package main import ( "bufio" "encoding/base64" "encoding/json" "errors" "flag" "fmt" "io" "net" "net/http" "os" "os/exec" "path/filepath" "regexp" "runtime" "sort" "strconv" "strings" "syscall" "time" "github.com/gorilla/websocket" "gopkg.in/yaml.v3" ) type registry struct { Agents map[string]*agentRecord `json:"agents"` FocusedAgentID string `json:"focused_agent_id,omitempty"` } type agentRecord struct { ID string `json:"id"` Name string `json:"name"` RepoRoot string `json:"repo_root"` WorkspaceRoot string `json:"workspace_root"` RepoCopyPath string `json:"repo_copy_path"` Branch string `json:"branch"` SourceBranch string `json:"source_branch,omitempty"` KeepWorktree bool `json:"keep_worktree,omitempty"` Pull bool `json:"pull,omitempty"` Runtime string `json:"runtime,omitempty"` Device string `json:"device,omitempty"` FeatureConfig string `json:"feature_config,omitempty"` RunLogPath string `json:"run_log_path,omitempty"` Port int `json:"port,omitempty"` URL string `json:"url,omitempty"` BrowserEnabled bool `json:"browser_enabled,omitempty"` TmuxSessionName string `json:"tmux_session_name,omitempty"` TmuxSessionID string `json:"tmux_session_id,omitempty"` TmuxWindowID string `json:"tmux_window_id,omitempty"` Panes agentPanes `json:"panes"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` LastFocusedAt *time.Time `json:"last_focused_at,omitempty"` LaunchWindowID string `json:"-"` } type agentPanes struct { AI string `json:"ai,omitempty"` Git string `json:"git,omitempty"` Run string `json:"run,omitempty"` } type agentStartOptions struct { SourceBranch string KeepWorktree bool Pull bool } type appConfig struct { Keys keyConfig `json:"keys"` Devices []string `json:"devices,omitempty"` StatusRight *statusRightConfig `json:"status_right,omitempty"` } type statusRightConfig struct { CPU *bool `json:"cpu,omitempty"` Network *bool `json:"network,omitempty"` Memory *bool `json:"memory,omitempty"` MemoryTotals *bool `json:"memory_totals,omitempty"` WindowMemory *bool `json:"window_memory,omitempty"` SessionMemory *bool `json:"session_memory,omitempty"` TotalMemory *bool `json:"total_memory,omitempty"` Scratch *bool `json:"scratch,omitempty"` FlashMoe *bool `json:"flash_moe,omitempty"` Host *bool `json:"host,omitempty"` } type keyConfig struct { MoveLeft string `json:"move_left"` MoveRight string `json:"move_right"` MoveUp string `json:"move_up"` MoveDown string `json:"move_down"` Edit string `json:"edit"` Cancel string `json:"cancel"` AddTodo string `json:"add_todo"` ToggleTodo string `json:"toggle_todo"` Destroy string `json:"destroy"` Confirm string `json:"confirm"` Back string `json:"back"` DeleteTodo string `json:"delete_todo"` Help string `json:"help"` FocusAI string `json:"focus_ai"` FocusGit string `json:"focus_git"` FocusRun string `json:"focus_run"` } type repoConfig struct { BaseBranch string `yaml:"base_branch,omitempty"` DefaultDevice string `yaml:"default_device,omitempty"` CopyIgnore []string `yaml:"copy_ignore,omitempty"` AgentKeyPaths []string `yaml:"agent_key_paths,omitempty"` } type featureConfig struct { Feature string `json:"feature"` Port int `json:"port,omitempty"` URL string `json:"url,omitempty"` Device string `json:"device"` IsFlutter bool `json:"is_flutter,omitempty"` Ready bool `json:"ready,omitempty"` } type browserCDPVersionInfo struct { Browser string `json:"Browser"` WebSocketDebuggerURL string `json:"webSocketDebuggerUrl"` } type browserTarget struct { Description string `json:"description,omitempty"` DevtoolsFrontendURL string `json:"devtoolsFrontendUrl,omitempty"` ID string `json:"id"` ParentID string `json:"parentId,omitempty"` Title string `json:"title,omitempty"` Type string `json:"type,omitempty"` URL string `json:"url,omitempty"` WebSocketDebuggerURL string `json:"webSocketDebuggerUrl,omitempty"` } type browserCDPEnvelope struct { ID int `json:"id,omitempty"` Method string `json:"method,omitempty"` Params json.RawMessage `json:"params,omitempty"` Result json.RawMessage `json:"result,omitempty"` Error *browserCDPError `json:"error,omitempty"` } type browserCDPError struct { Message string `json:"message,omitempty"` } var featureNamePattern = regexp.MustCompile(`[^a-z0-9._-]+`) func main() { if err := run(os.Args[1:]); err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(1) } } func run(args []string) error { if len(args) == 0 { return fmt.Errorf("usage: agent ") } switch args[0] { case "start": return runStart(args[1:]) case "resume": return runResume(args[1:]) case "list": return runList(args[1:]...) case "destroy": return runDestroy(args[1:]) case "init": return runInit(args[1:]) case "config": return runConfig(args[1:]) case "setup": return runSetup(args[1:]) case "palette": return runPalette(args[1:]) case "tmux": return runTmuxCommand(args[1:]) case "tracker": return runTracker(args[1:]) case "goal": return runGoal(args[1:]) case "browser": return runBrowserCommand(args[1:]) case "feature": return runFeatureCommand(args[1:]) case "hot-reload": return runHotReloadCommand(args[1:]) case "bootstrap": return runBootstrap(args[1:]) default: return fmt.Errorf("unknown subcommand: %s", args[0]) } } func runStart(args []string) error { fs := flag.NewFlagSet("agent start", flag.ContinueOnError) var feature string var device string var noDevice bool var keepWorktree bool var pull bool fs.StringVar(&feature, "name", "", "feature name") fs.StringVar(&device, "d", "", "flutter device") fs.BoolVar(&noDevice, "no-device", false, "leave the run pane idle until a device is chosen") fs.BoolVar(&keepWorktree, "keep-worktree", false, "copy the current repo worktree into the new agent") fs.BoolVar(&pull, "pull", false, "git fetch + fast-forward the source branch before creating the agent") fs.SetOutput(os.Stderr) if err := fs.Parse(args); err != nil { return err } repoRoot, err := repoRoot() if err != nil { return fmt.Errorf("%w; run `agent init` in your repo to set up agent config", err) } isFlutter := fileExists(filepath.Join(repoRoot, "pubspec.yaml")) if err := ensureGitExcludeEntries(repoRoot, []string{".agents"}); err != nil { return err } repoCfg, err := loadRepoConfig(repoRoot) if err != nil { return err } if feature == "" && fs.NArg() > 0 { feature = fs.Arg(0) } feature = sanitizeFeatureName(feature) if feature == "" { value, err := promptInput("Feature name: ") if err != nil { return err } feature = sanitizeFeatureName(value) } if feature == "" { return fmt.Errorf("feature name is required") } reg, err := loadRegistry() if err != nil { return err } if _, exists := reg.Agents[feature]; exists { return fmt.Errorf("agent %q already exists", feature) } workspaceRoot := filepath.Join(repoRoot, ".agents", feature) repoCopyPath := filepath.Join(workspaceRoot, "repo") featureConfigPath := filepath.Join(workspaceRoot, "agent.json") if err := os.MkdirAll(filepath.Join(workspaceRoot, "logs"), 0o755); err != nil { return err } if err := os.MkdirAll(repoCopyPath, 0o755); err != nil { return err } port := 0 url := "" runtime := "" browserEnabled := false device = strings.TrimSpace(device) if isFlutter { runtime = "flutter" if noDevice { device = "" } else if device == "" { device = resolveDefaultDevice(repoCfg) } browserEnabled = device == "web-server" port, err = allocatePort(repoRoot, 9100) if err != nil { return err } url = fmt.Sprintf("http://localhost:%d", port) if err := saveFeatureConfig(featureConfigPath, featureConfig{ Feature: feature, Port: port, URL: url, Device: device, IsFlutter: true, Ready: false, }); err != nil { return err } if device == "web-server" { if _, err := ensureChromeForTestingAvailable(); err != nil { return err } } } sourceBranch := resolveStartSourceBranch(repoRoot, repoCfg) if err := prepareAgentContext(repoRoot, repoCopyPath, repoCfg.AgentKeyPaths, false); err != nil { return err } if isFlutter { if err := writeFlutterHelperScripts(workspaceRoot, repoCopyPath, url, device); err != nil { return err } } record := &agentRecord{ ID: feature, Name: feature, RepoRoot: repoRoot, WorkspaceRoot: workspaceRoot, RepoCopyPath: repoCopyPath, Branch: feature, SourceBranch: sourceBranch, KeepWorktree: keepWorktree, Pull: pull, Runtime: runtime, Device: device, FeatureConfig: featureConfigPath, RunLogPath: filepath.Join(workspaceRoot, "logs", "run.log"), Port: port, URL: url, BrowserEnabled: browserEnabled, CreatedAt: time.Now(), UpdatedAt: time.Now(), LaunchWindowID: strings.TrimSpace(os.Getenv("AGENT_TMUX_TARGET_WINDOW")), } reg.Agents[record.ID] = record if err := saveRegistry(reg); err != nil { return err } bootstrapPID, err := spawnWorkspaceBootstrap(workspaceRoot) if err != nil { delete(reg.Agents, record.ID) _ = saveRegistry(reg) _ = os.RemoveAll(workspaceRoot) return err } if err := launchAgentLayout(record); err != nil { _ = killProcessGroup(bootstrapPID) delete(reg.Agents, record.ID) _ = saveRegistry(reg) _ = os.RemoveAll(workspaceRoot) return err } _ = primeAgentAIPane(record.Panes.AI) return nil } func runInit(args []string) error { fs := flag.NewFlagSet("agent init", flag.ContinueOnError) var force bool fs.BoolVar(&force, "force", false, "overwrite an existing .agent.yaml") fs.SetOutput(os.Stderr) if err := fs.Parse(args); err != nil { return err } repoRoot, err := repoRoot() if err != nil { return err } if err := ensureFlutterWebRepo(repoRoot); err != nil { return err } path := repoConfigPath(repoRoot) configExisted := fileExists(path) if configExisted && !force { fmt.Printf("Keeping existing %s\n", path) return nil } cfg := defaultRepoConfig() cfg.BaseBranch = detectDefaultBaseBranch(repoRoot) if fileExists(filepath.Join(repoRoot, "pubspec.yaml")) { device, err := promptInputWithDefault("Default device", defaultManagedDeviceID) if err != nil { return err } cfg.DefaultDevice = strings.TrimSpace(device) } if err := saveRepoConfig(repoRoot, cfg); err != nil { return err } fmt.Printf("Wrote %s\n", path) return nil } func runConfig(args []string) error { if len(args) == 0 { return fmt.Errorf("usage: agent config ") } repoRoot, err := repoRoot() if err != nil { return err } cfg, err := loadRepoConfig(repoRoot) if err != nil { return err } switch args[0] { case "show": data, err := yaml.Marshal(cfg) if err != nil { return err } fmt.Printf("%s\n%s", repoConfigPath(repoRoot), string(data)) return nil case "set-base-branch": branch := "" if len(args) > 1 { branch = strings.TrimSpace(args[1]) } if branch == "" { branch, err = promptInputWithDefault("Base branch", cfg.BaseBranch) if err != nil { return err } branch = strings.TrimSpace(branch) } if branch == "" { return fmt.Errorf("base branch is required") } cfg.BaseBranch = branch case "set-default-device": device := "" if len(args) > 1 { device = strings.TrimSpace(args[1]) } if device == "" { device, err = promptInputWithDefault("Default device", cfg.DefaultDevice) if err != nil { return err } device = strings.TrimSpace(device) } cfg.DefaultDevice = device case "add-ignore": values := normalizeIgnoreValues(args[1:]) if len(values) == 0 { value, err := promptInput("Ignore path to add: ") if err != nil { return err } values = normalizeIgnoreValues([]string{value}) } if len(values) == 0 { return fmt.Errorf("at least one ignore path is required") } for _, value := range values { if !containsString(cfg.CopyIgnore, value) { cfg.CopyIgnore = append(cfg.CopyIgnore, value) } } case "remove-ignore": values := normalizeIgnoreValues(args[1:]) if len(values) == 0 { value, err := promptInput("Ignore path to remove: ") if err != nil { return err } values = normalizeIgnoreValues([]string{value}) } if len(values) == 0 { return fmt.Errorf("at least one ignore path is required") } filtered := cfg.CopyIgnore[:0] for _, existing := range cfg.CopyIgnore { if !containsString(values, existing) { filtered = append(filtered, existing) } } cfg.CopyIgnore = filtered default: return fmt.Errorf("unknown config subcommand: %s", args[0]) } if err := saveRepoConfig(repoRoot, cfg); err != nil { return err } fmt.Printf("Wrote %s\n", repoConfigPath(repoRoot)) return nil } func runSetup(args []string) error { fs := flag.NewFlagSet("agent setup", flag.ContinueOnError) var baseBranch string fs.StringVar(&baseBranch, "base-branch", "", "base branch used for copied repos") fs.SetOutput(os.Stderr) if err := fs.Parse(args); err != nil { return err } forwarded := []string{"set-base-branch"} if strings.TrimSpace(baseBranch) != "" { forwarded = append(forwarded, strings.TrimSpace(baseBranch)) } return runConfig(forwarded) } func spawnWorkspaceBootstrap(workspaceRoot string) (int, error) { exe, err := os.Executable() if err != nil { return 0, err } if err := os.MkdirAll(filepath.Join(workspaceRoot, "logs"), 0o755); err != nil { return 0, err } logFile, err := os.OpenFile(bootstrapLogPath(workspaceRoot), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644) if err != nil { return 0, err } cmd := exec.Command(exe, "bootstrap", "--workspace", workspaceRoot) cmd.Stdin = nil cmd.Stdout = logFile cmd.Stderr = logFile cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} if err := cmd.Start(); err != nil { _ = logFile.Close() return 0, err } pid := cmd.Process.Pid _ = logFile.Close() return pid, nil } func spawnDetachedAgentCommand(args ...string) error { exe, err := os.Executable() if err != nil { return err } devNull, err := os.OpenFile(os.DevNull, os.O_RDWR, 0) if err != nil { return err } defer devNull.Close() cmd := exec.Command(exe, args...) cmd.Stdin = devNull cmd.Stdout = devNull cmd.Stderr = devNull cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} return cmd.Start() } func processRunning(pid int) bool { if pid <= 0 { return false } err := syscall.Kill(pid, 0) return err == nil || err == syscall.EPERM } func killProcessGroup(pid int) error { if pid <= 0 { return nil } err := syscall.Kill(-pid, syscall.SIGTERM) if err != nil && !errors.Is(err, syscall.ESRCH) { return err } time.Sleep(150 * time.Millisecond) if !processRunning(pid) { return nil } err = syscall.Kill(-pid, syscall.SIGKILL) if err != nil && !errors.Is(err, syscall.ESRCH) { return err } return nil } func stopWorkspaceBootstrap(workspaceRoot string) error { data, err := os.ReadFile(bootstrapPIDPath(workspaceRoot)) if errors.Is(err, os.ErrNotExist) { return nil } if err != nil { return err } pid, err := strconv.Atoi(strings.TrimSpace(string(data))) if err != nil { return err } return killProcessGroup(pid) } func ensureWorkspaceBootstrap(record *agentRecord, _ *repoConfig) error { if fileExists(bootstrapRepoReadyPath(record.WorkspaceRoot)) { return nil } data, err := os.ReadFile(bootstrapPIDPath(record.WorkspaceRoot)) if err == nil { pid, convErr := strconv.Atoi(strings.TrimSpace(string(data))) if convErr == nil && processRunning(pid) { return nil } } _, err = spawnWorkspaceBootstrap(record.WorkspaceRoot) return err } func resetBootstrapState(workspaceRoot string) error { stateDir := bootstrapStateDirPath(workspaceRoot) if err := os.MkdirAll(stateDir, 0o755); err != nil { return err } for _, path := range []string{ bootstrapGitReadyPath(workspaceRoot), bootstrapRepoReadyPath(workspaceRoot), bootstrapFailedPath(workspaceRoot), } { _ = os.Remove(path) } return nil } func markBootstrapReady(path string) error { if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { return err } return os.WriteFile(path, []byte(time.Now().Format(time.RFC3339Nano)+"\n"), 0o644) } func writeBootstrapFailure(workspaceRoot string, err error) { if err == nil { _ = os.Remove(bootstrapFailedPath(workspaceRoot)) return } _ = os.MkdirAll(bootstrapStateDirPath(workspaceRoot), 0o755) _ = os.Remove(bootstrapGitReadyPath(workspaceRoot)) _ = os.Remove(bootstrapRepoReadyPath(workspaceRoot)) _ = os.WriteFile(bootstrapFailedPath(workspaceRoot), []byte(err.Error()+"\n"), 0o644) } func runBootstrap(args []string) error { fs := flag.NewFlagSet("agent bootstrap", flag.ContinueOnError) var workspaceRoot string fs.StringVar(&workspaceRoot, "workspace", "", "workspace root") fs.SetOutput(os.Stderr) if err := fs.Parse(args); err != nil { return err } workspaceRoot = filepath.Clean(strings.TrimSpace(workspaceRoot)) if workspaceRoot == "" { return fmt.Errorf("--workspace is required") } repoRoot := repoRootFromWorkspaceRoot(workspaceRoot) if repoRoot == "" { return fmt.Errorf("unable to detect repo root for %s", workspaceRoot) } repoCfg, err := loadRepoConfigOrDefault(repoRoot) if err != nil { return err } startOptions := resolveBootstrapStartOptions(repoRoot, repoCfg, loadAgentRecordByWorkspaceRoot(workspaceRoot)) feature := sanitizeFeatureName(filepath.Base(workspaceRoot)) featureCfgPath := filepath.Join(workspaceRoot, "agent.json") featureCfg, featureErr := loadFeatureConfig(featureCfgPath) isFlutter := fileExists(filepath.Join(repoRoot, "pubspec.yaml")) port := 0 if featureErr == nil { if sanitized := sanitizeFeatureName(featureCfg.Feature); sanitized != "" { feature = sanitized } isFlutter = featureCfg.IsFlutter || isFlutter port = featureCfg.Port } if feature == "" { return fmt.Errorf("feature name is required") } if err := resetBootstrapState(workspaceRoot); err != nil { return err } if err := os.WriteFile(bootstrapPIDPath(workspaceRoot), []byte(strconv.Itoa(os.Getpid())+"\n"), 0o644); err != nil { return err } defer func() { _ = os.Remove(bootstrapPIDPath(workspaceRoot)) }() defer writeBootstrapFailure(workspaceRoot, err) repoCopyPath := filepath.Join(workspaceRoot, "repo") if startOptions.Pull { if pullErr := pullSourceBranch(repoRoot, repoCfg); pullErr != nil { fmt.Fprintf(os.Stderr, "warning: pull source branch failed (continuing): %v\n", pullErr) } } if err = copyGitMetadata(repoRoot, repoCopyPath); err != nil { return err } if err = ensureRepoCopyLocalExcludes(repoCopyPath, isFlutter); err != nil { return err } if _, err = createFeatureBranch(repoCopyPath, feature, startOptions.SourceBranch, repoCfg.AgentKeyPaths); err != nil { return err } if err = applyRepoCopyIgnores(repoRoot, repoCopyPath, repoCfg.CopyIgnore); err != nil { return err } if startOptions.KeepWorktree { if err = syncRepoWorktree(repoRoot, repoCopyPath, repoCfg.CopyIgnore); err != nil { return err } } if err = markBootstrapReady(bootstrapGitReadyPath(workspaceRoot)); err != nil { return err } if isFlutter { if err = removeLegacyRuntimeProject(workspaceRoot); err != nil { return err } if err = configureFlutterWebConfig(repoRoot, repoCopyPath, port); err != nil { return err } if featureErr == nil { if err = writeFlutterHelperScripts(workspaceRoot, repoCopyPath, featureCfg.URL, featureCfg.Device); err != nil { return err } } } if err = markBootstrapReady(bootstrapRepoReadyPath(workspaceRoot)); err != nil { return err } writeBootstrapFailure(workspaceRoot, nil) return nil } func runResume(args []string) error { repoRoot, err := repoRoot() if err != nil { return fmt.Errorf("agent resume defaults to the current repo; run inside a git repo") } reg, err := loadRegistry() if err != nil { reg = ®istry{Agents: map[string]*agentRecord{}} } recordsByID, err := loadWorkspaceAgentRecords(repoRoot, reg) if err != nil { return err } if len(recordsByID) == 0 { return fmt.Errorf("no agents found in %s", filepath.Join(repoRoot, ".agents")) } var agentID string if len(args) > 0 { agentID = sanitizeFeatureName(args[0]) } else { ids := make([]string, 0, len(recordsByID)) for id := range recordsByID { ids = append(ids, id) } sort.Strings(ids) fmt.Println("Select an agent:") for idx, id := range ids { fmt.Printf(" [%d] %s\n", idx+1, id) } value, err := promptInput("Choice: ") if err != nil { return err } choice, convErr := strconv.Atoi(strings.TrimSpace(value)) if convErr != nil || choice < 1 || choice > len(ids) { return fmt.Errorf("invalid choice") } agentID = ids[choice-1] } record := recordsByID[agentID] if record == nil { return fmt.Errorf("unknown agent: %s", agentID) } if _, err := os.Stat(record.RepoCopyPath); err != nil { return fmt.Errorf("agent repo copy missing: %s", record.RepoCopyPath) } if windowAlive(record.TmuxSessionID, record.TmuxWindowID) { return selectTmuxWindow(record.TmuxWindowID) } repoCfg, err := loadRepoConfigOrDefault(record.RepoRoot) if err != nil { return err } if err := prepareAgentContext(record.RepoRoot, record.RepoCopyPath, repoCfg.AgentKeyPaths, true); err != nil { return err } if err := removeLegacyRuntimeProject(record.WorkspaceRoot); err != nil { return err } if err := ensureWorkspaceBootstrap(record, repoCfg); err != nil { return err } if record.Runtime == "flutter" { if strings.TrimSpace(record.Device) == "web-server" { if _, err := ensureChromeForTestingAvailable(); err != nil { return err } } if err := writeFlutterHelperScripts(record.WorkspaceRoot, record.RepoCopyPath, record.URL, record.Device); err != nil { return err } } if err := launchAgentLayout(record); err != nil { return err } return nil } func loadWorkspaceAgentRecords(repoRoot string, reg *registry) (map[string]*agentRecord, error) { agentsRoot := filepath.Join(repoRoot, ".agents") entries, err := os.ReadDir(agentsRoot) if errors.Is(err, os.ErrNotExist) { return map[string]*agentRecord{}, nil } if err != nil { return nil, err } recordsByID := make(map[string]*agentRecord) for _, entry := range entries { if !entry.IsDir() { continue } workspaceRoot := filepath.Join(agentsRoot, entry.Name()) featurePath := filepath.Join(workspaceRoot, "agent.json") repoCopyPath := filepath.Join(workspaceRoot, "repo") if !pathExists(featurePath) && !pathExists(repoCopyPath) { continue } record, err := loadWorkspaceAgentRecord(repoRoot, workspaceRoot, reg) if err != nil { return nil, err } if record == nil { continue } if existing := recordsByID[record.ID]; existing != nil { return nil, fmt.Errorf("duplicate agent %q in %s and %s", record.ID, existing.WorkspaceRoot, workspaceRoot) } recordsByID[record.ID] = record } return recordsByID, nil } func loadWorkspaceAgentRecord(repoRoot, workspaceRoot string, reg *registry) (*agentRecord, error) { workspaceRoot = filepath.Clean(strings.TrimSpace(workspaceRoot)) if workspaceRoot == "" { return nil, nil } featurePath := filepath.Join(workspaceRoot, "agent.json") repoCopyPath := filepath.Join(workspaceRoot, "repo") featureCfg, err := loadFeatureConfig(featurePath) if err != nil && !errors.Is(err, os.ErrNotExist) { return nil, fmt.Errorf("load feature config for %s: %w", workspaceRoot, err) } agentID := sanitizeFeatureName(filepath.Base(workspaceRoot)) if featureCfg != nil { if sanitized := sanitizeFeatureName(featureCfg.Feature); sanitized != "" { agentID = sanitized } } if agentID == "" { return nil, nil } var record agentRecord if existing := registryRecordForWorkspace(reg, repoRoot, workspaceRoot, featurePath, agentID); existing != nil { record = *existing } record.ID = agentID record.Name = agentID record.RepoRoot = repoRoot record.WorkspaceRoot = workspaceRoot record.RepoCopyPath = repoCopyPath record.FeatureConfig = featurePath if strings.TrimSpace(record.Branch) == "" { record.Branch = agentID } if featureCfg != nil { if featureCfg.IsFlutter || (strings.TrimSpace(record.Runtime) == "" && fileExists(filepath.Join(repoRoot, "pubspec.yaml"))) { record.Runtime = "flutter" } record.Device = strings.TrimSpace(featureCfg.Device) record.Port = featureCfg.Port record.URL = strings.TrimSpace(featureCfg.URL) record.BrowserEnabled = record.Device == "web-server" } else if strings.TrimSpace(record.Runtime) == "" && fileExists(filepath.Join(repoRoot, "pubspec.yaml")) { record.Runtime = "flutter" } return &record, nil } func registryRecordForWorkspace(reg *registry, repoRoot, workspaceRoot, featurePath, agentID string) *agentRecord { if reg == nil { return nil } repoRoot = filepath.Clean(strings.TrimSpace(repoRoot)) workspaceRoot = filepath.Clean(strings.TrimSpace(workspaceRoot)) featurePath = filepath.Clean(strings.TrimSpace(featurePath)) for _, record := range reg.Agents { if record == nil { continue } if filepath.Clean(strings.TrimSpace(record.WorkspaceRoot)) == workspaceRoot { return record } if filepath.Clean(strings.TrimSpace(record.FeatureConfig)) == featurePath { return record } } record := reg.Agents[agentID] if record == nil { return nil } if filepath.Clean(strings.TrimSpace(record.RepoRoot)) != repoRoot { return nil } return record } func runList(args ...string) error { fs := flag.NewFlagSet("agent list", flag.ContinueOnError) var showAll bool fs.BoolVar(&showAll, "all", false, "show agents across all repos") fs.SetOutput(os.Stderr) if err := fs.Parse(args); err != nil { return err } reg, err := loadRegistry() if err != nil { return err } repoScope := "" if !showAll { repoScope, err = repoRoot() if err != nil { return fmt.Errorf("agent list defaults to the current repo; run inside a git repo or use --all") } } for _, id := range sortedAgentIDs(reg) { record := reg.Agents[id] if repoScope != "" && filepath.Clean(record.RepoRoot) != filepath.Clean(repoScope) { continue } state := "stopped" if windowAlive(record.TmuxSessionID, record.TmuxWindowID) { state = "running" } fmt.Printf("%s\t%s\t%s\n", id, state, record.RepoCopyPath) } return nil } func runDestroy(args []string) error { fs := flag.NewFlagSet("agent destroy", flag.ContinueOnError) var agentID string var confirmText string fs.StringVar(&agentID, "id", "", "agent id") fs.StringVar(&confirmText, "confirm", "", "required confirmation text for destructive destroy") fs.SetOutput(os.Stderr) if err := fs.Parse(args); err != nil { return err } if agentID == "" && fs.NArg() > 0 { agentID = fs.Arg(0) } if agentID == "" { ctx, err := detectCurrentAgentFromTmux("") if err != nil { return err } agentID = ctx.ID } target, err := loadDestroyTarget(agentID) if err != nil { return err } if target.RequiresExplicitConfirm && strings.TrimSpace(confirmText) != "destroy" { return fmt.Errorf("agent has uncommitted changes; rerun with --confirm destroy") } reg := target.Reg record := target.Record windowID := target.WindowID destroyingCurrentWindow := target.DestroyingCurrentWindow if record.URL != "" { _ = closeChromeTab(record.URL) } delete(reg.Agents, agentID) if reg.FocusedAgentID == agentID { reg.FocusedAgentID = "" } if err := saveRegistry(reg); err != nil { return err } _ = stopWorkspaceBootstrap(record.WorkspaceRoot) if err := os.RemoveAll(record.WorkspaceRoot); err != nil { return err } if windowAlive(record.TmuxSessionID, windowID) { if destroyingCurrentWindow { return runTmux("run-shell", "-b", fmt.Sprintf("sleep 0.2; tmux kill-window -t %s", shellQuote(windowID))) } _ = runTmux("kill-window", "-t", windowID) } return nil } type destroyTarget struct { Reg *registry Record *agentRecord WindowID string DestroyingCurrentWindow bool RequiresExplicitConfirm bool } func loadDestroyTarget(agentID string) (destroyTarget, error) { agentID = strings.TrimSpace(agentID) reg, err := loadRegistry() if err != nil { return destroyTarget{}, err } record := reg.Agents[agentID] if record == nil { return destroyTarget{}, fmt.Errorf("unknown agent: %s", agentID) } windowID := activeAgentWindowID(record) requiresExplicitConfirm, err := destroyRequiresExplicitConfirm(record) if err != nil { return destroyTarget{}, err } if strings.TrimSpace(windowID) != "" { openWindowTodos, err := countOpenTmuxTodos(todoScopeWindow, windowID) if err != nil { return destroyTarget{}, err } if openWindowTodos > 0 { label := "todos" if openWindowTodos == 1 { label = "todo" } return destroyTarget{}, fmt.Errorf("refusing to destroy agent with %d open window %s", openWindowTodos, label) } } currentWindowID := currentTmuxWindowID() return destroyTarget{ Reg: reg, Record: record, WindowID: windowID, DestroyingCurrentWindow: currentWindowID != "" && strings.TrimSpace(windowID) == currentWindowID, RequiresExplicitConfirm: requiresExplicitConfirm, }, nil } func destroyRequiresExplicitConfirm(record *agentRecord) (bool, error) { if record == nil { return false, nil } repoPath := strings.TrimSpace(record.RepoCopyPath) if repoPath == "" || !fileExists(repoPath) { return false, nil } cmd := exec.Command("git", "status", "--porcelain") cmd.Dir = repoPath out, err := cmd.CombinedOutput() if err != nil { message := strings.TrimSpace(string(out)) if message == "" { return false, err } return false, fmt.Errorf("check git status: %s", message) } return strings.TrimSpace(string(out)) != "", nil } func runTmuxCommand(args []string) error { if len(args) == 0 { return fmt.Errorf("usage: agent tmux ") } switch args[0] { case "on-focus": return runTmuxOnFocus(args[1:]) case "focus": return runTmuxFocus(args[1:]) case "palette": return runTmuxPalette(args[1:]) case "right-status": return runTmuxRightStatus(args[1:]) case "work-status": return runTmuxWorkStatus(args[1:]) case "scratch": return runTmuxScratch(args[1:]) default: return fmt.Errorf("unknown tmux subcommand: %s", args[0]) } } func runBrowserCommand(args []string) error { if len(args) == 0 { return fmt.Errorf("usage: agent browser ") } switch args[0] { case "open": return runBrowserOpen(args[1:]) case "refresh": return runBrowserRefresh(args[1:]) case "close": return runBrowserClose(args[1:]) case "screenshot": return runBrowserScreenshot(args[1:]) case "logs": return runBrowserLogs(args[1:]) case "clear-logs": return runBrowserClearLogs(args[1:]) case "mcp": return runBrowserMCP(args[1:]) default: return fmt.Errorf("unknown browser subcommand: %s", args[0]) } } func runBrowserOpen(args []string) error { fs := flag.NewFlagSet("agent browser open", flag.ContinueOnError) var workspace string var allowOpen bool fs.StringVar(&workspace, "workspace", "", "workspace root containing agent.json") fs.BoolVar(&allowOpen, "allow-open", false, "open a new tab if missing") fs.SetOutput(os.Stderr) if err := fs.Parse(args); err != nil { return err } _, featurePath, err := resolveBrowserWorkspace(workspace) if err != nil { return err } return syncChromeForFeature(featurePath, allowOpen) } func runBrowserRefresh(args []string) error { fs := flag.NewFlagSet("agent browser refresh", flag.ContinueOnError) var workspace string fs.StringVar(&workspace, "workspace", "", "workspace root containing agent.json") fs.SetOutput(os.Stderr) if err := fs.Parse(args); err != nil { return err } _, featurePath, err := resolveBrowserWorkspace(workspace) if err != nil { return err } return refreshChromeForFeature(featurePath) } func runBrowserClose(args []string) error { fs := flag.NewFlagSet("agent browser close", flag.ContinueOnError) var workspace string fs.StringVar(&workspace, "workspace", "", "workspace root containing agent.json") fs.SetOutput(os.Stderr) if err := fs.Parse(args); err != nil { return err } _, featurePath, err := resolveBrowserWorkspace(workspace) if err != nil { return err } return closeBrowserForFeature(featurePath) } func runBrowserScreenshot(args []string) error { fs := flag.NewFlagSet("agent browser screenshot", flag.ContinueOnError) var workspace string var outPath string fs.StringVar(&workspace, "workspace", "", "workspace root containing agent.json") fs.StringVar(&outPath, "out", "", "output path for the screenshot") fs.SetOutput(os.Stderr) if err := fs.Parse(args); err != nil { return err } _, featurePath, err := resolveBrowserWorkspace(workspace) if err != nil { return err } if strings.TrimSpace(outPath) == "" { defaultPath, err := defaultBrowserScreenshotPath(featurePath) if err != nil { return err } outPath = defaultPath } path, err := captureBrowserScreenshot(featurePath, outPath) if err != nil { return err } fmt.Println(path) return nil } func runBrowserLogs(args []string) error { fs := flag.NewFlagSet("agent browser logs", flag.ContinueOnError) var workspace string var durationSeconds int var keep bool var tail int fs.StringVar(&workspace, "workspace", "", "workspace root containing agent.json") fs.IntVar(&durationSeconds, "duration", 0, "seconds to live-stream console output (0 = read buffer)") fs.BoolVar(&keep, "keep", false, "keep logs in buffer after reading") fs.IntVar(&tail, "tail", 100, "max lines from buffer (0 = all)") fs.SetOutput(os.Stderr) if err := fs.Parse(args); err != nil { return err } _, featurePath, err := resolveBrowserWorkspace(workspace) if err != nil { return err } if durationSeconds > 0 { if durationSeconds < 1 { durationSeconds = 1 } return streamBrowserLogs(featurePath, time.Duration(durationSeconds)*time.Second, os.Stdout) } text, err := browserReadLogsForFeature(featurePath, tail) if err != nil { return err } if text != "" { fmt.Println(text) } if !keep { _ = browserClearLogsForFeature(featurePath) } return nil } func runBrowserClearLogs(args []string) error { fs := flag.NewFlagSet("agent browser clear-logs", flag.ContinueOnError) var workspace string fs.StringVar(&workspace, "workspace", "", "workspace root containing agent.json") fs.SetOutput(os.Stderr) if err := fs.Parse(args); err != nil { return err } _, featurePath, err := resolveBrowserWorkspace(workspace) if err != nil { return err } return browserClearLogsForFeature(featurePath) } func runFeatureCommand(args []string) error { fs := flag.NewFlagSet("agent feature", flag.ContinueOnError) var workspace string var device string var readyText string var writeScripts bool fs.StringVar(&workspace, "workspace", "", "workspace root containing agent.json") fs.StringVar(&device, "device", "", "set flutter device") fs.StringVar(&readyText, "ready", "", "set ready state (true/false)") fs.BoolVar(&writeScripts, "write-helper-scripts", false, "rewrite generated helper scripts for the workspace") fs.SetOutput(os.Stderr) if err := fs.Parse(args); err != nil { return err } workspace = strings.TrimSpace(workspace) if workspace == "" { return fmt.Errorf("--workspace is required") } featurePath := filepath.Join(workspace, "agent.json") if writeScripts { cfg, err := loadFeatureConfig(featurePath) if err != nil { return err } return writeFlutterHelperScripts(workspace, filepath.Join(workspace, "repo"), cfg.URL, cfg.Device) } if err := updateFeatureConfig(featurePath, func(cfg *featureConfig) error { if strings.TrimSpace(device) != "" { cfg.Device = strings.TrimSpace(device) } if strings.TrimSpace(readyText) != "" { value, err := strconv.ParseBool(strings.TrimSpace(readyText)) if err != nil { return fmt.Errorf("invalid --ready value: %w", err) } cfg.Ready = value } return nil }); err != nil { return err } if strings.TrimSpace(device) != "" { if err := syncFeatureDeviceToRegistry(workspace, featurePath, strings.TrimSpace(device)); err != nil { return err } } return nil } func syncFeatureDeviceToRegistry(workspaceRoot, featurePath, device string) error { reg, err := loadRegistry() if err != nil { return err } workspaceRoot = filepath.Clean(strings.TrimSpace(workspaceRoot)) featurePath = filepath.Clean(strings.TrimSpace(featurePath)) device = strings.TrimSpace(device) browserEnabled := device == "web-server" updated := false for _, record := range reg.Agents { if record == nil { continue } if filepath.Clean(strings.TrimSpace(record.WorkspaceRoot)) != workspaceRoot && filepath.Clean(strings.TrimSpace(record.FeatureConfig)) != featurePath { continue } record.Device = device record.BrowserEnabled = browserEnabled record.UpdatedAt = time.Now() updated = true break } if !updated { return nil } return saveRegistry(reg) } func runTmuxOnFocus(args []string) error { fs := flag.NewFlagSet("agent tmux on-focus", flag.ContinueOnError) var sessionID, windowID, paneID string fs.StringVar(&sessionID, "session", "", "session id") fs.StringVar(&windowID, "window", "", "window id") fs.StringVar(&paneID, "pane", "", "pane id") fs.SetOutput(os.Stderr) if err := fs.Parse(args); err != nil { return err } if !tmuxWindowIsActive(sessionID, windowID) { return nil } ctx, err := detectCurrentAgentFromTmux(windowID) if err != nil { return nil } _ = paneID reg, err := loadRegistry() if err != nil { return err } record := reg.Agents[ctx.ID] if record == nil { return nil } if reg.FocusedAgentID == record.ID { if record.BrowserEnabled && tmuxWindowIsActive(sessionID, windowID) { _ = syncChromeForFeature(record.FeatureConfig, true) } return nil } now := time.Now() record.LastFocusedAt = &now record.UpdatedAt = now reg.FocusedAgentID = record.ID if err := saveRegistry(reg); err != nil { return err } if record.BrowserEnabled && tmuxWindowIsActive(sessionID, windowID) { _ = syncChromeForFeature(record.FeatureConfig, true) } return nil } func tmuxWindowIsActive(sessionID, windowID string) bool { windowID = strings.TrimSpace(windowID) if windowID == "" { return true } target := strings.TrimSpace(sessionID) if target == "" { target = windowID } activeWindowID, err := runTmuxOutput("display-message", "-p", "-t", target, "#{window_id}") if err != nil { return false } return strings.TrimSpace(activeWindowID) == windowID } func runTmuxFocus(args []string) error { fs := flag.NewFlagSet("agent tmux focus", flag.ContinueOnError) var windowID string fs.StringVar(&windowID, "window", "", "window id") fs.SetOutput(os.Stderr) if err := fs.Parse(args); err != nil { return err } if fs.NArg() == 0 { return fmt.Errorf("usage: agent tmux focus ") } role := strings.ToLower(fs.Arg(0)) ctx, err := detectCurrentAgentFromTmux(windowID) if err != nil { return err } reg, err := loadRegistry() if err != nil { return err } record := reg.Agents[ctx.ID] if record == nil { return fmt.Errorf("unknown agent: %s", ctx.ID) } target := "" switch role { case "ai": target = record.Panes.AI case "git": target = record.Panes.Git case "run": target = record.Panes.Run default: return fmt.Errorf("unknown pane role: %s", role) } if target == "" { return fmt.Errorf("pane not found for role: %s", role) } return runTmux("select-pane", "-t", target) } func runTmuxScratch(args []string) error { fs := flag.NewFlagSet("agent tmux scratch", flag.ContinueOnError) var path string var client string var warm bool fs.StringVar(&path, "path", "", "directory for creating the scratch terminal") fs.StringVar(&client, "client", "", "tmux client tty") fs.BoolVar(&warm, "warm", false, "prepare the scratch terminal without opening it") fs.SetOutput(os.Stderr) if err := fs.Parse(args); err != nil { return err } if warm { return warmScratchSession(path) } current, err := currentTmuxLocation() if err != nil { return err } reg, err := loadRegistry() if err != nil { return err } record := reg.Agents["scratch"] if scratchRecordAlive(record) { _ = runTmux("rename-session", "-t", record.TmuxSessionID, "scratch") _ = configureScratchSession(record.TmuxSessionID, record.TmuxWindowID) if current.SessionID == record.TmuxSessionID { return closeScratchPopup(client) } setScratchOrigin(record.TmuxWindowID, current) if current.WindowID != "" { record.LaunchWindowID = current.WindowID } now := time.Now() record.LastFocusedAt = &now record.UpdatedAt = now reg.FocusedAgentID = record.ID if err := saveRegistry(reg); err != nil { return err } return openScratchPopup(record) } if record == nil { record = &agentRecord{ID: "scratch", Name: "scratch", CreatedAt: time.Now()} } startPath := firstNonEmpty(path, os.Getenv("HOME")) if startPath == "" || !dirExists(startPath) { startPath = os.Getenv("HOME") } windowID, sessionID, sessionName, paneID, err := createScratchTmuxSession(startPath) if err != nil { return err } now := time.Now() record.ID = "scratch" record.Name = "scratch" record.RepoRoot = startPath record.WorkspaceRoot = startPath record.RepoCopyPath = startPath record.TmuxSessionID = sessionID record.TmuxSessionName = sessionName record.TmuxWindowID = windowID record.Panes = agentPanes{AI: paneID} record.LaunchWindowID = current.WindowID record.LastFocusedAt = &now record.UpdatedAt = now if record.CreatedAt.IsZero() { record.CreatedAt = now } reg.Agents[record.ID] = record reg.FocusedAgentID = record.ID if err := saveRegistry(reg); err != nil { return err } setScratchOrigin(windowID, current) return openScratchPopup(record) } func warmScratchSession(path string) error { reg, err := loadRegistry() if err != nil { return err } record := reg.Agents["scratch"] if record == nil { return nil } if scratchRecordAlive(record) { _ = runTmux("rename-session", "-t", record.TmuxSessionID, "scratch") return configureScratchSession(record.TmuxSessionID, record.TmuxWindowID) } startPath := firstNonEmpty(path, record.RepoCopyPath, record.WorkspaceRoot, record.RepoRoot, os.Getenv("HOME")) if startPath == "" || !dirExists(startPath) { startPath = os.Getenv("HOME") } windowID, sessionID, sessionName, paneID, err := createScratchTmuxSession(startPath) if err != nil { return err } now := time.Now() record.TmuxSessionID = sessionID record.TmuxSessionName = sessionName record.TmuxWindowID = windowID record.Panes = agentPanes{AI: paneID} record.RepoRoot = startPath record.WorkspaceRoot = startPath record.RepoCopyPath = startPath record.UpdatedAt = now reg.Agents[record.ID] = record return saveRegistry(reg) } type tmuxLocation struct { SessionID string WindowID string PaneID string Path string } func currentTmuxLocation() (tmuxLocation, error) { out, err := runTmuxOutput("display-message", "-p", "#{session_id}\n#{window_id}\n#{pane_id}\n#{pane_current_path}") if err != nil { return tmuxLocation{}, err } parts := strings.SplitN(strings.TrimRight(out, "\n"), "\n", 4) for len(parts) < 4 { parts = append(parts, "") } return tmuxLocation{ SessionID: strings.TrimSpace(parts[0]), WindowID: strings.TrimSpace(parts[1]), PaneID: strings.TrimSpace(parts[2]), Path: strings.TrimSpace(parts[3]), }, nil } func scratchRecordAlive(record *agentRecord) bool { return record != nil && windowAlive(record.TmuxSessionID, record.TmuxWindowID) } func createScratchTmuxSession(path string) (windowID, sessionID, sessionName, paneID string, err error) { sessionName = "Scratch" if existingSessionID, existingSessionName, ok := findTmuxSessionByLabel(sessionName); ok { sessionID = existingSessionID sessionName = existingSessionName windowID, err = runTmuxOutput("new-window", "-P", "-F", "#{window_id}", "-t", sessionID, "-c", path) if err != nil { return "", "", "", "", err } } else { if err := runTmux("new-session", "-d", "-s", sessionName, "-c", path); err != nil { return "", "", "", "", err } sessionID, _ = runTmuxOutput("display-message", "-p", "-t", sessionName+":1", "#{session_id}") windowID, _ = runTmuxOutput("display-message", "-p", "-t", sessionName+":1", "#{window_id}") } windowID = strings.TrimSpace(windowID) sessionID = strings.TrimSpace(sessionID) sessionName = strings.TrimSpace(sessionName) if windowID == "" || sessionID == "" { return "", "", "", "", fmt.Errorf("unable to create scratch terminal") } paneID, err = currentPane(windowID) if err != nil { return "", "", "", "", err } _ = runTmux("rename-session", "-t", sessionID, "Scratch") _ = runTmux("set-option", "-w", "-t", windowID, "@agent_id", "scratch") _ = runTmux("set-option", "-w", "-t", windowID, "@scratch_window", "1") _ = runTmux("set-option", "-p", "-t", paneID, "@agent_role", "scratch") _ = configureScratchSession(sessionID, windowID) return windowID, sessionID, "Scratch", strings.TrimSpace(paneID), nil } func configureScratchSession(sessionID, windowID string) error { sessionID = strings.TrimSpace(sessionID) windowID = strings.TrimSpace(windowID) if sessionID != "" { _ = runTmux("set-option", "-t", sessionID, "status", "on") _ = runTmux("set-option", "-t", sessionID, "status-left", "") _ = runTmux("set-option", "-t", sessionID, "status-right", "") _ = runTmux("set-option", "-t", sessionID, "status-left-length", "0") _ = runTmux("set-option", "-t", sessionID, "status-right-length", "0") _ = runTmux("set-option", "-t", sessionID, "prefix", "None") _ = runTmux("set-option", "-t", sessionID, "escape-time", "0") _ = runTmux("set-option", "-t", sessionID, "detach-on-destroy", "on") } if windowID != "" { _ = runTmux("set-option", "-w", "-t", windowID, "@agent_id", "scratch") _ = runTmux("set-option", "-w", "-t", windowID, "@scratch_window", "1") } return nil } func openScratchPopup(record *agentRecord) error { if record == nil { return fmt.Errorf("scratch terminal is not available") } target := strings.TrimSpace(record.TmuxSessionID) if target == "" { target = strings.TrimSpace(record.TmuxWindowID) } if target == "" { return fmt.Errorf("scratch terminal is not available") } cmd := fmt.Sprintf("tmux attach-session -t %s", shellQuote(target)) return runTmux("display-popup", "-E", "-w", "85%", "-h", "80%", "-T", "Scratch", cmd) } func closeScratchPopup(client string) error { client = strings.TrimSpace(client) if client == "" { client = strings.TrimSpace(os.Getenv("TMUX_CLIENT")) } args := []string{"detach-client"} if client != "" { args = append(args, "-t", client) } return runTmux(args...) } func setScratchOrigin(scratchWindowID string, origin tmuxLocation) { scratchWindowID = strings.TrimSpace(scratchWindowID) if scratchWindowID == "" { return } if origin.SessionID != "" { _ = runTmux("set-option", "-w", "-t", scratchWindowID, "@scratch_origin_session", origin.SessionID) } if origin.WindowID != "" { _ = runTmux("set-option", "-w", "-t", scratchWindowID, "@scratch_origin_window", origin.WindowID) } if origin.PaneID != "" { _ = runTmux("set-option", "-w", "-t", scratchWindowID, "@scratch_origin_pane", origin.PaneID) } } func focusScratchOrigin(reg *registry) error { if reg == nil { return nil } record := reg.Agents["scratch"] if record == nil { return nil } originSessionID := "" originWindowID := "" originPaneID := "" if out, err := runTmuxOutput("show-options", "-wqv", "-t", record.TmuxWindowID, "@scratch_origin_session"); err == nil { originSessionID = strings.TrimSpace(out) } if out, err := runTmuxOutput("show-options", "-wqv", "-t", record.TmuxWindowID, "@scratch_origin_window"); err == nil { originWindowID = strings.TrimSpace(out) } if out, err := runTmuxOutput("show-options", "-wqv", "-t", record.TmuxWindowID, "@scratch_origin_pane"); err == nil { originPaneID = strings.TrimSpace(out) } if !windowAlive(originSessionID, originWindowID) { originSessionID = "" originWindowID = firstLiveNonScratchWindow(record.TmuxWindowID) } if originWindowID == "" { return nil } reg.FocusedAgentID = "" _ = saveRegistry(reg) if originSessionID != "" { _ = runTmux("switch-client", "-t", originSessionID) } if err := selectTmuxWindow(originWindowID); err != nil { return err } if originPaneID != "" { _ = runTmux("select-pane", "-t", originPaneID) } return nil } func firstLiveNonScratchWindow(scratchWindowID string) string { out, err := runTmuxOutput("list-windows", "-a", "-F", "#{window_id}") if err != nil { return "" } for _, line := range strings.Split(out, "\n") { windowID := strings.TrimSpace(line) if windowID != "" && windowID != strings.TrimSpace(scratchWindowID) { return windowID } } return "" } func runTmuxPalette(args []string) error { fs := flag.NewFlagSet("agent tmux palette", flag.ContinueOnError) var windowID string fs.StringVar(&windowID, "window", "", "window id") fs.SetOutput(os.Stderr) if err := fs.Parse(args); err != nil { return err } ctx, err := tmuxPaletteContext(windowID) if err != nil { return err } windowID = ctx.WindowID if windowID == "" { return fmt.Errorf("window id is required") } exe, err := os.Executable() if err != nil { return err } cmd := fmt.Sprintf( "%s palette --window=%s --agent-id=%s --path=%s --session-name=%s --window-name=%s --session-id=%s --pane-id=%s --window-index=%s --pane-index=%s", shellQuote(exe), shellQuote(ctx.WindowID), shellQuote(ctx.AgentID), shellQuote(ctx.CurrentPath), shellQuote(ctx.SessionName), shellQuote(ctx.WindowName), shellQuote(ctx.SessionID), shellQuote(ctx.PaneID), shellQuote(ctx.WindowIndex), shellQuote(ctx.PaneIndex), ) return runTmux("display-popup", "-E", "-w", "78%", "-h", "80%", "-T", "agent", cmd) } type currentAgentRef struct{ ID string } type tmuxPaletteLaunchContext struct { SessionID string WindowID string PaneID string AgentID string CurrentPath string SessionName string WindowName string WindowIndex string PaneIndex string } func tmuxPaletteContext(windowID string) (tmuxPaletteLaunchContext, error) { args := []string{"display-message", "-p"} if strings.TrimSpace(windowID) != "" { args = append(args, "-t", strings.TrimSpace(windowID)) } args = append(args, "#{session_id}\n#{window_id}\n#{pane_id}\n#{@agent_id}\n#{pane_current_path}\n#{session_name}\n#{window_name}\n#{window_index}\n#{pane_index}") out, err := runTmuxOutput(args...) if err != nil { return tmuxPaletteLaunchContext{}, err } parts := strings.SplitN(strings.TrimRight(out, "\n"), "\n", 9) for len(parts) < 9 { parts = append(parts, "") } return tmuxPaletteLaunchContext{ SessionID: strings.TrimSpace(parts[0]), WindowID: strings.TrimSpace(parts[1]), PaneID: strings.TrimSpace(parts[2]), AgentID: strings.TrimSpace(parts[3]), CurrentPath: strings.TrimSpace(parts[4]), SessionName: strings.TrimSpace(parts[5]), WindowName: strings.TrimSpace(parts[6]), WindowIndex: strings.TrimSpace(parts[7]), PaneIndex: strings.TrimSpace(parts[8]), }, nil } func detectCurrentAgentFromTmux(windowID string) (currentAgentRef, error) { if windowID == "" { out, err := runTmuxOutput("display-message", "-p", "#{window_id}") if err != nil { return currentAgentRef{}, err } windowID = strings.TrimSpace(out) } out, err := runTmuxOutput("show-options", "-wqv", "-t", windowID, "@agent_id") if err != nil { return currentAgentRef{}, err } id := strings.TrimSpace(out) if id == "" { return currentAgentRef{}, fmt.Errorf("no agent id for window %s", windowID) } return currentAgentRef{ID: id}, nil } func gatedWorkspaceCommand(workspaceRoot, readyMarker, successCmd string) string { failurePath := bootstrapFailedPath(workspaceRoot) return fmt.Sprintf( "cd %s; while [ ! -f %s ] && [ ! -f %s ]; do sleep 0.2; done; if [ -f %s ]; then cat %s 2>/dev/null; printf '\\nSee %%s\\n' %s; exec ${SHELL:-/bin/zsh}; fi; %s", shellQuote(workspaceRoot), shellQuote(readyMarker), shellQuote(failurePath), shellQuote(failurePath), shellQuote(failurePath), shellQuote(bootstrapLogPath(workspaceRoot)), successCmd, ) } func currentTmuxWindowID() string { if out, err := runTmuxOutput("display-message", "-p", "#{window_id}"); err == nil { return strings.TrimSpace(out) } return "" } func activeAgentWindowID(record *agentRecord) string { if record == nil { return "" } windowID := strings.TrimSpace(record.TmuxWindowID) if os.Getenv("TMUX") == "" { return windowID } ctx, err := detectCurrentAgentFromTmux("") if err != nil || strings.TrimSpace(ctx.ID) != strings.TrimSpace(record.ID) { return windowID } currentWindowID := currentTmuxWindowID() if currentWindowID != "" { return currentWindowID } return windowID } func launchAgentLayout(record *agentRecord) (err error) { previousWindowID := currentTmuxWindowID() cleanupWindow := false windowID := "" windowID, sessionID, sessionName, attachAfter, err := createWindow(record.Name, record.RepoCopyPath, record.LaunchWindowID) if err != nil { return err } cleanupWindow = true defer func() { if err == nil || !cleanupWindow || strings.TrimSpace(windowID) == "" { return } _ = runTmux("kill-window", "-t", windowID) if strings.TrimSpace(previousWindowID) != "" { _ = runTmux("select-window", "-t", previousWindowID) } }() topPane, err := currentPane(windowID) if err != nil { return err } if err := runTmux("split-window", "-t", topPane, "-h", "-l", "35%", "-c", record.WorkspaceRoot); err != nil { return err } rightPane, err := currentPane(windowID) if err != nil { return err } if err := runTmux("split-window", "-t", rightPane, "-v", "-l", "8", "-c", record.WorkspaceRoot); err != nil { return err } runPane, err := currentPane(windowID) if err != nil { return err } gitPane := rightPane aiPane := topPane if err := runTmux("set-option", "-w", "-t", windowID, "@agent_id", record.ID); err != nil { return err } _ = runTmux("rename-window", "-t", windowID, record.ID) for pane, role := range map[string]string{aiPane: "ai", gitPane: "git", runPane: "run"} { _ = runTmux("set-option", "-p", "-t", pane, "@agent_role", role) } record.TmuxSessionID = sessionID record.TmuxSessionName = sessionName record.TmuxWindowID = windowID record.Panes = agentPanes{AI: aiPane, Git: gitPane, Run: runPane} record.UpdatedAt = time.Now() reg, err := loadRegistry() if err == nil { reg.Agents[record.ID] = record _ = saveRegistry(reg) } aiCmd := fmt.Sprintf("cd %s; exec ${SHELL:-/bin/zsh}", shellQuote(record.RepoCopyPath)) gitCmd := gatedWorkspaceCommand( record.WorkspaceRoot, bootstrapGitReadyPath(record.WorkspaceRoot), fmt.Sprintf("cd %s; if command -v lazygit >/dev/null 2>&1; then lazygit; fi; exec ${SHELL:-/bin/zsh}", shellQuote(record.RepoCopyPath)), ) runCmd := agentRunPaneCommand(record) for pane, cmd := range map[string]string{aiPane: aiCmd, gitPane: gitCmd, runPane: runCmd} { if err := runTmux("respawn-pane", "-k", "-t", pane, cmd); err != nil { return err } } if attachAfter && canAttachTmux() { if err := runTmux("attach-session", "-t", sessionID); err != nil { return err } cleanupWindow = false return nil } if err := runTmux("select-window", "-t", windowID); err != nil { return err } if err := runTmux("select-pane", "-t", aiPane); err != nil { return err } cleanupWindow = false return nil } func primeAgentAIPane(paneID string) error { paneID = strings.TrimSpace(paneID) if paneID == "" { return nil } deadline := time.Now().Add(3 * time.Second) for time.Now().Before(deadline) { out, err := runTmuxOutput("display-message", "-p", "-t", paneID, "#{pane_current_command}") if err == nil { switch strings.TrimSpace(out) { case "zsh", "bash", "sh", "fish": deadline = time.Now() } } if !time.Now().Before(deadline) { break } time.Sleep(100 * time.Millisecond) } if err := runTmux("send-keys", "-t", paneID, "-l", "op"); err != nil { return err } return runTmux("send-keys", "-t", paneID, "Enter") } func agentRunPaneCommand(record *agentRecord) string { if record == nil { return "" } shellCmd := gatedWorkspaceCommand( record.WorkspaceRoot, bootstrapRepoReadyPath(record.WorkspaceRoot), fmt.Sprintf("cd %s; exec ${SHELL:-/bin/zsh}", shellQuote(record.WorkspaceRoot)), ) if record.Runtime != "flutter" || strings.TrimSpace(record.Device) == "" { return shellCmd } return gatedWorkspaceCommand( record.WorkspaceRoot, bootstrapRepoReadyPath(record.WorkspaceRoot), fmt.Sprintf("cd %s; ./ensure-server.sh %s; exec ${SHELL:-/bin/zsh}", shellQuote(record.WorkspaceRoot), shellQuote(record.Device)), ) } func canAttachTmux() bool { if os.Getenv("TMUX") != "" { return false } info, err := os.Stdin.Stat() if err != nil { return false } return info.Mode()&os.ModeCharDevice != 0 } func createWindow(feature, path string, targetWindowID string) (windowID, sessionID, sessionName string, attachAfter bool, err error) { targetWindowID = preferredNewWindowTarget(targetWindowID, os.Getenv("TMUX") != "", currentTmuxWindowID()) if targetWindowID != "" { targetSessionID, targetSessionName, resolveErr := tmuxSessionForWindow(targetWindowID) if resolveErr == nil && targetSessionID != "" { windowID, err = runTmuxOutput(positionedNewWindowArgs(feature, path, targetWindowID)...) if err != nil { return "", "", "", false, err } return strings.TrimSpace(windowID), strings.TrimSpace(targetSessionID), strings.TrimSpace(targetSessionName), false, nil } } if os.Getenv("TMUX") != "" { windowID, err = runTmuxOutput(positionedNewWindowArgs(feature, path, "")...) if err != nil { return "", "", "", false, err } windowID = strings.TrimSpace(windowID) sessionID, _ = runTmuxOutput("display-message", "-p", "-t", windowID, "#{session_id}") sessionName, _ = runTmuxOutput("display-message", "-p", "-t", windowID, "#{session_name}") return windowID, strings.TrimSpace(sessionID), strings.TrimSpace(sessionName), false, nil } repoName := filepath.Base(path) desiredSessionLabel := sanitizeFeatureName(repoName + "-agents") if desiredSessionLabel == "" { desiredSessionLabel = "agents" } if existingSessionID, existingSessionName, ok := findTmuxSessionByLabel(desiredSessionLabel); ok { windowID, err = runTmuxOutput("new-window", "-P", "-F", "#{window_id}", "-t", existingSessionID, "-n", feature, "-c", path) if err != nil { return "", "", "", false, err } return strings.TrimSpace(windowID), strings.TrimSpace(existingSessionID), strings.TrimSpace(existingSessionName), true, nil } if err := runTmux("new-session", "-d", "-s", desiredSessionLabel, "-n", feature, "-c", path); err != nil { return "", "", "", false, err } attachAfter = true sessionID, _ = runTmuxOutput("display-message", "-p", "-t", desiredSessionLabel+":1", "#{session_id}") sessionID = strings.TrimSpace(sessionID) if sessionID == "" { if existingSessionID, existingSessionName, ok := findTmuxSessionByLabel(desiredSessionLabel); ok { sessionID = strings.TrimSpace(existingSessionID) sessionName = strings.TrimSpace(existingSessionName) } } if sessionID == "" { return "", "", "", false, fmt.Errorf("unable to resolve tmux session for %s", desiredSessionLabel) } windowID, _ = runTmuxOutput("list-windows", "-t", sessionID, "-F", "#{window_id}") if sessionName == "" { sessionName, _ = runTmuxOutput("display-message", "-p", "-t", sessionID, "#{session_name}") } return strings.TrimSpace(strings.Split(windowID, "\n")[0]), strings.TrimSpace(sessionID), strings.TrimSpace(sessionName), true, nil } func preferredNewWindowTarget(targetWindowID string, inTmux bool, currentWindowID string) string { targetWindowID = strings.TrimSpace(targetWindowID) if targetWindowID != "" { return targetWindowID } if !inTmux { return "" } return strings.TrimSpace(currentWindowID) } func positionedNewWindowArgs(feature, path, targetWindowID string) []string { args := []string{"new-window", "-P", "-F", "#{window_id}"} if targetWindowID = strings.TrimSpace(targetWindowID); targetWindowID != "" { args = append(args, "-a", "-t", targetWindowID) } args = append(args, "-n", feature, "-c", path) return args } func tmuxSessionForWindow(windowID string) (sessionID, sessionName string, err error) { windowID = strings.TrimSpace(windowID) if windowID == "" { return "", "", fmt.Errorf("window id is required") } out, err := runTmuxOutput("display-message", "-p", "-t", windowID, "#{session_id}\n#{session_name}") if err != nil { return "", "", err } parts := strings.SplitN(strings.TrimRight(out, "\n"), "\n", 2) for len(parts) < 2 { parts = append(parts, "") } return strings.TrimSpace(parts[0]), strings.TrimSpace(parts[1]), nil } func findTmuxSessionByLabel(label string) (sessionID, sessionName string, ok bool) { out, err := runTmuxOutput("list-sessions", "-F", "#{session_id}\t#{session_name}") if err != nil { return "", "", false } for _, line := range strings.Split(out, "\n") { line = strings.TrimSpace(line) if line == "" { continue } parts := strings.SplitN(line, "\t", 2) if len(parts) != 2 { continue } id := strings.TrimSpace(parts[0]) name := strings.TrimSpace(parts[1]) if name == label { return id, name, true } if match := regexp.MustCompile(`^\d+-(.+)$`).FindStringSubmatch(name); len(match) == 2 && strings.TrimSpace(match[1]) == label { return id, name, true } } return "", "", false } func currentPane(windowID string) (string, error) { out, err := runTmuxOutput("display-message", "-p", "-t", windowID, "#{pane_id}") return strings.TrimSpace(out), err } func windowAlive(sessionID, windowID string) bool { if strings.TrimSpace(windowID) == "" { return false } out, err := runTmuxOutput("list-windows", "-a", "-F", "#{session_id}\t#{window_id}") if err != nil { return false } targetWindow := strings.TrimSpace(windowID) targetSession := strings.TrimSpace(sessionID) for _, line := range strings.Split(out, "\n") { line = strings.TrimSpace(line) if line == "" { continue } parts := strings.Split(line, "\t") if len(parts) != 2 { continue } if strings.TrimSpace(parts[1]) != targetWindow { continue } if targetSession == "" || strings.TrimSpace(parts[0]) == targetSession { return true } } return false } func selectTmuxWindow(windowID string) error { if err := runTmux("select-window", "-t", windowID); err != nil { return err } return nil } func repoRoot() (string, error) { out, err := runGitOutput("rev-parse", "--show-toplevel") if err != nil { return "", fmt.Errorf("not in a git repo") } return strings.TrimSpace(out), nil } func ensureFlutterWebRepo(repoRoot string) error { if !fileExists(filepath.Join(repoRoot, "pubspec.yaml")) || !dirExists(filepath.Join(repoRoot, "web")) { return fmt.Errorf("agent init only works for Flutter web repos right now; expected both pubspec.yaml and a web/ directory") } return nil } func requiredAgentExcludeEntries(repoRoot string, isFlutter bool) []string { entries := []string{".agent.yaml"} entries = append(entries, ".agents") if isFlutter { entries = append(entries, "web_dev_config.yaml") } return entries } func ensureRepoCopyLocalExcludes(repoCopyPath string, isFlutter bool) error { return ensureGitExcludeEntries(repoCopyPath, requiredAgentExcludeEntries(repoCopyPath, isFlutter)) } func gitInfoExcludePath(repoRoot string) string { return filepath.Join(repoRoot, ".git", "info", "exclude") } func ensureGitExcludeEntries(repoRoot string, entries []string) error { path := gitInfoExcludePath(repoRoot) existing := map[string]bool{} data, err := os.ReadFile(path) if err != nil && !errors.Is(err, os.ErrNotExist) { return err } if err == nil { for _, line := range strings.Split(string(data), "\n") { trimmed := strings.TrimSpace(line) if trimmed != "" { existing[trimmed] = true } } } missing := make([]string, 0, len(entries)) for _, entry := range entries { entry = strings.TrimSpace(entry) if entry == "" || existing[entry] { continue } missing = append(missing, entry) } if len(missing) == 0 { return nil } if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { return err } var builder strings.Builder if len(data) > 0 { builder.Write(data) if !strings.HasSuffix(string(data), "\n") { builder.WriteByte('\n') } } for _, entry := range missing { builder.WriteString(entry) builder.WriteByte('\n') } return os.WriteFile(path, []byte(builder.String()), 0o644) } func gitPathIgnored(repoRoot, relPath string) (bool, error) { relPath = strings.TrimSpace(relPath) if relPath == "" { return false, fmt.Errorf("path is required") } cmd := exec.Command("git", "check-ignore", "-q", "--", relPath) cmd.Dir = repoRoot if err := cmd.Run(); err != nil { if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 1 { return false, nil } return false, err } return true, nil } func runGitOutput(args ...string) (string, error) { cmd := exec.Command("git", args...) cmd.Stderr = os.Stderr out, err := cmd.Output() if err != nil { return "", err } return string(out), nil } func dirExists(path string) bool { info, err := os.Stat(path) if err != nil { return false } return info.IsDir() } func defaultAgentKeyPaths() []string { return []string{"AGENTS.md", ".agent-prompts", "opencode.json"} } func bootstrapStateDirPath(workspaceRoot string) string { return filepath.Join(workspaceRoot, ".bootstrap") } func bootstrapGitReadyPath(workspaceRoot string) string { return filepath.Join(bootstrapStateDirPath(workspaceRoot), "git-ready") } func bootstrapRepoReadyPath(workspaceRoot string) string { return filepath.Join(bootstrapStateDirPath(workspaceRoot), "repo-ready") } func bootstrapFailedPath(workspaceRoot string) string { return filepath.Join(bootstrapStateDirPath(workspaceRoot), "failed") } func bootstrapPIDPath(workspaceRoot string) string { return filepath.Join(bootstrapStateDirPath(workspaceRoot), "bootstrap.pid") } func bootstrapLogPath(workspaceRoot string) string { return filepath.Join(workspaceRoot, "logs", "bootstrap.log") } func pathExists(path string) bool { _, err := os.Lstat(path) return err == nil } func repoRootFromWorkspaceRoot(workspaceRoot string) string { clean := filepath.Clean(strings.TrimSpace(workspaceRoot)) needle := string(filepath.Separator) + ".agents" + string(filepath.Separator) if idx := strings.Index(clean, needle); idx >= 0 { return clean[:idx] } return "" } func loadAgentRecordByWorkspaceRoot(workspaceRoot string) *agentRecord { reg, err := loadRegistry() if err != nil { return nil } workspaceRoot = filepath.Clean(strings.TrimSpace(workspaceRoot)) for _, record := range reg.Agents { if record == nil { continue } if filepath.Clean(strings.TrimSpace(record.WorkspaceRoot)) == workspaceRoot { return record } } return nil } func resolveStartSourceBranch(repoRoot string, repoCfg *repoConfig) string { if repoCfg != nil && strings.TrimSpace(repoCfg.BaseBranch) != "" { return strings.TrimSpace(repoCfg.BaseBranch) } if branch := currentLocalBranch(repoRoot); branch != "" { return branch } return detectDefaultBaseBranch(repoRoot) } func pullSourceBranch(repoRoot string, repoCfg *repoConfig) error { branch := resolveStartSourceBranch(repoRoot, repoCfg) fetchCmd := exec.Command("git", "fetch", "origin") fetchCmd.Dir = repoRoot fetchCmd.Stdout = os.Stderr fetchCmd.Stderr = os.Stderr if err := fetchCmd.Run(); err != nil { return fmt.Errorf("git fetch origin: %w", err) } if remoteExists(repoRoot, "origin/"+branch) { mergeCmd := exec.Command("git", "merge", "--ff-only", "origin/"+branch) mergeCmd.Dir = repoRoot mergeCmd.Stdout = os.Stderr mergeCmd.Stderr = os.Stderr if err := mergeCmd.Run(); err != nil { return fmt.Errorf("git merge --ff-only origin/%s: %w", branch, err) } } return nil } func resolveDefaultDevice(repoCfg *repoConfig) string { if repoCfg != nil && strings.TrimSpace(repoCfg.DefaultDevice) != "" { d := strings.TrimSpace(repoCfg.DefaultDevice) if strings.EqualFold(d, "none") { return "" } return d } return defaultManagedDeviceID } func resolveBootstrapStartOptions(repoRoot string, repoCfg *repoConfig, record *agentRecord) agentStartOptions { options := agentStartOptions{} if record != nil { options.SourceBranch = strings.TrimSpace(record.SourceBranch) options.KeepWorktree = record.KeepWorktree options.Pull = record.Pull } if options.SourceBranch == "" { if repoCfg != nil && strings.TrimSpace(repoCfg.BaseBranch) != "" { options.SourceBranch = strings.TrimSpace(repoCfg.BaseBranch) } else { options.SourceBranch = detectDefaultBaseBranch(repoRoot) } } return options } func prepareAgentContext(repoRoot, repoCopyPath string, keyPaths []string, ignoreExisting bool) error { if err := os.MkdirAll(repoCopyPath, 0o755); err != nil { return err } return copySelectedRepoPaths(repoRoot, repoCopyPath, keyPaths, ignoreExisting) } func copySelectedRepoPaths(srcRoot, destRoot string, paths []string, ignoreExisting bool) error { for _, relPath := range normalizeIgnoreValues(paths) { relPath = filepath.Clean(filepath.FromSlash(relPath)) if relPath == "." || strings.HasPrefix(relPath, ".."+string(filepath.Separator)) { continue } if !pathExists(filepath.Join(srcRoot, relPath)) { continue } args := []string{"-a", "--relative"} if ignoreExisting { args = append(args, "--ignore-existing") } args = append(args, filepath.ToSlash("."+string(filepath.Separator)+relPath), destRoot+"/") cmd := exec.Command("rsync", args...) cmd.Dir = srcRoot output, err := cmd.CombinedOutput() if err != nil { message := strings.TrimSpace(string(output)) if message == "" { return err } return fmt.Errorf("copy path %s: %w: %s", relPath, err, message) } } return nil } func pathDepth(path string) int { clean := filepath.Clean(path) if clean == "." || clean == string(filepath.Separator) { return 0 } return len(strings.Split(clean, string(filepath.Separator))) } func copyRepoExcludeValues(extraIgnores []string) []string { return append(defaultCopyIgnoreExcludes(), extraIgnores...) } func copyGitMetadata(srcRoot, repoCopyPath string) error { gitPath := filepath.Join(repoCopyPath, ".git") if err := os.RemoveAll(gitPath); err != nil { return err } if err := os.MkdirAll(repoCopyPath, 0o755); err != nil { return err } cmd := exec.Command("rsync", "-a", filepath.Join(srcRoot, ".git")+"/", gitPath+"/") output, err := cmd.CombinedOutput() if err != nil { message := strings.TrimSpace(string(output)) if message == "" { return err } return fmt.Errorf("copy git metadata: %w: %s", err, message) } return nil } func syncRepoWorktree(srcRoot, repoCopyPath string, extraIgnores []string) error { if err := os.MkdirAll(repoCopyPath, 0o755); err != nil { return err } args := []string{"-a", "--delete", "--filter", ":- .gitignore", "--exclude", ".git", "--exclude", ".git/**"} for _, value := range copyRepoExcludeValues(extraIgnores) { value = strings.TrimSpace(value) if value == "" { continue } args = append(args, "--exclude", value) } args = append(args, srcRoot+"/", repoCopyPath+"/") cmd := exec.Command("rsync", args...) output, err := cmd.CombinedOutput() if err != nil { message := strings.TrimSpace(string(output)) if message == "" { return err } return fmt.Errorf("sync repo worktree: %w: %s", err, message) } return nil } func applyRepoCopyIgnores(sourceRepoRoot, repoCopyPath string, extraIgnores []string) error { relPaths, err := repoCopyIgnoredPaths(sourceRepoRoot, repoCopyPath, extraIgnores) if err != nil { return err } if len(relPaths) == 0 { return nil } trackedPaths, err := trackedPathsForRepoCopy(repoCopyPath, relPaths) if err != nil { return err } if err := markGitPathsSkipWorktree(repoCopyPath, trackedPaths); err != nil { return err } for _, relPath := range relPaths { if err := os.RemoveAll(filepath.Join(repoCopyPath, relPath)); err != nil { return err } } return nil } func repoCopyIgnoredPaths(sourceRepoRoot, repoCopyPath string, extraIgnores []string) ([]string, error) { args := []string{"-a", "-n", "--delete", "--delete-excluded", "--itemize-changes", "--exclude", ".git"} for _, value := range copyRepoExcludeValues(extraIgnores) { value = strings.TrimSpace(value) if value == "" { continue } args = append(args, "--exclude", value) } args = append(args, sourceRepoRoot+"/", repoCopyPath+"/") cmd := exec.Command("rsync", args...) output, err := cmd.CombinedOutput() if err != nil { message := strings.TrimSpace(string(output)) if message == "" { return nil, err } return nil, fmt.Errorf("compute repo copy ignores: %w: %s", err, message) } seen := map[string]bool{} relPaths := make([]string, 0) for _, line := range strings.Split(string(output), "\n") { line = strings.TrimSpace(line) if !strings.HasPrefix(line, "*deleting ") { continue } relPath := strings.TrimSpace(strings.TrimPrefix(line, "*deleting ")) relPath = strings.TrimSuffix(relPath, "/") if relPath == "" { continue } relPath = filepath.Clean(filepath.FromSlash(relPath)) if relPath == "." || relPath == ".git" || strings.HasPrefix(relPath, ".git"+string(filepath.Separator)) || strings.HasPrefix(relPath, ".."+string(filepath.Separator)) { continue } if seen[relPath] { continue } seen[relPath] = true relPaths = append(relPaths, relPath) } sort.Slice(relPaths, func(i, j int) bool { return pathDepth(relPaths[i]) > pathDepth(relPaths[j]) }) return relPaths, nil } func trackedPathsForRepoCopy(repoCopyPath string, relPaths []string) ([]string, error) { seen := map[string]bool{} trackedPaths := make([]string, 0) for _, relPath := range relPaths { cmd := exec.Command("git", "ls-files", "-z", "--", relPath) cmd.Dir = repoCopyPath output, err := cmd.Output() if err != nil { return nil, err } for _, trackedPath := range strings.Split(string(output), "\x00") { trackedPath = strings.TrimSpace(trackedPath) if trackedPath == "" || seen[trackedPath] { continue } seen[trackedPath] = true trackedPaths = append(trackedPaths, trackedPath) } } sort.Strings(trackedPaths) return trackedPaths, nil } func markGitPathsSkipWorktree(repoCopyPath string, trackedPaths []string) error { if len(trackedPaths) == 0 { return nil } input := strings.Join(trackedPaths, "\x00") + "\x00" cmd := exec.Command("git", "update-index", "--skip-worktree", "-z", "--stdin") cmd.Dir = repoCopyPath cmd.Stdin = strings.NewReader(input) output, err := cmd.CombinedOutput() if err != nil { message := strings.TrimSpace(string(output)) if message == "" { return err } return fmt.Errorf("mark skip-worktree: %w: %s", err, message) } return nil } func removeLegacyRuntimeProject(workspaceRoot string) error { return os.RemoveAll(filepath.Join(workspaceRoot, "runtime")) } func createFeatureBranch(repoCopyPath, branch, sourceBranch string, preservePaths []string) (string, error) { sourceBranch = strings.TrimSpace(sourceBranch) if sourceBranch == "" { sourceBranch = detectDefaultBaseBranch(repoCopyPath) } resetHeadCmd := exec.Command("git", "reset", "--hard", "HEAD") resetHeadCmd.Dir = repoCopyPath resetHeadCmd.Stdout = io.Discard resetHeadCmd.Stderr = io.Discard if err := resetHeadCmd.Run(); err != nil { return "", err } cleanArgs := []string{"clean", "-fdx"} for _, preservePath := range normalizeIgnoreValues(preservePaths) { preservePath = filepath.Clean(filepath.FromSlash(preservePath)) if preservePath == "." || strings.HasPrefix(preservePath, ".."+string(filepath.Separator)) { continue } cleanArgs = append(cleanArgs, "-e", preservePath) if dirExists(filepath.Join(repoCopyPath, preservePath)) { cleanArgs = append(cleanArgs, "-e", filepath.ToSlash(preservePath)+"/**") } } cleanCmd := exec.Command("git", cleanArgs...) cleanCmd.Dir = repoCopyPath cleanCmd.Stdout = io.Discard cleanCmd.Stderr = io.Discard if err := cleanCmd.Run(); err != nil { return "", err } fetchCmd := exec.Command("git", "remote", "update", "-p") fetchCmd.Dir = repoCopyPath _ = fetchCmd.Run() if remoteExists(repoCopyPath, "origin/"+sourceBranch) { cmd := exec.Command("git", "checkout", "-B", sourceBranch, "origin/"+sourceBranch) cmd.Dir = repoCopyPath cmd.Stdout = io.Discard cmd.Stderr = io.Discard if err := cmd.Run(); err == nil { resetCmd := exec.Command("git", "reset", "--hard", "origin/"+sourceBranch) resetCmd.Dir = repoCopyPath resetCmd.Stdout = io.Discard resetCmd.Stderr = io.Discard if err := resetCmd.Run(); err != nil { return "", err } } else { return "", err } } else if localExists(repoCopyPath, sourceBranch) { cmd := exec.Command("git", "checkout", "-B", sourceBranch) cmd.Dir = repoCopyPath cmd.Stdout = io.Discard cmd.Stderr = io.Discard if err := cmd.Run(); err != nil { return "", err } } else { cmd := exec.Command("git", "checkout", "-B", sourceBranch) cmd.Dir = repoCopyPath cmd.Stdout = io.Discard cmd.Stderr = io.Discard if err := cmd.Run(); err != nil { return "", err } } cmd := exec.Command("git", "checkout", "-B", branch, sourceBranch) cmd.Dir = repoCopyPath cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr if err := cmd.Run(); err != nil { return "", err } return branch, nil } func loadRepoConfig(repoRoot string) (*repoConfig, error) { path := repoConfigPath(repoRoot) data, err := os.ReadFile(path) if errors.Is(err, os.ErrNotExist) { return nil, fmt.Errorf("missing %s; run `agent init` first", path) } if err != nil { return nil, err } cfg := defaultRepoConfig() if err := yaml.Unmarshal(data, cfg); err != nil { return nil, err } normalizeRepoConfig(cfg) return cfg, nil } func loadRepoConfigOrDefault(repoRoot string) (*repoConfig, error) { path := repoConfigPath(repoRoot) data, err := os.ReadFile(path) if errors.Is(err, os.ErrNotExist) { return defaultRepoConfig(), nil } if err != nil { return nil, err } cfg := defaultRepoConfig() if err := yaml.Unmarshal(data, cfg); err != nil { return nil, err } normalizeRepoConfig(cfg) return cfg, nil } func repoConfigPath(repoRoot string) string { return filepath.Join(repoRoot, ".agent.yaml") } func defaultRepoConfig() *repoConfig { return &repoConfig{ CopyIgnore: []string{"build", ".dart_tool"}, AgentKeyPaths: defaultAgentKeyPaths(), } } func defaultCopyIgnoreExcludes() []string { return []string{".agents", ".DS_Store"} } func normalizeRepoConfig(cfg *repoConfig) { if cfg == nil { return } cfg.BaseBranch = strings.TrimSpace(cfg.BaseBranch) cfg.CopyIgnore = normalizeIgnoreValues(cfg.CopyIgnore) cfg.AgentKeyPaths = normalizeIgnoreValues(cfg.AgentKeyPaths) } func normalizeIgnoreValues(values []string) []string { result := make([]string, 0, len(values)) seen := map[string]bool{} for _, value := range values { value = strings.TrimSpace(value) value = strings.Trim(value, ",") if value == "" || seen[value] { continue } seen[value] = true result = append(result, value) } return result } func containsString(values []string, target string) bool { target = strings.TrimSpace(target) for _, value := range values { if strings.TrimSpace(value) == target { return true } } return false } func saveRepoConfig(repoRoot string, cfg *repoConfig) error { normalizeRepoConfig(cfg) data, err := yaml.Marshal(cfg) if err != nil { return err } path := repoConfigPath(repoRoot) return os.WriteFile(path, data, 0o644) } func loadFeatureConfig(path string) (*featureConfig, error) { data, err := os.ReadFile(path) if err != nil { return nil, err } var raw map[string]json.RawMessage if err := json.Unmarshal(data, &raw); err != nil { return nil, err } var cfg featureConfig if err := json.Unmarshal(data, &cfg); err != nil { return nil, err } if cfg.IsFlutter { if _, ok := raw["device"]; !ok { cfg.Device = defaultManagedDeviceID } } return &cfg, nil } func saveFeatureConfig(path string, cfg featureConfig) error { data, err := json.MarshalIndent(cfg, "", " ") if err != nil { return err } data = append(data, '\n') tmpPath := fmt.Sprintf("%s.tmp-%d", path, os.Getpid()) if err := os.WriteFile(tmpPath, data, 0o644); err != nil { return err } return os.Rename(tmpPath, path) } func updateFeatureConfig(path string, update func(*featureConfig) error) error { lockPath := path + ".lock" lockFile, err := os.OpenFile(lockPath, os.O_CREATE|os.O_RDWR, 0o644) if err != nil { return err } defer lockFile.Close() if err := syscall.Flock(int(lockFile.Fd()), syscall.LOCK_EX); err != nil { return err } defer func() { _ = syscall.Flock(int(lockFile.Fd()), syscall.LOCK_UN) }() cfg, err := loadFeatureConfig(path) if err != nil { return err } if err := update(cfg); err != nil { return err } return saveFeatureConfig(path, *cfg) } func configureFlutterWebConfig(repoRoot, repoCopyPath string, port int) error { if port <= 0 { return nil } templatePath := filepath.Join(repoRoot, "agents", "web_dev_config.template.yaml") configPath := filepath.Join(repoCopyPath, "web_dev_config.yaml") if fileExists(templatePath) && !fileExists(configPath) { data, err := os.ReadFile(templatePath) if err == nil { if err := os.WriteFile(configPath, data, 0o644); err != nil { return err } } } content := fmt.Sprintf("server:\n host: \"localhost\"\n port: %d\n headers:\n - name: \"Cache-Control\"\n value: \"no-cache, no-store, must-revalidate\"\n", port) return os.WriteFile(configPath, []byte(content), 0o644) } func writeFlutterHelperScripts(workspaceRoot, repoCopyPath, url, device string) error { if err := os.MkdirAll(filepath.Join(workspaceRoot, "logs"), 0o755); err != nil { return err } ensureServer := `#!/usr/bin/env bash set -euo pipefail DIR="$(cd "$(dirname "$0")" && pwd)" INFO="$DIR/agent.json" AGENT_BIN="${AGENT_BIN:-$HOME/.config/agent-tracker/bin/agent}" new_device="${1-}" if [[ -n "$new_device" ]]; then "$AGENT_BIN" feature --workspace "$DIR" --device "$new_device" fi port=$(python3 - "$INFO" <<'PY' import json, pathlib, sys data = json.loads(pathlib.Path(sys.argv[1]).read_text()) print(data.get('port', '')) PY ) device=$(python3 - "$INFO" <<'PY' import json, pathlib, sys data = json.loads(pathlib.Path(sys.argv[1]).read_text()) value = data.get('device') if value is None: value = 'web-server' print(value) PY ) logfile="$DIR/logs/flutter-$port.log" : > "$logfile" 2>/dev/null || true if [[ -z "$device" ]]; then echo "No launch device selected. Run ./ensure-server.sh to start Flutter." exit 0 fi if [[ "$device" == "web-server" ]]; then ( "$AGENT_BIN" browser refresh --workspace "$DIR" >/dev/null 2>&1 || true deadline=$((SECONDS+300)) while [ $SECONDS -lt $deadline ]; do if [[ -f "$logfile" ]] && grep -qi 'Flutter run key commands\.' "$logfile" 2>/dev/null; then "$AGENT_BIN" feature --workspace "$DIR" --ready true sleep 2 "$AGENT_BIN" browser refresh --workspace "$DIR" >/dev/null 2>&1 || true exit 0 fi sleep 0.1 done ) & fi cd "$DIR" exec script -qF "$logfile" bash -lc "cd \"$DIR/repo\" && exec flutter run -d \"$device\"" ` ensurePath := filepath.Join(workspaceRoot, "ensure-server.sh") if err := os.WriteFile(ensurePath, []byte(ensureServer), 0o755); err != nil { return err } for _, obsolete := range []string{"open-tab.sh", "refresh-tab.sh", "on-tmux-window-activate.sh"} { _ = os.Remove(filepath.Join(workspaceRoot, obsolete)) } _ = url _ = device return nil } func detectDefaultBaseBranch(repoRoot string) string { for _, candidate := range []string{"develop", "main", "master"} { if remoteExists(repoRoot, "origin/"+candidate) || localExists(repoRoot, candidate) { return candidate } } return "main" } func currentLocalBranch(repoRoot string) string { cmd := exec.Command("git", "symbolic-ref", "--quiet", "--short", "HEAD") cmd.Dir = repoRoot out, err := cmd.Output() if err != nil { return "" } branch := strings.TrimSpace(string(out)) if branch == "" || branch == "HEAD" { return "" } return branch } func remoteExists(repoRoot, ref string) bool { cmd := exec.Command("git", "show-ref", "--verify", "--quiet", "refs/remotes/"+ref) cmd.Dir = repoRoot return cmd.Run() == nil } func localExists(repoRoot, ref string) bool { cmd := exec.Command("git", "show-ref", "--verify", "--quiet", "refs/heads/"+ref) cmd.Dir = repoRoot return cmd.Run() == nil } func allocatePort(repoRoot string, start int) (int, error) { for port := start; port < start+500; port++ { if !portClaimedByRegistry(port) && !portClaimedByFeatureConfigs(repoRoot, port) && portFree(port) { return port, nil } } return 0, fmt.Errorf("failed to allocate port") } func portClaimedByFeatureConfigs(repoRoot string, port int) bool { paths, err := filepath.Glob(filepath.Join(repoRoot, ".agents", "*", "agent.json")) if err != nil { return false } for _, path := range paths { cfg, err := loadFeatureConfig(path) if err == nil && cfg.Port == port { return true } } return false } func portClaimedByRegistry(port int) bool { reg, err := loadRegistry() if err != nil { return false } for _, record := range reg.Agents { if record.Port == port { return true } } return false } func portFree(port int) bool { cmd := exec.Command("lsof", "-PiTCP:"+strconv.Itoa(port), "-sTCP:LISTEN", "-n") if err := cmd.Run(); err == nil { return false } return true } func loadRegistry() (*registry, error) { path := registryPath() reg := ®istry{Agents: map[string]*agentRecord{}} data, err := os.ReadFile(path) if errors.Is(err, os.ErrNotExist) { return reg, nil } if err != nil { return nil, err } if err := json.Unmarshal(data, reg); err != nil { fallback := ®istry{Agents: map[string]*agentRecord{}} dec := json.NewDecoder(strings.NewReader(string(data))) if decodeErr := dec.Decode(fallback); decodeErr != nil { return nil, err } reg = fallback _ = saveRegistry(reg) } if reg.Agents == nil { reg.Agents = map[string]*agentRecord{} } return reg, nil } func saveRegistry(reg *registry) error { path := registryPath() if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { return err } data, err := json.MarshalIndent(reg, "", " ") if err != nil { return err } tmp := path + ".tmp" if err := os.WriteFile(tmp, data, 0o644); err != nil { return err } return os.Rename(tmp, path) } func registryPath() string { return filepath.Join(os.Getenv("HOME"), ".config", "agent-tracker", "run", "agents.json") } func configPath() string { return filepath.Join(os.Getenv("HOME"), ".config", "agent-tracker", "agent-config.json") } func loadAppConfig() appConfig { cfg := appConfig{Keys: keyConfig{ MoveLeft: "n", MoveRight: "i", MoveUp: "u", MoveDown: "e", Edit: "Enter", Cancel: "Escape", AddTodo: "a", ToggleTodo: "x", Destroy: "D", Confirm: "y", Back: "Escape", DeleteTodo: "d", Help: "?", FocusAI: "M-a", FocusGit: "M-g", FocusRun: "M-r", }} data, err := os.ReadFile(configPath()) if err != nil { return cfg } _ = json.Unmarshal(data, &cfg) return cfg } func sortedAgentIDs(reg *registry) []string { ids := make([]string, 0, len(reg.Agents)) for id := range reg.Agents { ids = append(ids, id) } sort.Strings(ids) return ids } func promptInput(prompt string) (string, error) { fmt.Print(prompt) reader := bufio.NewReader(os.Stdin) text, err := reader.ReadString('\n') if err != nil { return "", err } return strings.TrimSpace(text), nil } func promptInputWithDefault(label, defaultValue string) (string, error) { if strings.TrimSpace(defaultValue) == "" { return promptInput(label + ": ") } value, err := promptInput(fmt.Sprintf("%s [%s]: ", label, defaultValue)) if err != nil { if strings.TrimSpace(value) == "" { return defaultValue, nil } return "", err } if strings.TrimSpace(value) == "" { return defaultValue, nil } return value, nil } func sanitizeFeatureName(value string) string { value = strings.ToLower(strings.TrimSpace(value)) value = strings.ReplaceAll(value, " ", "-") value = featureNamePattern.ReplaceAllString(value, "-") value = strings.Trim(value, "-._") return value } func shellQuote(value string) string { return "'" + strings.ReplaceAll(value, "'", "'\"'\"'") + "'" } func fileExists(path string) bool { _, err := os.Stat(path) return err == nil } func runTmux(args ...string) error { cmd := exec.Command("tmux", args...) cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr return cmd.Run() } func runTmuxOutput(args ...string) (string, error) { cmd := exec.Command("tmux", args...) cmd.Stderr = os.Stderr out, err := cmd.Output() if err != nil { return "", err } return string(out), nil } func syncChromeForFeature(featurePath string, allowOpen bool) error { cfg, err := loadFeatureConfig(featurePath) if err != nil { return err } if strings.TrimSpace(cfg.URL) == "" || strings.TrimSpace(cfg.Device) != "web-server" { return nil } version, err := ensureChromeForTestingRunning(cfg.URL) if err != nil { return err } match, err := browserTargetForURL(cfg.URL) if err != nil { return err } if match != nil { _, err := browserActivateExistingTabByURL(cfg.URL) if err == nil { _ = browserEnsureConsoleShim(match.WebSocketDebuggerURL) } return err } if !allowOpen { return nil } if _, err := browserCreateTarget(version.WebSocketDebuggerURL, cfg.URL, true); err != nil { return err } if err := browserActivateLastTab(); err != nil { return err } if target, err := browserTargetForURL(cfg.URL); err == nil && target != nil { _ = browserEnsureConsoleShim(target.WebSocketDebuggerURL) } return nil } func refreshChromeForFeature(featurePath string) error { cfg, target, err := browserFeatureTarget(featurePath) if err != nil { return err } if cfg == nil || target == nil { return nil } return browserPageReload(target.WebSocketDebuggerURL) } func closeBrowserForFeature(featurePath string) error { cfg, err := loadFeatureConfig(featurePath) if err != nil { return err } if strings.TrimSpace(cfg.URL) == "" || strings.TrimSpace(cfg.Device) != "web-server" { return nil } _, err = browserCloseTabsByURL(cfg.URL) return err } func captureBrowserScreenshot(featurePath, outPath string) (string, error) { result, err := browserActionScreenshot(featurePath, browserScreenshotOptions{OutPath: outPath}) if err != nil { return "", err } return result.Path, nil } func streamBrowserLogs(featurePath string, duration time.Duration, writer io.Writer) error { _, target, err := browserFeatureTarget(featurePath) if err != nil { return err } if target == nil { return fmt.Errorf("browser tab not found for feature") } if _, err := fmt.Fprintf(writer, "Listening to %s\n%s\n", firstNonEmpty(strings.TrimSpace(target.Title), "(untitled)"), strings.TrimSpace(target.URL)); err != nil { return err } return browserListenLogs(target.WebSocketDebuggerURL, duration, writer) } func ensureChromeForTestingAvailable() (string, error) { if runtime.GOOS != "darwin" { return "", fmt.Errorf("Chrome for Testing browser control currently supports macOS only") } home, err := os.UserHomeDir() if err != nil { return "", err } candidates := []string{} defaultApp := "/Applications/Google Chrome for Testing.app" if dirExists(defaultApp) { candidates = append(candidates, defaultApp) } matches, err := filepath.Glob(filepath.Join(home, "Library", "Caches", "ms-playwright", "chromium-*", "chrome-mac-arm64", "Google Chrome for Testing.app")) if err == nil { candidates = append(candidates, matches...) } sort.Strings(candidates) for i := len(candidates) - 1; i >= 0; i-- { if dirExists(candidates[i]) { return candidates[i], nil } } return "", fmt.Errorf("Chrome for Testing not found; install it with `npx playwright install chromium`") } func browserFeatureTarget(featurePath string) (*featureConfig, *browserTarget, error) { cfg, err := loadFeatureConfig(featurePath) if err != nil { return nil, nil, err } if strings.TrimSpace(cfg.URL) == "" || strings.TrimSpace(cfg.Device) != "web-server" { return cfg, nil, nil } if _, err := browserCDPVersion(); err != nil { return cfg, nil, nil } target, err := browserTargetForURL(cfg.URL) if err != nil { return nil, nil, err } return cfg, target, nil } func agentBrowserPort() int { for _, envName := range []string{"AGENT_BROWSER_PORT", "AGENT_BROWSER_TEST_PORT"} { value := strings.TrimSpace(os.Getenv(envName)) if value == "" { continue } port, err := strconv.Atoi(value) if err == nil && port > 0 { return port } } return 9224 } func agentBrowserBaseURL() string { return fmt.Sprintf("http://127.0.0.1:%d", agentBrowserPort()) } func agentBrowserProfileDir() string { home, err := os.UserHomeDir() if err != nil { return filepath.Join(os.TempDir(), "agent-tracker-browser-profile") } return filepath.Join(home, ".config", "agent-tracker", "run", "chrome-for-testing-profile") } func browserCDPVersion() (browserCDPVersionInfo, error) { resp, err := http.Get(agentBrowserBaseURL() + "/json/version") if err != nil { return browserCDPVersionInfo{}, err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return browserCDPVersionInfo{}, fmt.Errorf("browser version request failed: %s", resp.Status) } var version browserCDPVersionInfo if err := json.NewDecoder(resp.Body).Decode(&version); err != nil { return browserCDPVersionInfo{}, err } if strings.TrimSpace(version.WebSocketDebuggerURL) == "" { return browserCDPVersionInfo{}, fmt.Errorf("browser websocket debugger URL missing") } return version, nil } func ensureChromeForTestingRunning(initialURL string) (browserCDPVersionInfo, error) { if version, err := browserCDPVersion(); err == nil { return version, nil } appPath, err := ensureChromeForTestingAvailable() if err != nil { return browserCDPVersionInfo{}, err } if err := os.MkdirAll(agentBrowserProfileDir(), 0o755); err != nil { return browserCDPVersionInfo{}, err } sanitizeChromeForTestingProfile() launchURL := "about:blank" cmd := exec.Command( "open", "-g", "-a", appPath, "--args", "--user-data-dir="+agentBrowserProfileDir(), "--remote-debugging-port="+strconv.Itoa(agentBrowserPort()), "--no-first-run", "--disable-default-apps", "--disable-infobars", "--hide-crash-restore-bubble", "--disable-session-crashed-bubble", "--new-window", launchURL, ) out, err := cmd.CombinedOutput() if err != nil { message := strings.TrimSpace(string(out)) if message == "" { message = err.Error() } return browserCDPVersionInfo{}, fmt.Errorf("failed to launch Chrome for Testing: %s", message) } deadline := time.Now().Add(8 * time.Second) for time.Now().Before(deadline) { if version, err := browserCDPVersion(); err == nil { return version, nil } time.Sleep(50 * time.Millisecond) } return browserCDPVersionInfo{}, fmt.Errorf("Chrome for Testing did not expose CDP on port %d", agentBrowserPort()) } func sanitizeChromeForTestingProfile() { for _, path := range []string{ filepath.Join(agentBrowserProfileDir(), "Default", "Current Session"), filepath.Join(agentBrowserProfileDir(), "Default", "Current Tabs"), filepath.Join(agentBrowserProfileDir(), "Default", "Last Session"), filepath.Join(agentBrowserProfileDir(), "Default", "Last Tabs"), } { _ = os.Remove(path) } sessionDir := filepath.Join(agentBrowserProfileDir(), "Default", "Sessions") if entries, err := os.ReadDir(sessionDir); err == nil { for _, entry := range entries { _ = os.Remove(filepath.Join(sessionDir, entry.Name())) } } for _, path := range []string{ filepath.Join(agentBrowserProfileDir(), "Default", "Preferences"), filepath.Join(agentBrowserProfileDir(), "Local State"), } { data, err := os.ReadFile(path) if err != nil { continue } var root map[string]any if err := json.Unmarshal(data, &root); err != nil { continue } profile, ok := root["profile"].(map[string]any) if !ok { continue } changed := false if profile["exit_type"] != "Normal" { profile["exit_type"] = "Normal" changed = true } if profile["exited_cleanly"] != true { profile["exited_cleanly"] = true changed = true } if !changed { continue } encoded, err := json.Marshal(root) if err != nil { continue } _ = os.WriteFile(path, append(encoded, '\n'), 0o644) } } func browserTargets() ([]browserTarget, error) { resp, err := http.Get(agentBrowserBaseURL() + "/json/list") if err != nil { return nil, err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("browser target list request failed: %s", resp.Status) } var targets []browserTarget if err := json.NewDecoder(resp.Body).Decode(&targets); err != nil { return nil, err } return targets, nil } func browserTargetForURL(targetURL string) (*browserTarget, error) { targetURL = strings.TrimSpace(targetURL) if targetURL == "" { return nil, nil } targets, err := browserTargets() if err != nil { return nil, err } for _, target := range targets { if target.Type != "page" || strings.TrimSpace(target.ParentID) != "" { continue } if strings.HasPrefix(strings.TrimSpace(target.URL), targetURL) { copy := target return ©, nil } } return nil, nil } func browserActivePageURL() (string, error) { output, err := runAppleScript(`tell application "Google Chrome for Testing" if (count of windows) is 0 then return "" tell window 1 if (count of tabs) is 0 then return "" return URL of active tab end tell end tell`) if err != nil { return "", err } return strings.TrimSpace(output), nil } func browserActivateExistingTabByURL(targetURL string) (bool, error) { targetURL = strings.TrimSpace(targetURL) if targetURL == "" { return false, nil } activeURL, err := browserActivePageURL() if err != nil { return false, err } if strings.HasPrefix(activeURL, targetURL) { return true, nil } matched, err := browserActivateTabFromLiveChromeTabs(targetURL) if err != nil || !matched { return matched, err } return browserWaitForActivePageURL(targetURL) } func browserActivateTabFromLiveChromeTabs(targetURL string) (bool, error) { tabURLs, err := browserChromeTabURLs() if err != nil { return false, err } if len(tabURLs) == 0 { return false, nil } for i, tabURL := range tabURLs { if !browserURLsMatch(tabURL, targetURL) { continue } return true, browserSetChromeActiveTabIndex(i + 1) } return false, nil } func browserChromeTabURLs() ([]string, error) { output, err := runAppleScript(`tell application "Google Chrome for Testing" if (count of windows) is 0 then return "" tell window 1 set tabURLs to {} repeat with tabIndex from 1 to (count of tabs) set end of tabURLs to URL of tab tabIndex end repeat end tell end tell set AppleScript's text item delimiters to linefeed return tabURLs as text`) if err != nil { return nil, err } output = strings.TrimRight(output, "\r\n") if strings.TrimSpace(output) == "" { return nil, nil } urls := []string{} for _, line := range strings.Split(output, "\n") { urls = append(urls, strings.TrimSpace(strings.TrimRight(line, "\r"))) } return urls, nil } func browserSetChromeActiveTabIndex(index int) error { if index <= 0 { return nil } _, err := runAppleScript(`on run argv set tabIndex to (item 1 of argv) as integer tell application "Google Chrome for Testing" if (count of windows) is 0 then error "Google Chrome for Testing has no windows" tell window 1 if tabIndex > (count of tabs) then error "Chrome tab index is out of range" set active tab index to tabIndex end tell end tell end run`, strconv.Itoa(index)) return err } func browserURLsMatch(a, b string) bool { a = strings.TrimSpace(a) b = strings.TrimSpace(b) if a == "" || b == "" { return false } if browserIsBlankTabURL(a) && browserIsBlankTabURL(b) { return true } return strings.HasPrefix(a, b) || strings.HasPrefix(b, a) } func browserIsBlankTabURL(url string) bool { url = strings.TrimSpace(url) return url == "about:blank" || url == "chrome://newtab/" } func browserWaitForActivePageURL(targetURL string) (bool, error) { deadline := time.Now().Add(2 * time.Second) for { activeURL, err := browserActivePageURL() if err != nil { return false, err } if strings.HasPrefix(activeURL, targetURL) { return true, nil } if time.Now().After(deadline) { return false, nil } time.Sleep(50 * time.Millisecond) } } func browserActivateLastTab() error { _, err := runAppleScript(`tell application "Google Chrome for Testing" if (count of windows) is 0 then error "Google Chrome for Testing has no windows" tell window 1 set active tab index to (count of tabs) end tell end tell`) return err } func browserCloseTabsByURL(targetURL string) (int, error) { targetURL = strings.TrimSpace(targetURL) if targetURL == "" { return 0, nil } targets, err := browserTargets() if err != nil { return 0, err } closed := 0 for _, target := range targets { if target.Type != "page" || strings.TrimSpace(target.ParentID) != "" { continue } if strings.HasPrefix(strings.TrimSpace(target.URL), targetURL) { if err := browserCloseTarget(target.ID); err != nil { return closed, err } closed++ } } return closed, nil } func browserCDPRequest(wsURL, method string, params any, result any) error { conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil) if err != nil { return err } defer conn.Close() if err := conn.SetReadDeadline(time.Now().Add(5 * time.Second)); err != nil { return err } if err := conn.WriteJSON(map[string]any{"id": 1, "method": method, "params": params}); err != nil { return err } for { var envelope browserCDPEnvelope if err := conn.ReadJSON(&envelope); err != nil { if netErr, ok := err.(net.Error); ok && netErr.Timeout() { return fmt.Errorf("browser CDP request timed out") } return err } if envelope.ID != 1 { continue } if envelope.Error != nil { return errors.New(firstNonEmpty(strings.TrimSpace(envelope.Error.Message), "browser CDP request failed")) } if result != nil && len(envelope.Result) > 0 { if err := json.Unmarshal(envelope.Result, result); err != nil { return err } } return nil } } func browserCreateTarget(browserWSURL, targetURL string, background bool) (string, error) { var result struct { TargetID string `json:"targetId"` } if err := browserCDPRequest(browserWSURL, "Target.createTarget", map[string]any{ "url": targetURL, "background": background, }, &result); err != nil { return "", err } return strings.TrimSpace(result.TargetID), nil } func browserCloseTarget(targetID string) error { targetID = strings.TrimSpace(targetID) if targetID == "" { return nil } version, err := browserCDPVersion() if err != nil { return err } return browserCDPRequest(version.WebSocketDebuggerURL, "Target.closeTarget", map[string]any{"targetId": targetID}, nil) } func browserPageReload(pageWSURL string) error { return browserCDPRequest(pageWSURL, "Page.reload", map[string]any{}, nil) } func browserCaptureScreenshot(pageWSURL string) ([]byte, error) { var result struct { Data string `json:"data"` } if err := browserCDPRequest(pageWSURL, "Page.captureScreenshot", map[string]any{ "format": "png", "captureBeyondViewport": true, }, &result); err != nil { return nil, err } if strings.TrimSpace(result.Data) == "" { return nil, fmt.Errorf("browser returned an empty screenshot") } return base64.StdEncoding.DecodeString(result.Data) } func writeCompressedScreenshot(rawPNG []byte, outPath string) error { if strings.TrimSpace(outPath) == "" { return fmt.Errorf("output path is required") } maxSize := 1400 quality := 60 if value := strings.TrimSpace(os.Getenv("AGENT_BROWSER_SCREENSHOT_MAX")); value != "" { if parsed, err := strconv.Atoi(value); err == nil && parsed > 0 { maxSize = parsed } } if value := strings.TrimSpace(os.Getenv("AGENT_BROWSER_SCREENSHOT_QUALITY")); value != "" { if parsed, err := strconv.Atoi(value); err == nil && parsed > 0 { quality = parsed } } rawFile, err := os.CreateTemp(os.TempDir(), "agent-browser-screenshot-*.png") if err != nil { return err } rawPath := rawFile.Name() defer os.Remove(rawPath) if _, err := rawFile.Write(rawPNG); err != nil { rawFile.Close() return err } if err := rawFile.Close(); err != nil { return err } if err := os.MkdirAll(filepath.Dir(outPath), 0o755); err != nil { return err } cmd := exec.Command("sips", "-s", "format", "jpeg", "-s", "formatOptions", strconv.Itoa(quality), "-Z", strconv.Itoa(maxSize), rawPath, "--out", outPath) if out, err := cmd.CombinedOutput(); err != nil { message := strings.TrimSpace(string(out)) if message == "" { message = err.Error() } return fmt.Errorf("failed to compress screenshot: %s", message) } return nil } func browserListenLogs(pageWSURL string, duration time.Duration, writer io.Writer) error { conn, _, err := websocket.DefaultDialer.Dial(pageWSURL, nil) if err != nil { return err } defer conn.Close() if err := conn.SetReadDeadline(time.Now().Add(duration)); err != nil { return err } if err := conn.WriteJSON(map[string]any{"id": 1, "method": "Runtime.enable", "params": map[string]any{}}); err != nil { return err } if err := conn.WriteJSON(map[string]any{"id": 2, "method": "Log.enable", "params": map[string]any{}}); err != nil { return err } for { var envelope browserCDPEnvelope if err := conn.ReadJSON(&envelope); err != nil { if netErr, ok := err.(net.Error); ok && netErr.Timeout() { return nil } return err } switch envelope.Method { case "Runtime.consoleAPICalled": var params struct { Type string `json:"type"` Args []struct { Type string `json:"type"` Value interface{} `json:"value,omitempty"` Description string `json:"description,omitempty"` } `json:"args"` } if err := json.Unmarshal(envelope.Params, ¶ms); err != nil { continue } if _, err := fmt.Fprintf(writer, "[console.%s] %s\n", firstNonEmpty(strings.TrimSpace(params.Type), "log"), browserConsoleArgsText(params.Args)); err != nil { return err } case "Runtime.exceptionThrown": var params struct { ExceptionDetails struct { Text string `json:"text"` Exception struct { Description string `json:"description,omitempty"` Value interface{} `json:"value,omitempty"` } `json:"exception"` } `json:"exceptionDetails"` } if err := json.Unmarshal(envelope.Params, ¶ms); err != nil { continue } text := firstNonEmpty(strings.TrimSpace(params.ExceptionDetails.Text), strings.TrimSpace(params.ExceptionDetails.Exception.Description), fmt.Sprint(params.ExceptionDetails.Exception.Value)) if _, err := fmt.Fprintf(writer, "[exception] %s\n", text); err != nil { return err } case "Log.entryAdded": var params struct { Entry struct { Level string `json:"level"` Text string `json:"text"` } `json:"entry"` } if err := json.Unmarshal(envelope.Params, ¶ms); err != nil { continue } if _, err := fmt.Fprintf(writer, "[log.%s] %s\n", firstNonEmpty(strings.TrimSpace(params.Entry.Level), "info"), strings.TrimSpace(params.Entry.Text)); err != nil { return err } } } return nil } func browserConsoleArgsText(args []struct { Type string `json:"type"` Value interface{} `json:"value,omitempty"` Description string `json:"description,omitempty"` }) string { parts := make([]string, 0, len(args)) for _, arg := range args { switch { case arg.Value != nil: parts = append(parts, fmt.Sprint(arg.Value)) case strings.TrimSpace(arg.Description) != "": parts = append(parts, strings.TrimSpace(arg.Description)) case strings.TrimSpace(arg.Type) != "": parts = append(parts, "["+strings.TrimSpace(arg.Type)+"]") default: parts = append(parts, "[unknown]") } } return strings.TrimSpace(strings.Join(parts, " ")) } const browserConsoleShimJS = `(function(){if(window.__agentConsoleShimInstalled)return;window.__agentConsoleShimInstalled=true;var b=[];var MAX=500;function add(e){b.push(e);if(b.length>MAX)b=b.slice(-MAX)}['log','warn','error','info','debug','trace'].forEach(function(l){var o=console[l]?console[l].bind(console):function(){};console[l]=function(){var a=Array.prototype.slice.call(arguments);var t=a.map(function(x){try{if(x instanceof Error)return x.stack||(x.name+': '+x.message);if(typeof x==='object')return JSON.stringify(x);return String(x)}catch(e){return String(x)}}).join(' ');add({t:l,m:t,d:Date.now()});o.apply(console,a)}});window.addEventListener('error',function(e){var m=e.message||'Error';if(e.filename)m+=' ('+e.filename+':'+(e.lineno||0)+')';add({t:'error',m:m,d:Date.now()})});window.addEventListener('unhandledrejection',function(e){var r=e.reason;var t;try{if(r instanceof Error)t=r.stack||(r.name+': '+r.message);else if(typeof r==='object')t=JSON.stringify(r);else t=String(r)}catch(ex){t=String(r)}add({t:'error',m:'Unhandled rejection: '+t,d:Date.now()})});Object.defineProperty(window,'__agentConsoleBuffer',{configurable:true,get:function(){return b},set:function(v){b=Array.isArray(v)?v:[]}})})();` type browserConsoleEntry struct { Type string `json:"t"` Msg string `json:"m"` Time int64 `json:"d"` } func browserEnsureConsoleShim(pageWSURL string) error { _ = browserCDPRequest(pageWSURL, "Page.enable", map[string]any{}, nil) _ = browserCDPRequest(pageWSURL, "Page.addScriptToEvaluateOnNewDocument", map[string]any{ "source": browserConsoleShimJS, }, nil) return browserCDPRequest(pageWSURL, "Runtime.evaluate", map[string]any{ "expression": browserConsoleShimJS, "silent": true, }, nil) } func browserReadConsoleBuffer(pageWSURL string) ([]browserConsoleEntry, error) { var result struct { Result struct { Value string `json:"value"` } `json:"result"` } if err := browserCDPRequest(pageWSURL, "Runtime.evaluate", map[string]any{ "expression": "JSON.stringify(window.__agentConsoleBuffer||[])", "returnByValue": true, "silent": true, }, &result); err != nil { return nil, err } var entries []browserConsoleEntry raw := strings.TrimSpace(result.Result.Value) if raw == "" || raw == "[]" { return entries, nil } if err := json.Unmarshal([]byte(raw), &entries); err != nil { return nil, fmt.Errorf("parse console buffer: %w", err) } return entries, nil } func browserClearConsoleBuffer(pageWSURL string) error { return browserCDPRequest(pageWSURL, "Runtime.evaluate", map[string]any{ "expression": "window.__agentConsoleBuffer=[]", "silent": true, }, nil) } func formatConsoleEntries(entries []browserConsoleEntry, maxLines int) string { if len(entries) == 0 { return "" } start := 0 if maxLines > 0 && len(entries) > maxLines { start = len(entries) - maxLines } var lines []string for _, e := range entries[start:] { level := firstNonEmpty(strings.TrimSpace(e.Type), "log") msg := strings.TrimSpace(e.Msg) if msg == "" { continue } lines = append(lines, fmt.Sprintf("[%s] %s", level, msg)) } return strings.Join(lines, "\n") } func browserClearLogsForFeature(featurePath string) error { _, target, err := browserFeatureTarget(featurePath) if err != nil { return err } if target == nil { return nil } if err := browserEnsureConsoleShim(target.WebSocketDebuggerURL); err != nil { return err } return browserClearConsoleBuffer(target.WebSocketDebuggerURL) } func browserReadLogsForFeature(featurePath string, maxLines int) (string, error) { _, target, err := browserFeatureTarget(featurePath) if err != nil { return "", err } if target == nil { return "", nil } if err := browserEnsureConsoleShim(target.WebSocketDebuggerURL); err != nil { return "", err } entries, err := browserReadConsoleBuffer(target.WebSocketDebuggerURL) if err != nil { return "", err } return formatConsoleEntries(entries, maxLines), nil } func runAppleScript(script string, args ...string) (string, error) { cmdArgs := append([]string{"-e", script}, args...) cmd := exec.Command("/usr/bin/osascript", cmdArgs...) out, err := cmd.CombinedOutput() if err != nil { message := strings.TrimSpace(string(out)) if message == "" { return "", err } return "", fmt.Errorf("%w: %s", err, message) } return string(out), nil } func closeChromeTab(url string) error { if strings.TrimSpace(url) == "" { return nil } if _, err := browserCDPVersion(); err != nil { return nil } _, err := browserCloseTabsByURL(url) return err }