From 2e4f1634477d9d211a6a2d03fdde537edfff6d18 Mon Sep 17 00:00:00 2001 From: Bartek Lipinski Date: Mon, 6 Apr 2026 09:44:23 +0200 Subject: [PATCH 1/6] Resolve @self in queue commands to avoid $CODEMOB_MOB expansion Slash commands used "$CODEMOB_MOB" which triggered Claude's simple_expansion permission prompt. Replace with @self keyword that codemob resolves internally via CurrentMobName(). Closes #29 --- cmd/root.go | 7 +++++++ internal/mob/init.go | 11 ++++------- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/cmd/root.go b/cmd/root.go index a86eb18..8cdadfd 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -883,6 +883,13 @@ func cmdWriteNext(args []string) error { target = args[1] } + if target == "@self" { + target = mob.CurrentMobName() + if target == "" { + return fmt.Errorf("@self: not inside a mob") + } + } + // Validate: switch, remove, and change-agent require a target if target == "" && (action == "switch" || action == "remove" || action == "change-agent") { return fmt.Errorf("codemob queue %s requires a target", action) diff --git a/internal/mob/init.go b/internal/mob/init.go index 5309c2e..dff155b 100644 --- a/internal/mob/init.go +++ b/internal/mob/init.go @@ -98,14 +98,13 @@ If they choose a DIFFERENT mob (not the one marked with ◀), run ` + "`codemob If they choose the CURRENT mob (marked with ◀): -` + confirmationGuardExit + ` -Run this exact command: +` + confirmationGuardExit + `Run this exact command using the Bash tool: ` + "```" + ` -codemob queue remove "$CODEMOB_MOB" +codemob queue remove @self ` + "```" + ` -$CODEMOB_MOB is already set in your environment. There is no need to echo it - the command above will resolve it automatically. +If the command fails, tell the user: "This command can only be used from within a codemob workspace." and stop. `, }, "drop": { @@ -113,11 +112,9 @@ $CODEMOB_MOB is already set in your environment. There is no need to echo it - t Body: confirmationGuardExit + `Run this exact command using the Bash tool: ` + "```" + ` -codemob queue remove "$CODEMOB_MOB" +codemob queue remove @self ` + "```" + ` -$CODEMOB_MOB is already set in your environment. There is no need to echo it - the command above will resolve it automatically. - If the command fails, tell the user: "This command can only be used from within a codemob workspace." and stop. `, }, From f0173decc58d1a70954eab65ab4c16eaaf344dd4 Mon Sep 17 00:00:00 2001 From: Bartek Lipinski Date: Mon, 6 Apr 2026 19:27:07 +0200 Subject: [PATCH 2/6] Preserve queued actions outside queue processing --- AGENTS.md | 1 + cmd/root.go | 13 ++++++------- internal/mob/integration_test.go | 29 +++++++++++++++++++++++++++++ 3 files changed, 36 insertions(+), 7 deletions(-) create mode 120000 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 120000 index 0000000..681311e --- /dev/null +++ b/AGENTS.md @@ -0,0 +1 @@ +CLAUDE.md \ No newline at end of file diff --git a/cmd/root.go b/cmd/root.go index 8cdadfd..410b1f8 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -56,13 +56,6 @@ func (p *progress) Clear() { var Version = "dev" func Execute() error { - // Clear stale queue files on every invocation (except check-queue which reads them) - if len(os.Args) < 2 || os.Args[1] != "check-queue" { - if root, err := mob.FindRepoRoot(); err == nil { - mob.ClearAllQueues(root) - } - } - if len(os.Args) < 2 { printUsage() return nil @@ -928,6 +921,12 @@ func cmdWriteNext(args []string) error { // (keyed by $CODEMOB_SESSION) so resume can default to it. func launchAgent(root, agent, workdir string, resume bool) error { for { + // Drop any leftover queue file for the mob we're about to launch so a stale + // action from an earlier session cannot immediately terminate a fresh run. + if root != "" && filepath.IsAbs(root) { + mob.ClearQueue(root, filepath.Base(workdir)) + } + if err := runAgent(root, agent, workdir, resume); err != nil { // Log non-signal errors (signal exits are normal — user pressed Ctrl+C) if _, ok := err.(*exec.ExitError); !ok { diff --git a/internal/mob/integration_test.go b/internal/mob/integration_test.go index 0dee765..682e723 100644 --- a/internal/mob/integration_test.go +++ b/internal/mob/integration_test.go @@ -879,6 +879,35 @@ func TestQueueSwitchRequiresTarget(t *testing.T) { } } +func TestInfoDoesNotClearQueuedAction(t *testing.T) { + bin := buildCore(t) + _, repoPath := setupTestRepo(t) + initRepo(t, bin, repoPath) + runCore(t, bin, repoPath, "new", "test-mob", "--no-launch") + + cfg := readConfig(t, repoPath) + mobsDir, ok := cfg["mobs_dir"].(string) + if !ok || mobsDir == "" { + t.Fatalf("expected mobs_dir in config, got %v", cfg["mobs_dir"]) + } + mobPath := filepath.Join(mobsDir, "test-mob") + queuePath := filepath.Join(repoPath, ".codemob", "queues", "test-mob.json") + + runCore(t, bin, mobPath, "queue", "remove", "@self") + if _, err := os.Stat(queuePath); err != nil { + t.Fatalf("expected queued action file to exist, got %v", err) + } + + out := runCore(t, bin, mobPath, "info") + + if _, err := os.Stat(queuePath); err != nil { + t.Fatalf("expected info to preserve queued action file, got %v", err) + } + if !strings.Contains(out, "\"action\": \"remove\"") { + t.Errorf("expected info to show queued remove action, got: %s", out) + } +} + // ─── Agent Flag ────────────────────────────────────────────────────────────── func TestAgentMissingValue(t *testing.T) { From 433e39662adcbdbb2bea327e4f90f09093524b64 Mon Sep 17 00:00:00 2001 From: Bartek Lipinski Date: Mon, 6 Apr 2026 22:46:35 +0200 Subject: [PATCH 3/6] Move queue handling to session scope --- cmd/info.go | 4 + cmd/root.go | 86 ++++++++---- codemob-shell.sh | 9 +- internal/mob/integration_test.go | 231 ++++++++++++++++++++++++++++++- internal/mob/next.go | 43 +++--- 5 files changed, 323 insertions(+), 50 deletions(-) diff --git a/cmd/info.go b/cmd/info.go index cd6ac3c..c5cab42 100644 --- a/cmd/info.go +++ b/cmd/info.go @@ -84,6 +84,7 @@ func cmdInfo() error { section("Queues") queuesPath := filepath.Join(root, mob.CodemobDir, "queues") queueEntries, err := os.ReadDir(queuesPath) + currentSession := os.Getenv("CODEMOB_SESSION") if err != nil || len(queueEntries) == 0 { kv("queued actions", "(none)") } else { @@ -96,6 +97,9 @@ func cmdInfo() error { continue } name := strings.TrimSuffix(e.Name(), ".json") + if name == currentSession { + name += " (current)" + } kv(name, strings.TrimSpace(string(data))) } } diff --git a/cmd/root.go b/cmd/root.go index 410b1f8..4bdfbf8 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -7,8 +7,8 @@ import ( "os/signal" "path/filepath" "strconv" - "syscall" "strings" + "syscall" "text/tabwriter" "time" @@ -73,7 +73,7 @@ func Execute() error { repoRoot = root } mob.CheckUpgrade(Version, repoRoot) - case "switch", "list-others", "check-queue", "queue", "inject-args", "path", + case "switch", "list-others", "check-queue", "queue", "clear-queue", "inject-args", "path", "init", "reinit", "uninstall", "version", "--version", "-v", "help", "--help", "-h": // internal/setup commands: skip upgrade check default: @@ -114,6 +114,8 @@ func Execute() error { return cmdCheckNext(args) case "queue": return cmdWriteNext(args) + case "clear-queue": + return cmdClearQueue(args) case "inject-args": return cmdInjectArgs(args) @@ -516,7 +518,6 @@ func cmdResume(args []string) error { return nil } - func cmdOpen(args []string) error { root, cfg, err := requireInit() if err != nil { @@ -750,21 +751,41 @@ func cmdCheckNext(_ []string) error { if err != nil { return nil // not in a repo, nothing to do } - - mobName := mob.CurrentMobName() - if mobName == "" { - return nil // not in a mob, nothing to do + sessionID, err := mob.QueueSessionID() + if err != nil { + return err } - next, err := mob.ReadQueuedAction(root, mobName) + next, err := mob.ReadQueuedAction(root, sessionID) if err != nil || next == nil { return nil // no queued action } - mob.ClearQueue(root, mobName) + mob.ClearQueue(root, sessionID) return executeNextAction(root, next) } +func cmdClearQueue(args []string) error { + if len(args) > 0 && strings.HasPrefix(args[0], "--") { + return fmt.Errorf("unknown flag for clear-queue: %s", args[0]) + } + if len(args) > 0 { + return fmt.Errorf("usage: codemob clear-queue") + } + + root, err := mob.FindRepoRoot() + if err != nil { + return nil + } + sessionID, err := mob.QueueSessionID() + if err != nil { + return err + } + + mob.ClearQueue(root, sessionID) + return nil +} + // resolveNextAction resolves a next action to a workdir, agent, and resume flag. func resolveNextAction(root string, next *mob.QueuedAction) (workdir, agent string, resume bool, err error) { cfg, err := mob.LoadConfig(root) @@ -856,7 +877,6 @@ func executeNextAction(root string, next *mob.QueuedAction) error { return launchAgent(root, agent, workdir, resume) } - // cmdWriteNext writes a next action for the trampoline. // Used by slash commands: codemob queue switch func cmdWriteNext(args []string) error { @@ -912,7 +932,11 @@ func cmdWriteNext(args []string) error { if currentMob == "" { return fmt.Errorf("codemob queue must be run from inside a mob") } - return mob.WriteQueuedAction(root, currentMob, q) + sessionID, err := mob.QueueSessionID() + if err != nil { + return err + } + return mob.WriteQueuedAction(root, sessionID, q) } // launchAgent spawns the agent as a child process and implements the trampoline loop. @@ -920,14 +944,19 @@ func cmdWriteNext(args []string) error { // On final exit, writes the last active mob name to .codemob/sessions/ // (keyed by $CODEMOB_SESSION) so resume can default to it. func launchAgent(root, agent, workdir string, resume bool) error { + sessionID, err := mob.QueueSessionID() + if err != nil { + sessionID = "" + } + for { - // Drop any leftover queue file for the mob we're about to launch so a stale - // action from an earlier session cannot immediately terminate a fresh run. - if root != "" && filepath.IsAbs(root) { - mob.ClearQueue(root, filepath.Base(workdir)) + // Drop any leftover queue file for the session we're about to launch so a + // stale action from an earlier run cannot immediately terminate a fresh one. + if sessionID != "" && root != "" && filepath.IsAbs(root) { + mob.ClearQueue(root, sessionID) } - if err := runAgent(root, agent, workdir, resume); err != nil { + if err := runAgent(root, sessionID, agent, workdir, resume); err != nil { // Log non-signal errors (signal exits are normal — user pressed Ctrl+C) if _, ok := err.(*exec.ExitError); !ok { fmt.Fprintf(os.Stderr, " [codemob] agent error: %v\n", err) @@ -938,13 +967,16 @@ func launchAgent(root, agent, workdir string, resume bool) error { mobStatus(fmt.Sprintf("Session ended - mob '%s'", filepath.Base(workdir))) // Always check for queued action, regardless of how the agent exited - mobName := filepath.Base(workdir) - next, err := mob.ReadQueuedAction(root, mobName) + if sessionID == "" { + writeLastMob(workdir) + return nil // normal exit with no queue session available + } + next, err := mob.ReadQueuedAction(root, sessionID) if err != nil || next == nil { writeLastMob(workdir) return nil // normal exit } - mob.ClearQueue(root, mobName) + mob.ClearQueue(root, sessionID) newWorkdir, newAgent, newResume, err := resolveNextAction(root, next) if err != nil { @@ -991,14 +1023,14 @@ func writeLastMob(workdir string) { // runAgent spawns the agent process and waits for it to exit. // If resume is true and the agent fails (e.g., no session to continue), falls back to a new session. -func runAgent(root, agent, workdir string, resume bool) error { +func runAgent(root, sessionID, agent, workdir string, resume bool) error { binPath, resumeArgs, newArgs, err := agentArgs(agent, root) if err != nil { return err } if resume { - err := spawnAgent(root, binPath, resumeArgs, workdir) + err := spawnAgent(root, sessionID, binPath, resumeArgs, workdir) if err == nil { return nil } @@ -1011,7 +1043,7 @@ func runAgent(root, agent, workdir string, resume bool) error { mobStatus("No previous session found, starting new session") } - return spawnAgent(root, binPath, newArgs, workdir) + return spawnAgent(root, sessionID, binPath, newArgs, workdir) } func cmdInjectArgs(args []string) error { @@ -1071,7 +1103,7 @@ func agentArgs(agent, repoRoot string) (binPath string, resumeArgs, newArgs []st return } -func spawnAgent(root, binPath string, args []string, workdir string) error { +func spawnAgent(root, sessionID, binPath string, args []string, workdir string) error { cmd := exec.Command(binPath, args...) cmd.Dir = workdir cmd.Stdin = os.Stdin @@ -1098,10 +1130,10 @@ func spawnAgent(root, binPath string, args []string, workdir string) error { } }() - // Watch for per-mob queue file - auto-terminate agent when a queued action appears - if root != "" && filepath.IsAbs(root) { - mobName := filepath.Base(workdir) - queuePath := mob.QueueFilePath(root, mobName) + // Watch for the current session's queue file - auto-terminate the agent when a + // queued action appears. + if sessionID != "" && root != "" && filepath.IsAbs(root) { + queuePath := mob.QueueFilePath(root, sessionID) go func() { ticker := time.NewTicker(500 * time.Millisecond) defer ticker.Stop() diff --git a/codemob-shell.sh b/codemob-shell.sh index 5b938b8..1eb9c2e 100644 --- a/codemob-shell.sh +++ b/codemob-shell.sh @@ -17,7 +17,8 @@ codemob() { echo "Already here." return 0 fi - cd "$dir" + command codemob clear-queue 2>/dev/null + cd "$dir" || return $? ;; *) command codemob "$@" ;; esac @@ -42,9 +43,10 @@ claude() { *) extra_args+=("$line") ;; esac done < <(command codemob inject-args claude 2>/dev/null) + [ -n "$codemob_mob" ] && command codemob clear-queue 2>/dev/null CODEMOB_MOB="$codemob_mob" command claude "${extra_args[@]}" "$@" local ec=$? - CODEMOB_MOB="$codemob_mob" codemob check-queue 2>/dev/null + codemob check-queue 2>/dev/null return $ec ;; esac @@ -65,9 +67,10 @@ codex() { *) extra_args+=("$line") ;; esac done < <(command codemob inject-args codex 2>/dev/null) + [ -n "$codemob_mob" ] && command codemob clear-queue 2>/dev/null CODEMOB_MOB="$codemob_mob" command codex "${extra_args[@]}" "$@" local ec=$? - CODEMOB_MOB="$codemob_mob" codemob check-queue 2>/dev/null + codemob check-queue 2>/dev/null return $ec ;; esac diff --git a/internal/mob/integration_test.go b/internal/mob/integration_test.go index 682e723..86db031 100644 --- a/internal/mob/integration_test.go +++ b/internal/mob/integration_test.go @@ -2,6 +2,7 @@ package mob_test import ( "encoding/json" + "fmt" "os" "os/exec" "path/filepath" @@ -172,6 +173,20 @@ func runCoreExpectError(t *testing.T, bin, dir string, args ...string) string { return string(out) } +// runShell executes a bash command in the given directory with the built codemob +// binary prepended to PATH so the sourced shell wrapper can call `command codemob`. +func runShell(t *testing.T, bin, dir, script string) string { + t.Helper() + cmd := exec.Command("bash", "-lc", script) + cmd.Dir = dir + cmd.Env = append(os.Environ(), "PATH="+filepath.Dir(bin)+":"+os.Getenv("PATH")) + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("shell command failed: %s\n%s", err, out) + } + return string(out) +} + // patchConfig reads the config, applies a mutation, and writes it back. func patchConfig(t *testing.T, repoPath string, mutate func(map[string]interface{})) { t.Helper() @@ -195,6 +210,9 @@ func readConfig(t *testing.T, repoPath string) map[string]interface{} { return cfg } +func queuePath(repoPath, sessionID string) string { + return filepath.Join(repoPath, ".codemob", "queues", sessionID+".json") +} // ─── Tests ──────────────────────────────────────────────────────────────────── @@ -879,11 +897,43 @@ func TestQueueSwitchRequiresTarget(t *testing.T) { } } +func TestQueueRequiresSession(t *testing.T) { + bin := buildCore(t) + _, repoPath := setupTestRepo(t) + initRepo(t, bin, repoPath) + runCore(t, bin, repoPath, "new", "test-mob", "--no-launch") + + cfg := readConfig(t, repoPath) + mobPath := filepath.Join(cfg["mobs_dir"].(string), "test-mob") + + out := runCoreExpectError(t, bin, mobPath, "queue", "remove", "@self") + if !strings.Contains(out, "CODEMOB_SESSION") { + t.Errorf("expected missing session error, got: %s", out) + } +} + +func TestClearQueueRequiresSession(t *testing.T) { + bin := buildCore(t) + _, repoPath := setupTestRepo(t) + initRepo(t, bin, repoPath) + runCore(t, bin, repoPath, "new", "test-mob", "--no-launch") + + cfg := readConfig(t, repoPath) + mobPath := filepath.Join(cfg["mobs_dir"].(string), "test-mob") + + out := runCoreExpectError(t, bin, mobPath, "clear-queue") + if !strings.Contains(out, "CODEMOB_SESSION") { + t.Errorf("expected missing session error, got: %s", out) + } +} + func TestInfoDoesNotClearQueuedAction(t *testing.T) { bin := buildCore(t) _, repoPath := setupTestRepo(t) initRepo(t, bin, repoPath) runCore(t, bin, repoPath, "new", "test-mob", "--no-launch") + sessionID := "queue-info" + t.Setenv("CODEMOB_SESSION", sessionID) cfg := readConfig(t, repoPath) mobsDir, ok := cfg["mobs_dir"].(string) @@ -891,7 +941,7 @@ func TestInfoDoesNotClearQueuedAction(t *testing.T) { t.Fatalf("expected mobs_dir in config, got %v", cfg["mobs_dir"]) } mobPath := filepath.Join(mobsDir, "test-mob") - queuePath := filepath.Join(repoPath, ".codemob", "queues", "test-mob.json") + queuePath := queuePath(repoPath, sessionID) runCore(t, bin, mobPath, "queue", "remove", "@self") if _, err := os.Stat(queuePath); err != nil { @@ -908,6 +958,185 @@ func TestInfoDoesNotClearQueuedAction(t *testing.T) { } } +func TestClearQueueRemovesQueuedAction(t *testing.T) { + bin := buildCore(t) + _, repoPath := setupTestRepo(t) + initRepo(t, bin, repoPath) + runCore(t, bin, repoPath, "new", "test-mob", "--no-launch") + sessionID := "queue-clear" + t.Setenv("CODEMOB_SESSION", sessionID) + + cfg := readConfig(t, repoPath) + mobsDir, ok := cfg["mobs_dir"].(string) + if !ok || mobsDir == "" { + t.Fatalf("expected mobs_dir in config, got %v", cfg["mobs_dir"]) + } + mobPath := filepath.Join(mobsDir, "test-mob") + queuePath := queuePath(repoPath, sessionID) + + runCore(t, bin, mobPath, "queue", "remove", "@self") + if _, err := os.Stat(queuePath); err != nil { + t.Fatalf("expected queued action file to exist, got %v", err) + } + + runCore(t, bin, mobPath, "clear-queue") + + if _, err := os.Stat(queuePath); !os.IsNotExist(err) { + t.Fatalf("expected clear-queue to remove queued action, got %v", err) + } +} + +func TestShellCdClearsQueuedActionForTargetMob(t *testing.T) { + bin := buildCore(t) + _, repoPath := setupTestRepo(t) + initRepo(t, bin, repoPath) + runCore(t, bin, repoPath, "new", "test-mob", "--no-launch") + sessionID := "shell-cd" + t.Setenv("CODEMOB_SESSION", sessionID) + + cfg := readConfig(t, repoPath) + mobsDir, ok := cfg["mobs_dir"].(string) + if !ok || mobsDir == "" { + t.Fatalf("expected mobs_dir in config, got %v", cfg["mobs_dir"]) + } + mobPath := filepath.Join(mobsDir, "test-mob") + queuePath := queuePath(repoPath, sessionID) + + runCore(t, bin, mobPath, "queue", "remove", "@self") + if _, err := os.Stat(queuePath); err != nil { + t.Fatalf("expected queued action file to exist, got %v", err) + } + + shellScript := fmt.Sprintf( + "source %q; cd %q; codemob cd test-mob; pwd", + filepath.Join(repoRoot(t), "codemob-shell.sh"), + repoPath, + ) + out := runShell(t, bin, repoPath, shellScript) + + if got := strings.TrimSpace(out); got != mobPath { + t.Fatalf("expected shell to cd into %s, got %s", mobPath, got) + } + if _, err := os.Stat(queuePath); !os.IsNotExist(err) { + t.Fatalf("expected codemob cd to clear queued action, got %v", err) + } +} + +func TestShellCdRootClearsQueuedActionForCurrentSession(t *testing.T) { + bin := buildCore(t) + _, repoPath := setupTestRepo(t) + initRepo(t, bin, repoPath) + runCore(t, bin, repoPath, "new", "test-mob", "--no-launch") + sessionID := "shell-cd-root" + t.Setenv("CODEMOB_SESSION", sessionID) + + cfg := readConfig(t, repoPath) + mobsDir, ok := cfg["mobs_dir"].(string) + if !ok || mobsDir == "" { + t.Fatalf("expected mobs_dir in config, got %v", cfg["mobs_dir"]) + } + mobPath := filepath.Join(mobsDir, "test-mob") + queuePath := queuePath(repoPath, sessionID) + + runCore(t, bin, mobPath, "queue", "remove", "@self") + if _, err := os.Stat(queuePath); err != nil { + t.Fatalf("expected queued action file to exist, got %v", err) + } + + shellScript := fmt.Sprintf( + "source %q; cd %q; codemob cd root; pwd", + filepath.Join(repoRoot(t), "codemob-shell.sh"), + mobPath, + ) + out := runShell(t, bin, repoPath, shellScript) + + got := strings.TrimSpace(out) + resolvedGot, err := filepath.EvalSymlinks(got) + if err != nil { + t.Fatalf("failed to resolve shell cwd %s: %v", got, err) + } + resolvedRepoPath, err := filepath.EvalSymlinks(repoPath) + if err != nil { + t.Fatalf("failed to resolve repo path %s: %v", repoPath, err) + } + if resolvedGot != resolvedRepoPath { + t.Fatalf("expected shell to cd into %s, got %s", repoPath, got) + } + if _, err := os.Stat(queuePath); !os.IsNotExist(err) { + t.Fatalf("expected codemob cd root to clear queued action, got %v", err) + } +} + +func TestShellClaudeClearsQueuedActionForCurrentMob(t *testing.T) { + bin := buildCore(t) + _, repoPath := setupTestRepo(t) + initRepo(t, bin, repoPath) + runCore(t, bin, repoPath, "new", "test-mob", "--no-launch") + sessionID := "shell-claude" + t.Setenv("CODEMOB_SESSION", sessionID) + + cfg := readConfig(t, repoPath) + mobsDir, ok := cfg["mobs_dir"].(string) + if !ok || mobsDir == "" { + t.Fatalf("expected mobs_dir in config, got %v", cfg["mobs_dir"]) + } + mobPath := filepath.Join(mobsDir, "test-mob") + queuePath := queuePath(repoPath, sessionID) + + runCore(t, bin, mobPath, "queue", "remove", "@self") + if _, err := os.Stat(queuePath); err != nil { + t.Fatalf("expected queued action file to exist, got %v", err) + } + + shellScript := fmt.Sprintf( + "source %q; cd %q; claude >/dev/null", + filepath.Join(repoRoot(t), "codemob-shell.sh"), + mobPath, + ) + runShell(t, bin, repoPath, shellScript) + + if _, err := os.Stat(queuePath); !os.IsNotExist(err) { + t.Fatalf("expected shell claude launch to clear queued action, got %v", err) + } +} + +func TestQueueIsolationBySession(t *testing.T) { + bin := buildCore(t) + _, repoPath := setupTestRepo(t) + initRepo(t, bin, repoPath) + runCore(t, bin, repoPath, "new", "test-mob", "--no-launch") + + cfg := readConfig(t, repoPath) + mobPath := filepath.Join(cfg["mobs_dir"].(string), "test-mob") + sessionA := "session-a" + sessionB := "session-b" + + if out, err := runCoreWithSession(t, bin, mobPath, sessionA, "", "queue", "remove", "@self"); err != nil { + t.Fatalf("queue session-a failed: %v\n%s", err, out) + } + if out, err := runCoreWithSession(t, bin, mobPath, sessionB, "", "queue", "remove", "@self"); err != nil { + t.Fatalf("queue session-b failed: %v\n%s", err, out) + } + + if _, err := os.Stat(queuePath(repoPath, sessionA)); err != nil { + t.Fatalf("expected session-a queue file, got %v", err) + } + if _, err := os.Stat(queuePath(repoPath, sessionB)); err != nil { + t.Fatalf("expected session-b queue file, got %v", err) + } + + if out, err := runCoreWithSession(t, bin, mobPath, sessionA, "", "clear-queue"); err != nil { + t.Fatalf("clear-queue session-a failed: %v\n%s", err, out) + } + + if _, err := os.Stat(queuePath(repoPath, sessionA)); !os.IsNotExist(err) { + t.Fatalf("expected session-a queue file to be removed, got %v", err) + } + if _, err := os.Stat(queuePath(repoPath, sessionB)); err != nil { + t.Fatalf("expected session-b queue file to remain, got %v", err) + } +} + // ─── Agent Flag ────────────────────────────────────────────────────────────── func TestAgentMissingValue(t *testing.T) { diff --git a/internal/mob/next.go b/internal/mob/next.go index cb5ff81..5e3ff28 100644 --- a/internal/mob/next.go +++ b/internal/mob/next.go @@ -5,6 +5,7 @@ import ( "fmt" "os" "path/filepath" + "strings" ) var ValidQueueActions = map[string]bool{ @@ -16,21 +17,30 @@ var ValidQueueActions = map[string]bool{ const queuesDir = ".codemob/queues" +// QueueSessionID returns the current codemob session id from the environment. +func QueueSessionID() (string, error) { + sessionID := strings.TrimSpace(os.Getenv("CODEMOB_SESSION")) + if sessionID == "" { + return "", fmt.Errorf("queue commands require CODEMOB_SESSION") + } + return sessionID, nil +} + // QueuedAction represents a pending action to execute after an agent exits. type QueuedAction struct { - Action string `json:"action"` // "switch", "new", "remove", "change-agent" - Target string `json:"target"` // mob name, agent name, etc. - Mob string `json:"mob,omitempty"` // current mob name (for change-agent) - Agent string `json:"agent,omitempty"` // agent to use (for new) + Action string `json:"action"` // "switch", "new", "remove", "change-agent" + Target string `json:"target"` // mob name, agent name, etc. + Mob string `json:"mob,omitempty"` // current mob name (for change-agent) + Agent string `json:"agent,omitempty"` // agent to use (for new) } // WriteQueuedAction writes an action for the trampoline to pick up. -func WriteQueuedAction(repoRoot, mobName string, action QueuedAction) error { +func WriteQueuedAction(repoRoot, sessionID string, action QueuedAction) error { data, err := json.MarshalIndent(action, "", " ") if err != nil { return err } - path := QueueFilePath(repoRoot, mobName) + path := QueueFilePath(repoRoot, sessionID) if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { return err } @@ -38,8 +48,8 @@ func WriteQueuedAction(repoRoot, mobName string, action QueuedAction) error { } // ReadQueuedAction reads and returns the pending action, if any. -func ReadQueuedAction(repoRoot, mobName string) (*QueuedAction, error) { - data, err := os.ReadFile(QueueFilePath(repoRoot, mobName)) +func ReadQueuedAction(repoRoot, sessionID string) (*QueuedAction, error) { + data, err := os.ReadFile(QueueFilePath(repoRoot, sessionID)) if err != nil { if os.IsNotExist(err) { return nil, nil // no file = no action @@ -56,17 +66,12 @@ func ReadQueuedAction(repoRoot, mobName string) (*QueuedAction, error) { return &action, nil } -// QueueFilePath returns the absolute path to the queue file for a given mob. -func QueueFilePath(repoRoot, mobName string) string { - return filepath.Join(repoRoot, queuesDir, mobName+".json") -} - -// ClearQueue removes the queued action file for a given mob. -func ClearQueue(repoRoot, mobName string) { - os.Remove(QueueFilePath(repoRoot, mobName)) +// QueueFilePath returns the absolute path to the queue file for a given session. +func QueueFilePath(repoRoot, sessionID string) string { + return filepath.Join(repoRoot, queuesDir, sessionID+".json") } -// ClearAllQueues removes all queued action files. -func ClearAllQueues(repoRoot string) { - os.RemoveAll(filepath.Join(repoRoot, queuesDir)) +// ClearQueue removes the queued action file for a given session. +func ClearQueue(repoRoot, sessionID string) { + os.Remove(QueueFilePath(repoRoot, sessionID)) } From 671d228d66b7ace8bc812c5ae6ba53eefdfd7b4a Mon Sep 17 00:00:00 2001 From: Bartek Lipinski Date: Tue, 7 Apr 2026 11:04:41 +0200 Subject: [PATCH 4/6] Tighten session queue docs and test coverage --- CLAUDE.md | 25 +++++++++++++++++++------ Makefile | 7 +++++++ cmd/root.go | 2 +- internal/mob/integration_test.go | 15 +++++++++++++++ 4 files changed, 42 insertions(+), 7 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 31d6947..267d373 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -20,8 +20,8 @@ Key ideas: ## Architecture Two layers: -- **`codemob`** (Go binary) — all logic: config management, git operations, reconciliation, JSON. Launches agents as child processes (`exec.Command` with `cmd.Dir`), implements a trampoline loop that checks `queue.json` after agent exit for seamless switching. -- **`codemob-shell.sh`** (bash) — sourced into shell via `.zshrc`. Defines `mob` alias and `claude`/`codex` wrappers that intercept `--*-mob`/`--*-codemob` flags. Also checks `queue.json` after agent exit for the shell-launched path. Preserves agent exit codes. +- **`codemob`** (Go binary) — all logic: config management, git operations, reconciliation, JSON. Launches agents as child processes (`exec.Command` with `cmd.Dir`), implements a trampoline loop that checks the current session queue after agent exit for seamless switching. +- **`codemob-shell.sh`** (bash) — sourced into shell via `.zshrc`. Defines `mob` alias and `claude`/`codex` wrappers that intercept `--*-mob`/`--*-codemob` flags. Also checks the current session queue after agent exit for the shell-launched path. Preserves agent exit codes. ## CLI interface @@ -42,6 +42,13 @@ codemob info # show diagnostic information codemob uninstall # remove all codemob setup (global + local) ``` +Internal commands used by shell/slash-command flows: +```bash +codemob queue [target] # write the current session's queued action +codemob check-queue # consume the current session's queued action +codemob clear-queue # clear the current session's queued action +``` + Options: ``` --no-launch # skip launching the agent @@ -81,7 +88,7 @@ internal/ git/git.go # git command wrappers mob/mob.go # data model, config, reconciliation, name validation mob/init.go # init/uninstall, slash commands, Codex prompts, Claude permissions - mob/next.go # queue.json read/write/clear + mob/next.go # session queue read/write/clear mob/names.go # random name generation (adjective-fruit) mob/integration_test.go Makefile # build/install/test @@ -111,15 +118,19 @@ KNOWN_ISSUES.md # tracked issues not yet fixed **Config stores explicit absolute paths.** Both `repo_root` and `mobs_dir` are always set to absolute paths during init. If reality diverges (repo moved, mobs dir deleted), codemob fails with a hard error telling the user to reinit. This is intentional - we accept that repo moves require reinit rather than adding dynamic resolution or fallback logic. -`.codemob/queue.json` (transient, written by slash commands): +`.codemob/queues/.json` (transient, written by slash commands / internal queue commands; one file per `CODEMOB_SESSION`): ```json { "action": "switch", - "target": "other-mob", - "mob": "" + "target": "other-mob" } ``` +`target`, `mob`, and `agent` are action-dependent fields: +- `target` is used by `switch`, `remove`, and `change-agent` +- `mob` is used by `change-agent` to record the current mob +- `agent` is used by `new` so queued creation preserves the current agent + ## Design philosophy codemob is early-stage. Optimize for the common user, not power users. Consider what power users want/need, but don't add complexity to accommodate edge cases they create (e.g., hand-editing config files). Keep the product simple and predictable - complexity is the enemy at this stage. @@ -146,6 +157,8 @@ The Go binary is the primary interface — it handles everything including agent `codemob-shell.sh` sets `$CODEMOB_SESSION` (a UUID) once per terminal window at shell startup. The Go binary uses this as a file key under `.codemob/sessions/` to track the last active mob per terminal. +The queue system is also session-scoped: queued actions live under `.codemob/queues/.json`, keyed by the same `CODEMOB_SESSION`. This is why queue features intentionally fail if `CODEMOB_SESSION` is missing instead of inventing a fallback. + This enables `codemob resume` (no name) to default to the last-used mob in that terminal — even with parallel sessions in different terminals. **How it works:** diff --git a/Makefile b/Makefile index d617bf7..b6927ce 100644 --- a/Makefile +++ b/Makefile @@ -7,6 +7,8 @@ SHAREDIR := $(PREFIX)/share/codemob .PHONY: build install uninstall test clean release-dry-run +SESSION_QUEUE_TESTS := TestQueueUnknownAction|TestQueueSwitchRequiresTarget|TestQueueRequiresSession|TestClearQueueRequiresSession|TestInfoDoesNotClearQueuedAction|TestClearQueueRemovesQueuedAction|TestShellCdClearsQueuedActionForTargetMob|TestShellCdRootClearsQueuedActionForCurrentSession|TestShellClaudeClearsQueuedActionForCurrentMob|TestQueueIsolationBySession + build: @echo "Building codemob $(VERSION)..." @go build $(LDFLAGS) -o codemob . @@ -34,6 +36,11 @@ uninstall: test: @go test ./... -count=1 -v +test-session-queue: + @go test ./internal/mob -run '$(SESSION_QUEUE_TESTS)' -count=1 -v + +test-branch: test-session-queue + clean: @rm -f codemob @rm -rf dist diff --git a/cmd/root.go b/cmd/root.go index 4bdfbf8..501da40 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -753,7 +753,7 @@ func cmdCheckNext(_ []string) error { } sessionID, err := mob.QueueSessionID() if err != nil { - return err + return nil // no session queue available, nothing to do } next, err := mob.ReadQueuedAction(root, sessionID) diff --git a/internal/mob/integration_test.go b/internal/mob/integration_test.go index 86db031..486b4b3 100644 --- a/internal/mob/integration_test.go +++ b/internal/mob/integration_test.go @@ -927,6 +927,21 @@ func TestClearQueueRequiresSession(t *testing.T) { } } +func TestCheckQueueWithoutSessionIsNoOp(t *testing.T) { + bin := buildCore(t) + _, repoPath := setupTestRepo(t) + initRepo(t, bin, repoPath) + runCore(t, bin, repoPath, "new", "test-mob", "--no-launch") + + cfg := readConfig(t, repoPath) + mobPath := filepath.Join(cfg["mobs_dir"].(string), "test-mob") + + out := runCore(t, bin, mobPath, "check-queue") + if strings.TrimSpace(out) != "" { + t.Errorf("expected check-queue with no session to be silent, got: %s", out) + } +} + func TestInfoDoesNotClearQueuedAction(t *testing.T) { bin := buildCore(t) _, repoPath := setupTestRepo(t) From 9315c78c2edca403812d30a2edaeb9bfdc15bb5b Mon Sep 17 00:00:00 2001 From: Bartek Lipinski Date: Tue, 7 Apr 2026 11:18:00 +0200 Subject: [PATCH 5/6] Fix shell wrapper tests in CI --- internal/mob/integration_test.go | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/internal/mob/integration_test.go b/internal/mob/integration_test.go index 486b4b3..201c7a6 100644 --- a/internal/mob/integration_test.go +++ b/internal/mob/integration_test.go @@ -6,6 +6,7 @@ import ( "os" "os/exec" "path/filepath" + "runtime" "strings" "testing" ) @@ -26,12 +27,12 @@ func buildCore(t *testing.T) string { // repoRoot returns the root of the codemob source repo. func repoRoot(t *testing.T) string { t.Helper() - // We're in internal/mob/, go up two levels - wd, err := os.Getwd() - if err != nil { - t.Fatal(err) + _, file, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("failed to determine test file path") } - return filepath.Join(wd, "..", "..") + // integration_test.go lives in internal/mob/ + return filepath.Clean(filepath.Join(filepath.Dir(file), "..", "..")) } // setupTestRepo creates a temp HOME and a git repo inside it, returns (home, repoPath). From c8442c6cc4525c446f68b3c0b9cf41bfdbfdb8d6 Mon Sep 17 00:00:00 2001 From: Bartek Lipinski Date: Tue, 7 Apr 2026 11:22:06 +0200 Subject: [PATCH 6/6] Isolate shell wrapper tests from login rc files --- internal/mob/integration_test.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/internal/mob/integration_test.go b/internal/mob/integration_test.go index 201c7a6..6ac0f7b 100644 --- a/internal/mob/integration_test.go +++ b/internal/mob/integration_test.go @@ -176,9 +176,11 @@ func runCoreExpectError(t *testing.T, bin, dir string, args ...string) string { // runShell executes a bash command in the given directory with the built codemob // binary prepended to PATH so the sourced shell wrapper can call `command codemob`. +// Use a non-login shell so test HOME rc files written by `init` do not interfere +// with the script under test. func runShell(t *testing.T, bin, dir, script string) string { t.Helper() - cmd := exec.Command("bash", "-lc", script) + cmd := exec.Command("bash", "-c", script) cmd.Dir = dir cmd.Env = append(os.Environ(), "PATH="+filepath.Dir(bin)+":"+os.Getenv("PATH")) out, err := cmd.CombinedOutput()