Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/omo-opencode/src/config/schema/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ export const HookNameSchema = z.enum([
"webfetch-redirect-guard",
"fsync-skip-warning",
"plan-format-validator",
"v4-checkpoint-writer",
"legacy-plugin-toast",
])

Expand Down
1 change: 1 addition & 0 deletions packages/omo-opencode/src/hooks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
146 changes: 146 additions & 0 deletions packages/omo-opencode/src/hooks/v4-checkpoint-writer/hook.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
/// <reference types="bun-types" />

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)
})
})
104 changes: 104 additions & 0 deletions packages/omo-opencode/src/hooks/v4-checkpoint-writer/hook.ts
Original file line number Diff line number Diff line change
@@ -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<string, SessionState>

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),
})
}
},
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { createV4CheckpointWriterHook } from "./hook"
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
createFsyncSkipWarningHook,
createNotepadWriteGuardHook,
createPlanFormatValidatorHook,
createV4CheckpointWriterHook,
} from "../../hooks"
import {
getOpenCodeVersion,
Expand Down Expand Up @@ -49,6 +50,7 @@ export type ToolGuardHooks = {
teamToolGating: ReturnType<typeof createTeamToolGating> | null
notepadWriteGuard: ReturnType<typeof createNotepadWriteGuardHook> | null
planFormatValidator: ReturnType<typeof createPlanFormatValidatorHook> | null
v4CheckpointWriter: ReturnType<typeof createV4CheckpointWriterHook> | null
}

export function createToolGuardHooks(args: {
Expand Down Expand Up @@ -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,
Expand All @@ -176,5 +182,6 @@ export function createToolGuardHooks(args: {
teamToolGating,
notepadWriteGuard,
planFormatValidator,
v4CheckpointWriter,
}
}