Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.md
25 changes: 19 additions & 6 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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 <action> [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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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/<session-id>.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.
Expand All @@ -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/<uuid>` to track the last active mob per terminal.

The queue system is also session-scoped: queued actions live under `.codemob/queues/<uuid>.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:**
Expand Down
7 changes: 7 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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 .
Expand Down Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions cmd/info.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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)))
}
}
Expand Down
98 changes: 68 additions & 30 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@ import (
"os/signal"
"path/filepath"
"strconv"
"syscall"
"strings"
"syscall"
"text/tabwriter"
"time"

Expand Down Expand Up @@ -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
Expand All @@ -80,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:
Expand Down Expand Up @@ -121,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)

Expand Down Expand Up @@ -523,7 +518,6 @@ func cmdResume(args []string) error {
return nil
}


func cmdOpen(args []string) error {
root, cfg, err := requireInit()
if err != nil {
Expand Down Expand Up @@ -757,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 nil // no session queue available, nothing to do
}

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)
Expand Down Expand Up @@ -863,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 <mob-name>
func cmdWriteNext(args []string) error {
Expand All @@ -883,6 +896,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)
Expand Down Expand Up @@ -912,16 +932,31 @@ 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.
// After the agent exits, it checks for a next action (e.g., switch to another mob).
// On final exit, writes the last active mob name to .codemob/sessions/<session-id>
// (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 {
if err := runAgent(root, agent, workdir, resume); err != nil {
// 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, 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)
Expand All @@ -932,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 {
Expand Down Expand Up @@ -985,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
}
Expand All @@ -1005,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 {
Expand Down Expand Up @@ -1065,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
Expand All @@ -1092,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()
Expand Down
9 changes: 6 additions & 3 deletions codemob-shell.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down
11 changes: 4 additions & 7 deletions internal/mob/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -98,26 +98,23 @@ 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": {
Description: "Remove the current codemob workspace and exit",
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.
`,
},
Expand Down
Loading
Loading