Skip to content
Merged
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
11 changes: 11 additions & 0 deletions cmd/entire/cli/agent/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,17 @@ type FileWatcher interface {
OnFileChange(path string) (*SessionChange, error)
}

// ProtectedFilesProvider is implemented by agents that need to exclude
// repo-root-relative files owned by the agent integration itself from session
// tracking or destructive operations.
type ProtectedFilesProvider interface {
Agent

// ProtectedFiles returns repo-root-relative files that belong to the
// agent's own config/state and should be excluded from tracking.
ProtectedFiles() []string
}

// TranscriptAnalyzer provides format-specific transcript parsing.
// Agents that implement this get richer checkpoints (transcript-derived file lists,
// prompts, summaries). Agents that don't still participate in the checkpoint lifecycle
Expand Down
18 changes: 16 additions & 2 deletions cmd/entire/cli/agent/external/capabilities.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,14 @@ func Wrap(ea *Agent) (agent.Agent, error) {
if ea == nil {
return nil, errors.New("unable to wrap nil agent")
}
return &wrappedAgent{
base := &wrappedAgent{
ea: ea,
caps: ea.info.Capabilities,
}, nil
}
if len(ea.info.ProtectedFiles) > 0 {
return &wrappedAgentWithProtectedFiles{wrappedAgent: base}, nil
}
return base, nil
}

// wrappedAgent forwards all agent.Agent and optional interface methods to the
Expand All @@ -31,6 +35,12 @@ type wrappedAgent struct {
caps agent.DeclaredCaps
}

// wrappedAgentWithProtectedFiles opt-ins external agents to ProtectedFilesProvider
// only when the binary actually advertised protected_files in its info response.
type wrappedAgentWithProtectedFiles struct {
*wrappedAgent
}

// --- CapabilityDeclarer ---

func (w *wrappedAgent) DeclaredCapabilities() agent.DeclaredCaps { return w.caps }
Expand Down Expand Up @@ -120,6 +130,10 @@ func (w *wrappedAgent) WriteHookResponse(message string) error {
return w.ea.WriteHookResponse(message)
}

func (w *wrappedAgentWithProtectedFiles) ProtectedFiles() []string {
return w.ea.ProtectedFiles()
}

// --- SubagentAwareExtractor ---

