-
Notifications
You must be signed in to change notification settings - Fork 60
Add OpenCode agent integration #220
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Conversation
- 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)
There was a problem hiding this 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
opencodeagent (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. |
| 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 { |
Copilot
AI
Feb 10, 2026
There was a problem hiding this comment.
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.
| 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 { |
| // 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) | ||
| } |
Copilot
AI
Feb 10, 2026
There was a problem hiding this comment.
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.
| // 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) |
Copilot
AI
Feb 10, 2026
There was a problem hiding this comment.
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.
| // 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") |
| 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 | ||
| } |
Copilot
AI
Feb 10, 2026
There was a problem hiding this comment.
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).
| // 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) | ||
| } |
Copilot
AI
Feb 10, 2026
There was a problem hiding this comment.
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.
| 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, | ||
| }) |
Copilot
AI
Feb 10, 2026
There was a problem hiding this comment.
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.
| 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), | ||
| } |
Copilot
AI
Feb 10, 2026
There was a problem hiding this comment.
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.
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. |
Copilot
AI
Feb 10, 2026
There was a problem hiding this comment.
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).
| - 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. |
| 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) | ||
| } |
Copilot
AI
Feb 10, 2026
There was a problem hiding this comment.
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”.
| 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) |
| 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 | ||
| } |
Copilot
AI
Feb 10, 2026
There was a problem hiding this comment.
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.
|
good pr |
|
@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
There was a problem hiding this 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.
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 exportand 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 intoentire hooks opencodecommands, including session detection and validatedsession_idparsing.On
stop, the new handlers export the OpenCode session (opencode exportwith 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/StepCountas new content when no transcript file exists, and wires OpenCode into the hook registry/uninstall flow; minor cleanup removesireturnnolint annotations from strategy constructors/registry.Written by Cursor Bugbot for commit d5395ff. This will update automatically on new commits. Configure here.