12 |
13 | ## Installation
14 |
15 | ```bash
16 | go install github.com/devflowinc/uzi@latest
17 | ```
18 |
19 | Make sure that your GOBIN is in your PATH.
20 |
21 | ```sh
22 | export PATH="$PATH:$HOME/go/bin"
23 | ```
24 |
25 | ## Features
26 |
27 | - 🤖 Run multiple AI coding agents in parallel
28 | - 🌳 Automatic Git worktree management for isolated development
29 | - 🖥️ Tmux session management for each agent
30 | - 🚀 Automatic development server setup with port management
31 | - 📊 Real-time monitoring of agent status and code changes
32 | - 🔄 Automatic handling of agent prompts and confirmations
33 | - 🎯 Easy checkpoint and merge of agent changes
34 |
35 | ## Prerequisites
36 |
37 | - **Git**: For version control and worktree management
38 | - **Tmux**: For terminal session management
39 | - **Go**: For installing
40 | - **Your AI tool of choice**: Such as `claude`, `codex`, etc.
41 |
42 | ## Configuration
43 |
44 | ### uzi.yaml
45 |
46 | Create a `uzi.yaml` file in your project root to configure Uzi:
47 |
48 | ```yaml
49 | devCommand: cd astrobits && yarn && yarn dev --port $PORT
50 | portRange: 3000-3010
51 | ```
52 |
53 | #### Configuration Options
54 |
55 | - **`devCommand`**: The command to start your development server. Use `$PORT` as a placeholder for the port number.
56 | - Example for Next.js: `npm install && npm run dev -- --port $PORT`
57 | - Example for Vite: `npm install && npm run dev -- --port $PORT`
58 | - Example for Django: `pip install -r requirements.txt && python manage.py runserver 0.0.0.0:$PORT`
59 | - **`portRange`**: The range of ports Uzi can use for development servers (format: `start-end`)
60 |
61 | **Important**: The `devCommand` should include all necessary setup steps (like `npm install`, `pip install`, etc.) as each agent runs in an isolated worktree with its own dependencies.
62 |
63 | ## Basic Workflow
64 |
65 | 1. **Start agents with a task:**
66 |
67 | ```bash
68 | uzi prompt --agents claude:3,codex:2 "Implement a REST API for user management with authentication"
69 | ```
70 |
71 | 2. **Run uzi auto**
72 |
73 | uzi auto automatically presses Enter to confirm all tool calls
74 |
75 | ```
76 | uzi auto
77 | ```
78 |
79 | 3. **Monitor agent progress:**
80 |
81 | ```bash
82 | uzi ls -w # Watch mode
83 | ```
84 |
85 | 4. **Send additional instructions:**
86 |
87 | ```bash
88 | uzi broadcast "Make sure to add input validation"
89 | ```
90 |
91 | 5. **Merge completed work:**
92 | ```bash
93 | uzi checkpoint funny-elephant "feat: add user management API"
94 | ```
95 |
96 | ## Commands
97 |
98 | ### `uzi prompt` (alias: `uzi p`)
99 |
100 | Creates new agent sessions with the specified prompt.
101 |
102 | ```bash
103 | uzi prompt --agents claude:2,codex:1 "Build a todo app with React"
104 | ```
105 |
106 | **Options:**
107 |
108 | - `--agents`: Specify agents and counts in format `agent:count[,agent:count...]`
109 | - Use `random` as agent name for random agent names
110 | - Example: `--agents claude:2,random:3`
111 |
112 | ### `uzi ls` (alias: `uzi l`)
113 |
114 | Lists all active agent sessions with their status.
115 |
116 | ```bash
117 | uzi ls # List active sessions
118 | uzi ls -w # Watch mode - refreshes every second
119 | ```
120 |
121 | ```
122 | AGENT MODEL STATUS DIFF ADDR PROMPT
123 | brian codex ready +0/-0 http://localhost:3003 make a component that looks similar to @astrobits/src/components/Button/ that creates a Tooltip in the same style. Ensure that you include a reference to it and examples on the main page.
124 | gregory codex ready +0/-0 http://localhost:3001 make a component that `
125 | ```
126 |
127 | ### `uzi auto` (alias: `uzi a`)
128 |
129 | Monitors all agent sessions and automatically handles prompts.
130 |
131 | ```bash
132 | uzi auto
133 | ```
134 |
135 | **Features:**
136 |
137 | - Auto-presses Enter for trust prompts
138 | - Handles continuation confirmations
139 | - Runs in the background until interrupted (Ctrl+C)
140 |
141 | ### `uzi kill` (alias: `uzi k`)
142 |
143 | Terminates agent sessions and cleans up resources.
144 |
145 | ```bash
146 | uzi kill agent-name # Kill specific agent
147 | uzi kill all # Kill all agents
148 | ```
149 |
150 | ### `uzi run` (alias: `uzi r`)
151 |
152 | Executes a command in all active agent sessions.
153 |
154 | ```bash
155 | uzi run "git status" # Run in all agents
156 | uzi run --delete "npm test" # Run and delete the window after
157 | ```
158 |
159 | **Options:**
160 |
161 | - `--delete`: Remove the tmux window after running the command
162 |
163 | ### `uzi broadcast` (alias: `uzi b`)
164 |
165 | Sends a message to all active agent sessions.
166 |
167 | ```bash
168 | uzi broadcast "Please add error handling to all API calls"
169 | ```
170 |
171 | ### `uzi checkpoint` (alias: `uzi c`)
172 |
173 | Makes a commit and rebases changes from an agent's worktree into your current branch.
174 |
175 | ```bash
176 | uzi checkpoint agent-name "feat: implement user authentication"
177 | ```
178 |
179 | ### `uzi reset`
180 |
181 | Removes all Uzi data and configuration.
182 |
183 | ```bash
184 | uzi reset
185 | ```
186 |
187 | **Warning**: This deletes all data in `~/.local/share/uzi`
188 |
189 | ### Advanced Usage
190 |
191 | **Running different AI tools:**
192 |
193 | ```bash
194 | uzi prompt --agents=claude:2,aider:2,cursor:1 "Refactor the authentication system"
195 | ```
196 |
197 | **Using random agent names:**
198 |
199 | ```bash
200 | uzi prompt --agents=random:5 "Fix all TypeScript errors"
201 | ```
202 |
203 | **Running tests across all agents:**
204 |
205 | ```bash
206 | uzi run "npm test"
207 | ```
208 |
--------------------------------------------------------------------------------
/cmd/broadcast/broadcast.go:
--------------------------------------------------------------------------------
1 | package broadcast
2 |
3 | import (
4 | "context"
5 | "flag"
6 | "fmt"
7 | "os/exec"
8 | "strings"
9 |
10 | "github.com/devflowinc/uzi/pkg/state"
11 |
12 | "github.com/charmbracelet/log"
13 | "github.com/peterbourgon/ff/v3/ffcli"
14 | )
15 |
16 | var (
17 | fs = flag.NewFlagSet("uzi broadcast", flag.ExitOnError)
18 | CmdBroadcast = &ffcli.Command{
19 | Name: "broadcast",
20 | ShortUsage: "uzi broadcast ",
21 | ShortHelp: "Send a message to all active agent sessions",
22 | FlagSet: fs,
23 | Exec: executeBroadcast,
24 | }
25 | )
26 |
27 | func executeBroadcast(ctx context.Context, args []string) error {
28 | if len(args) == 0 {
29 | return fmt.Errorf("message argument is required")
30 | }
31 |
32 | message := strings.Join(args, " ")
33 | log.Debug("Broadcasting message", "message", message)
34 |
35 | // Get state manager to read from config
36 | sm := state.NewStateManager()
37 | if sm == nil {
38 | return fmt.Errorf("could not initialize state manager")
39 | }
40 |
41 | // Get active sessions from state
42 | activeSessions, err := sm.GetActiveSessionsForRepo()
43 | if err != nil {
44 | log.Error("Error getting active sessions", "error", err)
45 | return err
46 | }
47 |
48 | if len(activeSessions) == 0 {
49 | return fmt.Errorf("no active agent sessions found")
50 | }
51 |
52 | fmt.Printf("Broadcasting message to %d agent sessions:\n", len(activeSessions))
53 |
54 | // Send message to each session
55 | for _, session := range activeSessions {
56 | fmt.Printf("\n=== %s ===\n", session)
57 |
58 | // Send the message to the agent window
59 | sendKeysCmd := exec.Command("tmux", "send-keys", "-t", session+":agent", message, "Enter")
60 | if err := sendKeysCmd.Run(); err != nil {
61 | log.Error("Failed to send message to session", "session", session, "error", err)
62 | continue
63 | }
64 | exec.Command("tmux", "send-keys", "-t", session+":agent", "Enter").Run()
65 | }
66 |
67 | return nil
68 | }
69 |
--------------------------------------------------------------------------------
/cmd/checkpoint/checkpoint.go:
--------------------------------------------------------------------------------
1 | package checkpoint
2 |
3 | import (
4 | "context"
5 | "encoding/json"
6 | "flag"
7 | "fmt"
8 | "os"
9 | "os/exec"
10 | "strings"
11 |
12 | "github.com/devflowinc/uzi/pkg/state"
13 |
14 | "github.com/charmbracelet/log"
15 | "github.com/peterbourgon/ff/v3/ffcli"
16 | )
17 |
18 | var (
19 | fs = flag.NewFlagSet("uzi checkpoint", flag.ExitOnError)
20 | CmdCheckpoint = &ffcli.Command{
21 | Name: "checkpoint",
22 | ShortUsage: "uzi checkpoint ",
23 | ShortHelp: "Rebase changes from an agent worktree into the current worktree and commit",
24 | FlagSet: fs,
25 | Exec: executeCheckpoint,
26 | }
27 | )
28 |
29 | func executeCheckpoint(ctx context.Context, args []string) error {
30 | if len(args) < 2 {
31 | return fmt.Errorf("agent name and commit message arguments are required")
32 | }
33 |
34 | agentName := args[0]
35 | commitMessage := args[1]
36 | log.Debug("Checkpointing changes from agent", "agent", agentName)
37 |
38 | // Get state manager to read from config
39 | sm := state.NewStateManager()
40 | if sm == nil {
41 | return fmt.Errorf("could not initialize state manager")
42 | }
43 |
44 | // Get active sessions from state
45 | activeSessions, err := sm.GetActiveSessionsForRepo()
46 | if err != nil {
47 | log.Error("Error getting active sessions", "error", err)
48 | return err
49 | }
50 |
51 | // Find the session with the matching agent name
52 | var sessionToCheckpoint string
53 | for _, session := range activeSessions {
54 | // Extract agent name from session name (format: agent-projectDir-gitHash-agentName)
55 | parts := strings.Split(session, "-")
56 | if len(parts) >= 4 && parts[0] == "agent" {
57 | // Join all parts after the first 3 (in case agent name contains hyphens)
58 | sessionAgentName := strings.Join(parts[3:], "-")
59 | if sessionAgentName == agentName {
60 | sessionToCheckpoint = session
61 | break
62 | }
63 | }
64 | }
65 |
66 | if sessionToCheckpoint == "" {
67 | return fmt.Errorf("no active session found for agent: %s", agentName)
68 | }
69 |
70 | // Get session state to find worktree path
71 | states := make(map[string]state.AgentState)
72 | if data, err := os.ReadFile(sm.GetStatePath()); err != nil {
73 | return fmt.Errorf("error reading state file: %v", err)
74 | } else {
75 | if err := json.Unmarshal(data, &states); err != nil {
76 | return fmt.Errorf("error parsing state file: %v", err)
77 | }
78 | }
79 |
80 | sessionState, ok := states[sessionToCheckpoint]
81 | if !ok || sessionState.WorktreePath == "" {
82 | return fmt.Errorf("invalid state for session: %s", sessionToCheckpoint)
83 | }
84 |
85 | // Get the actual branch name from the state
86 | agentBranchName := sessionState.BranchName
87 |
88 | // Get current directory (should be the main worktree)
89 | currentDir, err := os.Getwd()
90 | if err != nil {
91 | return fmt.Errorf("error getting current directory: %v", err)
92 | }
93 |
94 | // Get the current branch name in the main worktree
95 | getCurrentBranchCmd := exec.CommandContext(ctx, "git", "branch", "--show-current")
96 | getCurrentBranchCmd.Dir = currentDir
97 | currentBranchOutput, err := getCurrentBranchCmd.Output()
98 | if err != nil {
99 | return fmt.Errorf("error getting current branch: %v", err)
100 | }
101 | currentBranch := strings.TrimSpace(string(currentBranchOutput))
102 |
103 | // Check if agent branch exists
104 | checkBranchCmd := exec.CommandContext(ctx, "git", "show-ref", "--verify", "--quiet", "refs/heads/"+agentBranchName)
105 | checkBranchCmd.Dir = currentDir
106 | if err := checkBranchCmd.Run(); err != nil {
107 | return fmt.Errorf("agent branch does not exist: %s", agentBranchName)
108 | }
109 |
110 | // Stage all changes and commit on the agent branch
111 | addCmd := exec.CommandContext(ctx, "git", "add", ".")
112 | addCmd.Dir = sessionState.WorktreePath
113 | if err := addCmd.Run(); err != nil {
114 | return fmt.Errorf("error staging changes: %v", err)
115 | }
116 |
117 | commitCmd := exec.CommandContext(ctx, "git", "commit", "-am", commitMessage)
118 | commitCmd.Dir = sessionState.WorktreePath
119 | commitCmd.Stdout = os.Stdout
120 | commitCmd.Stderr = os.Stderr
121 | if err := commitCmd.Run(); err != nil {
122 | log.Warn("No unstaged changes to commit, rebasing")
123 | }
124 |
125 | // Get the base commit where the agent branch diverged
126 | mergeBaseCmd := exec.CommandContext(ctx, "git", "merge-base", currentBranch, agentBranchName)
127 | mergeBaseCmd.Dir = currentDir
128 | mergeBaseOutput, err := mergeBaseCmd.Output()
129 | if err != nil {
130 | return fmt.Errorf("error finding merge base: %v", err)
131 | }
132 | mergeBase := strings.TrimSpace(string(mergeBaseOutput))
133 |
134 | // Check if there are any changes to rebase
135 | diffCmd := exec.CommandContext(ctx, "git", "rev-list", "--count", mergeBase+".."+agentBranchName)
136 | diffCmd.Dir = currentDir
137 | diffOutput, err := diffCmd.Output()
138 | if err != nil {
139 | return fmt.Errorf("error checking for changes: %v", err)
140 | }
141 | changeCount := strings.TrimSpace(string(diffOutput))
142 |
143 | fmt.Printf("Checkpointing %s commits from agent: %s\n", changeCount, agentName)
144 |
145 | // Rebase the agent branch onto the current branch
146 | rebaseCmd := exec.CommandContext(ctx, "git", "rebase", agentBranchName)
147 | rebaseCmd.Dir = currentDir
148 | rebaseCmd.Stdout = os.Stdout
149 | rebaseCmd.Stderr = os.Stderr
150 | if err := rebaseCmd.Run(); err != nil {
151 | return fmt.Errorf("error rebasing agent changes: %v", err)
152 | }
153 |
154 | fmt.Printf("Successfully checkpointed changes from agent: %s\n", agentName)
155 | fmt.Printf("Successfully committed changes with message: %s\n", commitMessage)
156 | return nil
157 | }
158 |
--------------------------------------------------------------------------------
/cmd/kill/kill.go:
--------------------------------------------------------------------------------
1 | package kill
2 |
3 | import (
4 | "context"
5 | "flag"
6 | "fmt"
7 | "os"
8 | "os/exec"
9 | "path/filepath"
10 | "strings"
11 |
12 | "github.com/devflowinc/uzi/pkg/state"
13 |
14 | "github.com/charmbracelet/log"
15 | "github.com/peterbourgon/ff/v3/ffcli"
16 | )
17 |
18 | var (
19 | fs = flag.NewFlagSet("uzi kill", flag.ExitOnError)
20 | CmdKill = &ffcli.Command{
21 | Name: "kill",
22 | ShortUsage: "uzi kill [|all]",
23 | ShortHelp: "Delete tmux session and git worktree for the specified agent",
24 | FlagSet: fs,
25 | Exec: executeKill,
26 | }
27 | )
28 |
29 | // killSession kills a single session and cleans up its associated resources
30 | func killSession(ctx context.Context, sessionName, agentName string, sm *state.StateManager) error {
31 | log.Debug("Deleting tmux session and git worktree", "session", sessionName, "agent", agentName)
32 |
33 | // Kill tmux session if it exists
34 | checkSession := exec.CommandContext(ctx, "tmux", "has-session", "-t", sessionName)
35 | if err := checkSession.Run(); err == nil {
36 | // Session exists, kill it
37 | killCmd := exec.CommandContext(ctx, "tmux", "kill-session", "-t", sessionName)
38 | if err := killCmd.Run(); err != nil {
39 | log.Error("Error killing tmux session", "session", sessionName, "error", err)
40 | } else {
41 | log.Debug("Killed tmux session", "session", sessionName)
42 | }
43 | }
44 |
45 | // Remove git worktree
46 | worktreePath := filepath.Join(filepath.Dir(os.Args[0]), "..", agentName)
47 | if _, err := os.Stat(worktreePath); err == nil {
48 | // Get worktree path from state
49 | worktreeInfo, err := sm.GetWorktreeInfo(sessionName)
50 | if err != nil {
51 | log.Error("Error getting worktree info", "session", sessionName, "error", err)
52 | return fmt.Errorf("failed to get worktree info: %w", err)
53 | }
54 |
55 | // First, remove the worktree
56 | removeCmd := exec.CommandContext(ctx, "git", "worktree", "remove", "--force", worktreeInfo.WorktreePath)
57 | removeCmd.Dir = filepath.Dir(os.Args[0])
58 | if err := removeCmd.Run(); err != nil {
59 | log.Error("Error removing git worktree", "path", worktreeInfo.WorktreePath, "error", err)
60 | return fmt.Errorf("failed to remove git worktree: %w", err)
61 | }
62 | log.Debug("Removed git worktree", "path", worktreeInfo.WorktreePath)
63 |
64 | // Then delete the branch
65 | deleteBranchCmd := exec.CommandContext(ctx, "git", "branch", "-D", agentName)
66 | deleteBranchCmd.Dir = filepath.Dir(os.Args[0])
67 | if err := deleteBranchCmd.Run(); err != nil {
68 | log.Error("Error deleting git branch", "branch", agentName, "error", err)
69 | return fmt.Errorf("failed to delete git branch: %w", err)
70 | }
71 | log.Debug("Deleted git branch", "branch", agentName)
72 | }
73 |
74 | // Delete from config store (~/.local/share/uzi/)
75 | homeDir, err := os.UserHomeDir()
76 | if err == nil {
77 | // Remove worktree directory from config store
78 | configWorktreePath := filepath.Join(homeDir, ".local", "share", "uzi", "worktrees", agentName)
79 | if _, err := os.Stat(configWorktreePath); err == nil {
80 | if err := os.RemoveAll(configWorktreePath); err != nil {
81 | log.Error("Error removing config worktree", "path", configWorktreePath, "error", err)
82 | } else {
83 | log.Debug("Removed config worktree", "path", configWorktreePath)
84 | }
85 | }
86 |
87 | // Remove worktree state directory
88 | worktreeStatePath := filepath.Join(homeDir, ".local", "share", "uzi", "worktree", sessionName)
89 | if _, err := os.Stat(worktreeStatePath); err == nil {
90 | if err := os.RemoveAll(worktreeStatePath); err != nil {
91 | log.Error("Error removing worktree state", "path", worktreeStatePath, "error", err)
92 | } else {
93 | log.Debug("Removed worktree state", "path", worktreeStatePath)
94 | }
95 | }
96 |
97 | // Remove from state.json
98 | if err := sm.RemoveState(sessionName); err != nil {
99 | log.Error("Error removing state entry", "session", sessionName, "error", err)
100 | } else {
101 | log.Debug("Removed state entry", "session", sessionName)
102 | }
103 | }
104 |
105 | return nil
106 | }
107 |
108 | // killAll kills all sessions for the current git repository
109 | func killAll(ctx context.Context, sm *state.StateManager) error {
110 | log.Debug("Deleting all agents for repository")
111 |
112 | // Get active sessions from state
113 | activeSessions, err := sm.GetActiveSessionsForRepo()
114 | if err != nil {
115 | log.Error("Error getting active sessions", "error", err)
116 | return err
117 | }
118 |
119 | if len(activeSessions) == 0 {
120 | fmt.Println("No active sessions found")
121 | return nil
122 | }
123 |
124 | killedCount := 0
125 | for _, sessionName := range activeSessions {
126 | // Extract agent name from session name (assuming format: repo-agentName)
127 | parts := strings.Split(sessionName, "-")
128 | if len(parts) < 2 {
129 | log.Warn("Unexpected session name format", "session", sessionName)
130 | continue
131 | }
132 | agentName := parts[len(parts)-1] // Get the last part as agent name
133 |
134 | if err := killSession(ctx, sessionName, agentName, sm); err != nil {
135 | log.Error("Error killing session", "session", sessionName, "error", err)
136 | continue
137 | }
138 |
139 | killedCount++
140 | fmt.Printf("Deleted agent: %s\n", agentName)
141 | }
142 |
143 | fmt.Printf("Successfully deleted %d agent(s)\n", killedCount)
144 | return nil
145 | }
146 |
147 | func executeKill(ctx context.Context, args []string) error {
148 | if len(args) == 0 {
149 | return fmt.Errorf("agent name argument is required")
150 | }
151 |
152 | agentName := args[0]
153 |
154 | // Get state manager to read from config
155 | sm := state.NewStateManager()
156 | if sm == nil {
157 | return fmt.Errorf("could not initialize state manager")
158 | }
159 |
160 | // Handle "all" case
161 | if agentName == "all" {
162 | return killAll(ctx, sm)
163 | }
164 |
165 | // Get active sessions from state
166 | activeSessions, err := sm.GetActiveSessionsForRepo()
167 | if err != nil {
168 | log.Error("Error getting active sessions", "error", err)
169 | return err
170 | }
171 |
172 | // Find the session with the matching agent name
173 | var sessionToKill string
174 | for _, session := range activeSessions {
175 | if strings.HasSuffix(session, "-"+agentName) {
176 | sessionToKill = session
177 | break
178 | }
179 | }
180 |
181 | if sessionToKill == "" {
182 | log.Debug("No active tmux session found for agent", "agent", agentName)
183 | return fmt.Errorf("no active session found for agent: %s", agentName)
184 | }
185 |
186 | // Kill the specific session
187 | if err := killSession(ctx, sessionToKill, agentName, sm); err != nil {
188 | return err
189 | }
190 |
191 | fmt.Printf("Deleted agent: %s\n", agentName)
192 | return nil
193 | }
194 |
--------------------------------------------------------------------------------
/cmd/ls/ls.go:
--------------------------------------------------------------------------------
1 | package ls
2 |
3 | import (
4 | "bytes"
5 | "context"
6 | "encoding/json"
7 | "flag"
8 | "fmt"
9 | "os"
10 | "os/exec"
11 | "regexp"
12 | "sort"
13 | "strings"
14 | "text/tabwriter"
15 | "time"
16 |
17 | "github.com/devflowinc/uzi/pkg/config"
18 | "github.com/devflowinc/uzi/pkg/state"
19 |
20 | "github.com/peterbourgon/ff/v3/ffcli"
21 | )
22 |
23 | var (
24 | fs = flag.NewFlagSet("uzi ls", flag.ExitOnError)
25 | configPath = fs.String("config", config.GetDefaultConfigPath(), "path to config file")
26 | allSessions = fs.Bool("a", false, "show all sessions including inactive")
27 | watchMode = fs.Bool("w", false, "watch mode - refresh output every second")
28 | CmdLs = &ffcli.Command{
29 | Name: "ls",
30 | ShortUsage: "uzi ls [-a] [-w]",
31 | ShortHelp: "List active agent sessions",
32 | FlagSet: fs,
33 | Exec: executeLs,
34 | }
35 | )
36 |
37 | func getGitDiffTotals(sessionName string, stateManager *state.StateManager) (int, int) {
38 | // Get session state to find worktree path
39 | states := make(map[string]state.AgentState)
40 | if data, err := os.ReadFile(stateManager.GetStatePath()); err != nil {
41 | return 0, 0
42 | } else {
43 | if err := json.Unmarshal(data, &states); err != nil {
44 | return 0, 0
45 | }
46 | }
47 |
48 | sessionState, ok := states[sessionName]
49 | if !ok || sessionState.WorktreePath == "" {
50 | return 0, 0
51 | }
52 |
53 | shellCmdString := "git add -A . && git diff --cached --shortstat HEAD && git reset HEAD > /dev/null"
54 |
55 | cmd := exec.Command("sh", "-c", shellCmdString)
56 | cmd.Dir = sessionState.WorktreePath
57 |
58 | var out bytes.Buffer
59 | var stderr bytes.Buffer
60 | cmd.Stdout = &out
61 | cmd.Stderr = &stderr
62 |
63 | if err := cmd.Run(); err != nil {
64 | return 0, 0
65 | }
66 |
67 | output := out.String()
68 |
69 | insertions := 0
70 | deletions := 0
71 |
72 | insRe := regexp.MustCompile(`(\d+) insertion(?:s)?\(\+\)`)
73 | delRe := regexp.MustCompile(`(\d+) deletion(?:s)?\(\-\)`)
74 |
75 | if m := insRe.FindStringSubmatch(output); len(m) > 1 {
76 | fmt.Sscanf(m[1], "%d", &insertions)
77 | }
78 | if m := delRe.FindStringSubmatch(output); len(m) > 1 {
79 | fmt.Sscanf(m[1], "%d", &deletions)
80 | }
81 |
82 | return insertions, deletions
83 | }
84 |
85 | func getPaneContent(sessionName string) (string, error) {
86 | cmd := exec.Command("tmux", "capture-pane", "-t", sessionName+":agent", "-p")
87 | output, err := cmd.Output()
88 | if err != nil {
89 | return "", err
90 | }
91 | return string(output), nil
92 | }
93 |
94 | func getAgentStatus(sessionName string) string {
95 | content, err := getPaneContent(sessionName)
96 | if err != nil {
97 | return "unknown"
98 | }
99 |
100 | if strings.Contains(content, "esc to interrupt") || strings.Contains(content, "Thinking") {
101 | return "running"
102 | }
103 | return "ready"
104 | }
105 |
106 | func formatStatus(status string) string {
107 | switch status {
108 | case "ready":
109 | return "\033[32mready\033[0m" // Green
110 | case "running":
111 | return "\033[33mrunning\033[0m" // Orange/Yellow
112 | default:
113 | return status
114 | }
115 | }
116 |
117 | func formatTime(t time.Time) string {
118 | now := time.Now()
119 | diff := now.Sub(t)
120 |
121 | if diff < time.Hour {
122 | return fmt.Sprintf("%2dm", int(diff.Minutes()))
123 | } else if diff < 24*time.Hour {
124 | return fmt.Sprintf("%2dh", int(diff.Hours()))
125 | } else if diff < 7*24*time.Hour {
126 | return fmt.Sprintf("%2dd", int(diff.Hours()/24))
127 | }
128 | return t.Format("Jan 02")
129 | }
130 |
131 | func printSessions(stateManager *state.StateManager, activeSessions []string) error {
132 | // Load all states to sort by UpdatedAt
133 | states := make(map[string]state.AgentState)
134 | if data, err := os.ReadFile(stateManager.GetStatePath()); err == nil {
135 | if err := json.Unmarshal(data, &states); err != nil {
136 | return fmt.Errorf("error parsing state file: %w", err)
137 | }
138 | }
139 |
140 | // Create a slice of sessions with their states for sorting
141 | type sessionInfo struct {
142 | name string
143 | state state.AgentState
144 | }
145 | var sessions []sessionInfo
146 | for _, sessionName := range activeSessions {
147 | if state, ok := states[sessionName]; ok {
148 | sessions = append(sessions, sessionInfo{name: sessionName, state: state})
149 | }
150 | }
151 |
152 | // Sort by UpdatedAt (most recent first)
153 | sort.Slice(sessions, func(i, j int) bool {
154 | return sessions[i].state.UpdatedAt.After(sessions[j].state.UpdatedAt)
155 | })
156 |
157 | // Long format with tabwriter for alignment
158 | w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
159 |
160 | // Print header
161 | fmt.Fprintf(w, "AGENT\tMODEL\tSTATUS DIFF\tADDR\tPROMPT\n")
162 |
163 | // Print sessions
164 | for _, session := range sessions {
165 | sessionName := session.name
166 | state := session.state
167 |
168 | // Extract agent name from session name
169 | parts := strings.Split(sessionName, "-")
170 | agentName := sessionName
171 | if len(parts) >= 4 && parts[0] == "agent" {
172 | agentName = strings.Join(parts[3:], "-")
173 | }
174 |
175 | status := getAgentStatus(sessionName)
176 | insertions, deletions := getGitDiffTotals(sessionName, stateManager)
177 |
178 | // Format diff stats with colors
179 | var changes string
180 | if insertions == 0 && deletions == 0 {
181 | changes = "\033[32m+0\033[0m/\033[31m-0\033[0m"
182 | } else {
183 | // ANSI color codes: green for additions, red for deletions
184 | changes = fmt.Sprintf("\033[32m+%d\033[0m/\033[31m-%d\033[0m", insertions, deletions)
185 | }
186 |
187 | // Get model name, default to "unknown" if empty (for backward compatibility)
188 | model := state.Model
189 | if model == "" {
190 | model = "unknown"
191 | }
192 |
193 | // Format: agent model status addr changes prompt
194 | addr := ""
195 | if state.Port != 0 {
196 | addr = fmt.Sprintf("http://localhost:%d", state.Port)
197 | }
198 | fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\n",
199 | agentName,
200 | model,
201 | formatStatus(status),
202 | changes,
203 | addr,
204 | state.Prompt,
205 | )
206 | }
207 | w.Flush()
208 |
209 | return nil
210 | }
211 |
212 | func clearScreen() {
213 | fmt.Print("\033[H\033[2J")
214 | }
215 |
216 | func executeLs(ctx context.Context, args []string) error {
217 | stateManager := state.NewStateManager()
218 | if stateManager == nil {
219 | return fmt.Errorf("failed to create state manager")
220 | }
221 |
222 | if *watchMode {
223 | // Watch mode - refresh every second
224 | ticker := time.NewTicker(1 * time.Second)
225 | defer ticker.Stop()
226 |
227 | // Initial display
228 | clearScreen()
229 | activeSessions, err := stateManager.GetActiveSessionsForRepo()
230 | if err != nil {
231 | return fmt.Errorf("error getting active sessions: %w", err)
232 | }
233 |
234 | if len(activeSessions) == 0 {
235 | fmt.Println("No active sessions found")
236 | } else {
237 | if err := printSessions(stateManager, activeSessions); err != nil {
238 | return err
239 | }
240 | }
241 |
242 | // Watch loop
243 | for {
244 | select {
245 | case <-ctx.Done():
246 | return ctx.Err()
247 | case <-ticker.C:
248 | clearScreen()
249 | activeSessions, err := stateManager.GetActiveSessionsForRepo()
250 | if err != nil {
251 | fmt.Printf("Error getting active sessions: %v\n", err)
252 | continue
253 | }
254 |
255 | if len(activeSessions) == 0 {
256 | fmt.Println("No active sessions found")
257 | } else {
258 | if err := printSessions(stateManager, activeSessions); err != nil {
259 | fmt.Printf("Error printing sessions: %v\n", err)
260 | }
261 | }
262 | }
263 | }
264 | } else {
265 | // Single run mode
266 | activeSessions, err := stateManager.GetActiveSessionsForRepo()
267 | if err != nil {
268 | return fmt.Errorf("error getting active sessions: %w", err)
269 | }
270 |
271 | if len(activeSessions) == 0 {
272 | fmt.Println("No active sessions found")
273 | return nil
274 | }
275 |
276 | return printSessions(stateManager, activeSessions)
277 | }
278 | }
279 |
--------------------------------------------------------------------------------
/cmd/prompt/prompt.go:
--------------------------------------------------------------------------------
1 | package prompt
2 |
3 | import (
4 | "context"
5 | "flag"
6 | "fmt"
7 | "net"
8 | "os"
9 | "os/exec"
10 | "path/filepath"
11 | "strconv"
12 | "strings"
13 | "time"
14 |
15 | "github.com/devflowinc/uzi/pkg/agents"
16 | "github.com/devflowinc/uzi/pkg/config"
17 | "github.com/devflowinc/uzi/pkg/state"
18 |
19 | "github.com/charmbracelet/log"
20 | "github.com/peterbourgon/ff/v3/ffcli"
21 | )
22 |
23 | type AgentConfig struct {
24 | Command string
25 | Count int
26 | }
27 |
28 | var (
29 | fs = flag.NewFlagSet("uzi prompt", flag.ExitOnError)
30 | agentsFlag = fs.String("agents", "claude:1", "agents to run with their commands and counts (e.g., 'claude:1,codex:2'). Use 'random' as agent name to select a random agent name.")
31 | configPath = fs.String("config", config.GetDefaultConfigPath(), "path to config file")
32 | CmdPrompt = &ffcli.Command{
33 | Name: "prompt",
34 | ShortUsage: "uzi prompt --agents=AGENT:COUNT[,AGENT:COUNT...] prompt text...",
35 | ShortHelp: "Run the prompt command with specified agents and counts",
36 | FlagSet: fs,
37 | Exec: executePrompt,
38 | }
39 | )
40 |
41 | // parseAgents parses the agents flag value into a map of agent configs
42 | func parseAgents(agentsStr string) (map[string]AgentConfig, error) {
43 | agentConfigs := make(map[string]AgentConfig)
44 |
45 | // Split by comma for multiple agent configurations
46 | agentPairs := strings.Split(agentsStr, ",")
47 |
48 | for _, pair := range agentPairs {
49 | // Split by colon for agent:count
50 | parts := strings.Split(strings.TrimSpace(pair), ":")
51 | if len(parts) != 2 {
52 | return nil, fmt.Errorf("invalid agent format: %s (expected agent:count)", pair)
53 | }
54 |
55 | agent := strings.TrimSpace(parts[0])
56 | countStr := strings.TrimSpace(parts[1])
57 |
58 | count, err := strconv.Atoi(countStr)
59 | if err != nil {
60 | return nil, fmt.Errorf("invalid count for agent %s: %s", agent, countStr)
61 | }
62 |
63 | if count < 1 {
64 | return nil, fmt.Errorf("count must be at least 1 for agent %s", agent)
65 | }
66 |
67 | // The command is the same as the agent name by default
68 | agentConfigs[agent] = AgentConfig{
69 | Command: agent,
70 | Count: count,
71 | }
72 | }
73 |
74 | return agentConfigs, nil
75 | }
76 |
77 | // isPortAvailable checks if a port is available for use
78 | func isPortAvailable(port int) bool {
79 | address := fmt.Sprintf(":%d", port)
80 | listener, err := net.Listen("tcp", address)
81 | if err != nil {
82 | return false
83 | }
84 | listener.Close()
85 | return true
86 | }
87 |
88 | // findAvailablePort finds the first available port in the given range, excluding already assigned ports
89 | func findAvailablePort(startPort, endPort int, assignedPorts []int) (int, error) {
90 | for port := startPort; port <= endPort; port++ {
91 | // Check if port is already assigned in this execution
92 | alreadyAssigned := false
93 | for _, assignedPort := range assignedPorts {
94 | if port == assignedPort {
95 | alreadyAssigned = true
96 | break
97 | }
98 | }
99 | if alreadyAssigned {
100 | continue
101 | }
102 |
103 | // Check if port is actually available
104 | if isPortAvailable(port) {
105 | return port, nil
106 | }
107 | }
108 | return 0, fmt.Errorf("no available ports in range %d-%d", startPort, endPort)
109 | }
110 |
111 | func executePrompt(ctx context.Context, args []string) error {
112 | if len(args) == 0 {
113 | return fmt.Errorf("prompt argument is required")
114 | }
115 |
116 | // Load config
117 | cfg, err := config.LoadConfig(*configPath)
118 | if err != nil {
119 | log.Warn("Error loading config, using default values", "error", err)
120 | cfg = &config.Config{} // Use default or empty config
121 | }
122 | if cfg.DevCommand == nil || *cfg.DevCommand == "" {
123 | log.Info("Dev command not set in config, skipping dev server startup.")
124 | }
125 | if cfg.PortRange == nil || *cfg.PortRange == "" {
126 | log.Info("Port range not set in config, skipping dev server startup.")
127 | }
128 |
129 | promptText := strings.Join(args, " ")
130 | log.Debug("Running prompt command", "prompt", promptText)
131 |
132 | // Track assigned ports to prevent collisions between iterations
133 | var assignedPorts []int
134 |
135 | // Parse agents
136 | agentConfigs, err := parseAgents(*agentsFlag)
137 | if err != nil {
138 | return fmt.Errorf("error parsing agents: %s", err)
139 | }
140 |
141 | for agent, config := range agentConfigs {
142 | for i := 0; i < config.Count; i++ {
143 | // Always get a random agent name for the session/branch/worktree names
144 | randomAgentName := agents.GetRandomAgent()
145 |
146 | // Use the specified agent for the command (unless it's "random")
147 | commandToUse := config.Command
148 | if agent == "random" {
149 | // If agent is "random", use the random name for the command too
150 | commandToUse = randomAgentName
151 | }
152 |
153 | fmt.Printf("%s: %s: %s\n", randomAgentName, commandToUse, promptText)
154 |
155 | // Check if git worktree exists
156 | // Get the current git hash
157 | gitHashCmd := exec.CommandContext(ctx, "git", "rev-parse", "--short", "HEAD")
158 | gitHashCmd.Dir = filepath.Dir(os.Args[0])
159 | gitHashOutput, err := gitHashCmd.Output()
160 | if err != nil {
161 | log.Error("Error getting git hash", "error", err)
162 | continue
163 | }
164 | gitHash := strings.TrimSpace(string(gitHashOutput))
165 |
166 | // Get the git repository name from remote URL
167 | gitRemoteCmd := exec.CommandContext(ctx, "git", "remote", "get-url", "origin")
168 | gitRemoteCmd.Dir = filepath.Dir(os.Args[0])
169 | gitRemoteOutput, err := gitRemoteCmd.Output()
170 | if err != nil {
171 | log.Error("Error getting git remote", "error", err)
172 | continue
173 | }
174 | remoteURL := strings.TrimSpace(string(gitRemoteOutput))
175 | // Extract repository name from URL (handle both https and ssh formats)
176 | repoName := filepath.Base(remoteURL)
177 | projectDir := strings.TrimSuffix(repoName, ".git")
178 |
179 | // Create unique identifier using timestamp and iteration
180 | timestamp := time.Now().Unix()
181 | uniqueId := fmt.Sprintf("%d-%d", timestamp, i)
182 |
183 | // Create unique branch and worktree names using the random agent name
184 | branchName := fmt.Sprintf("%s-%s-%s-%s", randomAgentName, projectDir, gitHash, uniqueId)
185 | worktreeName := fmt.Sprintf("%s-%s-%s-%s", randomAgentName, projectDir, gitHash, uniqueId)
186 |
187 | // Prefix the tmux session name with the git hash and use random agent name
188 | sessionName := fmt.Sprintf("agent-%s-%s-%s", projectDir, gitHash, randomAgentName)
189 |
190 | // Get home directory for worktree storage
191 | homeDir, err := os.UserHomeDir()
192 | if err != nil {
193 | log.Error("Error getting home directory", "error", err)
194 | continue
195 | }
196 |
197 | worktreesDir := filepath.Join(homeDir, ".local", "share", "uzi", "worktrees")
198 | if err := os.MkdirAll(worktreesDir, 0755); err != nil {
199 | log.Error("Error creating worktrees directory", "error", err)
200 | continue
201 | }
202 |
203 | worktreePath := filepath.Join(worktreesDir, worktreeName)
204 | var selectedPort int
205 | // Create git worktree
206 | cmd := fmt.Sprintf("git worktree add -b %s %s", branchName, worktreePath)
207 | cmdExec := exec.CommandContext(ctx, "sh", "-c", cmd)
208 | cmdExec.Dir = filepath.Dir(os.Args[0])
209 | if err := cmdExec.Run(); err != nil {
210 | log.Error("Error creating git worktree", "command", cmd, "error", err)
211 | continue
212 | }
213 |
214 | // Create tmux session
215 | cmd = fmt.Sprintf("tmux new-session -d -s %s -c %s", sessionName, worktreePath)
216 | cmdExec = exec.CommandContext(ctx, "sh", "-c", cmd)
217 | if err := cmdExec.Run(); err != nil {
218 | log.Error("Error creating tmux session", "command", cmd, "error", err)
219 | continue
220 | }
221 |
222 | // Rename the first window to "agent"
223 | renameCmd := fmt.Sprintf("tmux rename-window -t %s:0 agent", sessionName)
224 | renameExec := exec.CommandContext(ctx, "sh", "-c", renameCmd)
225 | if err := renameExec.Run(); err != nil {
226 | log.Error("Error renaming tmux window", "command", renameCmd, "error", err)
227 | continue
228 | }
229 |
230 | // Create uzi-dev pane and run dev command if configured
231 | if cfg.DevCommand == nil || *cfg.DevCommand == "" || cfg.PortRange == nil || *cfg.PortRange == "" {
232 | // Hit enter in the agent pane
233 | hitEnterCmd := fmt.Sprintf("tmux send-keys -t %s:agent C-m", sessionName)
234 | hitEnterExec := exec.CommandContext(ctx, "sh", "-c", hitEnterCmd)
235 | if err := hitEnterExec.Run(); err != nil {
236 | log.Error("Error hitting enter in tmux", "command", hitEnterCmd, "error", err)
237 | }
238 |
239 | // Always run send-keys command to the agent pane
240 | tmuxCmd := fmt.Sprintf("tmux send-keys -t %s:agent '%s \"%%s\"' C-m", sessionName, commandToUse)
241 | tmuxCmdExec := exec.CommandContext(ctx, "sh", "-c", fmt.Sprintf(tmuxCmd, promptText))
242 | tmuxCmdExec.Dir = worktreePath
243 | if err := tmuxCmdExec.Run(); err != nil {
244 | log.Error("Error sending keys to tmux", "command", tmuxCmd, "error", err)
245 | continue
246 | }
247 |
248 | // Save state before continuing (no port since dev server not started)
249 | stateManager := state.NewStateManager()
250 | if stateManager != nil {
251 | if err := stateManager.SaveState(promptText, branchName, sessionName, worktreePath, commandToUse); err != nil {
252 | log.Error("Error saving state", "error", err)
253 | }
254 | }
255 | continue
256 | }
257 |
258 | ports := strings.Split(*cfg.PortRange, "-")
259 | if len(ports) != 2 {
260 | log.Warn("Invalid port range format in config", "portRange", *cfg.PortRange)
261 | continue
262 | }
263 |
264 | startPort, _ := strconv.Atoi(ports[0])
265 | endPort, _ := strconv.Atoi(ports[1])
266 | if startPort <= 0 || endPort <= 0 || endPort < startPort {
267 | log.Warn("Invalid port range in config", "portRange", *cfg.PortRange)
268 | continue
269 | }
270 |
271 | selectedPort, err = findAvailablePort(startPort, endPort, assignedPorts)
272 | if err != nil {
273 | log.Error("Error finding available port", "error", err)
274 | continue
275 | }
276 |
277 | devCmdTemplate := *cfg.DevCommand
278 | devCmd := strings.Replace(devCmdTemplate, "$PORT", strconv.Itoa(selectedPort), 1)
279 |
280 | // Create new window named uzi-dev
281 | newWindowCmd := fmt.Sprintf("tmux new-window -t %s -n uzi-dev -c %s", sessionName, worktreePath)
282 | newWindowExec := exec.CommandContext(ctx, "sh", "-c", newWindowCmd)
283 | if err := newWindowExec.Run(); err != nil {
284 | log.Error("Error creating new tmux window for dev server", "command", newWindowCmd, "error", err)
285 | continue
286 | }
287 |
288 | // Send dev command to the new window
289 | sendDevCmd := fmt.Sprintf("tmux send-keys -t %s:uzi-dev '%s' C-m", sessionName, devCmd)
290 | sendDevCmdExec := exec.CommandContext(ctx, "sh", "-c", sendDevCmd)
291 | if err := sendDevCmdExec.Run(); err != nil {
292 | log.Error("Error sending dev command to tmux", "command", sendDevCmd, "error", err)
293 | }
294 |
295 | // Hit enter in the agent pane
296 | hitEnterCmd := fmt.Sprintf("tmux send-keys -t %s:agent C-m", sessionName)
297 | hitEnterExec := exec.CommandContext(ctx, "sh", "-c", hitEnterCmd)
298 | if err := hitEnterExec.Run(); err != nil {
299 | log.Error("Error hitting enter in tmux", "command", hitEnterCmd, "error", err)
300 | }
301 |
302 | assignedPorts = append(assignedPorts, selectedPort)
303 |
304 | // Always run send-keys command to the agent pane
305 | tmuxCmd := fmt.Sprintf("tmux send-keys -t %s:agent '%s \"%%s\"' C-m", sessionName, commandToUse)
306 | tmuxCmdExec := exec.CommandContext(ctx, "sh", "-c", fmt.Sprintf(tmuxCmd, promptText))
307 | tmuxCmdExec.Dir = worktreePath
308 | if err := tmuxCmdExec.Run(); err != nil {
309 | log.Error("Error sending keys to tmux", "command", tmuxCmd, "error", err)
310 | continue
311 | }
312 |
313 | // Save state after successful prompt execution
314 | stateManager := state.NewStateManager()
315 | if stateManager != nil {
316 | if err := stateManager.SaveStateWithPort(promptText, branchName, sessionName, worktreePath, commandToUse, selectedPort); err != nil {
317 | log.Error("Error saving state", "error", err)
318 | }
319 | }
320 | }
321 | }
322 |
323 | return nil
324 | }
325 |
--------------------------------------------------------------------------------
/cmd/reset/reset.go:
--------------------------------------------------------------------------------
1 | package reset
2 |
3 | import (
4 | "bufio"
5 | "context"
6 | "flag"
7 | "fmt"
8 | "os"
9 | "path/filepath"
10 | "strings"
11 |
12 | "github.com/charmbracelet/log"
13 | "github.com/peterbourgon/ff/v3/ffcli"
14 | )
15 |
16 | var (
17 | fs = flag.NewFlagSet("uzi reset", flag.ExitOnError)
18 | CmdReset = &ffcli.Command{
19 | Name: "reset",
20 | ShortUsage: "uzi reset",
21 | ShortHelp: "Delete all data stored in ~/.local/share/uzi",
22 | FlagSet: fs,
23 | Exec: executeReset,
24 | }
25 | )
26 |
27 | func executeReset(ctx context.Context, args []string) error {
28 | homeDir, err := os.UserHomeDir()
29 | if err != nil {
30 | return fmt.Errorf("could not get user home directory: %w", err)
31 | }
32 |
33 | uziDataPath := filepath.Join(homeDir, ".local", "share", "uzi")
34 |
35 | // Check if the directory exists
36 | if _, err := os.Stat(uziDataPath); os.IsNotExist(err) {
37 | log.Debug("Uzi data directory does not exist", "path", uziDataPath)
38 | fmt.Println("No uzi data found to reset")
39 | return nil
40 | }
41 |
42 | // Ask for confirmation
43 | fmt.Printf("This will permanently delete all uzi data from %s\n", uziDataPath)
44 | fmt.Print("Are you sure you want to continue? (y/N): ")
45 |
46 | reader := bufio.NewReader(os.Stdin)
47 | response, err := reader.ReadString('\n')
48 | if err != nil {
49 | return fmt.Errorf("failed to read user input: %w", err)
50 | }
51 |
52 | response = strings.ToLower(strings.TrimSpace(response))
53 | if response != "y" && response != "yes" {
54 | fmt.Println("Reset cancelled")
55 | return nil
56 | }
57 |
58 | // Remove the entire uzi data directory
59 | if err := os.RemoveAll(uziDataPath); err != nil {
60 | log.Error("Error removing uzi data directory", "path", uziDataPath, "error", err)
61 | return fmt.Errorf("failed to remove uzi data directory: %w", err)
62 | }
63 |
64 | log.Debug("Removed uzi data directory", "path", uziDataPath)
65 | fmt.Printf("Successfully reset all uzi data from %s\n", uziDataPath)
66 | return nil
67 | }
--------------------------------------------------------------------------------
/cmd/run/run.go:
--------------------------------------------------------------------------------
1 | package run
2 |
3 | import (
4 | "bytes"
5 | "context"
6 | "flag"
7 | "fmt"
8 | "os/exec"
9 | "strings"
10 | "github.com/devflowinc/uzi/pkg/config"
11 | "github.com/devflowinc/uzi/pkg/state"
12 |
13 | "github.com/charmbracelet/log"
14 | "github.com/peterbourgon/ff/v3/ffcli"
15 | )
16 |
17 | var (
18 | fs = flag.NewFlagSet("uzi run", flag.ExitOnError)
19 | deletePanel = fs.Bool("delete", false, "delete the panel after running the command")
20 | configPath = fs.String("config", config.GetDefaultConfigPath(), "path to config file")
21 | CmdRun = &ffcli.Command{
22 | Name: "run",
23 | ShortUsage: "uzi run ",
24 | ShortHelp: "Run a command in all agent sessions",
25 | FlagSet: fs,
26 | Exec: executeRun,
27 | }
28 | )
29 |
30 | func executeRun(ctx context.Context, args []string) error {
31 | log.Debug("Running run command")
32 |
33 | if len(args) == 0 {
34 | return fmt.Errorf("no command provided")
35 | }
36 |
37 | command := strings.Join(args, " ")
38 |
39 | // Get state manager to read from config
40 | sm := state.NewStateManager()
41 | if sm == nil {
42 | return fmt.Errorf("could not initialize state manager")
43 | }
44 |
45 | // Get active sessions from state
46 | activeSessions, err := sm.GetActiveSessionsForRepo()
47 | if err != nil {
48 | log.Error("Error getting active sessions", "error", err)
49 | return err
50 | }
51 |
52 | if len(activeSessions) == 0 {
53 | return fmt.Errorf("no active agent sessions found")
54 | }
55 |
56 | fmt.Printf("Running command '%s' in %d agent sessions:\n", command, len(activeSessions))
57 |
58 | // Execute command in each session
59 | for _, session := range activeSessions {
60 | fmt.Printf("\n=== %s ===\n", session)
61 |
62 | // Create a new window without specifying name or target to get next unused index
63 | // Use -P to print the window info in format session:index
64 | newWindowCmd := exec.Command("tmux", "new-window", "-t", session, "-P", "-F", "#{window_index}", "-c", "#{session_path}")
65 | windowIndexBytes, err := newWindowCmd.Output()
66 | if err != nil {
67 | log.Error("Failed to create new window", "session", session, "error", err)
68 | continue
69 | }
70 |
71 | windowIndex := strings.TrimSpace(string(windowIndexBytes))
72 | windowTarget := session + ":" + windowIndex
73 |
74 | sendKeysCmd := exec.Command("tmux", "send-keys", "-t", windowTarget, command, "Enter")
75 | if err := sendKeysCmd.Run(); err != nil {
76 | log.Error("Failed to send command ", command, " tosession", session, "error", err)
77 | continue
78 | }
79 |
80 | // Capture the output from the pane
81 | captureCmd := exec.Command("tmux", "capture-pane", "-t", windowTarget, "-p")
82 | var captureOut bytes.Buffer
83 | captureCmd.Stdout = &captureOut
84 | if err := captureCmd.Run(); err != nil {
85 | log.Error("Failed to capture output", "session", session, "error", err)
86 | } else {
87 | output := strings.TrimSpace(captureOut.String())
88 | if output != "" {
89 | fmt.Println(output)
90 | }
91 | }
92 |
93 | // If delete flag is set, kill the window after capturing output
94 | if *deletePanel {
95 | killWindowCmd := exec.Command("tmux", "kill-window", "-t", windowTarget)
96 | if err := killWindowCmd.Run(); err != nil {
97 | log.Error("Failed to kill window", "session", session, "window", windowTarget, "error", err)
98 | }
99 | }
100 | }
101 |
102 | return nil
103 | }
104 |
--------------------------------------------------------------------------------
/cmd/watch/auto.go:
--------------------------------------------------------------------------------
1 | package watch
2 |
3 | import (
4 | "bytes"
5 | "context"
6 | "crypto/sha256"
7 | "flag"
8 | "fmt"
9 | "os"
10 | "os/exec"
11 | "os/signal"
12 | "strings"
13 | "sync"
14 | "syscall"
15 | "time"
16 |
17 | "github.com/devflowinc/uzi/pkg/state"
18 |
19 | "github.com/charmbracelet/log"
20 | "github.com/peterbourgon/ff/v3/ffcli"
21 | )
22 |
23 | type AgentWatcher struct {
24 | stateManager *state.StateManager
25 | watchedSessions map[string]*SessionMonitor
26 | mu sync.RWMutex
27 | quit chan bool
28 | }
29 |
30 | type SessionMonitor struct {
31 | sessionName string
32 | prevOutputHash []byte
33 | lastUpdated time.Time
34 | updateCount int
35 | noUpdateCount int
36 | }
37 |
38 | func NewAgentWatcher() *AgentWatcher {
39 | return &AgentWatcher{
40 | stateManager: state.NewStateManager(),
41 | watchedSessions: make(map[string]*SessionMonitor),
42 | quit: make(chan bool),
43 | }
44 | }
45 |
46 | func (aw *AgentWatcher) hashContent(content []byte) []byte {
47 | hash := sha256.Sum256(content)
48 | return hash[:]
49 | }
50 |
51 | func (aw *AgentWatcher) capturePaneContent(sessionName string) (string, error) {
52 | cmd := exec.Command("tmux", "capture-pane", "-t", sessionName+":agent", "-p")
53 | output, err := cmd.Output()
54 | if err != nil {
55 | return "", err
56 | }
57 | return string(output), nil
58 | }
59 |
60 | func (aw *AgentWatcher) sendKeys(sessionName string, keys string) error {
61 | cmd := exec.Command("tmux", "send-keys", "-t", sessionName+":agent", keys)
62 | return cmd.Run()
63 | }
64 |
65 | func (aw *AgentWatcher) tapEnter(sessionName string) error {
66 | return aw.sendKeys(sessionName, "Enter")
67 | }
68 |
69 | func (aw *AgentWatcher) hasUpdated(sessionName string) (bool, bool, error) {
70 | content, err := aw.capturePaneContent(sessionName)
71 | if err != nil {
72 | return false, false, err
73 | }
74 |
75 | // Check for specific prompts that need auto-enter
76 | hasPrompt := false
77 |
78 | // Check for Claude trust prompt
79 | if strings.Contains(content, "Do you trust the files in this folder?") {
80 | hasPrompt = true
81 | }
82 |
83 | // Check for general continuation prompts
84 | if strings.Contains(content, "Press Enter to continue") ||
85 | strings.Contains(content, "Continue? (Y/n)") ||
86 | strings.Contains(content, "Do you want to proceed?") ||
87 | (strings.Contains(content, "Allow command") && !strings.Contains(content, "Thinking")) ||
88 | strings.Contains(content, "Do you want to") ||
89 | strings.Contains(content, "Proceed? (y/N)") {
90 | hasPrompt = true
91 | }
92 |
93 | aw.mu.Lock()
94 | monitor, exists := aw.watchedSessions[sessionName]
95 | if !exists {
96 | // First time monitoring this session
97 | aw.watchedSessions[sessionName] = &SessionMonitor{
98 | sessionName: sessionName,
99 | prevOutputHash: aw.hashContent([]byte(content)),
100 | lastUpdated: time.Now(),
101 | updateCount: 0,
102 | noUpdateCount: 0,
103 | }
104 | aw.mu.Unlock()
105 | return false, hasPrompt, nil
106 | }
107 |
108 | // Compare current content hash with previous
109 | currentHash := aw.hashContent([]byte(content))
110 | if !bytes.Equal(currentHash, monitor.prevOutputHash) {
111 | monitor.prevOutputHash = currentHash
112 | monitor.lastUpdated = time.Now()
113 | monitor.updateCount++
114 | monitor.noUpdateCount = 0
115 | aw.mu.Unlock()
116 | return true, hasPrompt, nil
117 | }
118 |
119 | monitor.noUpdateCount++
120 | aw.mu.Unlock()
121 | return false, hasPrompt, nil
122 | }
123 |
124 | func (aw *AgentWatcher) watchSession(sessionName string) {
125 | log.Info("Starting to watch session", "session", sessionName)
126 |
127 | for {
128 | select {
129 | case <-aw.quit:
130 | return
131 | default:
132 | updated, hasPrompt, err := aw.hasUpdated(sessionName)
133 | if err != nil {
134 | log.Error("Error checking session update", "session", sessionName, "error", err)
135 | time.Sleep(2 * time.Second)
136 | continue
137 | }
138 |
139 | if updated {
140 | log.Debug("Session updated", "session", sessionName)
141 | } else {
142 | // Check if session has no prompt and hasn't updated in 3 cycles
143 | }
144 |
145 | if hasPrompt {
146 | log.Info("Auto-pressing Enter for prompt", "session", sessionName)
147 | if err := aw.tapEnter(sessionName); err != nil {
148 | log.Error("Failed to send Enter", "session", sessionName, "error", err)
149 | } else {
150 | log.Info("Successfully sent Enter", "session", sessionName)
151 | }
152 | }
153 |
154 | time.Sleep(500 * time.Millisecond) // Check every 500ms
155 | }
156 | }
157 | }
158 |
159 | func (aw *AgentWatcher) refreshActiveSessions() error {
160 | activeSessions, err := aw.stateManager.GetActiveSessionsForRepo()
161 | if err != nil {
162 | return fmt.Errorf("failed to get active sessions: %w", err)
163 | }
164 |
165 | // Stop watching sessions that are no longer active
166 | aw.mu.Lock()
167 | for sessionName := range aw.watchedSessions {
168 | found := false
169 | for _, activeSession := range activeSessions {
170 | if activeSession == sessionName {
171 | found = true
172 | break
173 | }
174 | }
175 | if !found {
176 | log.Info("Session no longer active, stopping watch", "session", sessionName)
177 | delete(aw.watchedSessions, sessionName)
178 | }
179 | }
180 | aw.mu.Unlock()
181 |
182 | // Start watching new active sessions
183 | for _, sessionName := range activeSessions {
184 | aw.mu.RLock()
185 | _, exists := aw.watchedSessions[sessionName]
186 | aw.mu.RUnlock()
187 | if !exists {
188 | go aw.watchSession(sessionName)
189 | }
190 | }
191 |
192 | return nil
193 | }
194 |
195 | func (aw *AgentWatcher) Start() {
196 | log.Info("Starting Agent Watcher")
197 |
198 | // Set up signal handling for graceful shutdown
199 | sigChan := make(chan os.Signal, 1)
200 | signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
201 |
202 | // Refresh active sessions periodically
203 | refreshTicker := time.NewTicker(5 * time.Second)
204 | defer refreshTicker.Stop()
205 |
206 | go func() {
207 | for {
208 | select {
209 | case <-refreshTicker.C:
210 | if err := aw.refreshActiveSessions(); err != nil {
211 | log.Error("Failed to refresh active sessions", "error", err)
212 | }
213 | case <-aw.quit:
214 | return
215 | }
216 | }
217 | }()
218 |
219 | // Initial refresh
220 | if err := aw.refreshActiveSessions(); err != nil {
221 | log.Error("Failed initial session refresh", "error", err)
222 | }
223 |
224 | // Wait for signal
225 | <-sigChan
226 | log.Info("Shutting down Agent Watcher")
227 | close(aw.quit)
228 | }
229 |
230 | var CmdWatch = &ffcli.Command{
231 | Name: "auto",
232 | ShortUsage: "uzi auto",
233 | ShortHelp: "Automatically manage active agent sessions",
234 | LongHelp: `
235 | The auto command monitors all active agent sessions in the current repository
236 | and automatically presses Enter when it detects prompts that require user input,
237 | such as trust prompts or continuation confirmations. It can also handle other
238 | automated tasks in the future.
239 |
240 | This is useful for hands-free operation of multiple agents.
241 | `,
242 | FlagSet: func() *flag.FlagSet {
243 | fs := flag.NewFlagSet("auto", flag.ExitOnError)
244 | return fs
245 | }(),
246 | Exec: func(ctx context.Context, args []string) error {
247 | watcher := NewAgentWatcher()
248 | watcher.Start()
249 | return nil
250 | },
251 | }
252 |
--------------------------------------------------------------------------------
/go.mod:
--------------------------------------------------------------------------------
1 | module github.com/devflowinc/uzi
2 |
3 | go 1.24.3
4 |
5 | require (
6 | github.com/charmbracelet/log v0.4.2
7 | github.com/peterbourgon/ff/v3 v3.4.0
8 | gopkg.in/yaml.v3 v3.0.1
9 | )
10 |
11 | require (
12 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
13 | github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
14 | github.com/charmbracelet/lipgloss v1.1.0 // indirect
15 | github.com/charmbracelet/x/ansi v0.8.0 // indirect
16 | github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
17 | github.com/charmbracelet/x/term v0.2.1 // indirect
18 | github.com/go-logfmt/logfmt v0.6.0 // indirect
19 | github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
20 | github.com/mattn/go-isatty v0.0.20 // indirect
21 | github.com/mattn/go-runewidth v0.0.16 // indirect
22 | github.com/muesli/termenv v0.16.0 // indirect
23 | github.com/rivo/uniseg v0.4.7 // indirect
24 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
25 | golang.org/x/exp v0.0.0-20231006140011-7918f672742d // indirect
26 | golang.org/x/sys v0.32.0 // indirect
27 | )
28 |
--------------------------------------------------------------------------------
/go.sum:
--------------------------------------------------------------------------------
1 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
2 | github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
3 | github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
4 | github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
5 | github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
6 | github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
7 | github.com/charmbracelet/log v0.4.2 h1:hYt8Qj6a8yLnvR+h7MwsJv/XvmBJXiueUcI3cIxsyig=
8 | github.com/charmbracelet/log v0.4.2/go.mod h1:qifHGX/tc7eluv2R6pWIpyHDDrrb/AG71Pf2ysQu5nw=
9 | github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE=
10 | github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q=
11 | github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8=
12 | github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
13 | github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
14 | github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
15 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
16 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
17 | github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4=
18 | github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs=
19 | github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
20 | github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
21 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
22 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
23 | github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
24 | github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
25 | github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
26 | github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
27 | github.com/peterbourgon/ff/v3 v3.4.0 h1:QBvM/rizZM1cB0p0lGMdmR7HxZeI/ZrBWB4DqLkMUBc=
28 | github.com/peterbourgon/ff/v3 v3.4.0/go.mod h1:zjJVUhx+twciwfDl0zBcFzl4dW8axCRyXE/eKY9RztQ=
29 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
30 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
31 | github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
32 | github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
33 | github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
34 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
35 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
36 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
37 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
38 | golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
39 | golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
40 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
41 | golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
42 | golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
43 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
44 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
45 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
46 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
47 |
--------------------------------------------------------------------------------
/pkg/agents/agents.go:
--------------------------------------------------------------------------------
1 | package agents
2 |
3 | import (
4 | "math/rand"
5 | "strings"
6 | "time"
7 | )
8 |
9 | // AgentNames contains all available agent names
10 | const AgentNames = `john
11 | emily
12 | michael
13 | sarah
14 | david
15 | jessica
16 | christopher
17 | ashley
18 | matthew
19 | amanda
20 | james
21 | elizabeth
22 | robert
23 | jennifer
24 | william
25 | rachel
26 | daniel
27 | laura
28 | thomas
29 | hannah
30 | joshua
31 | megan
32 | ryan
33 | nicole
34 | andrew
35 | stephanie
36 | justin
37 | rebecca
38 | brandon
39 | lisa
40 | samuel
41 | katherine
42 | benjamin
43 | samantha
44 | nicholas
45 | alexandra
46 | tyler
47 | victoria
48 | alexander
49 | olivia
50 | anthony
51 | emma
52 | kevin
53 | madison
54 | brian
55 | abigail
56 | jason
57 | isabella
58 | eric
59 | sophia
60 | adam
61 | ava
62 | steven
63 | mia
64 | timothy
65 | charlotte
66 | mark
67 | amelia
68 | donald
69 | harper
70 | paul
71 | evelyn
72 | george
73 | abigail
74 | kenneth
75 | emily
76 | edward
77 | elizabeth
78 | brian
79 | sofia
80 | ronald
81 | avery
82 | kevin
83 | ella
84 | jason
85 | scarlett
86 | matthew
87 | grace
88 | gary
89 | chloe
90 | timothy
91 | camila
92 | jose
93 | penelope
94 | larry
95 | layla
96 | jeffrey
97 | lillian
98 | frank
99 | nora
100 | scott
101 | zoey
102 | eric
103 | mila
104 | stephen
105 | aubrey
106 | andrew
107 | violet
108 | raymond
109 | claire
110 | gregory
111 | bella
112 | joshua
113 | aurora
114 | jerry
115 | lucy
116 | dennis
117 | anna
118 | walter
119 | sarah
120 | peter
121 | caroline
122 | harold
123 | genesis
124 | douglas
125 | emilia
126 | henry
127 | kennedy`
128 |
129 | // GetRandomAgent returns a random agent name from the embedded list
130 | func GetRandomAgent() string {
131 | agents := strings.Split(strings.TrimSpace(AgentNames), "\n")
132 | rand.Seed(time.Now().UnixNano())
133 | return agents[rand.Intn(len(agents))]
134 | }
--------------------------------------------------------------------------------
/pkg/config/config.go:
--------------------------------------------------------------------------------
1 | package config
2 |
3 | import (
4 | "os"
5 |
6 | "gopkg.in/yaml.v3"
7 | )
8 |
9 | type Config struct {
10 | DevCommand *string `yaml:"devCommand"`
11 | PortRange *string `yaml:"portRange"`
12 | }
13 |
14 | func DefaultConfig() Config {
15 | return Config{
16 | DevCommand: nil,
17 | PortRange: nil,
18 | }
19 | }
20 |
21 | // LoadConfig loads the configuration from the specified path
22 | func LoadConfig(path string) (*Config, error) {
23 | data, err := os.ReadFile(path)
24 | if err != nil {
25 | return nil, err
26 | }
27 |
28 | var config Config
29 | if err := yaml.Unmarshal(data, &config); err != nil {
30 | return nil, err
31 | }
32 |
33 | return &config, nil
34 | }
35 |
36 | // GetDefaultConfigPath returns the default path for the config file
37 | func GetDefaultConfigPath() string {
38 | return "uzi.yaml"
39 | }
40 |
--------------------------------------------------------------------------------
/pkg/state/state.go:
--------------------------------------------------------------------------------
1 | package state
2 |
3 | import (
4 | "encoding/json"
5 | "fmt"
6 | "os"
7 | "os/exec"
8 | "path/filepath"
9 | "strings"
10 | "time"
11 |
12 | "github.com/charmbracelet/log"
13 | )
14 |
15 | type AgentState struct {
16 | GitRepo string `json:"git_repo"`
17 | BranchFrom string `json:"branch_from"`
18 | BranchName string `json:"branch_name"`
19 | Prompt string `json:"prompt"`
20 | WorktreePath string `json:"worktree_path"`
21 | Port int `json:"port,omitempty"`
22 | Model string `json:"model"`
23 | CreatedAt time.Time `json:"created_at"`
24 | UpdatedAt time.Time `json:"updated_at"`
25 | }
26 |
27 | type StateManager struct {
28 | statePath string
29 | }
30 |
31 | func NewStateManager() *StateManager {
32 | homeDir, err := os.UserHomeDir()
33 | if err != nil {
34 | log.Error("Error getting home directory", "error", err)
35 | return nil
36 | }
37 |
38 | statePath := filepath.Join(homeDir, ".local", "share", "uzi", "state.json")
39 | return &StateManager{statePath: statePath}
40 | }
41 |
42 | func (sm *StateManager) ensureStateDir() error {
43 | dir := filepath.Dir(sm.statePath)
44 | return os.MkdirAll(dir, 0755)
45 | }
46 |
47 | func (sm *StateManager) getGitRepo() string {
48 | cmd := exec.Command("git", "config", "--get", "remote.origin.url")
49 | output, err := cmd.Output()
50 | if err != nil {
51 | log.Debug("Could not get git remote URL", "error", err)
52 | return ""
53 | }
54 | return strings.TrimSpace(string(output))
55 | }
56 |
57 | func (sm *StateManager) getBranchFrom() string {
58 | // Get the main/master branch name
59 | cmd := exec.Command("git", "symbolic-ref", "refs/remotes/origin/HEAD")
60 | output, err := cmd.Output()
61 | if err != nil {
62 | // Fallback to main
63 | return "main"
64 | }
65 |
66 | ref := strings.TrimSpace(string(output))
67 | parts := strings.Split(ref, "/")
68 | if len(parts) > 0 {
69 | return parts[len(parts)-1]
70 | }
71 | return "main"
72 | }
73 |
74 | func (sm *StateManager) isActiveInTmux(sessionName string) bool {
75 | cmd := exec.Command("tmux", "has-session", "-t", sessionName)
76 | return cmd.Run() == nil
77 | }
78 |
79 | func (sm *StateManager) GetActiveSessionsForRepo() ([]string, error) {
80 | // Load existing state
81 | states := make(map[string]AgentState)
82 | if data, err := os.ReadFile(sm.statePath); err != nil {
83 | if os.IsNotExist(err) {
84 | return []string{}, nil
85 | }
86 | return nil, err
87 | } else {
88 | if err := json.Unmarshal(data, &states); err != nil {
89 | return nil, err
90 | }
91 | }
92 |
93 | currentRepo := sm.getGitRepo()
94 | if currentRepo == "" {
95 | return []string{}, nil
96 | }
97 |
98 | var activeSessions []string
99 | for sessionName, state := range states {
100 | if state.GitRepo == currentRepo && sm.isActiveInTmux(sessionName) {
101 | activeSessions = append(activeSessions, sessionName)
102 | }
103 | }
104 |
105 | return activeSessions, nil
106 | }
107 |
108 | func (sm *StateManager) SaveState(prompt, branchName, sessionName, worktreePath, model string) error {
109 | return sm.SaveStateWithPort(prompt, branchName, sessionName, worktreePath, model, 0)
110 | }
111 |
112 | func (sm *StateManager) SaveStateWithPort(prompt, branchName, sessionName, worktreePath, model string, port int) error {
113 | if err := sm.ensureStateDir(); err != nil {
114 | return err
115 | }
116 |
117 | // Load existing state
118 | states := make(map[string]AgentState)
119 | if data, err := os.ReadFile(sm.statePath); err == nil {
120 | json.Unmarshal(data, &states)
121 | }
122 |
123 | // Create new state entry
124 | now := time.Now()
125 | agentState := AgentState{
126 | GitRepo: sm.getGitRepo(),
127 | BranchFrom: sm.getBranchFrom(),
128 | BranchName: branchName,
129 | Prompt: prompt,
130 | WorktreePath: worktreePath,
131 | Port: port,
132 | Model: model,
133 | UpdatedAt: now,
134 | }
135 |
136 | // Set created time if this is a new entry
137 | if existing, exists := states[sessionName]; exists {
138 | agentState.CreatedAt = existing.CreatedAt
139 | } else {
140 | agentState.CreatedAt = now
141 | }
142 |
143 | states[sessionName] = agentState
144 |
145 | // Store the worktree branch in agent-specific file
146 | if err := sm.storeWorktreeBranch(sessionName); err != nil {
147 | log.Error("Error storing worktree branch", "error", err)
148 | }
149 |
150 | // Save to file
151 | data, err := json.MarshalIndent(states, "", " ")
152 | if err != nil {
153 | return err
154 | }
155 |
156 | return os.WriteFile(sm.statePath, data, 0644)
157 | }
158 |
159 | func (sm *StateManager) getCurrentBranch() string {
160 | cmd := exec.Command("git", "branch", "--show-current")
161 | output, err := cmd.Output()
162 | if err != nil {
163 | log.Debug("Could not get current branch", "error", err)
164 | return ""
165 | }
166 | return strings.TrimSpace(string(output))
167 | }
168 |
169 | func (sm *StateManager) storeWorktreeBranch(sessionName string) error {
170 | homeDir, err := os.UserHomeDir()
171 | if err != nil {
172 | return err
173 | }
174 |
175 | agentDir := filepath.Join(homeDir, ".local", "share", "uzi", "worktree", sessionName)
176 | if err := os.MkdirAll(agentDir, 0755); err != nil {
177 | return err
178 | }
179 |
180 | branchFile := filepath.Join(agentDir, "tree")
181 | currentBranch := sm.getCurrentBranch()
182 | if currentBranch == "" {
183 | return nil
184 | }
185 |
186 | return os.WriteFile(branchFile, []byte(currentBranch), 0644)
187 | }
188 |
189 | func (sm *StateManager) GetStatePath() string {
190 | return sm.statePath
191 | }
192 |
193 | func (sm *StateManager) RemoveState(sessionName string) error {
194 | // Load existing state
195 | states := make(map[string]AgentState)
196 | if data, err := os.ReadFile(sm.statePath); err != nil {
197 | if os.IsNotExist(err) {
198 | return nil // No state file, nothing to remove
199 | }
200 | return err
201 | } else {
202 | if err := json.Unmarshal(data, &states); err != nil {
203 | return err
204 | }
205 | }
206 |
207 | // Remove the session from the state
208 | delete(states, sessionName)
209 |
210 | // Save updated state to file
211 | data, err := json.MarshalIndent(states, "", " ")
212 | if err != nil {
213 | return err
214 | }
215 |
216 | return os.WriteFile(sm.statePath, data, 0644)
217 | }
218 |
219 | // GetWorktreeInfo returns the worktree information for a given session
220 | func (sm *StateManager) GetWorktreeInfo(sessionName string) (*AgentState, error) {
221 | // Load existing state
222 | states := make(map[string]AgentState)
223 | if data, err := os.ReadFile(sm.statePath); err != nil {
224 | return nil, fmt.Errorf("error reading state file: %w", err)
225 | } else {
226 | if err := json.Unmarshal(data, &states); err != nil {
227 | return nil, fmt.Errorf("error parsing state file: %w", err)
228 | }
229 | }
230 |
231 | state, ok := states[sessionName]
232 | if !ok {
233 | return nil, fmt.Errorf("no state found for session: %s", sessionName)
234 | }
235 |
236 | return &state, nil
237 | }
238 |
--------------------------------------------------------------------------------
/uzi:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/devflowinc/uzi/421685f65d66180152fb5f63128111ec12566ea5/uzi
--------------------------------------------------------------------------------
/uzi-site/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules/
2 | .DS_Store
3 | package-lock.json
4 | package.json
5 | *.log
--------------------------------------------------------------------------------
/uzi-site/CLAUDE.md:
--------------------------------------------------------------------------------
1 | # CLAUDE.md
2 |
3 | This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4 |
5 | ## Project Overview
6 |
7 | This is the "uzi-site" project - the first website for Uzi. The repository is currently minimal with only a README.md file.
8 |
9 | ## Current State
10 |
11 | The repository is in its initial state with no build system, dependencies, or source code yet established. Future development will likely involve setting up a web framework and development environment.
12 |
13 | ## Development Commands
14 |
15 | No build, test, or development commands are currently configured. These will need to be established as the project grows.
--------------------------------------------------------------------------------
/uzi-site/android-chrome-192x192.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/devflowinc/uzi/421685f65d66180152fb5f63128111ec12566ea5/uzi-site/android-chrome-192x192.png
--------------------------------------------------------------------------------
/uzi-site/android-chrome-512x512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/devflowinc/uzi/421685f65d66180152fb5f63128111ec12566ea5/uzi-site/android-chrome-512x512.png
--------------------------------------------------------------------------------
/uzi-site/apple-touch-icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/devflowinc/uzi/421685f65d66180152fb5f63128111ec12566ea5/uzi-site/apple-touch-icon.png
--------------------------------------------------------------------------------
/uzi-site/banner-messages.txt:
--------------------------------------------------------------------------------
1 | Q2 2025, LLMS ARE THE MASTERS OF DEVELOPMENT, ONLY HORSELESS CARRIAGES STAND BEFORE THEM, REPOS ARE NOW BATTLEFIELDS | THIS IS A WAR DO NOT THINK IT IS NOT | LITERALLY TENS OF BILLIONS ARE BEING SET ON FIRE FOR YOUR ATTENTION AND FOR YOUR LOYALTY | UZI IS THE FULL-AUTO OF CODE GEN | WHY ARE YOU NOT GIVING YOUR TEAM INSANELY ASSYMETRIC ADVANTAGES| BTW THIS IS WHAT TEN BILLION LOOKS LIKE IN NUMBERS - $10,000,000,000 USD | WHY WAIT FOR SMARTER MODELS WHEN YOU CAN RUN 50 GENERATIONS AT THE SAME TIME | INFERENCE IS TAX-DEDUCTIBLE ANYWAYS (NFA/DYOR) | AT LARGE ENOUGH NUMBERS, THE FACE OF RANDOMNESS SMILES | YOU ARE NOT SPENDING ENOUGH ON CODE GENERATION, ACTUALLY | WE CAN EVEN COME TRAIN YOU HOW TO USE IT | CLINE, CURSOR, WINDSURF, CLAUDE CODE, CODEX, CODY, AIDER, ETC, | IF THE GAME FEELS TOO EASY WITH UZI THAT'S BECAUSE IT IS, IT'S THAT EASY | YOU KNOW WHAT THEY SAY DO NOT KNOCK IT UNTIL YOU TRY IT | IN TMUX WE TRUST |
--------------------------------------------------------------------------------
/uzi-site/commit-types.txt:
--------------------------------------------------------------------------------
1 | feat
2 | fix
3 | chore
4 | refactor
5 | perf
6 | test
7 | docs
8 | style
9 | ci
10 | build
11 | revert
12 | wip
13 | init
14 | temp
15 | merge
16 | hotfix
--------------------------------------------------------------------------------
/uzi-site/favicon-16x16.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/devflowinc/uzi/421685f65d66180152fb5f63128111ec12566ea5/uzi-site/favicon-16x16.png
--------------------------------------------------------------------------------
/uzi-site/favicon-32x32.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/devflowinc/uzi/421685f65d66180152fb5f63128111ec12566ea5/uzi-site/favicon-32x32.png
--------------------------------------------------------------------------------
/uzi-site/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/devflowinc/uzi/421685f65d66180152fb5f63128111ec12566ea5/uzi-site/favicon.ico
--------------------------------------------------------------------------------
/uzi-site/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
9 |
13 |
17 |
18 |
19 |
20 |
21 |
25 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
42 |
46 |
47 |
48 |
52 |
56 |
57 |
58 |
59 |
60 |
61 | Uzi - Multi-Agent Development Tool
62 |
332 |
333 |
334 |
353 |
354 |
355 |
356 |
357 |
420 |
421 |
422 |
423 |
UZI: THE FULL-AUTO OF CODE GEN
424 |
425 |
428 |
429 |
go install github.com/devflowinc/uzi@latest
430 |
431 |
432 |
433 |
434 |
435 |
436 |
437 |
438 |
439 |
440 |
441 | Q3 2025, LLMS ARE THE MASTERS OF DEVELOPMENT, ISSUES STAND BEFORE
442 | THEM, REPOS ARE NOW BATTLEFIELDS | THIS IS A WAR DO NOT THINK IT IS
443 | NOT | LITERALLY TENS OF BILLIONS ARE BEING SET ON FIRE FOR YOUR
444 | ATTENTION AND FOR YOUR LOYALTY | UZI IS THE FULL-AUTO OF CODE GEN |
445 | WHY ARE YOU NOT GIVING YOUR TEAM INSANELY ASSYMETRIC ADVANTAGES
446 |
447 |
448 |
449 | Q3 2025, LLMS ARE THE MASTERS OF DEVELOPMENT, ISSUES STAND BEFORE
450 | THEM, REPOS ARE NOW BATTLEFIELDS | THIS IS A WAR DO NOT THINK IT IS
451 | NOT | LITERALLY TENS OF BILLIONS ARE BEING SET ON FIRE FOR YOUR
452 | ATTENTION AND FOR YOUR LOYALTY | UZI IS THE FULL-AUTO OF CODE GEN |
453 | WHY ARE YOU NOT GIVING YOUR TEAM INSANELY ASSYMETRIC ADVANTAGES
454 |