func (w *wrappedAgent) ExtractAllModifiedFiles(data []byte, offset int, dir string) ([]string, error) {
Expand Down
4 changes: 4 additions & 0 deletions cmd/entire/cli/agent/external/external.go
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,10 @@ func (e *Agent) ProtectedDirs() []string {
return e.info.ProtectedDirs
}

func (e *Agent) ProtectedFiles() []string {
return e.info.ProtectedFiles
}

// --- Agent interface: Transcript Storage ---

func (e *Agent) ReadTranscript(sessionRef string) ([]byte, error) {
Expand Down
1 change: 1 addition & 0 deletions cmd/entire/cli/agent/external/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ type InfoResponse struct {
Description string `json:"description"`
IsPreview bool `json:"is_preview"`
ProtectedDirs []string `json:"protected_dirs"`
ProtectedFiles []string `json:"protected_files"`
HookNames []string `json:"hook_names"`
Capabilities agent.DeclaredCaps `json:"capabilities"`
}
Expand Down
49 changes: 39 additions & 10 deletions cmd/entire/cli/agent/opencode/entire_plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,10 +47,10 @@ export const EntirePlugin: Plugin = async ({ directory }) => {
}

/**
* Synchronous variant for hooks that fire near process exit (turn-end, session-end).
* `opencode run` breaks its event loop on the same session.status idle event that
* triggers turn-end. The async callHook would be killed before completing.
* Bun.spawnSync blocks the event loop, preventing exit until the hook finishes.
* Synchronous variant for hooks that must complete before subsequent agent work
* or process exit. `turn-start` must finish initializing session state before a
* fast mid-turn commit can hit git hooks, and `turn-end` / `session-end` must
* finish before `opencode run` tears down its event loop.
*/
function callHookSync(hookName: string, payload: Record<string, unknown>) {
try {
Expand All @@ -66,6 +66,17 @@ export const EntirePlugin: Plugin = async ({ directory }) => {
}
}

function resetSessionTracking(sessionID: string) {
if (currentSessionID === sessionID) {
return false
}
seenUserMessages.clear()
messageStore.clear()
currentModel = null
currentSessionID = sessionID
return true
}

return {
event: async ({ event }) => {
try {
Expand All @@ -74,10 +85,7 @@ export const EntirePlugin: Plugin = async ({ directory }) => {
const session = (event as any).properties?.info
if (!session?.id) break
// Reset per-session tracking state when switching sessions.
if (currentSessionID !== session.id) {
seenUserMessages.clear()
messageStore.clear()
currentModel = null
if (resetSessionTracking(session.id)) {
const json = JSON.stringify({
session_id: session.id,
})
Expand All @@ -89,19 +97,40 @@ export const EntirePlugin: Plugin = async ({ directory }) => {
})
await proc.exited
}
currentSessionID = session.id
break
}

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

if (msg.sessionID && resetSessionTracking(msg.sessionID)) {
callHookSync("session-start", {
session_id: msg.sessionID,
})
}

// Store message metadata (role, time, tokens, etc.)
messageStore.set(msg.id, msg)
// Track model from assistant messages
if (msg.role === "assistant" && msg.modelID) {
currentModel = msg.modelID
}

// Fallback: some opencode run flows commit before any message.part.updated
// event is delivered for the user's prompt. Start the turn from the
// user message itself so git hooks see an ACTIVE session in time.
if (msg.role === "user" && !seenUserMessages.has(msg.id)) {
seenUserMessages.add(msg.id)
const sessionID = msg.sessionID ?? currentSessionID
if (sessionID) {
callHookSync("turn-start", {
session_id: sessionID,
prompt: "",
model: currentModel ?? "",
})
}
}
break
}

Expand All @@ -115,7 +144,7 @@ export const EntirePlugin: Plugin = async ({ directory }) => {
seenUserMessages.add(msg.id)
const sessionID = msg.sessionID ?? currentSessionID
if (sessionID) {
await callHook("turn-start", {
callHookSync("turn-start", {
session_id: sessionID,
prompt: part.text ?? "",
model: currentModel ?? "",
Expand Down
88 changes: 79 additions & 9 deletions cmd/entire/cli/agent/opencode/hooks_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -117,32 +117,102 @@ func TestInstallHooks_SessionStartIsGuardedBySessionSwitch(t *testing.T) {
}

content := string(data)
guard := "if (currentSessionID !== session.id) {"
guard := "if (resetSessionTracking(session.id)) {"
hook := `const proc = Bun.spawn(hookCmd("session-start"), {`
currentSessionAssignment := "currentSessionID = session.id"

guardIdx := strings.Index(content, guard)
hookIdx := strings.Index(content, hook)
assignIdx := strings.Index(content, currentSessionAssignment)

if guardIdx == -1 {
t.Fatalf("plugin file missing guard %q", guard)
}
if hookIdx == -1 {
t.Fatalf("plugin file missing session-start hook spawn %q", hook)
}
if assignIdx == -1 {
t.Fatalf("plugin file missing current session assignment %q", currentSessionAssignment)
}
if guardIdx >= hookIdx || hookIdx >= assignIdx {
t.Fatalf("expected guarded session-start call before session assignment, got guard=%d hook=%d assignment=%d",
guardIdx, hookIdx, assignIdx)
if guardIdx >= hookIdx {
t.Fatalf("expected guarded session-start call after guard, got guard=%d hook=%d",
guardIdx, hookIdx)
}
if !strings.Contains(content, `if ! command -v entire >/dev/null 2>&1; then exit 0; fi; exec entire hooks opencode ${hookName}`) {
t.Fatal("plugin file missing silent production hook command")
}
}

func TestInstallHooks_TurnStartUsesSyncHook(t *testing.T) {
dir := t.TempDir()
t.Chdir(dir)
ag := &OpenCodeAgent{}

if _, err := ag.InstallHooks(context.Background(), false, false); err != nil {
t.Fatalf("install failed: %v", err)
}

pluginPath := filepath.Join(dir, ".opencode", "plugins", "entire.ts")
data, err := os.ReadFile(pluginPath)
if err != nil {
t.Fatalf("plugin file not created: %v", err)
}

content := string(data)
if !strings.Contains(content, `callHookSync("turn-start", {`) {
t.Fatal("plugin file should dispatch turn-start via callHookSync")
}
if strings.Contains(content, `await callHook("turn-start", {`) {
t.Fatal("plugin file should not dispatch turn-start via async callHook")
}
Comment thread
Soph marked this conversation as resolved.
}

func TestInstallHooks_MessageUpdatedFallsBackToSessionStart(t *testing.T) {
dir := t.TempDir()
t.Chdir(dir)
ag := &OpenCodeAgent{}

if _, err := ag.InstallHooks(context.Background(), false, false); err != nil {
t.Fatalf("install failed: %v", err)
}

pluginPath := filepath.Join(dir, ".opencode", "plugins", "entire.ts")
data, err := os.ReadFile(pluginPath)
if err != nil {
t.Fatalf("plugin file not created: %v", err)
}

content := string(data)
if !strings.Contains(content, `if (msg.sessionID && resetSessionTracking(msg.sessionID)) {`) {
t.Fatal("plugin file should bootstrap session tracking from message.updated")
}
if !strings.Contains(content, `callHookSync("session-start", {`) {
t.Fatal("plugin file should dispatch fallback session-start via callHookSync")
}
if !strings.Contains(content, `session_id: msg.sessionID,`) {
t.Fatal("plugin file should pass msg.sessionID in fallback session-start")
}
}

func TestInstallHooks_MessageUpdatedFallsBackToTurnStart(t *testing.T) {
dir := t.TempDir()
t.Chdir(dir)
ag := &OpenCodeAgent{}

if _, err := ag.InstallHooks(context.Background(), false, false); err != nil {
t.Fatalf("install failed: %v", err)
}

pluginPath := filepath.Join(dir, ".opencode", "plugins", "entire.ts")
data, err := os.ReadFile(pluginPath)
if err != nil {
t.Fatalf("plugin file not created: %v", err)
}

content := string(data)
if !strings.Contains(content, `if (msg.role === "user" && !seenUserMessages.has(msg.id)) {`) {
t.Fatal("plugin file should use message.updated as a fallback turn-start source")
}
if !strings.Contains(content, `prompt: "",`) {
t.Fatal("plugin file should send an empty prompt for fallback turn-start")
}
}

func TestInstallHooks_ForceReinstall(t *testing.T) {
dir := t.TempDir()
t.Chdir(dir)
Expand Down
11 changes: 6 additions & 5 deletions cmd/entire/cli/agent/opencode/opencode.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,11 +33,12 @@ func NewOpenCodeAgent() agent.Agent {

// --- Identity ---

func (a *OpenCodeAgent) Name() types.AgentName { return agent.AgentNameOpenCode }
func (a *OpenCodeAgent) Type() types.AgentType { return agent.AgentTypeOpenCode }
func (a *OpenCodeAgent) Description() string { return "OpenCode - AI-powered terminal coding agent" }
func (a *OpenCodeAgent) IsPreview() bool { return true }
func (a *OpenCodeAgent) ProtectedDirs() []string { return []string{".opencode"} }
func (a *OpenCodeAgent) Name() types.AgentName { return agent.AgentNameOpenCode }
func (a *OpenCodeAgent) Type() types.AgentType { return agent.AgentTypeOpenCode }
func (a *OpenCodeAgent) Description() string { return "OpenCode - AI-powered terminal coding agent" }
func (a *OpenCodeAgent) IsPreview() bool { return true }
func (a *OpenCodeAgent) ProtectedDirs() []string { return []string{".opencode"} }
func (a *OpenCodeAgent) ProtectedFiles() []string { return []string{"opencode.json"} }

func (a *OpenCodeAgent) DetectPresence(ctx context.Context) (bool, error) {
repoRoot, err := paths.WorktreeRoot(ctx)
Expand Down
21 changes: 21 additions & 0 deletions cmd/entire/cli/agent/opencode/transcript.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import (
var (
_ agent.TranscriptAnalyzer = (*OpenCodeAgent)(nil)
_ agent.TranscriptPreparer = (*OpenCodeAgent)(nil)
_ agent.PromptExtractor = (*OpenCodeAgent)(nil)
_ agent.TokenCalculator = (*OpenCodeAgent)(nil)
)

Expand Down Expand Up @@ -280,6 +281,26 @@ func ExtractAllUserPrompts(data []byte) ([]string, error) {
return prompts, nil
}

// ExtractPrompts extracts user prompts from an OpenCode export transcript starting
// at the given message offset.
func (a *OpenCodeAgent) ExtractPrompts(sessionRef string, fromOffset int) ([]string, error) {
data, err := os.ReadFile(sessionRef) //nolint:gosec // path comes from validated agent session state
if err != nil {
return nil, fmt.Errorf("failed to read opencode transcript for prompt extraction: %w", err)
}

scoped, err := SliceFromMessage(data, fromOffset)
if err != nil {
return nil, fmt.Errorf("failed to scope opencode transcript for prompt extraction: %w", err)
}

prompts, err := ExtractAllUserPrompts(scoped)
if err != nil {
return nil, fmt.Errorf("failed to extract prompts from opencode transcript: %w", err)
}
return prompts, nil
}

// CalculateTokenUsage computes token usage from assistant messages starting at the given offset.
func (a *OpenCodeAgent) CalculateTokenUsage(transcriptData []byte, fromOffset int) (*agent.TokenUsage, error) {
session, err := ParseExportSession(transcriptData)
Expand Down
22 changes: 22 additions & 0 deletions cmd/entire/cli/agent/opencode/transcript_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,28 @@ func TestExtractModifiedFilesFromOffset(t *testing.T) {
}
}

func TestExtractPrompts(t *testing.T) {
t.Parallel()

ag := &OpenCodeAgent{}
path := writeTestTranscript(t, testExportJSON)

prompts, err := ag.ExtractPrompts(path, 0)
require.NoError(t, err)
require.Equal(t, []string{"Fix the bug in main.go", "Also fix util.go"}, prompts)
}

func TestExtractPrompts_FromOffset(t *testing.T) {
t.Parallel()

ag := &OpenCodeAgent{}
path := writeTestTranscript(t, testExportJSON)

prompts, err := ag.ExtractPrompts(path, 2)
require.NoError(t, err)
require.Equal(t, []string{"Also fix util.go"}, prompts)
}

func TestExtractFilePaths(t *testing.T) {
t.Parallel()

Expand Down
Loading
Loading