From bc27c07387961f957113880af97d8303b789924a Mon Sep 17 00:00:00 2001 From: Tuan Ngo Date: Sun, 28 Jun 2026 17:28:57 +0700 Subject: [PATCH 01/25] feat: Extend sloop check with manifest-sourced readiness criteria MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deepen the readiness checklist via data, not Go: a `readiness:` block in each adapter manifest declares extra best-practice checks (file-exists / dir-exists / git-tracked) cited to the provider's own docs, so criteria are attributable, not sloop's opinion. Optional checks render as advisory info (•) so power-user features don't nag a basic setup. Also adds a workspace-level "AGENTS.md committed to git" check (a best practice every provider echoes), a "learn more" line citing each provider's source, and seeds claude.yaml with the .claude/agents check from Claude Code's best-practices page. Documents the new field in ADAPTERS.md. --- docs/reference/ADAPTERS.md | 11 +++++ internal/adapter/adapter.go | 24 ++++++++++ internal/adapter/builtin/claude.yaml | 13 ++++++ internal/cli/commands/check.go | 65 ++++++++++++++++++++++++++- internal/cli/commands/check_test.go | 67 +++++++++++++++++++++++++++- 5 files changed, 178 insertions(+), 2 deletions(-) diff --git a/docs/reference/ADAPTERS.md b/docs/reference/ADAPTERS.md index 49c4657..77bcd57 100644 --- a/docs/reference/ADAPTERS.md +++ b/docs/reference/ADAPTERS.md @@ -51,6 +51,16 @@ run: # `sloop run` model/effort knobs (all optional) heuristics: # OPTIONAL fallback status markers (see below) — usually omit waiting: ["shall I apply"] # tool-specific phrasing the cross-tool defaults miss working: ["indexing repo"] + +readiness: # OPTIONAL extra `sloop check` best practices, sourced from the + docs: https://… # provider's own guidance (cited so it's not sloop's opinion) + checks: # only filesystem-detectable practices; behavioral tips don't belong + - id: subagents + label: "Subagents folder (.claude/agents)" + kind: dir-exists # "file-exists" | "dir-exists" | "git-tracked" + path: .claude/agents # repo-relative + fix: "sloop init --scaffold" + optional: true # missing → advisory info (•), not a counted gap (✗) ``` ## Which features are manifest-driven @@ -64,6 +74,7 @@ heuristics: # OPTIONAL fallback status markers (see below) | skills symlink | `sync` | `skills.target` | | `sloop hooks` + status | `hooks.go` | `hooks.*` | | `sloop ps` status fallback | `tmux.ClassifyStatus` | `heuristics.*` (additive; see below) | +| `sloop check` readiness | `check.go` | `readiness.*` (optional; provider-sourced) | | shell completion | `completion.go` | manifest keys | The runtime view of all of this is **`sloop tools`** (capability matrix: diff --git a/internal/adapter/adapter.go b/internal/adapter/adapter.go index 8f37e5b..3c1a03e 100644 --- a/internal/adapter/adapter.go +++ b/internal/adapter/adapter.go @@ -95,6 +95,30 @@ type Manifest struct { // can create (e.g. .claude/skills, .cursor/rules), so users start from the // provider's expected layout instead of an ad-hoc one. Scaffold []string `yaml:"scaffold"` + // Readiness declares extra best-practice checks for `sloop check`, sourced + // from the provider's own docs — so the checklist deepens by editing data. + Readiness ReadinessSpec `yaml:"readiness"` +} + +// ReadinessSpec is a provider's best-practice checklist for `sloop check`. Docs +// is the source these criteria come from (the provider's own guidance), so each +// check is attributable, not sloop's opinion. +type ReadinessSpec struct { + Docs string `yaml:"docs"` + Checks []ReadinessCheck `yaml:"checks"` +} + +// ReadinessCheck is one declarative, filesystem-detectable best practice. Kind is +// "file-exists" | "dir-exists" | "git-tracked"; Path is repo-relative. Optional +// checks surface as advisory info (not counted as a gap) so power-user features +// don't nag a basic setup. +type ReadinessCheck struct { + ID string `yaml:"id"` + Label string `yaml:"label"` + Kind string `yaml:"kind"` + Path string `yaml:"path"` + Fix string `yaml:"fix"` + Optional bool `yaml:"optional"` } func LoadBuiltin() (map[string]Manifest, error) { diff --git a/internal/adapter/builtin/claude.yaml b/internal/adapter/builtin/claude.yaml index d7cc13b..1a70414 100644 --- a/internal/adapter/builtin/claude.yaml +++ b/internal/adapter/builtin/claude.yaml @@ -25,6 +25,19 @@ run: models: [opus, sonnet, haiku, fable] prompt: positional # `claude "task"` seeds an interactive session (claude -p exits) resume_flag: "--continue" # `claude --continue` resumes the repo's most recent conversation +# Extra `sloop check` best practices, sourced from Claude Code's own guidance. +# These are the filesystem-detectable ones; behavioral tips (explore→plan→code, +# /clear, verification loops) are intentionally NOT here — they can't be checked +# mechanically and would only produce false gaps. +readiness: + docs: https://code.claude.com/docs/en/best-practices + checks: + - id: subagents + label: "Subagents folder (.claude/agents) for delegated, isolated tasks" + kind: dir-exists + path: .claude/agents + fix: "sloop init --scaffold" + optional: true # No `heuristics:` block: Claude's prompts use the cross-tool defaults (see # defaultWaitingMarkers/defaultWorkingMarkers in internal/tmux/status.go). # Precise status comes from the hooks above; heuristics are only the fallback. diff --git a/internal/cli/commands/check.go b/internal/cli/commands/check.go index d23ee22..b28575f 100644 --- a/internal/cli/commands/check.go +++ b/internal/cli/commands/check.go @@ -4,6 +4,8 @@ import ( "fmt" "io" "os" + "os/exec" + "path/filepath" "github.com/spf13/cobra" @@ -15,6 +17,33 @@ import ( "github.com/stroops/sloop/internal/workspace" ) +// git helpers are seams (overridable in tests) so the checklist can verify a +// file is committed without a real repo in the test. +var ( + inGitRepo = func(root string) bool { + return exec.Command("git", "-C", root, "rev-parse", "--is-inside-work-tree").Run() == nil + } + gitTracked = func(root, path string) bool { + return exec.Command("git", "-C", root, "ls-files", "--error-unmatch", "--", path).Run() == nil + } +) + +// evalReadiness evaluates one declarative manifest check against the filesystem. +func evalReadiness(root string, c adapter.ReadinessCheck) bool { + p := filepath.Join(root, c.Path) + switch c.Kind { + case "file-exists": + fi, err := os.Stat(p) + return err == nil && !fi.IsDir() + case "dir-exists": + fi, err := os.Stat(p) + return err == nil && fi.IsDir() + case "git-tracked": + return inGitRepo(root) && gitTracked(root, c.Path) + } + return false +} + // checkItem is one readiness line: whether it passed, a label, and the command // that fixes it when it hasn't. Info items are advisory (not counted as a gap). type checkItem struct { @@ -34,11 +63,21 @@ func readinessChecklist(root, sloopDir string, proj *config.Project, manifests m var items []checkItem // Workspace-level basics. + agentsOK := syncpkg.AgentsState(root) == "ok" items = append(items, checkItem{ - OK: syncpkg.AgentsState(root) == "ok", + OK: agentsOK, Label: "AGENTS.md present (canonical context)", Fix: "sloop init", }) + // Canonical context should be committed so the team shares it (a best practice + // every provider echoes). Only meaningful once it exists and we're in a repo. + if agentsOK && inGitRepo(root) { + items = append(items, checkItem{ + OK: gitTracked(root, "AGENTS.md"), + Label: "AGENTS.md committed to git (shared with your team)", + Fix: "git add AGENTS.md && git commit", + }) + } _, hasDefault := manifests[proj.DefaultTool] items = append(items, checkItem{ OK: proj.DefaultTool != "" && hasDefault, @@ -81,6 +120,19 @@ func readinessChecklist(root, sloopDir string, proj *config.Project, manifests m items = append(items, checkItem{Label: m.Name + ": status hooks not installed", Fix: "sloop hooks install " + t}) } } + + // Extra best practices declared in the manifest (sourced from the provider's + // own docs). Optional ones are advisory so power-user features don't nag. + for _, rc := range m.Readiness.Checks { + switch { + case evalReadiness(root, rc): + items = append(items, checkItem{OK: true, Label: m.Name + ": " + rc.Label}) + case rc.Optional: + items = append(items, checkItem{Info: true, Label: m.Name + ": " + rc.Label + " (optional)"}) + default: + items = append(items, checkItem{Label: m.Name + ": " + rc.Label, Fix: rc.Fix}) + } + } } return items @@ -129,6 +181,17 @@ func RunCheck(startDir string, w io.Writer) error { return err } renderChecklist(w, ws.Name, readinessChecklist(ws.Root, ws.SloopDir(), proj, manifests)) + + // Cite the source of each provider's extra criteria, so the checklist reads as + // "X's own best practices", not sloop's opinion. + seen := map[string]bool{} + for _, t := range proj.Tools { + m := manifests[t] + if m.Readiness.Docs != "" && !seen[m.Readiness.Docs] { + seen[m.Readiness.Docs] = true + _, _ = fmt.Fprintf(w, "%s %s — %s\n", tui.Grey(" learn more:"), m.Name, tui.Grey(m.Readiness.Docs)) + } + } return nil } diff --git a/internal/cli/commands/check_test.go b/internal/cli/commands/check_test.go index 059e8e9..d528832 100644 --- a/internal/cli/commands/check_test.go +++ b/internal/cli/commands/check_test.go @@ -57,7 +57,72 @@ func TestReadinessChecklist(t *testing.T) { t.Fatal(err) } items = readinessChecklist(root, sloopDir, proj, manifests) - if it, ok := findItem(items, "AGENTS.md"); !ok || !it.OK { + if it, ok := findItem(items, "AGENTS.md present"); !ok || !it.OK { t.Fatalf("AGENTS.md should now pass: %+v", it) } } + +func TestEvalReadiness(t *testing.T) { + root := t.TempDir() + _ = os.MkdirAll(filepath.Join(root, "adir"), 0o755) + _ = os.WriteFile(filepath.Join(root, "afile"), []byte("x"), 0o644) + + cases := []struct { + kind, path string + want bool + }{ + {"file-exists", "afile", true}, + {"file-exists", "adir", false}, // a dir is not a file + {"file-exists", "nope", false}, + {"dir-exists", "adir", true}, + {"dir-exists", "afile", false}, + {"weird", "afile", false}, // unknown kind + } + for _, c := range cases { + got := evalReadiness(root, adapter.ReadinessCheck{Kind: c.kind, Path: c.path}) + if got != c.want { + t.Errorf("evalReadiness(%s,%s) = %v, want %v", c.kind, c.path, got, c.want) + } + } +} + +func TestReadinessManifestChecks(t *testing.T) { + root := t.TempDir() + sloopDir := filepath.Join(root, ".sloop") + _ = os.MkdirAll(sloopDir, 0o755) + _ = os.WriteFile(filepath.Join(root, "AGENTS.md"), []byte("# g\n"), 0o644) + + // Pretend we're in a git repo where AGENTS.md is NOT committed. + defer func(a func(string) bool, b func(string, string) bool) { inGitRepo, gitTracked = a, b }(inGitRepo, gitTracked) + inGitRepo = func(string) bool { return true } + gitTracked = func(_, _ string) bool { return false } + + manifests := map[string]adapter.Manifest{ + "claude": { + Name: "Claude Code", + Context: adapter.ContextSpec{Mode: "native"}, + Readiness: adapter.ReadinessSpec{ + Docs: "https://example/best-practices", + Checks: []adapter.ReadinessCheck{ + {ID: "req", Label: "Required dir", Kind: "dir-exists", Path: ".claude/agents", Fix: "make it"}, + {ID: "opt", Label: "Optional dir", Kind: "dir-exists", Path: ".claude/extra", Optional: true}, + }, + }, + }, + } + proj := &config.Project{Tools: []string{"claude"}, DefaultTool: "claude"} + items := readinessChecklist(root, sloopDir, proj, manifests) + + // AGENTS.md exists but not committed → that check is a gap. + if it, ok := findItem(items, "committed to git"); !ok || it.OK { + t.Fatalf("uncommitted AGENTS.md should be a gap: %+v", it) + } + // Required manifest check missing → gap with its fix. + if it, ok := findItem(items, "Required dir"); !ok || it.OK || it.Fix != "make it" { + t.Fatalf("required readiness check should be a gap with fix: %+v", it) + } + // Optional manifest check missing → advisory info, not a gap. + if it, ok := findItem(items, "Optional dir"); !ok || it.OK || !it.Info { + t.Fatalf("optional readiness check should be info, not a gap: %+v", it) + } +} From f47875af82e67866b5b51ca46f9ff3bcd64b0954 Mon Sep 17 00:00:00 2001 From: Tuan Ngo Date: Sun, 28 Jun 2026 17:55:15 +0700 Subject: [PATCH 02/25] feat: Keep init minimal and provider-respecting (per-workspace tools) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `sloop init` no longer enables every AI CLI on the machine. It enables your primary tool by default; `--tools claude,cursor` or the interactive prompt add more per workspace. Downstream (sync/check/hooks) read that list, so a Claude-only folder isn't littered with GEMINI.md or .cursor. Add needsCanonical: sloop's portable-context layer (AGENTS.md + pointer files) is only set up when there's something to be portable across — two or more tools, or a native-mode tool. A lone pointer-mode tool keeps its own context file (e.g. CLAUDE.md from `claude` /init); sloop stays out of the way and manages the fleet and skills. init/sync/check all honor this. check now respects a context file the tool already owns (treated as ready, not a "repair" nag) and only asks for AGENTS.md in a canonical workspace. Tests updated to cover both the minimal single-tool and canonical multi-tool shapes. --- internal/cli/commands/check.go | 42 +++++---- internal/cli/commands/check_test.go | 11 ++- internal/cli/commands/init.go | 128 +++++++++++++++++++------- internal/cli/commands/initcmd_test.go | 47 ++++++---- internal/cli/commands/runcmd_test.go | 5 +- internal/cli/commands/sync.go | 57 ++++++++---- internal/cli/commands/synccmd_test.go | 5 +- 7 files changed, 205 insertions(+), 90 deletions(-) diff --git a/internal/cli/commands/check.go b/internal/cli/commands/check.go index b28575f..512427e 100644 --- a/internal/cli/commands/check.go +++ b/internal/cli/commands/check.go @@ -62,21 +62,25 @@ type checkItem struct { func readinessChecklist(root, sloopDir string, proj *config.Project, manifests map[string]adapter.Manifest) []checkItem { var items []checkItem - // Workspace-level basics. - agentsOK := syncpkg.AgentsState(root) == "ok" - items = append(items, checkItem{ - OK: agentsOK, - Label: "AGENTS.md present (canonical context)", - Fix: "sloop init", - }) - // Canonical context should be committed so the team shares it (a best practice - // every provider echoes). Only meaningful once it exists and we're in a repo. - if agentsOK && inGitRepo(root) { + // AGENTS.md is sloop's portable-context layer — only relevant when the + // workspace actually uses it (≥2 tools or a native-mode tool). A lone + // pointer-mode tool keeps its own context file, so we don't ask for AGENTS.md. + canonical := needsCanonical(proj.Tools, manifests) + if canonical { + agentsOK := syncpkg.AgentsState(root) == "ok" items = append(items, checkItem{ - OK: gitTracked(root, "AGENTS.md"), - Label: "AGENTS.md committed to git (shared with your team)", - Fix: "git add AGENTS.md && git commit", + OK: agentsOK, + Label: "AGENTS.md present (canonical context)", + Fix: "sloop init", }) + // Committed so the team shares it — a best practice every provider echoes. + if agentsOK && inGitRepo(root) { + items = append(items, checkItem{ + OK: gitTracked(root, "AGENTS.md"), + Label: "AGENTS.md committed to git (shared with your team)", + Fix: "git add AGENTS.md && git commit", + }) + } } _, hasDefault := manifests[proj.DefaultTool] items = append(items, checkItem{ @@ -93,14 +97,20 @@ func readinessChecklist(root, sloopDir string, proj *config.Project, manifests m continue } - // Context delivery (pointer-mode tools only; native tools need nothing). + // Context: the tool can see project context. We respect a context file the + // tool already owns (e.g. from its own /init) — that counts as ready, never + // a "repair me" nag. Native tools read AGENTS.md directly (only canonical). switch syncpkg.ContextState(root, m) { case "ok": items = append(items, checkItem{OK: true, Label: m.Name + ": context delivered (" + m.Context.File + ")"}) case "foreign": - items = append(items, checkItem{Label: m.Name + ": " + m.Context.File + " is a hand-authored file, not a sloop pointer", Fix: "sloop sync --repair"}) + items = append(items, checkItem{OK: true, Label: m.Name + ": has its own context (" + m.Context.File + ")"}) case "missing": - items = append(items, checkItem{Label: m.Name + ": context pointer not delivered (" + m.Context.File + ")", Fix: "sloop sync"}) + fix := "sloop sync" + if !canonical { + fix = "run `" + t + "` then /init" + } + items = append(items, checkItem{Label: m.Name + ": no context yet (" + m.Context.File + ")", Fix: fix}) } // Skills linked — only relevant once there are skills to deliver. diff --git a/internal/cli/commands/check_test.go b/internal/cli/commands/check_test.go index d528832..e0ee674 100644 --- a/internal/cli/commands/check_test.go +++ b/internal/cli/commands/check_test.go @@ -27,26 +27,29 @@ func TestReadinessChecklist(t *testing.T) { t.Fatal(err) } + // Two tools (claude pointer + cursor native) → canonical workspace, so the + // AGENTS.md + context checks apply. manifests := map[string]adapter.Manifest{ "claude": { Name: "Claude Code", Context: adapter.ContextSpec{Mode: "pointer", File: "CLAUDE.md"}, Hooks: adapter.HooksSpec{Install: "settings-json", Config: ".claude/settings.local.json", Events: adapter.HookEvents{Idle: "Stop"}}, }, + "cursor": {Name: "Cursor CLI", Context: adapter.ContextSpec{Mode: "native"}}, } - proj := &config.Project{Tools: []string{"claude"}, DefaultTool: "claude"} + proj := &config.Project{Tools: []string{"claude", "cursor"}, DefaultTool: "claude"} // Bare workspace: no AGENTS.md, no pointer, no hooks → those should be gaps. items := readinessChecklist(root, sloopDir, proj, manifests) - if it, ok := findItem(items, "AGENTS.md"); !ok || it.OK { + if it, ok := findItem(items, "AGENTS.md present"); !ok || it.OK { t.Fatalf("AGENTS.md should be a gap: %+v", it) } if it, ok := findItem(items, "Default tool"); !ok || !it.OK { t.Fatalf("default tool should pass: %+v", it) } - if it, ok := findItem(items, "context pointer not delivered"); !ok || it.OK || it.Fix != "sloop sync" { - t.Fatalf("missing pointer should suggest sloop sync: %+v", it) + if it, ok := findItem(items, "no context yet"); !ok || it.OK || it.Fix != "sloop sync" { + t.Fatalf("missing context should suggest sloop sync: %+v", it) } if it, ok := findItem(items, "status hooks not installed"); !ok || it.OK || !strings.Contains(it.Fix, "hooks install") { t.Fatalf("missing hooks should suggest install: %+v", it) diff --git a/internal/cli/commands/init.go b/internal/cli/commands/init.go index 47bb386..2540fc1 100644 --- a/internal/cli/commands/init.go +++ b/internal/cli/commands/init.go @@ -26,9 +26,22 @@ cache/ vault/ ` -// RunInit scaffolds the workspace and delivers context for every enabled tool, -// returning a human-readable summary of what it created/linked. -func RunInit(dir string, scan bool) ([]string, error) { +// defaultPrimary is the single tool a bare `init` enables — the user's installed +// default (claude when present), keeping a fresh workspace minimal rather than +// turning on every CLI on the machine. +func defaultPrimary(manifests map[string]adapter.Manifest) string { + detected := detect.InstalledKeys(manifests) + if len(detected) == 0 || contains(detected, fallbackTool) { + return fallbackTool + } + return detected[0] +} + +// RunInit scaffolds the workspace and enables the given tools (nil = a minimal +// single-tool default), delivering context only when the workspace actually +// needs sloop's portable layer (see needsCanonical). Returns a summary of what +// it created/linked. +func RunInit(dir string, tools []string, scan bool) ([]string, error) { sloopDir := filepath.Join(dir, config.SloopDirName) for _, sub := range []string{"skills", "vault"} { if err := os.MkdirAll(filepath.Join(sloopDir, sub), 0o700); err != nil { @@ -36,51 +49,47 @@ func RunInit(dir string, scan bool) ([]string, error) { } } - // Detect installed known tools; always ensure claude is usable. manifests, err := adapter.Load() if err != nil { return nil, err } - enabled := detect.InstalledKeys(manifests) - if len(enabled) == 0 { - enabled = []string{fallbackTool} + if len(tools) == 0 { + tools = []string{defaultPrimary(manifests)} } - defaultTool := fallbackTool - if !contains(enabled, fallbackTool) { - defaultTool = enabled[0] + defaultTool := tools[0] + if contains(tools, fallbackTool) { + defaultTool = fallbackTool } if err := config.SaveProject(sloopDir, &config.Project{ - Tools: enabled, + Tools: tools, DefaultTool: defaultTool, }); err != nil { return nil, err } - if scan { + // The portable-context layer (AGENTS.md + pointers) is only set up when the + // workspace needs it; a lone pointer-mode tool keeps its own context file. + canonical := needsCanonical(tools, manifests) + if canonical && scan { if _, err := syncpkg.EnsureAgentsContent(dir, scanpkg.Scan(dir).AgentsMarkdown()); err != nil { return nil, err } - } else { - if _, err := syncpkg.EnsureAgents(dir); err != nil { - return nil, err - } } if err := os.WriteFile(filepath.Join(sloopDir, ".gitignore"), []byte(sloopGitignore), 0o600); err != nil { return nil, err } - // Deliver native context (pointer files like CLAUDE.md + skills links) for - // every enabled tool, so the workspace is usable right after init — not only - // after the first `sloop sync`/`run`. Best-effort per tool. + // Deliver context (when canonical) + skills links for every enabled tool, so + // the workspace is usable right after init. Best-effort per tool. var summary []string - for _, tool := range enabled { + for _, tool := range tools { m, ok := manifests[tool] if !ok { continue } - log, err := syncOne(dir, sloopDir, m, false) + log, err := syncOne(dir, sloopDir, m, false, canonical) if err != nil { return nil, err } @@ -121,8 +130,58 @@ func RunInit(dir string, scan bool) ([]string, error) { var ( initScan bool initScaffold bool + initTools string ) +// resolveInitTools decides which tools `init` enables: the explicit --tools list +// if given, else an interactive per-tool prompt (primary on, others opt-in), else +// a minimal single-tool default. Keeps a fresh workspace from turning on every CLI +// installed on the machine. +func resolveInitTools(cmd *cobra.Command, manifests map[string]adapter.Manifest) []string { + detected := detect.InstalledKeys(manifests) + if len(detected) == 0 { + detected = []string{fallbackTool} + } + primary := defaultPrimary(manifests) + + if initTools != "" { + var out []string + for _, t := range strings.Split(initTools, ",") { + t = strings.TrimSpace(t) + if t == "" { + continue + } + if _, has := manifests[t]; !has { + cmd.Printf("%s\n", tui.Grey("ignoring unknown tool: "+t)) + continue + } + if !contains(out, t) { + out = append(out, t) + } + } + if len(out) > 0 { + return out + } + } + + if interactiveInit(cmd) { + r := bufio.NewReader(os.Stdin) + ix := initInteraction(cmd) + cmd.Printf("Detected tools: %s\n", strings.Join(detected, ", ")) + selected := []string{primary} + for _, t := range detected { + if t == primary { + continue + } + if ix.Ask("Also set up "+displayTool(t, manifests)+" in this workspace?", false, r, cmd.OutOrStdout()) { + selected = append(selected, t) + } + } + return selected + } + return []string{primary} // minimal default +} + var initCmd = &cobra.Command{ Use: "init", Short: "Scaffold a .sloop workspace in the current directory", @@ -131,27 +190,24 @@ var initCmd = &cobra.Command{ if err != nil { return err } + manifests, _ := adapter.Load() + tools := resolveInitTools(cmd, manifests) + canonical := needsCanonical(tools, manifests) - // Interactive onboarding: when on a real terminal and not under - // --auto/--no-input, walk a new user through the choices. Otherwise keep - // the scriptable behavior (flags only). + // Interactive onboarding: ask the remaining setup choices (only those that + // apply). Scriptable runs keep flag-only behavior. wantHooks := false if interactiveInit(cmd) { r := bufio.NewReader(os.Stdin) ix := initInteraction(cmd) - manifests, _ := adapter.Load() - det := detect.InstalledKeys(manifests) - if len(det) == 0 { - det = []string{fallbackTool} + if canonical { + initScan = ix.Ask("Pre-fill AGENTS.md from your codebase?", true, r, cmd.OutOrStdout()) } - cmd.Printf("Detected tools: %s\n", strings.Join(det, ", ")) - cmd.Println(tui.Grey("(edit .sloop/config.yaml later to change which are enabled)")) - initScan = ix.Ask("Pre-fill AGENTS.md from your codebase?", true, r, cmd.OutOrStdout()) initScaffold = ix.Ask("Create each tool's standard folders?", false, r, cmd.OutOrStdout()) wantHooks = ix.Ask("Install status hooks for precise `sloop ps`?", true, r, cmd.OutOrStdout()) } - summary, err := RunInit(cwd, initScan) + summary, err := RunInit(cwd, tools, initScan) if err != nil { return err } @@ -164,7 +220,12 @@ var initCmd = &cobra.Command{ cmd.Printf(" %s\n", l) } } - cmd.Println("Next: edit AGENTS.md, then `sloop run`.") + if canonical { + cmd.Println("Next: edit AGENTS.md, then `sloop run`.") + } else { + cmd.Printf("Next: %s keeps its own context (e.g. run it and `/init`). sloop manages the fleet & skills here.\n", + displayTool(tools[0], manifests)) + } hints.Show(cmd.OutOrStdout(), "init") return nil }, @@ -219,6 +280,7 @@ func installHooksForEnabled(root string) []string { func RegisterInit(cmd *cobra.Command) { initCmd.Flags().BoolVarP(&initScan, "scan", "s", false, "scan the existing codebase to pre-fill AGENTS.md") initCmd.Flags().BoolVarP(&initScaffold, "scaffold", "S", false, "also create each enabled tool's standard folders (.claude/skills, .cursor/rules, …)") + initCmd.Flags().StringVar(&initTools, "tools", "", "comma-separated tools to enable for this workspace (default: your primary tool)") cmd.AddCommand(initCmd) } diff --git a/internal/cli/commands/initcmd_test.go b/internal/cli/commands/initcmd_test.go index 4496010..ea2e785 100644 --- a/internal/cli/commands/initcmd_test.go +++ b/internal/cli/commands/initcmd_test.go @@ -12,27 +12,22 @@ import ( func TestRunInitScaffolds(t *testing.T) { dir := t.TempDir() t.Setenv("HOME", t.TempDir()) // isolate the global DB - t.Setenv("PATH", t.TempDir()) // empty PATH: no tools detected + t.Setenv("PATH", t.TempDir()) // empty PATH: no tools detected → minimal claude-only - if _, err := RunInit(dir, false); err != nil { + if _, err := RunInit(dir, nil, false); err != nil { t.Fatalf("RunInit: %v", err) } - if _, err := os.Stat(filepath.Join(dir, "AGENTS.md")); err != nil { - t.Fatalf("expected AGENTS.md: %v", err) - } - // init delivers pointers immediately (no separate sync needed). - if _, err := os.Stat(filepath.Join(dir, "CLAUDE.md")); err != nil { - t.Fatalf("expected CLAUDE.md delivered by init: %v", err) + // A lone pointer-mode tool stays minimal: no AGENTS.md / pointer (the tool + // keeps its own context). sloop just sets up .sloop and registers. + if _, err := os.Stat(filepath.Join(dir, "AGENTS.md")); !os.IsNotExist(err) { + t.Fatalf("single-tool init should NOT create AGENTS.md") } - if _, err := os.Stat(filepath.Join(dir, ".sloop", "context")); !os.IsNotExist(err) { - t.Fatalf(".sloop/context should not exist") + if _, err := os.Stat(filepath.Join(dir, "CLAUDE.md")); !os.IsNotExist(err) { + t.Fatalf("single-tool init should NOT create a CLAUDE.md pointer") } - for _, p := range []string{ - ".sloop/config.yaml", - ".sloop/.gitignore", - } { + for _, p := range []string{".sloop/config.yaml", ".sloop/.gitignore"} { if _, err := os.Stat(filepath.Join(dir, p)); err != nil { t.Fatalf("expected %s: %v", p, err) } @@ -48,11 +43,28 @@ func TestRunInitScaffolds(t *testing.T) { } } +func TestRunInitCanonicalDelivers(t *testing.T) { + dir := t.TempDir() + t.Setenv("HOME", t.TempDir()) + t.Setenv("PATH", t.TempDir()) + + // Two tools → canonical workspace: AGENTS.md + the CLAUDE.md pointer appear. + if _, err := RunInit(dir, []string{"claude", "cursor"}, false); err != nil { + t.Fatalf("RunInit: %v", err) + } + if _, err := os.Stat(filepath.Join(dir, "AGENTS.md")); err != nil { + t.Fatalf("expected AGENTS.md in a canonical workspace: %v", err) + } + if _, err := os.Stat(filepath.Join(dir, "CLAUDE.md")); err != nil { + t.Fatalf("expected CLAUDE.md pointer delivered: %v", err) + } +} + func TestRunInitFallsBackToClaude(t *testing.T) { dir := t.TempDir() t.Setenv("HOME", t.TempDir()) t.Setenv("PATH", t.TempDir()) // empty PATH: no tools detected - if _, err := RunInit(dir, false); err != nil { + if _, err := RunInit(dir, nil, false); err != nil { t.Fatalf("RunInit: %v", err) } p, err := config.LoadProject(filepath.Join(dir, ".sloop")) @@ -74,7 +86,7 @@ func TestRunInitScaffoldCreatesStandardFolders(t *testing.T) { initScaffold = true defer func() { initScaffold = false }() - if _, err := RunInit(dir, false); err != nil { + if _, err := RunInit(dir, nil, false); err != nil { t.Fatalf("RunInit: %v", err) } for _, d := range []string{".claude/skills", ".claude/agents"} { @@ -91,7 +103,8 @@ func TestRunInitScanPopulatesAgents(t *testing.T) { if err := os.WriteFile(filepath.Join(dir, "go.mod"), []byte("module demo\n\ngo 1.26\n"), 0o600); err != nil { t.Fatal(err) } - if _, err := RunInit(dir, true); err != nil { + // Scan pre-fills AGENTS.md, which only exists in a canonical workspace. + if _, err := RunInit(dir, []string{"claude", "cursor"}, true); err != nil { t.Fatalf("RunInit: %v", err) } b, err := os.ReadFile(filepath.Join(dir, "AGENTS.md")) diff --git a/internal/cli/commands/runcmd_test.go b/internal/cli/commands/runcmd_test.go index c8cbd72..0e09ff5 100644 --- a/internal/cli/commands/runcmd_test.go +++ b/internal/cli/commands/runcmd_test.go @@ -15,7 +15,8 @@ func (f *fakeRunner) Launch(s runner.Spec) error { f.got = s; return nil } func TestRunRunSyncsAndLaunches(t *testing.T) { dir := t.TempDir() t.Setenv("HOME", t.TempDir()) - if _, err := RunInit(dir, false); err != nil { + // Two tools → canonical workspace, so the CLAUDE.md pointer is delivered. + if _, err := RunInit(dir, []string{"claude", "cursor"}, false); err != nil { t.Fatalf("RunInit: %v", err) } @@ -42,7 +43,7 @@ func TestRunRunSyncsAndLaunches(t *testing.T) { func TestRunRunPassesThroughArgs(t *testing.T) { dir := t.TempDir() t.Setenv("HOME", t.TempDir()) - if _, err := RunInit(dir, false); err != nil { + if _, err := RunInit(dir, nil, false); err != nil { t.Fatalf("RunInit: %v", err) } diff --git a/internal/cli/commands/sync.go b/internal/cli/commands/sync.go index 45c8fd1..9984759 100644 --- a/internal/cli/commands/sync.go +++ b/internal/cli/commands/sync.go @@ -13,6 +13,23 @@ import ( "github.com/stroops/sloop/internal/workspace" ) +// needsCanonical reports whether a workspace needs sloop's portable-context layer +// (AGENTS.md + pointer files). It's only worth it when there's something to be +// portable across: two or more tools, or a native-mode tool that reads AGENTS.md +// directly. A lone pointer-mode tool (e.g. just Claude) keeps its own context +// file — sloop stays out of the way and just manages the fleet/skills. +func needsCanonical(tools []string, manifests map[string]adapter.Manifest) bool { + if len(tools) >= 2 { + return true + } + for _, t := range tools { + if m, ok := manifests[t]; ok && m.Context.Mode != "pointer" { + return true + } + } + return false +} + // resolveTool picks the target tool: the given target, or the project default. // (Enabled tools live in .sloop/config.yaml; there is no per-tool profile file.) func resolveTool(target, defaultTool string) (string, error) { @@ -48,15 +65,20 @@ func RunSync(startDir, target string, repair bool) ([]string, error) { return nil, fmt.Errorf("unknown tool %q (no adapter)", tool) } - return syncOne(ws.Root, ws.SloopDir(), m, repair) + return syncOne(ws.Root, ws.SloopDir(), m, repair, needsCanonical(proj.Tools, manifests)) } -func syncOne(root, sloopDir string, m adapter.Manifest, repair bool) ([]string, error) { +// syncOne delivers context + skills for one tool. canonical gates the portable- +// context layer (AGENTS.md + pointer file): when false (a lone pointer-mode +// tool) sloop leaves context to the tool's own file and only manages skills. +func syncOne(root, sloopDir string, m adapter.Manifest, repair, canonical bool) ([]string, error) { var log []string - if a, err := syncpkg.EnsureAgents(root); err != nil { - return nil, err - } else if a == syncpkg.ActionCreated { - log = append(log, "created AGENTS.md") + if canonical { + if a, err := syncpkg.EnsureAgents(root); err != nil { + return nil, err + } else if a == syncpkg.ActionCreated { + log = append(log, "created AGENTS.md") + } } ctx := syncpkg.SyncContext skl := syncpkg.SyncSkills @@ -65,15 +87,17 @@ func syncOne(root, sloopDir string, m adapter.Manifest, repair bool) ([]string, skl = func(r, s string, mm adapter.Manifest) (syncpkg.Action, error) { return syncpkg.RepairSkills(r, s, mm) } } - switch a, err := ctx(root, m); { - case err != nil: - return nil, err - case a == syncpkg.ActionCreated: - log = append(log, "created "+m.Context.File) - case a == syncpkg.ActionForeign: - log = append(log, m.Context.File+" exists, left as-is") - case a == syncpkg.ActionRepaired: - log = append(log, "repaired "+m.Context.File) + if canonical { + switch a, err := ctx(root, m); { + case err != nil: + return nil, err + case a == syncpkg.ActionCreated: + log = append(log, "created "+m.Context.File) + case a == syncpkg.ActionForeign: + log = append(log, m.Context.File+" exists, left as-is") + case a == syncpkg.ActionRepaired: + log = append(log, "repaired "+m.Context.File) + } } switch a, err := skl(root, sloopDir, m); { case err != nil: @@ -107,6 +131,7 @@ func RunSyncAll(startDir string, repair bool) ([]string, error) { if err != nil { return nil, err } + canonical := needsCanonical(proj.Tools, manifests) var log []string for _, tool := range proj.Tools { m, ok := manifests[tool] @@ -114,7 +139,7 @@ func RunSyncAll(startDir string, repair bool) ([]string, error) { log = append(log, tool+": unknown tool (no adapter), skipped") continue } - lines, err := syncOne(ws.Root, ws.SloopDir(), m, repair) + lines, err := syncOne(ws.Root, ws.SloopDir(), m, repair, canonical) if err != nil { return nil, fmt.Errorf("%s: %w", tool, err) } diff --git a/internal/cli/commands/synccmd_test.go b/internal/cli/commands/synccmd_test.go index 2e18c2b..e7cf192 100644 --- a/internal/cli/commands/synccmd_test.go +++ b/internal/cli/commands/synccmd_test.go @@ -12,7 +12,8 @@ import ( func TestRunSyncWritesClaudeMd(t *testing.T) { dir := t.TempDir() t.Setenv("HOME", t.TempDir()) - if _, err := RunInit(dir, false); err != nil { + // Two tools → canonical, so claude gets a CLAUDE.md pointer to AGENTS.md. + if _, err := RunInit(dir, []string{"claude", "cursor"}, false); err != nil { t.Fatalf("RunInit: %v", err) } written, err := RunSync(dir, "claude", false) @@ -32,7 +33,7 @@ func TestRunSyncWritesClaudeMd(t *testing.T) { func TestRunSyncAllDeliversEnabledTools(t *testing.T) { dir := t.TempDir() t.Setenv("HOME", t.TempDir()) - if _, err := RunInit(dir, false); err != nil { + if _, err := RunInit(dir, nil, false); err != nil { t.Fatalf("RunInit: %v", err) } // Enable claude + cursor. From 420094a3f2f20477d6ad1f017233eb2e8cee7a26 Mon Sep 17 00:00:00 2001 From: Tuan Ngo Date: Sun, 28 Jun 2026 18:20:09 +0700 Subject: [PATCH 03/25] =?UTF-8?q?fix:=20Make=20init=20prompts=20honest=20?= =?UTF-8?q?=E2=80=94=20ask=20about=20every=20tool,=20skip=20done=20work?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two init UX fixes from real-world testing: - Tool selection now asks about EVERY detected tool, including the primary — it isn't always Claude. The primary is defaulted on (a bare Enter keeps the simple case), but you can decline it; if nothing is picked, the primary is enabled as a fallback. - Only prompt for work that isn't already done: skip "Pre-fill AGENTS.md" when AGENTS.md exists, "Create standard folders" when they already exist, and "Install status hooks" when they're already installed (scaffoldNeeded / hooksNeeded gates). - Fix a latent bug where init opened two bufio readers on stdin (racing over buffered input); the whole interactive flow now shares one reader. --- internal/cli/commands/init.go | 100 +++++++++++++++++--------- internal/cli/commands/initcmd_test.go | 48 +++++++++++++ 2 files changed, 116 insertions(+), 32 deletions(-) diff --git a/internal/cli/commands/init.go b/internal/cli/commands/init.go index 2540fc1..d9decf9 100644 --- a/internal/cli/commands/init.go +++ b/internal/cli/commands/init.go @@ -134,16 +134,10 @@ var ( ) // resolveInitTools decides which tools `init` enables: the explicit --tools list -// if given, else an interactive per-tool prompt (primary on, others opt-in), else -// a minimal single-tool default. Keeps a fresh workspace from turning on every CLI -// installed on the machine. -func resolveInitTools(cmd *cobra.Command, manifests map[string]adapter.Manifest) []string { - detected := detect.InstalledKeys(manifests) - if len(detected) == 0 { - detected = []string{fallbackTool} - } - primary := defaultPrimary(manifests) - +// if given, else an interactive per-tool prompt (every detected tool is asked, the +// primary defaulted on), else a minimal single-tool default. r==nil means +// non-interactive. Keeps a fresh workspace from turning on every CLI installed. +func resolveInitTools(cmd *cobra.Command, manifests map[string]adapter.Manifest, r *bufio.Reader, ix Interaction) []string { if initTools != "" { var out []string for _, t := range strings.Split(initTools, ",") { @@ -164,22 +158,54 @@ func resolveInitTools(cmd *cobra.Command, manifests map[string]adapter.Manifest) } } - if interactiveInit(cmd) { - r := bufio.NewReader(os.Stdin) - ix := initInteraction(cmd) - cmd.Printf("Detected tools: %s\n", strings.Join(detected, ", ")) - selected := []string{primary} - for _, t := range detected { - if t == primary { - continue - } - if ix.Ask("Also set up "+displayTool(t, manifests)+" in this workspace?", false, r, cmd.OutOrStdout()) { - selected = append(selected, t) + detected := detect.InstalledKeys(manifests) + if len(detected) == 0 { + detected = []string{fallbackTool} + } + primary := defaultPrimary(manifests) + + if r == nil { + return []string{primary} // minimal default + } + + // Ask about every detected tool (including the primary — it isn't always + // Claude), defaulting the primary on so a quick Enter keeps it simple. + cmd.Printf("Detected tools: %s\n", strings.Join(detected, ", ")) + var selected []string + for _, t := range detected { + if ix.Ask("Use "+displayTool(t, manifests)+" in this workspace?", t == primary, r, cmd.OutOrStdout()) { + selected = append(selected, t) + } + } + if len(selected) == 0 { + cmd.Println(tui.Grey("none selected — enabling " + displayTool(primary, manifests))) + selected = []string{primary} + } + return selected +} + +// scaffoldNeeded reports whether any enabled tool has a standard folder that +// doesn't exist yet, so init only offers scaffolding when there's work to do. +func scaffoldNeeded(root string, tools []string, manifests map[string]adapter.Manifest) bool { + for _, t := range tools { + for _, d := range manifests[t].Scaffold { + if _, err := os.Stat(filepath.Join(root, d)); os.IsNotExist(err) { + return true } } - return selected } - return []string{primary} // minimal default + return false +} + +// hooksNeeded reports whether any enabled tool's status hooks aren't installed. +func hooksNeeded(root string, tools []string, manifests map[string]adapter.Manifest) bool { + for _, t := range tools { + m := manifests[t] + if hookInstaller(m.Hooks.Install) != nil && !hooksInstalledFor(root, m) { + return true + } + } + return false } var initCmd = &cobra.Command{ @@ -191,20 +217,30 @@ var initCmd = &cobra.Command{ return err } manifests, _ := adapter.Load() - tools := resolveInitTools(cmd, manifests) + + // One reader for the whole interactive flow (two readers on stdin would + // race over buffered input). + var r *bufio.Reader + var ix Interaction + if interactiveInit(cmd) { + r = bufio.NewReader(os.Stdin) + ix = initInteraction(cmd) + } + tools := resolveInitTools(cmd, manifests, r, ix) canonical := needsCanonical(tools, manifests) - // Interactive onboarding: ask the remaining setup choices (only those that - // apply). Scriptable runs keep flag-only behavior. + // Only ask about work that isn't already done. wantHooks := false - if interactiveInit(cmd) { - r := bufio.NewReader(os.Stdin) - ix := initInteraction(cmd) - if canonical { + if r != nil { + if canonical && syncpkg.AgentsState(cwd) != "ok" { initScan = ix.Ask("Pre-fill AGENTS.md from your codebase?", true, r, cmd.OutOrStdout()) } - initScaffold = ix.Ask("Create each tool's standard folders?", false, r, cmd.OutOrStdout()) - wantHooks = ix.Ask("Install status hooks for precise `sloop ps`?", true, r, cmd.OutOrStdout()) + if scaffoldNeeded(cwd, tools, manifests) { + initScaffold = ix.Ask("Create each tool's standard folders?", false, r, cmd.OutOrStdout()) + } + if hooksNeeded(cwd, tools, manifests) { + wantHooks = ix.Ask("Install status hooks for precise `sloop ps`?", true, r, cmd.OutOrStdout()) + } } summary, err := RunInit(cwd, tools, initScan) diff --git a/internal/cli/commands/initcmd_test.go b/internal/cli/commands/initcmd_test.go index ea2e785..438dbf8 100644 --- a/internal/cli/commands/initcmd_test.go +++ b/internal/cli/commands/initcmd_test.go @@ -6,9 +6,57 @@ import ( "strings" "testing" + "github.com/spf13/cobra" + + "github.com/stroops/sloop/internal/adapter" "github.com/stroops/sloop/internal/config" ) +func TestResolveInitToolsFlag(t *testing.T) { + manifests := map[string]adapter.Manifest{ + "claude": {Name: "Claude Code"}, + "cursor": {Name: "Cursor CLI"}, + } + defer func(v string) { initTools = v }(initTools) + initTools = "claude, bogus, cursor, claude" // unknown dropped, dupes deduped + + got := resolveInitTools(&cobra.Command{}, manifests, nil, Interaction{}) + if len(got) != 2 || got[0] != "claude" || got[1] != "cursor" { + t.Fatalf("resolveInitTools = %v, want [claude cursor]", got) + } +} + +func TestScaffoldNeeded(t *testing.T) { + root := t.TempDir() + manifests := map[string]adapter.Manifest{ + "claude": {Scaffold: []string{".claude/skills", ".claude/agents"}}, + } + tools := []string{"claude"} + if !scaffoldNeeded(root, tools, manifests) { + t.Fatal("nothing scaffolded yet → should be needed") + } + for _, d := range manifests["claude"].Scaffold { + _ = os.MkdirAll(filepath.Join(root, d), 0o755) + } + if scaffoldNeeded(root, tools, manifests) { + t.Fatal("all folders exist → should NOT be needed") + } +} + +func TestHooksNeeded(t *testing.T) { + root := t.TempDir() + manifests := map[string]adapter.Manifest{ + "claude": {Hooks: adapter.HooksSpec{Install: "settings-json", Config: ".claude/settings.local.json", Events: adapter.HookEvents{Idle: "Stop"}}}, + "codex": {Hooks: adapter.HooksSpec{Install: ""}}, // manual install → never "needed" + } + if !hooksNeeded(root, []string{"claude"}, manifests) { + t.Fatal("claude hooks not installed → needed") + } + if hooksNeeded(root, []string{"codex"}, manifests) { + t.Fatal("manual-install tool → not auto-needed") + } +} + func TestRunInitScaffolds(t *testing.T) { dir := t.TempDir() t.Setenv("HOME", t.TempDir()) // isolate the global DB From 761aba645abeeb3f9e2346c84c7a0db757049d42 Mon Sep 17 00:00:00 2001 From: Tuan Ngo Date: Sun, 28 Jun 2026 18:28:22 +0700 Subject: [PATCH 04/25] fix: Honest scaffolding and Claude-first tool order in init MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three init polish fixes from testing: - The scaffold prompt now lists exactly the folders it will create, e.g. "Create standard folders (.claude/agents, .cursor/rules)?" — and only the missing ones, via missingScaffold. - init no longer claims to have scaffolded a folder that already existed: the scaffold loop skips dirs that are present, so the summary lists only what it actually created. - Ask about the primary tool first (primaryFirst), so Claude (or whatever the default is) leads the per-tool prompts instead of alphabetical order. --- internal/cli/commands/init.go | 40 ++++++++++++++++++++------- internal/cli/commands/initcmd_test.go | 28 ++++++++++++++----- 2 files changed, 51 insertions(+), 17 deletions(-) diff --git a/internal/cli/commands/init.go b/internal/cli/commands/init.go index d9decf9..99f4f9d 100644 --- a/internal/cli/commands/init.go +++ b/internal/cli/commands/init.go @@ -99,6 +99,9 @@ func RunInit(dir string, tools []string, scan bool) ([]string, error) { if initScaffold { for _, d := range m.Scaffold { target := filepath.Join(dir, d) + if _, err := os.Stat(target); err == nil { + continue // already there — don't claim to have scaffolded it + } if err := os.MkdirAll(target, 0o755); err != nil { return nil, err } @@ -169,10 +172,10 @@ func resolveInitTools(cmd *cobra.Command, manifests map[string]adapter.Manifest, } // Ask about every detected tool (including the primary — it isn't always - // Claude), defaulting the primary on so a quick Enter keeps it simple. + // Claude), primary first and defaulted on so a quick Enter keeps it simple. cmd.Printf("Detected tools: %s\n", strings.Join(detected, ", ")) var selected []string - for _, t := range detected { + for _, t := range primaryFirst(detected, primary) { if ix.Ask("Use "+displayTool(t, manifests)+" in this workspace?", t == primary, r, cmd.OutOrStdout()) { selected = append(selected, t) } @@ -184,17 +187,34 @@ func resolveInitTools(cmd *cobra.Command, manifests map[string]adapter.Manifest, return selected } -// scaffoldNeeded reports whether any enabled tool has a standard folder that -// doesn't exist yet, so init only offers scaffolding when there's work to do. -func scaffoldNeeded(root string, tools []string, manifests map[string]adapter.Manifest) bool { +// primaryFirst returns detected tools with the primary moved to the front, so the +// most-likely tool (e.g. Claude) is the first one init asks about. +func primaryFirst(detected []string, primary string) []string { + out := make([]string, 0, len(detected)) + if contains(detected, primary) { + out = append(out, primary) + } + for _, t := range detected { + if t != primary { + out = append(out, t) + } + } + return out +} + +// missingScaffold lists the enabled tools' standard folders that don't exist yet +// (deduped, ordered). init only offers scaffolding for these — and only creates +// these — so it never claims to make a folder you already have. +func missingScaffold(root string, tools []string, manifests map[string]adapter.Manifest) []string { + var out []string for _, t := range tools { for _, d := range manifests[t].Scaffold { - if _, err := os.Stat(filepath.Join(root, d)); os.IsNotExist(err) { - return true + if _, err := os.Stat(filepath.Join(root, d)); os.IsNotExist(err) && !contains(out, d) { + out = append(out, d) } } } - return false + return out } // hooksNeeded reports whether any enabled tool's status hooks aren't installed. @@ -235,8 +255,8 @@ var initCmd = &cobra.Command{ if canonical && syncpkg.AgentsState(cwd) != "ok" { initScan = ix.Ask("Pre-fill AGENTS.md from your codebase?", true, r, cmd.OutOrStdout()) } - if scaffoldNeeded(cwd, tools, manifests) { - initScaffold = ix.Ask("Create each tool's standard folders?", false, r, cmd.OutOrStdout()) + if missing := missingScaffold(cwd, tools, manifests); len(missing) > 0 { + initScaffold = ix.Ask("Create standard folders ("+strings.Join(missing, ", ")+")?", false, r, cmd.OutOrStdout()) } if hooksNeeded(cwd, tools, manifests) { wantHooks = ix.Ask("Install status hooks for precise `sloop ps`?", true, r, cmd.OutOrStdout()) diff --git a/internal/cli/commands/initcmd_test.go b/internal/cli/commands/initcmd_test.go index 438dbf8..6693e14 100644 --- a/internal/cli/commands/initcmd_test.go +++ b/internal/cli/commands/initcmd_test.go @@ -26,20 +26,34 @@ func TestResolveInitToolsFlag(t *testing.T) { } } -func TestScaffoldNeeded(t *testing.T) { +func TestMissingScaffold(t *testing.T) { root := t.TempDir() manifests := map[string]adapter.Manifest{ "claude": {Scaffold: []string{".claude/skills", ".claude/agents"}}, } tools := []string{"claude"} - if !scaffoldNeeded(root, tools, manifests) { - t.Fatal("nothing scaffolded yet → should be needed") + + // Pre-create one of the two → only the other should be reported missing. + _ = os.MkdirAll(filepath.Join(root, ".claude/skills"), 0o755) + got := missingScaffold(root, tools, manifests) + if len(got) != 1 || got[0] != ".claude/agents" { + t.Fatalf("missingScaffold = %v, want [.claude/agents]", got) + } + + _ = os.MkdirAll(filepath.Join(root, ".claude/agents"), 0o755) + if got := missingScaffold(root, tools, manifests); len(got) != 0 { + t.Fatalf("all folders exist → want none, got %v", got) } - for _, d := range manifests["claude"].Scaffold { - _ = os.MkdirAll(filepath.Join(root, d), 0o755) +} + +func TestPrimaryFirst(t *testing.T) { + got := primaryFirst([]string{"agy", "claude", "cursor"}, "claude") + if len(got) != 3 || got[0] != "claude" { + t.Fatalf("primaryFirst = %v, want claude first", got) } - if scaffoldNeeded(root, tools, manifests) { - t.Fatal("all folders exist → should NOT be needed") + // Primary not in the list → order unchanged. + if got := primaryFirst([]string{"cursor", "gemini"}, "claude"); got[0] != "cursor" { + t.Fatalf("primaryFirst absent = %v", got) } } From 5234f50d9fb7e08cfc774cdcb9811ee6e1e0bb78 Mon Sep 17 00:00:00 2001 From: Tuan Ngo Date: Sun, 28 Jun 2026 18:34:05 +0700 Subject: [PATCH 05/25] fix: Don't ask to scaffold provider folders in init (provider-respect) Tools create their own standard folders (and sloop already creates the skills dir it needs via the symlink), so scaffolding empty .claude/agents / .cursor/rules with a .gitkeep is unnecessary intervention. Drop the interactive scaffold prompt; `--scaffold` stays as an explicit opt-in for anyone who wants the layout. --- internal/cli/commands/init.go | 23 ++++------------------- internal/cli/commands/initcmd_test.go | 20 -------------------- 2 files changed, 4 insertions(+), 39 deletions(-) diff --git a/internal/cli/commands/init.go b/internal/cli/commands/init.go index 99f4f9d..20c865c 100644 --- a/internal/cli/commands/init.go +++ b/internal/cli/commands/init.go @@ -202,21 +202,6 @@ func primaryFirst(detected []string, primary string) []string { return out } -// missingScaffold lists the enabled tools' standard folders that don't exist yet -// (deduped, ordered). init only offers scaffolding for these — and only creates -// these — so it never claims to make a folder you already have. -func missingScaffold(root string, tools []string, manifests map[string]adapter.Manifest) []string { - var out []string - for _, t := range tools { - for _, d := range manifests[t].Scaffold { - if _, err := os.Stat(filepath.Join(root, d)); os.IsNotExist(err) && !contains(out, d) { - out = append(out, d) - } - } - } - return out -} - // hooksNeeded reports whether any enabled tool's status hooks aren't installed. func hooksNeeded(root string, tools []string, manifests map[string]adapter.Manifest) bool { for _, t := range tools { @@ -255,9 +240,9 @@ var initCmd = &cobra.Command{ if canonical && syncpkg.AgentsState(cwd) != "ok" { initScan = ix.Ask("Pre-fill AGENTS.md from your codebase?", true, r, cmd.OutOrStdout()) } - if missing := missingScaffold(cwd, tools, manifests); len(missing) > 0 { - initScaffold = ix.Ask("Create standard folders ("+strings.Join(missing, ", ")+")?", false, r, cmd.OutOrStdout()) - } + // Scaffolding provider folders isn't asked: tools create their own + // layout, and sloop already creates the skills dir it needs. It stays + // available as the explicit `--scaffold` opt-in. if hooksNeeded(cwd, tools, manifests) { wantHooks = ix.Ask("Install status hooks for precise `sloop ps`?", true, r, cmd.OutOrStdout()) } @@ -335,7 +320,7 @@ func installHooksForEnabled(root string) []string { func RegisterInit(cmd *cobra.Command) { initCmd.Flags().BoolVarP(&initScan, "scan", "s", false, "scan the existing codebase to pre-fill AGENTS.md") - initCmd.Flags().BoolVarP(&initScaffold, "scaffold", "S", false, "also create each enabled tool's standard folders (.claude/skills, .cursor/rules, …)") + initCmd.Flags().BoolVarP(&initScaffold, "scaffold", "S", false, "opt-in: also create each enabled tool's standard folders (.claude/skills, .cursor/rules, …); off by default since tools create their own") initCmd.Flags().StringVar(&initTools, "tools", "", "comma-separated tools to enable for this workspace (default: your primary tool)") cmd.AddCommand(initCmd) } diff --git a/internal/cli/commands/initcmd_test.go b/internal/cli/commands/initcmd_test.go index 6693e14..fbf7488 100644 --- a/internal/cli/commands/initcmd_test.go +++ b/internal/cli/commands/initcmd_test.go @@ -26,26 +26,6 @@ func TestResolveInitToolsFlag(t *testing.T) { } } -func TestMissingScaffold(t *testing.T) { - root := t.TempDir() - manifests := map[string]adapter.Manifest{ - "claude": {Scaffold: []string{".claude/skills", ".claude/agents"}}, - } - tools := []string{"claude"} - - // Pre-create one of the two → only the other should be reported missing. - _ = os.MkdirAll(filepath.Join(root, ".claude/skills"), 0o755) - got := missingScaffold(root, tools, manifests) - if len(got) != 1 || got[0] != ".claude/agents" { - t.Fatalf("missingScaffold = %v, want [.claude/agents]", got) - } - - _ = os.MkdirAll(filepath.Join(root, ".claude/agents"), 0o755) - if got := missingScaffold(root, tools, manifests); len(got) != 0 { - t.Fatalf("all folders exist → want none, got %v", got) - } -} - func TestPrimaryFirst(t *testing.T) { got := primaryFirst([]string{"agy", "claude", "cursor"}, "claude") if len(got) != 3 || got[0] != "claude" { From 905f65d9bb75abbadeda565c3a4ead36e8fb5ee9 Mon Sep 17 00:00:00 2001 From: Tuan Ngo Date: Sun, 28 Jun 2026 18:40:12 +0700 Subject: [PATCH 06/25] feat: Group and color sloop doctor; explain the mode line MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Match the check command's readability: green ✓ / grey ✗ marks, and three clear groups — AI provider CLIs, Multiplexer (powers ps/run/attach), and Mode. A missing CLI is grey, not red, since the tools are optional. The Mode line now explains itself ("ask — asks before actions" / "auto — runs without prompts"). --- internal/cli/commands/doctor.go | 33 +++++++++++++++++++++------------ 1 file changed, 21 insertions(+), 12 deletions(-) diff --git a/internal/cli/commands/doctor.go b/internal/cli/commands/doctor.go index 649a939..19a1e45 100644 --- a/internal/cli/commands/doctor.go +++ b/internal/cli/commands/doctor.go @@ -9,38 +9,47 @@ import ( "github.com/stroops/sloop/internal/adapter" "github.com/stroops/sloop/internal/config" "github.com/stroops/sloop/internal/detect" + "github.com/stroops/sloop/internal/tui" ) func RunDoctor(w io.Writer) error { - _, _ = fmt.Fprintln(w, "Tools:") manifests, err := adapter.Load() if err != nil { return err } + + // AI provider CLIs (the tools sloop launches). A missing one isn't an error — + // you only use some — so it's marked in grey, not alarming red. + _, _ = fmt.Fprintln(w, "AI provider CLIs:") for _, s := range detect.Tools(manifests) { - mark := "✗" - extra := "" if s.Installed { - mark = "✓" + ver := "" if s.Version != "" { - extra = " " + s.Version + ver = " " + tui.Grey(s.Version) } + _, _ = fmt.Fprintf(w, " %s %s%s\n", tui.Green("✓"), s.Key, ver) + } else { + _, _ = fmt.Fprintf(w, " %s %s %s\n", tui.Grey("✗"), s.Key, tui.Grey("(not installed)")) } - _, _ = fmt.Fprintf(w, " %s %s%s\n", mark, s.Key, extra) } - tmux := detect.Tmux() - mark := "✗ (optional — exec fallback in use)" - if tmux.Installed { - mark = "✓ " + tmux.Version + // Multiplexer is its own group: it powers ps/run/attach, but is optional. + _, _ = fmt.Fprintln(w, "\nMultiplexer (powers ps / run / attach):") + if tmux := detect.Tmux(); tmux.Installed { + _, _ = fmt.Fprintf(w, " %s tmux %s\n", tui.Green("✓"), tui.Grey(tmux.Version)) + } else { + _, _ = fmt.Fprintf(w, " %s %s\n", tui.Grey("✗"), tui.Grey("tmux not found — optional; sloop falls back to plain exec")) } - _, _ = fmt.Fprintf(w, "tmux: %s\n", mark) g, err := config.LoadGlobal() if err != nil { return err } - _, _ = fmt.Fprintf(w, "mode: %s\n", g.Mode) + desc := "asks before actions" + if g.Mode == config.ModeAuto { + desc = "runs without prompts (assume yes)" + } + _, _ = fmt.Fprintf(w, "\nMode: %s %s\n", g.Mode, tui.Grey("— "+desc)) return nil } From cc1e467ed32541fe62077256f39f2dc1a916626b Mon Sep 17 00:00:00 2001 From: Tuan Ngo Date: Sun, 28 Jun 2026 19:06:21 +0700 Subject: [PATCH 07/25] docs: Sketch the memory/vault design and the status-hook privacy boundary --- .sloop/skills/.gitkeep | 0 docs/design/hooks.md | 81 +++++++++++++-- docs/design/memory.md | 114 ++++++++++++++++++++++ internal/cli/commands/runwflag_test.go | 2 +- internal/cli/commands/skilladd_test.go | 2 +- internal/cli/commands/skillcmd_test.go | 2 +- internal/cli/commands/skillupdate_test.go | 8 +- internal/cli/commands/statusls_test.go | 4 +- 8 files changed, 198 insertions(+), 15 deletions(-) delete mode 100644 .sloop/skills/.gitkeep create mode 100644 docs/design/memory.md diff --git a/.sloop/skills/.gitkeep b/.sloop/skills/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/docs/design/hooks.md b/docs/design/hooks.md index d0e8a89..463ee4a 100644 --- a/docs/design/hooks.md +++ b/docs/design/hooks.md @@ -1,14 +1,24 @@ # Hooks — status today, portable workflow automation next AI CLIs expose **hooks**: events that fire at points in a session (per-session, per-turn, per -tool-call) where a command can observe, react, or block. Sloop uses hooks two ways — and keeping the -two cleanly separated is the whole design. +tool-call) where a command can observe, react, or block. Sloop uses hooks three ways — and keeping +them cleanly separated is the whole design: -> **Status hooks** ship today. **Workflow hooks** are a v0.2.0 proposal — this doc is the contract to -> review *before* it's built. Command reference for status hooks: [USAGE.md](../guide/USAGE.md); the install -> mechanism: [ADAPTERS.md §Hook install strategies](../reference/ADAPTERS.md). +1. **Status hooks** (shipped) — observe state for `sloop ps`. +2. **Workflow hooks** (v0.2.0 proposal) — run the user's portable automation. +3. **Context-inject hooks** (proposal) — deliver sloop's portable context / memory into a session at + `SessionStart`, without writing into the tool's own context file. -## Two kinds of hook (don't conflate them) +> **Status hooks** ship today. **Workflow hooks** and **context-inject hooks** are proposals — this doc +> is the contract to review *before* they're built. Command reference for status hooks: +> [USAGE.md](../guide/USAGE.md); the install mechanism: +> [ADAPTERS.md §Hook install strategies](../reference/ADAPTERS.md). + +## Status vs. workflow hooks (don't conflate them) + +These two write into the *same* provider config, so the bulk of the design is keeping them apart. +(Context-inject hooks live on a different event and are read-only, so they don't share this hazard — +see their own section below.) | | **Status hooks** (shipped) | **Workflow hooks** (v0.2.0) | |---|---|---| @@ -41,6 +51,28 @@ writes a short-lived marker under `~/.sloop/state` that `sloop ps` prefers over in the adapter manifest. Auto-install today: **claude, gemini, cursor**; **copilot, codex** are `print+paste` pending a matcher-aware model (below). See [ADAPTERS.md](../reference/ADAPTERS.md). +### Privacy: status hooks never read your session + +This is a deliberate boundary and a promise we make to users. Sloop's status hook emits **one state +word** — `waiting` / `working` / `idle` plus a timestamp — into a local marker file. It does **not**: + +- read the prompt or the model's response, +- read file contents or tool inputs, +- make any network call. + +Contrast a typical lifecycle bridge (e.g. the `claude-code-warp` plugin), whose `Stop` hook *reads the +session, extracts the last prompt + response, and ships a summary* to an external notification center. +That is a real content-exfiltration surface; sloop's fleet awareness deliberately stays at the +state-enum level, so "sloop knows an agent is idle" never means "sloop read what it was doing." + +The wiring is auditable end to end: `sloop hooks print` shows the exact line that gets added, the +command is literally `sloop hooks emit `, it installs into `settings.local.json` (not +committed), the merge is idempotent, and any hand-written ("foreign") hooks in the file are preserved. + +The only surface that *does* execute arbitrary code is **workflow hooks** (below) — which is exactly +why they are gated behind a lockfile + checksum + review-before-run, and why the internal status +callback is namespaced as `hooks emit` rather than a bare top-level `hook`. + ## Workflow hooks (v0.2.0 proposal) Turn hooks into a **portable automation library**: pick a hook, sloop installs it into the right @@ -94,6 +126,43 @@ runs on a teammate's machine. So, unlike skills, the library needs: AI tool. - Each definition declares its target mechanism; sloop installs into that and nowhere else. +## Context-inject hooks — the memory/vault bridge (proposal) + +Today sloop delivers portable context by **writing files**: `AGENTS.md` plus per-tool pointers +(`CLAUDE.md`, etc.). That works, but it has two limits: it touches files the provider considers its +own, and it's static — it can't reflect what the workspace has *learned* between sessions. + +A `SessionStart` hook is the third delivery channel, and it's the one that unlocks the **memory / +vault** direction (design to live in `docs/design/memory.md`). The pattern is the +Superpowers one: register a single read-only hook that, at session start, prints sloop's relevant +context to stdout — the provider injects that into the session as additional context. Sloop never +rewrites the user's prompt and never edits the tool's `CLAUDE.md`; it speaks through the provider's +**own** `SessionStart` mechanism. + +```text +SessionStart → `sloop context emit` → prints relevant vault/AGENTS context → provider injects it +``` + +Why this is the right shape for memory: + +- **Dynamic, not static.** The hook runs each session, so it can surface what was learned/saved since + last time — the static `AGENTS.md` can't. +- **Provider-respecting.** It's the *third* way the user asked about ("write into AGENTS.md, or + CLAUDE.md, or through our hooks") — and the only one that doesn't write into the tool's files. +- **Same trust posture as status hooks.** Injection is **read-only and outbound-only**: sloop emits + context it already owns under `.sloop/`; it does not read the session. No new exfiltration surface. +- **No bundled LLM.** Selection of "relevant" context stays plumbing (recency, the active workspace, + simple matching). Any real retrieval is delegated to the agent — sloop is not the model. + +Open design questions specific to this channel (to be settled in `docs/design/memory.md`, not here): + +- What "relevant context" means at session start (whole vault vs. a recency/scoped slice) and the + token budget for an injection. +- How memory gets *written* (a `SessionEnd`/`Stop` capture hook? an explicit `sloop remember`?) — the + write path is a separate decision from this read path. +- Per-provider `SessionStart` support + payload shape in the adapter manifest (which CLIs expose it, + and whether non-supporting tools fall back to the file-pointer delivery). + ## Open questions (feedback wanted before the build) - Library distribution: embedded built-ins in this repo first (review via PR, ship with the binary, diff --git a/docs/design/memory.md b/docs/design/memory.md new file mode 100644 index 0000000..2c3a907 --- /dev/null +++ b/docs/design/memory.md @@ -0,0 +1,114 @@ +# Memory / vault — the 2nd-brain bridge (DRAFT design) + +> **Status: skeleton for discussion.** Nothing here is built or decided. This captures *what we want +> to do* and the constraints it must respect, so we can review and argue the shape before writing +> code. The delivery mechanism (a read-only `SessionStart` context-inject hook) is sketched in +> [hooks.md §Context-inject hooks](hooks.md); this doc owns the memory model itself. + +## The problem + +Portable context today is **static and write-once**: `init`/`sync` write `AGENTS.md` + per-tool +pointers, and that's it. Two gaps follow: + +1. **Nothing is learned.** A decision made or a gotcha hit in session A is gone by session B. The user + re-explains the same things to the same (or a different) tool. +2. **Nothing is shared across the fleet.** sloop's wedge is portable context + cross-repo fleet, but + "context" is currently just a file the user maintains by hand — not something the fleet accrues. + +The goal: a **2nd brain** that the workspace accrues over time and replays into any tool, any session, +any repo — without sloop becoming the model. + +## Goals + +- **Capture** durable facts/decisions/gotchas from real sessions (or explicit user input) into a local + store. +- **Replay** the relevant slice back into a fresh session automatically, via the provider's own + `SessionStart` mechanism (no rewriting prompts, no editing the tool's `CLAUDE.md`). +- **Portable** across the five+ CLIs and across repos — same store, any tool reads it. +- **Provider-respecting and private by construction** — same posture as status hooks (read-only, + outbound-only, local; see hooks.md privacy note). + +## Non-goals (bright lines) + +- **No bundled LLM / RAG / embeddings / vector DB.** sloop is plumbing. "Relevance" is mechanical + (recency, active workspace, simple matching), and any real semantic retrieval is delegated to the + agent — *it* is the LLM. The moment we'd reach for embeddings, we stop and hand the job to the tool. +- **No silent capture of session content.** Writing to memory is an explicit, visible act (see the + write-path question) — never a hook that quietly scrapes prompts/responses. +- **Not a knowledge base / wiki / sync service.** The vault is local notes the fleet replays, not a + hosted product. + +## Shape (two paths, decided separately) + +Memory splits into a **read path** (replay) and a **write path** (capture). They're independent +decisions; we can ship the read path against a hand-written vault long before automating capture. + +### Read path — replay (closest to ready) + +```text +SessionStart → `sloop context emit` → selects relevant vault slice → provider injects it as context +``` + +- Read-only, outbound-only. Emits content sloop already owns under `.sloop/`. No session reading. +- Selection is plumbing: recency + active workspace + simple matching, under a token budget. +- Falls back to file-pointer delivery for tools that don't expose `SessionStart`. + +### Write path — capture (more open) + +How does a fact *get into* the vault? Candidates, not yet chosen: + +- **Explicit** — `sloop remember ""` (and/or a `/remember`-style provider command). Highest + trust, zero magic, user-curated. Likely the v1. +- **Prompted capture** — a `SessionEnd`/`Stop` hook that *asks* the user "save anything from this + session?" rather than auto-scraping. Keeps the human in the loop. +- **Agent-authored** — the tool itself writes a note via a skill/command when it learns something. + Delegates the "what's worth remembering" judgment to the model (consistent with the no-LLM line). + +We deliberately **avoid** an auto-`Stop`-hook that reads and stores session content silently — that +would cross the privacy line we drew for status hooks. + +## Storage model (sketch) + +- Lives under `.sloop/vault/` (already scaffolded by `init`, already in `.sloop/.gitignore` as + personal/not-shared by default). +- Plain files (Markdown, one fact per file?) so it's greppable, diffable, and editable by hand — and + so the agent can read/write it with its normal file tools. Mirrors how *this* assistant's own + `MEMORY.md` index + per-fact files work; that pattern is a candidate to copy outright. +- **Open:** per-fact files + an index vs. a single file; frontmatter schema (type, scope, created); + personal (gitignored) vs. team-shared (committed) tiers; per-workspace vs. a global cross-repo vault + (the cross-repo wedge probably wants *both*: a global brain + a per-workspace overlay). + +## Open questions (the things to argue in review) + +- **Relevance without a model.** What's the actual selection rule for "what to inject at SessionStart"? + Whole vault (simple, but blows the token budget fast) vs. a recency/scope slice vs. tag-matching on + the workspace. Where's the budget cap? +- **Write trigger.** Explicit `sloop remember` first, or invest in prompted capture early? What's the + smallest thing that's genuinely useful? +- **Scope & sharing.** Per-workspace vs. global brain; personal (gitignored) vs. committed/team. How do + the two layers compose at injection time? +- **Cross-repo replay.** The wedge says context should follow you across repos — does a global vault + inject into *every* workspace, or only on demand? How is "this fact is about repo X" expressed? +- **Provider coverage.** Which CLIs expose `SessionStart` (and an injectable payload)? Manifest field + + fallback for those that don't. +- **De-dup / decay.** How do stale or contradicted facts get pruned? (Our own memory rules — "update + the existing file, delete what's wrong" — are a candidate policy.) +- **Interop.** Should sloop's vault read/emit alongside a tool's *native* memory (e.g. a provider's own + memory feature) rather than compete with it? + +## Phasing (proposal) + +1. **Read path on a hand-written vault.** `sloop context emit` + one provider's `SessionStart` wiring; + user maintains `.sloop/vault/` by hand. Proves the injection channel end to end. +2. **Explicit capture.** `sloop remember` writes a fact; emit picks it up next session. +3. **Selection + scope.** Recency/scope slicing, token budget, per-workspace vs. global layering. +4. **Cross-repo brain.** Global vault that replays into any workspace; team-shared tier. + +(Each phase is independently shippable and independently reviewable. We are not committing to past +phase 1 until phase 1 earns it.) + +## Related + +- [hooks.md](hooks.md) — the three hook uses; the `SessionStart` context-inject channel this doc rides + on, and the privacy posture it inherits. +- Product direction: portable context + cross-repo fleet is the wedge; keep LLM out of the core. diff --git a/internal/cli/commands/runwflag_test.go b/internal/cli/commands/runwflag_test.go index 231f00f..0df5934 100644 --- a/internal/cli/commands/runwflag_test.go +++ b/internal/cli/commands/runwflag_test.go @@ -11,7 +11,7 @@ import ( func TestResolveStartDirFromRegistry(t *testing.T) { dir := t.TempDir() t.Setenv("HOME", t.TempDir()) - if _, err := RunInit(dir, false); err != nil { + if _, err := RunInit(dir, nil, false); err != nil { t.Fatalf("RunInit: %v", err) } // init registered the workspace under base(dir). diff --git a/internal/cli/commands/skilladd_test.go b/internal/cli/commands/skilladd_test.go index 9503403..f87246a 100644 --- a/internal/cli/commands/skilladd_test.go +++ b/internal/cli/commands/skilladd_test.go @@ -27,7 +27,7 @@ func TestRunSkillAddFetchesAndLinks(t *testing.T) { dir := t.TempDir() t.Setenv("HOME", t.TempDir()) t.Setenv("PATH", t.TempDir()) // claude fallback (has .claude/skills target) - if _, err := RunInit(dir, false); err != nil { + if _, err := RunInit(dir, nil, false); err != nil { t.Fatalf("RunInit: %v", err) } diff --git a/internal/cli/commands/skillcmd_test.go b/internal/cli/commands/skillcmd_test.go index 4a57b4b..10730ce 100644 --- a/internal/cli/commands/skillcmd_test.go +++ b/internal/cli/commands/skillcmd_test.go @@ -10,7 +10,7 @@ func TestRunSkillNewCreatesAndLinks(t *testing.T) { dir := t.TempDir() t.Setenv("HOME", t.TempDir()) t.Setenv("PATH", t.TempDir()) // empty PATH → claude fallback (has .claude/skills target) - if _, err := RunInit(dir, false); err != nil { + if _, err := RunInit(dir, nil, false); err != nil { t.Fatalf("RunInit: %v", err) } path, linked, err := RunSkillNew(dir, "review") diff --git a/internal/cli/commands/skillupdate_test.go b/internal/cli/commands/skillupdate_test.go index e6661fc..3f348bd 100644 --- a/internal/cli/commands/skillupdate_test.go +++ b/internal/cli/commands/skillupdate_test.go @@ -23,7 +23,7 @@ func TestSkillAddRecordsLock(t *testing.T) { dir := t.TempDir() t.Setenv("HOME", t.TempDir()) t.Setenv("PATH", t.TempDir()) - if _, err := RunInit(dir, false); err != nil { + if _, err := RunInit(dir, nil, false); err != nil { t.Fatalf("RunInit: %v", err) } addLockedSkill(t, dir, "# Review\n\nv1\n") @@ -45,7 +45,7 @@ func TestRunSkillUpdateRefetches(t *testing.T) { dir := t.TempDir() t.Setenv("HOME", t.TempDir()) t.Setenv("PATH", t.TempDir()) - if _, err := RunInit(dir, false); err != nil { + if _, err := RunInit(dir, nil, false); err != nil { t.Fatalf("RunInit: %v", err) } addLockedSkill(t, dir, "# Review\n\nv1\n") @@ -81,7 +81,7 @@ func TestRunSkillUpdateUnknownName(t *testing.T) { dir := t.TempDir() t.Setenv("HOME", t.TempDir()) t.Setenv("PATH", t.TempDir()) - if _, err := RunInit(dir, false); err != nil { + if _, err := RunInit(dir, nil, false); err != nil { t.Fatalf("RunInit: %v", err) } if _, err := RunSkillUpdate(dir, []string{"nope"}); err == nil { @@ -93,7 +93,7 @@ func TestRunSkillUpdateEmptyLock(t *testing.T) { dir := t.TempDir() t.Setenv("HOME", t.TempDir()) t.Setenv("PATH", t.TempDir()) - if _, err := RunInit(dir, false); err != nil { + if _, err := RunInit(dir, nil, false); err != nil { t.Fatalf("RunInit: %v", err) } res, err := RunSkillUpdate(dir, nil) diff --git a/internal/cli/commands/statusls_test.go b/internal/cli/commands/statusls_test.go index 5b8c85b..f6c3dc6 100644 --- a/internal/cli/commands/statusls_test.go +++ b/internal/cli/commands/statusls_test.go @@ -8,7 +8,7 @@ import ( func TestRunStatusShowsWorkspaceAndStale(t *testing.T) { dir := t.TempDir() t.Setenv("HOME", t.TempDir()) - if _, err := RunInit(dir, false); err != nil { + if _, err := RunInit(dir, nil, false); err != nil { t.Fatalf("RunInit: %v", err) } var b strings.Builder @@ -24,7 +24,7 @@ func TestRunStatusShowsWorkspaceAndStale(t *testing.T) { func TestRunLsListsWorkspace(t *testing.T) { dir := t.TempDir() t.Setenv("HOME", t.TempDir()) - if _, err := RunInit(dir, false); err != nil { + if _, err := RunInit(dir, nil, false); err != nil { t.Fatalf("RunInit: %v", err) } var b strings.Builder From 1ad37c0a9827c7a4ee99dcaa934c3fe55977d360 Mon Sep 17 00:00:00 2001 From: Tuan Ngo Date: Sun, 28 Jun 2026 19:40:02 +0700 Subject: [PATCH 08/25] =?UTF-8?q?docs:=20Design=20sloop=20peek=20=E2=80=94?= =?UTF-8?q?=20overlay=20into=20a=20waiting=20agent=20without=20losing=20yo?= =?UTF-8?q?ur=20spot?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../2026-06-28-sloop-peek-overlay-design.md | 156 ++++++++++++++++++ 1 file changed, 156 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-28-sloop-peek-overlay-design.md diff --git a/docs/superpowers/specs/2026-06-28-sloop-peek-overlay-design.md b/docs/superpowers/specs/2026-06-28-sloop-peek-overlay-design.md new file mode 100644 index 0000000..721dc2c --- /dev/null +++ b/docs/superpowers/specs/2026-06-28-sloop-peek-overlay-design.md @@ -0,0 +1,156 @@ +# Design: `sloop peek` — overlay into a waiting agent without losing your spot + +**Date:** 2026-06-28 +**Status:** Approved design, ready for implementation plan +**Scope:** Phase 1 + Phase 2 (Phase 3 deferred) + +## Problem + +When you orchestrate a cross-repo fleet, you work from one "home base" session +while agents run in other tmux sessions. When an agent hits a hook that needs +your input (it goes `waiting`), the only way to answer it today is +`sloop attach`, which — because `attach` cannot nest inside tmux — falls back to +`switch-client` (`internal/tmux/fleet.go:27`). That **replaces your whole +screen** with the agent's session. You answer, then `switch-client` back. You +lose your spot and your context every round trip. + +What we actually want: from home base, get a quiet signal that an agent needs +you, press one key to **float that agent's live pane over your current screen**, +answer it in place, dismiss it, and be exactly where you were. + +This is the "peek and drop" flow. It is the unique thing tmux overlays enable +that a screen-replacing `switch-client` cannot. + +## Non-goals (deliberately deferred) + +- **Phase 3 — non-modal floating panes (tmux ≥ 3.7 `new-pane`).** A modal + `display-popup` is well-matched to "answer and dismiss," so non-modal is + polish, not foundation. It becomes a progressive enhancement behind a future + `FloatingSupported()` (≥ 3.7) once 3.7 is widespread. Not in this spec. +- **True auto-pop.** We will not auto-open an overlay onto the user mid-keystroke. + Surfacing is always user-triggered. A future opt-in config knob can add + aggressive auto-pop for those who want it. +- **Watching multiple agents floating at once**, move/resize, custom layouts. + +## Design overview: "Signal → Act" + +Two layers ship here. (The earlier three-layer framing folds the standalone HUD +glance into Phase 3; Phase 1+2 is the convenient core.) + +### Layer A — Signal (ambient, never interrupts) + +The status hooks already write a fresh `waiting | working | idle` marker per +session (`internal/fleetstate/state.go`, read via `fleetstate.Read`). We surface +a **fleet-wide waiting count** on the home-base session's status line, e.g. +`⏳ 2 waiting`, rendered empty when zero so the bar stays clean. + +The status line is drawn on every window/client the user looks at, so this +signal "follows the user" for free, on any tmux version, with zero interruption. + +- New hidden render command (called by tmux `#()`), parallel to the existing + per-session `statusline` (`internal/cli/commands/statusline.go`). Proposed: + `sloop statusline fleet` → counts `waiting` across all sloop sessions + (`tmux.ParseSessions(tmuxList())` + `fleetstate.Read`, with the same + capture-pane fallback `renderStatusline` already uses) and prints a short + badge to stdout (empty string when zero). +- A setup path adds it to `status-right` of the home-base session, mirroring the + existing `statusline setup` that sets `status-left`-style per-session output. + +### Layer B — Act (`sloop peek`) + +A new command that overlays a target agent's **live session** in a +`display-popup`, using a nested attach so the home-base client is never touched. + +``` +sloop peek [agent] + no arg → resolve target (see below) + agent → peek that session directly +``` + +**Target resolution (the "convenient" part):** +1. exactly one agent is `waiting` → peek it directly, no prompt. +2. more than one `waiting` → show the fleet picker (`pickFleetSession`, which is + already sorted waiting-first) so you choose. +3. none `waiting` → fall back to the same picker over the whole fleet. + +**The overlay mechanism (the one tricky part):** +`display-popup -E` runs its command with `$TMUX` still set, so a plain +`tmux attach` inside it would be refused ("sessions should be nested with care"). +We clear `TMUX` for the inner command so tmux allows a **fresh nested client**: + +``` +display-popup -w 90% -h 80% -E 'TMUX= attach -t ' +``` + +Closing the popup kills that nested client → it detaches → **the agent session +keeps running**. The home-base client was never switched. + +Caveats to verify during implementation (call-outs for the plan, not blockers): +- Nested attach clamps the session's window size to the smallest attached + client while the popup is open. Acceptable for a peek; note it. +- Confirm closing the popup detaches cleanly and never kills the agent session. +- Confirm `TMUX=` (and clearing `TMUX_PANE` if needed) reliably permits the + nested attach across tmux 3.2–3.6. + +**Keybind without double-popup:** +Pressing the bound key must open exactly one popup. So the keybind points +straight at the popup, and the *inner* command does target resolution (it has a +TTY inside the popup) then `exec`s the nested attach: + +``` +bind-key p display-popup -w 90% -h 80% -E " peek --in-popup" +``` + +- `sloop peek` (from a normal shell) wraps the inner command in the popup itself. +- `sloop peek --in-popup` (flag name TBD in plan): already inside a popup — + resolve target, then exec the `TMUX=`-cleared nested attach. No extra popup. +- `sloop peek setup [--key p]` installs the bind on the live server and prints + the `~/.tmux.conf` line, mirroring `popup setup` + (`internal/cli/commands/popup.go`). + +## Component breakdown + +| Unit | Location (proposed) | Responsibility | +|---|---|---| +| `NestedAttachCmd(session) string` | `internal/tmux/` | Build the `TMUX=`-cleared nested-attach shell command (testable seam, like `attachArgs`). | +| `BuildPeekPopupArgs(w,h,inner)` | `internal/tmux/popup.go` | Reuse `BuildPopupArgs`; peek just supplies a bigger default size. | +| `Peek(session)` | `internal/tmux/popup.go` | Run the outer `display-popup` wrapping the inner command. | +| peek target resolver | `internal/cli/commands/` | 0/1/many-waiting → direct or picker. Reuses `pickFleetSession` + `fleetstate`. | +| `peek` cobra command (+`--in-popup`, `setup`) | `internal/cli/commands/peek.go` | Wire the above; require tmux + `PopupSupported()`. | +| fleet waiting-count render | `internal/cli/commands/statusline.go` | `sloop statusline fleet` badge (empty when zero). | +| fleet statusline setup | `internal/cli/commands/statusline.go` | Add the badge to home-base `status-right`. | + +## Error & edge handling + +- **Not inside tmux** (`$TMUX` empty): peek has no meaning (nothing to overlay + over). Error clearly and point to `sloop attach`. +- **tmux < 3.2** (`!PopupSupported()`): no `display-popup`. Error and point to + `sloop attach`. (Peek's value *is* the overlay; silently switch-clienting + would defeat the purpose, so we don't.) +- **No sessions / empty fleet:** the resolver surfaces the same "no running AI + sessions" error `pickFleetSession` already returns. +- **Stale/missing markers:** `fleetstate.Read` already returns `ok=false` for + stale markers; resolver and badge fall back to the capture-pane heuristic the + status line already uses, so a missing hook never hides a waiting agent. + +## Testing + +- **Unit:** `NestedAttachCmd` / `BuildPeekPopupArgs` string shape (seam tests, + like `TestBuildTmuxAttachArgs`). Resolver target selection across 0/1/many + `waiting` with injected fleet rows + fleetstate. Fleet badge render: `0 → + ""`, `N → "⏳ N waiting"`. +- **e2e:** extend `e2e/fleet_test.go` to assert peek's popup arg construction and + that resolution picks the waiting session; the fully interactive nested attach + is left to manual verification (the caveats above), since a real attach needs a + live TTY. + +## Why this is the simple, convenient core + +- Ships on **today's tmux (≥ 3.2)** — every user, not just 3.7. +- Modal popup fits "answer and dismiss" exactly; no non-modal complexity needed. +- Reuses what exists: status hooks, `fleetstate`, `pickFleetSession`, + `BuildPopupArgs`, `BuildAttachArgs`, the `popup setup` keybind pattern. +- Keeps orchestration minimal, matching the project's wedge (portable context + + cross-repo fleet, not heavyweight in-repo orchestration). +- The tmux 3.7 feature that prompted this (floating panes) is correctly placed as + an optional later enhancement, not a dependency. From f5f992208d38ee26fb1e4782640e0ac368a34dc0 Mon Sep 17 00:00:00 2001 From: Tuan Ngo Date: Sun, 28 Jun 2026 19:51:56 +0700 Subject: [PATCH 09/25] docs: Move peek design into docs/design and ignore superpowers scratch --- .gitignore | 4 +++- .../peek.md} | 0 2 files changed, 3 insertions(+), 1 deletion(-) rename docs/{superpowers/specs/2026-06-28-sloop-peek-overlay-design.md => design/peek.md} (100%) diff --git a/.gitignore b/.gitignore index 1b978ed..d26b0c9 100644 --- a/.gitignore +++ b/.gitignore @@ -42,4 +42,6 @@ dev-notes.md _features/ # runtime -*.pid \ No newline at end of file +*.pid +# brainstorming scratch (tooling-specific, not for public repo) +docs/superpowers/ diff --git a/docs/superpowers/specs/2026-06-28-sloop-peek-overlay-design.md b/docs/design/peek.md similarity index 100% rename from docs/superpowers/specs/2026-06-28-sloop-peek-overlay-design.md rename to docs/design/peek.md From 09b1c176cded58f28de57551a8f0fc3d9706384b Mon Sep 17 00:00:00 2001 From: Tuan Ngo Date: Sun, 28 Jun 2026 19:55:40 +0700 Subject: [PATCH 10/25] docs: Simplify peek signal layer to augment the existing statusline render --- docs/design/peek.md | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/docs/design/peek.md b/docs/design/peek.md index 721dc2c..ddd8407 100644 --- a/docs/design/peek.md +++ b/docs/design/peek.md @@ -47,14 +47,16 @@ a **fleet-wide waiting count** on the home-base session's status line, e.g. The status line is drawn on every window/client the user looks at, so this signal "follows the user" for free, on any tmux version, with zero interruption. -- New hidden render command (called by tmux `#()`), parallel to the existing - per-session `statusline` (`internal/cli/commands/statusline.go`). Proposed: - `sloop statusline fleet` → counts `waiting` across all sloop sessions - (`tmux.ParseSessions(tmuxList())` + `fleetstate.Read`, with the same - capture-pane fallback `renderStatusline` already uses) and prints a short - badge to stdout (empty string when zero). -- A setup path adds it to `status-right` of the home-base session, mirroring the - existing `statusline setup` that sets `status-left`-style per-session output. +- **Mechanism (refined for simplicity):** rather than a separate command + a + new setup step, we **append the badge to the existing per-session + `renderStatusline`** (`internal/cli/commands/statusline.go`). Every sloop + session already points its `status-right` at `sloop statusline ` + (`tmux.SetStatusLine`), so the badge appears on every session's bar + automatically — it "follows you" for free with zero new setup. +- The badge counts sessions whose **fresh** marker is `waiting` + (`fleetstate.Read`, markers only — no capture-pane), **excluding the current + session** so it reads "others need you." Empty string when zero, so the bar + stays clean. Markers-only keeps this cheap enough to run every status-interval. ### Layer B — Act (`sloop peek`) @@ -117,8 +119,7 @@ bind-key p display-popup -w 90% -h 80% -E " peek --in-popup" | `Peek(session)` | `internal/tmux/popup.go` | Run the outer `display-popup` wrapping the inner command. | | peek target resolver | `internal/cli/commands/` | 0/1/many-waiting → direct or picker. Reuses `pickFleetSession` + `fleetstate`. | | `peek` cobra command (+`--in-popup`, `setup`) | `internal/cli/commands/peek.go` | Wire the above; require tmux + `PopupSupported()`. | -| fleet waiting-count render | `internal/cli/commands/statusline.go` | `sloop statusline fleet` badge (empty when zero). | -| fleet statusline setup | `internal/cli/commands/statusline.go` | Add the badge to home-base `status-right`. | +| fleet waiting badge | `internal/cli/commands/statusline.go` | Append a fleet-wide `⏳ N waiting` badge (empty when zero, excludes self) to the existing per-session `renderStatusline` — no new command or setup. | ## Error & edge handling From 8b1919633b210121ef8adbd74ae021a5396926f4 Mon Sep 17 00:00:00 2001 From: Tuan Ngo Date: Sun, 28 Jun 2026 19:58:06 +0700 Subject: [PATCH 11/25] feat: Add tmux peek helpers for nested-attach overlay --- internal/tmux/peek.go | 69 ++++++++++++++++++++++++++++++++++++++ internal/tmux/peek_test.go | 31 +++++++++++++++++ 2 files changed, 100 insertions(+) create mode 100644 internal/tmux/peek.go create mode 100644 internal/tmux/peek_test.go diff --git a/internal/tmux/peek.go b/internal/tmux/peek.go new file mode 100644 index 0000000..aa1cf60 --- /dev/null +++ b/internal/tmux/peek.go @@ -0,0 +1,69 @@ +package tmux + +import ( + "fmt" + "os" + "os/exec" + "strings" +) + +// peekPopupW/H are the default overlay size for peek — bigger than the HUD +// because you actually work inside the agent while it floats. +const ( + peekPopupW = "90%" + peekPopupH = "80%" +) + +// shellSingleQuote wraps s in single quotes safe for /bin/sh, escaping any +// embedded single quotes. Session names are simple, but never trust input. +func shellSingleQuote(s string) string { + return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'" +} + +// BuildNestedAttachCmd returns a /bin/sh command that attaches to session as a +// fresh nested client. display-popup runs with $TMUX set, which would make a +// plain `tmux attach` refuse ("sessions should be nested with care"), so we +// clear TMUX/TMUX_PANE for just this command. Closing the popup kills this +// client → it detaches → the agent session keeps running. +func BuildNestedAttachCmd(session string) string { + return fmt.Sprintf("env -u TMUX -u TMUX_PANE %s attach -t %s", Bin(), shellSingleQuote(session)) +} + +// NestedAttach attaches to session in the current TTY with TMUX cleared, used +// when we are already inside a popup (the keybind path) so no second popup opens. +func NestedAttach(session string) error { + cmd := exec.Command(Bin(), "attach", "-t", session) + cmd.Env = clearTmuxEnv(os.Environ()) + cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr + return cmd.Run() +} + +// clearTmuxEnv drops TMUX and TMUX_PANE so a nested attach is permitted. +func clearTmuxEnv(env []string) []string { + out := make([]string, 0, len(env)) + for _, e := range env { + if strings.HasPrefix(e, "TMUX=") || strings.HasPrefix(e, "TMUX_PANE=") { + continue + } + out = append(out, e) + } + return out +} + +// Peek floats session's live pane over the current pane in a display-popup +// (must be run inside tmux ≥ 3.2). The overlay closes back to your work on exit. +func Peek(session string) error { + cmd := exec.Command(Bin(), BuildPopupArgs(peekPopupW, peekPopupH, BuildNestedAttachCmd(session))...) + cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr + return cmd.Run() +} + +// BuildPeekBindArgs binds to open the peek popup running command. +func BuildPeekBindArgs(key, command string) []string { + return append([]string{"bind-key", key}, BuildPopupArgs(peekPopupW, peekPopupH, command)...) +} + +// BindPeek binds the peek key on the live tmux server. +func BindPeek(key, command string) error { + return Run(BuildPeekBindArgs(key, command)...) +} diff --git a/internal/tmux/peek_test.go b/internal/tmux/peek_test.go new file mode 100644 index 0000000..4dec330 --- /dev/null +++ b/internal/tmux/peek_test.go @@ -0,0 +1,31 @@ +package tmux + +import ( + "strings" + "testing" +) + +func TestShellSingleQuote(t *testing.T) { + if got := shellSingleQuote("ws__claude"); got != "'ws__claude'" { + t.Fatalf("plain: got %q", got) + } + if got := shellSingleQuote("a'b"); got != `'a'\''b'` { + t.Fatalf("embedded quote: got %q", got) + } +} + +func TestBuildNestedAttachCmd(t *testing.T) { + got := BuildNestedAttachCmd("ws__claude") + want := "env -u TMUX -u TMUX_PANE " + Bin() + " attach -t 'ws__claude'" + if got != want { + t.Fatalf("got %q want %q", got, want) + } +} + +func TestBuildPeekBindArgs(t *testing.T) { + got := BuildPeekBindArgs("p", "CMD") + want := []string{"bind-key", "p", "display-popup", "-w", "90%", "-h", "80%", "-E", "CMD"} + if strings.Join(got, " ") != strings.Join(want, " ") { + t.Fatalf("got %v want %v", got, want) + } +} From b56d973c6808b6c511c1260644dffe483d5af571 Mon Sep 17 00:00:00 2001 From: Tuan Ngo Date: Sun, 28 Jun 2026 19:58:54 +0700 Subject: [PATCH 12/25] feat: Add sloop peek to overlay a waiting agent without switching screens --- internal/cli/commands/peek.go | 122 +++++++++++++++++++++++++++++ internal/cli/commands/peek_test.go | 39 +++++++++ internal/cli/commands/registry.go | 1 + 3 files changed, 162 insertions(+) create mode 100644 internal/cli/commands/peek.go create mode 100644 internal/cli/commands/peek_test.go diff --git a/internal/cli/commands/peek.go b/internal/cli/commands/peek.go new file mode 100644 index 0000000..383bb65 --- /dev/null +++ b/internal/cli/commands/peek.go @@ -0,0 +1,122 @@ +package commands + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" + + "github.com/stroops/sloop/internal/adapter" + "github.com/stroops/sloop/internal/tmux" +) + +// soleWaiting returns the session name when exactly one row is waiting, so +// `sloop peek` with no argument can jump straight to the agent that needs you. +func soleWaiting(rows []FleetRow) (string, bool) { + name, n := "", 0 + for _, r := range rows { + if r.Status == tmux.StatusWaiting { + name = r.Name + n++ + } + } + if n == 1 { + return name, true + } + return "", false +} + +// resolvePeekTarget chooses which agent to peek: an explicit arg wins; else a +// lone waiting agent is auto-selected; 0 or many fall back to the fleet picker +// (already sorted waiting-first). +func resolvePeekTarget(args []string) (string, error) { + if len(args) == 1 { + return args[0], nil + } + manifests, _ := adapter.Load() + rows := enrichGlances(fleetRows(tmux.ParseSessions(tmuxList())), manifests) + if len(rows) == 0 { + return "", fmt.Errorf("no running AI sessions to peek (start one with `sloop run `)") + } + if name, ok := soleWaiting(rows); ok { + return name, nil + } + return pickFleetSession("👀 Peek into which agent?") +} + +// requirePeekEnv guards peek: it only makes sense as an overlay inside tmux ≥ 3.2. +func requirePeekEnv() error { + if os.Getenv("TMUX") == "" { + return fmt.Errorf("`sloop peek` overlays an agent inside tmux; outside tmux use `sloop attach`") + } + if !tmux.PopupSupported() { + return fmt.Errorf("`sloop peek` needs tmux ≥ 3.2 for display-popup; use `sloop attach` instead") + } + return nil +} + +var peekInPopup bool + +var peekCmd = &cobra.Command{ + Use: "peek [agent]", + Aliases: []string{"pk"}, + Short: "Overlay a waiting agent, answer it, and drop back — without losing your spot", + Long: `Float a running agent's live session in a tmux popup over your current +pane: answer the prompt it is blocked on, then close the popup and you are +exactly where you were — unlike ` + "`sloop attach`" + `, which switches your whole +screen to the agent. With no argument, peek jumps straight to the lone waiting +agent, or shows the fleet picker when several (or none) are waiting. Bind it to +a key with ` + "`sloop peek setup`" + `. Needs tmux ≥ 3.2.`, + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if err := requirePeekEnv(); err != nil { + return err + } + target, err := resolvePeekTarget(args) + if err != nil || target == "" { + return err + } + if peekInPopup { + // Already inside the keybind's popup: attach directly, no second popup. + return tmux.NestedAttach(target) + } + return tmux.Peek(target) + }, +} + +var peekKey string + +var peekSetupCmd = &cobra.Command{ + Use: "setup", + Short: "Bind to peek the waiting agent (and print the .tmux.conf line)", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + if !tmux.PopupSupported() { + return fmt.Errorf("`sloop peek setup` needs tmux ≥ 3.2") + } + exe, err := os.Executable() + if err != nil { + exe = "sloop" + } + inner := exe + " peek --in-popup" + bindLine := fmt.Sprintf(`bind-key %s display-popup -w 90%% -h 80%% -E "%s"`, peekKey, inner) + if tmux.Available() { + if err := tmux.BindPeek(peekKey, inner); err == nil { + cmd.Printf("bound for this tmux server: press your prefix then %s to peek the waiting agent\n", peekKey) + } else { + cmd.Printf("warning: failed to bind for this tmux server: %v\n", err) + } + } + cmd.Printf("add this to ~/.tmux.conf to make it permanent:\n %s\n", bindLine) + return nil + }, +} + +func RegisterPeek(cmd *cobra.Command) { + peekCmd.Flags().BoolVar(&peekInPopup, "in-popup", false, "internal: already inside a popup, attach directly") + _ = peekCmd.Flags().MarkHidden("in-popup") + peekSetupCmd.Flags().StringVar(&peekKey, "key", "p", "tmux key to bind (pressed after your prefix)") + peekCmd.AddCommand(peekSetupCmd) + peekCmd.ValidArgsFunction = completeSessionNames + cmd.AddCommand(peekCmd) +} diff --git a/internal/cli/commands/peek_test.go b/internal/cli/commands/peek_test.go new file mode 100644 index 0000000..83f5f7a --- /dev/null +++ b/internal/cli/commands/peek_test.go @@ -0,0 +1,39 @@ +package commands + +import ( + "testing" + + "github.com/stroops/sloop/internal/tmux" +) + +func row(name string, st tmux.AgentStatus) FleetRow { + return FleetRow{Name: name, Status: st} +} + +func TestSoleWaiting(t *testing.T) { + cases := []struct { + name string + rows []FleetRow + wantName string + wantOK bool + }{ + {"none waiting", []FleetRow{row("a__claude", tmux.StatusWorking)}, "", false}, + {"one waiting", []FleetRow{ + row("a__claude", tmux.StatusWorking), + row("b__claude", tmux.StatusWaiting), + }, "b__claude", true}, + {"many waiting", []FleetRow{ + row("a__claude", tmux.StatusWaiting), + row("b__claude", tmux.StatusWaiting), + }, "", false}, + {"empty", nil, "", false}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + gotName, gotOK := soleWaiting(c.rows) + if gotName != c.wantName || gotOK != c.wantOK { + t.Fatalf("got (%q,%v) want (%q,%v)", gotName, gotOK, c.wantName, c.wantOK) + } + }) + } +} diff --git a/internal/cli/commands/registry.go b/internal/cli/commands/registry.go index 0090741..b427b71 100644 --- a/internal/cli/commands/registry.go +++ b/internal/cli/commands/registry.go @@ -32,6 +32,7 @@ func init() { add(RegisterAdopt) add(RegisterRestore) add(RegisterPopup) + add(RegisterPeek) add(RegisterStatusline) add(RegisterSkill) add(RegisterHooks) From 2cf53c1d8f90cf58fb3311b5b11f64fb70f68dcb Mon Sep 17 00:00:00 2001 From: Tuan Ngo Date: Sun, 28 Jun 2026 19:59:41 +0700 Subject: [PATCH 13/25] feat: Show a fleet-wide waiting badge on the status line --- internal/cli/commands/statusline.go | 30 +++++++++++++++++++++++- internal/cli/commands/statusline_test.go | 13 ++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/internal/cli/commands/statusline.go b/internal/cli/commands/statusline.go index 51fde73..48efbb3 100644 --- a/internal/cli/commands/statusline.go +++ b/internal/cli/commands/statusline.go @@ -43,7 +43,35 @@ func renderStatusline(session string) string { manifests, _ := adapter.Load() st = tmux.ClassifyStatus(string(out), manifests[tool]) } - return fmt.Sprintf("⚓ %s %s %s", ws, tool, tmuxStatusLabel(st)) + return fmt.Sprintf("⚓ %s %s %s%s", ws, tool, tmuxStatusLabel(st), renderFleetBadge(session)) +} + +// waitingBadge formats the fleet-wide waiting count for the status bar, empty +// when nothing is waiting so the bar stays clean. +func waitingBadge(n int) string { + if n <= 0 { + return "" + } + return fmt.Sprintf(" #[fg=yellow]⏳ %d waiting#[default]", n) +} + +// renderFleetBadge counts sloop sessions whose fresh marker is `waiting`, +// excluding the current session (so it reads "others need you"). Markers only — +// no capture-pane — because this re-renders every status-interval. +func renderFleetBadge(exclude string) string { + n := 0 + for _, s := range tmux.ParseSessions(tmuxList()) { + if s.Name == exclude { + continue + } + if strings.LastIndex(s.Name, "__") < 0 { + continue + } + if m, ok := fleetstate.Read(s.Name); ok && m.Status == "waiting" { + n++ + } + } + return waitingBadge(n) } var statuslineCmd = &cobra.Command{ diff --git a/internal/cli/commands/statusline_test.go b/internal/cli/commands/statusline_test.go index b1015e4..9ae5113 100644 --- a/internal/cli/commands/statusline_test.go +++ b/internal/cli/commands/statusline_test.go @@ -38,6 +38,19 @@ func TestRenderStatusline(t *testing.T) { } } +func TestWaitingBadge(t *testing.T) { + if got := waitingBadge(0); got != "" { + t.Fatalf("zero should be empty, got %q", got) + } + if got := waitingBadge(-1); got != "" { + t.Fatalf("negative should be empty, got %q", got) + } + got := waitingBadge(2) + if !strings.Contains(got, "2 waiting") || !strings.Contains(got, "fg=yellow") { + t.Fatalf("got %q", got) + } +} + func TestTmuxStatusLabel(t *testing.T) { w := tmuxStatusLabel(tmux.StatusWaiting) if !strings.Contains(w, "waiting") || !strings.Contains(w, "fg=yellow") { From dc52f961a5f6de098ae079bcf358a9eb507e5d90 Mon Sep 17 00:00:00 2001 From: Tuan Ngo Date: Sun, 28 Jun 2026 20:53:40 +0700 Subject: [PATCH 14/25] docs: Design profiles and named instances for run --- docs/design/profiles.md | 204 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 204 insertions(+) create mode 100644 docs/design/profiles.md diff --git a/docs/design/profiles.md b/docs/design/profiles.md new file mode 100644 index 0000000..3365a1f --- /dev/null +++ b/docs/design/profiles.md @@ -0,0 +1,204 @@ +# Design: profiles & named instances — run a second agent (or a second account) of the same provider + +**Date:** 2026-06-28 +**Status:** Approved design, ready for implementation plan +**Scope:** Global profiles, named instances, env injection, `sloop profile` command + +## Problem + +Today a session is named `__` (`internal/tmux/tmux.go:84`), and +`tmux.Runner.Launch` re-attaches when that session already exists +(`internal/tmux/tmux.go:208`). Two consequences: + +1. **You cannot run a second agent of the same provider in one workspace.** + `sloop run claude` a second time just re-attaches the existing + `__claude` — there is no place in the name to distinguish a second + instance. +2. **You cannot run a different account of the same provider.** sloop launches + the real binary `m.Launch` (e.g. `claude`) directly via tmux `new-session`, + *not* through a shell, so a user's shell alias (`claude-sec`) does not apply, + and there is no way to inject the env that selects another account + (`CLAUDE_CONFIG_DIR=~/.claude-sec`). `runner.Spec` has no `Env` field. + +Both gaps reduce to two missing primitives — a per-instance name suffix, and +env injection at launch — plus declarative sugar so a reused alternate account +is one word. + +## Non-goals (deliberately deferred) + +- **Per-project profiles / project-over-global merge.** Profiles live in the + global config only. An alternate account is a personal thing reused across + every repo; per-project re-declaration is YAGNI. +- **`model`/`effort`/extra-args inside a profile.** v1 profiles carry `tool` + + `env` only. Model/effort still pass through at the call site + (`sloop run @sec -m opus`). The schema leaves room to add them later. +- **Validating env values** (e.g. checking the config dir exists). Values are + expanded (`~`, `$VAR`) and forwarded as-is, matching how `--model` is + forwarded un-validated. +- **Changing the default re-attach behavior.** `sloop run claude` with no + instance still means "jump to my claude." A fresh instance is always explicit + (`@`, `tool@instance`, `--name`, or `--new`). + +## Token grammar + +One positional grammar covers both profiles and ad-hoc instances. `@` always +introduces a *name*; what is left of it (if anything) is the tool. + +| Token | Meaning | Session | +|---|---|---| +| `claude` | tool, default instance (unchanged) | `ws__claude` | +| `@sec` | left of `@` empty → **profile** `sec` | `ws__claude__sec` | +| `claude@b` | left is a tool → ad-hoc **instance** `b`, default account | `ws__claude__b` | + +Resolution rule for a token containing `@`, split once into `left@right`: + +- `left == ""` → look up **profile** `right` in global config. Profile supplies + `tool` (required) and `env`. Instance name defaults to the profile key + (`right`), overridable by `--name`. +- `left` is a known tool/alias → ad-hoc **instance** `right` on that tool, no + env. Equivalent to `sloop run --name `. +- `left` is neither empty nor a known tool → error: + `unknown tool %q in %q (use @ or @)`. + +Tokens without `@` resolve exactly as today (tool key → binary alias → model +alias). + +## Flags on `sloop run` + +- `-n, --name ` — explicit instance suffix. Sanitized; rejected if it + contains `__` (would break the parser). Overrides a profile key. +- `--env KEY=VAL` (repeatable, `StringArray`) — ad-hoc env injection without a + profile. Merged after profile env (call-site wins on key clash). +- `-N, --new` — when no explicit name/instance is given, pick the first free + instance slot instead of re-attaching: try `ws__tool`, then `ws__tool__2`, + `__3`, … and create the first that does not exist. With an explicit name it is + a no-op (the name already makes it distinct). + +## Profile schema (global `~/.sloop/config.yaml`) + +```yaml +profiles: + sec: + tool: claude # required: a manifest key (or alias) + env: + CLAUDE_CONFIG_DIR: ~/.claude-sec # ~ and $VAR expanded at resolve time +``` + +Add to `config.Global`: + +```go +type Profile struct { + Tool string `yaml:"tool"` + Env map[string]string `yaml:"env,omitempty"` +} +// Global gains: +Profiles map[string]Profile `yaml:"profiles,omitempty"` +``` + +`LoadGlobal()` already tolerates missing keys, so old configs load with +`Profiles == nil`. + +## Session naming + +```go +// InstanceName returns ws__tool for instance=="", else ws__tool__. +func InstanceName(workspace, tool, instance string) string +``` + +`SessionName(ws, tool)` stays for the default/legacy call sites; `InstanceName` +is the superset used by `run`. The session DB already has a `Profile` column +(`internal/session/store.go:152`) that has never carried a real value — it now +records the instance name. + +## Tool-aware name parser (the one careful bit) + +Two sites *extract the tool* from a session name today, both in the `commands` +package and both using `strings.LastIndex(name, "__")`: + +- `internal/cli/commands/ps.go:87` (`fleetRows`) +- `internal/cli/commands/statusline.go:36` (`renderStatusline`) + +A three-segment `ws__tool__instance` breaks `LastIndex` (it would read the +instance as the tool). Replace both with one shared, manifest-aware helper: + +```go +// splitSession parses a sloop session name into workspace, tool, instance. +// Manifest-aware so the tool is identified by key, not by position: +// - if the last "__" segment is a known tool → tool=last, instance="" (legacy) +// - else if the second-to-last is a known tool → tool=that, instance=last +// - else fall back to LastIndex (unknown tool, instance="") +func splitSession(name string, manifests map[string]adapter.Manifest) (ws, tool, instance string) +``` + +This is backward compatible (two-segment names parse exactly as before) and +robust to a workspace name that itself contains `__`, because the tool is found +by key rather than by counting separators. (`renderFleetBadge` only *detects* +sloop sessions via `LastIndex(name, "__") < 0`, which still holds for +three-segment names, so it is left unchanged.) + +## Env injection + +Add `Env map[string]string` to `runner.Spec`. Two runners consume it: + +- **tmux** (`tmux.Runner.Launch`): prefix the launched command with the `env` + binary so it works on every tmux version (no dependency on `new-session -e`): + + ``` + new-session -d -s -c env K1=V1 K2=V2 + ``` + + `env` keys are emitted in sorted order for deterministic output/tests; nil/empty + env produces no prefix (byte-identical to today). +- **exec** (`runner.ExecRunner`, no tmux): `cmd.Env = append(os.Environ(), …)`. + +Values are expanded before launch: a leading `~/` → `$HOME/`, and `$VAR`/ +`${VAR}` via `os.Expand` against the current environment. + +## Fleet display + +The instance shows next to the tool so two claudes in one repo are +distinguishable: `claude·sec`, `claude·b`, `claude·2`. `FleetRow` gains an +`Instance` field set by `splitSession`; `displayTool` renders `tool` when +instance is empty, else `tool·instance`. `ps`, the picker, the status line, and +the waiting badge all read through that one display path. + +## `sloop profile` command + +A small group that edits the global config (hand-editing the YAML stays valid): + +``` +sloop profile add --tool [--env KEY=VAL …] # write/overwrite +sloop profile ls # name · tool · env keys +sloop profile rm # delete +``` + +`add` validates that `--tool` resolves to a manifest key (reusing +`toolKeyFor`) and writes via `SaveGlobal`. `ls` with no profiles prints a hint +pointing at `profile add`. + +## Error handling + +- Unknown profile → `unknown profile %q (see: sloop profile ls)`. +- Profile's `tool` not a manifest key → `profile %q uses unknown tool %q`. +- `--name` containing `__` → `instance name %q cannot contain "__"`. +- `--env` not `KEY=VAL` → `--env must be KEY=VAL, got %q`. +- `profile rm` of a missing name → `no profile %q to remove`. + +## Testing + +Pure, table-driven units (no tmux/network), matching the existing run tests: + +- `splitSession`: 2-seg legacy, 3-seg instance, unknown tool fallback, + workspace-containing-`__`. +- token grammar: `@sec` → profile, `claude@b` → instance, `claude` → plain, + `bad@x` → error, profile-key default vs `--name` override. +- `InstanceName`: empty vs non-empty instance; sanitize. +- env: `--env` parse + merge order (profile then `--env`), `~`/`$VAR` expansion, + sorted `env` prefix in the tmux create args, empty env → no prefix. +- `--new`: first free slot given a set of existing session names. +- profile config: round-trip add/ls/rm through `Global`, `add` rejects unknown + tool. + +Manual (cannot be unit-tested — needs a live tmux and two real accounts): +`sloop run @sec` actually launches claude under `~/.claude-sec`; two instances +coexist in `sloop ps` as `claude·sec` / `claude`; `--new` spins `claude·2`. From 0077a9bad19bdba8a3df50c6500fcc30d9a16722 Mon Sep 17 00:00:00 2001 From: Tuan Ngo Date: Sun, 28 Jun 2026 21:03:04 +0700 Subject: [PATCH 15/25] feat: Add instance naming and env injection primitives --- internal/runner/runner.go | 16 +++++++++++++ internal/runner/runner_test.go | 16 ++++++++++++- internal/tmux/tmux.go | 41 +++++++++++++++++++++++++++++++++- internal/tmux/tmux_test.go | 36 +++++++++++++++++++++++++++++ 4 files changed, 107 insertions(+), 2 deletions(-) diff --git a/internal/runner/runner.go b/internal/runner/runner.go index 52f68ff..76339c8 100644 --- a/internal/runner/runner.go +++ b/internal/runner/runner.go @@ -3,12 +3,16 @@ package runner import ( "os" "os/exec" + "sort" ) type Spec struct { Dir string Command string Args []string + // Env is extra environment for the launched command (e.g. CLAUDE_CONFIG_DIR + // to select a second account); empty leaves the inherited environment alone. + Env map[string]string } type Runner interface { @@ -21,6 +25,18 @@ func BuildExecCmd(s Spec) *exec.Cmd { cmd.Stdin = os.Stdin cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr + if len(s.Env) > 0 { + keys := make([]string, 0, len(s.Env)) + for k := range s.Env { + keys = append(keys, k) + } + sort.Strings(keys) + env := os.Environ() + for _, k := range keys { + env = append(env, k+"="+s.Env[k]) + } + cmd.Env = env + } return cmd } diff --git a/internal/runner/runner_test.go b/internal/runner/runner_test.go index a233877..3fa0587 100644 --- a/internal/runner/runner_test.go +++ b/internal/runner/runner_test.go @@ -1,6 +1,9 @@ package runner -import "testing" +import ( + "slices" + "testing" +) func TestBuildExecCmdSetsDirAndArgs(t *testing.T) { cmd := BuildExecCmd(Spec{Dir: "/tmp/backend", Command: "claude", Args: []string{"--resume"}}) @@ -12,3 +15,14 @@ func TestBuildExecCmdSetsDirAndArgs(t *testing.T) { t.Fatalf("unexpected args: %v", cmd.Args) } } + +func TestBuildExecCmdEnv(t *testing.T) { + cmd := BuildExecCmd(Spec{Command: "claude", Env: map[string]string{"CLAUDE_CONFIG_DIR": "/x"}}) + if !slices.Contains(cmd.Env, "CLAUDE_CONFIG_DIR=/x") { + t.Fatalf("env not injected: %v", cmd.Env) + } + // No Env → leave cmd.Env nil (inherit parent), unchanged from before. + if BuildExecCmd(Spec{Command: "claude"}).Env != nil { + t.Fatal("no Env should leave cmd.Env nil") + } +} diff --git a/internal/tmux/tmux.go b/internal/tmux/tmux.go index a535f63..44dc416 100644 --- a/internal/tmux/tmux.go +++ b/internal/tmux/tmux.go @@ -6,6 +6,7 @@ import ( "log/slog" "os" "os/exec" + "sort" "strings" "sync" @@ -84,6 +85,44 @@ func SessionName(workspace, tool string) string { return sanitize(workspace) + "__" + sanitize(tool) } +// InstanceName is SessionName plus an optional instance suffix; instance=="" is +// the default session (byte-identical to SessionName), so a second agent of the +// same provider in one workspace gets a distinct `ws__tool__instance` name. +func InstanceName(workspace, tool, instance string) string { + base := SessionName(workspace, tool) + if instance == "" { + return base + } + return base + "__" + sanitize(instance) +} + +// envPrefix returns ["env","K1=V1",…] in sorted key order, or nil when empty, so +// a launched command can carry extra env (e.g. CLAUDE_CONFIG_DIR for a second +// account) on any tmux version — no dependency on `new-session -e`. +func envPrefix(env map[string]string) []string { + if len(env) == 0 { + return nil + } + keys := make([]string, 0, len(env)) + for k := range env { + keys = append(keys, k) + } + sort.Strings(keys) + out := []string{"env"} + for _, k := range keys { + out = append(out, k+"="+env[k]) + } + return out +} + +// buildNewDetachedArgs builds `new-session -d -s -c [env K=V…] cmd args…`. +func buildNewDetachedArgs(session, dir string, env map[string]string, command string, cmdArgs []string) []string { + args := []string{"new-session", "-d", "-s", session, "-c", dir} + args = append(args, envPrefix(env)...) + args = append(args, command) + return append(args, cmdArgs...) +} + // DetachHint is the one place that explains how to detach (hide the agent and // return to the terminal), using the user's actual tmux prefix. Shared by run, // attach, and ps so the wording stays consistent. @@ -206,7 +245,7 @@ func (r Runner) Launch(s runner.Spec) error { // Create the session detached so we can give it sloop's own status bar // before attaching; then attach (or switch if already inside tmux). if !hasSession(r.Session) { - create := append([]string{"new-session", "-d", "-s", r.Session, "-c", s.Dir, s.Command}, s.Args...) + create := buildNewDetachedArgs(r.Session, s.Dir, s.Env, s.Command, s.Args) if err := Run(create...); err != nil { return err } diff --git a/internal/tmux/tmux_test.go b/internal/tmux/tmux_test.go index 721e100..9a98058 100644 --- a/internal/tmux/tmux_test.go +++ b/internal/tmux/tmux_test.go @@ -15,6 +15,42 @@ func TestTmuxSessionNameSanitizes(t *testing.T) { } } +func TestInstanceName(t *testing.T) { + if got := InstanceName("repo", "claude", ""); got != "repo__claude" { + t.Fatalf("empty instance: got %s", got) + } + if got := InstanceName("repo", "claude", "sec"); got != "repo__claude__sec" { + t.Fatalf("named instance: got %s", got) + } + if got := InstanceName("repo", "claude", "a b"); got != "repo__claude__a_b" { + t.Fatalf("sanitized instance: got %s", got) + } +} + +func TestEnvPrefix(t *testing.T) { + if envPrefix(nil) != nil { + t.Fatal("empty env should yield nil prefix") + } + got := envPrefix(map[string]string{"B": "2", "A": "1"}) + want := []string{"env", "A=1", "B=2"} // sorted by key + if !reflect.DeepEqual(got, want) { + t.Fatalf("got %v want %v", got, want) + } +} + +func TestBuildNewDetachedArgs(t *testing.T) { + plain := buildNewDetachedArgs("repo__claude", "/tmp/repo", nil, "claude", []string{"--resume"}) + wantPlain := []string{"new-session", "-d", "-s", "repo__claude", "-c", "/tmp/repo", "claude", "--resume"} + if !reflect.DeepEqual(plain, wantPlain) { + t.Fatalf("plain: got %v want %v", plain, wantPlain) + } + withEnv := buildNewDetachedArgs("repo__claude__sec", "/tmp/repo", map[string]string{"CLAUDE_CONFIG_DIR": "/x"}, "claude", nil) + wantEnv := []string{"new-session", "-d", "-s", "repo__claude__sec", "-c", "/tmp/repo", "env", "CLAUDE_CONFIG_DIR=/x", "claude"} + if !reflect.DeepEqual(withEnv, wantEnv) { + t.Fatalf("withEnv: got %v want %v", withEnv, wantEnv) + } +} + func TestBuildTmuxNewArgs(t *testing.T) { args := BuildNewArgs("backend__claude", runner.Spec{Dir: "/tmp/backend", Command: "claude", Args: []string{"--resume"}}) want := []string{"new-session", "-A", "-s", "backend__claude", "-c", "/tmp/backend", "claude", "--resume"} From 241bf448f84744fbd4861faf670d455f4b7b58c0 Mon Sep 17 00:00:00 2001 From: Tuan Ngo Date: Sun, 28 Jun 2026 21:03:43 +0700 Subject: [PATCH 16/25] feat: Add launch profiles to global config --- internal/config/config.go | 10 ++++++++++ internal/config/global_test.go | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+) diff --git a/internal/config/config.go b/internal/config/config.go index 9ada38d..48a533a 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -42,6 +42,14 @@ func UserAdaptersDir() (string, error) { // files so future schema changes can migrate safely. const ConfigVersion = 1 +// Profile is a named launch variant of a provider: the same tool with extra +// env (e.g. CLAUDE_CONFIG_DIR to select a second account). Invoked as +// `sloop run @`; the profile name becomes the session's instance suffix. +type Profile struct { + Tool string `yaml:"tool"` + Env map[string]string `yaml:"env,omitempty"` +} + // Global is the machine-local config at ~/.sloop/config.yaml. type Global struct { Version int `yaml:"version"` @@ -50,6 +58,8 @@ type Global struct { // auto from $SLOOP_LANG/$LANG. Hints nil = enabled, false = off. Lang string `yaml:"lang,omitempty"` Hints *bool `yaml:"hints,omitempty"` + // Profiles are reusable launch variants, keyed by name. + Profiles map[string]Profile `yaml:"profiles,omitempty"` } func GlobalConfigPath() (string, error) { diff --git a/internal/config/global_test.go b/internal/config/global_test.go index 5fae34a..1d78ed4 100644 --- a/internal/config/global_test.go +++ b/internal/config/global_test.go @@ -26,3 +26,35 @@ func TestSaveAndLoadGlobal(t *testing.T) { t.Fatalf("want %q, got %q", ModeAuto, g.Mode) } } + +func TestGlobalProfilesRoundTrip(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + in := &Global{Profiles: map[string]Profile{ + "sec": {Tool: "claude", Env: map[string]string{"CLAUDE_CONFIG_DIR": "~/.claude-sec"}}, + }} + if err := SaveGlobal(in); err != nil { + t.Fatalf("SaveGlobal: %v", err) + } + g, err := LoadGlobal() + if err != nil { + t.Fatalf("LoadGlobal: %v", err) + } + p, ok := g.Profiles["sec"] + if !ok || p.Tool != "claude" || p.Env["CLAUDE_CONFIG_DIR"] != "~/.claude-sec" { + t.Fatalf("profile did not round-trip: %+v", g.Profiles) + } +} + +func TestLoadGlobalNoProfilesIsNil(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + if err := SaveGlobal(&Global{Mode: ModeAuto}); err != nil { + t.Fatalf("SaveGlobal: %v", err) + } + g, err := LoadGlobal() + if err != nil { + t.Fatalf("LoadGlobal: %v", err) + } + if g.Profiles != nil { + t.Fatalf("want nil Profiles, got %+v", g.Profiles) + } +} From b5defb02d28ef3beffc1c28d9536eec4be8dd453 Mon Sep 17 00:00:00 2001 From: Tuan Ngo Date: Sun, 28 Jun 2026 21:05:24 +0700 Subject: [PATCH 17/25] feat: Resolve profile and instance tokens for run --- internal/cli/commands/profilerun.go | 148 +++++++++++++++++++++++ internal/cli/commands/profilerun_test.go | 133 ++++++++++++++++++++ internal/cli/commands/run.go | 5 +- 3 files changed, 282 insertions(+), 4 deletions(-) create mode 100644 internal/cli/commands/profilerun.go create mode 100644 internal/cli/commands/profilerun_test.go diff --git a/internal/cli/commands/profilerun.go b/internal/cli/commands/profilerun.go new file mode 100644 index 0000000..9e1a2de --- /dev/null +++ b/internal/cli/commands/profilerun.go @@ -0,0 +1,148 @@ +package commands + +import ( + "fmt" + "os" + "strconv" + "strings" + + "github.com/stroops/sloop/internal/adapter" + "github.com/stroops/sloop/internal/config" + "github.com/stroops/sloop/internal/runner" + "github.com/stroops/sloop/internal/tmux" +) + +// instanceResolution is the outcome of interpreting a `sloop run` target for +// profiles / named instances: which tool token to plan, the instance suffix +// (""=default session), and env to inject. +type instanceResolution struct { + target string + instance string + env map[string]string +} + +// resolveInstance interprets the `@profile` / `tool@instance` token grammar and +// folds in --name and --env. `@` always introduces a name: an empty left side +// looks up a profile (supplying tool + env); a tool on the left is an ad-hoc +// instance. --env merges over profile env (call-site wins); --name overrides the +// instance. +func resolveInstance(target, nameFlag string, envFlags []string, profiles map[string]config.Profile, manifests map[string]adapter.Manifest) (instanceResolution, error) { + res := instanceResolution{target: target} + if left, right, ok := strings.Cut(target, "@"); ok { + if right == "" { + return res, fmt.Errorf("missing name after '@' in %q", target) + } + if left == "" { + prof, ok := profiles[right] + if !ok { + return res, fmt.Errorf("unknown profile %q (see: sloop profile ls)", right) + } + if _, ok := toolKeyFor(prof.Tool, manifests); !ok { + return res, fmt.Errorf("profile %q uses unknown tool %q", right, prof.Tool) + } + res.target = prof.Tool + res.instance = right + res.env = expandEnvMap(prof.Env) + } else { + if _, ok := toolKeyFor(left, manifests); !ok { + return res, fmt.Errorf("unknown tool %q in %q (use @ or @)", left, target) + } + res.target = left + res.instance = right + } + } + if len(envFlags) > 0 { + ev, err := parseEnvFlags(envFlags) + if err != nil { + return res, err + } + res.env = mergeEnv(res.env, expandEnvMap(ev)) + } + if nameFlag != "" { + res.instance = nameFlag + } + if strings.Contains(res.instance, "__") { + return res, fmt.Errorf(`instance name %q cannot contain "__"`, res.instance) + } + return res, nil +} + +// parseEnvFlags turns `KEY=VAL` flags into a map; the first `=` splits, so the +// value may itself contain `=`. +func parseEnvFlags(flags []string) (map[string]string, error) { + if len(flags) == 0 { + return nil, nil + } + out := make(map[string]string, len(flags)) + for _, f := range flags { + k, v, ok := strings.Cut(f, "=") + if !ok || k == "" { + return nil, fmt.Errorf("--env must be KEY=VAL, got %q", f) + } + out[k] = v + } + return out, nil +} + +// expandEnvValue expands a leading `~/` to the home dir and any `$VAR`/`${VAR}` +// against the current environment, so profile env values can be written the way +// a user would type them in a shell. +func expandEnvValue(v string) string { + if strings.HasPrefix(v, "~/") { + if home, err := os.UserHomeDir(); err == nil { + v = home + v[1:] + } + } + return os.Expand(v, os.Getenv) +} + +func expandEnvMap(m map[string]string) map[string]string { + if len(m) == 0 { + return nil + } + out := make(map[string]string, len(m)) + for k, v := range m { + out[k] = expandEnvValue(v) + } + return out +} + +// mergeEnv overlays over onto base (over wins on key clash), returning a usable +// map even when either side is nil. +func mergeEnv(base, over map[string]string) map[string]string { + if len(base) == 0 { + return over + } + for k, v := range over { + base[k] = v + } + return base +} + +// nextFreeInstance returns the first instance suffix whose session name is not +// already taken: "" (ws__tool), then "2" (ws__tool__2), "3", … — so `--new` +// spins a fresh agent instead of re-attaching the existing one. +func nextFreeInstance(ws, tool string, sessions []tmux.Session) string { + taken := make(map[string]bool, len(sessions)) + for _, s := range sessions { + taken[s.Name] = true + } + if !taken[tmux.InstanceName(ws, tool, "")] { + return "" + } + for n := 2; ; n++ { + inst := strconv.Itoa(n) + if !taken[tmux.InstanceName(ws, tool, inst)] { + return inst + } + } +} + +// selectRunnerInstance is selectRunner with an instance suffix on the session +// name (tmux when available, else exec). +func selectRunnerInstance(ws, tool, instance string) runner.Runner { + if tmux.Available() { + return tmux.Runner{Session: tmux.InstanceName(ws, tool, instance)} + } + return runner.ExecRunner{} +} diff --git a/internal/cli/commands/profilerun_test.go b/internal/cli/commands/profilerun_test.go new file mode 100644 index 0000000..406ea27 --- /dev/null +++ b/internal/cli/commands/profilerun_test.go @@ -0,0 +1,133 @@ +package commands + +import ( + "strings" + "testing" + + "github.com/stroops/sloop/internal/config" + "github.com/stroops/sloop/internal/tmux" +) + +func TestParseEnvFlags(t *testing.T) { + got, err := parseEnvFlags([]string{"K=V", "X=a=b"}) + if err != nil { + t.Fatal(err) + } + if got["K"] != "V" || got["X"] != "a=b" { + t.Fatalf("got %v", got) + } + if _, err := parseEnvFlags([]string{"noeq"}); err == nil { + t.Fatal("want error for missing =") + } +} + +func TestExpandEnvValue(t *testing.T) { + t.Setenv("HOME", "/home/me") + cases := map[string]string{ + "~/.claude-sec": "/home/me/.claude-sec", + "$HOME/x": "/home/me/x", + "${HOME}/y": "/home/me/y", + "/abs/path": "/abs/path", + "~notme/x": "~notme/x", // only a leading ~/ expands + } + for in, want := range cases { + if got := expandEnvValue(in); got != want { + t.Fatalf("expandEnvValue(%q) = %q, want %q", in, got, want) + } + } +} + +func TestResolveInstance(t *testing.T) { + t.Setenv("HOME", "/home/me") + m := loadManifestsForTest(t) // resets HOME to a temp dir + t.Setenv("HOME", "/home/me") // restore for expansion assertions + profiles := map[string]config.Profile{ + "sec": {Tool: "claude", Env: map[string]string{"CLAUDE_CONFIG_DIR": "~/.claude-sec"}}, + "bogus": {Tool: "nope", Env: nil}, + } + + t.Run("profile", func(t *testing.T) { + r, err := resolveInstance("@sec", "", nil, profiles, m) + if err != nil { + t.Fatal(err) + } + if r.target != "claude" || r.instance != "sec" { + t.Fatalf("got target=%q instance=%q", r.target, r.instance) + } + if r.env["CLAUDE_CONFIG_DIR"] != "/home/me/.claude-sec" { + t.Fatalf("env not expanded: %v", r.env) + } + }) + t.Run("tool@instance", func(t *testing.T) { + r, err := resolveInstance("claude@b", "", nil, profiles, m) + if err != nil || r.target != "claude" || r.instance != "b" || r.env != nil { + t.Fatalf("got %+v err %v", r, err) + } + }) + t.Run("plain tool", func(t *testing.T) { + r, err := resolveInstance("claude", "", nil, profiles, m) + if err != nil || r.target != "claude" || r.instance != "" { + t.Fatalf("got %+v err %v", r, err) + } + }) + t.Run("unknown profile", func(t *testing.T) { + if _, err := resolveInstance("@nope", "", nil, profiles, m); err == nil || !strings.Contains(err.Error(), "unknown profile") { + t.Fatalf("want unknown profile, got %v", err) + } + }) + t.Run("profile bad tool", func(t *testing.T) { + if _, err := resolveInstance("@bogus", "", nil, profiles, m); err == nil || !strings.Contains(err.Error(), "unknown tool") { + t.Fatalf("want unknown tool, got %v", err) + } + }) + t.Run("bad left of @", func(t *testing.T) { + if _, err := resolveInstance("zzz@x", "", nil, profiles, m); err == nil || !strings.Contains(err.Error(), "unknown tool") { + t.Fatalf("want unknown tool, got %v", err) + } + }) + t.Run("name flag overrides instance", func(t *testing.T) { + r, err := resolveInstance("@sec", "override", nil, profiles, m) + if err != nil || r.instance != "override" { + t.Fatalf("got %+v err %v", r, err) + } + }) + t.Run("name flag on plain tool", func(t *testing.T) { + r, err := resolveInstance("claude", "x", nil, profiles, m) + if err != nil || r.instance != "x" { + t.Fatalf("got %+v err %v", r, err) + } + }) + t.Run("env flag merges and overrides", func(t *testing.T) { + r, err := resolveInstance("@sec", "", []string{"CLAUDE_CONFIG_DIR=/flag", "EXTRA=1"}, profiles, m) + if err != nil { + t.Fatal(err) + } + if r.env["CLAUDE_CONFIG_DIR"] != "/flag" || r.env["EXTRA"] != "1" { + t.Fatalf("env flag did not override/merge: %v", r.env) + } + }) + t.Run("instance cannot contain __", func(t *testing.T) { + if _, err := resolveInstance("claude", "a__b", nil, profiles, m); err == nil || !strings.Contains(err.Error(), "__") { + t.Fatalf("want __ error, got %v", err) + } + }) +} + +func TestNextFreeInstance(t *testing.T) { + sess := func(names ...string) []tmux.Session { + out := make([]tmux.Session, len(names)) + for i, n := range names { + out[i] = tmux.Session{Name: n} + } + return out + } + if got := nextFreeInstance("repo", "claude", nil); got != "" { + t.Fatalf("no sessions → empty, got %q", got) + } + if got := nextFreeInstance("repo", "claude", sess("repo__claude")); got != "2" { + t.Fatalf("base taken → 2, got %q", got) + } + if got := nextFreeInstance("repo", "claude", sess("repo__claude", "repo__claude__2")); got != "3" { + t.Fatalf("base+2 taken → 3, got %q", got) + } +} diff --git a/internal/cli/commands/run.go b/internal/cli/commands/run.go index ccd79e0..9a5cede 100644 --- a/internal/cli/commands/run.go +++ b/internal/cli/commands/run.go @@ -248,10 +248,7 @@ func resolveStartDir(cwd, workspaceFlag string) (string, error) { // selectRunner returns a tmux-backed runner when tmux is available, else exec. func selectRunner(workspace, tool string) runner.Runner { - if tmux.Available() { - return tmux.Runner{Session: tmux.SessionName(workspace, tool)} - } - return runner.ExecRunner{} + return selectRunnerInstance(workspace, tool, "") } // RunSplit syncs each tool then launches them side-by-side as tmux panes in one From be2a5773d580e2a95f4dbf783120995369b3cf82 Mon Sep 17 00:00:00 2001 From: Tuan Ngo Date: Sun, 28 Jun 2026 21:07:30 +0700 Subject: [PATCH 18/25] feat: Wire profiles and named instances into run --- internal/cli/commands/ls.go | 2 +- internal/cli/commands/run.go | 50 ++++++++++++++++++++++++---- internal/cli/commands/runcmd_test.go | 20 +++++++++-- 3 files changed, 62 insertions(+), 10 deletions(-) diff --git a/internal/cli/commands/ls.go b/internal/cli/commands/ls.go index 9476c48..c023701 100644 --- a/internal/cli/commands/ls.go +++ b/internal/cli/commands/ls.go @@ -99,7 +99,7 @@ func launchWorkspaceDefault(startDir string) error { if err != nil { return err } - return RunRun(startDir, "", "", "", "", "", nil, selectRunner(ws.Name, plan.toolKey)) + return RunRun(startDir, "", "", "", "", "", nil, nil, "", selectRunner(ws.Name, plan.toolKey)) } func RunLs(w io.Writer) error { diff --git a/internal/cli/commands/run.go b/internal/cli/commands/run.go index 9a5cede..f84303b 100644 --- a/internal/cli/commands/run.go +++ b/internal/cli/commands/run.go @@ -157,8 +157,10 @@ func buildRunArgs(m adapter.Manifest, model, effort, task string, passthrough [] } // RunRun resolves the target + model/effort/task flags, syncs context, then -// launches the CLI with the right flags in a managed session. -func RunRun(startDir, target, provider, model, effort, task string, passthrough []string, r runner.Runner) error { +// launches the CLI with the right flags in a managed session. env is injected +// into the launched process (e.g. a second account's CLAUDE_CONFIG_DIR), and +// instance records which named instance this session is. +func RunRun(startDir, target, provider, model, effort, task string, passthrough []string, env map[string]string, instance string, r runner.Runner) error { ws, err := workspace.Resolve(startDir) if err != nil { return err @@ -187,12 +189,12 @@ func RunRun(startDir, target, provider, model, effort, task string, passthrough } // Record session (best-effort: never block the launch). - sessID, store := recordSessionBestEffort(ws, plan.toolKey, target) + sessID, store := recordSessionBestEffort(ws, plan.toolKey, instance) if store != nil { defer func() { _ = store.Close() }() } - launchErr := r.Launch(runner.Spec{Dir: ws.Root, Command: m.Launch, Args: args}) + launchErr := r.Launch(runner.Spec{Dir: ws.Root, Command: m.Launch, Args: args, Env: env}) if store != nil && sessID > 0 { _ = store.EndSession(sessID, time.Now()) @@ -287,12 +289,27 @@ var ( runModel string runEffort string runTask string + runName string + runEnv []string + runNew bool ) var runCmd = &cobra.Command{ - Use: "run [tool|profile ...] [-- ]", + Use: "run [tool | @profile | tool@instance] [-- ]", Aliases: []string{"r"}, Short: "Sync context and launch an AI tool in the workspace (--split for side-by-side panes)", + Long: `Sync context and launch an AI tool in the workspace. + +Run a second agent of the same provider with a named instance, or a different +account with a saved profile: + + sloop run claude # the workspace's claude (re-attaches if running) + sloop run claude --new # a fresh claude instance (claude·2, claude·3, …) + sloop run claude@review # an ad-hoc instance named "review" + sloop run @sec # the "sec" profile (e.g. a second account) + +Profiles live in ~/.sloop/config.yaml; manage them with ` + "`sloop profile`" + `. +Use --env KEY=VAL for a one-off account/env without saving a profile.`, Args: func(cmd *cobra.Command, args []string) error { n := len(args) if d := cmd.ArgsLenAtDash(); d >= 0 { @@ -347,11 +364,27 @@ var runCmd = &cobra.Command{ if err != nil { return err } - plan, err := planLaunch(target, runProvider, runModel, runEffort, proj.DefaultTool, manifests) + // Interpret @profile / tool@instance and fold in --name/--env, then plan + // against the resolved tool token. + glob, err := config.LoadGlobal() + if err != nil { + return err + } + res, err := resolveInstance(target, runName, runEnv, glob.Profiles, manifests) if err != nil { return err } - if err := RunRun(startDir, target, runProvider, runModel, runEffort, runTask, passthrough, selectRunner(ws.Name, plan.toolKey)); err != nil { + plan, err := planLaunch(res.target, runProvider, runModel, runEffort, proj.DefaultTool, manifests) + if err != nil { + return err + } + // --new spins a fresh instance (next free slot) instead of re-attaching; + // it's a no-op once a name/instance already makes the session distinct. + if runNew && res.instance == "" { + res.instance = nextFreeInstance(ws.Name, plan.toolKey, tmux.ParseSessions(tmuxList())) + } + r := selectRunnerInstance(ws.Name, plan.toolKey, res.instance) + if err := RunRun(startDir, res.target, runProvider, runModel, runEffort, runTask, passthrough, res.env, res.instance, r); err != nil { return err } hints.Show(cmd.OutOrStdout(), "run") @@ -366,6 +399,9 @@ func RegisterRun(cmd *cobra.Command) { runCmd.Flags().StringVarP(&runModel, "model", "m", "", "model to pass to the CLI (forwarded as-is, not validated)") runCmd.Flags().StringVarP(&runEffort, "effort", "e", "", "reasoning effort: low|medium|high (if the CLI supports it)") runCmd.Flags().StringVarP(&runTask, "task", "t", "", "hand the agent an initial task — launches an interactive session already working on it") + runCmd.Flags().StringVarP(&runName, "name", "n", "", "name this instance so a second agent of the same tool gets its own session") + runCmd.Flags().StringArrayVar(&runEnv, "env", nil, "extra env for the launched tool, KEY=VAL (e.g. a second account's CLAUDE_CONFIG_DIR); repeatable") + runCmd.Flags().BoolVarP(&runNew, "new", "N", false, "start a fresh instance (next free slot) instead of re-attaching the existing session") runCmd.ValidArgsFunction = completeTools _ = runCmd.RegisterFlagCompletionFunc("workspace", completeWorkspaces) _ = runCmd.RegisterFlagCompletionFunc("provider", completeTools) diff --git a/internal/cli/commands/runcmd_test.go b/internal/cli/commands/runcmd_test.go index 0e09ff5..d7e4288 100644 --- a/internal/cli/commands/runcmd_test.go +++ b/internal/cli/commands/runcmd_test.go @@ -21,7 +21,7 @@ func TestRunRunSyncsAndLaunches(t *testing.T) { } fr := &fakeRunner{} - if err := RunRun(dir, "claude", "", "", "", "", nil, fr); err != nil { + if err := RunRun(dir, "claude", "", "", "", "", nil, nil, "", fr); err != nil { t.Fatalf("RunRun: %v", err) } @@ -48,10 +48,26 @@ func TestRunRunPassesThroughArgs(t *testing.T) { } fr := &fakeRunner{} - if err := RunRun(dir, "claude", "", "", "", "", []string{"--foo", "bar"}, fr); err != nil { + if err := RunRun(dir, "claude", "", "", "", "", []string{"--foo", "bar"}, nil, "", fr); err != nil { t.Fatalf("RunRun: %v", err) } if len(fr.got.Args) != 2 || fr.got.Args[0] != "--foo" || fr.got.Args[1] != "bar" { t.Fatalf("want passthrough args [--foo bar], got %v", fr.got.Args) } } + +func TestRunRunInjectsEnv(t *testing.T) { + dir := t.TempDir() + t.Setenv("HOME", t.TempDir()) + if _, err := RunInit(dir, nil, false); err != nil { + t.Fatalf("RunInit: %v", err) + } + fr := &fakeRunner{} + env := map[string]string{"CLAUDE_CONFIG_DIR": "/x"} + if err := RunRun(dir, "claude", "", "", "", "", nil, env, "sec", fr); err != nil { + t.Fatalf("RunRun: %v", err) + } + if fr.got.Env["CLAUDE_CONFIG_DIR"] != "/x" { + t.Fatalf("env not passed to Spec: %v", fr.got.Env) + } +} From b3fa8aba9576ed4de155c421fb057830d8eef632 Mon Sep 17 00:00:00 2001 From: Tuan Ngo Date: Sun, 28 Jun 2026 21:14:23 +0700 Subject: [PATCH 19/25] feat: Parse and display named instances in the fleet view --- internal/cli/commands/approve.go | 2 +- internal/cli/commands/completion.go | 7 +-- internal/cli/commands/kill.go | 2 +- internal/cli/commands/ls.go | 2 +- internal/cli/commands/peek.go | 2 +- internal/cli/commands/ps.go | 73 +++++++++++++++++++++++------ internal/cli/commands/ps_test.go | 36 +++++++++++++- internal/cli/commands/send.go | 5 +- internal/cli/commands/status.go | 3 +- internal/cli/commands/statusline.go | 11 +++-- 10 files changed, 112 insertions(+), 31 deletions(-) diff --git a/internal/cli/commands/approve.go b/internal/cli/commands/approve.go index a5f3c0a..6b154a1 100644 --- a/internal/cli/commands/approve.go +++ b/internal/cli/commands/approve.go @@ -23,7 +23,7 @@ func RunApprove(w io.Writer, in io.Reader, targets []string, all, waiting, yes b // Enrich first so each row has a Status; selectSessions does its own // filterWaiting for the --waiting case (mirrors kill). Pre-filtering here // would run before Status is set and drop every row. - rows := enrichGlances(fleetRows(tmux.ParseSessions(tmuxList())), manifests) + rows := enrichGlances(fleetRows(tmux.ParseSessions(tmuxList()), manifests), manifests) sel, err := selectSessions(rows, targets, all, waiting) if err != nil { return nil, err diff --git a/internal/cli/commands/completion.go b/internal/cli/commands/completion.go index 97d418e..3d58141 100644 --- a/internal/cli/commands/completion.go +++ b/internal/cli/commands/completion.go @@ -83,7 +83,7 @@ func completeSessionNames(_ *cobra.Command, args []string, _ string) ([]string, if len(args) > 0 { return nil, cobra.ShellCompDirectiveNoFileComp } - rows := fleetRows(tmux.ParseSessions(tmuxList())) + rows := fleetRows(tmux.ParseSessions(tmuxList()), nil) // only r.Name is used names := make([]string, 0, len(rows)) for _, r := range rows { names = append(names, r.Name) @@ -97,7 +97,8 @@ func completePsIndex(_ *cobra.Command, args []string, _ string) ([]string, cobra if len(args) > 0 { return nil, cobra.ShellCompDirectiveNoFileComp } - rows := fleetRows(tmux.ParseSessions(tmuxList())) + manifests, _ := adapter.Load() + rows := fleetRows(tmux.ParseSessions(tmuxList()), manifests) // same order as `ps` out := make([]string, 0, len(rows)) for i, r := range rows { out = append(out, fmt.Sprintf("%d\t%s", i+1, r.Name)) // valuedescription @@ -111,7 +112,7 @@ func completeSendTargets(_ *cobra.Command, args []string, _ string) ([]string, c if len(args) > 0 { return nil, cobra.ShellCompDirectiveNoFileComp } - rows := fleetRows(tmux.ParseSessions(tmuxList())) + rows := fleetRows(tmux.ParseSessions(tmuxList()), nil) // only r.Name is used out := make([]string, 0, len(rows)) for _, r := range rows { out = append(out, r.Name) diff --git a/internal/cli/commands/kill.go b/internal/cli/commands/kill.go index 4919265..bb61e22 100644 --- a/internal/cli/commands/kill.go +++ b/internal/cli/commands/kill.go @@ -68,7 +68,7 @@ func RunKill(w io.Writer, in io.Reader, targets []string, all, waiting, yes bool return nil, fmt.Errorf("tmux is not installed; `sloop kill` needs tmux") } manifests, _ := adapter.Load() - rows := enrichGlances(fleetRows(tmux.ParseSessions(tmuxList())), manifests) + rows := enrichGlances(fleetRows(tmux.ParseSessions(tmuxList()), manifests), manifests) if len(rows) == 0 { rows = enrichGlances(rows, manifests) // need status to find who's waiting } diff --git a/internal/cli/commands/ls.go b/internal/cli/commands/ls.go index c023701..f0a877e 100644 --- a/internal/cli/commands/ls.go +++ b/internal/cli/commands/ls.go @@ -37,7 +37,7 @@ func openShellIn(dir string) error { // status dots as `ps` — the bridge between the two views. func enrichedByWorkspace(manifests map[string]adapter.Manifest) map[string][]FleetRow { m := map[string][]FleetRow{} - for _, r := range enrichGlances(fleetRows(tmux.ParseSessions(tmuxList())), manifests) { + for _, r := range enrichGlances(fleetRows(tmux.ParseSessions(tmuxList()), manifests), manifests) { m[r.Workspace] = append(m[r.Workspace], r) } return m diff --git a/internal/cli/commands/peek.go b/internal/cli/commands/peek.go index 383bb65..8e18ef7 100644 --- a/internal/cli/commands/peek.go +++ b/internal/cli/commands/peek.go @@ -34,7 +34,7 @@ func resolvePeekTarget(args []string) (string, error) { return args[0], nil } manifests, _ := adapter.Load() - rows := enrichGlances(fleetRows(tmux.ParseSessions(tmuxList())), manifests) + rows := enrichGlances(fleetRows(tmux.ParseSessions(tmuxList()), manifests), manifests) if len(rows) == 0 { return "", fmt.Errorf("no running AI sessions to peek (start one with `sloop run `)") } diff --git a/internal/cli/commands/ps.go b/internal/cli/commands/ps.go index af0d3f4..62003b2 100644 --- a/internal/cli/commands/ps.go +++ b/internal/cli/commands/ps.go @@ -38,15 +38,22 @@ type FleetRow struct { Prompt string // the question the agent is blocked on (when waiting) Answers []tmux.Answer // parsed choices the agent offers (answer in one key) Display string // provider display name (e.g. "Google Antigravity" for "agy") + Instance string // named instance suffix (e.g. "sec" / "2"); "" = the default session } // toolName is the row's provider display name, falling back to the tool key when // the row hasn't been enriched (e.g. plain listings built straight from tmux). +// A named instance is appended (`claude·sec`) so two agents of the same provider +// in one workspace are distinguishable. func (r FleetRow) toolName() string { + name := r.Tool if r.Display != "" { - return r.Display + name = r.Display } - return r.Tool + if r.Instance != "" { + return name + "·" + r.Instance + } + return name } // registryPaths maps each registered workspace name to its repo path, so the @@ -79,18 +86,51 @@ func annotatePaths(rows []FleetRow, paths map[string]string) { } } -// fleetRows keeps only sloop-named sessions (`__`), splitting -// on the last `__`, and sorts them by workspace then tool. -func fleetRows(sessions []tmux.Session) []FleetRow { +// splitSession parses a sloop session name into workspace, tool, and instance. +// It is manifest-aware so the tool is found by key rather than by position, +// which keeps `ws__tool__instance` unambiguous: the tool is the last `__` +// segment that is a known tool (legacy `ws__tool` → instance ""), else the +// second-to-last when that is a known tool (→ instance = the last segment). +// With nil/empty manifests it falls back to the legacy last-`__` split, so +// callers that only need the name behave exactly as before. +func splitSession(name string, manifests map[string]adapter.Manifest) (ws, tool, instance string) { + parts := strings.Split(name, "__") + if len(parts) >= 2 { + last := parts[len(parts)-1] + if _, ok := manifests[last]; ok { + return strings.Join(parts[:len(parts)-1], "__"), last, "" + } + if len(parts) >= 3 { + if mid := parts[len(parts)-2]; hasKey(manifests, mid) { + return strings.Join(parts[:len(parts)-2], "__"), mid, last + } + } + } + i := strings.LastIndex(name, "__") + if i < 0 { + return name, "", "" // not a sloop session name + } + return name[:i], name[i+2:], "" +} + +func hasKey(m map[string]adapter.Manifest, k string) bool { + _, ok := m[k] + return ok +} + +// fleetRows keeps only sloop-named sessions (containing `__`), splitting each +// into workspace/tool/instance, and sorts them by workspace, tool, instance. +func fleetRows(sessions []tmux.Session, manifests map[string]adapter.Manifest) []FleetRow { var rows []FleetRow for _, s := range sessions { - i := strings.LastIndex(s.Name, "__") - if i < 0 { + if !strings.Contains(s.Name, "__") { continue // not a sloop session } + ws, tool, instance := splitSession(s.Name, manifests) rows = append(rows, FleetRow{ - Workspace: s.Name[:i], - Tool: s.Name[i+2:], + Workspace: ws, + Tool: tool, + Instance: instance, Name: s.Name, Attached: s.Attached, Windows: s.Windows, @@ -101,7 +141,10 @@ func fleetRows(sessions []tmux.Session) []FleetRow { if rows[i].Workspace != rows[j].Workspace { return rows[i].Workspace < rows[j].Workspace } - return rows[i].Tool < rows[j].Tool + if rows[i].Tool != rows[j].Tool { + return rows[i].Tool < rows[j].Tool + } + return rows[i].Instance < rows[j].Instance }) return rows } @@ -285,7 +328,7 @@ func jumpToFleet(rows []FleetRow, n int) error { // waiting-first order as `ps`, so the picker reads identically to the fleet view. func pickFleetSession(title string) (string, error) { manifests, _ := adapter.Load() - rows := enrichGlances(fleetRows(tmux.ParseSessions(tmuxList())), manifests) + rows := enrichGlances(fleetRows(tmux.ParseSessions(tmuxList()), manifests), manifests) if len(rows) == 0 { return "", fmt.Errorf("no running AI sessions to attach to (start one with `sloop run `)") } @@ -410,8 +453,9 @@ var psCmd = &cobra.Command{ Short: "List running AI sessions (the fleet); `sloop ps <#>` jumps to one", Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { + manifests, _ := adapter.Load() parsed := tmux.ParseSessions(tmuxList()) - rows := fleetRows(parsed) + rows := fleetRows(parsed, manifests) if len(args) == 1 { n, err := strconv.Atoi(args[0]) if err != nil { @@ -424,7 +468,6 @@ var psCmd = &cobra.Command{ return runWatch(cmd.OutOrStdout(), psInterval, psWaiting, psNotify) } - manifests, _ := adapter.Load() rows = enrichGlances(rows, manifests) sortNeedsAttention(rows) paths := registryPaths() @@ -464,7 +507,7 @@ var psCmd = &cobra.Command{ detach := tui.Grey(" ⏎ enters an agent — to come back, detach (keeps it running): " + tmux.PrefixRaw() + " d") for { tui.Clear() - rows = enrichGlances(fleetRows(tmux.ParseSessions(tmuxList())), manifests) + rows = enrichGlances(fleetRows(tmux.ParseSessions(tmuxList()), manifests), manifests) sortNeedsAttention(rows) annotatePaths(rows, registryPaths()) if psWaiting { @@ -724,7 +767,7 @@ func runWatch(w io.Writer, interval time.Duration, waitingOnly, notify bool) err var prev []FleetRow for { manifests, _ := adapter.Load() - rows := enrichGlances(fleetRows(tmux.ParseSessions(tmuxList())), manifests) + rows := enrichGlances(fleetRows(tmux.ParseSessions(tmuxList()), manifests), manifests) sortNeedsAttention(rows) shown := rows diff --git a/internal/cli/commands/ps_test.go b/internal/cli/commands/ps_test.go index bd44d94..7b6fada 100644 --- a/internal/cli/commands/ps_test.go +++ b/internal/cli/commands/ps_test.go @@ -6,6 +6,7 @@ import ( "testing" "time" + "github.com/stroops/sloop/internal/adapter" "github.com/stroops/sloop/internal/tmux" ) @@ -15,7 +16,7 @@ func TestFleetRowsFiltersAndSplits(t *testing.T) { {Name: "backend__cursor", Windows: 1}, {Name: "random", Windows: 1}, // not a sloop session } - rows := fleetRows(in) + rows := fleetRows(in, nil) if len(rows) != 2 { t.Fatalf("want 2 sloop rows, got %d (%+v)", len(rows), rows) } @@ -28,6 +29,39 @@ func TestFleetRowsFiltersAndSplits(t *testing.T) { } } +func TestSplitSession(t *testing.T) { + m := map[string]adapter.Manifest{"claude": {}, "cursor": {}} + cases := []struct { + name string + in string + manifests map[string]adapter.Manifest + wantWs, wantTool, wInst string + }{ + {"legacy two-segment", "repo__claude", m, "repo", "claude", ""}, + {"named instance", "repo__claude__sec", m, "repo", "claude", "sec"}, + {"numeric instance", "repo__claude__2", m, "repo", "claude", "2"}, + {"workspace with __", "a__b__claude", m, "a__b", "claude", ""}, + {"unknown tool falls back", "repo__weird", nil, "repo", "weird", ""}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + ws, tool, inst := splitSession(c.in, c.manifests) + if ws != c.wantWs || tool != c.wantTool || inst != c.wInst { + t.Fatalf("got (%q,%q,%q) want (%q,%q,%q)", ws, tool, inst, c.wantWs, c.wantTool, c.wInst) + } + }) + } +} + +func TestToolNameWithInstance(t *testing.T) { + if got := (FleetRow{Tool: "claude", Instance: "sec"}).toolName(); got != "claude·sec" { + t.Fatalf("got %q", got) + } + if got := (FleetRow{Tool: "claude"}).toolName(); got != "claude" { + t.Fatalf("got %q", got) + } +} + func TestRunPsEmptyAndPopulated(t *testing.T) { var b bytes.Buffer _ = RunPs(&b, nil) diff --git a/internal/cli/commands/send.go b/internal/cli/commands/send.go index e01c073..58ed0ff 100644 --- a/internal/cli/commands/send.go +++ b/internal/cli/commands/send.go @@ -59,7 +59,8 @@ func RunSend(target, msg string) error { if !tmux.Available() { return fmt.Errorf("tmux is not installed; `sloop send` needs tmux") } - rows := fleetRows(tmux.ParseSessions(tmuxList())) + manifests, _ := adapter.Load() + rows := fleetRows(tmux.ParseSessions(tmuxList()), manifests) row, err := resolveTarget(rows, target) if err != nil { return err @@ -77,7 +78,7 @@ func RunSendBroadcast(msg string, all, waiting bool) (int, error) { return 0, fmt.Errorf("tmux is not installed; `sloop send` needs tmux") } manifests, _ := adapter.Load() - rows := enrichGlances(fleetRows(tmux.ParseSessions(tmuxList())), manifests) + rows := enrichGlances(fleetRows(tmux.ParseSessions(tmuxList()), manifests), manifests) if waiting { rows = filterWaiting(rows) } diff --git a/internal/cli/commands/status.go b/internal/cli/commands/status.go index ce316bf..187d941 100644 --- a/internal/cli/commands/status.go +++ b/internal/cli/commands/status.go @@ -74,8 +74,9 @@ func RunStatus(startDir string, w io.Writer) error { // running sessions (best-effort; needs tmux). if tmux.Available() { + manifests, _ := adapter.Load() n := 0 - for _, r := range fleetRows(tmux.ParseSessions(tmuxList())) { + for _, r := range fleetRows(tmux.ParseSessions(tmuxList()), manifests) { if r.Workspace == ws.Name { n++ } diff --git a/internal/cli/commands/statusline.go b/internal/cli/commands/statusline.go index 48efbb3..2dbfaa5 100644 --- a/internal/cli/commands/statusline.go +++ b/internal/cli/commands/statusline.go @@ -32,18 +32,19 @@ func renderStatusline(session string) string { if session == "" { return "" } - ws, tool := session, "" - if i := strings.LastIndex(session, "__"); i >= 0 { - ws, tool = session[:i], session[i+2:] + manifests, _ := adapter.Load() + ws, tool, instance := splitSession(session, manifests) + label := tool + if instance != "" { + label = tool + "·" + instance // distinguish a second agent of the same tool } st := tmux.StatusUnknown if m, ok := fleetstate.Read(session); ok { st = stateToStatus(m.Status) } else if out, err := tmux.Output(tmux.BuildCaptureArgs(session)...); err == nil { - manifests, _ := adapter.Load() st = tmux.ClassifyStatus(string(out), manifests[tool]) } - return fmt.Sprintf("⚓ %s %s %s%s", ws, tool, tmuxStatusLabel(st), renderFleetBadge(session)) + return fmt.Sprintf("⚓ %s %s %s%s", ws, label, tmuxStatusLabel(st), renderFleetBadge(session)) } // waitingBadge formats the fleet-wide waiting count for the status bar, empty From c159f35b005fa0b4653bab92f8f203854b685ef8 Mon Sep 17 00:00:00 2001 From: Tuan Ngo Date: Sun, 28 Jun 2026 21:16:22 +0700 Subject: [PATCH 20/25] feat: Add sloop profile command to manage run profiles --- internal/cli/commands/profile.go | 149 ++++++++++++++++++++++++++ internal/cli/commands/profile_test.go | 54 ++++++++++ internal/cli/commands/registry.go | 1 + 3 files changed, 204 insertions(+) create mode 100644 internal/cli/commands/profile.go create mode 100644 internal/cli/commands/profile_test.go diff --git a/internal/cli/commands/profile.go b/internal/cli/commands/profile.go new file mode 100644 index 0000000..89112be --- /dev/null +++ b/internal/cli/commands/profile.go @@ -0,0 +1,149 @@ +package commands + +import ( + "fmt" + "sort" + "strings" + + "github.com/spf13/cobra" + + "github.com/stroops/sloop/internal/adapter" + "github.com/stroops/sloop/internal/config" +) + +// profileAdd validates the tool and stores (or overwrites) a profile in g. The +// tool is canonicalized to its manifest key so `sloop run @` always lands +// in the right adapter. +func profileAdd(g *config.Global, name, tool string, envFlags []string, manifests map[string]adapter.Manifest) error { + if name == "" { + return fmt.Errorf("profile name is required") + } + key, ok := toolKeyFor(tool, manifests) + if !ok { + return fmt.Errorf("unknown tool %q (no adapter)", tool) + } + env, err := parseEnvFlags(envFlags) + if err != nil { + return err + } + if g.Profiles == nil { + g.Profiles = map[string]config.Profile{} + } + g.Profiles[name] = config.Profile{Tool: key, Env: env} + return nil +} + +// profileRemove deletes a profile, erroring when it does not exist. +func profileRemove(g *config.Global, name string) error { + if _, ok := g.Profiles[name]; !ok { + return fmt.Errorf("no profile %q to remove", name) + } + delete(g.Profiles, name) + return nil +} + +// renderProfileList formats the configured profiles (sorted), or a hint to add +// one when there are none. +func renderProfileList(profiles map[string]config.Profile) string { + if len(profiles) == 0 { + return "no profiles yet — add one with `sloop profile add --tool --env KEY=VAL`" + } + names := make([]string, 0, len(profiles)) + for n := range profiles { + names = append(names, n) + } + sort.Strings(names) + var b strings.Builder + for _, n := range names { + p := profiles[n] + keys := make([]string, 0, len(p.Env)) + for k := range p.Env { + keys = append(keys, k) + } + sort.Strings(keys) + line := fmt.Sprintf("%-12s %s", n, p.Tool) + if len(keys) > 0 { + line += " env: " + strings.Join(keys, ", ") + } + b.WriteString(line + "\n") + } + return b.String() +} + +var profileTool string +var profileEnv []string + +var profileCmd = &cobra.Command{ + Use: "profile", + Aliases: []string{"prof", "profiles"}, + Short: "Manage reusable run profiles (e.g. a second account) in ~/.sloop/config.yaml", +} + +var profileAddCmd = &cobra.Command{ + Use: "add ", + Short: "Add or overwrite a profile: `sloop run @` launches its tool with its env", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + manifests, err := adapter.Load() + if err != nil { + return err + } + g, err := config.LoadGlobal() + if err != nil { + return err + } + if err := profileAdd(g, args[0], profileTool, profileEnv, manifests); err != nil { + return err + } + if err := config.SaveGlobal(g); err != nil { + return err + } + cmd.Printf("profile %q saved — run it with `sloop run @%s`\n", args[0], args[0]) + return nil + }, +} + +var profileLsCmd = &cobra.Command{ + Use: "ls", + Aliases: []string{"list"}, + Short: "List configured profiles", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + g, err := config.LoadGlobal() + if err != nil { + return err + } + cmd.Print(renderProfileList(g.Profiles)) + return nil + }, +} + +var profileRmCmd = &cobra.Command{ + Use: "rm ", + Aliases: []string{"remove"}, + Short: "Remove a profile", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + g, err := config.LoadGlobal() + if err != nil { + return err + } + if err := profileRemove(g, args[0]); err != nil { + return err + } + if err := config.SaveGlobal(g); err != nil { + return err + } + cmd.Printf("profile %q removed\n", args[0]) + return nil + }, +} + +func RegisterProfile(cmd *cobra.Command) { + profileAddCmd.Flags().StringVar(&profileTool, "tool", "", "the AI tool this profile launches (required)") + profileAddCmd.Flags().StringArrayVar(&profileEnv, "env", nil, "env for the launched tool, KEY=VAL (e.g. CLAUDE_CONFIG_DIR); repeatable") + _ = profileAddCmd.MarkFlagRequired("tool") + _ = profileAddCmd.RegisterFlagCompletionFunc("tool", completeTools) + profileCmd.AddCommand(profileAddCmd, profileLsCmd, profileRmCmd) + cmd.AddCommand(profileCmd) +} diff --git a/internal/cli/commands/profile_test.go b/internal/cli/commands/profile_test.go new file mode 100644 index 0000000..5e5f9a3 --- /dev/null +++ b/internal/cli/commands/profile_test.go @@ -0,0 +1,54 @@ +package commands + +import ( + "strings" + "testing" + + "github.com/stroops/sloop/internal/adapter" + "github.com/stroops/sloop/internal/config" +) + +func TestProfileAdd(t *testing.T) { + m := map[string]adapter.Manifest{"claude": {Name: "Claude"}} + g := &config.Global{} + + if err := profileAdd(g, "sec", "claude", []string{"CLAUDE_CONFIG_DIR=/x"}, m); err != nil { + t.Fatal(err) + } + p, ok := g.Profiles["sec"] + if !ok || p.Tool != "claude" || p.Env["CLAUDE_CONFIG_DIR"] != "/x" { + t.Fatalf("profile not stored: %+v", g.Profiles) + } + + if err := profileAdd(g, "bad", "nope", nil, m); err == nil || !strings.Contains(err.Error(), "unknown tool") { + t.Fatalf("want unknown tool error, got %v", err) + } + if err := profileAdd(g, "", "claude", nil, m); err == nil { + t.Fatal("want error for empty name") + } +} + +func TestProfileRemove(t *testing.T) { + g := &config.Global{Profiles: map[string]config.Profile{"sec": {Tool: "claude"}}} + if err := profileRemove(g, "nope"); err == nil || !strings.Contains(err.Error(), "no profile") { + t.Fatalf("want no-profile error, got %v", err) + } + if err := profileRemove(g, "sec"); err != nil { + t.Fatal(err) + } + if _, ok := g.Profiles["sec"]; ok { + t.Fatal("profile not removed") + } +} + +func TestRenderProfileList(t *testing.T) { + if got := renderProfileList(nil); !strings.Contains(got, "profile add") { + t.Fatalf("empty list should hint, got %q", got) + } + got := renderProfileList(map[string]config.Profile{ + "sec": {Tool: "claude", Env: map[string]string{"CLAUDE_CONFIG_DIR": "/x"}}, + }) + if !strings.Contains(got, "sec") || !strings.Contains(got, "claude") || !strings.Contains(got, "CLAUDE_CONFIG_DIR") { + t.Fatalf("list missing fields: %q", got) + } +} diff --git a/internal/cli/commands/registry.go b/internal/cli/commands/registry.go index b427b71..7144b2b 100644 --- a/internal/cli/commands/registry.go +++ b/internal/cli/commands/registry.go @@ -33,6 +33,7 @@ func init() { add(RegisterRestore) add(RegisterPopup) add(RegisterPeek) + add(RegisterProfile) add(RegisterStatusline) add(RegisterSkill) add(RegisterHooks) From a679bc463cdb24cb755a6bae595f50d1eb498918 Mon Sep 17 00:00:00 2001 From: Tuan Ngo Date: Sun, 28 Jun 2026 21:33:00 +0700 Subject: [PATCH 21/25] docs: Document profiles, named instances, and peek --- CHANGELOG.md | 15 ++++++++++++ README.md | 15 +++++++++++- docs/guide/USAGE.md | 52 ++++++++++++++++++++++++++++++++++++++++ docs/reference/CONFIG.md | 13 ++++++++++ 4 files changed, 94 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2af9200..a9067a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,21 @@ All notable changes to Sloop are documented here. This project adheres to [Semantic Versioning](https://semver.org/). +## Unreleased + +### Run multiple agents & accounts +- Named instances: `sloop run claude@review` / `-n/--name` runs a second agent of the same tool in one + repo (session `__tool__instance`); `-N/--new` auto-names the next free slot (`claude·2`…). +- Profiles: save a tool + env once with `sloop profile add|ls|rm` (global `~/.sloop/config.yaml`) and + launch it as `sloop run @` — e.g. a different account via `CLAUDE_CONFIG_DIR`. `--env KEY=VAL` + injects env one-off without a profile (`~`/`$VAR` expanded). The fleet view shows instances as + `tool·instance`. + +### Peek (overlay a waiting agent) +- `sloop peek` floats a waiting agent's live pane over your current screen so you can answer it and + drop back without `switch-client` swapping your whole screen; `sloop peek setup` binds a key. Needs + tmux ≥ 3.2. Every status bar gains a fleet-wide `⏳ N waiting` badge. + ## v0.1.0 — first release The initial public release: the local-first control layer for your AI coding CLIs. diff --git a/README.md b/README.md index 46bcf93..fac8f7d 100644 --- a/README.md +++ b/README.md @@ -133,6 +133,15 @@ picker. > only touches `status-left`/`status-right` (not your colors/theme). Keep your own bar fully intact with > `SLOOP_STATUSLINE=0`. +**Don't want to leave your screen?** From inside one agent, **`sloop peek`** floats a *waiting* agent's +live pane over your current work — answer it, close it, and you're exactly where you were (no +`switch-client` that swaps your whole screen). Bind a key with `sloop peek setup`. Needs tmux ≥ 3.2. + +**A second agent, or a second account?** `sloop run claude@review` opens another claude in the same +repo (`claude·review` in the fleet); `sloop run claude --new` auto-names the next one (`claude·2`). For a +different account, save a profile once — `sloop profile add sec --tool claude --env CLAUDE_CONFIG_DIR=~/.claude-sec` +— then `sloop run @sec`. + `sloop ls` is the companion view — your registered **workspaces** (running or not) with their live agents — to launch (`r`), open a shell (`s`), `c` to copy a `cd`, or `Enter` to jump in. @@ -145,7 +154,8 @@ agents — to launch (`r`), open a shell (`s`), `c` to copy a `cd`, or `Enter` t | Command | Alias | Flags (short) | What it does | |---|---|---|---| | `init` | — | `-s/--scan`, `-S/--scaffold` | Scaffold `AGENTS.md` + `.sloop/`, deliver pointers, register the workspace. `--scan` pre-fills `AGENTS.md` from the codebase; `--scaffold` creates each tool's standard folders. | -| `run [tool]` | `r` | `-w/--workspace`, `--split`, `-- ` | Sync context, then launch a tool (in tmux if present). `--split` runs several side by side; `-w` targets a registered workspace from anywhere. | +| `run [tool]` | `r` | `-n/--name`, `-N/--new`, `--env`, `-w/--workspace`, `--split`, `-- ` | Sync context, then launch a tool (in tmux if present). Run a **2nd agent of the same tool** with `tool@instance`, `-n/--name`, or `-N/--new` (auto-named `tool·2`…); a **different account** with a saved `@profile` or one-off `--env KEY=VAL`. `--split` runs several side by side; `-w` targets any registered workspace. | +| `profile add\|ls\|rm` | `prof` | `add --tool`, `add --env` | Save reusable run profiles in `~/.sloop/config.yaml` (e.g. a 2nd account's `CLAUDE_CONFIG_DIR`); launch one with `sloop run @`. | | `sync [tool]` | `s` | `-a/--all`, `-r/--repair`, `-w/--workspace` | (Re)deliver pointer files + skills symlinks without launching. `--repair` safely moves a foreign file aside (never deletes). | | `ps [#]` | — | `-f/--watch`, `-n/--interval`, `--waiting`, `--notify`, `--all` | The cross-repo fleet. Reads what each agent is asking and shows answer keys; `<#>` jumps; `-f` live-monitors + alerts. | | `approve ` | — | `--waiting`, `--all`, `--yes` | Send the Yes/Approve answer to waiting agent(s) — one-command approve. | @@ -154,6 +164,7 @@ agents — to launch (`r`), open a shell (`s`), `c` to copy a `cd`, or `Enter` t | `adopt ` | — | `-w/--workspace`, `--as` | Bring an external tmux session (one you started yourself) into the fleet. | | `restore` | — | `--resume`, `--yes` | Relaunch your recent agents (detached) after a reboot / tmux restart. `--resume` continues each tool's prior conversation. | | `popup` / `popup setup` | `hud` | `--key` | Open the fleet `ps` as a floating tmux popup / HUD; `setup` binds a key. Needs tmux ≥ 3.2. | +| `peek [agent]` / `peek setup` | `pk` | `--key` | Overlay a waiting agent in a floating popup — answer it and drop back **without switching your whole screen**. No arg → the lone waiting agent or a picker; `setup` binds a key. Needs tmux ≥ 3.2. | | `statusline setup` | — | — | Show a session's live status (`⚓ repo tool ◆ waiting`) in its tmux status bar. | | `attach [session]` | `a` | — | Attach to a session by name, or `sloop a` (no name) to pick from the fleet. | | `skills new\|add <…>` | `sk`, `skill` | `new`→`n`, `add`→`import` | Scaffold or import a reusable skill (shared across every tool). | @@ -248,6 +259,8 @@ hooks: # status hooks for `sloop ps` - [docs/reference/CONFIG.md](docs/reference/CONFIG.md) — the three config layers (local / global / built-in) - [docs/reference/ADAPTERS.md](docs/reference/ADAPTERS.md) — the provider-aware adapter contract - [docs/design/run.md](docs/design/run.md) — `sloop run` design: CLI · model · effort resolution +- [docs/design/profiles.md](docs/design/profiles.md) — named instances & profiles: a 2nd agent / 2nd account +- [docs/design/peek.md](docs/design/peek.md) — overlay a waiting agent without leaving your screen - [docs/design/skills.md](docs/design/skills.md) — skills model, lockfile, registry roadmap - [docs/design/hooks.md](docs/design/hooks.md) — status hooks today, workflow-hook design for v0.2.0 - [docs/reference/ARCHITECTURE.md](docs/reference/ARCHITECTURE.md) — packages, data flow, internals diff --git a/docs/guide/USAGE.md b/docs/guide/USAGE.md index d0d2346..230c241 100644 --- a/docs/guide/USAGE.md +++ b/docs/guide/USAGE.md @@ -124,6 +124,41 @@ sloop run --split # just the default tool (one pane) Great for running two agents on the same code and comparing, or a coder + a reviewer. +### A second agent, or a second account (named instances & profiles) + +`sloop run claude` a second time just re-attaches the existing `__claude` — by design, so it +means "jump back into my claude." To get a **distinct** second agent of the same tool, name it: + +```sh +sloop run claude@review # an ad-hoc instance → session __claude__review +sloop run claude -n review # same thing, with the flag +sloop run claude --new # no name? auto-pick the next free slot: claude·2, claude·3, … +``` + +Named instances show up in `sloop ps` as `claude·review` / `claude·2`, so two claudes in one repo are +never ambiguous. + +For a **different account** of the same provider, sloop launches the real binary (not your shell, so a +`claude-sec` alias won't apply) and injects the env that selects the account. One-off: + +```sh +sloop run claude --name sec --env CLAUDE_CONFIG_DIR=~/.claude-sec +``` + +Reusing it daily? Save a **profile** once (global, works from any repo) and call it with `@`: + +```sh +sloop profile add sec --tool claude --env CLAUDE_CONFIG_DIR=~/.claude-sec +sloop profile ls # name · tool · env keys +sloop run @sec # launches claude under ~/.claude-sec, session __claude__sec +sloop profile rm sec # remove it +``` + +The profile name becomes the instance suffix (override with `--name`). Env values expand `~` and +`$VAR`; `--env` at the call site overrides the profile on a key clash. Profiles live in +`~/.sloop/config.yaml` (see [CONFIG.md](../reference/CONFIG.md)); the full design is in +[profiles.md](../design/profiles.md). + --- ## 3. Manage the fleet (`sloop ps`) @@ -278,6 +313,23 @@ After `setup`, press your tmux **prefix then `g`** from inside any agent to summ control center appears floating, you answer/jump with one key, and the popup vanishes. (Native Windows psmux may not support popups; everything else still works.) +### Peek into a waiting agent (overlay, tmux ≥ 3.2) + +`sloop ps` + `Enter` (or `switch-client`) **replaces your whole screen** with the agent. When you just +need to answer a quick prompt and get back to deep work, **peek** floats that agent's live pane over +your current one instead: + +```sh +sloop peek # overlay the lone waiting agent (or pick when several/none wait) +sloop peek backend__claude # overlay a specific session +sloop peek setup # bind p to peek the waiting agent (prints the ~/.tmux.conf line) +``` + +Answer the agent in the floating pane, close it, and you're exactly where you were — the agent keeps +running (closing the overlay detaches, it never kills the session). Every sloop status bar also shows a +fleet-wide **`⏳ N waiting`** badge so you know when an agent needs you without leaving your seat. Full +design: [peek.md](../design/peek.md). + --- ## 4. Context delivery without launching diff --git a/docs/reference/CONFIG.md b/docs/reference/CONFIG.md index 207a1ce..d6301b8 100644 --- a/docs/reference/CONFIG.md +++ b/docs/reference/CONFIG.md @@ -21,8 +21,21 @@ version: 1 mode: ask lang: vi # optional: hint language (en/vi); empty = auto from $SLOOP_LANG/$LANG hints: false # optional: turn the 💡 education tips off (omit/true = on) + +# optional: reusable run profiles — a named tool + env, launched as `sloop run @`. +# Manage with `sloop profile add|ls|rm`, or hand-edit here. +profiles: + sec: # → sloop run @sec (session __claude__sec) + tool: claude # a manifest key (or its binary alias) + env: + CLAUDE_CONFIG_DIR: ~/.claude-sec # ~ and $VAR are expanded at launch ``` +Profiles are **global on purpose**: an alternate account is a personal thing reused across every repo, +not project state. The profile name becomes the session's instance suffix; `sloop run` flags +(`-m`, `-e`, `--env`, …) still apply on top. See the +[profiles design](../design/profiles.md) for the full token grammar (`@profile`, `tool@instance`). + ## 2. Local — `.sloop/` (per repo, committed) | Path | Purpose | From ef04ff2eeda4c8d629d3910f7e9928d0e10d781e Mon Sep 17 00:00:00 2001 From: Tuan Ngo Date: Sun, 28 Jun 2026 21:36:38 +0700 Subject: [PATCH 22/25] docs: Clean up design docs to pure design and mark shipped --- docs/design/peek.md | 140 ++++++++++++------------------- docs/design/profiles.md | 182 ++++++++++++++-------------------------- 2 files changed, 115 insertions(+), 207 deletions(-) diff --git a/docs/design/peek.md b/docs/design/peek.md index ddd8407..8af5bbe 100644 --- a/docs/design/peek.md +++ b/docs/design/peek.md @@ -1,7 +1,7 @@ # Design: `sloop peek` — overlay into a waiting agent without losing your spot **Date:** 2026-06-28 -**Status:** Approved design, ready for implementation plan +**Status:** Shipped **Scope:** Phase 1 + Phase 2 (Phase 3 deferred) ## Problem @@ -10,9 +10,9 @@ When you orchestrate a cross-repo fleet, you work from one "home base" session while agents run in other tmux sessions. When an agent hits a hook that needs your input (it goes `waiting`), the only way to answer it today is `sloop attach`, which — because `attach` cannot nest inside tmux — falls back to -`switch-client` (`internal/tmux/fleet.go:27`). That **replaces your whole -screen** with the agent's session. You answer, then `switch-client` back. You -lose your spot and your context every round trip. +`switch-client`. That **replaces your whole screen** with the agent's session. +You answer, then switch back. You lose your spot and your context every round +trip. What we actually want: from home base, get a quiet signal that an agent needs you, press one key to **float that agent's live pane over your current screen**, @@ -25,43 +25,37 @@ that a screen-replacing `switch-client` cannot. - **Phase 3 — non-modal floating panes (tmux ≥ 3.7 `new-pane`).** A modal `display-popup` is well-matched to "answer and dismiss," so non-modal is - polish, not foundation. It becomes a progressive enhancement behind a future - `FloatingSupported()` (≥ 3.7) once 3.7 is widespread. Not in this spec. -- **True auto-pop.** We will not auto-open an overlay onto the user mid-keystroke. - Surfacing is always user-triggered. A future opt-in config knob can add + polish, not foundation. It becomes a progressive enhancement once tmux 3.7 is + widespread. Not in this scope. +- **True auto-pop.** We never auto-open an overlay onto the user mid-keystroke. + Surfacing is always user-triggered. A future opt-in config knob could add aggressive auto-pop for those who want it. - **Watching multiple agents floating at once**, move/resize, custom layouts. ## Design overview: "Signal → Act" -Two layers ship here. (The earlier three-layer framing folds the standalone HUD -glance into Phase 3; Phase 1+2 is the convenient core.) +Two layers ship here: an ambient signal that an agent needs you, and a one-key +action to answer it. ### Layer A — Signal (ambient, never interrupts) The status hooks already write a fresh `waiting | working | idle` marker per -session (`internal/fleetstate/state.go`, read via `fleetstate.Read`). We surface -a **fleet-wide waiting count** on the home-base session's status line, e.g. -`⏳ 2 waiting`, rendered empty when zero so the bar stays clean. - -The status line is drawn on every window/client the user looks at, so this +session. We surface a **fleet-wide waiting count** on the home-base session's +status line, e.g. `⏳ 2 waiting`, rendered empty when zero so the bar stays +clean. The status line is drawn on every window/client the user looks at, so the signal "follows the user" for free, on any tmux version, with zero interruption. -- **Mechanism (refined for simplicity):** rather than a separate command + a - new setup step, we **append the badge to the existing per-session - `renderStatusline`** (`internal/cli/commands/statusline.go`). Every sloop - session already points its `status-right` at `sloop statusline ` - (`tmux.SetStatusLine`), so the badge appears on every session's bar - automatically — it "follows you" for free with zero new setup. -- The badge counts sessions whose **fresh** marker is `waiting` - (`fleetstate.Read`, markers only — no capture-pane), **excluding the current - session** so it reads "others need you." Empty string when zero, so the bar - stays clean. Markers-only keeps this cheap enough to run every status-interval. +Rather than a separate command and a new setup step, the badge is **appended to +the per-session status line that every sloop session already renders** — so it +appears everywhere automatically, with no new setup. It counts sessions whose +fresh marker is `waiting`, **excluding the current session** so it reads "others +need you," using markers only (no pane capture) so it is cheap enough to run +every status interval. ### Layer B — Act (`sloop peek`) -A new command that overlays a target agent's **live session** in a -`display-popup`, using a nested attach so the home-base client is never touched. +A command that overlays a target agent's **live session** in a `display-popup`, +using a nested attach so the home-base client is never touched. ``` sloop peek [agent] @@ -70,87 +64,59 @@ sloop peek [agent] ``` **Target resolution (the "convenient" part):** + 1. exactly one agent is `waiting` → peek it directly, no prompt. -2. more than one `waiting` → show the fleet picker (`pickFleetSession`, which is - already sorted waiting-first) so you choose. -3. none `waiting` → fall back to the same picker over the whole fleet. +2. more than one `waiting` → show the fleet picker (already sorted waiting-first) + so you choose. +3. none `waiting` → the same picker over the whole fleet. -**The overlay mechanism (the one tricky part):** -`display-popup -E` runs its command with `$TMUX` still set, so a plain -`tmux attach` inside it would be refused ("sessions should be nested with care"). -We clear `TMUX` for the inner command so tmux allows a **fresh nested client**: +**The overlay mechanism (the one tricky part):** `display-popup -E` runs its +command with `$TMUX` still set, so a plain `tmux attach` inside it would be +refused ("sessions should be nested with care"). Clearing `TMUX` for the inner +command lets tmux allow a **fresh nested client**: ``` display-popup -w 90% -h 80% -E 'TMUX= attach -t ' ``` -Closing the popup kills that nested client → it detaches → **the agent session -keeps running**. The home-base client was never switched. - -Caveats to verify during implementation (call-outs for the plan, not blockers): -- Nested attach clamps the session's window size to the smallest attached - client while the popup is open. Acceptable for a peek; note it. -- Confirm closing the popup detaches cleanly and never kills the agent session. -- Confirm `TMUX=` (and clearing `TMUX_PANE` if needed) reliably permits the - nested attach across tmux 3.2–3.6. +Closing the popup ends that nested client → it detaches → **the agent session +keeps running**. The home-base client was never switched. (Confirmed in use: +the nested attach is permitted across tmux 3.2–3.6, and closing the overlay +detaches cleanly without killing the agent. While the popup is open the session +window is clamped to the smallest attached client — acceptable for a peek.) -**Keybind without double-popup:** -Pressing the bound key must open exactly one popup. So the keybind points -straight at the popup, and the *inner* command does target resolution (it has a -TTY inside the popup) then `exec`s the nested attach: +**Keybind without double-popup:** pressing the bound key must open exactly one +popup. So the keybind points straight at the popup, and the *inner* command +(`sloop peek --in-popup`, which has a TTY inside the popup) does target +resolution and then the `TMUX=`-cleared nested attach — no second popup: ``` bind-key p display-popup -w 90% -h 80% -E " peek --in-popup" ``` -- `sloop peek` (from a normal shell) wraps the inner command in the popup itself. -- `sloop peek --in-popup` (flag name TBD in plan): already inside a popup — - resolve target, then exec the `TMUX=`-cleared nested attach. No extra popup. -- `sloop peek setup [--key p]` installs the bind on the live server and prints - the `~/.tmux.conf` line, mirroring `popup setup` - (`internal/cli/commands/popup.go`). - -## Component breakdown - -| Unit | Location (proposed) | Responsibility | -|---|---|---| -| `NestedAttachCmd(session) string` | `internal/tmux/` | Build the `TMUX=`-cleared nested-attach shell command (testable seam, like `attachArgs`). | -| `BuildPeekPopupArgs(w,h,inner)` | `internal/tmux/popup.go` | Reuse `BuildPopupArgs`; peek just supplies a bigger default size. | -| `Peek(session)` | `internal/tmux/popup.go` | Run the outer `display-popup` wrapping the inner command. | -| peek target resolver | `internal/cli/commands/` | 0/1/many-waiting → direct or picker. Reuses `pickFleetSession` + `fleetstate`. | -| `peek` cobra command (+`--in-popup`, `setup`) | `internal/cli/commands/peek.go` | Wire the above; require tmux + `PopupSupported()`. | -| fleet waiting badge | `internal/cli/commands/statusline.go` | Append a fleet-wide `⏳ N waiting` badge (empty when zero, excludes self) to the existing per-session `renderStatusline` — no new command or setup. | +`sloop peek` from a normal shell wraps the inner command in the popup itself. +`sloop peek setup [--key p]` installs the bind on the live server and prints the +`~/.tmux.conf` line, mirroring `popup setup`. ## Error & edge handling -- **Not inside tmux** (`$TMUX` empty): peek has no meaning (nothing to overlay - over). Error clearly and point to `sloop attach`. -- **tmux < 3.2** (`!PopupSupported()`): no `display-popup`. Error and point to - `sloop attach`. (Peek's value *is* the overlay; silently switch-clienting - would defeat the purpose, so we don't.) +- **Not inside tmux:** peek has nothing to overlay over. Error clearly and point + to `sloop attach`. +- **tmux < 3.2:** no `display-popup`. Error and point to `sloop attach` — peek's + value *is* the overlay, so silently switching the whole screen would defeat the + purpose. - **No sessions / empty fleet:** the resolver surfaces the same "no running AI - sessions" error `pickFleetSession` already returns. -- **Stale/missing markers:** `fleetstate.Read` already returns `ok=false` for - stale markers; resolver and badge fall back to the capture-pane heuristic the - status line already uses, so a missing hook never hides a waiting agent. - -## Testing - -- **Unit:** `NestedAttachCmd` / `BuildPeekPopupArgs` string shape (seam tests, - like `TestBuildTmuxAttachArgs`). Resolver target selection across 0/1/many - `waiting` with injected fleet rows + fleetstate. Fleet badge render: `0 → - ""`, `N → "⏳ N waiting"`. -- **e2e:** extend `e2e/fleet_test.go` to assert peek's popup arg construction and - that resolution picks the waiting session; the fully interactive nested attach - is left to manual verification (the caveats above), since a real attach needs a - live TTY. + sessions" error the fleet picker already returns. +- **Stale/missing markers:** the fleet state already treats stale markers as + absent, so the resolver and badge fall back to the capture-pane heuristic the + status line already uses — a missing hook never hides a waiting agent. ## Why this is the simple, convenient core - Ships on **today's tmux (≥ 3.2)** — every user, not just 3.7. -- Modal popup fits "answer and dismiss" exactly; no non-modal complexity needed. -- Reuses what exists: status hooks, `fleetstate`, `pickFleetSession`, - `BuildPopupArgs`, `BuildAttachArgs`, the `popup setup` keybind pattern. +- A modal popup fits "answer and dismiss" exactly; no non-modal complexity needed. +- Reuses what exists: status hooks, fleet state, the fleet picker, and the + `popup setup` keybind pattern. - Keeps orchestration minimal, matching the project's wedge (portable context + cross-repo fleet, not heavyweight in-repo orchestration). - The tmux 3.7 feature that prompted this (floating panes) is correctly placed as diff --git a/docs/design/profiles.md b/docs/design/profiles.md index 3365a1f..ff51ca3 100644 --- a/docs/design/profiles.md +++ b/docs/design/profiles.md @@ -1,28 +1,25 @@ # Design: profiles & named instances — run a second agent (or a second account) of the same provider **Date:** 2026-06-28 -**Status:** Approved design, ready for implementation plan +**Status:** Shipped **Scope:** Global profiles, named instances, env injection, `sloop profile` command ## Problem -Today a session is named `__` (`internal/tmux/tmux.go:84`), and -`tmux.Runner.Launch` re-attaches when that session already exists -(`internal/tmux/tmux.go:208`). Two consequences: +A session is named `__`, and launching re-attaches when that +session already exists. Two consequences: 1. **You cannot run a second agent of the same provider in one workspace.** - `sloop run claude` a second time just re-attaches the existing - `__claude` — there is no place in the name to distinguish a second - instance. + `sloop run claude` a second time just re-attaches the existing `__claude` + — there is no place in the name to distinguish a second instance. 2. **You cannot run a different account of the same provider.** sloop launches - the real binary `m.Launch` (e.g. `claude`) directly via tmux `new-session`, - *not* through a shell, so a user's shell alias (`claude-sec`) does not apply, - and there is no way to inject the env that selects another account - (`CLAUDE_CONFIG_DIR=~/.claude-sec`). `runner.Spec` has no `Env` field. + the real binary (e.g. `claude`) directly, *not* through a shell, so a user's + shell alias (`claude-sec`) does not apply, and there is nowhere to inject the + env that selects another account (`CLAUDE_CONFIG_DIR=~/.claude-sec`). -Both gaps reduce to two missing primitives — a per-instance name suffix, and -env injection at launch — plus declarative sugar so a reused alternate account -is one word. +Both gaps reduce to two missing primitives — a per-instance name suffix, and env +injection at launch — plus declarative sugar so a reused alternate account is one +word. ## Non-goals (deliberately deferred) @@ -37,7 +34,7 @@ is one word. forwarded un-validated. - **Changing the default re-attach behavior.** `sloop run claude` with no instance still means "jump to my claude." A fresh instance is always explicit - (`@`, `tool@instance`, `--name`, or `--new`). + (`@profile`, `tool@instance`, `--name`, or `--new`). ## Token grammar @@ -50,15 +47,15 @@ introduces a *name*; what is left of it (if anything) is the tool. | `@sec` | left of `@` empty → **profile** `sec` | `ws__claude__sec` | | `claude@b` | left is a tool → ad-hoc **instance** `b`, default account | `ws__claude__b` | -Resolution rule for a token containing `@`, split once into `left@right`: +For a token containing `@`, split once into `left@right`: -- `left == ""` → look up **profile** `right` in global config. Profile supplies - `tool` (required) and `env`. Instance name defaults to the profile key - (`right`), overridable by `--name`. +- `left` empty → look up **profile** `right` in the global config. The profile + supplies the tool (required) and env. The instance name defaults to the + profile key (`right`), overridable by `--name`. - `left` is a known tool/alias → ad-hoc **instance** `right` on that tool, no - env. Equivalent to `sloop run --name `. -- `left` is neither empty nor a known tool → error: - `unknown tool %q in %q (use @ or @)`. + env — equivalent to `sloop run --name `. +- `left` is neither empty nor a known tool → error (it is neither a profile nor + a known tool). Tokens without `@` resolve exactly as today (tool key → binary alias → model alias). @@ -66,13 +63,12 @@ alias). ## Flags on `sloop run` - `-n, --name ` — explicit instance suffix. Sanitized; rejected if it - contains `__` (would break the parser). Overrides a profile key. -- `--env KEY=VAL` (repeatable, `StringArray`) — ad-hoc env injection without a - profile. Merged after profile env (call-site wins on key clash). -- `-N, --new` — when no explicit name/instance is given, pick the first free - instance slot instead of re-attaching: try `ws__tool`, then `ws__tool__2`, - `__3`, … and create the first that does not exist. With an explicit name it is - a no-op (the name already makes it distinct). + contains `__` (the parser uses `__` as the separator). Overrides a profile key. +- `--env KEY=VAL` (repeatable) — ad-hoc env injection without a profile. Merged + after profile env, so the call site wins on a key clash. +- `-N, --new` — when no explicit name/instance is given, take the first free + instance slot instead of re-attaching: `ws__tool`, then `ws__tool__2`, `__3`, + … With an explicit name it is a no-op (the name already makes it distinct). ## Profile schema (global `~/.sloop/config.yaml`) @@ -81,86 +77,50 @@ profiles: sec: tool: claude # required: a manifest key (or alias) env: - CLAUDE_CONFIG_DIR: ~/.claude-sec # ~ and $VAR expanded at resolve time + CLAUDE_CONFIG_DIR: ~/.claude-sec # ~ and $VAR expanded at launch ``` -Add to `config.Global`: - -```go -type Profile struct { - Tool string `yaml:"tool"` - Env map[string]string `yaml:"env,omitempty"` -} -// Global gains: -Profiles map[string]Profile `yaml:"profiles,omitempty"` -``` - -`LoadGlobal()` already tolerates missing keys, so old configs load with -`Profiles == nil`. +Profiles are optional and unknown to older configs: a config without a +`profiles:` key is unaffected. ## Session naming -```go -// InstanceName returns ws__tool for instance=="", else ws__tool__. -func InstanceName(workspace, tool, instance string) string -``` - -`SessionName(ws, tool)` stays for the default/legacy call sites; `InstanceName` -is the superset used by `run`. The session DB already has a `Profile` column -(`internal/session/store.go:152`) that has never carried a real value — it now -records the instance name. - -## Tool-aware name parser (the one careful bit) +A named instance appends a suffix: `ws__tool__instance`. An empty instance is +the legacy `ws__tool`, byte-for-byte unchanged. The profile name (or `--name`, +or the auto-picked `2`, `3`, …) becomes the instance suffix, and the session +registry records it. -Two sites *extract the tool* from a session name today, both in the `commands` -package and both using `strings.LastIndex(name, "__")`: +## Tool-aware name parsing (the one careful bit) -- `internal/cli/commands/ps.go:87` (`fleetRows`) -- `internal/cli/commands/statusline.go:36` (`renderStatusline`) - -A three-segment `ws__tool__instance` breaks `LastIndex` (it would read the -instance as the tool). Replace both with one shared, manifest-aware helper: - -```go -// splitSession parses a sloop session name into workspace, tool, instance. -// Manifest-aware so the tool is identified by key, not by position: -// - if the last "__" segment is a known tool → tool=last, instance="" (legacy) -// - else if the second-to-last is a known tool → tool=that, instance=last -// - else fall back to LastIndex (unknown tool, instance="") -func splitSession(name string, manifests map[string]adapter.Manifest) (ws, tool, instance string) -``` - -This is backward compatible (two-segment names parse exactly as before) and -robust to a workspace name that itself contains `__`, because the tool is found -by key rather than by counting separators. (`renderFleetBadge` only *detects* -sloop sessions via `LastIndex(name, "__") < 0`, which still holds for -three-segment names, so it is left unchanged.) +The fleet view splits a session name back into workspace and tool. A +three-segment `ws__tool__instance` breaks the old "split on the last `__`" rule +— it would read the instance as the tool. Instead the tool is identified **by +matching known adapter keys**: the tool is the last `__` segment that is a known +tool (legacy `ws__tool` → no instance), else the segment just before a trailing +instance. This is backward compatible, and robust even when a workspace name +itself contains `__`, because the tool is found by key rather than by counting +separators. ## Env injection -Add `Env map[string]string` to `runner.Spec`. Two runners consume it: - -- **tmux** (`tmux.Runner.Launch`): prefix the launched command with the `env` - binary so it works on every tmux version (no dependency on `new-session -e`): - - ``` - new-session -d -s -c env K1=V1 K2=V2 - ``` +Launch carries extra env (e.g. `CLAUDE_CONFIG_DIR`). Under tmux the launched +command is prefixed with the `env` binary, so it works on every tmux version +with no dependency on `new-session -e`: - `env` keys are emitted in sorted order for deterministic output/tests; nil/empty - env produces no prefix (byte-identical to today). -- **exec** (`runner.ExecRunner`, no tmux): `cmd.Env = append(os.Environ(), …)`. +``` +new-session -d -s -c env CLAUDE_CONFIG_DIR=… +``` -Values are expanded before launch: a leading `~/` → `$HOME/`, and `$VAR`/ -`${VAR}` via `os.Expand` against the current environment. +Without tmux, the env is set on the child process directly. Values are expanded +before launch: a leading `~/` becomes the home dir, and `$VAR`/`${VAR}` expand +against the current environment. Empty env changes nothing (identical to today). ## Fleet display The instance shows next to the tool so two claudes in one repo are -distinguishable: `claude·sec`, `claude·b`, `claude·2`. `FleetRow` gains an -`Instance` field set by `splitSession`; `displayTool` renders `tool` when -instance is empty, else `tool·instance`. `ps`, the picker, the status line, and -the waiting badge all read through that one display path. +distinguishable — `claude·sec`, `claude·b`, `claude·2` — consistently across +`sloop ps`, the fleet picker, the per-session status line, and the waiting +badge. ## `sloop profile` command @@ -172,33 +132,15 @@ sloop profile ls # name · tool · en sloop profile rm # delete ``` -`add` validates that `--tool` resolves to a manifest key (reusing -`toolKeyFor`) and writes via `SaveGlobal`. `ls` with no profiles prints a hint -pointing at `profile add`. +`add` validates that the tool resolves to a known adapter before saving; `ls` +with no profiles prints a hint pointing back at `profile add`. ## Error handling -- Unknown profile → `unknown profile %q (see: sloop profile ls)`. -- Profile's `tool` not a manifest key → `profile %q uses unknown tool %q`. -- `--name` containing `__` → `instance name %q cannot contain "__"`. -- `--env` not `KEY=VAL` → `--env must be KEY=VAL, got %q`. -- `profile rm` of a missing name → `no profile %q to remove`. - -## Testing - -Pure, table-driven units (no tmux/network), matching the existing run tests: - -- `splitSession`: 2-seg legacy, 3-seg instance, unknown tool fallback, - workspace-containing-`__`. -- token grammar: `@sec` → profile, `claude@b` → instance, `claude` → plain, - `bad@x` → error, profile-key default vs `--name` override. -- `InstanceName`: empty vs non-empty instance; sanitize. -- env: `--env` parse + merge order (profile then `--env`), `~`/`$VAR` expansion, - sorted `env` prefix in the tmux create args, empty env → no prefix. -- `--new`: first free slot given a set of existing session names. -- profile config: round-trip add/ls/rm through `Global`, `add` rejects unknown - tool. - -Manual (cannot be unit-tested — needs a live tmux and two real accounts): -`sloop run @sec` actually launches claude under `~/.claude-sec`; two instances -coexist in `sloop ps` as `claude·sec` / `claude`; `--new` spins `claude·2`. +User-facing messages stay specific and actionable: + +- Unknown profile → points at `sloop profile ls`. +- A profile whose `tool` is not a known adapter → names the bad tool. +- An instance name containing `__` → rejected (reserved separator). +- An `--env` value that is not `KEY=VAL` → rejected. +- `profile rm` of a name that does not exist → says so. From bf5b1323f5ed82bb72e70aff6f500e202f868d00 Mon Sep 17 00:00:00 2001 From: Tuan Ngo Date: Sun, 28 Jun 2026 21:43:57 +0700 Subject: [PATCH 23/25] docs: Prepare v0.1.1 changelog --- CHANGELOG.md | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a9067a3..253b34d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,10 @@ All notable changes to Sloop are documented here. This project adheres to [Semantic Versioning](https://semver.org/). -## Unreleased +## v0.1.1 — 2026-06-28 + +Run more than one agent per repo, peek into a waiting agent without leaving your +screen, and a sharper first-run experience — all on top of v0.1.0. ### Run multiple agents & accounts - Named instances: `sloop run claude@review` / `-n/--name` runs a second agent of the same tool in one @@ -18,6 +21,13 @@ All notable changes to Sloop are documented here. This project adheres to drop back without `switch-client` swapping your whole screen; `sloop peek setup` binds a key. Needs tmux ≥ 3.2. Every status bar gains a fleet-wide `⏳ N waiting` badge. +### Sharper onboarding (init / check / doctor) +- `sloop init` is provider-respecting: it asks about the tools you actually use (Claude-first order), + prompts honestly and skips work that is already done, selects tools per workspace, and no longer + offers to scaffold a provider's folders for it. +- `sloop check` gains more AI-readiness criteria, sourced from each tool's adapter manifest. +- `sloop doctor` groups and colors its output and explains the `mode` line. + ## v0.1.0 — first release The initial public release: the local-first control layer for your AI coding CLIs. From a6da94919108df12541439858adacb33d6bfa10f Mon Sep 17 00:00:00 2001 From: Tuan Ngo Date: Sun, 28 Jun 2026 21:53:36 +0700 Subject: [PATCH 24/25] chore: Apply gofmt to test struct alignment --- internal/cli/commands/ps_test.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/internal/cli/commands/ps_test.go b/internal/cli/commands/ps_test.go index 7b6fada..8add6fc 100644 --- a/internal/cli/commands/ps_test.go +++ b/internal/cli/commands/ps_test.go @@ -32,9 +32,9 @@ func TestFleetRowsFiltersAndSplits(t *testing.T) { func TestSplitSession(t *testing.T) { m := map[string]adapter.Manifest{"claude": {}, "cursor": {}} cases := []struct { - name string - in string - manifests map[string]adapter.Manifest + name string + in string + manifests map[string]adapter.Manifest wantWs, wantTool, wInst string }{ {"legacy two-segment", "repo__claude", m, "repo", "claude", ""}, From 46daa8662fa4d80519d412258868ca77581933c2 Mon Sep 17 00:00:00 2001 From: Tuan Ngo Date: Sun, 28 Jun 2026 21:53:45 +0700 Subject: [PATCH 25/25] fix: Cancel TUI prompts on stdin read error and check marshal error --- internal/cli/commands/hooks.go | 10 ++++++++-- internal/tui/input.go | 11 +++++++++-- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/internal/cli/commands/hooks.go b/internal/cli/commands/hooks.go index b494e6f..0e6c1b8 100644 --- a/internal/cli/commands/hooks.go +++ b/internal/cli/commands/hooks.go @@ -366,12 +366,18 @@ var hooksPrintCmd = &cobra.Command{ switch m.Hooks.Install { case "settings-json": doc, _ := mergeSettingsHooks(nil, eventCommands(m.Hooks)) - out, _ := json.MarshalIndent(doc, "", " ") + out, err := json.MarshalIndent(doc, "", " ") + if err != nil { + return err + } cmd.Printf("# %s — add to %s\n%s\n", tool, m.Hooks.Config, string(out)) return nil case "cursor-json": doc, _ := mergeCursorHooks(nil, eventCommands(m.Hooks)) - out, _ := json.MarshalIndent(doc, "", " ") + out, err := json.MarshalIndent(doc, "", " ") + if err != nil { + return err + } cmd.Printf("# %s — add to %s\n%s\n", tool, m.Hooks.Config, string(out)) return nil } diff --git a/internal/tui/input.go b/internal/tui/input.go index dadd5f9..5340f3c 100644 --- a/internal/tui/input.go +++ b/internal/tui/input.go @@ -28,7 +28,11 @@ func ReadLine(prompt string) (string, bool) { var buf []byte in := make([]byte, 16) for { - n, _ := os.Stdin.Read(in) + n, err := os.Stdin.Read(in) + if err != nil { + fmt.Print("\r\n") + return "", false // stdin closed / hard error → cancel, like Esc (never spin) + } if n == 0 { continue } @@ -74,7 +78,10 @@ func Confirm(prompt string) bool { fmt.Print(prompt) in := make([]byte, 1) for { - n, _ := os.Stdin.Read(in) + n, err := os.Stdin.Read(in) + if err != nil { + return false // stdin closed / hard error → treat as "no" (never spin) + } if n == 0 { continue }