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
254 changes: 254 additions & 0 deletions cmd/entire/cli/agent/opencode/entire_plugin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,254 @@
// Entire CLI plugin for OpenCode
// Auto-generated by `entire enable --agent opencode`
// Do not edit manually — changes will be overwritten on next install.
// Requires Bun runtime (used by OpenCode's plugin system for loading ESM plugins).
import type { Plugin } from "@opencode-ai/plugin"
import { tmpdir } from "node:os"

export const EntirePlugin: Plugin = async ({ client, directory, $ }) => {
const ENTIRE_CMD = "__ENTIRE_CMD__"
// Store transcripts in a temp directory — these are ephemeral handoff files
// between the plugin and the Go hook handler. Once checkpointed, the data
// lives on git refs and the file is disposable.
const sanitized = directory.replace(/[^a-zA-Z0-9]/g, "-")
const transcriptDir = `${tmpdir()}/entire-opencode/${sanitized}`
const seenUserMessages = new Set<string>()

// In-memory stores — used to write transcripts without relying on the SDK API,
// which may be unavailable during shutdown.
// messageStore: keyed by message ID, stores message metadata (role, time, tokens, etc.)
const messageStore = new Map<string, any>()
// partStore: keyed by message ID, stores accumulated parts from message.part.updated events
const partStore = new Map<string, any[]>()
let currentSessionID: string | null = null
// Full session info from session.created — needed for OpenCode export format on resume/rewind
let currentSessionInfo: any = null

// Ensure transcript directory exists
await $`mkdir -p ${transcriptDir}`.quiet().nothrow()

/**
* Pipe JSON payload to an entire hooks command.
* Errors are logged but never thrown — plugin failures must not crash OpenCode.
*/
async function callHook(hookName: string, payload: Record<string, unknown>) {
try {
const json = JSON.stringify(payload)
await $`echo ${json} | ${ENTIRE_CMD} hooks opencode ${hookName}`.quiet().nothrow()
} catch {
// Silently ignore — plugin failures must not crash OpenCode
}
}

/** Extract text content from a list of parts. */
function textFromParts(parts: any[]): string {
return parts
.filter((p: any) => p.type === "text")
.map((p: any) => p.text ?? "")
.join("\n")
}

/** Format a message object from its accumulated parts. */
function formatMessageFromStore(msg: any) {
const parts = partStore.get(msg.id) ?? []
return {
id: msg.id,
role: msg.role,
content: textFromParts(parts),
time: msg.time,
...(msg.role === "assistant" ? {
tokens: msg.tokens,
cost: msg.cost,
parts: parts.map((p: any) => ({
type: p.type,
...(p.type === "text" ? { text: p.text } : {}),
...(p.type === "tool" ? { tool: p.tool, callID: p.callID, state: p.state } : {}),
})),
} : {}),
}
}

/** Format a message from an API response (which includes parts inline). */
function formatMessageFromAPI(info: any, parts: any[]) {
return {
id: info.id,
role: info.role,
content: textFromParts(parts),
time: info.time,
...(info.role === "assistant" ? {
tokens: info.tokens,
cost: info.cost,
parts: parts.map((p: any) => ({
type: p.type,
...(p.type === "text" ? { text: p.text } : {}),
...(p.type === "tool" ? { tool: p.tool, callID: p.callID, state: p.state } : {}),
})),
} : {}),
}
}

/**
* Write transcript as JSONL (one message per line) from in-memory stores.
* This does NOT call the SDK API, so it works even during shutdown.
*/
async function writeTranscriptFromMemory(sessionID: string): Promise<string> {
const transcriptPath = `${transcriptDir}/${sessionID}.jsonl`
try {
const messages = Array.from(messageStore.values())
.sort((a, b) => (a.time?.created ?? 0) - (b.time?.created ?? 0))

const lines = messages.map(msg => JSON.stringify(formatMessageFromStore(msg)))
await Bun.write(transcriptPath, lines.join("\n") + "\n")
} catch {
// Silently ignore write failures
}
return transcriptPath
}

/**
* Try to fetch messages via the SDK API (returns messages with parts inline)
* and write transcript as JSONL. Falls back to in-memory stores if the API is unavailable.
*/
async function writeTranscriptWithFallback(sessionID: string): Promise<string> {
const transcriptPath = `${transcriptDir}/${sessionID}.jsonl`
try {
const response = await client.session.message.list({ path: { id: sessionID } })
// API returns Array<{ info: Message, parts: Array<Part> }>
const items = response.data ?? []

const lines = items.map((item: any) =>
JSON.stringify(formatMessageFromAPI(item.info, item.parts ?? []))
)
await Bun.write(transcriptPath, lines.join("\n") + "\n")
return transcriptPath
} catch {
// API unavailable (likely shutting down) — fall back to in-memory stores
return writeTranscriptFromMemory(sessionID)
}
}

/**
* Write session in OpenCode's native export format (JSON).
* This file is used by `opencode import` during resume/rewind to restore
* the session into OpenCode's SQLite database with the original session ID.
*/
async function writeExportJSON(sessionID: string): Promise<string> {
const exportPath = `${transcriptDir}/${sessionID}.export.json`
try {
const messages = Array.from(messageStore.values())
.sort((a, b) => (a.time?.created ?? 0) - (b.time?.created ?? 0))

const exportData = {
info: currentSessionInfo ?? { id: sessionID },
messages: messages.map(msg => ({
info: msg,
parts: (partStore.get(msg.id) ?? []),
})),
}
await Bun.write(exportPath, JSON.stringify(exportData))
} catch {
// Silently ignore — plugin failures must not crash OpenCode
}
return exportPath
}

return {
event: async ({ event }) => {
switch (event.type) {
case "session.created": {
const session = (event as any).properties?.info
if (!session?.id) break
currentSessionID = session.id
currentSessionInfo = session
await callHook("session-start", {
session_id: session.id,
transcript_path: `${transcriptDir}/${session.id}.jsonl`,
})
break
}

case "message.updated": {
const msg = (event as any).properties?.info
if (!msg) break
// Store message metadata (role, time, tokens, etc.)
// Content is NOT on the message — it arrives via message.part.updated events.
messageStore.set(msg.id, msg)
break
}

case "message.part.updated": {
const part = (event as any).properties?.part
if (!part?.messageID) break

// Accumulate parts per message
const existing = partStore.get(part.messageID) ?? []
// Replace existing part with same id, or append new one
const idx = existing.findIndex((p: any) => p.id === part.id)
if (idx >= 0) {
existing[idx] = part
} else {
existing.push(part)
}
partStore.set(part.messageID, existing)

// Fire turn-start on the first text part of a new user message
const msg = messageStore.get(part.messageID)
if (msg?.role === "user" && part.type === "text" && !seenUserMessages.has(msg.id)) {
seenUserMessages.add(msg.id)
const sessionID = msg.sessionID ?? currentSessionID
if (sessionID) {
await callHook("turn-start", {
session_id: sessionID,
transcript_path: `${transcriptDir}/${sessionID}.jsonl`,
prompt: part.text ?? "",
})
}
}
break
}

case "session.idle": {
const sessionID = (event as any).properties?.sessionID
if (!sessionID) break
const transcriptPath = await writeTranscriptWithFallback(sessionID)
await writeExportJSON(sessionID)
await callHook("turn-end", {
session_id: sessionID,
transcript_path: transcriptPath,
})
break
}

case "session.compacted": {
const sessionID = (event as any).properties?.sessionID
if (!sessionID) break
await callHook("compaction", {
session_id: sessionID,
transcript_path: `${transcriptDir}/${sessionID}.jsonl`,
})
break
}

case "session.deleted": {
const session = (event as any).properties?.info
if (!session?.id) break
// Write final transcript + export JSON before signaling session end
if (messageStore.size > 0) {
await writeTranscriptFromMemory(session.id)
await writeExportJSON(session.id)
}
seenUserMessages.clear()
messageStore.clear()
partStore.clear()
currentSessionID = null
currentSessionInfo = null
await callHook("session-end", {
session_id: session.id,
transcript_path: `${transcriptDir}/${session.id}.jsonl`,
})
break
}
}
},
}
}
132 changes: 132 additions & 0 deletions cmd/entire/cli/agent/opencode/hooks.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
package opencode

