fix(claude-code-hooks): cache idle hook config and parent lookups
Reduce repeated session.idle work by reusing hook config loads across a short TTL and by retrying parent session lookup instead of permanently caching transient failures. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
@@ -0,0 +1,110 @@
|
|||||||
|
const { afterEach, beforeEach, describe, expect, mock, test } = require("bun:test")
|
||||||
|
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"
|
||||||
|
import { tmpdir } from "node:os"
|
||||||
|
import { join } from "node:path"
|
||||||
|
import { getOpenCodeConfigDir } from "../../shared"
|
||||||
|
|
||||||
|
const { clearPluginExtendedConfigCache, loadPluginExtendedConfig } = await import("./config-loader")
|
||||||
|
|
||||||
|
describe("loadPluginExtendedConfig", () => {
|
||||||
|
const originalDateNow = Date.now
|
||||||
|
let originalWorkingDirectory = ""
|
||||||
|
let tempDirectory = ""
|
||||||
|
let userConfigPath = ""
|
||||||
|
let projectConfigPath = ""
|
||||||
|
let originalUserConfig: string | null = null
|
||||||
|
let mockedNow = 0
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
//#given
|
||||||
|
originalWorkingDirectory = process.cwd()
|
||||||
|
tempDirectory = mkdtempSync(join(tmpdir(), "omo-cc-plugin-project-config-"))
|
||||||
|
userConfigPath = join(getOpenCodeConfigDir({ binary: "opencode" }), "opencode-cc-plugin.json")
|
||||||
|
projectConfigPath = join(tempDirectory, ".opencode", "opencode-cc-plugin.json")
|
||||||
|
mkdirSync(getOpenCodeConfigDir({ binary: "opencode" }), { recursive: true })
|
||||||
|
mkdirSync(join(tempDirectory, ".opencode"), { recursive: true })
|
||||||
|
originalUserConfig = existsSync(userConfigPath)
|
||||||
|
? readFileSync(userConfigPath, "utf8")
|
||||||
|
: null
|
||||||
|
process.chdir(tempDirectory)
|
||||||
|
mockedNow = 1_000
|
||||||
|
Date.now = () => mockedNow
|
||||||
|
clearPluginExtendedConfigCache()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
clearPluginExtendedConfigCache()
|
||||||
|
Date.now = originalDateNow
|
||||||
|
process.chdir(originalWorkingDirectory)
|
||||||
|
rmSync(tempDirectory, { recursive: true, force: true })
|
||||||
|
if (originalUserConfig === null) {
|
||||||
|
rmSync(userConfigPath, { force: true })
|
||||||
|
} else {
|
||||||
|
writeFileSync(userConfigPath, originalUserConfig)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test("#given cached extended config #when files change within ttl #then cached config is reused", async () => {
|
||||||
|
//#given
|
||||||
|
writeConfigFile(userConfigPath, ["user-first"])
|
||||||
|
writeConfigFile(projectConfigPath, ["project-first"])
|
||||||
|
|
||||||
|
//#when
|
||||||
|
const firstResult = await loadPluginExtendedConfig()
|
||||||
|
writeConfigFile(userConfigPath, ["user-second"])
|
||||||
|
writeConfigFile(projectConfigPath, ["project-second"])
|
||||||
|
mockedNow += 5_000
|
||||||
|
const secondResult = await loadPluginExtendedConfig()
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(firstResult).toEqual({
|
||||||
|
disabledHooks: {
|
||||||
|
Stop: ["project-first"],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
expect(secondResult).toEqual(firstResult)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("#given cached extended config #when ttl expires or cache clears #then updated config is reloaded", async () => {
|
||||||
|
//#given
|
||||||
|
writeConfigFile(userConfigPath, ["user-first"])
|
||||||
|
writeConfigFile(projectConfigPath, ["project-first"])
|
||||||
|
await loadPluginExtendedConfig()
|
||||||
|
|
||||||
|
//#when
|
||||||
|
writeConfigFile(userConfigPath, ["user-second"])
|
||||||
|
writeConfigFile(projectConfigPath, ["project-second"])
|
||||||
|
mockedNow += 31_000
|
||||||
|
const ttlReloaded = await loadPluginExtendedConfig()
|
||||||
|
|
||||||
|
writeConfigFile(userConfigPath, ["user-third"])
|
||||||
|
writeConfigFile(projectConfigPath, ["project-third"])
|
||||||
|
clearPluginExtendedConfigCache()
|
||||||
|
const manuallyReloaded = await loadPluginExtendedConfig()
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(ttlReloaded).toEqual({
|
||||||
|
disabledHooks: {
|
||||||
|
Stop: ["project-second"],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
expect(manuallyReloaded).toEqual({
|
||||||
|
disabledHooks: {
|
||||||
|
Stop: ["project-third"],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
function writeConfigFile(filePath: string, stopPatterns: string[]): void {
|
||||||
|
writeFileSync(
|
||||||
|
filePath,
|
||||||
|
JSON.stringify({
|
||||||
|
disabledHooks: {
|
||||||
|
Stop: stopPatterns,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {}
|
||||||
@@ -4,6 +4,8 @@ import type { ClaudeHookEvent } from "./types"
|
|||||||
import { log } from "../../shared/logger"
|
import { log } from "../../shared/logger"
|
||||||
import { getOpenCodeConfigDir } from "../../shared"
|
import { getOpenCodeConfigDir } from "../../shared"
|
||||||
|
|
||||||
|
const CONFIG_CACHE_TTL_MS = 30_000
|
||||||
|
|
||||||
export interface DisabledHooksConfig {
|
export interface DisabledHooksConfig {
|
||||||
Stop?: string[]
|
Stop?: string[]
|
||||||
PreToolUse?: string[]
|
PreToolUse?: string[]
|
||||||
@@ -16,12 +18,40 @@ export interface PluginExtendedConfig {
|
|||||||
disabledHooks?: DisabledHooksConfig
|
disabledHooks?: DisabledHooksConfig
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface PluginExtendedConfigCacheEntry {
|
||||||
|
value: PluginExtendedConfig
|
||||||
|
cachedAt: number
|
||||||
|
}
|
||||||
|
|
||||||
const USER_CONFIG_PATH = join(getOpenCodeConfigDir({ binary: "opencode" }), "opencode-cc-plugin.json")
|
const USER_CONFIG_PATH = join(getOpenCodeConfigDir({ binary: "opencode" }), "opencode-cc-plugin.json")
|
||||||
|
const configCache = new Map<string, PluginExtendedConfigCacheEntry>()
|
||||||
|
|
||||||
function getProjectConfigPath(): string {
|
function getProjectConfigPath(): string {
|
||||||
return join(process.cwd(), ".opencode", "opencode-cc-plugin.json")
|
return join(process.cwd(), ".opencode", "opencode-cc-plugin.json")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getCacheKey(): string {
|
||||||
|
return process.cwd()
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCachedConfig(cacheKey: string): PluginExtendedConfig | undefined {
|
||||||
|
const cachedEntry = configCache.get(cacheKey)
|
||||||
|
if (!cachedEntry) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Date.now() - cachedEntry.cachedAt >= CONFIG_CACHE_TTL_MS) {
|
||||||
|
configCache.delete(cacheKey)
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
return cachedEntry.value
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearPluginExtendedConfigCache(): void {
|
||||||
|
configCache.clear()
|
||||||
|
}
|
||||||
|
|
||||||
async function loadConfigFromPath(path: string): Promise<PluginExtendedConfig | null> {
|
async function loadConfigFromPath(path: string): Promise<PluginExtendedConfig | null> {
|
||||||
if (!existsSync(path)) {
|
if (!existsSync(path)) {
|
||||||
return null
|
return null
|
||||||
@@ -53,6 +83,12 @@ function mergeDisabledHooks(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function loadPluginExtendedConfig(): Promise<PluginExtendedConfig> {
|
export async function loadPluginExtendedConfig(): Promise<PluginExtendedConfig> {
|
||||||
|
const cacheKey = getCacheKey()
|
||||||
|
const cachedConfig = getCachedConfig(cacheKey)
|
||||||
|
if (cachedConfig) {
|
||||||
|
return cachedConfig
|
||||||
|
}
|
||||||
|
|
||||||
const userConfig = await loadConfigFromPath(USER_CONFIG_PATH)
|
const userConfig = await loadConfigFromPath(USER_CONFIG_PATH)
|
||||||
const projectConfig = await loadConfigFromPath(getProjectConfigPath())
|
const projectConfig = await loadConfigFromPath(getProjectConfigPath())
|
||||||
|
|
||||||
@@ -71,6 +107,11 @@ export async function loadPluginExtendedConfig(): Promise<PluginExtendedConfig>
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
configCache.set(cacheKey, {
|
||||||
|
value: merged,
|
||||||
|
cachedAt: Date.now(),
|
||||||
|
})
|
||||||
|
|
||||||
return merged
|
return merged
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
const { afterEach, beforeEach, describe, expect, mock, test } = require("bun:test")
|
||||||
|
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"
|
||||||
|
import { tmpdir } from "node:os"
|
||||||
|
import { join } from "node:path"
|
||||||
|
|
||||||
|
const { clearClaudeHooksConfigCache, loadClaudeHooksConfig } = await import("./config")
|
||||||
|
|
||||||
|
describe("loadClaudeHooksConfig", () => {
|
||||||
|
const originalDateNow = Date.now
|
||||||
|
let originalWorkingDirectory = ""
|
||||||
|
let tempDirectory = ""
|
||||||
|
let customSettingsPath = ""
|
||||||
|
let mockedNow = 0
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
//#given
|
||||||
|
originalWorkingDirectory = process.cwd()
|
||||||
|
tempDirectory = mkdtempSync(join(tmpdir(), "omo-claude-hooks-config-"))
|
||||||
|
customSettingsPath = join(tempDirectory, "custom-settings.json")
|
||||||
|
mkdirSync(join(tempDirectory, ".claude"), { recursive: true })
|
||||||
|
process.chdir(tempDirectory)
|
||||||
|
mockedNow = 1_000
|
||||||
|
Date.now = () => mockedNow
|
||||||
|
clearClaudeHooksConfigCache()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
clearClaudeHooksConfigCache()
|
||||||
|
Date.now = originalDateNow
|
||||||
|
process.chdir(originalWorkingDirectory)
|
||||||
|
rmSync(tempDirectory, { recursive: true, force: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
test("#given cached hook config #when file changes within ttl #then cached value is reused", async () => {
|
||||||
|
//#given
|
||||||
|
writeSettingsFile(customSettingsPath, "first-stop-command")
|
||||||
|
|
||||||
|
//#when
|
||||||
|
const firstResult = await loadClaudeHooksConfig(customSettingsPath)
|
||||||
|
writeSettingsFile(customSettingsPath, "second-stop-command")
|
||||||
|
mockedNow += 5_000
|
||||||
|
const secondResult = await loadClaudeHooksConfig(customSettingsPath)
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(getStopCommands(firstResult)).toContain("first-stop-command")
|
||||||
|
expect(getStopCommands(secondResult)).toContain("first-stop-command")
|
||||||
|
expect(getStopCommands(secondResult)).not.toContain("second-stop-command")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("#given cached hook config #when ttl expires or cache clears #then updated file contents are reloaded", async () => {
|
||||||
|
//#given
|
||||||
|
writeSettingsFile(customSettingsPath, "first-stop-command")
|
||||||
|
await loadClaudeHooksConfig(customSettingsPath)
|
||||||
|
|
||||||
|
//#when
|
||||||
|
writeSettingsFile(customSettingsPath, "second-stop-command")
|
||||||
|
mockedNow += 31_000
|
||||||
|
const ttlReloaded = await loadClaudeHooksConfig(customSettingsPath)
|
||||||
|
|
||||||
|
writeSettingsFile(customSettingsPath, "third-stop-command")
|
||||||
|
clearClaudeHooksConfigCache()
|
||||||
|
const manuallyReloaded = await loadClaudeHooksConfig(customSettingsPath)
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(getStopCommands(ttlReloaded)).toContain("second-stop-command")
|
||||||
|
expect(getStopCommands(ttlReloaded)).not.toContain("first-stop-command")
|
||||||
|
expect(getStopCommands(manuallyReloaded)).toContain("third-stop-command")
|
||||||
|
expect(getStopCommands(manuallyReloaded)).not.toContain("second-stop-command")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
function writeSettingsFile(filePath: string, command: string): void {
|
||||||
|
writeFileSync(
|
||||||
|
filePath,
|
||||||
|
JSON.stringify({
|
||||||
|
hooks: {
|
||||||
|
Stop: [
|
||||||
|
{
|
||||||
|
matcher: "*",
|
||||||
|
hooks: [{ command }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStopCommands(config: Awaited<ReturnType<typeof loadClaudeHooksConfig>>): string[] {
|
||||||
|
return (config?.Stop ?? []).flatMap((matcher) =>
|
||||||
|
matcher.hooks.flatMap((hook) =>
|
||||||
|
"command" in hook && typeof hook.command === "string" ? [hook.command] : [],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {}
|
||||||
@@ -3,6 +3,15 @@ import { existsSync } from "fs"
|
|||||||
import { getClaudeConfigDir } from "../../shared"
|
import { getClaudeConfigDir } from "../../shared"
|
||||||
import type { ClaudeHooksConfig, HookMatcher, HookAction } from "./types"
|
import type { ClaudeHooksConfig, HookMatcher, HookAction } from "./types"
|
||||||
|
|
||||||
|
const CONFIG_CACHE_TTL_MS = 30_000
|
||||||
|
|
||||||
|
interface ClaudeHooksConfigCacheEntry {
|
||||||
|
value: ClaudeHooksConfig | null
|
||||||
|
cachedAt: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const configCache = new Map<string, ClaudeHooksConfigCacheEntry>()
|
||||||
|
|
||||||
interface RawHookMatcher {
|
interface RawHookMatcher {
|
||||||
matcher?: string
|
matcher?: string
|
||||||
pattern?: string
|
pattern?: string
|
||||||
@@ -60,6 +69,28 @@ export function getClaudeSettingsPaths(customPath?: string): string[] {
|
|||||||
return [...new Set(paths)]
|
return [...new Set(paths)]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getCacheKey(customSettingsPath?: string): string {
|
||||||
|
return `${process.cwd()}::${customSettingsPath ?? ""}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCachedConfig(cacheKey: string): ClaudeHooksConfig | null | undefined {
|
||||||
|
const cachedEntry = configCache.get(cacheKey)
|
||||||
|
if (!cachedEntry) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Date.now() - cachedEntry.cachedAt >= CONFIG_CACHE_TTL_MS) {
|
||||||
|
configCache.delete(cacheKey)
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
return cachedEntry.value
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearClaudeHooksConfigCache(): void {
|
||||||
|
configCache.clear()
|
||||||
|
}
|
||||||
|
|
||||||
function mergeHooksConfig(
|
function mergeHooksConfig(
|
||||||
base: ClaudeHooksConfig,
|
base: ClaudeHooksConfig,
|
||||||
override: ClaudeHooksConfig
|
override: ClaudeHooksConfig
|
||||||
@@ -83,6 +114,12 @@ function mergeHooksConfig(
|
|||||||
export async function loadClaudeHooksConfig(
|
export async function loadClaudeHooksConfig(
|
||||||
customSettingsPath?: string
|
customSettingsPath?: string
|
||||||
): Promise<ClaudeHooksConfig | null> {
|
): Promise<ClaudeHooksConfig | null> {
|
||||||
|
const cacheKey = getCacheKey(customSettingsPath)
|
||||||
|
const cachedConfig = getCachedConfig(cacheKey)
|
||||||
|
if (cachedConfig !== undefined) {
|
||||||
|
return cachedConfig
|
||||||
|
}
|
||||||
|
|
||||||
const paths = getClaudeSettingsPaths(customSettingsPath)
|
const paths = getClaudeSettingsPaths(customSettingsPath)
|
||||||
let mergedConfig: ClaudeHooksConfig = {}
|
let mergedConfig: ClaudeHooksConfig = {}
|
||||||
|
|
||||||
@@ -101,5 +138,10 @@ export async function loadClaudeHooksConfig(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return Object.keys(mergedConfig).length > 0 ? mergedConfig : null
|
const resolvedConfig = Object.keys(mergedConfig).length > 0 ? mergedConfig : null
|
||||||
|
configCache.set(cacheKey, {
|
||||||
|
value: resolvedConfig,
|
||||||
|
cachedAt: Date.now(),
|
||||||
|
})
|
||||||
|
return resolvedConfig
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
const { beforeEach, describe, expect, mock, test } = require("bun:test")
|
||||||
|
|
||||||
|
const executeStopHooks = mock(async (context: { parentSessionId?: string }) => ({
|
||||||
|
block: false,
|
||||||
|
observedParentSessionId: context.parentSessionId,
|
||||||
|
}))
|
||||||
|
|
||||||
|
mock.module("../config", () => ({
|
||||||
|
clearClaudeHooksConfigCache: () => {},
|
||||||
|
loadClaudeHooksConfig: async () => null,
|
||||||
|
}))
|
||||||
|
|
||||||
|
mock.module("../config-loader", () => ({
|
||||||
|
clearPluginExtendedConfigCache: () => {},
|
||||||
|
loadPluginExtendedConfig: async () => ({}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
mock.module("../stop", () => ({
|
||||||
|
executeStopHooks,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const { createSessionEventHandler } = await import("./session-event-handler")
|
||||||
|
|
||||||
|
describe("createSessionEventHandler retry behavior", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
executeStopHooks.mockClear()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("#given transient parent lookup failure #when the next idle succeeds #then stop hooks receive the later parent session id", async () => {
|
||||||
|
//#given
|
||||||
|
let getCallCount = 0
|
||||||
|
const handler = createSessionEventHandler(
|
||||||
|
{
|
||||||
|
directory: "/repo",
|
||||||
|
client: {
|
||||||
|
session: {
|
||||||
|
get: async () => {
|
||||||
|
getCallCount += 1
|
||||||
|
if (getCallCount === 1) {
|
||||||
|
throw new Error("temporary failure")
|
||||||
|
}
|
||||||
|
return { data: { parentID: "ses_parent" } }
|
||||||
|
},
|
||||||
|
prompt: async () => undefined,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as never,
|
||||||
|
{},
|
||||||
|
)
|
||||||
|
|
||||||
|
//#when
|
||||||
|
await handler({ event: { type: "session.idle", properties: { sessionID: "ses_retry" } } })
|
||||||
|
await handler({ event: { type: "session.idle", properties: { sessionID: "ses_retry" } } })
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(getCallCount).toBe(2)
|
||||||
|
expect(executeStopHooks).toHaveBeenLastCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
parentSessionId: "ses_parent",
|
||||||
|
}),
|
||||||
|
null,
|
||||||
|
{},
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
export {}
|
||||||
@@ -70,4 +70,70 @@ describe("createSessionEventHandler", () => {
|
|||||||
|
|
||||||
stopToolInputCacheCleanup()
|
stopToolInputCacheCleanup()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("#given repeated idle events for one session #when stop hook preparation runs #then parent session lookup is reused", async () => {
|
||||||
|
//#given
|
||||||
|
let getCallCount = 0
|
||||||
|
const handler = createSessionEventHandler(
|
||||||
|
{
|
||||||
|
client: {
|
||||||
|
session: {
|
||||||
|
get: async () => {
|
||||||
|
getCallCount += 1
|
||||||
|
return { data: { parentID: "ses_parent" } }
|
||||||
|
},
|
||||||
|
prompt: async () => undefined,
|
||||||
|
messages: async () => ({ data: [] }),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as never,
|
||||||
|
{},
|
||||||
|
)
|
||||||
|
|
||||||
|
//#when
|
||||||
|
await handler({
|
||||||
|
event: { type: "session.idle", properties: { sessionID: "ses_reuse" } },
|
||||||
|
})
|
||||||
|
await handler({
|
||||||
|
event: { type: "session.idle", properties: { sessionID: "ses_reuse" } },
|
||||||
|
})
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(getCallCount).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("#given deleted session #when it idles again #then parent session lookup is fetched again", async () => {
|
||||||
|
//#given
|
||||||
|
let getCallCount = 0
|
||||||
|
const handler = createSessionEventHandler(
|
||||||
|
{
|
||||||
|
client: {
|
||||||
|
session: {
|
||||||
|
get: async () => {
|
||||||
|
getCallCount += 1
|
||||||
|
return { data: { parentID: "ses_parent" } }
|
||||||
|
},
|
||||||
|
prompt: async () => undefined,
|
||||||
|
messages: async () => ({ data: [] }),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as never,
|
||||||
|
{},
|
||||||
|
)
|
||||||
|
|
||||||
|
await handler({
|
||||||
|
event: { type: "session.idle", properties: { sessionID: "ses_reset" } },
|
||||||
|
})
|
||||||
|
await handler({
|
||||||
|
event: { type: "session.deleted", properties: { info: { id: "ses_reset" } } },
|
||||||
|
})
|
||||||
|
|
||||||
|
//#when
|
||||||
|
await handler({
|
||||||
|
event: { type: "session.idle", properties: { sessionID: "ses_reset" } },
|
||||||
|
})
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(getCallCount).toBe(2)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { PluginInput } from "@opencode-ai/plugin"
|
import type { PluginInput } from "@opencode-ai/plugin"
|
||||||
import type { ContextCollector } from "../../../features/context-injector"
|
import type { ContextCollector } from "../../../features/context-injector"
|
||||||
import { loadClaudeHooksConfig } from "../config"
|
import { clearClaudeHooksConfigCache, loadClaudeHooksConfig } from "../config"
|
||||||
import { loadPluginExtendedConfig } from "../config-loader"
|
import { clearPluginExtendedConfigCache, loadPluginExtendedConfig } from "../config-loader"
|
||||||
import { executeStopHooks, type StopContext } from "../stop"
|
import { executeStopHooks, type StopContext } from "../stop"
|
||||||
import { clearTranscriptCache } from "../transcript"
|
import { clearTranscriptCache } from "../transcript"
|
||||||
import { clearToolInputCache, stopToolInputCacheCleanup } from "../tool-input-cache"
|
import { clearToolInputCache, stopToolInputCacheCleanup } from "../tool-input-cache"
|
||||||
@@ -19,6 +19,8 @@ export function createSessionEventHandler(
|
|||||||
config: PluginConfig,
|
config: PluginConfig,
|
||||||
contextCollector?: ContextCollector,
|
contextCollector?: ContextCollector,
|
||||||
) {
|
) {
|
||||||
|
const parentSessionIdCache = new Map<string, string | undefined>()
|
||||||
|
|
||||||
return async (input: { event: { type: string; properties?: unknown } }) => {
|
return async (input: { event: { type: string; properties?: unknown } }) => {
|
||||||
const { event } = input
|
const { event } = input
|
||||||
|
|
||||||
@@ -38,6 +40,7 @@ export function createSessionEventHandler(
|
|||||||
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 sessionInfo = props?.info as { id?: string } | undefined
|
||||||
if (sessionInfo?.id) {
|
if (sessionInfo?.id) {
|
||||||
|
parentSessionIdCache.delete(sessionInfo.id)
|
||||||
clearTranscriptCache(sessionInfo.id)
|
clearTranscriptCache(sessionInfo.id)
|
||||||
clearToolInputCache(sessionInfo.id)
|
clearToolInputCache(sessionInfo.id)
|
||||||
contextCollector?.clear(sessionInfo.id)
|
contextCollector?.clear(sessionInfo.id)
|
||||||
@@ -62,14 +65,17 @@ export function createSessionEventHandler(
|
|||||||
const interruptStateBefore = sessionInterruptState.get(sessionID)
|
const interruptStateBefore = sessionInterruptState.get(sessionID)
|
||||||
const interruptedBefore = interruptStateBefore?.interrupted === true
|
const interruptedBefore = interruptStateBefore?.interrupted === true
|
||||||
|
|
||||||
let parentSessionId: string | undefined
|
let parentSessionId = parentSessionIdCache.get(sessionID)
|
||||||
try {
|
if (parentSessionId === undefined && !parentSessionIdCache.has(sessionID)) {
|
||||||
const sessionInfo = await ctx.client.session.get({
|
try {
|
||||||
path: { id: sessionID },
|
const sessionInfo = await ctx.client.session.get({
|
||||||
})
|
path: { id: sessionID },
|
||||||
parentSessionId = sessionInfo.data?.parentID
|
})
|
||||||
} catch {
|
parentSessionId = sessionInfo.data?.parentID
|
||||||
parentSessionId = undefined
|
parentSessionIdCache.set(sessionID, parentSessionId)
|
||||||
|
} catch {
|
||||||
|
parentSessionId = undefined
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isHookDisabled(config, "Stop")) {
|
if (!isHookDisabled(config, "Stop")) {
|
||||||
@@ -123,6 +129,8 @@ export function createSessionEventHandler(
|
|||||||
|
|
||||||
export function disposeSessionEventHandler(contextCollector?: ContextCollector): void {
|
export function disposeSessionEventHandler(contextCollector?: ContextCollector): void {
|
||||||
clearTranscriptCache()
|
clearTranscriptCache()
|
||||||
|
clearClaudeHooksConfigCache()
|
||||||
|
clearPluginExtendedConfigCache()
|
||||||
stopToolInputCacheCleanup()
|
stopToolInputCacheCleanup()
|
||||||
contextCollector?.clearAll()
|
contextCollector?.clearAll()
|
||||||
clearAllSessionHookState()
|
clearAllSessionHookState()
|
||||||
|
|||||||
Reference in New Issue
Block a user