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

## [Unreleased]

### Added

- 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
`internal/agent/codex/` shape; resume is `hermes --resume <id>` (flag, not
positional) and yolo flag is `--yolo`. Session ID discovery shells out to
`hermes sessions list --source cli` rather than re-introducing a SQLite
driver. Doctor dep list now walks `agent.Registered()` so future agents are
picked up automatically without code edits.

### Changed

- `internal/config/config.go` schema bumped to v2. Existing
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,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.
- **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
5 changes: 4 additions & 1 deletion cmd/doctor.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"strings"

"github.com/spf13/cobra"
"github.com/RandomCodeSpace/ctm/internal/agent"
"github.com/RandomCodeSpace/ctm/internal/config"
"github.com/RandomCodeSpace/ctm/internal/doctor"
"github.com/RandomCodeSpace/ctm/internal/output"
Expand Down Expand Up @@ -41,7 +42,9 @@ func runDoctor(cmd *cobra.Command, args []string) error {

// --- Dependencies ---
out.Bold("Dependencies:")
for _, dep := range []string{"tmux", "codex", "git"} {
deps := append([]string{"tmux"}, agent.Registered()...)
deps = append(deps, "git")
for _, dep := range deps {
if path, ok := doctor.LookupBinary(dep); ok {
out.Success(" [OK] %-10s %s", dep, path)
} else {
Expand Down
50 changes: 50 additions & 0 deletions internal/agent/hermes/command.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package hermes

import (
"fmt"
"strings"
)

// shellQuote wraps s in single quotes, escaping any embedded single quotes.
// Safe for paths and IDs passed through /bin/sh -c.
func shellQuote(s string) string {
return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
}

// BuildCommand builds the hermes CLI command string.
//
// agentSessionID is the hermes session ID (Session.AgentSessionID). On
// resume:
// - if non-empty: `hermes --resume <id>`
// - if empty: `hermes -c` (continue most-recent)
//
// In both resume branches the command falls back to a fresh `hermes` on
// non-zero exit — matches codex's pattern for crash/Ctrl-C parity.
// (Hermes itself exits 0 even on bad ID, so this fallback is purely
// defensive against crashes during resume.)
//
// envExports, when non-empty, is prepended verbatim as a shell prelude.
func BuildCommand(agentSessionID, mode string, resume bool, envExports string) string {
var yoloFlag string
if mode == "yolo" {
yoloFlag = " --yolo"
}

freshCmd := "hermes" + yoloFlag

var core string
switch {
case !resume:
core = freshCmd
case agentSessionID != "":
core = fmt.Sprintf("hermes --resume %s%s || %s",
shellQuote(agentSessionID), yoloFlag, freshCmd)
default:
core = "hermes -c" + yoloFlag + " || " + freshCmd
}

if envExports != "" {
return envExports + "; " + core
}
return core
}
89 changes: 89 additions & 0 deletions internal/agent/hermes/command_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
package hermes

import "testing"

func TestBuildCommand(t *testing.T) {
tests := []struct {
name string
agentSessionID string
mode string
resume bool
envExports string
want string
}{
{
name: "fresh-safe",
mode: "safe", resume: false,
want: "hermes",
},
{
name: "fresh-yolo",
mode: "yolo", resume: false,
want: "hermes --yolo",
},
{
name: "resume-with-id-safe",
agentSessionID: "20260515_152727_9da209",
mode: "safe", resume: true,
want: "hermes --resume '20260515_152727_9da209' || hermes",
},
{
name: "resume-with-id-yolo",
agentSessionID: "20260515_152727_9da209",
mode: "yolo", resume: true,
want: "hermes --resume '20260515_152727_9da209' --yolo || hermes --yolo",
},
{
name: "resume-no-id-safe",
mode: "safe", resume: true,
want: "hermes -c || hermes",
},
{
name: "resume-no-id-yolo",
mode: "yolo", resume: true,
want: "hermes -c --yolo || hermes --yolo",
},
{
name: "env-prelude-fresh-safe",
mode: "safe", resume: false,
envExports: "export FOO='bar'",
want: "export FOO='bar'; hermes",
},
{
name: "env-prelude-resume-yolo",
agentSessionID: "id1",
mode: "yolo", resume: true,
envExports: "export FOO='bar'",
want: "export FOO='bar'; hermes --resume 'id1' --yolo || hermes --yolo",
},
{
name: "shell-quote-escapes-single-quote",
agentSessionID: `weird'id`,
mode: "safe", resume: true,
want: `hermes --resume 'weird'\''id' || hermes`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := BuildCommand(tt.agentSessionID, tt.mode, tt.resume, tt.envExports)
if got != tt.want {
t.Errorf("BuildCommand(%q, %q, %v, %q)\n got: %q\nwant: %q",
tt.agentSessionID, tt.mode, tt.resume, tt.envExports, got, tt.want)
}
})
}
}

func TestShellQuote(t *testing.T) {
tests := []struct{ in, want string }{
{"abc", "'abc'"},
{"", "''"},
{`a'b`, `'a'\''b'`},
{`'`, `''\'''`},
}
for _, tt := range tests {
if got := shellQuote(tt.in); got != tt.want {
t.Errorf("shellQuote(%q) = %q, want %q", tt.in, got, tt.want)
}
}
}
94 changes: 94 additions & 0 deletions internal/agent/hermes/discover.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
package hermes

import (
"bytes"
"os"
"os/exec"
"regexp"
"time"
)

// discoverBudgetVar is the wall-clock ceiling for DiscoverSessionID.
// Hermes typically writes its session row within ~200ms of invocation;
// 5s gives plenty of slack. var (not const) so tests can shrink it.
var discoverBudgetVar = 5 * time.Second

// discoverPollVar is the interval between `hermes sessions list` calls.
// 250ms keeps latency tight without thrashing subprocesses.
var discoverPollVar = 250 * time.Millisecond

// sessionIDRe matches hermes' session ID at the trailing edge of a line:
//
// YYYYMMDD_HHMMSS_<6+ hex chars>
//
// e.g. 20260515_152727_9da209. Anchored on end-of-line so we ignore
// stray hex elsewhere in the row.
var sessionIDRe = regexp.MustCompile(`(\d{8}_\d{6}_[0-9a-fA-F]{6,})\s*$`)

// hermesBin returns the hermes binary to invoke; honors CTM_HERMES_BIN.
func hermesBin() string {
if b := os.Getenv("CTM_HERMES_BIN"); b != "" {
return b
}
return "hermes"
}

// DiscoverSessionID polls `hermes sessions list --source cli --limit 10`
// for a row whose ID-encoded timestamp is at or after spawnStart, and
// returns the newest match.
//
// Returns ("", false) on timeout, missing binary, or any subprocess
// error — callers fall through to `hermes -c` semantics, which is
// strictly less precise but still correct.
func DiscoverSessionID(spawnStart time.Time) (string, bool) {
deadline := time.Now().Add(discoverBudgetVar)
cutoff := spawnStart.Truncate(time.Second)
for {
if id, ok := scanSessions(cutoff); ok {
return id, true
}
if time.Now().After(deadline) {
return "", false
}
time.Sleep(discoverPollVar)
}
}

// scanSessions runs `hermes sessions list --source cli --limit 10` and
// returns the newest session ID whose timestamp prefix is at or after
// cutoff. Returns ("", false) when no match is found, the subprocess
// fails, or the binary is missing.
func scanSessions(cutoff time.Time) (string, bool) {
cmd := exec.Command(hermesBin(), "sessions", "list", "--source", "cli", "--limit", "10")
var stdout bytes.Buffer
cmd.Stdout = &stdout
if err := cmd.Run(); err != nil {
return "", false
}

var bestID string
var bestTime time.Time
for _, line := range bytes.Split(stdout.Bytes(), []byte("\n")) {
m := sessionIDRe.FindSubmatch(line)
if m == nil {
continue
}
id := string(m[1])
// First 15 chars are "YYYYMMDD_HHMMSS"; the rest is the random suffix.
t, err := time.ParseInLocation("20060102_150405", id[:15], time.Local)
if err != nil {
continue
}
if t.Before(cutoff) {
continue
}
if t.After(bestTime) {
bestID = id
bestTime = t
}
}
if bestID == "" {
return "", false
}
return bestID, true
}
Loading
Loading