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 @@ -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
Expand Down
9 changes: 5 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```

Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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 <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.
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.

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

Expand Down Expand Up @@ -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 <name>` | Attach to a named session, or create it. |
| `ctm cc` | Shorthand for attaching to `cc`. |
| `ctm new <name>` | Create a new session in a specific workdir. |
Expand Down
8 changes: 4 additions & 4 deletions cmd/yolo_helpers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"},
}
Expand Down Expand Up @@ -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)
}
}

Expand Down
5 changes: 4 additions & 1 deletion internal/session/spawn_discovery_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand Down Expand Up @@ -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) },
Expand Down Expand Up @@ -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) },
Expand Down
3 changes: 2 additions & 1 deletion internal/session/spawn_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand Down
40 changes: 28 additions & 12 deletions internal/session/state.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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()
Expand Down
15 changes: 9 additions & 6 deletions internal/session/state_migration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
Expand All @@ -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{
Expand All @@ -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)
}
}

Expand Down
23 changes: 14 additions & 9 deletions internal/session/state_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading