From d885cd8ad054603b7678a905438dfe21b9d3ad41 Mon Sep 17 00:00:00 2001 From: Helmut Januschka Date: Wed, 11 Feb 2026 00:36:32 +0100 Subject: [PATCH 1/3] feat: Add complete Pi coding agent integration Implements full Pi coding agent support with: - Complete Agent interface implementation - JSONL transcript parsing matching Pi's session format - Modified files extraction from write/edit tool calls - TranscriptAnalyzer interface for efficient position tracking - TranscriptChunker interface for large transcript handling - HookSupport for extension-based hook installation - HookHandler with session-start, session-end, stop, user-prompt-submit hooks - Proper session directory detection (~/.pi/agent/sessions/) - Extension package installation via .pi/settings.json The implementation follows the same patterns as claudecode and geminicli agents, with full support for: - Hook input parsing from stdin JSON - Session reading/writing with NativeData preservation - File modification tracking from tool results - Transcript position tracking for incremental updates Works with the companion npm package @anthropic/pi-entire which provides the pi extension that calls these hooks. Closes #221 --- cmd/entire/cli/agent/pi/hooks.go | 170 ++++++++++++++ cmd/entire/cli/agent/pi/pi.go | 375 +++++++++++++++++++++++++++++++ cmd/entire/cli/agent/pi/types.go | 60 +++++ cmd/entire/cli/agent/registry.go | 2 + 4 files changed, 607 insertions(+) create mode 100644 cmd/entire/cli/agent/pi/hooks.go create mode 100644 cmd/entire/cli/agent/pi/pi.go create mode 100644 cmd/entire/cli/agent/pi/types.go diff --git a/cmd/entire/cli/agent/pi/hooks.go b/cmd/entire/cli/agent/pi/hooks.go new file mode 100644 index 000000000..76259cbfc --- /dev/null +++ b/cmd/entire/cli/agent/pi/hooks.go @@ -0,0 +1,170 @@ +package pi + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + + "github.com/entireio/cli/cmd/entire/cli/agent" + "github.com/entireio/cli/cmd/entire/cli/paths" +) + +// Ensure PiAgent implements HookSupport and HookHandler +var ( + _ agent.HookSupport = (*PiAgent)(nil) + _ agent.HookHandler = (*PiAgent)(nil) +) + +// Pi hook names - these become subcommands under `entire hooks pi` +const ( + HookNameSessionStart = "session-start" + HookNameSessionEnd = "session-end" + HookNameStop = "stop" + HookNameUserPromptSubmit = "user-prompt-submit" +) + +// PiSettingsFileName is the settings file used by pi. +const PiSettingsFileName = "settings.json" + +// entireExtensionPackage is the npm package for the pi-entire extension. +const entireExtensionPackage = "npm:@anthropic/pi-entire" + +// GetHookNames returns the hook verbs Pi supports. +// These become subcommands: entire hooks pi +func (p *PiAgent) GetHookNames() []string { + return []string{ + HookNameSessionStart, + HookNameSessionEnd, + HookNameStop, + HookNameUserPromptSubmit, + } +} + +// InstallHooks installs pi-entire extension in .pi/settings.json. +// Pi uses extensions rather than external hooks, so we add the extension package. +// Returns the number of changes made. +func (p *PiAgent) InstallHooks(localDev bool, force bool) (int, error) { + repoRoot, err := paths.RepoRoot() + if err != nil { + repoRoot, err = os.Getwd() + if err != nil { + return 0, fmt.Errorf("failed to get current directory: %w", err) + } + } + + settingsPath := filepath.Join(repoRoot, ".pi", PiSettingsFileName) + + // Read existing settings if they exist + var settings PiSettings + existingData, readErr := os.ReadFile(settingsPath) //nolint:gosec + if readErr == nil { + if err := json.Unmarshal(existingData, &settings); err != nil { + return 0, fmt.Errorf("failed to parse existing settings.json: %w", err) + } + } + + // Check if extension is already installed + for _, pkg := range settings.Packages { + if pkg == entireExtensionPackage { + return 0, nil // Already installed + } + } + + // Add the extension package + settings.Packages = append(settings.Packages, entireExtensionPackage) + + // Create directory if needed + if err := os.MkdirAll(filepath.Dir(settingsPath), 0o750); err != nil { + return 0, fmt.Errorf("failed to create .pi directory: %w", err) + } + + // Write settings + output, err := json.MarshalIndent(settings, "", " ") + if err != nil { + return 0, fmt.Errorf("failed to marshal settings: %w", err) + } + + if err := os.WriteFile(settingsPath, output, 0o644); err != nil { + return 0, fmt.Errorf("failed to write settings.json: %w", err) + } + + return 1, nil +} + +// UninstallHooks removes pi-entire extension from settings. +func (p *PiAgent) UninstallHooks() error { + repoRoot, err := paths.RepoRoot() + if err != nil { + repoRoot = "." + } + + settingsPath := filepath.Join(repoRoot, ".pi", PiSettingsFileName) + data, err := os.ReadFile(settingsPath) //nolint:gosec + if err != nil { + return nil // No settings file means nothing to uninstall + } + + var settings PiSettings + if err := json.Unmarshal(data, &settings); err != nil { + return fmt.Errorf("failed to parse settings.json: %w", err) + } + + // Remove the extension package + newPackages := make([]string, 0, len(settings.Packages)) + for _, pkg := range settings.Packages { + if pkg != entireExtensionPackage { + newPackages = append(newPackages, pkg) + } + } + settings.Packages = newPackages + + // Write back + output, err := json.MarshalIndent(settings, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal settings: %w", err) + } + + if err := os.WriteFile(settingsPath, output, 0o644); err != nil { + return fmt.Errorf("failed to write settings.json: %w", err) + } + + return nil +} + +// AreHooksInstalled checks if pi-entire extension is installed. +func (p *PiAgent) AreHooksInstalled() bool { + repoRoot, err := paths.RepoRoot() + if err != nil { + repoRoot = "." + } + + settingsPath := filepath.Join(repoRoot, ".pi", PiSettingsFileName) + data, err := os.ReadFile(settingsPath) //nolint:gosec + if err != nil { + return false + } + + var settings PiSettings + if err := json.Unmarshal(data, &settings); err != nil { + return false + } + + for _, pkg := range settings.Packages { + if pkg == entireExtensionPackage { + return true + } + } + + return false +} + +// GetSupportedHooks returns the hook types Pi supports. +func (p *PiAgent) GetSupportedHooks() []agent.HookType { + return []agent.HookType{ + agent.HookSessionStart, + agent.HookSessionEnd, + agent.HookUserPromptSubmit, + agent.HookStop, + } +} diff --git a/cmd/entire/cli/agent/pi/pi.go b/cmd/entire/cli/agent/pi/pi.go new file mode 100644 index 000000000..b5902ce22 --- /dev/null +++ b/cmd/entire/cli/agent/pi/pi.go @@ -0,0 +1,375 @@ +// Package pi implements the Agent interface for pi coding agent. +package pi + +import ( + "bufio" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "time" + + "github.com/entireio/cli/cmd/entire/cli/agent" + "github.com/entireio/cli/cmd/entire/cli/paths" +) + +// Agent name and type constants +const ( + AgentNamePi agent.AgentName = "pi" + AgentTypePi agent.AgentType = "Pi Coding Agent" +) + +//nolint:gochecknoinits // Agent self-registration is the intended pattern +func init() { + agent.Register(AgentNamePi, NewPiAgent) +} + +// PiAgent implements the Agent interface for pi coding agent. +type PiAgent struct{} + +// NewPiAgent creates a new pi agent instance. +func NewPiAgent() agent.Agent { + return &PiAgent{} +} + +// Name returns the agent registry key. +func (p *PiAgent) Name() agent.AgentName { + return AgentNamePi +} + +// Type returns the agent type identifier. +func (p *PiAgent) Type() agent.AgentType { + return AgentTypePi +} + +// Description returns a human-readable description. +func (p *PiAgent) Description() string { + return "Pi - AI coding agent CLI" +} + +// DetectPresence checks if pi is configured in the repository. +func (p *PiAgent) DetectPresence() (bool, error) { + repoRoot, err := paths.RepoRoot() + if err != nil { + repoRoot = "." + } + + // Check for .pi directory + piDir := filepath.Join(repoRoot, ".pi") + if _, err := os.Stat(piDir); err == nil { + return true, nil + } + + // Check for .pi/settings.json + settingsFile := filepath.Join(repoRoot, ".pi", "settings.json") + if _, err := os.Stat(settingsFile); err == nil { + return true, nil + } + + return false, nil +} + +// GetHookConfigPath returns the path to pi's settings file. +// Pi uses extensions rather than hook config, so this returns empty. +func (p *PiAgent) GetHookConfigPath() string { + return "" +} + +// SupportsHooks returns true as pi supports lifecycle events via extensions. +func (p *PiAgent) SupportsHooks() bool { + return true +} + +// ParseHookInput parses pi hook input from stdin. +func (p *PiAgent) 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{}), + } + + // Parse the JSON input + var raw piHookInput + 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 + + // Store modified files in RawData + if len(raw.ModifiedFiles) > 0 { + input.RawData["modified_files"] = raw.ModifiedFiles + } + + return input, nil +} + +// GetSessionID extracts the session ID from hook input. +func (p *PiAgent) GetSessionID(input *agent.HookInput) string { + return input.SessionID +} + +// TransformSessionID converts a pi session ID to an Entire session ID. +// Pi uses UUIDs, which we use directly. +func (p *PiAgent) TransformSessionID(agentSessionID string) string { + return agentSessionID +} + +// ExtractAgentSessionID extracts the pi session ID from an Entire session ID. +func (p *PiAgent) ExtractAgentSessionID(entireSessionID string) string { + return entireSessionID +} + +// GetSessionDir returns the directory where pi stores session transcripts. +func (p *PiAgent) GetSessionDir(repoPath string) (string, error) { + homeDir, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("failed to get home directory: %w", err) + } + + // Pi stores sessions in ~/.pi/agent/sessions/----/ + sanitizedPath := sanitizePathForPi(repoPath) + return filepath.Join(homeDir, ".pi", "agent", "sessions", sanitizedPath), nil +} + +// sanitizePathForPi converts a path to pi's session directory format. +// Pi replaces / with - and adds -- prefix/suffix. +func sanitizePathForPi(path string) string { + // Replace path separators with dashes + sanitized := strings.ReplaceAll(path, "/", "-") + sanitized = strings.ReplaceAll(sanitized, "\\", "-") + return "--" + sanitized + "--" +} + +// ReadSession reads a session from pi's storage (JSONL transcript file). +func (p *PiAgent) ReadSession(input *agent.HookInput) (*agent.AgentSession, error) { + if input.SessionRef == "" { + return nil, errors.New("session reference (transcript path) is required") + } + + // Read the raw JSONL file + data, err := os.ReadFile(input.SessionRef) + if err != nil { + return nil, fmt.Errorf("failed to read transcript: %w", err) + } + + // Parse to extract computed fields + modifiedFiles := p.extractModifiedFiles(data) + + return &agent.AgentSession{ + SessionID: input.SessionID, + AgentName: p.Name(), + SessionRef: input.SessionRef, + StartTime: time.Now(), + NativeData: data, + ModifiedFiles: modifiedFiles, + }, nil +} + +// WriteSession writes a session to pi's storage. +func (p *PiAgent) WriteSession(session *agent.AgentSession) error { + if session == nil { + return errors.New("session is nil") + } + + if session.AgentName != "" && session.AgentName != p.Name() { + return fmt.Errorf("session belongs to agent %q, not %q", session.AgentName, p.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 a pi session. +func (p *PiAgent) FormatResumeCommand(sessionID string) string { + return "pi # then use /resume to select session" +} + +// extractModifiedFiles parses JSONL and extracts modified file paths. +func (p *PiAgent) extractModifiedFiles(data []byte) []string { + files := make(map[string]bool) + + scanner := bufio.NewScanner(strings.NewReader(string(data))) + // Increase buffer for large lines + buf := make([]byte, 0, 64*1024) + scanner.Buffer(buf, 1024*1024) + + for scanner.Scan() { + line := scanner.Bytes() + if len(line) == 0 { + continue + } + + var entry piSessionEntry + if err := json.Unmarshal(line, &entry); err != nil { + continue + } + + // Check for tool results with file paths + if entry.Type == "message" && entry.Message != nil { + msg := entry.Message + + // Check toolResult messages for write/edit + if msg.Role == "toolResult" { + if msg.ToolName == "write" || msg.ToolName == "edit" { + if details, ok := msg.Details.(map[string]interface{}); ok { + if path, ok := details["path"].(string); ok && path != "" { + files[path] = true + } + } + } + } + + // Check assistant messages for tool calls + if msg.Role == "assistant" { + for _, content := range msg.Content { + if content.Type == "toolCall" { + if content.Name == "write" || content.Name == "edit" { + if args, ok := content.Arguments.(map[string]interface{}); ok { + if path, ok := args["path"].(string); ok && path != "" { + files[path] = true + } + } + } + } + } + } + } + } + + result := make([]string, 0, len(files)) + for f := range files { + result = append(result, f) + } + return result +} + +// TranscriptAnalyzer interface implementation + +// GetTranscriptPosition returns the current line count of a pi transcript. +func (p *PiAgent) GetTranscriptPosition(path string) (int, error) { + if path == "" { + return 0, nil + } + + file, err := os.Open(path) //nolint:gosec + 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 (p *PiAgent) ExtractModifiedFilesFromOffset(path string, startOffset int) (files []string, currentPosition int, err error) { + if path == "" { + return nil, 0, nil + } + + file, openErr := os.Open(path) //nolint:gosec + if openErr != nil { + return nil, 0, fmt.Errorf("failed to open transcript file: %w", openErr) + } + defer file.Close() + + reader := bufio.NewReader(file) + modFiles := make(map[string]bool) + 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 entry piSessionEntry + if parseErr := json.Unmarshal(lineData, &entry); parseErr == nil { + // Extract files from this entry + if entry.Type == "message" && entry.Message != nil { + msg := entry.Message + if msg.Role == "toolResult" && (msg.ToolName == "write" || msg.ToolName == "edit") { + if details, ok := msg.Details.(map[string]interface{}); ok { + if filePath, ok := details["path"].(string); ok && filePath != "" { + modFiles[filePath] = true + } + } + } + } + } + } + } + + if readErr == io.EOF { + break + } + } + + result := make([]string, 0, len(modFiles)) + for f := range modFiles { + result = append(result, f) + } + + return result, lineNum, nil +} + +// TranscriptChunker interface implementation + +// ChunkTranscript splits a JSONL transcript at line boundaries. +func (p *PiAgent) 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. +func (p *PiAgent) ReassembleTranscript(chunks [][]byte) ([]byte, error) { + return agent.ReassembleJSONL(chunks), nil +} diff --git a/cmd/entire/cli/agent/pi/types.go b/cmd/entire/cli/agent/pi/types.go new file mode 100644 index 000000000..ca0a631fb --- /dev/null +++ b/cmd/entire/cli/agent/pi/types.go @@ -0,0 +1,60 @@ +package pi + +// piHookInput represents the JSON input from pi extension hooks. +type piHookInput struct { + SessionID string `json:"session_id"` + TranscriptPath string `json:"transcript_path"` + Prompt string `json:"prompt,omitempty"` + ModifiedFiles []string `json:"modified_files,omitempty"` +} + +// piSessionEntry represents a line in pi's JSONL session file. +type piSessionEntry struct { + Type string `json:"type"` + ID string `json:"id,omitempty"` + ParentID string `json:"parentId,omitempty"` + Timestamp string `json:"timestamp,omitempty"` + Message *piMessage `json:"message,omitempty"` + Version int `json:"version,omitempty"` + CWD string `json:"cwd,omitempty"` + Summary string `json:"summary,omitempty"` + Data interface{} `json:"data,omitempty"` +} + +// piMessage represents a message in pi's session format. +type piMessage struct { + Role string `json:"role"` + Content []piContentBlock `json:"content,omitempty"` + ToolName string `json:"toolName,omitempty"` + ToolCallID string `json:"toolCallId,omitempty"` + Details interface{} `json:"details,omitempty"` + IsError bool `json:"isError,omitempty"` + Timestamp int64 `json:"timestamp,omitempty"` + Provider string `json:"provider,omitempty"` + Model string `json:"model,omitempty"` +} + +// piContentBlock represents a content block in pi messages. +type piContentBlock struct { + Type string `json:"type"` + Text string `json:"text,omitempty"` + ID string `json:"id,omitempty"` + Name string `json:"name,omitempty"` + Arguments interface{} `json:"arguments,omitempty"` +} + +// PiSettings represents pi's .pi/settings.json format. +type PiSettings struct { + Packages []string `json:"packages,omitempty"` + Extensions []string `json:"extensions,omitempty"` +} + +// PiSessionHeader represents the first line of a pi session file. +type PiSessionHeader struct { + Type string `json:"type"` // "session" + Version int `json:"version"` + ID string `json:"id"` + Timestamp string `json:"timestamp"` + CWD string `json:"cwd"` + ParentSession string `json:"parentSession,omitempty"` +} diff --git a/cmd/entire/cli/agent/registry.go b/cmd/entire/cli/agent/registry.go index 901300196..638d5c425 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" + AgentNamePi AgentName = "pi" ) // Agent type constants (type identifiers stored in metadata/trailers) const ( AgentTypeClaudeCode AgentType = "Claude Code" AgentTypeGemini AgentType = "Gemini CLI" + AgentTypePi AgentType = "Pi Coding Agent" AgentTypeUnknown AgentType = "Agent" // Fallback for backwards compatibility ) From b78731546a16e1611f3cffd2ae0eff68c9ab85e7 Mon Sep 17 00:00:00 2001 From: Helmut Januschka Date: Wed, 11 Feb 2026 00:40:16 +0100 Subject: [PATCH 2/3] feat(pi): Add transcript helper methods for full feature parity - GetLastUserPrompt() - Extract last user prompt from session - TruncateAtUUID() - Truncate session at entry ID for rewind - FindCheckpointUUID() - Find entry ID for tool call (checkpoint lookup) - CalculateTokenUsage() - Basic token usage tracking Also fix package name to @hjanuschka/pi-entire --- cmd/entire/cli/agent/pi/hooks.go | 2 +- cmd/entire/cli/agent/pi/pi.go | 157 +++++++++++++++++++++++++++++++ 2 files changed, 158 insertions(+), 1 deletion(-) diff --git a/cmd/entire/cli/agent/pi/hooks.go b/cmd/entire/cli/agent/pi/hooks.go index 76259cbfc..eebbee61b 100644 --- a/cmd/entire/cli/agent/pi/hooks.go +++ b/cmd/entire/cli/agent/pi/hooks.go @@ -28,7 +28,7 @@ const ( const PiSettingsFileName = "settings.json" // entireExtensionPackage is the npm package for the pi-entire extension. -const entireExtensionPackage = "npm:@anthropic/pi-entire" +const entireExtensionPackage = "npm:@hjanuschka/pi-entire" // GetHookNames returns the hook verbs Pi supports. // These become subcommands: entire hooks pi diff --git a/cmd/entire/cli/agent/pi/pi.go b/cmd/entire/cli/agent/pi/pi.go index b5902ce22..6621c7915 100644 --- a/cmd/entire/cli/agent/pi/pi.go +++ b/cmd/entire/cli/agent/pi/pi.go @@ -210,6 +210,163 @@ func (p *PiAgent) FormatResumeCommand(sessionID string) string { return "pi # then use /resume to select session" } +// GetLastUserPrompt extracts the last user prompt from the session. +func (p *PiAgent) GetLastUserPrompt(session *agent.AgentSession) string { + if session == nil || len(session.NativeData) == 0 { + return "" + } + + scanner := bufio.NewScanner(strings.NewReader(string(session.NativeData))) + buf := make([]byte, 0, 64*1024) + scanner.Buffer(buf, 1024*1024) + + var lastPrompt string + + for scanner.Scan() { + line := scanner.Bytes() + if len(line) == 0 { + continue + } + + var entry piSessionEntry + if err := json.Unmarshal(line, &entry); err != nil { + continue + } + + if entry.Type == "message" && entry.Message != nil && entry.Message.Role == "user" { + // Extract text from content + for _, block := range entry.Message.Content { + if block.Type == "text" && block.Text != "" { + lastPrompt = block.Text + } + } + } + } + + return lastPrompt +} + +// TruncateAtUUID returns a new session truncated at the given entry ID (inclusive). +func (p *PiAgent) TruncateAtUUID(session *agent.AgentSession, entryID string) (*agent.AgentSession, error) { + if session == nil { + return nil, errors.New("session is nil") + } + + if len(session.NativeData) == 0 { + return nil, errors.New("session has no native data") + } + + if entryID == "" { + // No truncation needed, return copy + return &agent.AgentSession{ + SessionID: session.SessionID, + AgentName: session.AgentName, + RepoPath: session.RepoPath, + SessionRef: session.SessionRef, + StartTime: session.StartTime, + NativeData: session.NativeData, + ModifiedFiles: session.ModifiedFiles, + }, nil + } + + // Parse and truncate + var result []byte + scanner := bufio.NewScanner(strings.NewReader(string(session.NativeData))) + buf := make([]byte, 0, 64*1024) + scanner.Buffer(buf, 1024*1024) + + for scanner.Scan() { + line := scanner.Bytes() + if len(line) == 0 { + continue + } + + // Add this line to result + result = append(result, line...) + result = append(result, '\n') + + // Check if this is the target entry + var entry piSessionEntry + if err := json.Unmarshal(line, &entry); err == nil { + if entry.ID == entryID { + break + } + } + } + + return &agent.AgentSession{ + SessionID: session.SessionID, + AgentName: session.AgentName, + RepoPath: session.RepoPath, + SessionRef: session.SessionRef, + StartTime: session.StartTime, + NativeData: result, + ModifiedFiles: p.extractModifiedFiles(result), + }, nil +} + +// FindCheckpointUUID finds the entry ID of the message containing the tool result +// for the given tool call ID. +func (p *PiAgent) FindCheckpointUUID(session *agent.AgentSession, toolCallID string) (string, bool) { + if session == nil || len(session.NativeData) == 0 { + return "", false + } + + scanner := bufio.NewScanner(strings.NewReader(string(session.NativeData))) + buf := make([]byte, 0, 64*1024) + scanner.Buffer(buf, 1024*1024) + + for scanner.Scan() { + line := scanner.Bytes() + if len(line) == 0 { + continue + } + + var entry piSessionEntry + if err := json.Unmarshal(line, &entry); err != nil { + continue + } + + if entry.Type == "message" && entry.Message != nil && entry.Message.Role == "toolResult" { + if entry.Message.ToolCallID == toolCallID { + return entry.ID, true + } + } + } + + return "", false +} + +// CalculateTokenUsage calculates token usage from a pi transcript. +// Pi stores usage in assistant messages. +func (p *PiAgent) CalculateTokenUsage(transcript []byte) *agent.TokenUsage { + usage := &agent.TokenUsage{} + + scanner := bufio.NewScanner(strings.NewReader(string(transcript))) + buf := make([]byte, 0, 64*1024) + scanner.Buffer(buf, 1024*1024) + + for scanner.Scan() { + line := scanner.Bytes() + if len(line) == 0 { + continue + } + + var entry piSessionEntry + if err := json.Unmarshal(line, &entry); err != nil { + continue + } + + if entry.Type == "message" && entry.Message != nil && entry.Message.Role == "assistant" { + // Pi stores usage in the message - we'd need to parse it + // For now, count API calls + usage.APICallCount++ + } + } + + return usage +} + // extractModifiedFiles parses JSONL and extracts modified file paths. func (p *PiAgent) extractModifiedFiles(data []byte) []string { files := make(map[string]bool) From 2dca1ed3012b4ebb6837538f1b46c5085b4d1952 Mon Sep 17 00:00:00 2001 From: Helmut Januschka Date: Wed, 11 Feb 2026 08:12:14 +0100 Subject: [PATCH 3/3] fix(pi-agent): Address Cursor Bugbot review comments Fixes all 6 issues identified in PR #233 code review: High Severity: - Pi agent registration: Added side-effect imports to hooks_cmd.go and config.go to ensure Pi agent init() runs and registers with the agent registry. Without this, 'entire hooks pi' commands were non-functional. - Settings round-trip preservation: Refactored InstallHooks, UninstallHooks, and AreHooksInstalled to use map[string]json.RawMessage pattern (matching Claude agent) to preserve unknown JSON fields in .pi/settings.json instead of unmarshaling into PiSettings struct that only has Packages/Extensions. Medium Severity: - Incremental file extraction: Added assistant message handling to ExtractModifiedFilesFromOffset to match extractModifiedFiles behavior. Now extracts file paths from both toolResult messages AND assistant messages with toolCall content blocks (write/edit). Low Severity: - Line counting consistency: Fixed off-by-one in GetTranscriptPosition to count last line even when it lacks trailing newline, matching ExtractModifiedFilesFromOffset behavior. - Dead code removal: Removed unreachable .pi/settings.json check in DetectPresence (can't exist if .pi directory doesn't exist). - Constant deduplication: Removed local AgentNamePi/AgentTypePi constants, now using agent.AgentNamePi/AgentTypePi from registry like Claude/Gemini agents do. Closes cursor-bot review feedback on PR #233. --- cmd/entire/cli/agent/pi/hooks.go | 69 +++++++++++++++++++++++++------- cmd/entire/cli/agent/pi/pi.go | 41 +++++++++++-------- cmd/entire/cli/agent/pi/types.go | 36 ++++++++--------- cmd/entire/cli/config.go | 4 +- cmd/entire/cli/hooks_cmd.go | 1 + 5 files changed, 101 insertions(+), 50 deletions(-) diff --git a/cmd/entire/cli/agent/pi/hooks.go b/cmd/entire/cli/agent/pi/hooks.go index eebbee61b..927c6c210 100644 --- a/cmd/entire/cli/agent/pi/hooks.go +++ b/cmd/entire/cli/agent/pi/hooks.go @@ -56,23 +56,41 @@ func (p *PiAgent) InstallHooks(localDev bool, force bool) (int, error) { settingsPath := filepath.Join(repoRoot, ".pi", PiSettingsFileName) // Read existing settings if they exist - var settings PiSettings + // Use map[string]json.RawMessage to preserve unknown fields + var rawSettings map[string]json.RawMessage + var packages []string + existingData, readErr := os.ReadFile(settingsPath) //nolint:gosec if readErr == nil { - if err := json.Unmarshal(existingData, &settings); err != nil { + if err := json.Unmarshal(existingData, &rawSettings); err != nil { return 0, fmt.Errorf("failed to parse existing settings.json: %w", err) } + // Extract packages array if it exists + if packagesRaw, ok := rawSettings["packages"]; ok { + if err := json.Unmarshal(packagesRaw, &packages); err != nil { + return 0, fmt.Errorf("failed to parse packages in settings.json: %w", err) + } + } + } else { + rawSettings = make(map[string]json.RawMessage) } // Check if extension is already installed - for _, pkg := range settings.Packages { + for _, pkg := range packages { if pkg == entireExtensionPackage { return 0, nil // Already installed } } // Add the extension package - settings.Packages = append(settings.Packages, entireExtensionPackage) + packages = append(packages, entireExtensionPackage) + + // Update the packages field in rawSettings + packagesJSON, err := json.Marshal(packages) + if err != nil { + return 0, fmt.Errorf("failed to marshal packages: %w", err) + } + rawSettings["packages"] = packagesJSON // Create directory if needed if err := os.MkdirAll(filepath.Dir(settingsPath), 0o750); err != nil { @@ -80,7 +98,7 @@ func (p *PiAgent) InstallHooks(localDev bool, force bool) (int, error) { } // Write settings - output, err := json.MarshalIndent(settings, "", " ") + output, err := json.MarshalIndent(rawSettings, "", " ") if err != nil { return 0, fmt.Errorf("failed to marshal settings: %w", err) } @@ -105,22 +123,37 @@ func (p *PiAgent) UninstallHooks() error { return nil // No settings file means nothing to uninstall } - var settings PiSettings - if err := json.Unmarshal(data, &settings); err != nil { + // Use map[string]json.RawMessage to preserve unknown fields + var rawSettings map[string]json.RawMessage + if err := json.Unmarshal(data, &rawSettings); err != nil { return fmt.Errorf("failed to parse settings.json: %w", err) } + // Extract and modify packages array + var packages []string + if packagesRaw, ok := rawSettings["packages"]; ok { + if err := json.Unmarshal(packagesRaw, &packages); err != nil { + return fmt.Errorf("failed to parse packages in settings.json: %w", err) + } + } + // Remove the extension package - newPackages := make([]string, 0, len(settings.Packages)) - for _, pkg := range settings.Packages { + newPackages := make([]string, 0, len(packages)) + for _, pkg := range packages { if pkg != entireExtensionPackage { newPackages = append(newPackages, pkg) } } - settings.Packages = newPackages + + // Update the packages field in rawSettings + packagesJSON, err := json.Marshal(newPackages) + if err != nil { + return fmt.Errorf("failed to marshal packages: %w", err) + } + rawSettings["packages"] = packagesJSON // Write back - output, err := json.MarshalIndent(settings, "", " ") + output, err := json.MarshalIndent(rawSettings, "", " ") if err != nil { return fmt.Errorf("failed to marshal settings: %w", err) } @@ -145,12 +178,20 @@ func (p *PiAgent) AreHooksInstalled() bool { return false } - var settings PiSettings - if err := json.Unmarshal(data, &settings); err != nil { + // Use map[string]json.RawMessage for consistency + var rawSettings map[string]json.RawMessage + if err := json.Unmarshal(data, &rawSettings); err != nil { return false } - for _, pkg := range settings.Packages { + var packages []string + if packagesRaw, ok := rawSettings["packages"]; ok { + if err := json.Unmarshal(packagesRaw, &packages); err != nil { + return false + } + } + + for _, pkg := range packages { if pkg == entireExtensionPackage { return true } diff --git a/cmd/entire/cli/agent/pi/pi.go b/cmd/entire/cli/agent/pi/pi.go index 6621c7915..d2b116a86 100644 --- a/cmd/entire/cli/agent/pi/pi.go +++ b/cmd/entire/cli/agent/pi/pi.go @@ -16,15 +16,9 @@ import ( "github.com/entireio/cli/cmd/entire/cli/paths" ) -// Agent name and type constants -const ( - AgentNamePi agent.AgentName = "pi" - AgentTypePi agent.AgentType = "Pi Coding Agent" -) - //nolint:gochecknoinits // Agent self-registration is the intended pattern func init() { - agent.Register(AgentNamePi, NewPiAgent) + agent.Register(agent.AgentNamePi, NewPiAgent) } // PiAgent implements the Agent interface for pi coding agent. @@ -37,12 +31,12 @@ func NewPiAgent() agent.Agent { // Name returns the agent registry key. func (p *PiAgent) Name() agent.AgentName { - return AgentNamePi + return agent.AgentNamePi } // Type returns the agent type identifier. func (p *PiAgent) Type() agent.AgentType { - return AgentTypePi + return agent.AgentTypePi } // Description returns a human-readable description. @@ -63,12 +57,6 @@ func (p *PiAgent) DetectPresence() (bool, error) { return true, nil } - // Check for .pi/settings.json - settingsFile := filepath.Join(repoRoot, ".pi", "settings.json") - if _, err := os.Stat(settingsFile); err == nil { - return true, nil - } - return false, nil } @@ -447,14 +435,16 @@ func (p *PiAgent) GetTranscriptPosition(path string) (int, error) { lineCount := 0 for { - _, err := reader.ReadBytes('\n') + lineData, err := reader.ReadBytes('\n') + if len(lineData) > 0 { + lineCount++ + } if err != nil { if err == io.EOF { break } return 0, fmt.Errorf("failed to read transcript: %w", err) } - lineCount++ } return lineCount, nil @@ -490,6 +480,8 @@ func (p *PiAgent) ExtractModifiedFilesFromOffset(path string, startOffset int) ( // Extract files from this entry if entry.Type == "message" && entry.Message != nil { msg := entry.Message + + // Check toolResult messages for write/edit if msg.Role == "toolResult" && (msg.ToolName == "write" || msg.ToolName == "edit") { if details, ok := msg.Details.(map[string]interface{}); ok { if filePath, ok := details["path"].(string); ok && filePath != "" { @@ -497,6 +489,21 @@ func (p *PiAgent) ExtractModifiedFilesFromOffset(path string, startOffset int) ( } } } + + // Check assistant messages for tool calls + if msg.Role == "assistant" { + for _, content := range msg.Content { + if content.Type == "toolCall" { + if content.Name == "write" || content.Name == "edit" { + if args, ok := content.Arguments.(map[string]interface{}); ok { + if filePath, ok := args["path"].(string); ok && filePath != "" { + modFiles[filePath] = true + } + } + } + } + } + } } } } diff --git a/cmd/entire/cli/agent/pi/types.go b/cmd/entire/cli/agent/pi/types.go index ca0a631fb..8bb67c64a 100644 --- a/cmd/entire/cli/agent/pi/types.go +++ b/cmd/entire/cli/agent/pi/types.go @@ -10,28 +10,28 @@ type piHookInput struct { // piSessionEntry represents a line in pi's JSONL session file. type piSessionEntry struct { - Type string `json:"type"` - ID string `json:"id,omitempty"` - ParentID string `json:"parentId,omitempty"` - Timestamp string `json:"timestamp,omitempty"` - Message *piMessage `json:"message,omitempty"` - Version int `json:"version,omitempty"` - CWD string `json:"cwd,omitempty"` - Summary string `json:"summary,omitempty"` - Data interface{} `json:"data,omitempty"` + Type string `json:"type"` + ID string `json:"id,omitempty"` + ParentID string `json:"parentId,omitempty"` + Timestamp string `json:"timestamp,omitempty"` + Message *piMessage `json:"message,omitempty"` + Version int `json:"version,omitempty"` + CWD string `json:"cwd,omitempty"` + Summary string `json:"summary,omitempty"` + Data interface{} `json:"data,omitempty"` } // piMessage represents a message in pi's session format. type piMessage struct { - Role string `json:"role"` - Content []piContentBlock `json:"content,omitempty"` - ToolName string `json:"toolName,omitempty"` - ToolCallID string `json:"toolCallId,omitempty"` - Details interface{} `json:"details,omitempty"` - IsError bool `json:"isError,omitempty"` - Timestamp int64 `json:"timestamp,omitempty"` - Provider string `json:"provider,omitempty"` - Model string `json:"model,omitempty"` + Role string `json:"role"` + Content []piContentBlock `json:"content,omitempty"` + ToolName string `json:"toolName,omitempty"` + ToolCallID string `json:"toolCallId,omitempty"` + Details interface{} `json:"details,omitempty"` + IsError bool `json:"isError,omitempty"` + Timestamp int64 `json:"timestamp,omitempty"` + Provider string `json:"provider,omitempty"` + Model string `json:"model,omitempty"` } // piContentBlock represents a content block in pi messages. diff --git a/cmd/entire/cli/config.go b/cmd/entire/cli/config.go index 48246c5be..6394e6162 100644 --- a/cmd/entire/cli/config.go +++ b/cmd/entire/cli/config.go @@ -11,8 +11,10 @@ import ( "github.com/entireio/cli/cmd/entire/cli/settings" "github.com/entireio/cli/cmd/entire/cli/strategy" - // Import claudecode to register the agent + // Import agents to register them _ "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/pi" ) // Package-level aliases to avoid shadowing the settings package with local variables named "settings". diff --git a/cmd/entire/cli/hooks_cmd.go b/cmd/entire/cli/hooks_cmd.go index d12922523..e03696d35 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/pi" "github.com/spf13/cobra" )