import (
"fmt"
"os"
"path/filepath"
"strings"

"github.com/entireio/cli/cmd/entire/cli/agent"
"github.com/entireio/cli/cmd/entire/cli/paths"
)

// Compile-time interface assertion
var _ agent.HookSupport = (*OpenCodeAgent)(nil)

const (
// pluginFileName is the name of the plugin file written to .opencode/plugins/
pluginFileName = "entire.ts"

// pluginDirName is the directory under .opencode/ where plugins live
pluginDirName = "plugins"

// entireMarker is a string present in the plugin file to identify it as Entire's
entireMarker = "Auto-generated by `entire enable --agent opencode`"
)

// getPluginPath returns the absolute path to the plugin file.
func getPluginPath() (string, error) {
repoRoot, err := paths.RepoRoot()
if err != nil {
// Fallback to CWD if not in a git repo (e.g., during tests)
//nolint:forbidigo // Intentional fallback when RepoRoot() fails (tests run outside git repos)
repoRoot, err = os.Getwd()
if err != nil {
return "", fmt.Errorf("failed to get current directory: %w", err)
}
}
return filepath.Join(repoRoot, ".opencode", pluginDirName, pluginFileName), nil
}

// InstallHooks writes the Entire plugin file to .opencode/plugins/entire.ts.
// Returns 1 if the plugin was installed, 0 if already present (idempotent).
func (a *OpenCodeAgent) InstallHooks(localDev bool, force bool) (int, error) {
pluginPath, err := getPluginPath()
if err != nil {
return 0, err
}

// Check if already installed (idempotent) unless force
if !force {
if _, err := os.Stat(pluginPath); err == nil {
data, readErr := os.ReadFile(pluginPath) //nolint:gosec // Path constructed from repo root
if readErr == nil && strings.Contains(string(data), entireMarker) {
return 0, nil // Already installed
}
}
}

// Build the command prefix
var cmdPrefix string
if localDev {
cmdPrefix = "go run ${OPENCODE_PROJECT_DIR}/cmd/entire/main.go"
} else {
cmdPrefix = "entire"
}

// Generate plugin content from template
content := strings.ReplaceAll(pluginTemplate, entireCmdPlaceholder, cmdPrefix)

// Ensure directory exists
pluginDir := filepath.Dir(pluginPath)
//nolint:gosec // G301: Plugin directory needs standard permissions
if err := os.MkdirAll(pluginDir, 0o755); err != nil {
return 0, fmt.Errorf("failed to create plugin directory: %w", err)
}

// Write plugin file
//nolint:gosec // G306: Plugin file needs standard permissions for OpenCode to read
if err := os.WriteFile(pluginPath, []byte(content), 0o644); err != nil {
return 0, fmt.Errorf("failed to write plugin file: %w", err)
}

return 1, nil
}

