feat: add git worktree isolation for per-session plan/heartbeat execution
Each session (user plan, heartbeat) gets an independent git worktree, preventing concurrent sessions from conflicting on the same working tree. Key changes: - pkg/git/worktree.go: Git worktree CRUD with safe disposal (auto-commit before removing, branch retained if it has unique commits) - pkg/tools/workspace_ctx.go: Context-based filesystem redirect that transparently routes file/edit/shell tools to the session's worktree while keeping memory/ paths on the original workspace - Per-session worktree state on AgentInstance with activate/deactivate - SessionTracker extended with project coordination (purpose, branch) and GetPeerPurposes for lightweight cross-session awareness - System prompt injection of peer session info to avoid conflicts - Loop integration: worktree activation on /plan start, lazy creation for heartbeat on first write-tool, safe disposal on plan completion with merge instructions, cleanup after heartbeat - Shell guard: block git checkout/switch to prevent branch escapes Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
615a1fabc5
commit
53c4398487
14 changed files with 845 additions and 36 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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: <workspace>/.picoclaw/worktrees/<branch-basename>/
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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 <task>" (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 <task>" (new plan start) and:
|
||||
// - writes the interview seed to MEMORY.md
|
||||
// - rewrites the message content for the LLM
|
||||
|
|
|
|||
|
|
@ -465,6 +465,19 @@ func (ms *MemoryStore) GetPlanWorkDir() string {
|
|||
return strings.TrimSpace(m[1])
|
||||
}
|
||||
|
||||
// reTaskLine extracts the task name from "> Task: <description>".
|
||||
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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
241
pkg/git/worktree.go
Normal file
241
pkg/git/worktree.go
Normal file
|
|
@ -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/<safe-40-chars>".
|
||||
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()
|
||||
}
|
||||
218
pkg/git/worktree_test.go
Normal file
218
pkg/git/worktree_test.go
Normal file
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
67
pkg/tools/workspace_ctx.go
Normal file
67
pkg/tools/workspace_ctx.go
Normal file
|
|
@ -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
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue