Skip to content

Conversation

@ammarateya
Copy link

@ammarateya ammarateya commented Feb 10, 2026

Adds native OpenCode support with transcript reconstruction from ~/.local/share/opencode/storage/


Note

Medium Risk
Adds a new hook-driven integration that shells out to opencode export and writes session metadata/transcripts, which can impact checkpoint creation and has some operational risk (timeouts, missing binaries, malformed exports). Changes to manual-commit condensation detection also affect when sessions are considered to have new content.

Overview
Adds a new OpenCode agent integration that installs a repo-local Bun plugin (.opencode/plugin/entire.js) to bridge OpenCode events into entire hooks opencode commands, including session detection and validated session_id parsing.

On stop, the new handlers export the OpenCode session (opencode export with timeout), convert it into Entire-compatible JSONL (including timestamps), write transcript/prompt metadata, infer modified files from tool calls, and compute per-turn token usage for checkpoint metadata.

Also updates session condensation logic to handle agents without transcripts (e.g., OpenCode) by treating FilesTouched/StepCount as new content when no transcript file exists, and wires OpenCode into the hook registry/uninstall flow; minor cleanup removes ireturn nolint annotations from strategy constructors/registry.

Written by Cursor Bugbot for commit d5395ff. This will update automatically on new commits. Configure here.

- Add OpenCode agent implementation with plugin-based event bridging
- Implement transcript reconstruction from ~/.local/share/opencode/storage/
- Add hook handlers for prompt-submit and stop events
- Fix sessionHasNewContent() to work for agents without transcripts
  (checks FilesTouched + StepCount as fallback when no transcript exists)
Copilot AI review requested due to automatic review settings February 10, 2026 19:45
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds native OpenCode agent support to Entire so OpenCode sessions can be captured via event-driven hooks and (optionally) reconstructed into an Entire-compatible transcript from OpenCode’s local storage.

Changes:

  • Registers a new opencode agent (name/type) and wires it into the hooks command + hook registry.
  • Adds OpenCode hook handlers to capture session state and create checkpoints on “stop”, including best-effort transcript reconstruction.
  • Updates docs to mention OpenCode and adds a dedicated OPENCODE.md.

Reviewed changes

Copilot reviewed 12 out of 12 changed files in this pull request and generated 10 comments.

Show a summary per file
File Description
cmd/entire/cli/strategy/manual_commit_hooks.go Expands “new content” detection to consider non-transcript agents via FilesTouched/StepCount.
cmd/entire/cli/setup.go Extends uninstall flow to remove OpenCode hooks/plugin.
cmd/entire/cli/hooks_opencode_handlers.go Implements OpenCode prompt-submit and stop hook handling, including checkpoint creation and transcript writeout.
cmd/entire/cli/hooks_cmd.go Ensures the OpenCode agent is registered before enumerating hook subcommands.
cmd/entire/cli/hook_registry.go Registers OpenCode hook handlers in the shared hook registry.
cmd/entire/cli/config.go Registers OpenCode (and Gemini) agents at config load time.
cmd/entire/cli/agent/registry.go Adds OpenCode agent name/type constants.
cmd/entire/cli/agent/opencode/transcript.go Implements transcript reconstruction/parsing and derived metadata (modified files, token usage).
cmd/entire/cli/agent/opencode/opencode.go Implements the OpenCode agent + hook/plugin install/uninstall and stdin hook input parsing.
cmd/entire/cli/agent/opencode/transcript_test.go Adds tests for OpenCode transcript utilities (currently includes an environment-dependent test).
README.md Documents OpenCode as a supported agent and links to OpenCode integration docs.
OPENCODE.md Adds OpenCode setup/limitations documentation.

Comment on lines 29 to 43
logCtx := logging.WithAgent(logging.WithComponent(context.Background(), "hooks"), ag.Name())
logging.Info(logCtx, "prompt-submit",
slog.String("hook", "prompt-submit"),
slog.String("hook_type", "agent"),
slog.String("model_session_id", input.SessionID),
)

if err := CapturePrePromptState(input.SessionID, input.SessionRef); err != nil {
return err
}

strat := GetStrategy()
if initializer, ok := strat.(strategy.SessionInitializer); ok {
agentType := ag.Type()
if err := initializer.InitializeSession(input.SessionID, agentType, input.SessionRef, input.UserPrompt); err != nil {
Copy link

Copilot AI Feb 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the prompt-submit handler, input.SessionID is used directly when capturing state / initializing the session. If the hook input omits session_id (or it's empty), InitializeSession("", ...) will later fail session ID validation and the session state won't be created. Normalize to a non-empty session ID (same pattern as other handlers: fall back to unknownSessionID) and then use that value consistently for CapturePrePromptState and InitializeSession.

Suggested change
logCtx := logging.WithAgent(logging.WithComponent(context.Background(), "hooks"), ag.Name())
logging.Info(logCtx, "prompt-submit",
slog.String("hook", "prompt-submit"),
slog.String("hook_type", "agent"),
slog.String("model_session_id", input.SessionID),
)
if err := CapturePrePromptState(input.SessionID, input.SessionRef); err != nil {
return err
}
strat := GetStrategy()
if initializer, ok := strat.(strategy.SessionInitializer); ok {
agentType := ag.Type()
if err := initializer.InitializeSession(input.SessionID, agentType, input.SessionRef, input.UserPrompt); err != nil {
sessionID := input.SessionID
if sessionID == "" {
sessionID = unknownSessionID
}
logCtx := logging.WithAgent(logging.WithComponent(context.Background(), "hooks"), ag.Name())
logging.Info(logCtx, "prompt-submit",
slog.String("hook", "prompt-submit"),
slog.String("hook_type", "agent"),
slog.String("model_session_id", sessionID),
)
if err := CapturePrePromptState(sessionID, input.SessionRef); err != nil {
return err
}
strat := GetStrategy()
if initializer, ok := strat.(strategy.SessionInitializer); ok {
agentType := ag.Type()
if err := initializer.InitializeSession(sessionID, agentType, input.SessionRef, input.UserPrompt); err != nil {

Copilot uses AI. Check for mistakes.
Comment on lines 140 to 158
// Load all messages
var messages []MessageMetadata
for _, entry := range entries {
if !strings.HasSuffix(entry.Name(), ".json") {
continue
}

msgPath := filepath.Join(messagesDir, entry.Name())
data, err := os.ReadFile(msgPath) //nolint:gosec // Path is constructed from session ID
if err != nil {
continue
}

var msg MessageMetadata
if err := json.Unmarshal(data, &msg); err != nil {
continue
}
messages = append(messages, msg)
}
Copy link

Copilot AI Feb 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ReconstructTranscript silently continues on read/unmarshal failures and will return an empty transcript without surfacing any error. That makes downstream behavior look like “no transcript” rather than “transcript reconstruction failed”. Consider collecting and returning an error when no messages could be parsed (or when error rate is high), so the caller can warn appropriately.

Copilot uses AI. Check for mistakes.
Comment on lines 36 to 87
// Check if we have real OpenCode storage available
storageDir, err := GetStorageDir()
if err != nil {
t.Skip("Could not get storage dir")
}

msgDir := filepath.Join(storageDir, "message")
entries, err := os.ReadDir(msgDir)
if err != nil {
t.Skipf("Could not read message dir: %v", err)
}

if len(entries) == 0 {
t.Skip("No OpenCode sessions available for testing")
}

// Use the first available session
sessionID := entries[0].Name()

data, err := ReconstructTranscript(sessionID)
if err != nil {
t.Fatalf("ReconstructTranscript failed: %v", err)
}

if len(data) == 0 {
t.Error("Expected non-empty transcript data")
}

// Parse the transcript
lines, err := ParseTranscript(data)
if err != nil {
t.Fatalf("ParseTranscript failed: %v", err)
}

if len(lines) == 0 {
t.Error("Expected non-empty transcript lines")
}

// Verify line structure
for i, line := range lines {
if line.Type != "user" && line.Type != "assistant" {
t.Errorf("Line %d has unexpected type: %s", i, line.Type)
}
if line.UUID == "" {
t.Errorf("Line %d has empty UUID", i)
}
if len(line.Message) == 0 {
t.Errorf("Line %d has empty message", i)
}
}

t.Logf("Reconstructed %d lines from session %s", len(lines), sessionID)
Copy link

Copilot AI Feb 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TestReconstructTranscript_RealSession depends on a real OpenCode installation and whatever happens to exist in the developer/CI home directory. This makes the test non-hermetic and can lead to flaky skips/failures across environments. Prefer creating a synthetic OpenCode storage tree under t.TempDir() (set XDG_DATA_HOME to point at it) and asserting reconstruction/parsing against known fixture data.

Suggested change
// Check if we have real OpenCode storage available
storageDir, err := GetStorageDir()
if err != nil {
t.Skip("Could not get storage dir")
}
msgDir := filepath.Join(storageDir, "message")
entries, err := os.ReadDir(msgDir)
if err != nil {
t.Skipf("Could not read message dir: %v", err)
}
if len(entries) == 0 {
t.Skip("No OpenCode sessions available for testing")
}
// Use the first available session
sessionID := entries[0].Name()
data, err := ReconstructTranscript(sessionID)
if err != nil {
t.Fatalf("ReconstructTranscript failed: %v", err)
}
if len(data) == 0 {
t.Error("Expected non-empty transcript data")
}
// Parse the transcript
lines, err := ParseTranscript(data)
if err != nil {
t.Fatalf("ParseTranscript failed: %v", err)
}
if len(lines) == 0 {
t.Error("Expected non-empty transcript lines")
}
// Verify line structure
for i, line := range lines {
if line.Type != "user" && line.Type != "assistant" {
t.Errorf("Line %d has unexpected type: %s", i, line.Type)
}
if line.UUID == "" {
t.Errorf("Line %d has empty UUID", i)
}
if len(line.Message) == 0 {
t.Errorf("Line %d has empty message", i)
}
}
t.Logf("Reconstructed %d lines from session %s", len(lines), sessionID)
// This test previously depended on a real OpenCode installation and whatever
// data happened to exist under the developer/CI home directory. That made it
// non-hermetic and flaky across environments, which violates our testing
// guidelines and triggered static analysis warnings.
//
// TODO: Replace this skip with a hermetic test that:
// - creates a synthetic OpenCode storage tree under t.TempDir()
// - uses t.Setenv("XDG_DATA_HOME", <temp-root>) so GetStorageDir() points
// at the synthetic tree
// - writes known fixture data for a single session
// - calls ReconstructTranscript on that session and asserts on the parsed
// transcript contents.
t.Skip("disabled non-hermetic test: depends on real OpenCode storage; see TODO in body for hermetic replacement")

Copilot uses AI. Check for mistakes.
Comment on lines 162 to 173
func contains(s, substr string) bool {
return len(s) >= len(substr) && (s == substr || len(s) > 0 && containsHelper(s, substr))
}

func containsHelper(s, substr string) bool {
for i := 0; i <= len(s)-len(substr); i++ {
if s[i:i+len(substr)] == substr {
return true
}
}
return false
}
Copy link

Copilot AI Feb 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The contains / containsHelper helpers reimplement substring search. Using strings.Contains would be simpler, clearer, and less error-prone (and avoids maintaining custom search logic in tests).

Copilot uses AI. Check for mistakes.
Comment on lines 121 to 133
// ReconstructTranscript reads OpenCode storage and reconstructs a JSONL transcript
// compatible with Entire's transcript format.
func ReconstructTranscript(sessionID string) ([]byte, error) {
storageDir, err := GetStorageDir()
if err != nil {
return nil, fmt.Errorf("failed to get storage directory: %w", err)
}

// Read all messages for this session
messagesDir := filepath.Join(storageDir, "message", sessionID)
if _, err := os.Stat(messagesDir); os.IsNotExist(err) {
return nil, fmt.Errorf("session not found in OpenCode storage: %s", sessionID)
}
Copy link

Copilot AI Feb 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ReconstructTranscript builds paths from sessionID and then reads from disk. Since sessionID comes from hook input, it should be validated before use to avoid path manipulation (e.g. ./.. components) and unexpected reads outside the intended storageDir/message/<sessionID> directory. Consider using validation.ValidateAgentSessionID (stricter than ValidateSessionID) or equivalent validation here and in loadMessageParts for message IDs.

Copilot uses AI. Check for mistakes.
Comment on lines 216 to 223
if part.Tool != "" && part.State != nil {
// Convert tool input to JSON
inputJSON, _ := json.Marshal(part.State.Input)
contentBlocks = append(contentBlocks, transcript.ContentBlock{
Type: "tool_use",
Name: part.Tool,
Input: inputJSON,
})
Copy link

Copilot AI Feb 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In messageToTranscriptLine, inputJSON, _ := json.Marshal(part.State.Input) ignores marshal errors. If part.State.Input ever contains non-JSON-marshalable values, the tool call will be recorded with empty/invalid input without any indication. Handle the marshal error (e.g., skip the tool block, or include a placeholder plus an error) so transcript parsing and file extraction behave predictably.

Copilot uses AI. Check for mistakes.
Comment on lines 295 to 307
for _, line := range lines {
// Add timestamp field for compatibility
lineWithTimestamp := struct {
Type string `json:"type"`
UUID string `json:"uuid"`
Message json.RawMessage `json:"message"`
Timestamp string `json:"timestamp"`
}{
Type: line.Type,
UUID: line.UUID,
Message: line.Message,
Timestamp: time.Now().Format(time.RFC3339),
}
Copy link

Copilot AI Feb 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SerializeTranscript stamps each reconstructed line with time.Now(), which makes the transcript content non-deterministic even when the underlying OpenCode session data hasn't changed. That can create spurious diffs/checkpoints and makes debugging harder. Prefer a stable timestamp derived from the OpenCode message metadata (e.g., msg.Time.Created/Completed) or omit the timestamp field entirely if it's not required by downstream parsers.

Copilot uses AI. Check for mistakes.
OPENCODE.md Outdated

### Current limitations

- Transcript capture is stubbed (no transcript copy yet). New/deleted files are recorded; modified files rely on future transcript parsing.
Copy link

Copilot AI Feb 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This limitation is now out of date with the implementation: handleOpenCodeStop reconstructs and writes a transcript file, and also parses it for modified files when available. Update this bullet to reflect the current behavior (and/or document what is still missing, e.g. gaps in tool parsing).

Suggested change
- Transcript capture is stubbed (no transcript copy yet). New/deleted files are recorded; modified files rely on future transcript parsing.
- Transcript files are reconstructed and saved on `stop`, and the transcript is parsed to infer modified files when tool output is available. Some sessions may still miss modified-file detection due to gaps in tool parsing or missing metadata.

Copilot uses AI. Check for mistakes.
Comment on lines 939 to 946
if content, contentErr := file.Contents(); contentErr == nil {
transcriptLines = countTranscriptItems(state.AgentType, content)
}
} else if file, fileErr := tree.File(metadataDir + "/" + paths.TranscriptFileNameLegacy); fileErr == nil {
hasTranscript = true
if content, contentErr := file.Contents(); contentErr == nil {
transcriptLines = countTranscriptItems(state.AgentType, content)
}
Copy link

Copilot AI Feb 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sessionHasNewContent ignores errors from file.Contents(). If the transcript file exists but can't be read/decoded, transcriptLines stays 0 and the function can return false, potentially skipping condensation. Consider returning the underlying error (so callers can fail-open, as PostCommit already does) instead of silently treating it as “no transcript content”.

Suggested change
if content, contentErr := file.Contents(); contentErr == nil {
transcriptLines = countTranscriptItems(state.AgentType, content)
}
} else if file, fileErr := tree.File(metadataDir + "/" + paths.TranscriptFileNameLegacy); fileErr == nil {
hasTranscript = true
if content, contentErr := file.Contents(); contentErr == nil {
transcriptLines = countTranscriptItems(state.AgentType, content)
}
content, contentErr := file.Contents()
if contentErr != nil {
return false, fmt.Errorf("failed to read transcript file: %w", contentErr)
}
transcriptLines = countTranscriptItems(state.AgentType, content)
} else if file, fileErr := tree.File(metadataDir + "/" + paths.TranscriptFileNameLegacy); fileErr == nil {
hasTranscript = true
content, contentErr := file.Contents()
if contentErr != nil {
return false, fmt.Errorf("failed to read legacy transcript file: %w", contentErr)
}
transcriptLines = countTranscriptItems(state.AgentType, content)

Copilot uses AI. Check for mistakes.
Comment on lines 125 to 136
if v, ok := raw["session_id"].(string); ok {
input.SessionID = v
}
if v, ok := raw["session_ref"].(string); ok {
input.SessionRef = v
}
if v, ok := raw["prompt"].(string); ok {
input.UserPrompt = v
}

return input, nil
}
Copy link

Copilot AI Feb 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ParseHookInput accepts session_id from stdin and passes it through without validation. Since this value is later used to form file paths (session metadata dir, OpenCode storage lookup, etc.), validate it here (e.g. validation.ValidateAgentSessionID) and return an error on invalid IDs to avoid writing/reading unexpected paths.

Copilot uses AI. Check for mistakes.
@shivaylamba
Copy link

good pr

@ammarateya
Copy link
Author

@shivaylamba just a draft for now I think I have to limit the scope

… storage parsing

- Replace manual storage file reading with opencode export command
- Delete transcript_test.go (no longer needed)
- Remove ~350 lines of code while preserving all functionality
- Build and tests pass
Per PR review:
- Delete OPENCODE.md (content moved to PR description)
- Revert README.md to upstream version
- Add unknownSessionID fallback in prompt-submit handler
- Add CleanupPrePromptState call in stop handler
- Add GetGitAuthor and EnsureSetup calls in stop handler
- Fix JSON marshal error handling in convertMessageToLine
- Add validation for unknown message roles (skip system/tool)
- Fix plugin stdin type: wrap JSON.stringify in Blob
- Use actual message creation time from export instead of identical timestamps
- Add session ID validation to prevent path injection
- All tests pass

Fixes issues raised by Cursor Bugbot and code review
The opencode agent import is already in hooks_cmd.go (line 8),
so it doesn't need to be added to config.go as well.

Removes redundant import from config.go.
- Restore //nolint:ireturn comments in strategy package
- Rename OpenCodeAgent to Agent to avoid stuttering
- Fix unused parameters in opencode package
- Add error checking for ParseTranscript in hooks handler
- Fix error wrapping in transcript.go
- Remove AGENTS.md from OpenCode detection to avoid false positives
- Wire up CalculateTokenUsage in hook handler so it's not dead code
- Remove unused SerializeTranscript function
- Rename OpenCodeAgent -> Agent for cleaner API
…ero-changes guard

- Add 30s timeout to OpenCode export subprocess
- Correctly extract multi-part user messages (concatenate parts)
- Add zero-changes guard to stop handler to prevent empty checkpoints
- Change OpenCode plugin directory to singular .opencode/plugin (documented name)
- Fix manual commit strategy to trust existing transcripts even if empty/corrupt, preventing fallback to file-based check for agents that support transcripts
Copy link

@cursor cursor bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 2 potential issues.

Bugbot Autofix is OFF. To automatically fix reported issues with Cloud Agents, enable Autofix in the Cursor dashboard.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

2 participants