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
217 changes: 166 additions & 51 deletions agent-support/opencode/git-ai.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@
* git-ai plugin for OpenCode
*
* This plugin integrates git-ai with OpenCode to track AI-generated code.
* It uses the tool.execute.before and tool.execute.after events to create
* checkpoints that mark code changes as human or AI-authored.
* It uses tool, session, and message lifecycle events to emit telemetry and
* create checkpoints that mark code changes as human or AI-authored.
*
* Installation:
* - Automatically installed by `git-ai install-hooks`
Expand All @@ -25,6 +25,7 @@ const GIT_AI_BIN = "__GIT_AI_BINARY_PATH__"

// Tools that modify files and should be tracked
const FILE_EDIT_TOOLS = ["edit", "write"]
const MCP_TOOL_PREFIX = "mcp__"

export const GitAiPlugin: Plugin = async (ctx) => {
const { $ } = ctx
Expand All @@ -42,9 +43,8 @@ export const GitAiPlugin: Plugin = async (ctx) => {
return {}
}

// Track pending edits by callID so we can reference them in the after hook
// Stores { filePath, repoDir, sessionID } for each pending edit
const pendingEdits = new Map<string, { filePath: string; repoDir: string; sessionID: string }>()
// Track pending edits by callID so we can reference them in the after hook.
const pendingEdits = new Map<string, { filePath: string; repoDir: string; sessionID: string; toolName: string }>()

// Helper to find git repo root from a file path
const findGitRepo = async (filePath: string): Promise<string | null> => {
Expand All @@ -59,78 +59,193 @@ export const GitAiPlugin: Plugin = async (ctx) => {
}
}

const charsCount = (value: unknown): number => {
if (typeof value !== "string") {
return 0
}
return Array.from(value).length
}

const getSessionId = (event: any): string | null => {
return event?.sessionID ?? event?.sessionId ?? event?.session?.id ?? null
}

const getCwd = (event: any): string => {
return event?.cwd ?? event?.workspace ?? process.cwd()
}

const emitCheckpoint = async (payload: Record<string, unknown>) => {
try {
const hookInput = JSON.stringify(payload)
await $`echo ${hookInput} | ${GIT_AI_BIN} checkpoint opencode --hook-input stdin`.quiet()
} catch (error) {
console.error("[git-ai] Failed to emit checkpoint payload:", String(error))
}
}

const emitTelemetryOnly = async (
hookEventName: string,
event: any,
telemetryPayload: Record<string, string> = {},
) => {
const sessionID = getSessionId(event)
if (!sessionID) {
return
}

await emitCheckpoint({
hook_event_name: hookEventName,
hook_source: "opencode_plugin",
session_id: sessionID,
cwd: getCwd(event),
telemetry_payload: telemetryPayload,
})
}

return {
"session.created": async (event: any) => {
await emitTelemetryOnly("session.created", event, {
source: "opencode",
})
},

"session.deleted": async (event: any) => {
const reason = event?.reason ? String(event.reason) : "completed"
await emitTelemetryOnly("session.deleted", event, {
reason,
})
},

"session.idle": async (event: any) => {
await emitTelemetryOnly("session.idle", event, {
status: "idle",
})
},

"message.updated": async (event: any) => {
const role = event?.role ?? event?.message?.role
const messageText = event?.text ?? event?.message?.text ?? ""
const messageID = event?.messageID ?? event?.messageId ?? event?.message?.id ?? event?.id
const telemetryPayload: Record<string, string> = {
role: typeof role === "string" ? role : "unknown",
}
if (typeof messageID === "string" && messageID.length > 0) {
telemetryPayload.message_id = messageID
}
const normalizedRole = typeof role === "string" ? role.toLowerCase() : ""
const textChars = charsCount(messageText)
if ((normalizedRole === "user" || normalizedRole === "human") && textChars > 0) {
telemetryPayload.prompt_char_count = String(textChars)
} else if (normalizedRole === "assistant" && textChars > 0) {
telemetryPayload.response_char_count = String(textChars)
}
await emitTelemetryOnly("message.updated", event, telemetryPayload)
},

"message.part.updated": async (event: any) => {
const role = event?.role ?? event?.message?.role ?? "assistant"
const partText = event?.text ?? event?.part?.text ?? ""
const messageID = event?.messageID ?? event?.messageId ?? event?.message?.id ?? event?.id
const telemetryPayload: Record<string, string> = {
role: typeof role === "string" ? role : "assistant",
}
if (typeof messageID === "string" && messageID.length > 0) {
telemetryPayload.message_id = messageID
}
const responseChars = charsCount(partText)
if (responseChars > 0) {
telemetryPayload.response_char_count = String(responseChars)
}
await emitTelemetryOnly("message.part.updated", event, telemetryPayload)
},

"tool.execute.before": async (input, output) => {
// Only intercept file editing tools
if (!FILE_EDIT_TOOLS.includes(input.tool)) {
const sessionID = input?.sessionID
if (!sessionID) {
return
}

// Extract file path from tool arguments (args are in output, not input)
const filePath = output.args?.filePath as string | undefined
if (!filePath) {
const toolName = String(input?.tool ?? "unknown")
const isMcp = toolName.startsWith(MCP_TOOL_PREFIX)
const filePath = output?.args?.filePath as string | undefined
const isFileEdit = FILE_EDIT_TOOLS.includes(toolName)

if (!isFileEdit || !filePath) {
const telemetryPayload: Record<string, string> = {
tool_name: toolName,
tool_use_id: String(input?.callID ?? ""),
}
if (isMcp) {
telemetryPayload.mcp_tool_name = toolName
}
await emitTelemetryOnly("tool.execute.before", input, telemetryPayload)
return
}

// Find the git repo for this file
const repoDir = await findGitRepo(filePath)
if (!repoDir) {
// File is not in a git repo, skip silently
await emitTelemetryOnly("tool.execute.before", input, {
tool_name: toolName,
tool_use_id: String(input?.callID ?? ""),
})
return
}

// Store filePath, repoDir, and sessionID for the after hook
pendingEdits.set(input.callID, { filePath, repoDir, sessionID: input.sessionID })

try {
// Create human checkpoint before AI edit
// This marks any changes since the last checkpoint as human-authored
const hookInput = JSON.stringify({
hook_event_name: "PreToolUse",
session_id: input.sessionID,
cwd: repoDir,
tool_input: { filePath },
})

await $`echo ${hookInput} | ${GIT_AI_BIN} checkpoint opencode --hook-input stdin`.quiet()
} catch (error) {
// Log to stderr for debugging, but don't throw - git-ai errors shouldn't break the agent
console.error("[git-ai] Failed to create human checkpoint:", String(error))
}
pendingEdits.set(input.callID, { filePath, repoDir, sessionID, toolName })

await emitCheckpoint({
hook_event_name: "PreToolUse",
hook_source: "opencode_plugin",
session_id: sessionID,
cwd: repoDir,
tool_name: toolName,
tool_input: { filePath },
telemetry_payload: {
tool_name: toolName,
tool_use_id: String(input?.callID ?? ""),
},
})
},

"tool.execute.after": async (input, _output) => {
// Only intercept file editing tools
if (!FILE_EDIT_TOOLS.includes(input.tool)) {
return
}

"tool.execute.after": async (input, output) => {
// Get the filePath and repoDir we stored in the before hook
const editInfo = pendingEdits.get(input.callID)
pendingEdits.delete(input.callID)

if (!editInfo) {
const toolName = String(input?.tool ?? "unknown")
const telemetryPayload: Record<string, string> = {
tool_name: toolName,
tool_use_id: String(input?.callID ?? ""),
}
if (toolName.startsWith(MCP_TOOL_PREFIX)) {
telemetryPayload.mcp_tool_name = toolName
}
await emitTelemetryOnly("tool.execute.after", input, telemetryPayload)
return
}

const { filePath, repoDir, sessionID } = editInfo

try {
// Create AI checkpoint after edit
// This marks the changes made by this tool call as AI-authored
// Transcript is fetched from OpenCode's local storage by the preset
const hookInput = JSON.stringify({
hook_event_name: "PostToolUse",
session_id: sessionID,
cwd: repoDir,
tool_input: { filePath },
})

await $`echo ${hookInput} | ${GIT_AI_BIN} checkpoint opencode --hook-input stdin`.quiet()
} catch (error) {
// Log to stderr for debugging, but don't throw - git-ai errors shouldn't break the agent
console.error("[git-ai] Failed to create AI checkpoint:", String(error))
const { filePath, repoDir, sessionID, toolName } = editInfo
const telemetryPayload: Record<string, string> = {
tool_name: toolName,
tool_use_id: String(input?.callID ?? ""),
}
const durationMs = output?.metadata?.duration_ms ?? output?.metadata?.duration
if (typeof durationMs === "number") {
telemetryPayload.duration_ms = String(Math.max(0, Math.floor(durationMs)))
}

await emitCheckpoint({
hook_event_name: "PostToolUse",
hook_source: "opencode_plugin",
session_id: sessionID,
cwd: repoDir,
tool_name: toolName,
tool_input: { filePath },
telemetry_payload: telemetryPayload,
})
},
}
}
Loading
Loading