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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,12 @@ generated notes plus the signed checksums — see

### Added

- `--agent <name>` flag on `ctm new`, `ctm yolo`, `ctm yolo!`, and `ctm safe`.
Without the flag, sessions use `session.DefaultAgent`. With it, the chosen
agent (validated against `agent.Registered()`) overrides the default for
that spawn — e.g. `ctm new mytask --agent codex` to opt back into codex
when hermes is the default. Unknown agent values produce an error listing
the registered names.
- Hermes agent support: `ctm new --agent hermes` and `ctm yolo --agent hermes`
spawn a Hermes Agent (https://hermes-agent.dev) session with full resume +
session-discovery parity to codex. `internal/agent/hermes/` mirrors
Expand Down
9 changes: 6 additions & 3 deletions cmd/attach.go
Original file line number Diff line number Diff line change
Expand Up @@ -67,14 +67,16 @@ func runAttach(cmd *cobra.Command, args []string) error {
sess, err := store.Get(name)
if err != nil {
// Session doesn't exist — create new
return createAndAttach(name, ".", cfg.DefaultMode, store, tc, out)
return createAndAttach(name, ".", cfg.DefaultMode, "", store, tc, out)
}

return preflight(sess, cfg, store, tc, out)
}

// createAndAttach creates a new session and attaches to it.
func createAndAttach(name, workdir, _ string, store *session.Store, tc *tmux.Client, out *output.Printer) error {
// createAndAttach creates a new session and attaches to it. agentName, when
// non-empty, overrides session.DefaultAgent for this spawn (must already be
// validated against the registry by the caller — see resolveAgent in yolo.go).
func createAndAttach(name, workdir, _ string, agentName string, store *session.Store, tc *tmux.Client, out *output.Printer) error {
abs, err := filepath.Abs(workdir)
if err != nil {
return fmt.Errorf("resolving workdir: %w", err)
Expand All @@ -85,6 +87,7 @@ func createAndAttach(name, workdir, _ string, store *session.Store, tc *tmux.Cli
sess, err := session.Yolo(session.SpawnOpts{
Name: name,
Workdir: abs,
Agent: agentName,
Tmux: tc,
Store: store,
})
Expand Down
8 changes: 7 additions & 1 deletion cmd/new.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import (
)

func init() {
addAgentFlag(newCmd)
rootCmd.AddCommand(newCmd)
}

Expand Down Expand Up @@ -71,6 +72,11 @@ func runNew(cmd *cobra.Command, args []string) error {
return err
}

agentName, err := agentFromCmd(cmd)
if err != nil {
return err
}

// Kill existing session with the same name if present
if _, err := store.Get(name); err == nil {
out.Warn("session %q already exists — replacing", name)
Expand All @@ -80,5 +86,5 @@ func runNew(cmd *cobra.Command, args []string) error {
}
}

return createAndAttach(name, workdir, cfg.DefaultMode, store, tc, out)
return createAndAttach(name, workdir, cfg.DefaultMode, agentName, store, tc, out)
}
37 changes: 37 additions & 0 deletions cmd/yolo.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,49 @@ package cmd
import (
"fmt"
"os"
"strings"

"github.com/spf13/cobra"

"github.com/RandomCodeSpace/ctm/internal/agent"
"github.com/RandomCodeSpace/ctm/internal/output"
"github.com/RandomCodeSpace/ctm/internal/session"
"github.com/RandomCodeSpace/ctm/internal/tmux"
)

const agentFlagUsage = "Agent to spawn (codex, hermes). Empty uses the configured default."

// addAgentFlag registers the --agent flag on a command. Single
// definition shared by ctm new / yolo / yolo! / safe so the flag name,
// default, and usage description don't drift between commands.
func addAgentFlag(c *cobra.Command) {
c.Flags().String("agent", "", agentFlagUsage)
}

// agentFromCmd reads the --agent flag and validates it via resolveAgent.
// Shared by every RunE that accepts --agent so the read + validate
// pattern isn't duplicated across commands.
func agentFromCmd(cmd *cobra.Command) (string, error) {
name, _ := cmd.Flags().GetString("agent")
return resolveAgent(name)
}

// resolveAgent validates an agent name against the registry. Returns
// ("", nil) for empty input (caller falls back to session.DefaultAgent
// via spawn.go's empty-Agent handling), and an error listing
// registered agents when name is non-empty but unknown so the user
// sees what choices they have.
func resolveAgent(name string) (string, error) {
if name == "" {
return "", nil
}
if _, ok := agent.For(name); ok {
return name, nil
}
return "", fmt.Errorf("unknown agent %q; available: %s",
name, strings.Join(agent.Registered(), ", "))
}

// shouldResumeExisting reports whether a stored session should be resumed via
// preflight rather than torn down and recreated. A session is resumable iff
// its recorded mode matches the requested mode — tmux liveness is irrelevant
Expand Down
97 changes: 97 additions & 0 deletions cmd/yolo_helpers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ import (
"strings"
"testing"

"github.com/spf13/cobra"

_ "github.com/RandomCodeSpace/ctm/internal/agent/codex" // register codex for resolveAgent tests
_ "github.com/RandomCodeSpace/ctm/internal/agent/hermes" // register hermes for resolveAgent tests
"github.com/RandomCodeSpace/ctm/internal/output"
"github.com/RandomCodeSpace/ctm/internal/session"
"github.com/RandomCodeSpace/ctm/internal/tmux"
Expand Down Expand Up @@ -334,6 +338,99 @@ func TestResolveModeTargetRejectsInvalidName(t *testing.T) {
}
}

// --- resolveAgent (--agent flag validation) ----------------------------------

func TestResolveAgent_Empty(t *testing.T) {
got, err := resolveAgent("")
if err != nil {
t.Fatalf("resolveAgent(\"\") err = %v, want nil", err)
}
if got != "" {
t.Errorf("resolveAgent(\"\") = %q, want \"\" (caller falls back to DefaultAgent)", got)
}
}

func TestResolveAgent_Registered(t *testing.T) {
for _, name := range []string{"codex", "hermes"} {
got, err := resolveAgent(name)
if err != nil {
t.Errorf("resolveAgent(%q) err = %v, want nil", name, err)
}
if got != name {
t.Errorf("resolveAgent(%q) = %q, want %q", name, got, name)
}
}
}

func TestResolveAgent_Unregistered(t *testing.T) {
_, err := resolveAgent("totally-not-an-agent-xyz")
if err == nil {
t.Fatal("resolveAgent on unregistered name returned nil error")
}
msg := err.Error()
if !strings.Contains(msg, "unknown agent") {
t.Errorf("error %q should mention \"unknown agent\"", msg)
}
// Error must list available agents so the user can correct their flag.
if !strings.Contains(msg, "codex") || !strings.Contains(msg, "hermes") {
t.Errorf("error %q should list registered agents (codex, hermes)", msg)
}
}

func TestAddAgentFlag(t *testing.T) {
c := &cobra.Command{Use: "fake"}
addAgentFlag(c)
f := c.Flags().Lookup("agent")
if f == nil {
t.Fatal("addAgentFlag did not register --agent on the command")
}
if f.DefValue != "" {
t.Errorf("--agent default = %q, want \"\" (empty → DefaultAgent)", f.DefValue)
}
if !strings.Contains(f.Usage, "codex") || !strings.Contains(f.Usage, "hermes") {
t.Errorf("--agent usage %q should mention codex + hermes", f.Usage)
}
}

func TestAgentFromCmd_FlagUnset(t *testing.T) {
c := &cobra.Command{Use: "fake"}
addAgentFlag(c)
got, err := agentFromCmd(c)
if err != nil {
t.Fatalf("agentFromCmd err = %v, want nil", err)
}
if got != "" {
t.Errorf("agentFromCmd with unset flag = %q, want \"\"", got)
}
}

func TestAgentFromCmd_FlagSetValid(t *testing.T) {
c := &cobra.Command{Use: "fake"}
addAgentFlag(c)
if err := c.Flags().Set("agent", "hermes"); err != nil {
t.Fatalf("Flags.Set: %v", err)
}
got, err := agentFromCmd(c)
if err != nil {
t.Fatalf("agentFromCmd err = %v, want nil", err)
}
if got != "hermes" {
t.Errorf("agentFromCmd = %q, want hermes", got)
}
}

func TestAgentFromCmd_FlagSetInvalid(t *testing.T) {
c := &cobra.Command{Use: "fake"}
addAgentFlag(c)
if err := c.Flags().Set("agent", "not-a-real-agent"); err != nil {
t.Fatalf("Flags.Set: %v", err)
}
_, err := agentFromCmd(c)
if err == nil {
t.Fatal("agentFromCmd accepted unknown agent name; want error")
}
}

// --- helpers -----------------------------------------------------------------

type bufWriter struct{ s string }
Expand Down
24 changes: 21 additions & 3 deletions cmd/yolo_runners.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ import (
)

func init() {
for _, c := range []*cobra.Command{yoloCmd, yoloBangCmd, safeCmd} {
addAgentFlag(c)
}
rootCmd.AddCommand(yoloCmd)
rootCmd.AddCommand(yoloBangCmd)
rootCmd.AddCommand(safeCmd)
Expand Down Expand Up @@ -106,6 +109,11 @@ func runYolo(cmd *cobra.Command, args []string) error {
return err
}

agentName, err := agentFromCmd(cmd)
if err != nil {
return err
}

if cfg.GitCheckpointBeforeYolo {
out.Debug(Verbose, "git checkpoint for %s", workdir)
gitCheckpoint(workdir, out)
Expand All @@ -130,7 +138,7 @@ func runYolo(cmd *cobra.Command, args []string) error {
}

out.Debug(Verbose, "creating yolo session: %s", name)
return createAndAttach(name, workdir, "yolo", store, tc, out)
return createAndAttach(name, workdir, "yolo", agentName, store, tc, out)
}

func runYoloBang(cmd *cobra.Command, args []string) error {
Expand All @@ -149,6 +157,11 @@ func runYoloBang(cmd *cobra.Command, args []string) error {
return err
}

agentName, err := agentFromCmd(cmd)
if err != nil {
return err
}

if cfg.GitCheckpointBeforeYolo {
gitCheckpoint(workdir, out)
}
Expand All @@ -161,7 +174,7 @@ func runYoloBang(cmd *cobra.Command, args []string) error {
// a best-effort reset.
tearDownForRecreate(name, store, tc, out, false)

return createAndAttach(name, workdir, "yolo", store, tc, out)
return createAndAttach(name, workdir, "yolo", agentName, store, tc, out)
}

func runSafe(cmd *cobra.Command, args []string) error {
Expand All @@ -180,6 +193,11 @@ func runSafe(cmd *cobra.Command, args []string) error {
return err
}

agentName, err := agentFromCmd(cmd)
if err != nil {
return err
}

printBanner(out, "safe")
fireLaunchEvents(store, name, workdir, "safe")

Expand All @@ -197,7 +215,7 @@ func runSafe(cmd *cobra.Command, args []string) error {
tearDownForRecreate(name, store, tc, out, false)
}

return createAndAttach(name, workdir, "safe", store, tc, out)
return createAndAttach(name, workdir, "safe", agentName, store, tc, out)
}

// gitCheckpoint creates a git checkpoint commit in workdir before yolo mode.
Expand Down
Loading