diff --git a/cmd/entire/cli/agent/openclaw/hooks.go b/cmd/entire/cli/agent/openclaw/hooks.go new file mode 100644 index 000000000..85da670e3 --- /dev/null +++ b/cmd/entire/cli/agent/openclaw/hooks.go @@ -0,0 +1,53 @@ +package openclaw + +import "github.com/entireio/cli/cmd/entire/cli/agent" + +// Ensure OpenClawAgent implements HookHandler and HookSupport +var _ agent.HookHandler = (*OpenClawAgent)(nil) +var _ agent.HookSupport = (*OpenClawAgent)(nil) + +// InstallHooks is a no-op for OpenClaw — hooks are invoked externally by the OpenClaw runtime. +// The OpenClaw gateway calls `entire hooks openclaw ` directly. +func (o *OpenClawAgent) InstallHooks(_ bool, _ bool) (int, error) { + return 4, nil // 4 hooks: session-start, session-end, stop, user-prompt-submit +} + +// UninstallHooks is a no-op for OpenClaw — no config files to clean up. +func (o *OpenClawAgent) UninstallHooks() error { + return nil +} + +// AreHooksInstalled returns true for OpenClaw — hooks are always available +// since they are invoked by the OpenClaw runtime, not configured in a file. +func (o *OpenClawAgent) AreHooksInstalled() bool { + return true +} + +// GetSupportedHooks returns the hook types OpenClaw supports. +func (o *OpenClawAgent) GetSupportedHooks() []agent.HookType { + return []agent.HookType{ + agent.HookSessionStart, + agent.HookSessionEnd, + agent.HookStop, + agent.HookUserPromptSubmit, + } +} + +// OpenClaw hook names - these become subcommands under `entire hooks openclaw` +const ( + HookNameSessionStart = "session-start" + HookNameSessionEnd = "session-end" + HookNameStop = "stop" + HookNameUserPromptSubmit = "user-prompt-submit" +) + +// GetHookNames returns the hook verbs OpenClaw supports. +// These become subcommands: entire hooks openclaw +func (o *OpenClawAgent) GetHookNames() []string { + return []string{ + HookNameSessionStart, + HookNameSessionEnd, + HookNameStop, + HookNameUserPromptSubmit, + } +} diff --git a/cmd/entire/cli/agent/openclaw/openclaw.go b/cmd/entire/cli/agent/openclaw/openclaw.go new file mode 100644 index 000000000..57b3eb0c6 --- /dev/null +++ b/cmd/entire/cli/agent/openclaw/openclaw.go @@ -0,0 +1,392 @@ +// Package openclaw implements the Agent interface for OpenClaw. +package openclaw + +import ( + "bufio" + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "time" + + "github.com/entireio/cli/cmd/entire/cli/agent" + "github.com/entireio/cli/cmd/entire/cli/paths" + "github.com/entireio/cli/cmd/entire/cli/sessionid" +) + +//nolint:gochecknoinits // Agent self-registration is the intended pattern +func init() { + agent.Register(agent.AgentNameOpenClaw, NewOpenClawAgent) +} + +// OpenClawAgent implements the Agent interface for OpenClaw. +// +//nolint:revive // OpenClawAgent is clearer than Agent in this context +type OpenClawAgent struct{} + +// NewOpenClawAgent creates a new OpenClaw agent instance. +func NewOpenClawAgent() agent.Agent { + return &OpenClawAgent{} +} + +// Name returns the agent registry key. +func (o *OpenClawAgent) Name() agent.AgentName { + return agent.AgentNameOpenClaw +} + +// Type returns the agent type identifier. +func (o *OpenClawAgent) Type() agent.AgentType { + return agent.AgentTypeOpenClaw +} + +// Description returns a human-readable description. +func (o *OpenClawAgent) Description() string { + return "OpenClaw - AI agent runtime for coding assistants" +} + +// DetectPresence checks if OpenClaw is configured in the repository. +func (o *OpenClawAgent) DetectPresence() (bool, error) { + repoRoot, err := paths.RepoRoot() + if err != nil { + repoRoot = "." + } + + // Check for .openclaw directory + openclawDir := filepath.Join(repoRoot, ".openclaw") + if _, err := os.Stat(openclawDir); err == nil { + return true, nil + } + + // Check for AGENTS.md (OpenClaw workspace marker) + agentsFile := filepath.Join(repoRoot, "AGENTS.md") + if _, err := os.Stat(agentsFile); err == nil { + return true, nil + } + + return false, nil +} + +// GetHookConfigPath returns the path to OpenClaw's hook config file. +func (o *OpenClawAgent) GetHookConfigPath() string { + return "" +} + +// SupportsHooks returns true as OpenClaw supports lifecycle hooks. +func (o *OpenClawAgent) SupportsHooks() bool { + return true +} + +// ParseHookInput parses OpenClaw hook input from stdin. +// OpenClaw sends JSON with session_id and transcript_path fields. +func (o *OpenClawAgent) ParseHookInput(hookType agent.HookType, reader io.Reader) (*agent.HookInput, error) { + data, err := io.ReadAll(reader) + if err != nil { + return nil, fmt.Errorf("failed to read input: %w", err) + } + + if len(data) == 0 { + return nil, errors.New("empty input") + } + + input := &agent.HookInput{ + HookType: hookType, + Timestamp: time.Now(), + RawData: make(map[string]interface{}), + } + + var raw openClawHookInput + if err := json.Unmarshal(data, &raw); err != nil { + return nil, fmt.Errorf("failed to parse hook input: %w", err) + } + + input.SessionID = raw.SessionID + input.SessionRef = raw.TranscriptPath + input.UserPrompt = raw.Prompt + + return input, nil +} + +// openClawHookInput represents the JSON structure sent by OpenClaw hooks. +type openClawHookInput struct { + SessionID string `json:"session_id"` + TranscriptPath string `json:"transcript_path"` + Prompt string `json:"prompt,omitempty"` +} + +// GetSessionID extracts the session ID from hook input. +func (o *OpenClawAgent) GetSessionID(input *agent.HookInput) string { + return input.SessionID +} + +// TransformSessionID converts an OpenClaw session ID to an Entire session ID. +func (o *OpenClawAgent) TransformSessionID(agentSessionID string) string { + return agentSessionID +} + +// ExtractAgentSessionID extracts the OpenClaw session ID from an Entire session ID. +func (o *OpenClawAgent) ExtractAgentSessionID(entireSessionID string) string { + return sessionid.ModelSessionID(entireSessionID) +} + +// ResolveSessionFile returns the path to an OpenClaw session file. +// OpenClaw names session files directly as .jsonl. +func (o *OpenClawAgent) ResolveSessionFile(sessionDir, agentSessionID string) string { + return filepath.Join(sessionDir, agentSessionID+".jsonl") +} + +// ProtectedDirs returns directories that OpenClaw uses for config/state. +func (o *OpenClawAgent) ProtectedDirs() []string { return []string{".openclaw"} } + +// GetSessionDir returns the directory where OpenClaw stores session transcripts. +func (o *OpenClawAgent) GetSessionDir(_ string) (string, error) { + homeDir, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("failed to get home directory: %w", err) + } + + return filepath.Join(homeDir, ".openclaw", "sessions"), nil +} + +// ReadSession reads a session from OpenClaw's storage (JSONL transcript file). +func (o *OpenClawAgent) ReadSession(input *agent.HookInput) (*agent.AgentSession, error) { + if input.SessionRef == "" { + return nil, errors.New("session reference (transcript path) is required") + } + + data, err := os.ReadFile(input.SessionRef) + if err != nil { + return nil, fmt.Errorf("failed to read transcript: %w", err) + } + + lines, err := ParseTranscript(data) + if err != nil { + return nil, fmt.Errorf("failed to parse transcript: %w", err) + } + + return &agent.AgentSession{ + SessionID: input.SessionID, + AgentName: o.Name(), + SessionRef: input.SessionRef, + StartTime: time.Now(), + NativeData: data, + ModifiedFiles: ExtractModifiedFiles(lines), + }, nil +} + +// WriteSession writes a session to OpenClaw's storage (JSONL transcript file). +func (o *OpenClawAgent) WriteSession(session *agent.AgentSession) error { + if session == nil { + return errors.New("session is nil") + } + + if session.AgentName != "" && session.AgentName != o.Name() { + return fmt.Errorf("session belongs to agent %q, not %q", session.AgentName, o.Name()) + } + + if session.SessionRef == "" { + return errors.New("session reference (transcript path) is required") + } + + if len(session.NativeData) == 0 { + return errors.New("session has no native data to write") + } + + if err := os.WriteFile(session.SessionRef, session.NativeData, 0o600); err != nil { + return fmt.Errorf("failed to write transcript: %w", err) + } + + return nil +} + +// FormatResumeCommand returns the command to resume an OpenClaw session. +func (o *OpenClawAgent) FormatResumeCommand(sessionID string) string { + return "openclaw session resume " + sessionID +} + +// TranscriptLine represents a single line in an OpenClaw JSONL transcript. +type TranscriptLine struct { + Role string `json:"role"` + Content json.RawMessage `json:"content,omitempty"` + Timestamp string `json:"timestamp,omitempty"` + ToolCalls json.RawMessage `json:"tool_calls,omitempty"` + Usage json.RawMessage `json:"usage,omitempty"` +} + +// toolCall represents a tool call in an OpenClaw transcript. +type toolCall struct { + Name string `json:"name"` + Input json.RawMessage `json:"input,omitempty"` +} + +// toolInput represents the input to a file-modifying tool. +type toolInput struct { + FilePath string `json:"file_path,omitempty"` + Path string `json:"path,omitempty"` + Command string `json:"command,omitempty"` +} + +// ParseTranscript parses raw JSONL content into transcript lines. +func ParseTranscript(data []byte) ([]TranscriptLine, error) { + var lines []TranscriptLine + scanner := bufio.NewScanner(bytes.NewReader(data)) + scanner.Buffer(make([]byte, 0, scannerBufferSize), scannerBufferSize) + + for scanner.Scan() { + var line TranscriptLine + if err := json.Unmarshal(scanner.Bytes(), &line); err != nil { + continue // Skip malformed lines + } + lines = append(lines, line) + } + + if err := scanner.Err(); err != nil { + return nil, fmt.Errorf("failed to scan transcript: %w", err) + } + return lines, nil +} + +// scannerBufferSize for large transcript files (10MB). +const scannerBufferSize = 10 * 1024 * 1024 + +// ExtractModifiedFiles extracts files modified by tool calls from an OpenClaw transcript. +// OpenClaw uses tool calls with names like "write", "edit", "exec" that contain file paths. +func ExtractModifiedFiles(lines []TranscriptLine) []string { + fileSet := make(map[string]bool) + var files []string + + for _, line := range lines { + if line.Role != "assistant" || len(line.ToolCalls) == 0 { + continue + } + + var calls []toolCall + if err := json.Unmarshal(line.ToolCalls, &calls); err != nil { + continue + } + + for _, call := range calls { + switch call.Name { + case "Write", "write", "Edit", "edit": + var input toolInput + if err := json.Unmarshal(call.Input, &input); err != nil { + continue + } + path := input.FilePath + if path == "" { + path = input.Path + } + if path != "" && !fileSet[path] { + fileSet[path] = true + files = append(files, path) + } + } + } + } + + return files +} + +// ExtractLastUserPrompt extracts the last user prompt from the transcript. +func ExtractLastUserPrompt(lines []TranscriptLine) string { + for i := len(lines) - 1; i >= 0; i-- { + if lines[i].Role == "user" { + var content string + if err := json.Unmarshal(lines[i].Content, &content); err != nil { + return string(lines[i].Content) + } + return content + } + } + return "" +} + +// GetTranscriptPosition returns the current line count of an OpenClaw transcript. +func (o *OpenClawAgent) GetTranscriptPosition(path string) (int, error) { + if path == "" { + return 0, nil + } + + file, err := os.Open(path) //nolint:gosec // Path comes from OpenClaw transcript location + if err != nil { + if os.IsNotExist(err) { + return 0, nil + } + return 0, fmt.Errorf("failed to open transcript file: %w", err) + } + defer file.Close() + + reader := bufio.NewReader(file) + lineCount := 0 + + for { + _, err := reader.ReadBytes('\n') + if err != nil { + if err == io.EOF { + break + } + return 0, fmt.Errorf("failed to read transcript: %w", err) + } + lineCount++ + } + + return lineCount, nil +} + +// ExtractModifiedFilesFromOffset extracts files modified since a given line number. +func (o *OpenClawAgent) ExtractModifiedFilesFromOffset(path string, startOffset int) (files []string, currentPosition int, err error) { + if path == "" { + return nil, 0, nil + } + + file, openErr := os.Open(path) //nolint:gosec // Path comes from OpenClaw transcript location + if openErr != nil { + return nil, 0, fmt.Errorf("failed to open transcript file: %w", openErr) + } + defer file.Close() + + reader := bufio.NewReader(file) + var lines []TranscriptLine + lineNum := 0 + + for { + lineData, readErr := reader.ReadBytes('\n') + if readErr != nil && readErr != io.EOF { + return nil, 0, fmt.Errorf("failed to read transcript: %w", readErr) + } + + if len(lineData) > 0 { + lineNum++ + if lineNum > startOffset { + var line TranscriptLine + if parseErr := json.Unmarshal(lineData, &line); parseErr == nil { + lines = append(lines, line) + } + } + } + + if readErr == io.EOF { + break + } + } + + return ExtractModifiedFiles(lines), lineNum, nil +} + +// ChunkTranscript splits a JSONL transcript at line boundaries. +func (o *OpenClawAgent) ChunkTranscript(content []byte, maxSize int) ([][]byte, error) { + chunks, err := agent.ChunkJSONL(content, maxSize) + if err != nil { + return nil, fmt.Errorf("failed to chunk JSONL transcript: %w", err) + } + return chunks, nil +} + +// ReassembleTranscript concatenates JSONL chunks with newlines. +// +//nolint:unparam // error return is required by interface, kept for consistency +func (o *OpenClawAgent) ReassembleTranscript(chunks [][]byte) ([]byte, error) { + return agent.ReassembleJSONL(chunks), nil +} diff --git a/cmd/entire/cli/agent/openclaw/openclaw_test.go b/cmd/entire/cli/agent/openclaw/openclaw_test.go new file mode 100644 index 000000000..e2d54d5de --- /dev/null +++ b/cmd/entire/cli/agent/openclaw/openclaw_test.go @@ -0,0 +1,137 @@ +package openclaw + +import ( + "strings" + "testing" + + "github.com/entireio/cli/cmd/entire/cli/agent" +) + +func TestOpenClawAgentName(t *testing.T) { + ag := NewOpenClawAgent() + if ag.Name() != agent.AgentNameOpenClaw { + t.Errorf("expected Name() = %q, got %q", agent.AgentNameOpenClaw, ag.Name()) + } +} + +func TestOpenClawAgentType(t *testing.T) { + ag := NewOpenClawAgent() + if ag.Type() != agent.AgentTypeOpenClaw { + t.Errorf("expected Type() = %q, got %q", agent.AgentTypeOpenClaw, ag.Type()) + } +} + +func TestOpenClawSupportsHooks(t *testing.T) { + ag := NewOpenClawAgent() + if !ag.SupportsHooks() { + t.Error("expected SupportsHooks() = true") + } +} + +func TestOpenClawProtectedDirs(t *testing.T) { + ag := NewOpenClawAgent() + dirs := ag.ProtectedDirs() + if len(dirs) != 1 || dirs[0] != ".openclaw" { + t.Errorf("expected ProtectedDirs() = [\".openclaw\"], got %v", dirs) + } +} + +func TestOpenClawFormatResumeCommand(t *testing.T) { + ag := NewOpenClawAgent() + cmd := ag.FormatResumeCommand("test-session-123") + expected := "openclaw session resume test-session-123" + if cmd != expected { + t.Errorf("expected FormatResumeCommand() = %q, got %q", expected, cmd) + } +} + +func TestOpenClawResolveSessionFile(t *testing.T) { + ag := NewOpenClawAgent() + path := ag.ResolveSessionFile("/home/user/.openclaw/sessions", "abc123") + if !strings.HasSuffix(path, "abc123.jsonl") { + t.Errorf("expected path ending with abc123.jsonl, got %q", path) + } +} + +func TestOpenClawParseHookInput(t *testing.T) { + ag := NewOpenClawAgent() + input := `{"session_id": "sess-123", "transcript_path": "/tmp/sess.jsonl", "prompt": "hello"}` + reader := strings.NewReader(input) + + hookInput, err := ag.ParseHookInput(agent.HookUserPromptSubmit, reader) + if err != nil { + t.Fatalf("ParseHookInput failed: %v", err) + } + + if hookInput.SessionID != "sess-123" { + t.Errorf("expected SessionID = %q, got %q", "sess-123", hookInput.SessionID) + } + if hookInput.SessionRef != "/tmp/sess.jsonl" { + t.Errorf("expected SessionRef = %q, got %q", "/tmp/sess.jsonl", hookInput.SessionRef) + } + if hookInput.UserPrompt != "hello" { + t.Errorf("expected UserPrompt = %q, got %q", "hello", hookInput.UserPrompt) + } +} + +func TestOpenClawParseHookInputEmpty(t *testing.T) { + ag := NewOpenClawAgent() + reader := strings.NewReader("") + + _, err := ag.ParseHookInput(agent.HookStop, reader) + if err == nil { + t.Error("expected error for empty input") + } +} + +func TestExtractModifiedFiles(t *testing.T) { + jsonl := `{"role":"user","content":"fix the bug"} +{"role":"assistant","content":"I'll fix it","tool_calls":[{"name":"Edit","input":{"file_path":"main.go"}},{"name":"Write","input":{"path":"new_file.go"}}]} +{"role":"assistant","content":"done","tool_calls":[{"name":"exec","input":{"command":"go build"}}]} +` + lines, err := ParseTranscript([]byte(jsonl)) + if err != nil { + t.Fatalf("ParseTranscript failed: %v", err) + } + + files := ExtractModifiedFiles(lines) + if len(files) != 2 { + t.Fatalf("expected 2 modified files, got %d: %v", len(files), files) + } + if files[0] != "main.go" { + t.Errorf("expected files[0] = %q, got %q", "main.go", files[0]) + } + if files[1] != "new_file.go" { + t.Errorf("expected files[1] = %q, got %q", "new_file.go", files[1]) + } +} + +func TestExtractLastUserPrompt(t *testing.T) { + jsonl := `{"role":"user","content":"first prompt"} +{"role":"assistant","content":"response"} +{"role":"user","content":"second prompt"} +` + lines, err := ParseTranscript([]byte(jsonl)) + if err != nil { + t.Fatalf("ParseTranscript failed: %v", err) + } + + prompt := ExtractLastUserPrompt(lines) + if prompt != "second prompt" { + t.Errorf("expected %q, got %q", "second prompt", prompt) + } +} + +func TestGetHookNames(t *testing.T) { + ag := &OpenClawAgent{} + names := ag.GetHookNames() + expected := []string{"session-start", "session-end", "stop", "user-prompt-submit"} + if len(names) != len(expected) { + t.Fatalf("expected %d hook names, got %d", len(expected), len(names)) + } + for i, name := range names { + if name != expected[i] { + t.Errorf("expected hook name[%d] = %q, got %q", i, expected[i], name) + } + } +} diff --git a/cmd/entire/cli/agent/registry.go b/cmd/entire/cli/agent/registry.go index 5f3df9e02..91eb53fa6 100644 --- a/cmd/entire/cli/agent/registry.go +++ b/cmd/entire/cli/agent/registry.go @@ -80,12 +80,14 @@ type AgentType string const ( AgentNameClaudeCode AgentName = "claude-code" AgentNameGemini AgentName = "gemini" + AgentNameOpenClaw AgentName = "openclaw" ) // Agent type constants (type identifiers stored in metadata/trailers) const ( AgentTypeClaudeCode AgentType = "Claude Code" AgentTypeGemini AgentType = "Gemini CLI" + AgentTypeOpenClaw AgentType = "OpenClaw" AgentTypeUnknown AgentType = "Agent" // Fallback for backwards compatibility ) diff --git a/cmd/entire/cli/hook_registry.go b/cmd/entire/cli/hook_registry.go index 8505c9be5..63a8247d4 100644 --- a/cmd/entire/cli/hook_registry.go +++ b/cmd/entire/cli/hook_registry.go @@ -10,6 +10,7 @@ import ( "github.com/entireio/cli/cmd/entire/cli/agent" "github.com/entireio/cli/cmd/entire/cli/agent/claudecode" "github.com/entireio/cli/cmd/entire/cli/agent/geminicli" + "github.com/entireio/cli/cmd/entire/cli/agent/openclaw" "github.com/entireio/cli/cmd/entire/cli/logging" "github.com/entireio/cli/cmd/entire/cli/paths" @@ -175,6 +176,39 @@ func init() { return handleGeminiBeforeToolSelection() }) + // Register OpenClaw handlers + RegisterHookHandler(agent.AgentNameOpenClaw, openclaw.HookNameSessionStart, func() error { + enabled, err := IsEnabled() + if err == nil && !enabled { + return nil + } + return handleOpenClawSessionStart() + }) + + RegisterHookHandler(agent.AgentNameOpenClaw, openclaw.HookNameSessionEnd, func() error { + enabled, err := IsEnabled() + if err == nil && !enabled { + return nil + } + return handleOpenClawSessionEnd() + }) + + RegisterHookHandler(agent.AgentNameOpenClaw, openclaw.HookNameStop, func() error { + enabled, err := IsEnabled() + if err == nil && !enabled { + return nil + } + return handleOpenClawStop() + }) + + RegisterHookHandler(agent.AgentNameOpenClaw, openclaw.HookNameUserPromptSubmit, func() error { + enabled, err := IsEnabled() + if err == nil && !enabled { + return nil + } + return handleOpenClawUserPromptSubmit() + }) + RegisterHookHandler(agent.AgentNameGemini, geminicli.HookNamePreCompress, func() error { enabled, err := IsEnabled() if err == nil && !enabled { diff --git a/cmd/entire/cli/hooks_cmd.go b/cmd/entire/cli/hooks_cmd.go index d12922523..36c3823bc 100644 --- a/cmd/entire/cli/hooks_cmd.go +++ b/cmd/entire/cli/hooks_cmd.go @@ -5,6 +5,7 @@ import ( // Import agents to ensure they are registered before we iterate _ "github.com/entireio/cli/cmd/entire/cli/agent/claudecode" _ "github.com/entireio/cli/cmd/entire/cli/agent/geminicli" + _ "github.com/entireio/cli/cmd/entire/cli/agent/openclaw" "github.com/spf13/cobra" ) diff --git a/cmd/entire/cli/hooks_openclaw_handlers.go b/cmd/entire/cli/hooks_openclaw_handlers.go new file mode 100644 index 000000000..5ae311dd3 --- /dev/null +++ b/cmd/entire/cli/hooks_openclaw_handlers.go @@ -0,0 +1,313 @@ +// hooks_openclaw_handlers.go contains OpenClaw specific hook handler implementations. +// These are called by the hook registry in hook_registry.go. +package cli + +import ( + "context" + "fmt" + "log/slog" + "os" + "path/filepath" + "strings" + + "github.com/entireio/cli/cmd/entire/cli/agent" + "github.com/entireio/cli/cmd/entire/cli/logging" + "github.com/entireio/cli/cmd/entire/cli/paths" + "github.com/entireio/cli/cmd/entire/cli/strategy" +) + +// handleOpenClawSessionStart handles the SessionStart hook for OpenClaw. +func handleOpenClawSessionStart() error { + return handleSessionStartCommon() +} + +// handleOpenClawSessionEnd handles the SessionEnd hook for OpenClaw. +func handleOpenClawSessionEnd() error { + ag, err := GetCurrentHookAgent() + if err != nil { + return fmt.Errorf("failed to get agent: %w", err) + } + + input, err := ag.ParseHookInput(agent.HookSessionEnd, os.Stdin) + if err != nil { + return fmt.Errorf("failed to parse hook input: %w", err) + } + + logCtx := logging.WithAgent(logging.WithComponent(context.Background(), "hooks"), ag.Name()) + logging.Info(logCtx, "session-end", + slog.String("hook", "session-end"), + slog.String("hook_type", "agent"), + slog.String("model_session_id", input.SessionID), + ) + + if input.SessionID == "" { + return nil + } + + if err := markSessionEnded(input.SessionID); err != nil { + fmt.Fprintf(os.Stderr, "Warning: failed to mark session ended: %v\n", err) + } + return nil +} + +// handleOpenClawUserPromptSubmit captures initial state on user prompt submit. +func handleOpenClawUserPromptSubmit() error { + hookData, err := parseAndLogHookInput() + if err != nil { + return err + } + + if err := CapturePrePromptState(hookData.sessionID, hookData.input.SessionRef); err != nil { + return err + } + + strat := GetStrategy() + if initializer, ok := strat.(strategy.SessionInitializer); ok { + agentType := hookData.agent.Type() + if err := initializer.InitializeSession(hookData.sessionID, agentType, hookData.input.SessionRef, hookData.input.UserPrompt); err != nil { + fmt.Fprintf(os.Stderr, "Warning: failed to initialize session state: %v\n", err) + } + } + + return nil +} + +// handleOpenClawStop commits the session changes with metadata. +func handleOpenClawStop() error { + ag, err := GetCurrentHookAgent() + if err != nil { + return fmt.Errorf("failed to get agent: %w", err) + } + + input, err := ag.ParseHookInput(agent.HookStop, os.Stdin) + if err != nil { + return fmt.Errorf("failed to parse hook input: %w", err) + } + + logCtx := logging.WithAgent(logging.WithComponent(context.Background(), "hooks"), ag.Name()) + logging.Info(logCtx, "stop", + slog.String("hook", "stop"), + slog.String("hook_type", "agent"), + slog.String("model_session_id", input.SessionID), + slog.String("transcript_path", input.SessionRef), + ) + + sessionID := input.SessionID + if sessionID == "" { + sessionID = unknownSessionID + } + + transcriptPath := input.SessionRef + if transcriptPath == "" || !fileExists(transcriptPath) { + return fmt.Errorf("transcript file not found or empty: %s", transcriptPath) + } + + if repo, err := strategy.OpenRepository(); err == nil && strategy.IsEmptyRepository(repo) { + fmt.Fprintln(os.Stderr, "Entire: skipping checkpoint. Will activate after first commit.") + return NewSilentError(strategy.ErrEmptyRepository) + } + + sessionDir := paths.SessionMetadataDirFromSessionID(sessionID) + sessionDirAbs, err := paths.AbsPath(sessionDir) + if err != nil { + sessionDirAbs = sessionDir + } + if err := os.MkdirAll(sessionDirAbs, 0o750); err != nil { + return fmt.Errorf("failed to create session directory: %w", err) + } + + // Copy transcript + logFile := filepath.Join(sessionDirAbs, paths.TranscriptFileName) + if err := copyFile(transcriptPath, logFile); err != nil { + return fmt.Errorf("failed to copy transcript: %w", err) + } + fmt.Fprintf(os.Stderr, "Copied transcript to: %s\n", sessionDir+"/"+paths.TranscriptFileName) + + // Load pre-prompt state + preState, err := LoadPrePromptState(sessionID) + if err != nil { + fmt.Fprintf(os.Stderr, "Warning: failed to load pre-prompt state: %v\n", err) + } + + var transcriptOffset int + if preState != nil && preState.StepTranscriptStart > 0 { + transcriptOffset = preState.StepTranscriptStart + fmt.Fprintf(os.Stderr, "Pre-prompt state found: parsing transcript from line %d\n", transcriptOffset) + } else { + sessionState, loadErr := strategy.LoadSessionState(sessionID) + if loadErr != nil { + fmt.Fprintf(os.Stderr, "Warning: failed to load session state: %v\n", loadErr) + } + if sessionState != nil && sessionState.CheckpointTranscriptStart > 0 { + transcriptOffset = sessionState.CheckpointTranscriptStart + fmt.Fprintf(os.Stderr, "Session state found: parsing transcript from line %d\n", transcriptOffset) + } + } + + var transcript []transcriptLine + var totalLines int + if transcriptOffset > 0 { + transcript, totalLines, err = parseTranscriptFromLine(transcriptPath, transcriptOffset) + if err != nil { + return fmt.Errorf("failed to parse transcript from line %d: %w", transcriptOffset, err) + } + fmt.Fprintf(os.Stderr, "Parsed %d new transcript lines (total: %d)\n", len(transcript), totalLines) + } else { + transcript, totalLines, err = parseTranscriptFromLine(transcriptPath, 0) + if err != nil { + return fmt.Errorf("failed to parse transcript: %w", err) + } + } + + // Extract prompts + allPrompts := extractUserPrompts(transcript) + promptFile := filepath.Join(sessionDirAbs, paths.PromptFileName) + promptContent := strings.Join(allPrompts, "\n\n---\n\n") + if err := os.WriteFile(promptFile, []byte(promptContent), 0o600); err != nil { + return fmt.Errorf("failed to write prompt file: %w", err) + } + fmt.Fprintf(os.Stderr, "Extracted %d prompt(s) to: %s\n", len(allPrompts), sessionDir+"/"+paths.PromptFileName) + + // Extract summary + summaryFile := filepath.Join(sessionDirAbs, paths.SummaryFileName) + summary := extractLastAssistantMessage(transcript) + if err := os.WriteFile(summaryFile, []byte(summary), 0o600); err != nil { + return fmt.Errorf("failed to write summary file: %w", err) + } + fmt.Fprintf(os.Stderr, "Extracted summary to: %s\n", sessionDir+"/"+paths.SummaryFileName) + + // Get modified files from transcript + modifiedFiles := extractModifiedFiles(transcript) + + lastPrompt := "" + if len(allPrompts) > 0 { + lastPrompt = allPrompts[len(allPrompts)-1] + } + commitMessage := generateCommitMessage(lastPrompt) + fmt.Fprintf(os.Stderr, "Using commit message: %s\n", commitMessage) + + repoRoot, err := paths.RepoRoot() + if err != nil { + return fmt.Errorf("failed to get repo root: %w", err) + } + + if preState != nil { + fmt.Fprintf(os.Stderr, "Pre-prompt state: %d pre-existing untracked files\n", len(preState.UntrackedFiles)) + } + + changes, err := DetectFileChanges(preState.PreUntrackedFiles()) + if err != nil { + fmt.Fprintf(os.Stderr, "Warning: failed to compute file changes: %v\n", err) + } + + relModifiedFiles := FilterAndNormalizePaths(modifiedFiles, repoRoot) + var relNewFiles, relDeletedFiles []string + if changes != nil { + relNewFiles = FilterAndNormalizePaths(changes.New, repoRoot) + relDeletedFiles = FilterAndNormalizePaths(changes.Deleted, repoRoot) + } + + totalChanges := len(relModifiedFiles) + len(relNewFiles) + len(relDeletedFiles) + if totalChanges == 0 { + fmt.Fprintf(os.Stderr, "No files were modified during this session\n") + fmt.Fprintf(os.Stderr, "Skipping commit\n") + transitionSessionTurnEnd(sessionID) + if err := CleanupPrePromptState(sessionID); err != nil { + fmt.Fprintf(os.Stderr, "Warning: failed to cleanup pre-prompt state: %v\n", err) + } + return nil + } + + fmt.Fprintf(os.Stderr, "Files modified during session (%d):\n", len(relModifiedFiles)) + for _, file := range relModifiedFiles { + fmt.Fprintf(os.Stderr, " - %s\n", file) + } + if len(relNewFiles) > 0 { + fmt.Fprintf(os.Stderr, "New files created (%d):\n", len(relNewFiles)) + for _, file := range relNewFiles { + fmt.Fprintf(os.Stderr, " + %s\n", file) + } + } + if len(relDeletedFiles) > 0 { + fmt.Fprintf(os.Stderr, "Files deleted (%d):\n", len(relDeletedFiles)) + for _, file := range relDeletedFiles { + fmt.Fprintf(os.Stderr, " - %s\n", file) + } + } + + contextFile := filepath.Join(sessionDirAbs, paths.ContextFileName) + if err := createContextFileMinimal(contextFile, commitMessage, sessionID, promptFile, summaryFile, transcript); err != nil { + return fmt.Errorf("failed to create context file: %w", err) + } + fmt.Fprintf(os.Stderr, "Created context file: %s\n", sessionDir+"/"+paths.ContextFileName) + + author, err := GetGitAuthor() + if err != nil { + return fmt.Errorf("failed to get git author: %w", err) + } + + strat := GetStrategy() + if err := strat.EnsureSetup(); err != nil { + fmt.Fprintf(os.Stderr, "Warning: failed to ensure strategy setup: %v\n", err) + } + + var agentType agent.AgentType + if hookAgent, agentErr := GetCurrentHookAgent(); agentErr == nil { + agentType = hookAgent.Type() + } + + var transcriptIdentifierAtStart string + var transcriptLinesAtStart int + if preState != nil { + transcriptIdentifierAtStart = preState.LastTranscriptIdentifier + transcriptLinesAtStart = preState.StepTranscriptStart + } + + ctx := strategy.SaveContext{ + SessionID: sessionID, + ModifiedFiles: relModifiedFiles, + NewFiles: relNewFiles, + DeletedFiles: relDeletedFiles, + MetadataDir: sessionDir, + MetadataDirAbs: sessionDirAbs, + CommitMessage: commitMessage, + TranscriptPath: transcriptPath, + AuthorName: author.Name, + AuthorEmail: author.Email, + AgentType: agentType, + StepTranscriptIdentifier: transcriptIdentifierAtStart, + StepTranscriptStart: transcriptLinesAtStart, + } + + if err := strat.SaveChanges(ctx); err != nil { + return fmt.Errorf("failed to save changes: %w", err) + } + + if strat.Name() == strategy.StrategyNameAutoCommit { + sessionState, loadErr := strategy.LoadSessionState(sessionID) + if loadErr != nil { + fmt.Fprintf(os.Stderr, "Warning: failed to load session state: %v\n", loadErr) + } + if sessionState == nil { + sessionState = &strategy.SessionState{ + SessionID: sessionID, + } + } + sessionState.CheckpointTranscriptStart = totalLines + sessionState.StepCount++ + if updateErr := strategy.SaveSessionState(sessionState); updateErr != nil { + fmt.Fprintf(os.Stderr, "Warning: failed to update session state: %v\n", updateErr) + } else { + fmt.Fprintf(os.Stderr, "Updated session state: transcript position=%d, checkpoint=%d\n", + totalLines, sessionState.StepCount) + } + } + + transitionSessionTurnEnd(sessionID) + + if err := CleanupPrePromptState(sessionID); err != nil { + fmt.Fprintf(os.Stderr, "Warning: failed to cleanup pre-prompt state: %v\n", err) + } + + return nil +}