// UninstallHooks removes the Entire plugin file.
func (a *OpenCodeAgent) UninstallHooks() error {
pluginPath, err := getPluginPath()
if err != nil {
return err
}

if err := os.Remove(pluginPath); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("failed to remove plugin file: %w", err)
}

return nil
}

// AreHooksInstalled checks if the Entire plugin file exists and contains the marker.
func (a *OpenCodeAgent) AreHooksInstalled() bool {
pluginPath, err := getPluginPath()
if err != nil {
return false
}

data, err := os.ReadFile(pluginPath) //nolint:gosec // Path constructed from repo root
if err != nil {
return false
}

return strings.Contains(string(data), entireMarker)
}

// GetSupportedHooks returns the normalized lifecycle events this agent supports.
// OpenCode's native hooks map to standard agent lifecycle events:
// - session-start → HookSessionStart
// - session-end → HookSessionEnd
// - turn-start → HookUserPromptSubmit (user prompt triggers a turn)
// - turn-end → HookStop (agent response complete)
//
// Note: HookNames() returns 5 hooks (including "compaction"), but GetSupportedHooks()
// returns only 4. The "compaction" hook is OpenCode-specific with no standard HookType
// mapping — it is handled via ParseHookEvent but not advertised as a standard lifecycle event.
func (a *OpenCodeAgent) GetSupportedHooks() []agent.HookType {
return []agent.HookType{
agent.HookSessionStart,
agent.HookSessionEnd,
agent.HookUserPromptSubmit,
agent.HookStop,
}
}
Loading