diff --git a/packages/omo-opencode/src/config/schema/hooks.ts b/packages/omo-opencode/src/config/schema/hooks.ts index c243e882356..3deec422228 100644 --- a/packages/omo-opencode/src/config/schema/hooks.ts +++ b/packages/omo-opencode/src/config/schema/hooks.ts @@ -58,6 +58,7 @@ export const HookNameSchema = z.enum([ "webfetch-redirect-guard", "fsync-skip-warning", "plan-format-validator", + "v4-checkpoint-writer", "legacy-plugin-toast", ]) diff --git a/packages/omo-opencode/src/hooks/index.ts b/packages/omo-opencode/src/hooks/index.ts index 537145e095b..d7501a7dbee 100644 --- a/packages/omo-opencode/src/hooks/index.ts +++ b/packages/omo-opencode/src/hooks/index.ts @@ -69,3 +69,4 @@ export { createFsyncSkipWarningHook } from "./fsync-skip-warning" export { createNotepadWriteGuardHook } from "./notepad-write-guard" export { createPlanFormatValidatorHook } from "./plan-format-validator" export { createMonitorStatusInjectorHook } from "./monitor-status-injector" +export { createV4CheckpointWriterHook } from "./v4-checkpoint-writer" diff --git a/packages/omo-opencode/src/hooks/v4-checkpoint-writer/hook.test.ts b/packages/omo-opencode/src/hooks/v4-checkpoint-writer/hook.test.ts new file mode 100644 index 00000000000..a63c3868a43 --- /dev/null +++ b/packages/omo-opencode/src/hooks/v4-checkpoint-writer/hook.test.ts @@ -0,0 +1,146 @@ +/// + +import { describe, expect, test, mock, afterAll, beforeEach, afterEach } from "bun:test" +import { existsSync, readFileSync, rmSync, mkdtempSync } from "node:fs" +import { join } from "node:path" +import { tmpdir } from "node:os" + +const logMock = mock(() => {}) + +mock.module("../../shared/logger", () => ({ + log: logMock, +})) + +afterAll(() => { + mock.restore() +}) + +const { createV4CheckpointWriterHook } = await import("./hook") + +describe("v4-checkpoint-writer", () => { + let tempDir: string + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), "v4-checkpoint-test-")) + }) + + afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }) + }) + + test("#given V4 session reaching 20 tool calls #when tool.execute.after runs #then writes checkpoint file", () => { + // given + const hook = createV4CheckpointWriterHook({ directory: tempDir }) + const sessionID = "ses_v4_checkpoint" + + // simulate model detection + hook.event({ + event: { + type: "message.updated", + properties: { + info: { sessionID, modelID: "deepseek/deepseek-v4-pro", role: "assistant" }, + }, + }, + }) + + // when — simulate 20 tool calls + for (let i = 0; i < 20; i++) { + hook["tool.execute.after"]( + { tool: "bash", sessionID, callID: `call_${i}` }, + { title: "", output: "result", metadata: null }, + ) + } + + // then + const checkpointPath = join(tempDir, ".omo/checkpoints", `${sessionID}.json`) + expect(existsSync(checkpointPath)).toBe(true) + const checkpoint = JSON.parse(readFileSync(checkpointPath, "utf8")) + expect(checkpoint.sessionID).toBe(sessionID) + expect(checkpoint.modelID).toBe("deepseek/deepseek-v4-pro") + expect(checkpoint.toolCallCount).toBe(20) + expect(checkpoint.lastToolName).toBe("bash") + }) + + test("#given V4 session at 19 tool calls #when tool.execute.after runs #then does NOT write checkpoint", () => { + // given + const hook = createV4CheckpointWriterHook({ directory: tempDir }) + const sessionID = "ses_v4_no_checkpoint" + + hook.event({ + event: { + type: "message.updated", + properties: { + info: { sessionID, modelID: "deepseek/deepseek-v4-flash", role: "assistant" }, + }, + }, + }) + + // when — 19 tool calls (one short of the 20-call interval) + for (let i = 0; i < 19; i++) { + hook["tool.execute.after"]( + { tool: "read", sessionID, callID: `call_${i}` }, + { title: "", output: "result", metadata: null }, + ) + } + + // then + const checkpointPath = join(tempDir, ".omo/checkpoints", `${sessionID}.json`) + expect(existsSync(checkpointPath)).toBe(false) + }) + + test("#given non-V4 session at 20 tool calls #when tool.execute.after runs #then does NOT write checkpoint", () => { + // given + const hook = createV4CheckpointWriterHook({ directory: tempDir }) + const sessionID = "ses_non_v4" + + hook.event({ + event: { + type: "message.updated", + properties: { + info: { sessionID, modelID: "anthropic/claude-sonnet-4-6", role: "assistant" }, + }, + }, + }) + + // when + for (let i = 0; i < 20; i++) { + hook["tool.execute.after"]( + { tool: "bash", sessionID, callID: `call_${i}` }, + { title: "", output: "result", metadata: null }, + ) + } + + // then + const checkpointPath = join(tempDir, ".omo/checkpoints", `${sessionID}.json`) + expect(existsSync(checkpointPath)).toBe(false) + }) + + test("#given V4 session reaching 40 tool calls #when tool.execute.after runs #then writes 2nd checkpoint", () => { + // given + const hook = createV4CheckpointWriterHook({ directory: tempDir }) + const sessionID = "ses_v4_40" + + hook.event({ + event: { + type: "message.updated", + properties: { + info: { sessionID, modelID: "deepseek/deepseek-v4-pro", role: "assistant" }, + }, + }, + }) + + // when — 40 tool calls (two intervals) + for (let i = 0; i < 40; i++) { + hook["tool.execute.after"]( + { tool: "edit", sessionID, callID: `call_${i}` }, + { title: "", output: "result", metadata: null }, + ) + } + + // then — checkpoint should show 40 calls + const checkpointPath = join(tempDir, ".omo/checkpoints", `${sessionID}.json`) + expect(existsSync(checkpointPath)).toBe(true) + const checkpoint = JSON.parse(readFileSync(checkpointPath, "utf8")) + expect(checkpoint.toolCallCount).toBe(40) + }) +}) diff --git a/packages/omo-opencode/src/hooks/v4-checkpoint-writer/hook.ts b/packages/omo-opencode/src/hooks/v4-checkpoint-writer/hook.ts new file mode 100644 index 00000000000..91c64574651 --- /dev/null +++ b/packages/omo-opencode/src/hooks/v4-checkpoint-writer/hook.ts @@ -0,0 +1,104 @@ +import { writeFileSync, mkdirSync, existsSync } from "node:fs" +import { join, dirname } from "node:path" +import { homedir } from "node:os" +import { log } from "../../shared/logger" + +const CHECKPOINT_INTERVAL = 20 +const CHECKPOINT_DIR_NAME = ".omo/checkpoints" + +function isV4Model(modelID: string): boolean { + const lower = modelID.toLowerCase() + return lower.includes("deepseek-v4") || lower.includes("deepseek_v4") +} + +function resolveCheckpointDir(directory?: string): string { + if (directory) return join(directory, CHECKPOINT_DIR_NAME) + return join(homedir(), ".omo", "checkpoints") +} + +type SessionState = { + modelID: string + toolCallCount: number + lastToolName: string + lastCheckpointAt: number +} + +type SessionStateCache = Map + +export function createV4CheckpointWriterHook(options?: { directory?: string }) { + const sessionStates: SessionStateCache = new Map() + const checkpointDir = resolveCheckpointDir(options?.directory) + + return { + event: (input: { + event: { + type: string + properties: { + info?: { + sessionID?: string + modelID?: string + role?: string + } + } + } + }): void => { + if (input.event.type !== "message.updated") return + const info = input.event.properties?.info + if (!info?.modelID || !info?.sessionID) return + if (!isV4Model(info.modelID)) return + + const existing = sessionStates.get(info.sessionID) + if (!existing) { + sessionStates.set(info.sessionID, { + modelID: info.modelID, + toolCallCount: 0, + lastToolName: "", + lastCheckpointAt: 0, + }) + } else { + existing.modelID = info.modelID + } + }, + + "tool.execute.after": ( + input: { tool: string; sessionID: string; callID: string }, + _output?: { title?: string; output?: string; metadata?: unknown }, + ): void => { + const state = sessionStates.get(input.sessionID) + if (!state) return + + state.toolCallCount++ + state.lastToolName = input.tool + + if (state.toolCallCount % CHECKPOINT_INTERVAL !== 0) return + + const now = Date.now() + state.lastCheckpointAt = now + + const checkpoint = { + sessionID: input.sessionID, + modelID: state.modelID, + toolCallCount: state.toolCallCount, + lastToolName: state.lastToolName, + timestamp: new Date(now).toISOString(), + } + + try { + if (!existsSync(checkpointDir)) { + mkdirSync(checkpointDir, { recursive: true }) + } + const filePath = join(checkpointDir, `${input.sessionID}.json`) + writeFileSync(filePath, JSON.stringify(checkpoint, null, 2) + "\n", "utf8") + log("[v4-checkpoint-writer] Checkpoint written", { + sessionID: input.sessionID, + toolCallCount: state.toolCallCount, + }) + } catch (error) { + log("[v4-checkpoint-writer] Failed to write checkpoint", { + sessionID: input.sessionID, + error: String(error), + }) + } + }, + } +} diff --git a/packages/omo-opencode/src/hooks/v4-checkpoint-writer/index.ts b/packages/omo-opencode/src/hooks/v4-checkpoint-writer/index.ts new file mode 100644 index 00000000000..38bfb2fe112 --- /dev/null +++ b/packages/omo-opencode/src/hooks/v4-checkpoint-writer/index.ts @@ -0,0 +1 @@ +export { createV4CheckpointWriterHook } from "./hook" diff --git a/packages/omo-opencode/src/plugin/hooks/create-tool-guard-hooks.ts b/packages/omo-opencode/src/plugin/hooks/create-tool-guard-hooks.ts index 0ddfea11815..a01862de22e 100644 --- a/packages/omo-opencode/src/plugin/hooks/create-tool-guard-hooks.ts +++ b/packages/omo-opencode/src/plugin/hooks/create-tool-guard-hooks.ts @@ -21,6 +21,7 @@ import { createFsyncSkipWarningHook, createNotepadWriteGuardHook, createPlanFormatValidatorHook, + createV4CheckpointWriterHook, } from "../../hooks" import { getOpenCodeVersion, @@ -49,6 +50,7 @@ export type ToolGuardHooks = { teamToolGating: ReturnType | null notepadWriteGuard: ReturnType | null planFormatValidator: ReturnType | null + v4CheckpointWriter: ReturnType | null } export function createToolGuardHooks(args: { @@ -157,6 +159,10 @@ export function createToolGuardHooks(args: { ? safeHook("notepad-write-guard", () => createNotepadWriteGuardHook()) : null + const v4CheckpointWriter = isHookEnabled("v4-checkpoint-writer") + ? safeHook("v4-checkpoint-writer", () => createV4CheckpointWriterHook({ directory: ctx.directory })) + : null + return { commentChecker, toolOutputTruncator, @@ -176,5 +182,6 @@ export function createToolGuardHooks(args: { teamToolGating, notepadWriteGuard, planFormatValidator, + v4CheckpointWriter, } }