Skip to content

Commit 8949d4c

Browse files
authored
feat(session): flip DefaultAgent from codex to hermes (#26)
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.
1 parent e08ac6f commit 8949d4c

8 files changed

Lines changed: 72 additions & 37 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,12 @@ generated notes plus the signed checksums — see
3838

3939
### Changed
4040

41+
- **Default agent flipped from `codex` to `hermes`.** `ctm new` / `ctm yolo`
42+
without an explicit `--agent` flag now spawn Hermes Agent. Existing sessions
43+
on disk keep whatever agent they were created with — only NEW sessions
44+
default to hermes. Legacy `claude` rows continue to migrate to `codex` (not
45+
the new default) in both `state.go` Save and NormalizeAgent, so the flip
46+
cannot silently retarget historical conversations.
4147
- `internal/config/config.go` schema bumped to v2. Existing
4248
`config.json` files with `required_in_path: ["claude", …]` are
4349
migrated to `["codex", …]` on next `ctm` invocation. User

README.md

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,8 @@ Download the prebuilt binary for your platform (no Go toolchain needed):
2727
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
2828
tar xzf ctm-*-linux-amd64.tar.gz && sudo mv ctm-*/ctm /usr/local/bin/
2929

30-
ctm # launches tmux + codex; drop SSH, reattach anytime
30+
ctm # launches tmux + hermes (default agent); drop SSH, reattach anytime
31+
ctm new --agent codex # opt back into codex per-session
3132
ctm last # one-word reconnect from your phone
3233
```
3334

@@ -57,7 +58,7 @@ codex 0.130.0 ~/projects/ctm ●
5758
- **YOLO mode.** Auto-commits a git checkpoint before launching with `codex --sandbox danger-full-access`, so you can always roll back.
5859
- **Preflight health checks.** Env vars, PATH, workdir, tmux session, codex process — cached for 60 s to keep mobile reconnects snappy.
5960
- **Tight lifecycle coupling.** When codex exits, the tmux session dies. No stuck bash shells, no zombie tabs.
60-
- **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.
61+
- **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.
6162
- **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`.
6263
- **Zero non-tmux runtime deps.** Pure Go throughout. No `jq`, `pgrep`, `grep`, or `uuidgen` required.
6364

@@ -92,7 +93,7 @@ go install github.com/RandomCodeSpace/ctm@latest
9293

9394
### Post-install
9495

95-
No extra setup step is required — the first time you run any codex-launching command (`ctm`, `ctm <name>`, `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.
96+
No extra setup step is required — the first time you run any agent-launching command (`ctm`, `ctm <name>`, `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.
9697

9798
If you prefer an explicit setup step (or want the cc-session migration to run), `ctm install` still does the same work upfront.
9899

@@ -172,7 +173,7 @@ Completion is aware of subcommands, flags, and (for `ctm attach`, `ctm kill`, `c
172173

173174
| Command | Description |
174175
|---|---|
175-
| `ctm` | Attach to the default session (`codex`). Creates it if missing. |
176+
| `ctm` | Attach to the default session (`hermes`). Creates it if missing. |
176177
| `ctm <name>` | Attach to a named session, or create it. |
177178
| `ctm cc` | Shorthand for attaching to `cc`. |
178179
| `ctm new <name>` | Create a new session in a specific workdir. |

cmd/yolo_helpers_test.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -159,8 +159,8 @@ func TestResolveSimpleName(t *testing.T) {
159159
args []string
160160
want string
161161
}{
162-
{"no args → default 'codex'", nil, "codex"},
163-
{"empty slice → default 'codex'", []string{}, "codex"},
162+
{"no args → DefaultAgent", nil, session.DefaultAgent},
163+
{"empty slice → DefaultAgent", []string{}, session.DefaultAgent},
164164
{"single arg → that name", []string{"my-sess"}, "my-sess"},
165165
{"extra args ignored — first wins", []string{"first", "ignored"}, "first"},
166166
}
@@ -235,8 +235,8 @@ func TestResolveModeTargetDefaultName(t *testing.T) {
235235
if err != nil {
236236
t.Fatalf("resolveModeTarget: %v", err)
237237
}
238-
if name != "codex" {
239-
t.Errorf("default name = %q, want codex", name)
238+
if name != session.DefaultAgent {
239+
t.Errorf("default name = %q, want %q", name, session.DefaultAgent)
240240
}
241241
}
242242

internal/session/spawn_discovery_test.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@ import (
66
"testing"
77
"time"
88

9-
_ "github.com/RandomCodeSpace/ctm/internal/agent/codex" // register codex
9+
_ "github.com/RandomCodeSpace/ctm/internal/agent/codex" // register codex
10+
_ "github.com/RandomCodeSpace/ctm/internal/agent/hermes" // register hermes (DefaultAgent)
1011
"github.com/RandomCodeSpace/ctm/internal/session"
1112
)
1213

@@ -59,6 +60,7 @@ func TestYolo_DiscoveryStampsAgentSessionID(t *testing.T) {
5960
sess, err := session.Yolo(session.SpawnOpts{
6061
Name: "discsess",
6162
Workdir: wd,
63+
Agent: "codex", // this test exercises codex rollout-file discovery specifically
6264
Tmux: tmux,
6365
Store: store,
6466
OnDiscoveryComplete: func() { close(done) },
@@ -110,6 +112,7 @@ func TestYolo_DiscoveryTimeoutLeavesStoreRowEmpty(t *testing.T) {
110112
if _, err := session.Yolo(session.SpawnOpts{
111113
Name: "timeoutsess",
112114
Workdir: wd,
115+
Agent: "codex", // this test exercises the codex no-rollout/timeout path
113116
Tmux: tmux,
114117
Store: store,
115118
OnDiscoveryComplete: func() { close(done) },

internal/session/spawn_test.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@ import (
66
"path/filepath"
77
"testing"
88

9-
_ "github.com/RandomCodeSpace/ctm/internal/agent/codex" // register codex via init
9+
_ "github.com/RandomCodeSpace/ctm/internal/agent/codex" // register codex via init
10+
_ "github.com/RandomCodeSpace/ctm/internal/agent/hermes" // register hermes (DefaultAgent) via init
1011
"github.com/RandomCodeSpace/ctm/internal/session"
1112
)
1213

internal/session/state.go

Lines changed: 28 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,13 @@ const SchemaVersion = 3
2828
// field set. Exposed as a constant so cmd/* code branching on the
2929
// default doesn't drift from the migration / Save / NormalizeAgent
3030
// codepaths.
31-
const DefaultAgent = "codex"
31+
//
32+
// Note on the legacy "claude" remap: Save and NormalizeAgent map
33+
// legacy "claude" values to "codex" (the v2→v3 migration target) —
34+
// not DefaultAgent — so changing the default later doesn't silently
35+
// move historical claude sessions onto whatever the current default
36+
// happens to be.
37+
const DefaultAgent = "hermes"
3238

3339
// errFmtNotFound is the consistent shape returned by Get/Set/Delete/etc.
3440
// when a session name is unknown. Callers that distinguish "not found"
@@ -158,17 +164,22 @@ type Session struct {
158164
AgentSessionID string `json:"agent_session_id,omitempty"`
159165
}
160166

161-
// NormalizeAgent returns DefaultAgent ("codex") when s.Agent is empty,
162-
// else s.Agent verbatim. Cheap idempotent guard used by read paths
163-
// that handle pre-migration in-memory values without touching disk.
167+
// NormalizeAgent returns DefaultAgent when s.Agent is empty, else
168+
// s.Agent verbatim. Cheap idempotent guard used by read paths that
169+
// handle pre-migration in-memory values without touching disk.
164170
//
165-
// Legacy "claude" values that escaped the v2→v3 migration are also
166-
// remapped to "codex" so a stale in-memory Session never surfaces as
167-
// an agent.For miss at the call site.
171+
// Legacy "claude" values that escaped the v2→v3 migration are
172+
// remapped to "codex" (the on-disk migration target) — not
173+
// DefaultAgent — so a stale in-memory Session never surfaces as an
174+
// agent.For miss at the call site, and changing DefaultAgent doesn't
175+
// silently move historical claude sessions.
168176
func (s *Session) NormalizeAgent() string {
169-
if s.Agent == "" || s.Agent == "claude" {
177+
if s.Agent == "" {
170178
return DefaultAgent
171179
}
180+
if s.Agent == "claude" {
181+
return "codex"
182+
}
172183
return s.Agent
173184
}
174185

@@ -314,12 +325,17 @@ func (s *Store) backupLocked() (string, error) {
314325
}
315326

316327
// Save adds or updates a session. Empty sess.Agent is normalized to
317-
// DefaultAgent ("codex"). Legacy "claude" values are also rewritten —
318-
// the claude implementation was removed and a stray "claude" row
319-
// would fail at spawn-time agent.For lookup.
328+
// DefaultAgent. Legacy "claude" values are rewritten to "codex" (the
329+
// v2→v3 migration target) — not DefaultAgent — so changing the
330+
// default later doesn't silently retarget historical claude sessions.
331+
// A stray "claude" row would otherwise fail at spawn-time agent.For
332+
// lookup.
320333
func (s *Store) Save(sess *Session) error {
321-
if sess.Agent == "" || sess.Agent == "claude" {
334+
switch sess.Agent {
335+
case "":
322336
sess.Agent = DefaultAgent
337+
case "claude":
338+
sess.Agent = "codex"
323339
}
324340

325341
lf, err := s.lock()

internal/session/state_migration_test.go

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,7 @@ func TestMigration_V1ToV2_Idempotent(t *testing.T) {
118118
// TestSession_AgentFieldRoundTrip verifies that the Agent and
119119
// AgentSessionID fields survive a Save / Get cycle for non-default
120120
// agents. (The default agent path is covered by
121-
// TestSession_EmptyAgentDefaultsCodexOnSave.)
121+
// TestSession_EmptyAgentDefaultsToDefaultAgentOnSave.)
122122
func TestSession_AgentFieldRoundTrip(t *testing.T) {
123123
dir := t.TempDir()
124124
store := session.NewStore(filepath.Join(dir, "sessions.json"))
@@ -145,9 +145,11 @@ func TestSession_AgentFieldRoundTrip(t *testing.T) {
145145
}
146146
}
147147

148-
// TestSession_EmptyAgentDefaultsCodexOnSave verifies the read-side
149-
// guard: Save sets s.Agent = "codex" when empty.
150-
func TestSession_EmptyAgentDefaultsCodexOnSave(t *testing.T) {
148+
// TestSession_EmptyAgentDefaultsToDefaultAgentOnSave verifies the
149+
// write-side guard: Save sets s.Agent = DefaultAgent when empty.
150+
// Asserts on the constant (not a literal) so a future default flip
151+
// doesn't silently leave this test pinned to a stale value.
152+
func TestSession_EmptyAgentDefaultsToDefaultAgentOnSave(t *testing.T) {
151153
dir := t.TempDir()
152154
store := session.NewStore(filepath.Join(dir, "sessions.json"))
153155
in := &session.Session{
@@ -161,8 +163,9 @@ func TestSession_EmptyAgentDefaultsCodexOnSave(t *testing.T) {
161163
t.Fatalf("save: %v", err)
162164
}
163165
out, _ := store.Get("bar")
164-
if out.Agent != "codex" {
165-
t.Fatalf("empty Agent should default to \"codex\" on Save, got %q", out.Agent)
166+
if out.Agent != session.DefaultAgent {
167+
t.Fatalf("empty Agent should default to %q on Save, got %q",
168+
session.DefaultAgent, out.Agent)
166169
}
167170
}
168171

internal/session/state_test.go

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -305,24 +305,29 @@ func TestMigrationPlan_MatchesSchemaVersion(t *testing.T) {
305305
}
306306
}
307307

308-
// TestNormalizeAgent_DefaultsCodex covers the read-side helper. Empty
309-
// values default to "codex" (the post-claude-removal default); legacy
310-
// "claude" values are also remapped to "codex" so a stale Session
311-
// never surfaces as an agent.For miss at the call site. Other agent
312-
// names pass through verbatim.
313-
func TestNormalizeAgent_DefaultsCodex(t *testing.T) {
308+
// TestNormalizeAgent covers the read-side helper. Empty values default
309+
// to DefaultAgent ("hermes" post-default-flip); legacy "claude" values
310+
// are remapped to "codex" (the v2→v3 on-disk migration target — NOT
311+
// DefaultAgent, by design, so the default flip doesn't silently
312+
// retarget historical claude sessions). Other agent names pass
313+
// through verbatim.
314+
func TestNormalizeAgent(t *testing.T) {
314315
s := &session.Session{}
315-
if got := s.NormalizeAgent(); got != "codex" {
316-
t.Fatalf("NormalizeAgent on zero-value = %q, want codex", got)
316+
if got := s.NormalizeAgent(); got != "hermes" {
317+
t.Fatalf("NormalizeAgent on zero-value = %q, want hermes", got)
317318
}
318319
s.Agent = "claude"
319320
if got := s.NormalizeAgent(); got != "codex" {
320-
t.Fatalf("NormalizeAgent(claude) = %q, want codex (legacy remap)", got)
321+
t.Fatalf("NormalizeAgent(claude) = %q, want codex (legacy remap, not DefaultAgent)", got)
321322
}
322323
s.Agent = "codex"
323324
if got := s.NormalizeAgent(); got != "codex" {
324325
t.Fatalf("NormalizeAgent(codex) = %q, want codex", got)
325326
}
327+
s.Agent = "hermes"
328+
if got := s.NormalizeAgent(); got != "hermes" {
329+
t.Fatalf("NormalizeAgent(hermes) = %q, want hermes", got)
330+
}
326331
s.Agent = "opencode"
327332
if got := s.NormalizeAgent(); got != "opencode" {
328333
t.Fatalf("NormalizeAgent(opencode) = %q, want opencode (forward-compat)", got)

0 commit comments

Comments
 (0)