diff --git a/cmd/picoclaw/cmd_gateway.go b/cmd/picoclaw/cmd_gateway.go index 8757e667c..eb7f99430 100644 --- a/cmd/picoclaw/cmd_gateway.go +++ b/cmd/picoclaw/cmd_gateway.go @@ -381,12 +381,15 @@ func (p *agentLoopDataProvider) GetActiveSessions() []miniapp.SessionInfo { result := make([]miniapp.SessionInfo, len(entries)) for i, e := range entries { result[i] = miniapp.SessionInfo{ - SessionKey: e.SessionKey, - Channel: e.Channel, - ChatID: e.ChatID, - TouchDir: e.TouchDir, - LastSeenAt: e.LastSeenAt.Format(time.RFC3339), - AgeSec: int(time.Since(e.LastSeenAt).Seconds()), + SessionKey: e.SessionKey, + Channel: e.Channel, + ChatID: e.ChatID, + TouchDir: e.TouchDir, + ProjectPath: e.ProjectPath, + Purpose: e.Purpose, + Branch: e.Branch, + LastSeenAt: e.LastSeenAt.Format(time.RFC3339), + AgeSec: int(time.Since(e.LastSeenAt).Seconds()), } } return result diff --git a/pkg/agent/context.go b/pkg/agent/context.go index d2bdc1c27..8df221ad2 100644 --- a/pkg/agent/context.go +++ b/pkg/agent/context.go @@ -19,6 +19,7 @@ type ContextBuilder struct { skillsLoader *skills.SkillsLoader memory *MemoryStore tools *tools.ToolRegistry // Direct reference to tool registry + peerNote string // set per-call from loop.go for peer session awareness } func getGlobalConfigDir() string { @@ -48,6 +49,11 @@ func (cb *ContextBuilder) SetToolsRegistry(registry *tools.ToolRegistry) { cb.tools = registry } +// SetPeerNote sets the peer session awareness note for the current call. +func (cb *ContextBuilder) SetPeerNote(note string) { + cb.peerNote = note +} + func (cb *ContextBuilder) getIdentity() string { now := time.Now().Format("2006-01-02 15:04 (Monday)") workspacePath, _ := filepath.Abs(filepath.Join(cb.workspace)) @@ -169,6 +175,11 @@ The following skills extend your capabilities. To use a skill, read its SKILL.md } } + // Peer session coordination + if cb.peerNote != "" { + parts = append(parts, "## Active Sessions\n\n"+cb.peerNote) + } + // Memory context memoryContext := cb.memory.GetMemoryContext() if memoryContext != "" { @@ -449,6 +460,11 @@ func (cb *ContextBuilder) GetPlanWorkDir() string { return cb.memory.GetPlanWorkDir() } +// GetPlanTaskName returns the task description from the plan metadata, or "". +func (cb *ContextBuilder) GetPlanTaskName() string { + return cb.memory.GetPlanTaskName() +} + // GetSkillsInfo returns information about loaded skills. func (cb *ContextBuilder) GetSkillsInfo() map[string]any { allSkills := cb.skillsLoader.ListSkills() diff --git a/pkg/agent/instance.go b/pkg/agent/instance.go index 66ad51e5c..110dc11a7 100644 --- a/pkg/agent/instance.go +++ b/pkg/agent/instance.go @@ -1,11 +1,14 @@ package agent import ( + "fmt" "os" "path/filepath" "strings" + "sync" "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/git" "github.com/sipeed/picoclaw/pkg/providers" "github.com/sipeed/picoclaw/pkg/routing" "github.com/sipeed/picoclaw/pkg/session" @@ -39,6 +42,10 @@ type AgentInstance struct { // Interview staleness tracking: consecutive turns where MEMORY.md was not updated. interviewStaleCount int interviewMemoryLen int + + // Per-session worktree isolation + worktrees map[string]*git.WorktreeInfo // sessionKey → worktree + worktreeMu sync.RWMutex } // NewAgentInstance creates an agent instance from config. @@ -123,6 +130,12 @@ func NewAgentInstance( planCandidates = providers.ResolveCandidates(planModelCfg, defaults.Provider) } + // Startup cleanup: prune orphaned worktrees + worktreesDir := filepath.Join(workspace, ".picoclaw", "worktrees") + if repoRoot := git.FindRepoRoot(workspace); repoRoot != "" { + git.PruneOrphaned(repoRoot, worktreesDir) + } + return &AgentInstance{ ID: agentID, Name: agentName, @@ -192,6 +205,88 @@ func resolvePlanFallbacks(agentCfg *config.AgentConfig, defaults *config.AgentDe return defaults.PlanModelFallbacks } +// ActivateWorktree creates a worktree for a session. +// Path: /.picoclaw/worktrees// +func (ai *AgentInstance) ActivateWorktree(sessionKey, taskName string) (*git.WorktreeInfo, error) { + repoRoot := git.FindRepoRoot(ai.Workspace) + if repoRoot == "" { + return nil, fmt.Errorf("workspace is not a git repository") + } + + branchName := git.SanitizeBranchName(taskName) + baseName := git.BranchBaseName(branchName) + wtPath := filepath.Join(ai.Workspace, ".picoclaw", "worktrees", baseName) + + wt, err := git.CreateWorktree(repoRoot, wtPath, branchName) + if err != nil { + return nil, err + } + + ai.worktreeMu.Lock() + if ai.worktrees == nil { + ai.worktrees = make(map[string]*git.WorktreeInfo) + } + ai.worktrees[sessionKey] = wt + ai.worktreeMu.Unlock() + + return wt, nil +} + +// DeactivateWorktree safe-disposes the session's worktree. +func (ai *AgentInstance) DeactivateWorktree(sessionKey, commitMsg string, discard bool) (*git.DisposeResult, error) { + ai.worktreeMu.Lock() + wt, ok := ai.worktrees[sessionKey] + if ok { + delete(ai.worktrees, sessionKey) + } + ai.worktreeMu.Unlock() + + if !ok || wt == nil { + return nil, nil + } + + repoRoot := git.FindRepoRoot(ai.Workspace) + if repoRoot == "" { + return nil, fmt.Errorf("workspace is not a git repository") + } + + // Even on discard, SafeDispose auto-commits first for safety + if commitMsg != "" && git.HasUncommittedChanges(wt.Path) { + _ = git.AutoCommit(wt.Path, commitMsg) + } + + result := git.SafeDispose(repoRoot, wt) + return &result, nil +} + +// GetWorktree returns the session's active worktree, or nil. +func (ai *AgentInstance) GetWorktree(sessionKey string) *git.WorktreeInfo { + ai.worktreeMu.RLock() + defer ai.worktreeMu.RUnlock() + return ai.worktrees[sessionKey] +} + +// IsInWorktree returns true if the session has an active worktree. +func (ai *AgentInstance) IsInWorktree(sessionKey string) bool { + return ai.GetWorktree(sessionKey) != nil +} + +// EffectiveWorkspace returns worktree path for session, or original Workspace. +func (ai *AgentInstance) EffectiveWorkspace(sessionKey string) string { + if wt := ai.GetWorktree(sessionKey); wt != nil { + return wt.Path + } + return ai.Workspace +} + +// GetWorktreeBranch returns the branch name for the session's worktree, or "". +func (ai *AgentInstance) GetWorktreeBranch(sessionKey string) string { + if wt := ai.GetWorktree(sessionKey); wt != nil { + return wt.Branch + } + return "" +} + func expandHome(path string) string { if path == "" { return path diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index a4181a491..93919d9de 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -300,6 +300,19 @@ func (al *AgentLoop) Run(ctx context.Context) error { } } + // Activate worktree for the session's plan execution + if agent := al.registry.GetDefaultAgent(); agent != nil { + taskName := agent.ContextBuilder.Memory().GetPlanTaskName() + if taskName == "" { + taskName = "plan-execution" + } + if wt, err := agent.ActivateWorktree(msg.SessionKey, taskName); err != nil { + logger.WarnCF("agent", "Worktree activation skipped", map[string]any{"error": err.Error()}) + } else { + logger.InfoCF("agent", "Worktree activated", map[string]any{"branch": wt.Branch}) + } + } + syntheticMeta := map[string]string{"echoed": "1"} for k, v := range msg.Metadata { if k != "source" { @@ -768,6 +781,24 @@ func (al *AgentLoop) runAgentLoop(ctx context.Context, agent *AgentInstance, opt // 1. Update tool contexts al.updateToolContexts(agent, opts.Channel, opts.ChatID) + // 1b. Inject peer session awareness into system prompt + projectPath := agent.ContextBuilder.GetPlanWorkDir() + if projectPath == "" { + projectPath = agent.Workspace + } + peers := al.sessions.GetPeerPurposes(opts.SessionKey, projectPath) + if len(peers) > 0 { + var peerNote strings.Builder + peerNote.WriteString("Other sessions working on this project:\n") + for _, p := range peers { + peerNote.WriteString(fmt.Sprintf("- %s: %s (branch: %s)\n", p.SessionKey, p.Purpose, p.Branch)) + } + peerNote.WriteString("\nAvoid conflicting changes with these sessions.") + agent.ContextBuilder.SetPeerNote(peerNote.String()) + } else { + agent.ContextBuilder.SetPeerNote("") + } + // 2. Build messages (skip history for heartbeat) var history []providers.Message var summary string @@ -889,11 +920,21 @@ func (al *AgentLoop) runAgentLoop(ctx context.Context, agent *AgentInstance, opt _ = agent.ContextBuilder.SetCurrentPhase(total) if preStatus != "completed" { _ = agent.ContextBuilder.SetPlanStatus("completed") + + // Deactivate worktree on plan completion + commitMsg := "plan: " + agent.ContextBuilder.Memory().GetPlanTaskName() + wtResult, _ := agent.DeactivateWorktree(opts.SessionKey, commitMsg, false) + if !constants.IsInternalChannel(opts.Channel) { + msg := "\u2705 Plan completed!" + if wtResult != nil && wtResult.CommitsAhead > 0 { + msg += fmt.Sprintf("\nBranch `%s` retained (%d commits). To merge: `git merge %s`", + wtResult.Branch, wtResult.CommitsAhead, wtResult.Branch) + } al.bus.PublishOutbound(bus.OutboundMessage{ Channel: opts.Channel, ChatID: opts.ChatID, - Content: "\u2705 Plan completed!", + Content: msg, SkipPlaceholder: true, }) } @@ -962,6 +1003,20 @@ func (al *AgentLoop) runAgentLoop(ctx context.Context, agent *AgentInstance, opt "final_length": len(finalContent), }) + // 10. Heartbeat worktree cleanup: auto-commit and dispose after background task + if opts.Background && agent.IsInWorktree(opts.SessionKey) { + commitMsg := "heartbeat: auto-save" + wtResult, _ := agent.DeactivateWorktree(opts.SessionKey, commitMsg, false) + if wtResult != nil && wtResult.CommitsAhead > 0 && !constants.IsInternalChannel(opts.Channel) { + al.bus.PublishOutbound(bus.OutboundMessage{ + Channel: opts.Channel, + ChatID: opts.ChatID, + Content: fmt.Sprintf("Heartbeat made code changes on branch `%s` (%d commits).", + wtResult.Branch, wtResult.CommitsAhead), + }) + } + } + return finalContent, nil } @@ -1950,7 +2005,15 @@ func (al *AgentLoop) runLLMIteration( } } if detectedDir != "" { - al.sessions.Touch(opts.SessionKey, opts.Channel, opts.ChatID, detectedDir) + meta := &TouchMeta{ + ProjectPath: agent.ContextBuilder.GetPlanWorkDir(), + Purpose: utils.Truncate(opts.UserMessage, 80), + Branch: agent.GetWorktreeBranch(opts.SessionKey), + } + if meta.ProjectPath == "" { + meta.ProjectPath = agent.Workspace + } + al.sessions.Touch(opts.SessionKey, opts.Channel, opts.ChatID, detectedDir, meta) } } @@ -1998,6 +2061,14 @@ func (al *AgentLoop) runLLMIteration( "iteration": iteration, }) + // Heartbeat lazy worktree: create worktree on first write-tool call + if opts.Background && isWriteTool(tc.Name) && !agent.IsInWorktree(opts.SessionKey) { + taskName := "heartbeat-" + time.Now().Format("20060102") + if wt, err := agent.ActivateWorktree(opts.SessionKey, taskName); err == nil { + logger.InfoCF("agent", "Heartbeat worktree created", map[string]any{"branch": wt.Branch}) + } + } + // Create async callback for tools that implement AsyncTool // NOTE: Following openclaw's design, async tools do NOT send results directly to users. // Instead, they notify the agent via PublishInbound, and the agent decides @@ -2015,7 +2086,11 @@ func (al *AgentLoop) runLLMIteration( } toolStart := time.Now() - toolResult := agent.Tools.ExecuteWithContext(ctx, tc.Name, tc.Arguments, opts.Channel, opts.ChatID, asyncCallback) + toolCtx := ctx + if wt := agent.GetWorktree(opts.SessionKey); wt != nil { + toolCtx = tools.WithWorkspaceOverride(toolCtx, wt.Path) + } + toolResult := agent.Tools.ExecuteWithContext(toolCtx, tc.Name, tc.Arguments, opts.Channel, opts.ChatID, asyncCallback) toolDuration := time.Since(toolStart) // Update tool log entry with result @@ -2619,7 +2694,7 @@ func (al *AgentLoop) handleCommand(ctx context.Context, msg bus.InboundMessage) return al.handleSkillsCommand(), true case "/plan": - resp, handled := al.handlePlanCommand(args) + resp, handled := al.handlePlanCommand(args, msg.SessionKey) if handled { al.notifyStateChange() } @@ -2739,7 +2814,7 @@ func (al *AgentLoop) handleSkillsCommand() string { // Returns (response, handled). For "/plan " (new plan), it returns // ("", false) so the message falls through to the LLM queue, where // expandPlanCommand writes the seed and rewrites the content. -func (al *AgentLoop) handlePlanCommand(args []string) (string, bool) { +func (al *AgentLoop) handlePlanCommand(args []string, sessionKey string) (string, bool) { agent := al.registry.GetDefaultAgent() if agent == nil { return "No agent configured.", true @@ -2756,6 +2831,10 @@ func (al *AgentLoop) handlePlanCommand(args []string) (string, bool) { if agent.ContextBuilder.ReadMemory() == "" { return "No active plan to clear.", true } + // Deactivate worktree on plan clear + if sessionKey != "" { + agent.DeactivateWorktree(sessionKey, "", true) + } if err := agent.ContextBuilder.ClearMemory(); err != nil { return fmt.Sprintf("Error clearing plan: %v", err), true } @@ -2945,6 +3024,15 @@ func isReadOnlyCommand(cmd string) bool { return false } +// isWriteTool returns true if the tool can modify files. +func isWriteTool(name string) bool { + switch tools.NormalizeToolName(name) { + case "writefile", "editfile", "appendfile", "exec": + return true + } + return false +} + // expandPlanCommand detects "/plan " (new plan start) and: // - writes the interview seed to MEMORY.md // - rewrites the message content for the LLM diff --git a/pkg/agent/memory.go b/pkg/agent/memory.go index 8ad8b7502..9069bdc9c 100644 --- a/pkg/agent/memory.go +++ b/pkg/agent/memory.go @@ -465,6 +465,19 @@ func (ms *MemoryStore) GetPlanWorkDir() string { return strings.TrimSpace(m[1]) } +// reTaskLine extracts the task name from "> Task: ". +var reTaskLine = regexp.MustCompile(`(?m)^> Task:\s*(.+)`) + +// GetPlanTaskName returns the task description from the plan metadata, or "". +func (ms *MemoryStore) GetPlanTaskName() string { + content := ms.ReadLongTerm() + m := reTaskLine.FindStringSubmatch(content) + if len(m) < 2 { + return "" + } + return strings.TrimSpace(m[1]) +} + // interviewSeed is the initial content written to MEMORY.md when /plan starts. const interviewSeedTemplate = `# Active Plan diff --git a/pkg/agent/session_tracker.go b/pkg/agent/session_tracker.go index 44adf9771..806348415 100644 --- a/pkg/agent/session_tracker.go +++ b/pkg/agent/session_tracker.go @@ -9,11 +9,28 @@ import ( // SessionEntry represents an active or recently-active session. type SessionEntry struct { - SessionKey string `json:"session_key"` - Channel string `json:"channel"` - ChatID string `json:"chat_id"` - TouchDir string `json:"touch_dir"` - LastSeenAt time.Time `json:"last_seen_at"` + SessionKey string `json:"session_key"` + Channel string `json:"channel"` + ChatID string `json:"chat_id"` + TouchDir string `json:"touch_dir"` + ProjectPath string `json:"project_path,omitempty"` // canonical project path + Purpose string `json:"purpose,omitempty"` // 1-line task description + Branch string `json:"branch,omitempty"` // git branch name + LastSeenAt time.Time `json:"last_seen_at"` +} + +// TouchMeta carries optional metadata for Touch calls. +type TouchMeta struct { + ProjectPath string // canonical project path (always original workspace-relative) + Purpose string // 1-line task description + Branch string // git branch name +} + +// PeerInfo is the minimal info shared between sessions on the same project. +type PeerInfo struct { + SessionKey string + Purpose string + Branch string } // SessionTracker tracks per-session tool-call activity. @@ -32,7 +49,8 @@ const sessionActivityTimeout = 15 * time.Minute // Touch records a tool-call activity for a session. // dir is the workspace-relative directory the tool call targeted. // If dir is empty, only LastSeenAt is updated. -func (st *SessionTracker) Touch(sessionKey, channel, chatID, dir string) { +// meta is optional and carries project coordination metadata. +func (st *SessionTracker) Touch(sessionKey, channel, chatID, dir string, meta *TouchMeta) { now := time.Now() val, loaded := st.entries.Load(sessionKey) if loaded { @@ -47,15 +65,32 @@ func (st *SessionTracker) Touch(sessionKey, channel, chatID, dir string) { if chatID != "" { entry.ChatID = chatID } + if meta != nil { + if meta.ProjectPath != "" { + entry.ProjectPath = meta.ProjectPath + } + if meta.Purpose != "" { + entry.Purpose = meta.Purpose + } + if meta.Branch != "" { + entry.Branch = meta.Branch + } + } return } - st.entries.Store(sessionKey, &SessionEntry{ + entry := &SessionEntry{ SessionKey: sessionKey, Channel: channel, ChatID: chatID, TouchDir: dir, LastSeenAt: now, - }) + } + if meta != nil { + entry.ProjectPath = meta.ProjectPath + entry.Purpose = meta.Purpose + entry.Branch = meta.Branch + } + st.entries.Store(sessionKey, entry) } // IsActiveInDir returns true if any session (excluding those matching excludeKey) @@ -96,3 +131,28 @@ func (st *SessionTracker) ListActive() []SessionEntry { }) return result } + +// GetPeerPurposes returns purposes of other active sessions targeting the same project. +// Used for lightweight coordination without context pollution. +func (st *SessionTracker) GetPeerPurposes(sessionKey, projectPath string) []PeerInfo { + if projectPath == "" { + return nil + } + cutoff := time.Now().Add(-sessionActivityTimeout) + var result []PeerInfo + st.entries.Range(func(key, val any) bool { + if key.(string) == sessionKey { + return true + } + entry := val.(*SessionEntry) + if entry.LastSeenAt.After(cutoff) && entry.ProjectPath == projectPath { + result = append(result, PeerInfo{ + SessionKey: entry.SessionKey, + Purpose: entry.Purpose, + Branch: entry.Branch, + }) + } + return true + }) + return result +} diff --git a/pkg/agent/session_tracker_test.go b/pkg/agent/session_tracker_test.go index 0882d0513..61f19064e 100644 --- a/pkg/agent/session_tracker_test.go +++ b/pkg/agent/session_tracker_test.go @@ -9,7 +9,7 @@ func TestTouch(t *testing.T) { st := NewSessionTracker() // Basic touch creates entry - st.Touch("sess1", "telegram", "123", "projects/myapp") + st.Touch("sess1", "telegram", "123", "projects/myapp", nil) entries := st.ListActive() if len(entries) != 1 { t.Fatalf("expected 1 entry, got %d", len(entries)) @@ -25,7 +25,7 @@ func TestTouch(t *testing.T) { } // Touch again with new dir overwrites TouchDir - st.Touch("sess1", "", "", "projects/other") + st.Touch("sess1", "", "", "projects/other", nil) entries = st.ListActive() if len(entries) != 1 { t.Fatalf("expected 1 entry, got %d", len(entries)) @@ -39,7 +39,7 @@ func TestTouch(t *testing.T) { } // Touch with empty dir does not overwrite TouchDir - st.Touch("sess1", "", "", "") + st.Touch("sess1", "", "", "", nil) entries = st.ListActive() if entries[0].TouchDir != "projects/other" { t.Errorf("expected touch_dir unchanged, got %s", entries[0].TouchDir) @@ -50,7 +50,7 @@ func TestIsActiveInDir(t *testing.T) { st := NewSessionTracker() // Setup: sess1 touches "projects/myapp" - st.Touch("sess1", "telegram", "123", "projects/myapp") + st.Touch("sess1", "telegram", "123", "projects/myapp", nil) // Same dir, excluding sess1 → false if st.IsActiveInDir("projects/myapp", "sess1") { @@ -91,9 +91,9 @@ func TestListActive(t *testing.T) { st := NewSessionTracker() // Add two sessions - st.Touch("sess1", "telegram", "123", "projects/a") + st.Touch("sess1", "telegram", "123", "projects/a", nil) time.Sleep(5 * time.Millisecond) // ensure different timestamps - st.Touch("sess2", "discord", "456", "projects/b") + st.Touch("sess2", "discord", "456", "projects/b", nil) entries := st.ListActive() if len(entries) != 2 { diff --git a/pkg/git/worktree.go b/pkg/git/worktree.go new file mode 100644 index 000000000..fbb91da1d --- /dev/null +++ b/pkg/git/worktree.go @@ -0,0 +1,241 @@ +package git + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "regexp" + "strconv" + "strings" + "unicode" +) + +// WorktreeInfo describes an active git worktree. +type WorktreeInfo struct { + Path string // absolute worktree dir + Branch string // e.g. "plan/setup-monitoring" + BaseBranch string // branch forked from + RepoRoot string // main repo root +} + +// DisposeResult describes what happened when a worktree was disposed. +type DisposeResult struct { + Branch string + AutoCommitted bool // true if uncommitted changes were saved + BranchDeleted bool // true if branch had no unique commits + CommitsAhead int // unique commits on branch (0 = safe to delete) +} + +// FindRepoRoot returns the git repository root for dir, or "" if not a git repo. +func FindRepoRoot(dir string) string { + cmd := exec.Command("git", "rev-parse", "--show-toplevel") + cmd.Dir = dir + out, err := cmd.Output() + if err != nil { + return "" + } + return strings.TrimSpace(string(out)) +} + +// CurrentBranch returns the current branch name, or "" on error. +func CurrentBranch(dir string) string { + cmd := exec.Command("git", "rev-parse", "--abbrev-ref", "HEAD") + cmd.Dir = dir + out, err := cmd.Output() + if err != nil { + return "" + } + return strings.TrimSpace(string(out)) +} + +var unsafeBranchRe = regexp.MustCompile(`[^a-z0-9-]`) + +// SanitizeBranchName creates a safe branch name from a task description. +// Returns "plan/". +func SanitizeBranchName(task string) string { + s := strings.ToLower(strings.TrimSpace(task)) + s = unsafeBranchRe.ReplaceAllString(s, "-") + + // Collapse consecutive hyphens + for strings.Contains(s, "--") { + s = strings.ReplaceAll(s, "--", "-") + } + s = strings.Trim(s, "-") + + if s == "" { + s = "worktree" + } + + // Truncate to 40 chars + runes := []rune(s) + if len(runes) > 40 { + runes = runes[:40] + } + s = strings.TrimRight(string(runes), "-") + + return "plan/" + s +} + +// CreateWorktree creates a new git worktree at worktreePath with branchName. +// If the branch already exists, it reuses it. +func CreateWorktree(repoDir, worktreePath, branchName string) (*WorktreeInfo, error) { + baseBranch := CurrentBranch(repoDir) + if baseBranch == "" { + baseBranch = "HEAD" + } + + if err := os.MkdirAll(filepath.Dir(worktreePath), 0o755); err != nil { + return nil, fmt.Errorf("create worktree parent: %w", err) + } + + // Check if branch already exists + checkCmd := exec.Command("git", "rev-parse", "--verify", branchName) + checkCmd.Dir = repoDir + branchExists := checkCmd.Run() == nil + + var cmd *exec.Cmd + if branchExists { + // Reuse existing branch + cmd = exec.Command("git", "worktree", "add", worktreePath, branchName) + } else { + // Create new branch + cmd = exec.Command("git", "worktree", "add", "-b", branchName, worktreePath) + } + cmd.Dir = repoDir + if out, err := cmd.CombinedOutput(); err != nil { + return nil, fmt.Errorf("git worktree add: %s: %w", strings.TrimSpace(string(out)), err) + } + + return &WorktreeInfo{ + Path: worktreePath, + Branch: branchName, + BaseBranch: baseBranch, + RepoRoot: repoDir, + }, nil +} + +// HasUncommittedChanges returns true if the working tree has staged or unstaged changes. +func HasUncommittedChanges(dir string) bool { + cmd := exec.Command("git", "status", "--porcelain") + cmd.Dir = dir + out, err := cmd.Output() + if err != nil { + return false + } + return len(strings.TrimSpace(string(out))) > 0 +} + +// AutoCommit stages all changes and commits with the given message. +func AutoCommit(worktreePath, message string) error { + addCmd := exec.Command("git", "add", "-A") + addCmd.Dir = worktreePath + if out, err := addCmd.CombinedOutput(); err != nil { + return fmt.Errorf("git add: %s: %w", strings.TrimSpace(string(out)), err) + } + + commitCmd := exec.Command("git", "commit", "-m", message, "--allow-empty-message") + commitCmd.Dir = worktreePath + if out, err := commitCmd.CombinedOutput(); err != nil { + // "nothing to commit" is not a real error + if strings.Contains(string(out), "nothing to commit") { + return nil + } + return fmt.Errorf("git commit: %s: %w", strings.TrimSpace(string(out)), err) + } + return nil +} + +// CommitsAhead returns the number of commits on branch that are not on base. +func CommitsAhead(repoDir, base, branch string) int { + cmd := exec.Command("git", "rev-list", "--count", base+".."+branch) + cmd.Dir = repoDir + out, err := cmd.Output() + if err != nil { + return 0 + } + n, _ := strconv.Atoi(strings.TrimSpace(string(out))) + return n +} + +// SafeDispose auto-commits uncommitted changes, removes the worktree directory, +// and deletes the branch ONLY if it has no unique commits. +func SafeDispose(repoDir string, wt *WorktreeInfo) DisposeResult { + result := DisposeResult{Branch: wt.Branch} + + // 1. Auto-commit if there are uncommitted changes + if HasUncommittedChanges(wt.Path) { + msg := fmt.Sprintf("auto: save from %s", wt.Branch) + if err := AutoCommit(wt.Path, msg); err == nil { + result.AutoCommitted = true + } + } + + // 2. Count unique commits + result.CommitsAhead = CommitsAhead(repoDir, wt.BaseBranch, wt.Branch) + + // 3. Remove worktree + removeCmd := exec.Command("git", "worktree", "remove", "--force", wt.Path) + removeCmd.Dir = repoDir + removeCmd.Run() // best-effort + + // 4. Delete branch if no unique commits + if result.CommitsAhead == 0 { + delCmd := exec.Command("git", "branch", "-D", wt.Branch) + delCmd.Dir = repoDir + if delCmd.Run() == nil { + result.BranchDeleted = true + } + } + + // 5. Fallback cleanup + os.RemoveAll(wt.Path) + + return result +} + +// PruneOrphaned runs git worktree prune and removes dirs in worktreesDir +// that aren't valid git worktrees. +func PruneOrphaned(repoDir, worktreesDir string) { + pruneCmd := exec.Command("git", "worktree", "prune") + pruneCmd.Dir = repoDir + pruneCmd.Run() // best-effort + + entries, err := os.ReadDir(worktreesDir) + if err != nil { + return + } + + for _, entry := range entries { + if !entry.IsDir() { + continue + } + wtPath := filepath.Join(worktreesDir, entry.Name()) + // Check if it's still a valid git worktree + checkCmd := exec.Command("git", "rev-parse", "--git-dir") + checkCmd.Dir = wtPath + if err := checkCmd.Run(); err != nil { + // Not a valid git worktree — remove + os.RemoveAll(wtPath) + } + } +} + +// BranchBaseName extracts the last segment of a branch name. +// "plan/add-auth" → "plan-add-auth" +func BranchBaseName(branch string) string { + s := strings.ReplaceAll(branch, "/", "-") + // Remove leading/trailing hyphens + s = strings.Trim(s, "-") + // Remove non-printable chars + var b strings.Builder + for _, r := range s { + if unicode.IsPrint(r) { + b.WriteRune(r) + } + } + if b.Len() == 0 { + return "worktree" + } + return b.String() +} diff --git a/pkg/git/worktree_test.go b/pkg/git/worktree_test.go new file mode 100644 index 000000000..09d01cbc6 --- /dev/null +++ b/pkg/git/worktree_test.go @@ -0,0 +1,218 @@ +package git + +import ( + "os" + "os/exec" + "path/filepath" + "testing" +) + +func TestSanitizeBranchName(t *testing.T) { + tests := []struct { + input string + want string + }{ + {"Add auth module", "plan/add-auth-module"}, + {"", "plan/worktree"}, + {" spaces ", "plan/spaces"}, + {"UPPER-case_Mix", "plan/upper-case-mix"}, + {"a/b/c", "plan/a-b-c"}, + {"very long task name that exceeds the forty character limit for safety", "plan/very-long-task-name-that-exceeds-the-for"}, + {"---leading-trailing---", "plan/leading-trailing"}, + {"special!@#$%chars", "plan/special-chars"}, + } + + for _, tt := range tests { + got := SanitizeBranchName(tt.input) + if got != tt.want { + t.Errorf("SanitizeBranchName(%q) = %q, want %q", tt.input, got, tt.want) + } + } +} + +func TestBranchBaseName(t *testing.T) { + tests := []struct { + input string + want string + }{ + {"plan/add-auth", "plan-add-auth"}, + {"heartbeat/20260224", "heartbeat-20260224"}, + {"main", "main"}, + {"", "worktree"}, + } + + for _, tt := range tests { + got := BranchBaseName(tt.input) + if got != tt.want { + t.Errorf("BranchBaseName(%q) = %q, want %q", tt.input, got, tt.want) + } + } +} + +// initTestRepo creates a temporary git repo with an initial commit. +func initTestRepo(t *testing.T) string { + t.Helper() + dir := t.TempDir() + + cmds := [][]string{ + {"git", "init"}, + {"git", "config", "user.email", "test@test.com"}, + {"git", "config", "user.name", "Test"}, + } + for _, args := range cmds { + cmd := exec.Command(args[0], args[1:]...) + cmd.Dir = dir + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git init: %s: %v", out, err) + } + } + + // Create initial commit + f := filepath.Join(dir, "README.md") + os.WriteFile(f, []byte("# Test\n"), 0o644) + add := exec.Command("git", "add", "-A") + add.Dir = dir + add.Run() + commit := exec.Command("git", "commit", "-m", "initial") + commit.Dir = dir + commit.Run() + + return dir +} + +func TestFindRepoRoot(t *testing.T) { + dir := initTestRepo(t) + root := FindRepoRoot(dir) + if root == "" { + t.Fatal("FindRepoRoot returned empty for valid repo") + } + + // Non-repo should return "" + tmpDir := t.TempDir() + if got := FindRepoRoot(tmpDir); got != "" { + t.Errorf("FindRepoRoot(non-repo) = %q, want empty", got) + } +} + +func TestCurrentBranch(t *testing.T) { + dir := initTestRepo(t) + branch := CurrentBranch(dir) + // Should be "main" or "master" depending on git config + if branch == "" { + t.Fatal("CurrentBranch returned empty for valid repo") + } +} + +func TestCreateWorktreeAndDispose(t *testing.T) { + dir := initTestRepo(t) + wtPath := filepath.Join(dir, ".picoclaw", "worktrees", "test-wt") + + wt, err := CreateWorktree(dir, wtPath, "plan/test-feature") + if err != nil { + t.Fatalf("CreateWorktree: %v", err) + } + + if wt.Path != wtPath { + t.Errorf("Path = %q, want %q", wt.Path, wtPath) + } + if wt.Branch != "plan/test-feature" { + t.Errorf("Branch = %q, want %q", wt.Branch, "plan/test-feature") + } + + // Verify worktree exists + if _, err := os.Stat(wtPath); os.IsNotExist(err) { + t.Fatal("worktree dir was not created") + } + + // SafeDispose with no changes — should delete branch + result := SafeDispose(dir, wt) + if result.AutoCommitted { + t.Error("AutoCommitted should be false with no changes") + } + if result.CommitsAhead != 0 { + t.Errorf("CommitsAhead = %d, want 0", result.CommitsAhead) + } + if !result.BranchDeleted { + t.Error("BranchDeleted should be true when no unique commits") + } +} + +func TestCreateWorktreeWithChangesAndDispose(t *testing.T) { + dir := initTestRepo(t) + wtPath := filepath.Join(dir, ".picoclaw", "worktrees", "test-changes") + + wt, err := CreateWorktree(dir, wtPath, "plan/with-changes") + if err != nil { + t.Fatalf("CreateWorktree: %v", err) + } + + // Make a change in the worktree + os.WriteFile(filepath.Join(wtPath, "new-file.txt"), []byte("hello"), 0o644) + + if !HasUncommittedChanges(wtPath) { + t.Fatal("HasUncommittedChanges should be true after adding file") + } + + // SafeDispose should auto-commit + result := SafeDispose(dir, wt) + if !result.AutoCommitted { + t.Error("AutoCommitted should be true") + } + if result.CommitsAhead != 1 { + t.Errorf("CommitsAhead = %d, want 1", result.CommitsAhead) + } + if result.BranchDeleted { + t.Error("BranchDeleted should be false when branch has commits") + } +} + +func TestHasUncommittedChanges(t *testing.T) { + dir := initTestRepo(t) + + if HasUncommittedChanges(dir) { + t.Fatal("clean repo should have no uncommitted changes") + } + + os.WriteFile(filepath.Join(dir, "test.txt"), []byte("data"), 0o644) + if !HasUncommittedChanges(dir) { + t.Fatal("should detect uncommitted changes after adding file") + } +} + +func TestCommitsAhead(t *testing.T) { + dir := initTestRepo(t) + base := CurrentBranch(dir) + + // Create a branch with a commit + exec.Command("git", "checkout", "-b", "test-ahead").Run() + branchCmd := exec.Command("git", "checkout", "-b", "test-ahead") + branchCmd.Dir = dir + branchCmd.Run() + + os.WriteFile(filepath.Join(dir, "extra.txt"), []byte("data"), 0o644) + AutoCommit(dir, "extra commit") + + n := CommitsAhead(dir, base, "test-ahead") + if n != 1 { + t.Errorf("CommitsAhead = %d, want 1", n) + } +} + +func TestPruneOrphaned(t *testing.T) { + dir := initTestRepo(t) + + // Use a separate temp dir for worktrees (outside the repo) to avoid + // git rev-parse finding the parent repo's .git. + worktreesDir := filepath.Join(t.TempDir(), "worktrees") + os.MkdirAll(worktreesDir, 0o755) + + // Create a fake dir that's not a worktree + orphanDir := filepath.Join(worktreesDir, "orphan") + os.MkdirAll(orphanDir, 0o755) + + PruneOrphaned(dir, worktreesDir) + + if _, err := os.Stat(orphanDir); !os.IsNotExist(err) { + t.Error("orphaned dir should have been removed") + } +} diff --git a/pkg/miniapp/miniapp.go b/pkg/miniapp/miniapp.go index ee1ea2323..d604b2129 100644 --- a/pkg/miniapp/miniapp.go +++ b/pkg/miniapp/miniapp.go @@ -59,12 +59,15 @@ type PlanInfo struct { // SessionInfo represents an active session entry for the API response. type SessionInfo struct { - SessionKey string `json:"session_key"` - Channel string `json:"channel"` - ChatID string `json:"chat_id"` - TouchDir string `json:"touch_dir"` - LastSeenAt string `json:"last_seen_at"` - AgeSec int `json:"age_sec"` + SessionKey string `json:"session_key"` + Channel string `json:"channel"` + ChatID string `json:"chat_id"` + TouchDir string `json:"touch_dir"` + ProjectPath string `json:"project_path,omitempty"` + Purpose string `json:"purpose,omitempty"` + Branch string `json:"branch,omitempty"` + LastSeenAt string `json:"last_seen_at"` + AgeSec int `json:"age_sec"` } // GitRepoSummary represents a lightweight repo entry for the list view. diff --git a/pkg/tools/edit.go b/pkg/tools/edit.go index d3ab267bf..3447d6e96 100644 --- a/pkg/tools/edit.go +++ b/pkg/tools/edit.go @@ -70,7 +70,7 @@ func (t *EditFileTool) Execute(ctx context.Context, args map[string]any) *ToolRe return ErrorResult("new_text is required") } - if err := editFile(t.fs, path, oldText, newText); err != nil { + if err := editFile(resolveFS(ctx, t.fs, path), path, oldText, newText); err != nil { return ErrorResult(err.Error()) } return SilentResult(fmt.Sprintf("File edited: %s", path)) @@ -126,7 +126,7 @@ func (t *AppendFileTool) Execute(ctx context.Context, args map[string]any) *Tool return ErrorResult("content is required") } - if err := appendFile(t.fs, path, content); err != nil { + if err := appendFile(resolveFS(ctx, t.fs, path), path, content); err != nil { return ErrorResult(err.Error()) } return SilentResult(fmt.Sprintf("Appended to %s", path)) diff --git a/pkg/tools/filesystem.go b/pkg/tools/filesystem.go index f36ad5476..5ec0ba85c 100644 --- a/pkg/tools/filesystem.go +++ b/pkg/tools/filesystem.go @@ -123,7 +123,7 @@ func (t *ReadFileTool) Execute(ctx context.Context, args map[string]any) *ToolRe return ErrorResult("path is required") } - content, err := t.fs.ReadFile(path) + content, err := resolveFS(ctx, t.fs, path).ReadFile(path) if err != nil { return ErrorResult(err.Error()) } @@ -180,7 +180,7 @@ func (t *WriteFileTool) Execute(ctx context.Context, args map[string]any) *ToolR return ErrorResult("content is required") } - if err := t.fs.WriteFile(path, []byte(content)); err != nil { + if err := resolveFS(ctx, t.fs, path).WriteFile(path, []byte(content)); err != nil { return ErrorResult(err.Error()) } @@ -228,7 +228,7 @@ func (t *ListDirTool) Execute(ctx context.Context, args map[string]any) *ToolRes path = "." } - entries, err := t.fs.ReadDir(path) + entries, err := resolveFS(ctx, t.fs, path).ReadDir(path) if err != nil { return ErrorResult(err.Error()) } diff --git a/pkg/tools/shell.go b/pkg/tools/shell.go index 3a62bb41e..3fc90acd7 100644 --- a/pkg/tools/shell.go +++ b/pkg/tools/shell.go @@ -171,6 +171,8 @@ var defaultDenyPatterns = []*regexp.Regexp{ regexp.MustCompile(`\bdocker\s+exec\b`), regexp.MustCompile(`\bgit\s+push\b`), regexp.MustCompile(`\bgit\s+force\b`), + regexp.MustCompile(`\bgit\s+checkout\b`), + regexp.MustCompile(`\bgit\s+switch\b`), regexp.MustCompile(`\bssh\b.*@`), regexp.MustCompile(`\beval\b`), regexp.MustCompile(`\bsource\s+.*\.sh\b`), @@ -277,6 +279,9 @@ func (t *ExecTool) Execute(ctx context.Context, args map[string]any) *ToolResult } cwd := t.workingDir + if override := WorkspaceOverrideFromCtx(ctx); override != "" { + cwd = override + } if wd, ok := args["working_dir"].(string); ok && wd != "" { if t.restrictToWorkspace && t.workingDir != "" { resolvedWD, err := validatePath(wd, t.workingDir, true) diff --git a/pkg/tools/workspace_ctx.go b/pkg/tools/workspace_ctx.go new file mode 100644 index 000000000..e4adf4aac --- /dev/null +++ b/pkg/tools/workspace_ctx.go @@ -0,0 +1,67 @@ +package tools + +import ( + "context" + "path/filepath" + "strings" +) + +type workspaceOverrideKey struct{} + +// WithWorkspaceOverride returns a context carrying a workspace override path. +// Tools will resolve file operations against this path instead of the original workspace. +func WithWorkspaceOverride(ctx context.Context, workspace string) context.Context { + return context.WithValue(ctx, workspaceOverrideKey{}, workspace) +} + +// WorkspaceOverrideFromCtx extracts the workspace override from context, or "". +func WorkspaceOverrideFromCtx(ctx context.Context) string { + if v, ok := ctx.Value(workspaceOverrideKey{}).(string); ok { + return v + } + return "" +} + +// resolveFS returns a fileSystem applying workspace override from context. +// Paths under "memory/" are excluded (always use original workspace). +// For sandboxFs: creates a temporary instance with the override workspace. +// For hostFs (unrestricted): returns as-is. +func resolveFS(ctx context.Context, fs fileSystem, path string) fileSystem { + override := WorkspaceOverrideFromCtx(ctx) + if override == "" { + return fs + } + + // memory/ paths always use original workspace + if isMemoryPath(path) { + return fs + } + + // Only sandboxFs supports workspace override + if sfs, ok := fs.(*sandboxFs); ok { + if sfs.workspace == override { + return fs + } + return &sandboxFs{workspace: override} + } + + return fs +} + +// isMemoryPath returns true for paths under the memory/ directory. +// Matches: "memory/MEMORY.md", "memory", "/workspace/memory/notes.md" +func isMemoryPath(path string) bool { + p := filepath.ToSlash(filepath.Clean(path)) + + // Relative path starting with memory/ + if strings.HasPrefix(p, "memory/") || p == "memory" { + return true + } + + // Absolute path containing /memory/ or ending with /memory + if strings.Contains(p, "/memory/") || strings.HasSuffix(p, "/memory") { + return true + } + + return false +}