diff --git a/CHANGELOG.md b/CHANGELOG.md index 4afbd55..969ccdd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,12 @@ generated notes plus the signed checksums — see ### Changed +- **Default agent flipped from `codex` to `hermes`.** `ctm new` / `ctm yolo` + without an explicit `--agent` flag now spawn Hermes Agent. Existing sessions + on disk keep whatever agent they were created with — only NEW sessions + default to hermes. Legacy `claude` rows continue to migrate to `codex` (not + the new default) in both `state.go` Save and NormalizeAgent, so the flip + cannot silently retarget historical conversations. - `internal/config/config.go` schema bumped to v2. Existing `config.json` files with `required_in_path: ["claude", …]` are migrated to `["codex", …]` on next `ctm` invocation. User diff --git a/README.md b/README.md index 127faaa..de276bf 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,8 @@ Download the prebuilt binary for your platform (no Go toolchain needed): curl -LO https://github.com/RandomCodeSpace/ctm/releases/latest/download/ctm-$(curl -s https://api.github.com/repos/RandomCodeSpace/ctm/releases/latest | jq -r .tag_name)-linux-amd64.tar.gz tar xzf ctm-*-linux-amd64.tar.gz && sudo mv ctm-*/ctm /usr/local/bin/ -ctm # launches tmux + codex; drop SSH, reattach anytime +ctm # launches tmux + hermes (default agent); drop SSH, reattach anytime +ctm new --agent codex # opt back into codex per-session ctm last # one-word reconnect from your phone ``` @@ -57,7 +58,7 @@ codex 0.130.0 ~/projects/ctm ● - **YOLO mode.** Auto-commits a git checkpoint before launching with `codex --sandbox danger-full-access`, so you can always roll back. - **Preflight health checks.** Env vars, PATH, workdir, tmux session, codex process — cached for 60 s to keep mobile reconnects snappy. - **Tight lifecycle coupling.** When codex exits, the tmux session dies. No stuck bash shells, no zombie tabs. -- **Multi-agent.** Codex is the default; pass `--agent hermes` to `ctm new` or `ctm yolo` to spawn [Hermes Agent](https://hermes-agent.dev) instead. New agents plug in via `internal/agent.Register` without touching call sites. +- **Multi-agent.** [Hermes Agent](https://hermes-agent.dev) is the default; pass `--agent codex` to `ctm new` or `ctm yolo` to spawn codex instead. New agents plug in via `internal/agent.Register` without touching call sites. - **Crash-safe state.** Atomic writes, flock-based locking, strict JSON decode with self-healing strip-to-.bak, `schema_version` + startup migrations on `sessions.json` / `config.json`. - **Zero non-tmux runtime deps.** Pure Go throughout. No `jq`, `pgrep`, `grep`, or `uuidgen` required. @@ -92,7 +93,7 @@ go install github.com/RandomCodeSpace/ctm@latest ### Post-install -No extra setup step is required — the first time you run any codex-launching command (`ctm`, `ctm `, `ctm new`, `ctm yolo`), ctm bootstraps `~/.config/ctm/` with sensible defaults, regenerates `tmux.conf` on every launch, and injects shell aliases into `~/.bashrc` / `~/.zshrc` if they exist. +No extra setup step is required — the first time you run any agent-launching command (`ctm`, `ctm `, `ctm new`, `ctm yolo`), ctm bootstraps `~/.config/ctm/` with sensible defaults, regenerates `tmux.conf` on every launch, and injects shell aliases into `~/.bashrc` / `~/.zshrc` if they exist. If you prefer an explicit setup step (or want the cc-session migration to run), `ctm install` still does the same work upfront. @@ -172,7 +173,7 @@ Completion is aware of subcommands, flags, and (for `ctm attach`, `ctm kill`, `c | Command | Description | |---|---| -| `ctm` | Attach to the default session (`codex`). Creates it if missing. | +| `ctm` | Attach to the default session (`hermes`). Creates it if missing. | | `ctm ` | Attach to a named session, or create it. | | `ctm cc` | Shorthand for attaching to `cc`. | | `ctm new ` | Create a new session in a specific workdir. | diff --git a/cmd/yolo_helpers_test.go b/cmd/yolo_helpers_test.go index a75e416..cb7896e 100644 --- a/cmd/yolo_helpers_test.go +++ b/cmd/yolo_helpers_test.go @@ -159,8 +159,8 @@ func TestResolveSimpleName(t *testing.T) { args []string want string }{ - {"no args → default 'codex'", nil, "codex"}, - {"empty slice → default 'codex'", []string{}, "codex"}, + {"no args → DefaultAgent", nil, session.DefaultAgent}, + {"empty slice → DefaultAgent", []string{}, session.DefaultAgent}, {"single arg → that name", []string{"my-sess"}, "my-sess"}, {"extra args ignored — first wins", []string{"first", "ignored"}, "first"}, } @@ -235,8 +235,8 @@ func TestResolveModeTargetDefaultName(t *testing.T) { if err != nil { t.Fatalf("resolveModeTarget: %v", err) } - if name != "codex" { - t.Errorf("default name = %q, want codex", name) + if name != session.DefaultAgent { + t.Errorf("default name = %q, want %q", name, session.DefaultAgent) } } diff --git a/internal/session/spawn_discovery_test.go b/internal/session/spawn_discovery_test.go index e0c69c4..6b8de3f 100644 --- a/internal/session/spawn_discovery_test.go +++ b/internal/session/spawn_discovery_test.go @@ -6,7 +6,8 @@ import ( "testing" "time" - _ "github.com/RandomCodeSpace/ctm/internal/agent/codex" // register codex + _ "github.com/RandomCodeSpace/ctm/internal/agent/codex" // register codex + _ "github.com/RandomCodeSpace/ctm/internal/agent/hermes" // register hermes (DefaultAgent) "github.com/RandomCodeSpace/ctm/internal/session" ) @@ -59,6 +60,7 @@ func TestYolo_DiscoveryStampsAgentSessionID(t *testing.T) { sess, err := session.Yolo(session.SpawnOpts{ Name: "discsess", Workdir: wd, + Agent: "codex", // this test exercises codex rollout-file discovery specifically Tmux: tmux, Store: store, OnDiscoveryComplete: func() { close(done) }, @@ -110,6 +112,7 @@ func TestYolo_DiscoveryTimeoutLeavesStoreRowEmpty(t *testing.T) { if _, err := session.Yolo(session.SpawnOpts{ Name: "timeoutsess", Workdir: wd, + Agent: "codex", // this test exercises the codex no-rollout/timeout path Tmux: tmux, Store: store, OnDiscoveryComplete: func() { close(done) }, diff --git a/internal/session/spawn_test.go b/internal/session/spawn_test.go index 0a87bf2..8bdeaf5 100644 --- a/internal/session/spawn_test.go +++ b/internal/session/spawn_test.go @@ -6,7 +6,8 @@ import ( "path/filepath" "testing" - _ "github.com/RandomCodeSpace/ctm/internal/agent/codex" // register codex via init + _ "github.com/RandomCodeSpace/ctm/internal/agent/codex" // register codex via init + _ "github.com/RandomCodeSpace/ctm/internal/agent/hermes" // register hermes (DefaultAgent) via init "github.com/RandomCodeSpace/ctm/internal/session" ) diff --git a/internal/session/state.go b/internal/session/state.go index 0d3c5a9..59c6cb2 100644 --- a/internal/session/state.go +++ b/internal/session/state.go @@ -28,7 +28,13 @@ const SchemaVersion = 3 // field set. Exposed as a constant so cmd/* code branching on the // default doesn't drift from the migration / Save / NormalizeAgent // codepaths. -const DefaultAgent = "codex" +// +// Note on the legacy "claude" remap: Save and NormalizeAgent map +// legacy "claude" values to "codex" (the v2→v3 migration target) — +// not DefaultAgent — so changing the default later doesn't silently +// move historical claude sessions onto whatever the current default +// happens to be. +const DefaultAgent = "hermes" // errFmtNotFound is the consistent shape returned by Get/Set/Delete/etc. // when a session name is unknown. Callers that distinguish "not found" @@ -158,17 +164,22 @@ type Session struct { AgentSessionID string `json:"agent_session_id,omitempty"` } -// NormalizeAgent returns DefaultAgent ("codex") when s.Agent is empty, -// else s.Agent verbatim. Cheap idempotent guard used by read paths -// that handle pre-migration in-memory values without touching disk. +// NormalizeAgent returns DefaultAgent when s.Agent is empty, else +// s.Agent verbatim. Cheap idempotent guard used by read paths that +// handle pre-migration in-memory values without touching disk. // -// Legacy "claude" values that escaped the v2→v3 migration are also -// remapped to "codex" so a stale in-memory Session never surfaces as -// an agent.For miss at the call site. +// Legacy "claude" values that escaped the v2→v3 migration are +// remapped to "codex" (the on-disk migration target) — not +// DefaultAgent — so a stale in-memory Session never surfaces as an +// agent.For miss at the call site, and changing DefaultAgent doesn't +// silently move historical claude sessions. func (s *Session) NormalizeAgent() string { - if s.Agent == "" || s.Agent == "claude" { + if s.Agent == "" { return DefaultAgent } + if s.Agent == "claude" { + return "codex" + } return s.Agent } @@ -314,12 +325,17 @@ func (s *Store) backupLocked() (string, error) { } // Save adds or updates a session. Empty sess.Agent is normalized to -// DefaultAgent ("codex"). Legacy "claude" values are also rewritten — -// the claude implementation was removed and a stray "claude" row -// would fail at spawn-time agent.For lookup. +// DefaultAgent. Legacy "claude" values are rewritten to "codex" (the +// v2→v3 migration target) — not DefaultAgent — so changing the +// default later doesn't silently retarget historical claude sessions. +// A stray "claude" row would otherwise fail at spawn-time agent.For +// lookup. func (s *Store) Save(sess *Session) error { - if sess.Agent == "" || sess.Agent == "claude" { + switch sess.Agent { + case "": sess.Agent = DefaultAgent + case "claude": + sess.Agent = "codex" } lf, err := s.lock() diff --git a/internal/session/state_migration_test.go b/internal/session/state_migration_test.go index 4319531..4954985 100644 --- a/internal/session/state_migration_test.go +++ b/internal/session/state_migration_test.go @@ -118,7 +118,7 @@ func TestMigration_V1ToV2_Idempotent(t *testing.T) { // TestSession_AgentFieldRoundTrip verifies that the Agent and // AgentSessionID fields survive a Save / Get cycle for non-default // agents. (The default agent path is covered by -// TestSession_EmptyAgentDefaultsCodexOnSave.) +// TestSession_EmptyAgentDefaultsToDefaultAgentOnSave.) func TestSession_AgentFieldRoundTrip(t *testing.T) { dir := t.TempDir() store := session.NewStore(filepath.Join(dir, "sessions.json")) @@ -145,9 +145,11 @@ func TestSession_AgentFieldRoundTrip(t *testing.T) { } } -// TestSession_EmptyAgentDefaultsCodexOnSave verifies the read-side -// guard: Save sets s.Agent = "codex" when empty. -func TestSession_EmptyAgentDefaultsCodexOnSave(t *testing.T) { +// TestSession_EmptyAgentDefaultsToDefaultAgentOnSave verifies the +// write-side guard: Save sets s.Agent = DefaultAgent when empty. +// Asserts on the constant (not a literal) so a future default flip +// doesn't silently leave this test pinned to a stale value. +func TestSession_EmptyAgentDefaultsToDefaultAgentOnSave(t *testing.T) { dir := t.TempDir() store := session.NewStore(filepath.Join(dir, "sessions.json")) in := &session.Session{ @@ -161,8 +163,9 @@ func TestSession_EmptyAgentDefaultsCodexOnSave(t *testing.T) { t.Fatalf("save: %v", err) } out, _ := store.Get("bar") - if out.Agent != "codex" { - t.Fatalf("empty Agent should default to \"codex\" on Save, got %q", out.Agent) + if out.Agent != session.DefaultAgent { + t.Fatalf("empty Agent should default to %q on Save, got %q", + session.DefaultAgent, out.Agent) } } diff --git a/internal/session/state_test.go b/internal/session/state_test.go index b3dc038..275ed1c 100644 --- a/internal/session/state_test.go +++ b/internal/session/state_test.go @@ -305,24 +305,29 @@ func TestMigrationPlan_MatchesSchemaVersion(t *testing.T) { } } -// TestNormalizeAgent_DefaultsCodex covers the read-side helper. Empty -// values default to "codex" (the post-claude-removal default); legacy -// "claude" values are also remapped to "codex" so a stale Session -// never surfaces as an agent.For miss at the call site. Other agent -// names pass through verbatim. -func TestNormalizeAgent_DefaultsCodex(t *testing.T) { +// TestNormalizeAgent covers the read-side helper. Empty values default +// to DefaultAgent ("hermes" post-default-flip); legacy "claude" values +// are remapped to "codex" (the v2→v3 on-disk migration target — NOT +// DefaultAgent, by design, so the default flip doesn't silently +// retarget historical claude sessions). Other agent names pass +// through verbatim. +func TestNormalizeAgent(t *testing.T) { s := &session.Session{} - if got := s.NormalizeAgent(); got != "codex" { - t.Fatalf("NormalizeAgent on zero-value = %q, want codex", got) + if got := s.NormalizeAgent(); got != "hermes" { + t.Fatalf("NormalizeAgent on zero-value = %q, want hermes", got) } s.Agent = "claude" if got := s.NormalizeAgent(); got != "codex" { - t.Fatalf("NormalizeAgent(claude) = %q, want codex (legacy remap)", got) + t.Fatalf("NormalizeAgent(claude) = %q, want codex (legacy remap, not DefaultAgent)", got) } s.Agent = "codex" if got := s.NormalizeAgent(); got != "codex" { t.Fatalf("NormalizeAgent(codex) = %q, want codex", got) } + s.Agent = "hermes" + if got := s.NormalizeAgent(); got != "hermes" { + t.Fatalf("NormalizeAgent(hermes) = %q, want hermes", got) + } s.Agent = "opencode" if got := s.NormalizeAgent(); got != "opencode" { t.Fatalf("NormalizeAgent(opencode) = %q, want opencode (forward-compat)", got)