Merge pull request #3964 from code-yeongyu/fix/electron-bun-runtime-shims
fix(electron): eliminate raw Bun.* runtime calls so plugin loads on OpenCode Desktop
This commit is contained in:
@@ -46,6 +46,9 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
BUN_INSTALL_ALLOW_SCRIPTS: "@ast-grep/napi"
|
BUN_INSTALL_ALLOW_SCRIPTS: "@ast-grep/napi"
|
||||||
|
|
||||||
|
- name: Build plugin
|
||||||
|
run: bun run build
|
||||||
|
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
run: bun run script/run-ci-tests.ts
|
run: bun run script/run-ci-tests.ts
|
||||||
|
|
||||||
|
|||||||
@@ -26,10 +26,6 @@ declare function clearTimeout(timeout: number): void
|
|||||||
|
|
||||||
type ProcessOutputStream = ReturnType<typeof spawnWithWindowsHide>["stdout"]
|
type ProcessOutputStream = ReturnType<typeof spawnWithWindowsHide>["stdout"]
|
||||||
|
|
||||||
declare const Bun: {
|
|
||||||
readableStreamToText(stream: NonNullable<ProcessOutputStream>): Promise<string>
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BunInstallResult {
|
export interface BunInstallResult {
|
||||||
success: boolean
|
success: boolean
|
||||||
timedOut?: boolean
|
timedOut?: boolean
|
||||||
@@ -50,7 +46,7 @@ function readProcessOutput(stream: ProcessOutputStream): Promise<string> {
|
|||||||
return Promise.resolve("")
|
return Promise.resolve("")
|
||||||
}
|
}
|
||||||
|
|
||||||
return Bun.readableStreamToText(stream)
|
return new Response(stream).text()
|
||||||
}
|
}
|
||||||
|
|
||||||
function logCapturedOutputOnFailure(outputMode: BunInstallOutputMode, output: BunInstallOutput): void {
|
function logCapturedOutputOnFailure(outputMode: BunInstallOutputMode, output: BunInstallOutput): void {
|
||||||
|
|||||||
@@ -5023,6 +5023,54 @@ describe("BackgroundManager.handleEvent - session.error", () => {
|
|||||||
manager.shutdown()
|
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 () => {
|
test("completes task on session.status idle after todo-continuation finishes", async () => {
|
||||||
//#given
|
//#given
|
||||||
const sessionID = "ses-status-idle-after-todo-continuation"
|
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)
|
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", () => {
|
test("should update lastUpdate on thinking-type message.part.updated event", () => {
|
||||||
//#given - a running task with stale lastUpdate
|
//#given - a running task with stale lastUpdate
|
||||||
const client = {
|
const client = {
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
resolveInheritedPromptTools,
|
resolveInheritedPromptTools,
|
||||||
createInternalAgentTextPart,
|
createInternalAgentTextPart,
|
||||||
} from "../../shared"
|
} from "../../shared"
|
||||||
|
import { resolveMessageEventSessionID, resolveSessionEventID } from "../../shared/event-session-id"
|
||||||
import { applySessionPromptParams } from "../../shared/session-prompt-params-helpers"
|
import { applySessionPromptParams } from "../../shared/session-prompt-params-helpers"
|
||||||
import { setSessionTools } from "../../shared/session-tools-store"
|
import { setSessionTools } from "../../shared/session-tools-store"
|
||||||
import { SessionCategoryRegistry } from "../../shared/session-category-registry"
|
import { SessionCategoryRegistry } from "../../shared/session-category-registry"
|
||||||
@@ -118,7 +119,7 @@ interface MessagePartInfo {
|
|||||||
|
|
||||||
interface EventProperties {
|
interface EventProperties {
|
||||||
sessionID?: string
|
sessionID?: string
|
||||||
info?: { id?: string }
|
info?: { id?: string; sessionID?: string }
|
||||||
[key: string]: unknown
|
[key: string]: unknown
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1260,8 +1261,9 @@ The fallback retry session is now created and can be inspected directly.
|
|||||||
this.observedIncompleteTodosBySession.delete(sessionID)
|
this.observedIncompleteTodosBySession.delete(sessionID)
|
||||||
}
|
}
|
||||||
|
|
||||||
private hasOutputSignalFromPart(partInfo: MessagePartInfo | undefined): boolean {
|
private hasOutputSignalFromPart(partInfo: MessagePartInfo | undefined, sessionID?: string): boolean {
|
||||||
if (!partInfo?.sessionID) return false
|
if (!partInfo) return false
|
||||||
|
if (!partInfo.sessionID && !sessionID) return false
|
||||||
if (partInfo.tool) return true
|
if (partInfo.tool) return true
|
||||||
if (partInfo.type === "tool" || partInfo.type === "tool_result") return true
|
if (partInfo.type === "tool" || partInfo.type === "tool_result") return true
|
||||||
if (partInfo.type === "text" || partInfo.type === "reasoning") 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
|
const info = props?.info
|
||||||
if (!info || typeof info !== "object") return
|
if (!info || typeof info !== "object") return
|
||||||
|
|
||||||
const sessionID = (info as Record<string, unknown>)["sessionID"]
|
const sessionID = resolveMessageEventSessionID(props)
|
||||||
const role = (info as Record<string, unknown>)["role"]
|
const role = (info as Record<string, unknown>)["role"]
|
||||||
if (typeof sessionID !== "string") return
|
if (!sessionID) return
|
||||||
|
|
||||||
if (role === "tool") {
|
if (role === "tool") {
|
||||||
this.markSessionOutputObserved(sessionID)
|
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") {
|
if (event.type === "message.part.updated" || event.type === "message.part.delta") {
|
||||||
const partInfo = resolveMessagePartInfo(props)
|
const partInfo = resolveMessagePartInfo(props)
|
||||||
const sessionID = partInfo?.sessionID
|
const sessionID = resolveMessageEventSessionID(props)
|
||||||
if (!sessionID) return
|
if (!sessionID) return
|
||||||
|
|
||||||
const resolved = this.resolveTaskAttemptBySession(sessionID)
|
const resolved = this.resolveTaskAttemptBySession(sessionID)
|
||||||
@@ -1320,7 +1322,7 @@ The fallback retry session is now created and can be inspected directly.
|
|||||||
|
|
||||||
const { task } = resolved
|
const { task } = resolved
|
||||||
|
|
||||||
if (this.hasOutputSignalFromPart(partInfo)) {
|
if (this.hasOutputSignalFromPart(partInfo, sessionID)) {
|
||||||
this.markSessionOutputObserved(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") {
|
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
|
const todos = Array.isArray(props?.todos) ? props.todos : undefined
|
||||||
if (!sessionID || !todos) return
|
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 (event.type === "session.idle") {
|
||||||
if (!props || typeof props !== "object") return
|
if (!props || typeof props !== "object") return
|
||||||
const sessionID = typeof props.sessionID === "string" ? props.sessionID : undefined
|
const sessionID = resolveSessionEventID(props)
|
||||||
if (sessionID) {
|
if (sessionID) {
|
||||||
void this.enqueueNotificationForParent(sessionID, () => this.flushPendingParentWake(sessionID)).catch((error) => {
|
void this.enqueueNotificationForParent(sessionID, () => this.flushPendingParentWake(sessionID)).catch((error) => {
|
||||||
log("[background-agent] Failed to flush pending parent wake:", { sessionID, 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") {
|
if (event.type === "session.error") {
|
||||||
const sessionID = typeof props?.sessionID === "string" ? props.sessionID : undefined
|
const sessionID = resolveSessionEventID(props)
|
||||||
if (!sessionID) return
|
if (!sessionID) return
|
||||||
|
|
||||||
const resolved = this.resolveTaskAttemptBySession(sessionID)
|
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") {
|
if (event.type === "session.deleted") {
|
||||||
const info = props?.info
|
const sessionID = resolveSessionEventID(props)
|
||||||
if (!info || typeof info.id !== "string") return
|
if (!sessionID) return
|
||||||
const sessionID = info.id
|
|
||||||
this.clearSessionOutputObserved(sessionID)
|
this.clearSessionOutputObserved(sessionID)
|
||||||
this.clearSessionTodoObservation(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") {
|
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
|
const status = props?.status as { type?: string; message?: string } | undefined
|
||||||
if (!sessionID || !status?.type) return
|
if (!sessionID || !status?.type) return
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,8 @@
|
|||||||
import { log } from "../../shared"
|
import { log } from "../../shared"
|
||||||
|
import { resolveSessionEventID } from "../../shared/event-session-id"
|
||||||
import { MIN_IDLE_TIME_MS } from "./constants"
|
import { MIN_IDLE_TIME_MS } from "./constants"
|
||||||
import type { BackgroundTask } from "./types"
|
import type { BackgroundTask } from "./types"
|
||||||
|
|
||||||
function getString(obj: Record<string, unknown>, key: string): string | undefined {
|
|
||||||
const value = obj[key]
|
|
||||||
return typeof value === "string" ? value : undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
export function handleSessionIdleBackgroundEvent(args: {
|
export function handleSessionIdleBackgroundEvent(args: {
|
||||||
properties: Record<string, unknown>
|
properties: Record<string, unknown>
|
||||||
findBySession: (sessionID: string) => BackgroundTask | undefined
|
findBySession: (sessionID: string) => BackgroundTask | undefined
|
||||||
@@ -26,7 +22,7 @@ export function handleSessionIdleBackgroundEvent(args: {
|
|||||||
emitIdleEvent,
|
emitIdleEvent,
|
||||||
} = args
|
} = args
|
||||||
|
|
||||||
const sessionID = getString(properties, "sessionID")
|
const sessionID = resolveSessionEventID(properties)
|
||||||
if (!sessionID) return
|
if (!sessionID) return
|
||||||
|
|
||||||
const task = findBySession(sessionID)
|
const task = findBySession(sessionID)
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import type {
|
|||||||
import { transformMcpServer } from "./transformer"
|
import { transformMcpServer } from "./transformer"
|
||||||
import { log } from "../../shared/logger"
|
import { log } from "../../shared/logger"
|
||||||
import { shouldLoadMcpServer } from "./scope-filter"
|
import { shouldLoadMcpServer } from "./scope-filter"
|
||||||
|
import { bunFile } from "../../shared/bun-file-shim"
|
||||||
|
|
||||||
interface McpConfigPath {
|
interface McpConfigPath {
|
||||||
path: string
|
path: string
|
||||||
@@ -37,7 +38,7 @@ async function loadMcpConfigFile(
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const content = await Bun.file(filePath).text()
|
const content = await bunFile(filePath).text()
|
||||||
return JSON.parse(content) as ClaudeCodeMcpConfig
|
return JSON.parse(content) as ClaudeCodeMcpConfig
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log(`Failed to load MCP config from ${filePath}`, error)
|
log(`Failed to load MCP config from ${filePath}`, error)
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import type { ClaudeCodeMcpConfig } from "../claude-code-mcp-loader/types"
|
|||||||
import { log } from "../../shared/logger"
|
import { log } from "../../shared/logger"
|
||||||
import type { LoadedPlugin } from "./types"
|
import type { LoadedPlugin } from "./types"
|
||||||
import { resolvePluginPaths } from "./plugin-path-resolver"
|
import { resolvePluginPaths } from "./plugin-path-resolver"
|
||||||
|
import { bunFile } from "../../shared/bun-file-shim"
|
||||||
|
|
||||||
export async function loadPluginMcpServers(
|
export async function loadPluginMcpServers(
|
||||||
plugins: LoadedPlugin[],
|
plugins: LoadedPlugin[],
|
||||||
@@ -18,7 +19,7 @@ export async function loadPluginMcpServers(
|
|||||||
if (!plugin.mcpPath || !existsSync(plugin.mcpPath)) continue
|
if (!plugin.mcpPath || !existsSync(plugin.mcpPath)) continue
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const content = await Bun.file(plugin.mcpPath).text()
|
const content = await bunFile(plugin.mcpPath).text()
|
||||||
let config = JSON.parse(content) as ClaudeCodeMcpConfig
|
let config = JSON.parse(content) as ClaudeCodeMcpConfig
|
||||||
|
|
||||||
config = resolvePluginPaths(config, plugin.installPath)
|
config = resolvePluginPaths(config, plugin.installPath)
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { createServer, type IncomingMessage, type ServerResponse } from "node:http"
|
||||||
|
|
||||||
import { findAvailablePort as findAvailablePortShared } from "../../shared/port-utils"
|
import { findAvailablePort as findAvailablePortShared } from "../../shared/port-utils"
|
||||||
|
|
||||||
const DEFAULT_PORT = 19877
|
const DEFAULT_PORT = 19877
|
||||||
@@ -51,56 +53,73 @@ export async function startCallbackServer(startPort: number = DEFAULT_PORT): Pro
|
|||||||
|
|
||||||
const timeoutId = setTimeout(() => {
|
const timeoutId = setTimeout(() => {
|
||||||
rejectCallback?.(new Error("OAuth callback timed out after 5 minutes"))
|
rejectCallback?.(new Error("OAuth callback timed out after 5 minutes"))
|
||||||
server.stop(true)
|
server.close()
|
||||||
}, TIMEOUT_MS)
|
}, TIMEOUT_MS)
|
||||||
|
|
||||||
const server = Bun.serve({
|
const server = createServer((request: IncomingMessage, response: ServerResponse) => {
|
||||||
port: requestedPort,
|
const url = new URL(request.url ?? "/", "http://127.0.0.1")
|
||||||
hostname: "127.0.0.1",
|
|
||||||
fetch(request: Request): Response {
|
|
||||||
const url = new URL(request.url)
|
|
||||||
|
|
||||||
if (url.pathname !== "/oauth/callback") {
|
if (url.pathname !== "/oauth/callback") {
|
||||||
return new Response("Not Found", { status: 404 })
|
response.statusCode = 404
|
||||||
}
|
response.end("Not Found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const oauthError = url.searchParams.get("error")
|
const oauthError = url.searchParams.get("error")
|
||||||
if (oauthError) {
|
if (oauthError) {
|
||||||
const description = url.searchParams.get("error_description") ?? 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 })
|
|
||||||
clearTimeout(timeoutId)
|
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, {
|
if (!code || !state) {
|
||||||
headers: { "content-type": "text/html; charset=utf-8" },
|
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<void>((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 {
|
return {
|
||||||
port: activePort,
|
port: activePort,
|
||||||
waitForCallback: () => callbackPromise,
|
waitForCallback: () => callbackPromise,
|
||||||
close: () => {
|
close: () => {
|
||||||
clearTimeout(timeoutId)
|
clearTimeout(timeoutId)
|
||||||
server.stop(true)
|
server.close()
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { TeamModeConfig } from "../../config/schema/team-mode"
|
import type { TeamModeConfig } from "../../config/schema/team-mode"
|
||||||
|
import { spawn } from "../../shared/bun-spawn-shim"
|
||||||
|
|
||||||
export interface TeamModeDependencyReport {
|
export interface TeamModeDependencyReport {
|
||||||
tmuxAvailable: boolean
|
tmuxAvailable: boolean
|
||||||
@@ -20,7 +21,7 @@ export async function checkTeamModeDependencies(
|
|||||||
|
|
||||||
async function probeBinary(cmd: string, args: string[]): Promise<boolean> {
|
async function probeBinary(cmd: string, args: string[]): Promise<boolean> {
|
||||||
try {
|
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
|
const code = await proc.exited
|
||||||
return code === 0
|
return code === 0
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { PluginInput } from "@opencode-ai/plugin"
|
|||||||
import type { TmuxConfig } from "../../config/schema"
|
import type { TmuxConfig } from "../../config/schema"
|
||||||
import type { TrackedSession, CapacityConfig, WindowState } from "./types"
|
import type { TrackedSession, CapacityConfig, WindowState } from "./types"
|
||||||
import * as sharedModule from "../../shared"
|
import * as sharedModule from "../../shared"
|
||||||
|
import { resolveSessionEventID } from "../../shared/event-session-id"
|
||||||
import {
|
import {
|
||||||
isInsideTmux as defaultIsInsideTmux,
|
isInsideTmux as defaultIsInsideTmux,
|
||||||
getCurrentPaneId as defaultGetCurrentPaneId,
|
getCurrentPaneId as defaultGetCurrentPaneId,
|
||||||
@@ -1098,9 +1099,9 @@ export class TmuxSessionManager {
|
|||||||
if (event.type !== "session.created") return
|
if (event.type !== "session.created") return
|
||||||
|
|
||||||
const info = event.properties?.info
|
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"
|
const title = info.title ?? "Subagent"
|
||||||
|
|
||||||
if (!this.sourcePaneId) {
|
if (!this.sourcePaneId) {
|
||||||
|
|||||||
@@ -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<string, TrackedSession>()
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
import type { TrackedSession } from "./types"
|
import type { TrackedSession } from "./types"
|
||||||
import { log } from "../../shared"
|
import { log } from "../../shared"
|
||||||
import { normalizeSDKResponse } from "../../shared"
|
import { normalizeSDKResponse } from "../../shared"
|
||||||
|
import { resolveMessageEventSessionID } from "../../shared/event-session-id"
|
||||||
|
|
||||||
const MIN_STABILITY_TIME_MS = 10 * 1000
|
const MIN_STABILITY_TIME_MS = 10 * 1000
|
||||||
const STABLE_POLLS_REQUIRED = 3
|
const STABLE_POLLS_REQUIRED = 3
|
||||||
@@ -170,10 +171,7 @@ export class TmuxPollingManager {
|
|||||||
if (!properties) return undefined
|
if (!properties) return undefined
|
||||||
|
|
||||||
if (event.type === "message.updated") {
|
if (event.type === "message.updated") {
|
||||||
const info = properties.info
|
return resolveMessageEventSessionID(properties)
|
||||||
if (!info || typeof info !== "object") return undefined
|
|
||||||
const sessionId = (info as { sessionID?: unknown }).sessionID
|
|
||||||
return typeof sessionId === "string" ? sessionId : undefined
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@@ -182,8 +180,7 @@ export class TmuxPollingManager {
|
|||||||
|| event.type === "message.part.removed"
|
|| event.type === "message.part.removed"
|
||||||
|| event.type === "message.removed"
|
|| event.type === "message.removed"
|
||||||
) {
|
) {
|
||||||
const sessionId = properties.sessionID
|
return resolveMessageEventSessionID(properties)
|
||||||
return typeof sessionId === "string" ? sessionId : undefined
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return undefined
|
return undefined
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { PluginInput } from "@opencode-ai/plugin"
|
|||||||
import type { TmuxConfig } from "../../config/schema"
|
import type { TmuxConfig } from "../../config/schema"
|
||||||
import type { CapacityConfig, TrackedSession } from "./types"
|
import type { CapacityConfig, TrackedSession } from "./types"
|
||||||
import { log } from "../../shared"
|
import { log } from "../../shared"
|
||||||
|
import { resolveSessionEventID } from "../../shared/event-session-id"
|
||||||
import { queryWindowState } from "./pane-state-querier"
|
import { queryWindowState } from "./pane-state-querier"
|
||||||
import { decideSpawnActions, type SessionMapping } from "./decision-engine"
|
import { decideSpawnActions, type SessionMapping } from "./decision-engine"
|
||||||
import { executeActions } from "./action-executor"
|
import { executeActions } from "./action-executor"
|
||||||
@@ -44,9 +45,9 @@ export async function handleSessionCreated(
|
|||||||
if (event.type !== "session.created") return
|
if (event.type !== "session.created") return
|
||||||
|
|
||||||
const info = event.properties?.info
|
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"
|
const title = info.title ?? "Subagent"
|
||||||
|
|
||||||
if (deps.sessions.has(sessionId) || deps.pendingSessions.has(sessionId)) {
|
if (deps.sessions.has(sessionId) || deps.pendingSessions.has(sessionId)) {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { TARGET_TOOLS, AGENT_TOOLS, REMINDER_MESSAGE } from "./constants";
|
|||||||
import type { AgentUsageState } from "./types";
|
import type { AgentUsageState } from "./types";
|
||||||
import { getSessionAgent } from "../../features/claude-code-session-state";
|
import { getSessionAgent } from "../../features/claude-code-session-state";
|
||||||
import { getAgentConfigKey } from "../../shared/agent-display-names";
|
import { getAgentConfigKey } from "../../shared/agent-display-names";
|
||||||
|
import { resolveSessionEventID } from "../../shared/event-session-id";
|
||||||
|
|
||||||
interface ToolExecuteInput {
|
interface ToolExecuteInput {
|
||||||
tool: string;
|
tool: string;
|
||||||
@@ -112,15 +113,14 @@ export function createAgentUsageReminderHook(_ctx: PluginInput) {
|
|||||||
const props = event.properties as Record<string, unknown> | undefined;
|
const props = event.properties as Record<string, unknown> | undefined;
|
||||||
|
|
||||||
if (event.type === "session.deleted") {
|
if (event.type === "session.deleted") {
|
||||||
const sessionInfo = props?.info as { id?: string } | undefined;
|
const sessionID = resolveSessionEventID(props);
|
||||||
if (sessionInfo?.id) {
|
if (sessionID) {
|
||||||
resetState(sessionInfo.id);
|
resetState(sessionID);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (event.type === "session.compacted") {
|
if (event.type === "session.compacted") {
|
||||||
const sessionID = (props?.sessionID ??
|
const sessionID = resolveSessionEventID(props);
|
||||||
(props?.info as { id?: string } | undefined)?.id) as string | undefined;
|
|
||||||
if (sessionID) {
|
if (sessionID) {
|
||||||
resetState(sessionID);
|
resetState(sessionID);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { executeCompact, getLastAssistant } from "./executor"
|
|||||||
import { attemptDeduplicationRecovery } from "./deduplication-recovery"
|
import { attemptDeduplicationRecovery } from "./deduplication-recovery"
|
||||||
import { clearSessionState } from "./state"
|
import { clearSessionState } from "./state"
|
||||||
import { clearAllSessionTimeouts, clearSessionTimeout } from "./session-timeout-map"
|
import { clearAllSessionTimeouts, clearSessionTimeout } from "./session-timeout-map"
|
||||||
|
import { resolveMessageEventSessionID, resolveSessionEventID } from "../../shared/event-session-id"
|
||||||
import { log } from "../../shared/logger"
|
import { log } from "../../shared/logger"
|
||||||
|
|
||||||
export interface AnthropicContextWindowLimitRecoveryOptions {
|
export interface AnthropicContextWindowLimitRecoveryOptions {
|
||||||
@@ -53,17 +54,17 @@ export function createAnthropicContextWindowLimitRecoveryHook(
|
|||||||
const props = event.properties as Record<string, unknown> | undefined
|
const props = event.properties as Record<string, unknown> | undefined
|
||||||
|
|
||||||
if (event.type === "session.deleted") {
|
if (event.type === "session.deleted") {
|
||||||
const sessionInfo = props?.info as { id?: string } | undefined
|
const sessionID = resolveSessionEventID(props)
|
||||||
if (sessionInfo?.id) {
|
if (sessionID) {
|
||||||
clearSessionTimeout(pendingCompactionTimeoutBySession, sessionInfo.id)
|
clearSessionTimeout(pendingCompactionTimeoutBySession, sessionID)
|
||||||
|
|
||||||
clearSessionState(autoCompactState, sessionInfo.id)
|
clearSessionState(autoCompactState, sessionID)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (event.type === "session.error") {
|
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 })
|
dependencies.log("[auto-compact] session.error received", { sessionID, error: props?.error })
|
||||||
if (!sessionID) return
|
if (!sessionID) return
|
||||||
|
|
||||||
@@ -120,7 +121,7 @@ export function createAnthropicContextWindowLimitRecoveryHook(
|
|||||||
|
|
||||||
if (event.type === "message.updated") {
|
if (event.type === "message.updated") {
|
||||||
const info = props?.info as Record<string, unknown> | undefined
|
const info = props?.info as Record<string, unknown> | undefined
|
||||||
const sessionID = info?.sessionID as string | undefined
|
const sessionID = resolveMessageEventSessionID(props)
|
||||||
|
|
||||||
if (sessionID && info?.role === "assistant" && info.error) {
|
if (sessionID && info?.role === "assistant" && info.error) {
|
||||||
dependencies.log("[auto-compact] message.updated with error", { sessionID, error: 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") {
|
if (event.type === "session.idle") {
|
||||||
const sessionID = props?.sessionID as string | undefined
|
const sessionID = resolveSessionEventID(props)
|
||||||
if (!sessionID) return
|
if (!sessionID) return
|
||||||
|
|
||||||
if (!autoCompactState.pendingCompact.has(sessionID)) return
|
if (!autoCompactState.pendingCompact.has(sessionID)) return
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { PluginInput } from "@opencode-ai/plugin"
|
import type { PluginInput } from "@opencode-ai/plugin"
|
||||||
import { log } from "../../shared/logger"
|
import { log } from "../../shared/logger"
|
||||||
|
import { resolveMessageEventSessionID, resolveSessionEventID } from "../../shared/event-session-id"
|
||||||
import { HOOK_NAME } from "./hook-name"
|
import { HOOK_NAME } from "./hook-name"
|
||||||
import { isAbortError } from "./is-abort-error"
|
import { isAbortError } from "./is-abort-error"
|
||||||
import { handleAtlasSessionIdle } from "./idle-event"
|
import { handleAtlasSessionIdle } from "./idle-event"
|
||||||
@@ -17,7 +18,7 @@ export function createAtlasEventHandler(input: {
|
|||||||
const props = event.properties as Record<string, unknown> | undefined
|
const props = event.properties as Record<string, unknown> | undefined
|
||||||
|
|
||||||
if (event.type === "session.error") {
|
if (event.type === "session.error") {
|
||||||
const sessionID = props?.sessionID as string | undefined
|
const sessionID = resolveSessionEventID(props)
|
||||||
if (!sessionID) return
|
if (!sessionID) return
|
||||||
|
|
||||||
const state = getState(sessionID)
|
const state = getState(sessionID)
|
||||||
@@ -39,7 +40,7 @@ export function createAtlasEventHandler(input: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (event.type === "session.idle") {
|
if (event.type === "session.idle") {
|
||||||
const sessionID = props?.sessionID as string | undefined
|
const sessionID = resolveSessionEventID(props)
|
||||||
if (!sessionID) return
|
if (!sessionID) return
|
||||||
await handleAtlasSessionIdle({ ctx, options, getState, sessionID })
|
await handleAtlasSessionIdle({ ctx, options, getState, sessionID })
|
||||||
return
|
return
|
||||||
@@ -47,7 +48,7 @@ export function createAtlasEventHandler(input: {
|
|||||||
|
|
||||||
if (event.type === "message.updated") {
|
if (event.type === "message.updated") {
|
||||||
const info = props?.info as Record<string, unknown> | undefined
|
const info = props?.info as Record<string, unknown> | undefined
|
||||||
const sessionID = info?.sessionID as string | undefined
|
const sessionID = resolveMessageEventSessionID(props)
|
||||||
const role = info?.role as string | undefined
|
const role = info?.role as string | undefined
|
||||||
if (!sessionID) return
|
if (!sessionID) return
|
||||||
|
|
||||||
@@ -64,7 +65,7 @@ export function createAtlasEventHandler(input: {
|
|||||||
|
|
||||||
if (event.type === "message.part.updated") {
|
if (event.type === "message.part.updated") {
|
||||||
const info = props?.info as Record<string, unknown> | undefined
|
const info = props?.info as Record<string, unknown> | undefined
|
||||||
const sessionID = info?.sessionID as string | undefined
|
const sessionID = resolveMessageEventSessionID(props)
|
||||||
const role = info?.role as string | undefined
|
const role = info?.role as string | undefined
|
||||||
|
|
||||||
if (sessionID && role === "assistant") {
|
if (sessionID && role === "assistant") {
|
||||||
@@ -78,7 +79,7 @@ export function createAtlasEventHandler(input: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (event.type === "tool.execute.before" || event.type === "tool.execute.after") {
|
if (event.type === "tool.execute.before" || event.type === "tool.execute.after") {
|
||||||
const sessionID = props?.sessionID as string | undefined
|
const sessionID = resolveMessageEventSessionID(props)
|
||||||
if (sessionID) {
|
if (sessionID) {
|
||||||
const state = sessions.get(sessionID)
|
const state = sessions.get(sessionID)
|
||||||
if (state) {
|
if (state) {
|
||||||
@@ -90,20 +91,20 @@ export function createAtlasEventHandler(input: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (event.type === "session.deleted") {
|
if (event.type === "session.deleted") {
|
||||||
const sessionInfo = props?.info as { id?: string } | undefined
|
const sessionID = resolveSessionEventID(props)
|
||||||
if (sessionInfo?.id) {
|
if (sessionID) {
|
||||||
const deletedState = sessions.get(sessionInfo.id)
|
const deletedState = sessions.get(sessionID)
|
||||||
if (deletedState?.pendingRetryTimer) {
|
if (deletedState?.pendingRetryTimer) {
|
||||||
clearTimeout(deletedState.pendingRetryTimer)
|
clearTimeout(deletedState.pendingRetryTimer)
|
||||||
}
|
}
|
||||||
sessions.delete(sessionInfo.id)
|
sessions.delete(sessionID)
|
||||||
log(`[${HOOK_NAME}] Session deleted: cleaned up`, { sessionID: sessionInfo.id })
|
log(`[${HOOK_NAME}] Session deleted: cleaned up`, { sessionID })
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (event.type === "session.compacted") {
|
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) {
|
if (sessionID) {
|
||||||
const compactedState = sessions.get(sessionID)
|
const compactedState = sessions.get(sessionID)
|
||||||
if (compactedState?.pendingRetryTimer) {
|
if (compactedState?.pendingRetryTimer) {
|
||||||
|
|||||||
@@ -1347,6 +1347,38 @@ session_id: ses_untrusted_999
|
|||||||
expect(callArgs.body.parts[0].text).toContain("2 remaining")
|
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 () => {
|
test("should settle idle before injecting boulder continuation", async () => {
|
||||||
// given
|
// given
|
||||||
const planPath = join(TEST_DIR, "test-plan.md")
|
const planPath = join(TEST_DIR, "test-plan.md")
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
} from "./detector"
|
} from "./detector"
|
||||||
import { executeSlashCommand, type ExecutorOptions } from "./executor"
|
import { executeSlashCommand, type ExecutorOptions } from "./executor"
|
||||||
import { log } from "../../shared"
|
import { log } from "../../shared"
|
||||||
|
import { resolveSessionEventID } from "../../shared/event-session-id"
|
||||||
import {
|
import {
|
||||||
AUTO_SLASH_COMMAND_TAG_CLOSE,
|
AUTO_SLASH_COMMAND_TAG_CLOSE,
|
||||||
AUTO_SLASH_COMMAND_TAG_OPEN,
|
AUTO_SLASH_COMMAND_TAG_OPEN,
|
||||||
@@ -25,16 +26,7 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getDeletedSessionID(properties: unknown): string | null {
|
function getDeletedSessionID(properties: unknown): string | null {
|
||||||
if (!isRecord(properties)) {
|
return resolveSessionEventID(properties) ?? null
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const info = properties.info
|
|
||||||
if (!isRecord(info)) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
return typeof info.id === "string" ? info.id : null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getCommandExecutionEventID(input: CommandExecuteBeforeInput): string | null {
|
function getCommandExecutionEventID(input: CommandExecuteBeforeInput): string | null {
|
||||||
@@ -49,7 +41,7 @@ function getCommandExecutionEventID(input: CommandExecuteBeforeInput): string |
|
|||||||
"commandId",
|
"commandId",
|
||||||
]
|
]
|
||||||
|
|
||||||
const recordInput = input as unknown
|
const recordInput: unknown = input
|
||||||
if (!isRecord(recordInput)) {
|
if (!isRecord(recordInput)) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type { AvailableSkill } from "../../agents/dynamic-agent-prompt-builder"
|
|||||||
import { getSessionAgent } from "../../features/claude-code-session-state"
|
import { getSessionAgent } from "../../features/claude-code-session-state"
|
||||||
import { log } from "../../shared"
|
import { log } from "../../shared"
|
||||||
import { getAgentConfigKey } from "../../shared/agent-display-names"
|
import { getAgentConfigKey } from "../../shared/agent-display-names"
|
||||||
|
import { resolveSessionEventID } from "../../shared/event-session-id"
|
||||||
import { buildReminderMessage } from "./formatter"
|
import { buildReminderMessage } from "./formatter"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -120,15 +121,14 @@ export function createCategorySkillReminderHook(
|
|||||||
const props = event.properties as Record<string, unknown> | undefined
|
const props = event.properties as Record<string, unknown> | undefined
|
||||||
|
|
||||||
if (event.type === "session.deleted") {
|
if (event.type === "session.deleted") {
|
||||||
const sessionInfo = props?.info as { id?: string } | undefined
|
const sessionID = resolveSessionEventID(props)
|
||||||
if (sessionInfo?.id) {
|
if (sessionID) {
|
||||||
sessionStates.delete(sessionInfo.id)
|
sessionStates.delete(sessionID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (event.type === "session.compacted") {
|
if (event.type === "session.compacted") {
|
||||||
const sessionID = (props?.sessionID ??
|
const sessionID = resolveSessionEventID(props)
|
||||||
(props?.info as { id?: string } | undefined)?.id) as string | undefined
|
|
||||||
if (sessionID) {
|
if (sessionID) {
|
||||||
sessionStates.delete(sessionID)
|
sessionStates.delete(sessionID)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { join } from "path"
|
|||||||
import type { ClaudeHookEvent } from "./types"
|
import type { ClaudeHookEvent } from "./types"
|
||||||
import { log } from "../../shared/logger"
|
import { log } from "../../shared/logger"
|
||||||
import { getOpenCodeConfigDir } from "../../shared"
|
import { getOpenCodeConfigDir } from "../../shared"
|
||||||
|
import { bunFile } from "../../shared/bun-file-shim"
|
||||||
|
|
||||||
const CONFIG_CACHE_TTL_MS = 30_000
|
const CONFIG_CACHE_TTL_MS = 30_000
|
||||||
|
|
||||||
@@ -61,7 +62,7 @@ async function loadConfigFromPath(path: string): Promise<PluginExtendedConfig |
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const content = await Bun.file(path).text()
|
const content = await bunFile(path).text()
|
||||||
return JSON.parse(content) as PluginExtendedConfig
|
return JSON.parse(content) as PluginExtendedConfig
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log("Failed to load config", { path, error })
|
log("Failed to load config", { path, error })
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { join } from "path"
|
import { join } from "path"
|
||||||
import { existsSync } from "fs"
|
import { existsSync } from "fs"
|
||||||
import { getClaudeConfigDir } from "../../shared"
|
import { getClaudeConfigDir } from "../../shared"
|
||||||
|
import { bunFile } from "../../shared/bun-file-shim"
|
||||||
import type { ClaudeHooksConfig, HookMatcher, HookAction } from "./types"
|
import type { ClaudeHooksConfig, HookMatcher, HookAction } from "./types"
|
||||||
|
|
||||||
const CONFIG_CACHE_TTL_MS = 30_000
|
const CONFIG_CACHE_TTL_MS = 30_000
|
||||||
@@ -126,7 +127,7 @@ export async function loadClaudeHooksConfig(
|
|||||||
for (const settingsPath of paths) {
|
for (const settingsPath of paths) {
|
||||||
if (existsSync(settingsPath)) {
|
if (existsSync(settingsPath)) {
|
||||||
try {
|
try {
|
||||||
const content = await Bun.file(settingsPath).text()
|
const content = await bunFile(settingsPath).text()
|
||||||
const settings = JSON.parse(content) as { hooks?: RawClaudeHooksConfig }
|
const settings = JSON.parse(content) as { hooks?: RawClaudeHooksConfig }
|
||||||
if (settings.hooks) {
|
if (settings.hooks) {
|
||||||
const normalizedHooks = normalizeHooksConfig(settings.hooks)
|
const normalizedHooks = normalizeHooksConfig(settings.hooks)
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { clearTranscriptCache } from "../transcript"
|
|||||||
import { clearToolInputCache, stopToolInputCacheCleanup } from "../tool-input-cache"
|
import { clearToolInputCache, stopToolInputCacheCleanup } from "../tool-input-cache"
|
||||||
import type { PluginConfig } from "../types"
|
import type { PluginConfig } from "../types"
|
||||||
import { createInternalAgentTextPart, isHookDisabled, log } from "../../../shared"
|
import { createInternalAgentTextPart, isHookDisabled, log } from "../../../shared"
|
||||||
|
import { resolveSessionEventID } from "../../../shared/event-session-id"
|
||||||
import {
|
import {
|
||||||
clearAllSessionHookState,
|
clearAllSessionHookState,
|
||||||
clearSessionHookState,
|
clearSessionHookState,
|
||||||
@@ -26,7 +27,7 @@ export function createSessionEventHandler(
|
|||||||
|
|
||||||
if (event.type === "session.error") {
|
if (event.type === "session.error") {
|
||||||
const props = event.properties as Record<string, unknown> | undefined
|
const props = event.properties as Record<string, unknown> | undefined
|
||||||
const sessionID = props?.sessionID as string | undefined
|
const sessionID = resolveSessionEventID(props)
|
||||||
if (sessionID) {
|
if (sessionID) {
|
||||||
sessionErrorState.set(sessionID, {
|
sessionErrorState.set(sessionID, {
|
||||||
hasError: true,
|
hasError: true,
|
||||||
@@ -38,13 +39,13 @@ export function createSessionEventHandler(
|
|||||||
|
|
||||||
if (event.type === "session.deleted") {
|
if (event.type === "session.deleted") {
|
||||||
const props = event.properties as Record<string, unknown> | undefined
|
const props = event.properties as Record<string, unknown> | undefined
|
||||||
const sessionInfo = props?.info as { id?: string } | undefined
|
const sessionID = resolveSessionEventID(props)
|
||||||
if (sessionInfo?.id) {
|
if (sessionID) {
|
||||||
parentSessionIdCache.delete(sessionInfo.id)
|
parentSessionIdCache.delete(sessionID)
|
||||||
clearTranscriptCache(sessionInfo.id)
|
clearTranscriptCache(sessionID)
|
||||||
clearToolInputCache(sessionInfo.id)
|
clearToolInputCache(sessionID)
|
||||||
contextCollector?.clear(sessionInfo.id)
|
contextCollector?.clear(sessionID)
|
||||||
clearSessionHookState(sessionInfo.id)
|
clearSessionHookState(sessionID)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -54,7 +55,7 @@ export function createSessionEventHandler(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const props = event.properties as Record<string, unknown> | undefined
|
const props = event.properties as Record<string, unknown> | undefined
|
||||||
const sessionID = props?.sessionID as string | undefined
|
const sessionID = resolveSessionEventID(props)
|
||||||
if (!sessionID) return
|
if (!sessionID) return
|
||||||
|
|
||||||
const claudeConfig = await loadClaudeHooksConfig()
|
const claudeConfig = await loadClaudeHooksConfig()
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
clearCompactionAgentConfigCheckpoint,
|
clearCompactionAgentConfigCheckpoint,
|
||||||
setCompactionAgentConfigCheckpoint,
|
setCompactionAgentConfigCheckpoint,
|
||||||
} from "../../shared/compaction-agent-config-checkpoint"
|
} from "../../shared/compaction-agent-config-checkpoint"
|
||||||
|
import { resolveMessageEventSessionID } from "../../shared/event-session-id"
|
||||||
import { log } from "../../shared/logger"
|
import { log } from "../../shared/logger"
|
||||||
import { COMPACTION_CONTEXT_PROMPT } from "./compaction-context-prompt"
|
import { COMPACTION_CONTEXT_PROMPT } from "./compaction-context-prompt"
|
||||||
import { resolveSessionPromptConfig } from "./session-prompt-config-resolver"
|
import { resolveSessionPromptConfig } from "./session-prompt-config-resolver"
|
||||||
@@ -121,14 +122,15 @@ export function createCompactionContextInjector(options?: {
|
|||||||
sessionID?: string
|
sessionID?: string
|
||||||
} | undefined
|
} | undefined
|
||||||
|
|
||||||
if (!info?.sessionID || info.role !== "assistant" || !info.id) {
|
const sessionID = resolveMessageEventSessionID(props)
|
||||||
|
if (!sessionID || info?.role !== "assistant" || !info.id) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const tailState = getTailState(info.sessionID)
|
const tailState = getTailState(sessionID)
|
||||||
if (tailState.currentMessageID && tailState.currentMessageID !== info.id) {
|
if (tailState.currentMessageID && tailState.currentMessageID !== info.id) {
|
||||||
finalizeTrackedAssistantMessage(tailState)
|
finalizeTrackedAssistantMessage(tailState)
|
||||||
await maybeWarnAboutNoTextTail(info.sessionID)
|
await maybeWarnAboutNoTextTail(sessionID)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (tailState.currentMessageID !== info.id) {
|
if (tailState.currentMessageID !== info.id) {
|
||||||
@@ -139,7 +141,7 @@ export function createCompactionContextInjector(options?: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (event.type === "message.part.delta") {
|
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 messageID = props?.messageID as string | undefined
|
||||||
const field = props?.field as string | undefined
|
const field = props?.field as string | undefined
|
||||||
const delta = props?.delta as string | undefined
|
const delta = props?.delta as string | undefined
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
|
import { resolveSessionEventID } from "../../shared/event-session-id"
|
||||||
|
|
||||||
export function isCompactionAgent(agent: string | undefined): boolean {
|
export function isCompactionAgent(agent: string | undefined): boolean {
|
||||||
return agent?.trim().toLowerCase() === "compaction"
|
return agent?.trim().toLowerCase() === "compaction"
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveSessionID(props?: Record<string, unknown>): string | undefined {
|
export function resolveSessionID(props?: Record<string, unknown>): string | undefined {
|
||||||
return (props?.sessionID ??
|
return resolveSessionEventID(props)
|
||||||
(props?.info as { id?: string } | undefined)?.id) as string | undefined
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { PluginInput } from "@opencode-ai/plugin"
|
import type { PluginInput } from "@opencode-ai/plugin"
|
||||||
|
import { resolveSessionEventID } from "../../shared/event-session-id"
|
||||||
import { log } from "../../shared/logger"
|
import { log } from "../../shared/logger"
|
||||||
|
|
||||||
interface TodoSnapshot {
|
interface TodoSnapshot {
|
||||||
@@ -97,8 +98,7 @@ async function resolveTodoWriter(): Promise<TodoWriter | null> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function resolveSessionID(props?: Record<string, unknown>): string | undefined {
|
function resolveSessionID(props?: Record<string, unknown>): string | undefined {
|
||||||
return (props?.sessionID ??
|
return resolveSessionEventID(props)
|
||||||
(props?.info as { id?: string } | undefined)?.id) as string | undefined
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CompactionTodoPreserver {
|
export interface CompactionTodoPreserver {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
type ContextLimitModelCacheState,
|
type ContextLimitModelCacheState,
|
||||||
} from "../shared/context-limit-resolver"
|
} from "../shared/context-limit-resolver"
|
||||||
import { isCompactionAgent } from "../shared/compaction-marker"
|
import { isCompactionAgent } from "../shared/compaction-marker"
|
||||||
|
import { resolveMessageEventSessionID, resolveSessionEventID } from "../shared/event-session-id"
|
||||||
import { createSystemDirective, SystemDirectiveTypes } from "../shared/system-directive"
|
import { createSystemDirective, SystemDirectiveTypes } from "../shared/system-directive"
|
||||||
|
|
||||||
const CONTEXT_WARNING_THRESHOLD = 0.70
|
const CONTEXT_WARNING_THRESHOLD = 0.70
|
||||||
@@ -86,10 +87,10 @@ export function createContextWindowMonitorHook(
|
|||||||
const props = event.properties as Record<string, unknown> | undefined
|
const props = event.properties as Record<string, unknown> | undefined
|
||||||
|
|
||||||
if (event.type === "session.deleted") {
|
if (event.type === "session.deleted") {
|
||||||
const sessionInfo = props?.info as { id?: string } | undefined
|
const sessionID = resolveSessionEventID(props)
|
||||||
if (sessionInfo?.id) {
|
if (sessionID) {
|
||||||
remindedSessions.delete(sessionInfo.id)
|
remindedSessions.delete(sessionID)
|
||||||
tokenCache.delete(sessionInfo.id)
|
tokenCache.delete(sessionID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,9 +107,10 @@ export function createContextWindowMonitorHook(
|
|||||||
|
|
||||||
if (!info || info.role !== "assistant" || !info.finish) return
|
if (!info || info.role !== "assistant" || !info.finish) return
|
||||||
if (isCompactionAgent(info.agent)) 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,
|
providerID: info.providerID,
|
||||||
modelID: info.modelID ?? "",
|
modelID: info.modelID ?? "",
|
||||||
tokens: info.tokens,
|
tokens: info.tokens,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { PluginInput } from "@opencode-ai/plugin";
|
import type { PluginInput } from "@opencode-ai/plugin";
|
||||||
|
|
||||||
import { createDynamicTruncator } from "../../shared/dynamic-truncator";
|
import { createDynamicTruncator } from "../../shared/dynamic-truncator";
|
||||||
|
import { resolveSessionEventID } from "../../shared/event-session-id";
|
||||||
import { processFilePathForAgentsInjection } from "./injector";
|
import { processFilePathForAgentsInjection } from "./injector";
|
||||||
import { clearInjectedPaths } from "./storage";
|
import { clearInjectedPaths } from "./storage";
|
||||||
|
|
||||||
@@ -56,16 +57,15 @@ export function createDirectoryAgentsInjectorHook(
|
|||||||
const props = event.properties as Record<string, unknown> | undefined;
|
const props = event.properties as Record<string, unknown> | undefined;
|
||||||
|
|
||||||
if (event.type === "session.deleted") {
|
if (event.type === "session.deleted") {
|
||||||
const sessionInfo = props?.info as { id?: string } | undefined;
|
const sessionID = resolveSessionEventID(props);
|
||||||
if (sessionInfo?.id) {
|
if (sessionID) {
|
||||||
sessionCaches.delete(sessionInfo.id);
|
sessionCaches.delete(sessionID);
|
||||||
clearInjectedPaths(sessionInfo.id);
|
clearInjectedPaths(sessionID);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (event.type === "session.compacted") {
|
if (event.type === "session.compacted") {
|
||||||
const sessionID = (props?.sessionID ??
|
const sessionID = resolveSessionEventID(props);
|
||||||
(props?.info as { id?: string } | undefined)?.id) as string | undefined;
|
|
||||||
if (sessionID) {
|
if (sessionID) {
|
||||||
sessionCaches.delete(sessionID);
|
sessionCaches.delete(sessionID);
|
||||||
clearInjectedPaths(sessionID);
|
clearInjectedPaths(sessionID);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { PluginInput } from "@opencode-ai/plugin";
|
import type { PluginInput } from "@opencode-ai/plugin";
|
||||||
|
|
||||||
import { createDynamicTruncator } from "../../shared/dynamic-truncator";
|
import { createDynamicTruncator } from "../../shared/dynamic-truncator";
|
||||||
|
import { resolveSessionEventID } from "../../shared/event-session-id";
|
||||||
import { processFilePathForReadmeInjection } from "./injector";
|
import { processFilePathForReadmeInjection } from "./injector";
|
||||||
import { clearInjectedPaths } from "./storage";
|
import { clearInjectedPaths } from "./storage";
|
||||||
|
|
||||||
@@ -56,16 +57,15 @@ export function createDirectoryReadmeInjectorHook(
|
|||||||
const props = event.properties as Record<string, unknown> | undefined;
|
const props = event.properties as Record<string, unknown> | undefined;
|
||||||
|
|
||||||
if (event.type === "session.deleted") {
|
if (event.type === "session.deleted") {
|
||||||
const sessionInfo = props?.info as { id?: string } | undefined;
|
const sessionID = resolveSessionEventID(props);
|
||||||
if (sessionInfo?.id) {
|
if (sessionID) {
|
||||||
sessionCaches.delete(sessionInfo.id);
|
sessionCaches.delete(sessionID);
|
||||||
clearInjectedPaths(sessionInfo.id);
|
clearInjectedPaths(sessionID);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (event.type === "session.compacted") {
|
if (event.type === "session.compacted") {
|
||||||
const sessionID = (props?.sessionID ??
|
const sessionID = resolveSessionEventID(props);
|
||||||
(props?.info as { id?: string } | undefined)?.id) as string | undefined;
|
|
||||||
if (sessionID) {
|
if (sessionID) {
|
||||||
sessionCaches.delete(sessionID);
|
sessionCaches.delete(sessionID);
|
||||||
clearInjectedPaths(sessionID);
|
clearInjectedPaths(sessionID);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { log } from "../../shared"
|
import { log } from "../../shared"
|
||||||
|
import { bunFile } from "../../shared/bun-file-shim"
|
||||||
import { generateUnifiedDiff, countLineDiffs } from "../../tools/hashline-edit/diff-utils"
|
import { generateUnifiedDiff, countLineDiffs } from "../../tools/hashline-edit/diff-utils"
|
||||||
|
|
||||||
interface HashlineEditDiffEnhancerConfig {
|
interface HashlineEditDiffEnhancerConfig {
|
||||||
@@ -38,7 +39,7 @@ function extractFilePath(args: Record<string, unknown>): string | undefined {
|
|||||||
|
|
||||||
async function captureOldContent(filePath: string): Promise<string> {
|
async function captureOldContent(filePath: string): Promise<string> {
|
||||||
try {
|
try {
|
||||||
const file = Bun.file(filePath)
|
const file = bunFile(filePath)
|
||||||
if (await file.exists()) {
|
if (await file.exists()) {
|
||||||
return await file.text()
|
return await file.text()
|
||||||
}
|
}
|
||||||
@@ -79,7 +80,7 @@ export function createHashlineEditDiffEnhancerHook(config: HashlineEditDiffEnhan
|
|||||||
|
|
||||||
let newContent: string
|
let newContent: string
|
||||||
try {
|
try {
|
||||||
newContent = await Bun.file(filePath).text()
|
newContent = await bunFile(filePath).text()
|
||||||
} catch {
|
} catch {
|
||||||
log("[hashline-edit-diff-enhancer] failed to read new content", { filePath })
|
log("[hashline-edit-diff-enhancer] failed to read new content", { filePath })
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { PluginInput } from "@opencode-ai/plugin"
|
import type { PluginInput } from "@opencode-ai/plugin"
|
||||||
|
import { bunFile } from "../../shared/bun-file-shim"
|
||||||
import { computeLineHash } from "../../tools/hashline-edit/hash-computation"
|
import { computeLineHash } from "../../tools/hashline-edit/hash-computation"
|
||||||
|
|
||||||
const WRITE_SUCCESS_MARKER = "File written successfully."
|
const WRITE_SUCCESS_MARKER = "File written successfully."
|
||||||
@@ -178,7 +179,7 @@ async function appendWriteHashlineOutput(output: { output: string; metadata: unk
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const file = Bun.file(filePath)
|
const file = bunFile(filePath)
|
||||||
if (!(await file.exists())) {
|
if (!(await file.exists())) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type { InteractiveBashSessionState } from "./types";
|
|||||||
import { tokenizeCommand, findSubcommand, extractSessionNameFromTokens } from "./parser";
|
import { tokenizeCommand, findSubcommand, extractSessionNameFromTokens } from "./parser";
|
||||||
import { getOrCreateState, isOmoSession, killAllTrackedSessions } from "./state-manager";
|
import { getOrCreateState, isOmoSession, killAllTrackedSessions } from "./state-manager";
|
||||||
import { subagentSessions } from "../../features/claude-code-session-state";
|
import { subagentSessions } from "../../features/claude-code-session-state";
|
||||||
|
import { resolveSessionEventID } from "../../shared/event-session-id";
|
||||||
|
|
||||||
interface ToolExecuteInput {
|
interface ToolExecuteInput {
|
||||||
tool: string;
|
tool: string;
|
||||||
@@ -106,8 +107,7 @@ export function createInteractiveBashSessionHook(ctx: PluginInput) {
|
|||||||
const props = event.properties as Record<string, unknown> | undefined;
|
const props = event.properties as Record<string, unknown> | undefined;
|
||||||
|
|
||||||
if (event.type === "session.deleted") {
|
if (event.type === "session.deleted") {
|
||||||
const sessionInfo = props?.info as { id?: string } | undefined;
|
const sessionID = resolveSessionEventID(props);
|
||||||
const sessionID = sessionInfo?.id;
|
|
||||||
|
|
||||||
if (sessionID) {
|
if (sessionID) {
|
||||||
const state = getOrCreateStateLocal(sessionID);
|
const state = getOrCreateStateLocal(sessionID);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { OhMyOpenCodeConfig } from "../config"
|
import type { OhMyOpenCodeConfig } from "../config"
|
||||||
import { isCompactionAgent } from "../shared/compaction-marker"
|
import { isCompactionAgent } from "../shared/compaction-marker"
|
||||||
|
import { resolveMessageEventSessionID, resolveSessionEventID } from "../shared/event-session-id"
|
||||||
import type { ContextLimitModelCacheState } from "../shared/context-limit-resolver"
|
import type { ContextLimitModelCacheState } from "../shared/context-limit-resolver"
|
||||||
|
|
||||||
import { createPostCompactionDegradationMonitor } from "./preemptive-compaction-degradation-monitor"
|
import { createPostCompactionDegradationMonitor } from "./preemptive-compaction-degradation-monitor"
|
||||||
@@ -48,7 +49,7 @@ export function createPreemptiveCompactionHook(
|
|||||||
const props = event.properties as Record<string, unknown> | undefined
|
const props = event.properties as Record<string, unknown> | undefined
|
||||||
|
|
||||||
if (event.type === "session.deleted") {
|
if (event.type === "session.deleted") {
|
||||||
const sessionID = (props?.info as { id?: string } | undefined)?.id
|
const sessionID = resolveSessionEventID(props)
|
||||||
if (sessionID) {
|
if (sessionID) {
|
||||||
compactionInProgress.delete(sessionID)
|
compactionInProgress.delete(sessionID)
|
||||||
compactedSessions.delete(sessionID)
|
compactedSessions.delete(sessionID)
|
||||||
@@ -60,8 +61,7 @@ export function createPreemptiveCompactionHook(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (event.type === "session.compacted") {
|
if (event.type === "session.compacted") {
|
||||||
const sessionID = (props?.sessionID as string | undefined)
|
const sessionID = resolveSessionEventID(props)
|
||||||
?? (props?.info as { id?: string } | undefined)?.id
|
|
||||||
if (sessionID) {
|
if (sessionID) {
|
||||||
postCompactionMonitor.onSessionCompacted(sessionID)
|
postCompactionMonitor.onSessionCompacted(sessionID)
|
||||||
}
|
}
|
||||||
@@ -81,20 +81,21 @@ export function createPreemptiveCompactionHook(
|
|||||||
parts?: unknown
|
parts?: unknown
|
||||||
} | undefined
|
} | 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 (isCompactionAgent(info.agent)) return
|
||||||
|
|
||||||
if (info.providerID && info.tokens) {
|
if (info.providerID && info.tokens) {
|
||||||
tokenCache.set(info.sessionID, {
|
tokenCache.set(sessionID, {
|
||||||
providerID: info.providerID,
|
providerID: info.providerID,
|
||||||
modelID: info.modelID ?? "",
|
modelID: info.modelID ?? "",
|
||||||
tokens: info.tokens,
|
tokens: info.tokens,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
compactedSessions.delete(info.sessionID)
|
compactedSessions.delete(sessionID)
|
||||||
|
|
||||||
await postCompactionMonitor.onAssistantMessageUpdated({
|
await postCompactionMonitor.onAssistantMessageUpdated({
|
||||||
sessionID: info.sessionID,
|
sessionID,
|
||||||
id: info.id,
|
id: info.id,
|
||||||
parts: info.parts,
|
parts: info.parts,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -304,6 +304,25 @@ describe("ralph-loop", () => {
|
|||||||
expect(state?.iteration).toBe(2)
|
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 () => {
|
test("should settle idle before injecting continuation", async () => {
|
||||||
// given - active loop state with a configured idle settle delay
|
// given - active loop state with a configured idle settle delay
|
||||||
const hook = createRalphLoopHook(createMockPluginInput(), { idleSettleMs: 25 })
|
const hook = createRalphLoopHook(createMockPluginInput(), { idleSettleMs: 25 })
|
||||||
|
|||||||
@@ -213,6 +213,77 @@ describe("ralph-loop non-abort error continuation", () => {
|
|||||||
expect(hook.getState()?.iteration).toBe(3)
|
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 () => {
|
test("skips immediate runtime retry while background tasks are running", async () => {
|
||||||
// given - an active loop owns running background work
|
// given - an active loop owns running background work
|
||||||
const hook = createRalphLoopHook({
|
const hook = createRalphLoopHook({
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { PluginInput } from "@opencode-ai/plugin"
|
import type { PluginInput } from "@opencode-ai/plugin"
|
||||||
import { log } from "../../shared/logger"
|
import { log } from "../../shared/logger"
|
||||||
|
import { resolveMessageEventSessionID, resolveSessionEventID } from "../../shared/event-session-id"
|
||||||
import type { RalphLoopOptions, RalphLoopState } from "./types"
|
import type { RalphLoopOptions, RalphLoopState } from "./types"
|
||||||
import { HOOK_NAME } from "./constants"
|
import { HOOK_NAME } from "./constants"
|
||||||
import { handleDetectedCompletion } from "./completion-handler"
|
import { handleDetectedCompletion } from "./completion-handler"
|
||||||
@@ -36,12 +37,6 @@ function hasRunningBackgroundTasks(
|
|||||||
: false
|
: false
|
||||||
}
|
}
|
||||||
|
|
||||||
function getInfoSessionID(props: Record<string, unknown> | undefined): string | undefined {
|
|
||||||
const info = props?.info as Record<string, unknown> | undefined
|
|
||||||
const sessionID = info?.sessionID
|
|
||||||
return typeof sessionID === "string" ? sessionID : undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
function getRuntimeRetryActivitySessionID(
|
function getRuntimeRetryActivitySessionID(
|
||||||
eventType: string,
|
eventType: string,
|
||||||
props: Record<string, unknown> | undefined,
|
props: Record<string, unknown> | undefined,
|
||||||
@@ -49,20 +44,19 @@ function getRuntimeRetryActivitySessionID(
|
|||||||
if (eventType === "message.updated") {
|
if (eventType === "message.updated") {
|
||||||
const info = props?.info as Record<string, unknown> | undefined
|
const info = props?.info as Record<string, unknown> | undefined
|
||||||
const role = info?.role
|
const role = info?.role
|
||||||
return role === "assistant" ? getInfoSessionID(props) : undefined
|
return role === "assistant" ? resolveMessageEventSessionID(props) : undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
if (eventType === "message.part.updated") {
|
if (eventType === "message.part.updated") {
|
||||||
if (typeof props?.sessionID === "string") return props.sessionID
|
return resolveMessageEventSessionID(props)
|
||||||
return getInfoSessionID(props)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (eventType === "message.part.delta") {
|
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") {
|
if (eventType === "tool.execute.before" || eventType === "tool.execute.after") {
|
||||||
return typeof props?.sessionID === "string" ? props.sessionID : undefined
|
return resolveMessageEventSessionID(props)
|
||||||
}
|
}
|
||||||
|
|
||||||
return undefined
|
return undefined
|
||||||
@@ -198,7 +192,7 @@ export function createRalphLoopEventHandler(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (event.type === "session.idle") {
|
if (event.type === "session.idle") {
|
||||||
const sessionID = props?.sessionID as string | undefined
|
const sessionID = resolveSessionEventID(props)
|
||||||
if (!sessionID) return
|
if (!sessionID) return
|
||||||
|
|
||||||
if (inFlightSessions.has(sessionID)) {
|
if (inFlightSessions.has(sessionID)) {
|
||||||
@@ -389,7 +383,7 @@ export function createRalphLoopEventHandler(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (event.type === "session.error") {
|
if (event.type === "session.error") {
|
||||||
const sessionID = props?.sessionID as string | undefined
|
const sessionID = resolveSessionEventID(props)
|
||||||
const error = props?.error
|
const error = props?.error
|
||||||
if (!sessionID || isAbortError(error)) {
|
if (!sessionID || isAbortError(error)) {
|
||||||
handleErroredLoopSession(props, options.loopState)
|
handleErroredLoopSession(props, options.loopState)
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { log } from "../../shared/logger"
|
import { log } from "../../shared/logger"
|
||||||
|
import { resolveSessionEventID } from "../../shared/event-session-id"
|
||||||
import { HOOK_NAME } from "./constants"
|
import { HOOK_NAME } from "./constants"
|
||||||
import type { RalphLoopState } from "./types"
|
import type { RalphLoopState } from "./types"
|
||||||
|
|
||||||
@@ -11,13 +12,13 @@ export function handleDeletedLoopSession(
|
|||||||
props: Record<string, unknown> | undefined,
|
props: Record<string, unknown> | undefined,
|
||||||
loopState: LoopStateController,
|
loopState: LoopStateController,
|
||||||
): boolean {
|
): boolean {
|
||||||
const sessionInfo = props?.info as { id?: string } | undefined
|
const sessionID = resolveSessionEventID(props)
|
||||||
if (!sessionInfo?.id) return false
|
if (!sessionID) return false
|
||||||
|
|
||||||
const state = loopState.getState()
|
const state = loopState.getState()
|
||||||
if (state?.session_id === sessionInfo.id) {
|
if (state?.session_id === sessionID) {
|
||||||
loopState.clear()
|
loopState.clear()
|
||||||
log(`[${HOOK_NAME}] Session deleted, loop cleared`, { sessionID: sessionInfo.id })
|
log(`[${HOOK_NAME}] Session deleted, loop cleared`, { sessionID })
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -26,7 +27,7 @@ export function handleErroredLoopSession(
|
|||||||
props: Record<string, unknown> | undefined,
|
props: Record<string, unknown> | undefined,
|
||||||
loopState: LoopStateController,
|
loopState: LoopStateController,
|
||||||
): boolean {
|
): boolean {
|
||||||
const sessionID = props?.sessionID as string | undefined
|
const sessionID = resolveSessionEventID(props)
|
||||||
const error = props?.error as { name?: string } | undefined
|
const error = props?.error as { name?: string } | undefined
|
||||||
|
|
||||||
if (error?.name === "MessageAbortedError") {
|
if (error?.name === "MessageAbortedError") {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { PluginInput } from "@opencode-ai/plugin";
|
import type { PluginInput } from "@opencode-ai/plugin";
|
||||||
import { createDynamicTruncator } from "../../shared/dynamic-truncator";
|
import { createDynamicTruncator } from "../../shared/dynamic-truncator";
|
||||||
|
import { resolveSessionEventID } from "../../shared/event-session-id";
|
||||||
import { getRuleInjectionFilePath } from "./output-path";
|
import { getRuleInjectionFilePath } from "./output-path";
|
||||||
import { createSessionCacheStore, createSessionRuleScanCacheStore } from "./cache";
|
import { createSessionCacheStore, createSessionRuleScanCacheStore } from "./cache";
|
||||||
import { createRuleInjectionProcessor } from "./injector";
|
import { createRuleInjectionProcessor } from "./injector";
|
||||||
@@ -80,16 +81,15 @@ export function createRulesInjectorHook(
|
|||||||
const props = event.properties as Record<string, unknown> | undefined;
|
const props = event.properties as Record<string, unknown> | undefined;
|
||||||
|
|
||||||
if (event.type === "session.deleted") {
|
if (event.type === "session.deleted") {
|
||||||
const sessionInfo = props?.info as { id?: string } | undefined;
|
const sessionID = resolveSessionEventID(props);
|
||||||
if (sessionInfo?.id) {
|
if (sessionID) {
|
||||||
clearSessionState(sessionInfo.id);
|
clearSessionState(sessionID);
|
||||||
}
|
}
|
||||||
clearProjectRootCache();
|
clearProjectRootCache();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (event.type === "session.compacted") {
|
if (event.type === "session.compacted") {
|
||||||
const sessionID = (props?.sessionID ??
|
const sessionID = resolveSessionEventID(props);
|
||||||
(props?.info as { id?: string } | undefined)?.id) as string | undefined;
|
|
||||||
if (sessionID) {
|
if (sessionID) {
|
||||||
clearSessionState(sessionID);
|
clearSessionState(sessionID);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { isAbortError } from "../../shared/is-abort-error"
|
|||||||
import { resolveFallbackBootstrapModel } from "./fallback-bootstrap-model"
|
import { resolveFallbackBootstrapModel } from "./fallback-bootstrap-model"
|
||||||
import { dispatchFallbackRetry } from "./fallback-retry-dispatcher"
|
import { dispatchFallbackRetry } from "./fallback-retry-dispatcher"
|
||||||
import { createSessionStatusHandler } from "./session-status-handler"
|
import { createSessionStatusHandler } from "./session-status-handler"
|
||||||
|
import { resolveMessageEventSessionID, resolveSessionEventID } from "../../shared/event-session-id"
|
||||||
|
|
||||||
export function createEventHandler(deps: HookDeps, helpers: AutoRetryHelpers) {
|
export function createEventHandler(deps: HookDeps, helpers: AutoRetryHelpers) {
|
||||||
const { config, pluginConfig, sessionStates, sessionLastAccess, sessionRetryInFlight, sessionAwaitingFallbackResult, sessionFallbackTimeouts, sessionStatusRetryKeys } = deps
|
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<string, unknown> | undefined) => {
|
const handleSessionCreated = (props: Record<string, unknown> | undefined) => {
|
||||||
const sessionInfo = props?.info as { id?: string; model?: string } | undefined
|
const sessionInfo = props?.info as { id?: string; model?: string } | undefined
|
||||||
const sessionID = sessionInfo?.id
|
const sessionID = resolveSessionEventID(props)
|
||||||
const model = sessionInfo?.model
|
const model = sessionInfo?.model
|
||||||
|
|
||||||
if (sessionID && model) {
|
if (sessionID && model) {
|
||||||
@@ -41,8 +42,7 @@ export function createEventHandler(deps: HookDeps, helpers: AutoRetryHelpers) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleSessionDeleted = (props: Record<string, unknown> | undefined) => {
|
const handleSessionDeleted = (props: Record<string, unknown> | undefined) => {
|
||||||
const sessionInfo = props?.info as { id?: string } | undefined
|
const sessionID = resolveSessionEventID(props)
|
||||||
const sessionID = sessionInfo?.id
|
|
||||||
|
|
||||||
if (sessionID) {
|
if (sessionID) {
|
||||||
log(`[${HOOK_NAME}] Cleaning up session state`, { 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<string, unknown> | undefined) => {
|
const handleSessionStop = async (props: Record<string, unknown> | undefined) => {
|
||||||
const sessionID = props?.sessionID as string | undefined
|
const sessionID = resolveSessionEventID(props)
|
||||||
if (!sessionID) return
|
if (!sessionID) return
|
||||||
|
|
||||||
if (sessionRetryInFlight.has(sessionID) || sessionAwaitingFallbackResult.has(sessionID)) {
|
if (sessionRetryInFlight.has(sessionID) || sessionAwaitingFallbackResult.has(sessionID)) {
|
||||||
@@ -73,7 +73,7 @@ export function createEventHandler(deps: HookDeps, helpers: AutoRetryHelpers) {
|
|||||||
|
|
||||||
const handleMessageUpdated = (props: Record<string, unknown> | undefined) => {
|
const handleMessageUpdated = (props: Record<string, unknown> | undefined) => {
|
||||||
const info = props?.info as Record<string, unknown> | undefined
|
const info = props?.info as Record<string, unknown> | undefined
|
||||||
const sessionID = info?.sessionID as string | undefined
|
const sessionID = resolveMessageEventSessionID(props)
|
||||||
const role = info?.role as string | undefined
|
const role = info?.role as string | undefined
|
||||||
if (!sessionID || role !== "user") return
|
if (!sessionID || role !== "user") return
|
||||||
|
|
||||||
@@ -81,7 +81,7 @@ export function createEventHandler(deps: HookDeps, helpers: AutoRetryHelpers) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleSessionIdle = (props: Record<string, unknown> | undefined) => {
|
const handleSessionIdle = (props: Record<string, unknown> | undefined) => {
|
||||||
const sessionID = props?.sessionID as string | undefined
|
const sessionID = resolveSessionEventID(props)
|
||||||
if (!sessionID) return
|
if (!sessionID) return
|
||||||
|
|
||||||
if (cancelledSessions.has(sessionID)) {
|
if (cancelledSessions.has(sessionID)) {
|
||||||
@@ -111,7 +111,7 @@ export function createEventHandler(deps: HookDeps, helpers: AutoRetryHelpers) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleSessionError = async (props: Record<string, unknown> | undefined) => {
|
const handleSessionError = async (props: Record<string, unknown> | undefined) => {
|
||||||
const sessionID = props?.sessionID as string | undefined
|
const sessionID = resolveSessionEventID(props)
|
||||||
const error = props?.error
|
const error = props?.error
|
||||||
const agent = props?.agent as string | undefined
|
const agent = props?.agent as string | undefined
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { getFallbackModelsForSession } from "./fallback-models"
|
|||||||
import { resolveFallbackBootstrapModel } from "./fallback-bootstrap-model"
|
import { resolveFallbackBootstrapModel } from "./fallback-bootstrap-model"
|
||||||
import { dispatchFallbackRetry } from "./fallback-retry-dispatcher"
|
import { dispatchFallbackRetry } from "./fallback-retry-dispatcher"
|
||||||
import { hasVisibleAssistantResponse } from "./visible-assistant-response"
|
import { hasVisibleAssistantResponse } from "./visible-assistant-response"
|
||||||
|
import { resolveMessageEventSessionID } from "../../shared/event-session-id"
|
||||||
|
|
||||||
export { hasVisibleAssistantResponse } from "./visible-assistant-response"
|
export { hasVisibleAssistantResponse } from "./visible-assistant-response"
|
||||||
|
|
||||||
@@ -17,7 +18,7 @@ export function createMessageUpdateHandler(deps: HookDeps, helpers: AutoRetryHel
|
|||||||
|
|
||||||
return async (props: Record<string, unknown> | undefined) => {
|
return async (props: Record<string, unknown> | undefined) => {
|
||||||
const info = props?.info as Record<string, unknown> | undefined
|
const info = props?.info as Record<string, unknown> | undefined
|
||||||
const sessionID = info?.sessionID as string | undefined
|
const sessionID = resolveMessageEventSessionID(props)
|
||||||
const timeoutEnabled = config.timeout_seconds > 0
|
const timeoutEnabled = config.timeout_seconds > 0
|
||||||
const eventParts = props?.parts as Array<{ type?: string; text?: string }> | undefined
|
const eventParts = props?.parts as Array<{ type?: string; text?: string }> | undefined
|
||||||
const infoParts = info?.parts as Array<{ type?: string; text?: string }> | undefined
|
const infoParts = info?.parts as Array<{ type?: string; text?: string }> | undefined
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { getFallbackModelsForSession } from "./fallback-models"
|
|||||||
import { normalizeRetryStatusMessage, extractRetryAttempt } from "../../shared/retry-status-utils"
|
import { normalizeRetryStatusMessage, extractRetryAttempt } from "../../shared/retry-status-utils"
|
||||||
import { resolveFallbackBootstrapModel } from "./fallback-bootstrap-model"
|
import { resolveFallbackBootstrapModel } from "./fallback-bootstrap-model"
|
||||||
import { dispatchFallbackRetry } from "./fallback-retry-dispatcher"
|
import { dispatchFallbackRetry } from "./fallback-retry-dispatcher"
|
||||||
|
import { resolveSessionEventID } from "../../shared/event-session-id"
|
||||||
|
|
||||||
export function createSessionStatusHandler(
|
export function createSessionStatusHandler(
|
||||||
deps: HookDeps,
|
deps: HookDeps,
|
||||||
@@ -22,7 +23,7 @@ export function createSessionStatusHandler(
|
|||||||
} = deps
|
} = deps
|
||||||
|
|
||||||
return async (props: Record<string, unknown> | undefined) => {
|
return async (props: Record<string, unknown> | 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 status = props?.status as { type?: string; message?: string; attempt?: number } | undefined
|
||||||
const agent = props?.agent as string | undefined
|
const agent = props?.agent as string | undefined
|
||||||
const model = props?.model as string | undefined
|
const model = props?.model as string | undefined
|
||||||
|
|||||||
@@ -23,6 +23,15 @@ export function getSessionID(properties: EventProperties): string | undefined {
|
|||||||
const infoSessionId = info?.sessionId
|
const infoSessionId = info?.sessionId
|
||||||
if (typeof infoSessionId === "string" && infoSessionId.length > 0) return infoSessionId
|
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
|
return undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,11 @@
|
|||||||
import { log } from "../shared/logger"
|
import { log } from "../shared/logger"
|
||||||
|
import { bunWhich } from "../shared/bun-which-shim"
|
||||||
declare const Bun: {
|
|
||||||
which(commandName: string): string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
type Platform = "darwin" | "linux" | "win32" | "unsupported"
|
type Platform = "darwin" | "linux" | "win32" | "unsupported"
|
||||||
|
|
||||||
async function findCommand(commandName: string): Promise<string | null> {
|
async function findCommand(commandName: string): Promise<string | null> {
|
||||||
try {
|
try {
|
||||||
return Bun.which(commandName)
|
return bunWhich(commandName)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log("[session-notification] failed to resolve command path", {
|
log("[session-notification] failed to resolve command path", {
|
||||||
commandName,
|
commandName,
|
||||||
|
|||||||
@@ -375,6 +375,47 @@ describe("session-notification", () => {
|
|||||||
expect(notificationCalls).toHaveLength(0)
|
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 () => {
|
test("should mark session activity on tool.execute.before event", async () => {
|
||||||
// given - main session is set
|
// given - main session is set
|
||||||
const mainSessionID = "main-tool"
|
const mainSessionID = "main-tool"
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { getEventToolName, getQuestionText, getSessionID } from "./session-notif
|
|||||||
import { hasIncompleteTodos } from "./session-todo-status"
|
import { hasIncompleteTodos } from "./session-todo-status"
|
||||||
import { createIdleNotificationScheduler } from "./session-notification-scheduler"
|
import { createIdleNotificationScheduler } from "./session-notification-scheduler"
|
||||||
import { createSessionNotificationInit } from "./session-notification-init"
|
import { createSessionNotificationInit } from "./session-notification-init"
|
||||||
|
import { resolveSessionEventID } from "../shared/event-session-id"
|
||||||
|
|
||||||
interface SessionNotificationConfig {
|
interface SessionNotificationConfig {
|
||||||
title?: string
|
title?: string
|
||||||
@@ -98,8 +99,7 @@ export function createSessionNotification(ctx: PluginInput, config: SessionNotif
|
|||||||
const props = event.properties as Record<string, unknown> | undefined
|
const props = event.properties as Record<string, unknown> | undefined
|
||||||
|
|
||||||
if (event.type === "session.created") {
|
if (event.type === "session.created") {
|
||||||
const info = props?.info as Record<string, unknown> | undefined
|
const sessionID = resolveSessionEventID(props)
|
||||||
const sessionID = info?.id as string | undefined
|
|
||||||
if (sessionID) scheduler.markSessionActivity(sessionID)
|
if (sessionID) scheduler.markSessionActivity(sessionID)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -116,7 +116,11 @@ export function createSessionNotification(ctx: PluginInput, config: SessionNotif
|
|||||||
return
|
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<string, unknown> | undefined
|
const info = props?.info as Record<string, unknown> | undefined
|
||||||
const sessionID = getSessionID({ ...props, info })
|
const sessionID = getSessionID({ ...props, info })
|
||||||
if (sessionID) scheduler.markSessionActivity(sessionID)
|
if (sessionID) scheduler.markSessionActivity(sessionID)
|
||||||
@@ -165,8 +169,8 @@ export function createSessionNotification(ctx: PluginInput, config: SessionNotif
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (event.type === "session.deleted") {
|
if (event.type === "session.deleted") {
|
||||||
const sessionInfo = props?.info as { id?: string } | undefined
|
const sessionID = resolveSessionEventID(props)
|
||||||
if (sessionInfo?.id) scheduler.deleteSession(sessionInfo.id)
|
if (sessionID) scheduler.deleteSession(sessionID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
clearContinuationMarker,
|
clearContinuationMarker,
|
||||||
setContinuationMarkerSource,
|
setContinuationMarkerSource,
|
||||||
} from "../../features/run-continuation-state"
|
} from "../../features/run-continuation-state"
|
||||||
|
import { resolveSessionEventID } from "../../shared/event-session-id"
|
||||||
import { log } from "../../shared/logger"
|
import { log } from "../../shared/logger"
|
||||||
|
|
||||||
const HOOK_NAME = "stop-continuation-guard"
|
const HOOK_NAME = "stop-continuation-guard"
|
||||||
@@ -86,11 +87,11 @@ export function createStopContinuationGuardHook(
|
|||||||
const props = event.properties as Record<string, unknown> | undefined
|
const props = event.properties as Record<string, unknown> | undefined
|
||||||
|
|
||||||
if (event.type === "session.deleted") {
|
if (event.type === "session.deleted") {
|
||||||
const sessionInfo = props?.info as { id?: string } | undefined
|
const sessionID = resolveSessionEventID(props)
|
||||||
if (sessionInfo?.id) {
|
if (sessionID) {
|
||||||
clear(sessionInfo.id)
|
clear(sessionID)
|
||||||
clearContinuationMarker(ctx.directory, sessionInfo.id)
|
clearContinuationMarker(ctx.directory, sessionID)
|
||||||
log(`[${HOOK_NAME}] Session deleted: cleaned up`, { sessionID: sessionInfo.id })
|
log(`[${HOOK_NAME}] Session deleted: cleaned up`, { sessionID })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import type { PluginInput } from "@opencode-ai/plugin"
|
import type { PluginInput } from "@opencode-ai/plugin"
|
||||||
|
|
||||||
|
import { resolveSessionEventID } from "../../shared/event-session-id"
|
||||||
|
|
||||||
const TASK_TOOLS = new Set([
|
const TASK_TOOLS = new Set([
|
||||||
"task",
|
"task",
|
||||||
"task_create",
|
"task_create",
|
||||||
@@ -50,8 +52,7 @@ export function createTaskReminderHook(_ctx: PluginInput) {
|
|||||||
"tool.execute.after": toolExecuteAfter,
|
"tool.execute.after": toolExecuteAfter,
|
||||||
event: async ({ event }: { event: { type: string; properties?: unknown } }) => {
|
event: async ({ event }: { event: { type: string; properties?: unknown } }) => {
|
||||||
if (event.type !== "session.deleted") return
|
if (event.type !== "session.deleted") return
|
||||||
const props = event.properties as { info?: { id?: string } } | undefined
|
const sessionId = resolveSessionEventID(event.properties)
|
||||||
const sessionId = props?.info?.id
|
|
||||||
if (!sessionId) return
|
if (!sessionId) return
|
||||||
sessionCounters.delete(sessionId)
|
sessionCounters.delete(sessionId)
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
applyMemberSessionRouting,
|
applyMemberSessionRouting,
|
||||||
buildMemberPromptBody,
|
buildMemberPromptBody,
|
||||||
} from "../../features/team-mode/member-session-routing"
|
} from "../../features/team-mode/member-session-routing"
|
||||||
|
import { resolveSessionEventID } from "../../shared/event-session-id"
|
||||||
import { log } from "../../shared/logger"
|
import { log } from "../../shared/logger"
|
||||||
import { settleAfterSessionIdle } from "../shared/session-idle-settle"
|
import { settleAfterSessionIdle } from "../shared/session-idle-settle"
|
||||||
|
|
||||||
@@ -35,8 +36,7 @@ export type HookImpl = (input: HookInput) => Promise<void>
|
|||||||
type TeamIdleWakeHintOptions = { idleSettleMs?: number }
|
type TeamIdleWakeHintOptions = { idleSettleMs?: number }
|
||||||
|
|
||||||
function getIdleSessionID(properties: unknown): string | undefined {
|
function getIdleSessionID(properties: unknown): string | undefined {
|
||||||
const record = properties as { sessionID?: string } | undefined
|
return resolveSessionEventID(properties)
|
||||||
return record?.sessionID
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildWakeHint(unreadCount: number): string {
|
function buildWakeHint(unreadCount: number): string {
|
||||||
|
|||||||
@@ -3,14 +3,14 @@ import type { BackgroundManager } from "../../features/background-agent/manager"
|
|||||||
import { lookupTeamSession } from "../../features/team-mode/team-session-registry"
|
import { lookupTeamSession } from "../../features/team-mode/team-session-registry"
|
||||||
import { loadRuntimeState, listActiveTeams, transitionRuntimeState } from "../../features/team-mode/team-state-store/store"
|
import { loadRuntimeState, listActiveTeams, transitionRuntimeState } from "../../features/team-mode/team-state-store/store"
|
||||||
import type { TmuxSessionManager } from "../../features/tmux-subagent/manager"
|
import type { TmuxSessionManager } from "../../features/tmux-subagent/manager"
|
||||||
|
import { resolveSessionEventID } from "../../shared/event-session-id"
|
||||||
import { log } from "../../shared/logger"
|
import { log } from "../../shared/logger"
|
||||||
|
|
||||||
type HookInput = { event: { type: string; properties?: unknown } }
|
type HookInput = { event: { type: string; properties?: unknown } }
|
||||||
export type HookImpl = (input: HookInput) => Promise<void>
|
export type HookImpl = (input: HookInput) => Promise<void>
|
||||||
|
|
||||||
function getDeletedSessionID(properties: unknown): string | undefined {
|
function getDeletedSessionID(properties: unknown): string | undefined {
|
||||||
const record = properties as { info?: { id?: string } } | undefined
|
return resolveSessionEventID(properties)
|
||||||
return record?.info?.id
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function findLeadTeamRunId(
|
async function findLeadTeamRunId(
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
import type { TeamModeConfig } from "../../config/schema/team-mode"
|
import type { TeamModeConfig } from "../../config/schema/team-mode"
|
||||||
import { findResolvedMemberSession } from "../../features/team-mode/member-session-resolution"
|
import { findResolvedMemberSession } from "../../features/team-mode/member-session-resolution"
|
||||||
import { loadRuntimeState, transitionRuntimeState } from "../../features/team-mode/team-state-store/store"
|
import { loadRuntimeState, transitionRuntimeState } from "../../features/team-mode/team-state-store/store"
|
||||||
|
import { resolveSessionEventID } from "../../shared/event-session-id"
|
||||||
import { log } from "../../shared/logger"
|
import { log } from "../../shared/logger"
|
||||||
|
|
||||||
type HookInput = { event: { type: string; properties?: unknown } }
|
type HookInput = { event: { type: string; properties?: unknown } }
|
||||||
export type HookImpl = (input: HookInput) => Promise<void>
|
export type HookImpl = (input: HookInput) => Promise<void>
|
||||||
|
|
||||||
function getErroredSessionID(properties: unknown): string | undefined {
|
function getErroredSessionID(properties: unknown): string | undefined {
|
||||||
const record = properties as { sessionID?: string } | undefined
|
return resolveSessionEventID(properties)
|
||||||
return record?.sessionID
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createTeamMemberErrorHandler(config: TeamModeConfig): HookImpl {
|
export function createTeamMemberErrorHandler(config: TeamModeConfig): HookImpl {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { TeamModeConfig } from "../../config/schema/team-mode"
|
|||||||
import { findResolvedMemberSession } from "../../features/team-mode/member-session-resolution"
|
import { findResolvedMemberSession } from "../../features/team-mode/member-session-resolution"
|
||||||
import { loadRuntimeState, transitionRuntimeState } from "../../features/team-mode/team-state-store/store"
|
import { loadRuntimeState, transitionRuntimeState } from "../../features/team-mode/team-state-store/store"
|
||||||
import type { RuntimeStateMember } from "../../features/team-mode/types"
|
import type { RuntimeStateMember } from "../../features/team-mode/types"
|
||||||
|
import { resolveSessionEventID } from "../../shared/event-session-id"
|
||||||
import { log } from "../../shared/logger"
|
import { log } from "../../shared/logger"
|
||||||
|
|
||||||
type HookInput = { event: { type: string; properties?: unknown } }
|
type HookInput = { event: { type: string; properties?: unknown } }
|
||||||
@@ -13,13 +14,11 @@ const IDLE_TRANSITION_SOURCE_STATUSES: ReadonlySet<MemberStatus> = new Set(["run
|
|||||||
const COMPLETED_TRANSITION_SOURCE_STATUSES: ReadonlySet<MemberStatus> = new Set(["running", "idle", "pending"])
|
const COMPLETED_TRANSITION_SOURCE_STATUSES: ReadonlySet<MemberStatus> = new Set(["running", "idle", "pending"])
|
||||||
|
|
||||||
function getSessionIDFromIdleEvent(properties: unknown): string | undefined {
|
function getSessionIDFromIdleEvent(properties: unknown): string | undefined {
|
||||||
const record = properties as { sessionID?: string } | undefined
|
return resolveSessionEventID(properties)
|
||||||
return record?.sessionID
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getSessionIDFromDeletedEvent(properties: unknown): string | undefined {
|
function getSessionIDFromDeletedEvent(properties: unknown): string | undefined {
|
||||||
const record = properties as { info?: { id?: string } } | undefined
|
return resolveSessionEventID(properties)
|
||||||
return record?.info?.id
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function transitionMemberStatus(
|
async function transitionMemberStatus(
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { detectThinkKeyword, extractPromptText } from "./detector"
|
|||||||
import { isAlreadyHighVariant } from "./switcher"
|
import { isAlreadyHighVariant } from "./switcher"
|
||||||
import type { ThinkModeState } from "./types"
|
import type { ThinkModeState } from "./types"
|
||||||
import { log } from "../../shared"
|
import { log } from "../../shared"
|
||||||
|
import { resolveSessionEventID } from "../../shared/event-session-id"
|
||||||
|
|
||||||
const thinkModeState = new Map<string, ThinkModeState>()
|
const thinkModeState = new Map<string, ThinkModeState>()
|
||||||
|
|
||||||
@@ -66,9 +67,9 @@ export function createThinkModeHook() {
|
|||||||
|
|
||||||
event: async ({ event }: { event: { type: string; properties?: unknown } }) => {
|
event: async ({ event }: { event: { type: string; properties?: unknown } }) => {
|
||||||
if (event.type === "session.deleted") {
|
if (event.type === "session.deleted") {
|
||||||
const props = event.properties as { info?: { id?: string } } | undefined
|
const sessionID = resolveSessionEventID(event.properties)
|
||||||
if (props?.info?.id) {
|
if (sessionID) {
|
||||||
thinkModeState.delete(props.info.id)
|
thinkModeState.delete(sessionID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
clearContinuationMarker,
|
clearContinuationMarker,
|
||||||
} from "../../features/run-continuation-state"
|
} from "../../features/run-continuation-state"
|
||||||
import { log } from "../../shared/logger"
|
import { log } from "../../shared/logger"
|
||||||
|
import { resolveSessionEventID } from "../../shared/event-session-id"
|
||||||
|
|
||||||
import { DEFAULT_SKIP_AGENTS, HOOK_NAME } from "./constants"
|
import { DEFAULT_SKIP_AGENTS, HOOK_NAME } from "./constants"
|
||||||
import { armCompactionGuard } from "./compaction-guard"
|
import { armCompactionGuard } from "./compaction-guard"
|
||||||
@@ -71,7 +72,7 @@ export function createTodoContinuationHandler(args: {
|
|||||||
const props = event.properties as Record<string, unknown> | undefined
|
const props = event.properties as Record<string, unknown> | undefined
|
||||||
|
|
||||||
if (event.type === "session.error") {
|
if (event.type === "session.error") {
|
||||||
const sessionID = props?.sessionID as string | undefined
|
const sessionID = resolveSessionEventID(props)
|
||||||
if (!sessionID) return
|
if (!sessionID) return
|
||||||
|
|
||||||
const error = extractSessionErrorInfo(props?.error)
|
const error = extractSessionErrorInfo(props?.error)
|
||||||
@@ -102,7 +103,7 @@ export function createTodoContinuationHandler(args: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (event.type === "session.idle") {
|
if (event.type === "session.idle") {
|
||||||
const sessionID = props?.sessionID as string | undefined
|
const sessionID = resolveSessionEventID(props)
|
||||||
if (!sessionID) return
|
if (!sessionID) return
|
||||||
|
|
||||||
sessionStateStore.startPruneInterval()
|
sessionStateStore.startPruneInterval()
|
||||||
@@ -118,7 +119,7 @@ export function createTodoContinuationHandler(args: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (event.type === "session.compacted") {
|
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) {
|
if (sessionID) {
|
||||||
const state = sessionStateStore.getState(sessionID)
|
const state = sessionStateStore.getState(sessionID)
|
||||||
const compactionEpoch = armCompactionGuard(state, Date.now())
|
const compactionEpoch = armCompactionGuard(state, Date.now())
|
||||||
@@ -129,9 +130,9 @@ export function createTodoContinuationHandler(args: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (event.type === "session.deleted") {
|
if (event.type === "session.deleted") {
|
||||||
const sessionInfo = props?.info as { id?: string } | undefined
|
const sessionID = resolveSessionEventID(props)
|
||||||
if (sessionInfo?.id) {
|
if (sessionID) {
|
||||||
clearContinuationMarker(ctx.directory, sessionInfo.id)
|
clearContinuationMarker(ctx.directory, sessionID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { log } from "../../shared/logger"
|
import { log } from "../../shared/logger"
|
||||||
|
import { resolveMessageEventSessionID, resolveSessionEventID } from "../../shared/event-session-id"
|
||||||
|
|
||||||
import { COUNTDOWN_GRACE_PERIOD_MS, HOOK_NAME } from "./constants"
|
import { COUNTDOWN_GRACE_PERIOD_MS, HOOK_NAME } from "./constants"
|
||||||
import type { SessionStateStore } from "./session-state"
|
import type { SessionStateStore } from "./session-state"
|
||||||
@@ -12,7 +13,7 @@ export function handleNonIdleEvent(args: {
|
|||||||
|
|
||||||
if (eventType === "message.updated") {
|
if (eventType === "message.updated") {
|
||||||
const info = properties?.info as Record<string, unknown> | undefined
|
const info = properties?.info as Record<string, unknown> | undefined
|
||||||
const sessionID = info?.sessionID as string | undefined
|
const sessionID = resolveMessageEventSessionID(properties)
|
||||||
const role = info?.role as string | undefined
|
const role = info?.role as string | undefined
|
||||||
if (!sessionID) return
|
if (!sessionID) return
|
||||||
|
|
||||||
@@ -50,12 +51,7 @@ export function handleNonIdleEvent(args: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (eventType === "message.part.updated") {
|
if (eventType === "message.part.updated") {
|
||||||
const sessionID = typeof properties?.sessionID === "string"
|
const targetSessionID = resolveMessageEventSessionID(properties)
|
||||||
? properties.sessionID
|
|
||||||
: undefined
|
|
||||||
const legacyInfo = properties?.info as Record<string, unknown> | undefined
|
|
||||||
const legacySessionID = legacyInfo?.sessionID as string | undefined
|
|
||||||
const targetSessionID = sessionID ?? legacySessionID
|
|
||||||
|
|
||||||
if (targetSessionID) {
|
if (targetSessionID) {
|
||||||
const state = sessionStateStore.getExistingState(targetSessionID)
|
const state = sessionStateStore.getExistingState(targetSessionID)
|
||||||
@@ -69,7 +65,7 @@ export function handleNonIdleEvent(args: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (eventType === "message.part.delta") {
|
if (eventType === "message.part.delta") {
|
||||||
const sessionID = properties?.sessionID as string | undefined
|
const sessionID = resolveMessageEventSessionID(properties)
|
||||||
if (sessionID) {
|
if (sessionID) {
|
||||||
const state = sessionStateStore.getExistingState(sessionID)
|
const state = sessionStateStore.getExistingState(sessionID)
|
||||||
if (state) {
|
if (state) {
|
||||||
@@ -83,7 +79,7 @@ export function handleNonIdleEvent(args: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (eventType === "tool.execute.before" || eventType === "tool.execute.after") {
|
if (eventType === "tool.execute.before" || eventType === "tool.execute.after") {
|
||||||
const sessionID = properties?.sessionID as string | undefined
|
const sessionID = resolveMessageEventSessionID(properties)
|
||||||
if (sessionID) {
|
if (sessionID) {
|
||||||
const state = sessionStateStore.getExistingState(sessionID)
|
const state = sessionStateStore.getExistingState(sessionID)
|
||||||
if (state) {
|
if (state) {
|
||||||
@@ -97,10 +93,10 @@ export function handleNonIdleEvent(args: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (eventType === "session.deleted") {
|
if (eventType === "session.deleted") {
|
||||||
const sessionInfo = properties?.info as { id?: string } | undefined
|
const sessionID = resolveSessionEventID(properties)
|
||||||
if (sessionInfo?.id) {
|
if (sessionID) {
|
||||||
sessionStateStore.cleanup(sessionInfo.id)
|
sessionStateStore.cleanup(sessionID)
|
||||||
log(`[${HOOK_NAME}] Session deleted: cleaned up`, { sessionID: sessionInfo.id })
|
log(`[${HOOK_NAME}] Session deleted: cleaned up`, { sessionID })
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
} from "./constants"
|
} from "./constants"
|
||||||
|
|
||||||
type TimerCallback = (...args: any[]) => void
|
type TimerCallback = (...args: any[]) => void
|
||||||
|
type FakeTimerID = number & ReturnType<typeof setTimeout> & ReturnType<typeof setInterval>
|
||||||
|
|
||||||
interface FakeTimers {
|
interface FakeTimers {
|
||||||
advanceBy: (ms: number, advanceClock?: boolean) => Promise<void>
|
advanceBy: (ms: number, advanceClock?: boolean) => Promise<void>
|
||||||
@@ -57,7 +58,7 @@ function createFakeTimers(): FakeTimers {
|
|||||||
callback,
|
callback,
|
||||||
args,
|
args,
|
||||||
})
|
})
|
||||||
return id
|
return id as FakeTimerID
|
||||||
}
|
}
|
||||||
|
|
||||||
const clear = (id: number | undefined) => {
|
const clear = (id: number | undefined) => {
|
||||||
@@ -74,7 +75,7 @@ function createFakeTimers(): FakeTimers {
|
|||||||
if (normalized >= REAL_MAX_DELAY_MS) {
|
if (normalized >= REAL_MAX_DELAY_MS) {
|
||||||
return original.setTimeout(callback, delay, ...args)
|
return original.setTimeout(callback, delay, ...args)
|
||||||
}
|
}
|
||||||
return schedule(callback, normalized, null, args) as unknown as ReturnType<typeof setTimeout>
|
return schedule(callback, normalized, null, args)
|
||||||
}) as typeof setTimeout
|
}) as typeof setTimeout
|
||||||
|
|
||||||
globalThis.setInterval = ((callback: TimerCallback, delay?: number, ...args: any[]) => {
|
globalThis.setInterval = ((callback: TimerCallback, delay?: number, ...args: any[]) => {
|
||||||
@@ -85,7 +86,7 @@ function createFakeTimers(): FakeTimers {
|
|||||||
if (interval >= REAL_MAX_DELAY_MS) {
|
if (interval >= REAL_MAX_DELAY_MS) {
|
||||||
return original.setInterval(callback, delay, ...args)
|
return original.setInterval(callback, delay, ...args)
|
||||||
}
|
}
|
||||||
return schedule(callback, interval, interval, args) as unknown as ReturnType<typeof setInterval>
|
return schedule(callback, interval, interval, args)
|
||||||
}) as typeof setInterval
|
}) as typeof setInterval
|
||||||
|
|
||||||
globalThis.clearTimeout = ((id?: Parameters<typeof clearTimeout>[0]) => {
|
globalThis.clearTimeout = ((id?: Parameters<typeof clearTimeout>[0]) => {
|
||||||
@@ -184,6 +185,8 @@ describe("todo-continuation-enforcer", () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type MockPluginInput = Parameters<typeof createTodoContinuationEnforcer>[0]
|
||||||
|
|
||||||
let mockMessages: MockMessage[] = []
|
let mockMessages: MockMessage[] = []
|
||||||
|
|
||||||
function createMockPluginInput() {
|
function createMockPluginInput() {
|
||||||
@@ -225,7 +228,7 @@ describe("todo-continuation-enforcer", () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
directory: "/tmp/test",
|
directory: "/tmp/test",
|
||||||
} as any
|
} as MockPluginInput
|
||||||
}
|
}
|
||||||
|
|
||||||
function createMockBackgroundManager(runningTasks: boolean = false): BackgroundManager {
|
function createMockBackgroundManager(runningTasks: boolean = false): BackgroundManager {
|
||||||
@@ -233,7 +236,7 @@ describe("todo-continuation-enforcer", () => {
|
|||||||
getTasksByParentSession: () => runningTasks
|
getTasksByParentSession: () => runningTasks
|
||||||
? [{ status: "running" }]
|
? [{ status: "running" }]
|
||||||
: [],
|
: [],
|
||||||
} as any
|
} as BackgroundManager
|
||||||
}
|
}
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -302,6 +305,26 @@ describe("todo-continuation-enforcer", () => {
|
|||||||
expect(promptCalls[0].text).toContain("TODO CONTINUATION")
|
expect(promptCalls[0].text).toContain("TODO CONTINUATION")
|
||||||
}, { timeout: 15000 })
|
}, { 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 () => {
|
test("should not inject when all todos are complete", async () => {
|
||||||
// given - session with all todos complete
|
// given - session with all todos complete
|
||||||
const sessionID = "main-456"
|
const sessionID = "main-456"
|
||||||
@@ -527,6 +550,42 @@ describe("todo-continuation-enforcer", () => {
|
|||||||
expect(promptCalls).toHaveLength(0)
|
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 () => {
|
test("should cancel countdown on assistant activity with message.part.delta payload", async () => {
|
||||||
// given - session starting countdown
|
// given - session starting countdown
|
||||||
const sessionID = "main-assistant-delta"
|
const sessionID = "main-assistant-delta"
|
||||||
@@ -1599,7 +1658,7 @@ describe("todo-continuation-enforcer", () => {
|
|||||||
tui: { showToast: async () => ({}) },
|
tui: { showToast: async () => ({}) },
|
||||||
},
|
},
|
||||||
directory: "/tmp/test",
|
directory: "/tmp/test",
|
||||||
} as any
|
} as MockPluginInput
|
||||||
|
|
||||||
const hook = createTodoContinuationEnforcer(mockInput, {
|
const hook = createTodoContinuationEnforcer(mockInput, {
|
||||||
backgroundManager: createMockBackgroundManager(false),
|
backgroundManager: createMockBackgroundManager(false),
|
||||||
@@ -1660,7 +1719,7 @@ describe("todo-continuation-enforcer", () => {
|
|||||||
tui: { showToast: async () => ({}) },
|
tui: { showToast: async () => ({}) },
|
||||||
},
|
},
|
||||||
directory: "/tmp/test",
|
directory: "/tmp/test",
|
||||||
} as any
|
} as MockPluginInput
|
||||||
|
|
||||||
const hook = createTodoContinuationEnforcer(mockInput, {
|
const hook = createTodoContinuationEnforcer(mockInput, {
|
||||||
backgroundManager: createMockBackgroundManager(false),
|
backgroundManager: createMockBackgroundManager(false),
|
||||||
@@ -1712,7 +1771,7 @@ describe("todo-continuation-enforcer", () => {
|
|||||||
tui: { showToast: async () => ({}) },
|
tui: { showToast: async () => ({}) },
|
||||||
},
|
},
|
||||||
directory: "/tmp/test",
|
directory: "/tmp/test",
|
||||||
} as any
|
} as MockPluginInput
|
||||||
|
|
||||||
const hook = createTodoContinuationEnforcer(mockInput, {})
|
const hook = createTodoContinuationEnforcer(mockInput, {})
|
||||||
|
|
||||||
@@ -1769,7 +1828,7 @@ describe("todo-continuation-enforcer", () => {
|
|||||||
tui: { showToast: async () => ({}) },
|
tui: { showToast: async () => ({}) },
|
||||||
},
|
},
|
||||||
directory: "/tmp/test",
|
directory: "/tmp/test",
|
||||||
} as any
|
} as MockPluginInput
|
||||||
|
|
||||||
const hook = createTodoContinuationEnforcer(mockInput, {
|
const hook = createTodoContinuationEnforcer(mockInput, {
|
||||||
backgroundManager: createMockBackgroundManager(false),
|
backgroundManager: createMockBackgroundManager(false),
|
||||||
@@ -1823,7 +1882,7 @@ describe("todo-continuation-enforcer", () => {
|
|||||||
tui: { showToast: async () => ({}) },
|
tui: { showToast: async () => ({}) },
|
||||||
},
|
},
|
||||||
directory: "/tmp/test",
|
directory: "/tmp/test",
|
||||||
} as any
|
} as MockPluginInput
|
||||||
|
|
||||||
const hook = createTodoContinuationEnforcer(mockInput, {})
|
const hook = createTodoContinuationEnforcer(mockInput, {})
|
||||||
|
|
||||||
@@ -1878,7 +1937,7 @@ describe("todo-continuation-enforcer", () => {
|
|||||||
tui: { showToast: async () => ({}) },
|
tui: { showToast: async () => ({}) },
|
||||||
},
|
},
|
||||||
directory: "/tmp/test",
|
directory: "/tmp/test",
|
||||||
} as any
|
} as MockPluginInput
|
||||||
|
|
||||||
const hook = createTodoContinuationEnforcer(mockInput, {
|
const hook = createTodoContinuationEnforcer(mockInput, {
|
||||||
skipAgents: [],
|
skipAgents: [],
|
||||||
@@ -2122,7 +2181,7 @@ describe("todo-continuation-enforcer", () => {
|
|||||||
const mockInput = createMockPluginInput()
|
const mockInput = createMockPluginInput()
|
||||||
mockInput.client.session.promptAsync = async () => {
|
mockInput.client.session.promptAsync = async () => {
|
||||||
const error = new Error("prompt is too long: 150000 tokens > 100000 maximum")
|
const error = new Error("prompt is too long: 150000 tokens > 100000 maximum")
|
||||||
;(error as any).name = "ContextLengthError"
|
error.name = "ContextLengthError"
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { BackgroundManager } from "../../features/background-agent"
|
|||||||
import { getMainSessionID, getSessionAgent } from "../../features/claude-code-session-state"
|
import { getMainSessionID, getSessionAgent } from "../../features/claude-code-session-state"
|
||||||
import { log } from "../../shared/logger"
|
import { log } from "../../shared/logger"
|
||||||
import { createInternalAgentTextPart, resolveInheritedPromptTools } from "../../shared"
|
import { createInternalAgentTextPart, resolveInheritedPromptTools } from "../../shared"
|
||||||
|
import { resolveMessageEventSessionID, resolveSessionEventID } from "../../shared/event-session-id"
|
||||||
import { isAbortError } from "../../shared/is-abort-error"
|
import { isAbortError } from "../../shared/is-abort-error"
|
||||||
import {
|
import {
|
||||||
buildReminder,
|
buildReminder,
|
||||||
@@ -128,7 +129,7 @@ export function createUnstableAgentBabysitterHook(ctx: BabysitterContext, option
|
|||||||
const props = event.properties as Record<string, unknown> | undefined
|
const props = event.properties as Record<string, unknown> | undefined
|
||||||
|
|
||||||
if (event.type === "session.error") {
|
if (event.type === "session.error") {
|
||||||
const sessionID = props?.sessionID as string | undefined
|
const sessionID = resolveSessionEventID(props)
|
||||||
if (!sessionID || !isAbortError(props?.error)) return
|
if (!sessionID || !isAbortError(props?.error)) return
|
||||||
|
|
||||||
cancelledSessions.add(sessionID)
|
cancelledSessions.add(sessionID)
|
||||||
@@ -138,7 +139,7 @@ export function createUnstableAgentBabysitterHook(ctx: BabysitterContext, option
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (event.type === "session.stop") {
|
if (event.type === "session.stop") {
|
||||||
const sessionID = props?.sessionID as string | undefined
|
const sessionID = resolveSessionEventID(props)
|
||||||
if (!sessionID) return
|
if (!sessionID) return
|
||||||
|
|
||||||
cancelledSessions.add(sessionID)
|
cancelledSessions.add(sessionID)
|
||||||
@@ -149,7 +150,7 @@ export function createUnstableAgentBabysitterHook(ctx: BabysitterContext, option
|
|||||||
|
|
||||||
if (event.type === "message.updated") {
|
if (event.type === "message.updated") {
|
||||||
const info = props?.info as Record<string, unknown> | undefined
|
const info = props?.info as Record<string, unknown> | undefined
|
||||||
const sessionID = info?.sessionID as string | undefined
|
const sessionID = resolveMessageEventSessionID(props)
|
||||||
const role = info?.role as string | undefined
|
const role = info?.role as string | undefined
|
||||||
if (!sessionID || (role !== "user" && role !== "assistant")) return
|
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") {
|
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
|
if (!sessionID) return
|
||||||
|
|
||||||
cancelledSessions.delete(sessionID)
|
cancelledSessions.delete(sessionID)
|
||||||
@@ -166,16 +167,16 @@ export function createUnstableAgentBabysitterHook(ctx: BabysitterContext, option
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (event.type === "session.deleted") {
|
if (event.type === "session.deleted") {
|
||||||
const sessionInfo = props?.info as { id?: string } | undefined
|
const sessionID = resolveSessionEventID(props)
|
||||||
if (!sessionInfo?.id) return
|
if (!sessionID) return
|
||||||
|
|
||||||
cancelledSessions.delete(sessionInfo.id)
|
cancelledSessions.delete(sessionID)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (event.type !== "session.idle") return
|
if (event.type !== "session.idle") return
|
||||||
|
|
||||||
const sessionID = props?.sessionID as string | undefined
|
const sessionID = resolveSessionEventID(props)
|
||||||
if (!sessionID) return
|
if (!sessionID) return
|
||||||
|
|
||||||
const mainSessionID = getMainSessionID()
|
const mainSessionID = getMainSessionID()
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { existsSync, realpathSync } from "fs"
|
|||||||
import { basename, dirname, isAbsolute, join, normalize, relative, resolve } from "path"
|
import { basename, dirname, isAbsolute, join, normalize, relative, resolve } from "path"
|
||||||
|
|
||||||
import { handleWriteExistingFileGuardToolExecuteBefore } from "./tool-execute-before-handler"
|
import { handleWriteExistingFileGuardToolExecuteBefore } from "./tool-execute-before-handler"
|
||||||
|
import { resolveSessionEventID } from "../../shared/event-session-id"
|
||||||
|
|
||||||
export type GuardArgs = {
|
export type GuardArgs = {
|
||||||
filePath?: string
|
filePath?: string
|
||||||
@@ -108,8 +109,7 @@ export function createWriteExistingFileGuardHook(ctx: PluginInput, options?: Wri
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = event.properties as { info?: { id?: string } } | undefined
|
const sessionID = resolveSessionEventID(event.properties)
|
||||||
const sessionID = props?.info?.id
|
|
||||||
if (!sessionID) {
|
if (!sessionID) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -802,6 +802,56 @@ describe("createEventHandler - event forwarding", () => {
|
|||||||
expect(forwardedEvents[0]?.event.type).toBe("message.part.delta")
|
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 () => {
|
it("does not forward tmux activity events when tmux integration is disabled", async () => {
|
||||||
const forwardedEvents: EventInput[] = []
|
const forwardedEvents: EventInput[] = []
|
||||||
const eventHandler = createEventHandler({
|
const eventHandler = createEventHandler({
|
||||||
|
|||||||
+44
-42
@@ -47,6 +47,7 @@ import type { CreatedHooks } from "../create-hooks";
|
|||||||
import type { Managers } from "../create-managers";
|
import type { Managers } from "../create-managers";
|
||||||
import { pruneRecentSyntheticIdles } from "./recent-synthetic-idles";
|
import { pruneRecentSyntheticIdles } from "./recent-synthetic-idles";
|
||||||
import { normalizeSessionStatusToIdle } from "./session-status-normalizer";
|
import { normalizeSessionStatusToIdle } from "./session-status-normalizer";
|
||||||
|
import { resolveMessageEventSessionID, resolveSessionEventID } from "../shared/event-session-id";
|
||||||
|
|
||||||
type FirstMessageVariantGate = {
|
type FirstMessageVariantGate = {
|
||||||
markSessionCreated: (sessionInfo: { id?: string; title?: string; parentID?: string } | undefined) => void;
|
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 getEventSessionID = (input: EventInput): string | undefined => {
|
||||||
const properties = input.event.properties;
|
const properties = input.event.properties;
|
||||||
if (
|
if (input.event.type.startsWith("session.")) {
|
||||||
!properties ||
|
return resolveSessionEventID(properties);
|
||||||
typeof properties !== "object" ||
|
|
||||||
!("sessionID" in properties) ||
|
|
||||||
typeof properties.sessionID !== "string"
|
|
||||||
) {
|
|
||||||
return undefined;
|
|
||||||
}
|
}
|
||||||
return properties.sessionID;
|
if (input.event.type.startsWith("message.") || input.event.type.startsWith("tool.")) {
|
||||||
|
return resolveMessageEventSessionID(properties);
|
||||||
|
}
|
||||||
|
const record: Record<string, unknown> | undefined = isRecord(properties) ? properties : undefined;
|
||||||
|
const sessionID = record?.sessionID;
|
||||||
|
return typeof sessionID === "string" && sessionID.length > 0 ? sessionID : undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
const runEventHookSafely = async (
|
const runEventHookSafely = async (
|
||||||
@@ -467,10 +468,11 @@ export function createEventHandler(args: {
|
|||||||
|
|
||||||
if (event.type === "session.created") {
|
if (event.type === "session.created") {
|
||||||
const sessionInfo = props?.info as { id?: string; title?: string; parentID?: string } | undefined;
|
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) {
|
if (!isSubagentSession) {
|
||||||
setMainSession(sessionInfo?.id);
|
setMainSession(sessionID);
|
||||||
}
|
}
|
||||||
|
|
||||||
firstMessageVariantGate.markSessionCreated(sessionInfo);
|
firstMessageVariantGate.markSessionCreated(sessionInfo);
|
||||||
@@ -489,62 +491,62 @@ export function createEventHandler(args: {
|
|||||||
|
|
||||||
// Skip subagent sessions — they are dispatched by specialized callbacks
|
// Skip subagent sessions — they are dispatched by specialized callbacks
|
||||||
// in create-managers.ts (async) and tool-registry.ts (sync)
|
// in create-managers.ts (async) and tool-registry.ts (sync)
|
||||||
if (pluginConfig.openclaw && sessionInfo?.id && !isSubagentSession) {
|
if (pluginConfig.openclaw && sessionID && !isSubagentSession) {
|
||||||
await dispatchOpenClawEvent({
|
await dispatchOpenClawEvent({
|
||||||
config: pluginConfig.openclaw,
|
config: pluginConfig.openclaw,
|
||||||
rawEvent: event.type,
|
rawEvent: event.type,
|
||||||
context: {
|
context: {
|
||||||
sessionId: sessionInfo.id,
|
sessionId: sessionID,
|
||||||
projectPath: pluginContext.directory,
|
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") {
|
if (event.type === "session.deleted") {
|
||||||
const sessionInfo = props?.info as { id?: string } | undefined;
|
const sessionID = resolveSessionEventID(props);
|
||||||
if (sessionInfo?.id === getMainSessionID()) {
|
if (sessionID === getMainSessionID()) {
|
||||||
setMainSession(undefined);
|
setMainSession(undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (sessionInfo?.id) {
|
if (sessionID) {
|
||||||
const wasSyncSubagentSession = syncSubagentSessions.has(sessionInfo.id);
|
const wasSyncSubagentSession = syncSubagentSessions.has(sessionID);
|
||||||
clearSessionAgent(sessionInfo.id);
|
clearSessionAgent(sessionID);
|
||||||
lastHandledModelErrorMessageID.delete(sessionInfo.id);
|
lastHandledModelErrorMessageID.delete(sessionID);
|
||||||
lastHandledRetryStatusKey.delete(sessionInfo.id);
|
lastHandledRetryStatusKey.delete(sessionID);
|
||||||
lastKnownModelBySession.delete(sessionInfo.id);
|
lastKnownModelBySession.delete(sessionID);
|
||||||
if (modelFallback) {
|
if (modelFallback) {
|
||||||
clearPendingModelFallback(modelFallback, sessionInfo.id);
|
clearPendingModelFallback(modelFallback, sessionID);
|
||||||
clearSessionFallbackChain(modelFallback, sessionInfo.id);
|
clearSessionFallbackChain(modelFallback, sessionID);
|
||||||
}
|
}
|
||||||
resetMessageCursor(sessionInfo.id);
|
resetMessageCursor(sessionID);
|
||||||
clearBackgroundOutputConsumptionsForParentSession(sessionInfo.id);
|
clearBackgroundOutputConsumptionsForParentSession(sessionID);
|
||||||
clearBackgroundOutputConsumptionsForTaskSession(sessionInfo.id);
|
clearBackgroundOutputConsumptionsForTaskSession(sessionID);
|
||||||
firstMessageVariantGate.clear(sessionInfo.id);
|
firstMessageVariantGate.clear(sessionID);
|
||||||
clearSessionModel(sessionInfo.id);
|
clearSessionModel(sessionID);
|
||||||
clearSessionPromptParams(sessionInfo.id);
|
clearSessionPromptParams(sessionID);
|
||||||
syncSubagentSessions.delete(sessionInfo.id);
|
syncSubagentSessions.delete(sessionID);
|
||||||
if (pluginConfig.openclaw) {
|
if (pluginConfig.openclaw) {
|
||||||
await dispatchOpenClawEvent({
|
await dispatchOpenClawEvent({
|
||||||
config: pluginConfig.openclaw,
|
config: pluginConfig.openclaw,
|
||||||
rawEvent: event.type,
|
rawEvent: event.type,
|
||||||
context: {
|
context: {
|
||||||
sessionId: sessionInfo.id,
|
sessionId: sessionID,
|
||||||
projectPath: pluginContext.directory,
|
projectPath: pluginContext.directory,
|
||||||
tmuxPaneId: managers.tmuxSessionManager.getTrackedPaneId?.(sessionInfo.id) ?? process.env.TMUX_PANE,
|
tmuxPaneId: managers.tmuxSessionManager.getTrackedPaneId?.(sessionID) ?? process.env.TMUX_PANE,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (wasSyncSubagentSession) {
|
if (wasSyncSubagentSession) {
|
||||||
subagentSessions.delete(sessionInfo.id);
|
subagentSessions.delete(sessionID);
|
||||||
}
|
}
|
||||||
deleteSessionTools(sessionInfo.id);
|
deleteSessionTools(sessionID);
|
||||||
await managers.skillMcpManager.disconnectSession(sessionInfo.id);
|
await managers.skillMcpManager.disconnectSession(sessionID);
|
||||||
await lspManager.cleanupTempDirectoryClients();
|
await lspManager.cleanupTempDirectoryClients();
|
||||||
if (tmuxIntegrationEnabled) {
|
if (tmuxIntegrationEnabled) {
|
||||||
await managers.tmuxSessionManager.onSessionDeleted({
|
await managers.tmuxSessionManager.onSessionDeleted({
|
||||||
sessionID: sessionInfo.id,
|
sessionID,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -555,12 +557,12 @@ export function createEventHandler(args: {
|
|||||||
|
|
||||||
if (event.type === "message.removed") {
|
if (event.type === "message.removed") {
|
||||||
const messageID = props?.messageID as string | undefined;
|
const messageID = props?.messageID as string | undefined;
|
||||||
const sessionID = props?.sessionID as string | undefined;
|
const sessionID = resolveMessageEventSessionID(props);
|
||||||
restoreBackgroundOutputConsumption(sessionID, messageID);
|
restoreBackgroundOutputConsumption(sessionID, messageID);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (event.type === "session.idle" && pluginConfig.openclaw) {
|
if (event.type === "session.idle" && pluginConfig.openclaw) {
|
||||||
const sessionID = props?.sessionID as string | undefined;
|
const sessionID = resolveSessionEventID(props);
|
||||||
if (sessionID) {
|
if (sessionID) {
|
||||||
await dispatchOpenClawEvent({
|
await dispatchOpenClawEvent({
|
||||||
config: pluginConfig.openclaw,
|
config: pluginConfig.openclaw,
|
||||||
@@ -582,7 +584,7 @@ export function createEventHandler(args: {
|
|||||||
|
|
||||||
if (event.type === "message.updated") {
|
if (event.type === "message.updated") {
|
||||||
const info = props?.info as Record<string, unknown> | undefined;
|
const info = props?.info as Record<string, unknown> | undefined;
|
||||||
const sessionID = info?.sessionID as string | undefined;
|
const sessionID = resolveMessageEventSessionID(props);
|
||||||
const agent = info?.agent as string | undefined;
|
const agent = info?.agent as string | undefined;
|
||||||
const role = info?.role as string | undefined;
|
const role = info?.role as string | undefined;
|
||||||
if (sessionID && info?.finish === true) {
|
if (sessionID && info?.finish === true) {
|
||||||
@@ -665,7 +667,7 @@ export function createEventHandler(args: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (event.type === "session.status") {
|
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;
|
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
|
// 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") {
|
if (event.type === "session.error") {
|
||||||
try {
|
try {
|
||||||
const sessionID = props?.sessionID as string | undefined;
|
const sessionID = resolveSessionEventID(props);
|
||||||
const error = props?.error;
|
const error = props?.error;
|
||||||
|
|
||||||
const errorName = extractErrorName(error);
|
const errorName = extractErrorName(error);
|
||||||
@@ -818,7 +820,7 @@ export function createEventHandler(args: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const sessionID = props?.sessionID as string | undefined;
|
const sessionID = resolveSessionEventID(props);
|
||||||
log("[event] model-fallback error in session.error:", { sessionID, error: err });
|
log("[event] model-fallback error in session.error:", { sessionID, error: err });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { resolveSessionEventID } from "../shared/event-session-id"
|
||||||
|
|
||||||
type EventInput = { event: { type: string; properties?: Record<string, unknown> } }
|
type EventInput = { event: { type: string; properties?: Record<string, unknown> } }
|
||||||
type SessionStatus = { type: string }
|
type SessionStatus = { type: string }
|
||||||
|
|
||||||
@@ -10,7 +12,7 @@ export function normalizeSessionStatusToIdle(input: EventInput): EventInput | nu
|
|||||||
const status = props.status as SessionStatus | undefined
|
const status = props.status as SessionStatus | undefined
|
||||||
if (!status || status.type !== "idle") return null
|
if (!status || status.type !== "idle") return null
|
||||||
|
|
||||||
const sessionID = props.sessionID as string | undefined
|
const sessionID = resolveSessionEventID(props)
|
||||||
if (!sessionID) return null
|
if (!sessionID) return null
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { chmodSync, existsSync, mkdirSync, unlinkSync } from "node:fs";
|
import { chmodSync, existsSync, mkdirSync, unlinkSync } from "node:fs";
|
||||||
import * as path from "node:path";
|
import * as path from "node:path";
|
||||||
import { spawn } from "./bun-spawn-shim";
|
import { spawn } from "./bun-spawn-shim";
|
||||||
|
import { bunWrite } from "./bun-file-shim";
|
||||||
import { validateArchiveEntries, type ArchiveEntry } from "./archive-entry-validator";
|
import { validateArchiveEntries, type ArchiveEntry } from "./archive-entry-validator";
|
||||||
import { extractZip } from "./zip-extractor";
|
import { extractZip } from "./zip-extractor";
|
||||||
|
|
||||||
@@ -26,7 +27,7 @@ export async function downloadArchive(downloadUrl: string, archivePath: string):
|
|||||||
}
|
}
|
||||||
|
|
||||||
const arrayBuffer = await response.arrayBuffer();
|
const arrayBuffer = await response.arrayBuffer();
|
||||||
await Bun.write(archivePath, arrayBuffer);
|
await bunWrite(archivePath, arrayBuffer);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function extractTarGz(
|
export async function extractTarGz(
|
||||||
|
|||||||
@@ -0,0 +1,300 @@
|
|||||||
|
/// <reference path="../../bun-test.d.ts" />
|
||||||
|
|
||||||
|
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<string>
|
||||||
|
arrayBuffer(): Promise<ArrayBuffer>
|
||||||
|
exists(): Promise<boolean>
|
||||||
|
delete(): Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
type NodeFallbackBunFile = (path: string) => NodeFallbackBunFileLike
|
||||||
|
type NodeFallbackBunWrite = (path: string, data: string | ArrayBuffer | Uint8Array) => Promise<number>
|
||||||
|
|
||||||
|
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<number> {"
|
||||||
|
|
||||||
|
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<number> {",
|
||||||
|
)
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { access, readFile, unlink, writeFile } from "node:fs/promises"
|
||||||
|
|
||||||
|
export interface BunFileLike {
|
||||||
|
text(): Promise<string>
|
||||||
|
arrayBuffer(): Promise<ArrayBuffer>
|
||||||
|
exists(): Promise<boolean>
|
||||||
|
delete(): Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
type BunFileRuntime = {
|
||||||
|
file(path: string): BunFileLike
|
||||||
|
write(path: string, data: string | ArrayBuffer | Uint8Array): Promise<number>
|
||||||
|
}
|
||||||
|
|
||||||
|
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<number> {
|
||||||
|
if (IS_BUN) return runtime.Bun!.write(path, data)
|
||||||
|
|
||||||
|
await writeFile(path, toWritableData(data))
|
||||||
|
|
||||||
|
return byteLength(data)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -1,9 +1,58 @@
|
|||||||
|
/// <reference types="bun-types" />
|
||||||
|
|
||||||
import { existsSync } from "node:fs"
|
import { existsSync } from "node:fs"
|
||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
|
|
||||||
const DIST_INDEX = "dist/index.js"
|
const DIST_INDEX = "dist/index.js"
|
||||||
const GLOBAL_BUN_DESTRUCTURE = /^\s*(?:var|let|const)\s*\{[^}]*\}\s*=\s*globalThis\.Bun/gm
|
const GLOBAL_BUN_DESTRUCTURE = /^\s*(?:var|let|const)\s*\{[^}]*\}\s*=\s*globalThis\.Bun/gm
|
||||||
const TOP_LEVEL_REQUIRE_CALL = "__require("
|
const TOP_LEVEL_REQUIRE_CALL = "__require("
|
||||||
|
const RAW_BUN_API_CALL = /(?<![.$\w])Bun\.[a-zA-Z_$][a-zA-Z_$0-9]*\s*[.(]/g
|
||||||
|
const NODE_EXPORT_SMOKE_SCRIPT = [
|
||||||
|
"const mod = await import('./dist/index.js');",
|
||||||
|
"const keys = Object.keys(mod).join(',');",
|
||||||
|
"console.log('SMOKE_OK:' + keys);",
|
||||||
|
].join("\n")
|
||||||
|
|
||||||
|
function hasRawBunApiCall(line: string): boolean {
|
||||||
|
RAW_BUN_API_CALL.lastIndex = 0
|
||||||
|
return RAW_BUN_API_CALL.test(line)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isInsideStringLiteral(line: string, position: number): boolean {
|
||||||
|
let quote: "'" | '"' | "`" | null = null
|
||||||
|
let escaped = false
|
||||||
|
|
||||||
|
for (let index = 0; index < position; index += 1) {
|
||||||
|
const char = line.charAt(index)
|
||||||
|
|
||||||
|
if (escaped) {
|
||||||
|
escaped = false
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (quote !== null && char === "\\") {
|
||||||
|
escaped = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (char === "'" || char === '"' || char === "`") {
|
||||||
|
if (quote === char) {
|
||||||
|
quote = null
|
||||||
|
} else if (quote === null) {
|
||||||
|
quote = char
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return quote !== null
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatOffendingLine(lineNumber: number, line: string): string {
|
||||||
|
const content = line.trim()
|
||||||
|
const truncated = content.length > 120 ? `${content.slice(0, 117)}...` : content
|
||||||
|
|
||||||
|
return `${lineNumber}: ${truncated}`
|
||||||
|
}
|
||||||
|
|
||||||
describe("dist bundle Bun globals", () => {
|
describe("dist bundle Bun globals", () => {
|
||||||
test.skipIf(!existsSync(DIST_INDEX))("#given dist bundle #when scanned #then no globalThis.Bun destructures remain", async () => {
|
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: "",
|
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")
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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")
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { isRecord } from "./record-type-guard"
|
||||||
|
|
||||||
|
function getStringField(record: Record<string, unknown> | 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")
|
||||||
|
}
|
||||||
@@ -54,6 +54,7 @@ export * from "./fallback-model-availability"
|
|||||||
export * from "./connected-providers-cache"
|
export * from "./connected-providers-cache"
|
||||||
export * from "./context-limit-resolver"
|
export * from "./context-limit-resolver"
|
||||||
export * from "./session-utils"
|
export * from "./session-utils"
|
||||||
|
export * from "./event-session-id"
|
||||||
export * from "./tmux"
|
export * from "./tmux"
|
||||||
export * from "./model-suggestion-retry"
|
export * from "./model-suggestion-retry"
|
||||||
export * from "./opencode-server-auth"
|
export * from "./opencode-server-auth"
|
||||||
|
|||||||
+376
-274
@@ -1,291 +1,393 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it, spyOn } from "bun:test"
|
import { createServer, Server } from "node:net"
|
||||||
import {
|
import type { AddressInfo } from "node:net"
|
||||||
isPortAvailable,
|
import { networkInterfaces } from "node:os"
|
||||||
findAvailablePort,
|
|
||||||
getAvailableServerPort,
|
|
||||||
DEFAULT_SERVER_PORT,
|
|
||||||
} from "./port-utils"
|
|
||||||
|
|
||||||
const HOSTNAME = "127.0.0.1"
|
import { afterAll, afterEach, beforeAll, describe, expect, test } from "bun:test"
|
||||||
const REAL_PORT_SEARCH_WINDOW = 200
|
|
||||||
|
|
||||||
function supportsRealSocketBinding(): boolean {
|
import { DEFAULT_SERVER_PORT, findAvailablePort, getAvailableServerPort, isPortAvailable } from "./port-utils"
|
||||||
try {
|
|
||||||
const server = Bun.serve({
|
const DEFAULT_HOSTNAME = "127.0.0.1"
|
||||||
port: 0,
|
const MAX_PORT_ATTEMPTS = 20
|
||||||
hostname: HOSTNAME,
|
const EXHAUSTED_PORT_COUNT = MAX_PORT_ATTEMPTS + 1
|
||||||
fetch: () => new Response("probe"),
|
const CONTIGUOUS_SEARCH_WINDOW = 256
|
||||||
|
const CONTIGUOUS_SEARCH_SEEDS = 8
|
||||||
|
|
||||||
|
const trackedServers = new Set<Server>()
|
||||||
|
|
||||||
|
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<Server["address"]>): 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<Server> {
|
||||||
|
return new Promise<Server>((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<void> {
|
||||||
|
trackedServers.delete(server)
|
||||||
|
|
||||||
|
if (!server.listening) {
|
||||||
|
return Promise.resolve()
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise<void>((resolve, reject) => {
|
||||||
|
server.close((error?: Error) => {
|
||||||
|
if (error) {
|
||||||
|
reject(error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve()
|
||||||
})
|
})
|
||||||
server.stop(true)
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function closeAllTrackedServers(): Promise<void> {
|
||||||
|
await Promise.all(Array.from(trackedServers).map((server) => closeTrackedServer(server)))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getReleasedPort(hostname: string = DEFAULT_HOSTNAME): Promise<number> {
|
||||||
|
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<boolean> {
|
||||||
|
const servers: Server[] = []
|
||||||
|
|
||||||
|
try {
|
||||||
|
for (let offset = 0; offset < portCount; offset++) {
|
||||||
|
servers.push(await startTrackedServer(startPort + offset, hostname))
|
||||||
|
}
|
||||||
|
|
||||||
return true
|
return true
|
||||||
} catch {
|
} catch {
|
||||||
return false
|
return false
|
||||||
|
} finally {
|
||||||
|
await Promise.all(servers.map((server) => closeTrackedServer(server)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const canBindRealSockets = supportsRealSocketBinding()
|
async function findContiguousAvailableStart(
|
||||||
|
portCount: number,
|
||||||
|
hostname: string = DEFAULT_HOSTNAME
|
||||||
|
): Promise<number> {
|
||||||
|
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", () => {
|
for (let candidatePort = seedPort; candidatePort <= maxStartPort; candidatePort++) {
|
||||||
if (canBindRealSockets) {
|
if (await canBindContiguousPorts(candidatePort, portCount, hostname)) {
|
||||||
function startRealBlocker(port: number = 0) {
|
return candidatePort
|
||||||
return Bun.serve({
|
|
||||||
port,
|
|
||||||
hostname: HOSTNAME,
|
|
||||||
fetch: () => new Response("blocked"),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async function findContiguousAvailableStart(length: number): Promise<number> {
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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<string>()
|
|
||||||
let serveSpy: ReturnType<typeof spyOn>
|
|
||||||
|
|
||||||
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", () => {
|
throw new Error(`Could not find ${portCount} contiguous available ports`)
|
||||||
it("#given constant #when accessed #then returns 4096", () => {
|
}
|
||||||
|
|
||||||
|
async function startConsecutiveBlockers(
|
||||||
|
startPort: number,
|
||||||
|
portCount: number,
|
||||||
|
hostname: string = DEFAULT_HOSTNAME
|
||||||
|
): Promise<Server[]> {
|
||||||
|
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<string | undefined> {
|
||||||
|
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<TimeoutProbeResult> {
|
||||||
|
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<typeof setTimeout> => 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)
|
expect(DEFAULT_SERVER_PORT).toBe(4096)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
+45
-10
@@ -1,18 +1,53 @@
|
|||||||
|
import { createServer } from "node:net"
|
||||||
|
|
||||||
const DEFAULT_SERVER_PORT = 4096
|
const DEFAULT_SERVER_PORT = 4096
|
||||||
const MAX_PORT_ATTEMPTS = 20
|
const MAX_PORT_ATTEMPTS = 20
|
||||||
|
const PORT_CHECK_TIMEOUT_MS = 2000
|
||||||
|
|
||||||
export async function isPortAvailable(port: number, hostname: string = "127.0.0.1"): Promise<boolean> {
|
export async function isPortAvailable(port: number, hostname: string = "127.0.0.1"): Promise<boolean> {
|
||||||
try {
|
return new Promise<boolean>((resolve) => {
|
||||||
const server = Bun.serve({
|
const server = createServer()
|
||||||
port,
|
let timeoutId: ReturnType<typeof setTimeout> | undefined
|
||||||
hostname,
|
let resolved = false
|
||||||
fetch: () => new Response(),
|
|
||||||
|
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)
|
server.once("listening", () => {
|
||||||
return true
|
closeThenFinish(true)
|
||||||
} catch {
|
})
|
||||||
return false
|
|
||||||
}
|
try {
|
||||||
|
server.listen(port, hostname)
|
||||||
|
} catch {
|
||||||
|
finish(false)
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function findAvailablePort(
|
export async function findAvailablePort(
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import { HASHLINE_DICT } from "./constants"
|
import { HASHLINE_DICT } from "./constants"
|
||||||
import { createHashlineChunkFormatter } from "./hashline-chunk-formatter"
|
import { createHashlineChunkFormatter } from "./hashline-chunk-formatter"
|
||||||
|
import { bunHashXxh32 } from "../../shared/bun-hash-shim"
|
||||||
|
|
||||||
const RE_SIGNIFICANT = /[\p{L}\p{N}]/u
|
const RE_SIGNIFICANT = /[\p{L}\p{N}]/u
|
||||||
|
|
||||||
function computeNormalizedLineHash(lineNumber: number, normalizedContent: string): string {
|
function computeNormalizedLineHash(lineNumber: number, normalizedContent: string): string {
|
||||||
const stripped = normalizedContent
|
const stripped = normalizedContent
|
||||||
const seed = RE_SIGNIFICANT.test(stripped) ? 0 : lineNumber
|
const seed = RE_SIGNIFICANT.test(stripped) ? 0 : lineNumber
|
||||||
const hash = Bun.hash.xxHash32(stripped, seed)
|
const hash = bunHashXxh32(stripped, seed)
|
||||||
const index = hash % 256
|
const index = hash % 256
|
||||||
return HASHLINE_DICT[index]
|
return HASHLINE_DICT[index]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { ToolContext } from "@opencode-ai/plugin/tool"
|
import type { ToolContext } from "@opencode-ai/plugin/tool"
|
||||||
import { publishToolMetadata } from "../../features/tool-metadata-store"
|
import { publishToolMetadata } from "../../features/tool-metadata-store"
|
||||||
|
import { bunFile, bunWrite } from "../../shared/bun-file-shim"
|
||||||
import { applyHashlineEditsWithReport } from "./edit-operations"
|
import { applyHashlineEditsWithReport } from "./edit-operations"
|
||||||
import { countLineDiffs, generateUnifiedDiff } from "./diff-utils"
|
import { countLineDiffs, generateUnifiedDiff } from "./diff-utils"
|
||||||
import { canonicalizeFileText, restoreFileText } from "./file-text-canonicalization"
|
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 edits = deleteMode ? [] : normalizeHashlineEdits(args.edits)
|
||||||
|
|
||||||
const file = Bun.file(filePath)
|
const file = bunFile(filePath)
|
||||||
const exists = await file.exists()
|
const exists = await file.exists()
|
||||||
if (!exists && !deleteMode && !canCreateFromMissingFile(edits)) {
|
if (!exists && !deleteMode && !canCreateFromMissingFile(edits)) {
|
||||||
return `Error: File not found: ${filePath}`
|
return `Error: File not found: ${filePath}`
|
||||||
@@ -102,7 +103,7 @@ export async function executeHashlineEditTool(args: HashlineEditArgs, context: T
|
|||||||
|
|
||||||
if (deleteMode) {
|
if (deleteMode) {
|
||||||
if (!exists) return `Error: File not found: ${filePath}`
|
if (!exists) return `Error: File not found: ${filePath}`
|
||||||
await Bun.file(filePath).delete()
|
await bunFile(filePath).delete()
|
||||||
return `Successfully deleted ${filePath}`
|
return `Successfully deleted ${filePath}`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,11 +123,11 @@ export async function executeHashlineEditTool(args: HashlineEditArgs, context: T
|
|||||||
|
|
||||||
const writeContent = restoreFileText(canonicalNewContent, oldEnvelope)
|
const writeContent = restoreFileText(canonicalNewContent, oldEnvelope)
|
||||||
|
|
||||||
await Bun.write(filePath, writeContent)
|
await bunWrite(filePath, writeContent)
|
||||||
|
|
||||||
if (pluginCtx?.client) {
|
if (pluginCtx?.client) {
|
||||||
await runFormattersForFile(pluginCtx.client as FormatterClient, context.directory, filePath)
|
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) {
|
if (formattedContent !== writeContent) {
|
||||||
const formattedEnvelope = canonicalizeFileText(formattedContent)
|
const formattedEnvelope = canonicalizeFileText(formattedContent)
|
||||||
const formattedMeta = buildSuccessMeta(
|
const formattedMeta = buildSuccessMeta(
|
||||||
@@ -138,8 +139,8 @@ export async function executeHashlineEditTool(args: HashlineEditArgs, context: T
|
|||||||
)
|
)
|
||||||
await publishToolMetadata(metadataContext, formattedMeta)
|
await publishToolMetadata(metadataContext, formattedMeta)
|
||||||
if (rename && rename !== filePath) {
|
if (rename && rename !== filePath) {
|
||||||
await Bun.write(rename, formattedContent)
|
await bunWrite(rename, formattedContent)
|
||||||
await Bun.file(filePath).delete()
|
await bunFile(filePath).delete()
|
||||||
return `Moved ${filePath} to ${rename}`
|
return `Moved ${filePath} to ${rename}`
|
||||||
}
|
}
|
||||||
return `Updated ${filePath}`
|
return `Updated ${filePath}`
|
||||||
@@ -147,8 +148,8 @@ export async function executeHashlineEditTool(args: HashlineEditArgs, context: T
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (rename && rename !== filePath) {
|
if (rename && rename !== filePath) {
|
||||||
await Bun.write(rename, writeContent)
|
await bunWrite(rename, writeContent)
|
||||||
await Bun.file(filePath).delete()
|
await bunFile(filePath).delete()
|
||||||
}
|
}
|
||||||
|
|
||||||
const effectivePath = rename && rename !== filePath ? rename : filePath
|
const effectivePath = rename && rename !== filePath ? rename : filePath
|
||||||
|
|||||||
Reference in New Issue
Block a user