From 39bb7d6622b694c26172a47f854e9b399b9da03e Mon Sep 17 00:00:00 2001 From: aksops Date: Fri, 15 May 2026 17:14:35 +0000 Subject: [PATCH] feat(session): flip DefaultAgent from codex to hermes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ctm new / ctm yolo without --agent now spawn Hermes Agent. The default-name fallback in cmd/yolo.go's resolveSimpleName also flips, so 'ctm yolo' with no args creates a session named 'hermes'. Option 1 of the default-flip design: legacy claude rows continue to migrate to 'codex' (the v2->v3 target) — not DefaultAgent — in both Store.Save and Session.NormalizeAgent, so this flip cannot silently retarget historical conversations. Tests pinned to the literal 'codex' have been rewritten against session.DefaultAgent where they were testing the default path, or pinned to Agent: "codex" where they were testing codex-specific discovery. README + CHANGELOG updated. --- CHANGELOG.md | 6 ++++ README.md | 9 +++--- cmd/yolo_helpers_test.go | 8 ++--- internal/session/spawn_discovery_test.go | 5 ++- internal/session/spawn_test.go | 3 +- internal/session/state.go | 40 +++++++++++++++++------- internal/session/state_migration_test.go | 15 +++++---- internal/session/state_test.go | 23 ++++++++------ 8 files changed, 72 insertions(+), 37 deletions(-) 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)