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,
}
}