From 49bbf04f43fe6e983b40a7edcff7253609baf004 Mon Sep 17 00:00:00 2001 From: aksops Date: Fri, 15 May 2026 16:49:45 +0000 Subject: [PATCH 1/6] feat(agent): scaffold hermes agent package Registers hermes under agent.Registry with the canonical name 'hermes'. BuildCommand and DiscoverSessionID are stubbed; real implementations land in the next two commits. --- internal/agent/hermes/command.go | 7 ++++ internal/agent/hermes/discover.go | 9 +++++ internal/agent/hermes/hermes.go | 52 +++++++++++++++++++++++++ internal/agent/hermes/hermes_test.go | 57 ++++++++++++++++++++++++++++ 4 files changed, 125 insertions(+) create mode 100644 internal/agent/hermes/command.go create mode 100644 internal/agent/hermes/discover.go create mode 100644 internal/agent/hermes/hermes.go create mode 100644 internal/agent/hermes/hermes_test.go diff --git a/internal/agent/hermes/command.go b/internal/agent/hermes/command.go new file mode 100644 index 0000000..4fc3550 --- /dev/null +++ b/internal/agent/hermes/command.go @@ -0,0 +1,7 @@ +package hermes + +// BuildCommand is implemented in Task 2 (see plan). +// Stub returns empty so the package compiles for Task 1's registration tests. +func BuildCommand(agentSessionID, mode string, resume bool, envExports string) string { + return "" +} diff --git a/internal/agent/hermes/discover.go b/internal/agent/hermes/discover.go new file mode 100644 index 0000000..fa3132a --- /dev/null +++ b/internal/agent/hermes/discover.go @@ -0,0 +1,9 @@ +package hermes + +import "time" + +// DiscoverSessionID is implemented in Task 3 (see plan). +// Stub returns ("", false) so the package compiles for Task 1's tests. +func DiscoverSessionID(spawnStart time.Time) (string, bool) { + return "", false +} diff --git a/internal/agent/hermes/hermes.go b/internal/agent/hermes/hermes.go new file mode 100644 index 0000000..838582a --- /dev/null +++ b/internal/agent/hermes/hermes.go @@ -0,0 +1,52 @@ +// Package hermes provides the Agent implementation for the Hermes Agent CLI. +// +// Registration happens in init() — any binary that links this package will +// have "hermes" available in the agent registry. +package hermes + +import ( + "os" + "time" + + "github.com/RandomCodeSpace/ctm/internal/agent" +) + +func init() { + agent.Register(New()) +} + +type hermesAgent struct{} + +// New returns the hermes Agent. Exposed (not just via init) so test code +// that needs a fresh registry can re-register after agent.Reset(). +func New() agent.Agent { return hermesAgent{} } + +func (hermesAgent) Name() string { return "hermes" } +func (hermesAgent) DefaultSessionName() string { return "hermes" } +func (hermesAgent) ProcessName() string { return "hermes" } + +// Binary honors CTM_HERMES_BIN for fake-binary fixture overrides in +// integration tests. Production deployments leave it unset → "hermes" is +// resolved through PATH at exec time. +func (hermesAgent) Binary() string { + if b := os.Getenv("CTM_HERMES_BIN"); b != "" { + return b + } + return "hermes" +} + +// BuildCommand delegates to the package-level BuildCommand (command.go). +func (hermesAgent) BuildCommand(s agent.SpawnSpec) string { + return BuildCommand(s.AgentSessionID, s.Mode, s.Resume, s.EnvExports) +} + +// YOLOFlag is hermes' single bypass-all-prompts flag. +func (hermesAgent) YOLOFlag() []string { + return []string{"--yolo"} +} + +// DiscoverSessionID polls hermes' on-disk state for the session created by +// a fresh spawn at spawnStart. See discover.go for the polling contract. +func (hermesAgent) DiscoverSessionID(spawnStart time.Time) (string, bool) { + return DiscoverSessionID(spawnStart) +} diff --git a/internal/agent/hermes/hermes_test.go b/internal/agent/hermes/hermes_test.go new file mode 100644 index 0000000..1a10098 --- /dev/null +++ b/internal/agent/hermes/hermes_test.go @@ -0,0 +1,57 @@ +package hermes_test + +import ( + "os" + "testing" + "time" + + "github.com/RandomCodeSpace/ctm/internal/agent" + _ "github.com/RandomCodeSpace/ctm/internal/agent/hermes" // register via init +) + +func TestRegisteredUnderHermes(t *testing.T) { + a, ok := agent.For("hermes") + if !ok { + t.Fatal(`agent.For("hermes") = false; want registered`) + } + if a.Name() != "hermes" { + t.Errorf("Name() = %q, want %q", a.Name(), "hermes") + } + if a.DefaultSessionName() != "hermes" { + t.Errorf("DefaultSessionName() = %q, want %q", a.DefaultSessionName(), "hermes") + } + if a.ProcessName() != "hermes" { + t.Errorf("ProcessName() = %q, want %q", a.ProcessName(), "hermes") + } +} + +func TestBinaryHonorsEnv(t *testing.T) { + a, _ := agent.For("hermes") + + t.Setenv("CTM_HERMES_BIN", "/tmp/fake-hermes") + if got := a.Binary(); got != "/tmp/fake-hermes" { + t.Errorf("Binary() with env = %q, want %q", got, "/tmp/fake-hermes") + } + + os.Unsetenv("CTM_HERMES_BIN") + if got := a.Binary(); got != "hermes" { + t.Errorf("Binary() without env = %q, want %q", got, "hermes") + } +} + +func TestYOLOFlag(t *testing.T) { + a, _ := agent.For("hermes") + got := a.YOLOFlag() + if len(got) != 1 || got[0] != "--yolo" { + t.Errorf("YOLOFlag() = %v, want [\"--yolo\"]", got) + } +} + +func TestDiscoverSessionID_missingBinaryReturnsFalse(t *testing.T) { + t.Setenv("CTM_HERMES_BIN", "/nonexistent/path/to/hermes-xyz") + a, _ := agent.For("hermes") + id, ok := a.DiscoverSessionID(time.Now()) + if ok || id != "" { + t.Errorf("DiscoverSessionID = (%q, %v), want (\"\", false)", id, ok) + } +} From 989cbcb3dbaa74eb2dbd419214e42331cff49b14 Mon Sep 17 00:00:00 2001 From: aksops Date: Fri, 15 May 2026 16:51:49 +0000 Subject: [PATCH 2/6] feat(agent/hermes): BuildCommand for fresh/resume/yolo branches Hermes' resume API differs from codex: --resume (flag), -c for most-recent, --yolo as the bypass flag. Pattern otherwise mirrors codex including the '|| hermes' fallback for crash recovery on resume. --- internal/agent/hermes/command.go | 49 ++++++++++++++- internal/agent/hermes/command_test.go | 89 +++++++++++++++++++++++++++ 2 files changed, 135 insertions(+), 3 deletions(-) create mode 100644 internal/agent/hermes/command_test.go diff --git a/internal/agent/hermes/command.go b/internal/agent/hermes/command.go index 4fc3550..b7e9423 100644 --- a/internal/agent/hermes/command.go +++ b/internal/agent/hermes/command.go @@ -1,7 +1,50 @@ package hermes -// BuildCommand is implemented in Task 2 (see plan). -// Stub returns empty so the package compiles for Task 1's registration tests. +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 ` +// - 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 { - return "" + 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 } diff --git a/internal/agent/hermes/command_test.go b/internal/agent/hermes/command_test.go new file mode 100644 index 0000000..4797244 --- /dev/null +++ b/internal/agent/hermes/command_test.go @@ -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) + } + } +} From 4dd284d66b6b980869bc692004d43811c3222237 Mon Sep 17 00:00:00 2001 From: aksops Date: Fri, 15 May 2026 16:54:58 +0000 Subject: [PATCH 3/6] feat(agent/hermes): DiscoverSessionID via 'hermes sessions list' Shells out to the hermes CLI and regex-extracts the session ID instead of linking a SQLite driver (which the codex pivot intentionally removed). 5s budget, 250ms poll. Tests use a bash shim wired in via CTM_HERMES_BIN to drive the subprocess deterministically. --- internal/agent/hermes/discover.go | 93 ++++++++++++- internal/agent/hermes/discover_test.go | 122 ++++++++++++++++++ internal/agent/hermes/testdata/fake-hermes.sh | 18 +++ 3 files changed, 229 insertions(+), 4 deletions(-) create mode 100644 internal/agent/hermes/discover_test.go create mode 100755 internal/agent/hermes/testdata/fake-hermes.sh diff --git a/internal/agent/hermes/discover.go b/internal/agent/hermes/discover.go index fa3132a..9aacbd1 100644 --- a/internal/agent/hermes/discover.go +++ b/internal/agent/hermes/discover.go @@ -1,9 +1,94 @@ package hermes -import "time" +import ( + "bytes" + "os" + "os/exec" + "regexp" + "time" +) -// DiscoverSessionID is implemented in Task 3 (see plan). -// Stub returns ("", false) so the package compiles for Task 1's tests. +// 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) { - return "", false + 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 } diff --git a/internal/agent/hermes/discover_test.go b/internal/agent/hermes/discover_test.go new file mode 100644 index 0000000..248740e --- /dev/null +++ b/internal/agent/hermes/discover_test.go @@ -0,0 +1,122 @@ +package hermes + +import ( + "os" + "path/filepath" + "testing" + "time" +) + +func shimPath(t *testing.T) string { + t.Helper() + abs, err := filepath.Abs("testdata/fake-hermes.sh") + if err != nil { + t.Fatalf("filepath.Abs: %v", err) + } + return abs +} + +func withShim(t *testing.T, fixture string) { + t.Helper() + t.Setenv("CTM_HERMES_BIN", shimPath(t)) + if fixture != "" { + path := filepath.Join(t.TempDir(), "fixture.txt") + if err := os.WriteFile(path, []byte(fixture), 0o644); err != nil { + t.Fatalf("WriteFile fixture: %v", err) + } + t.Setenv("CTM_FAKE_HERMES_FIXTURE", path) + } + prevBudget, prevPoll := discoverBudgetVar, discoverPollVar + discoverBudgetVar = 300 * time.Millisecond + discoverPollVar = 50 * time.Millisecond + t.Cleanup(func() { + discoverBudgetVar = prevBudget + discoverPollVar = prevPoll + }) +} + +func TestDiscoverSessionID_match(t *testing.T) { + fixture := "Preview Last Active Src ID\n" + + "─────────────────────────────────────────────────────\n" + + "say hi just now cli 20260515_152727_9da209\n" + withShim(t, fixture) + spawnStart, _ := time.ParseInLocation("20060102_150405", "20260515_152700", time.Local) + id, ok := DiscoverSessionID(spawnStart) + if !ok || id != "20260515_152727_9da209" { + t.Errorf("DiscoverSessionID = (%q, %v), want (%q, true)", + id, ok, "20260515_152727_9da209") + } +} + +func TestDiscoverSessionID_newestWins(t *testing.T) { + fixture := "Preview Last Active Src ID\n" + + "─────────────────────────────────────────────────\n" + + "older 2m ago cli 20260515_152700_aaaaaa\n" + + "newer just now cli 20260515_153000_bbbbbb\n" + + "oldest 5m ago cli 20260515_152500_cccccc\n" + withShim(t, fixture) + spawnStart, _ := time.ParseInLocation("20060102_150405", "20260515_152600", time.Local) + id, ok := DiscoverSessionID(spawnStart) + if !ok || id != "20260515_153000_bbbbbb" { + t.Errorf("DiscoverSessionID = (%q, %v), want newest (20260515_153000_bbbbbb, true)", id, ok) + } +} + +func TestDiscoverSessionID_cutoffFiltersOldRows(t *testing.T) { + fixture := "Preview Last Active Src ID\n" + + "─────────────────────────────────────────────────\n" + + "old 2m ago cli 20260515_152700_aaaaaa\n" + withShim(t, fixture) + spawnStart, _ := time.ParseInLocation("20060102_150405", "20260515_153000", time.Local) + id, ok := DiscoverSessionID(spawnStart) + if ok || id != "" { + t.Errorf("DiscoverSessionID = (%q, %v), want (\"\", false) for pre-cutoff row", id, ok) + } +} + +func TestDiscoverSessionID_emptyOutput(t *testing.T) { + withShim(t, "") + id, ok := DiscoverSessionID(time.Now()) + if ok || id != "" { + t.Errorf("DiscoverSessionID = (%q, %v), want (\"\", false) for empty output", id, ok) + } +} + +func TestDiscoverSessionID_malformedLines(t *testing.T) { + fixture := "not a real table\nnot even close\nrandom gibberish\n" + withShim(t, fixture) + id, ok := DiscoverSessionID(time.Now().Add(-1 * time.Hour)) + if ok || id != "" { + t.Errorf("DiscoverSessionID = (%q, %v), want (\"\", false) for no IDs", id, ok) + } +} + +func TestDiscoverSessionID_missingBinaryReturnsFalse(t *testing.T) { + t.Setenv("CTM_HERMES_BIN", "/nonexistent/path/to/hermes-xyz") + prevBudget, prevPoll := discoverBudgetVar, discoverPollVar + discoverBudgetVar = 200 * time.Millisecond + discoverPollVar = 50 * time.Millisecond + defer func() { + discoverBudgetVar = prevBudget + discoverPollVar = prevPoll + }() + + id, ok := DiscoverSessionID(time.Now()) + if ok || id != "" { + t.Errorf("DiscoverSessionID = (%q, %v), want (\"\", false)", id, ok) + } +} + +func TestHermesBin_defaultsToHermes(t *testing.T) { + os.Unsetenv("CTM_HERMES_BIN") + if got := hermesBin(); got != "hermes" { + t.Errorf("hermesBin() = %q, want %q", got, "hermes") + } +} + +func TestHermesBin_envOverride(t *testing.T) { + t.Setenv("CTM_HERMES_BIN", "/some/path") + if got := hermesBin(); got != "/some/path" { + t.Errorf("hermesBin() = %q, want %q", got, "/some/path") + } +} diff --git a/internal/agent/hermes/testdata/fake-hermes.sh b/internal/agent/hermes/testdata/fake-hermes.sh new file mode 100755 index 0000000..d5b4fcf --- /dev/null +++ b/internal/agent/hermes/testdata/fake-hermes.sh @@ -0,0 +1,18 @@ +#!/bin/bash +# Test shim for internal/agent/hermes/discover_test.go. +# Echoes $CTM_FAKE_HERMES_FIXTURE when called with `sessions list ...`. +# Any other invocation exits 99 to make accidental real calls obvious. +set -u + +case "${1:-} ${2:-}" in + "sessions list") + if [ -n "${CTM_FAKE_HERMES_FIXTURE:-}" ] && [ -f "${CTM_FAKE_HERMES_FIXTURE}" ]; then + cat "${CTM_FAKE_HERMES_FIXTURE}" + fi + exit 0 + ;; + *) + echo "fake-hermes: unsupported args $*" >&2 + exit 99 + ;; +esac From 85f0981371f8132cefb62d6e99f06350d6f44ade Mon Sep 17 00:00:00 2001 From: aksops Date: Fri, 15 May 2026 16:58:11 +0000 Subject: [PATCH 4/6] refactor(doctor): drive dep list from agent.Registered() Replaces hardcoded []string{"tmux","codex","git"} in both cmd/doctor.go and internal/doctor/doctor.go with a registry walk: {tmux} + agent.Registered() + {git}. Future agents are picked up automatically without touching doctor. Also blank-imports internal/agent/hermes from main.go so the ctm binary actually registers the hermes agent at startup (codex was already blank-imported); without it the registry walk would not surface hermes in `ctm doctor`. --- cmd/doctor.go | 5 ++++- internal/doctor/doctor.go | 4 +++- internal/doctor/doctor_test.go | 20 ++++++++++++++++++++ main.go | 1 + 4 files changed, 28 insertions(+), 2 deletions(-) diff --git a/cmd/doctor.go b/cmd/doctor.go index 96a4ddb..d6bbdc9 100644 --- a/cmd/doctor.go +++ b/cmd/doctor.go @@ -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" @@ -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 { diff --git a/internal/doctor/doctor.go b/internal/doctor/doctor.go index cf2257e..a783c9e 100644 --- a/internal/doctor/doctor.go +++ b/internal/doctor/doctor.go @@ -13,6 +13,7 @@ import ( "os/exec" "strings" + "github.com/RandomCodeSpace/ctm/internal/agent" "github.com/RandomCodeSpace/ctm/internal/config" "github.com/RandomCodeSpace/ctm/internal/session" "github.com/RandomCodeSpace/ctm/internal/tmux" @@ -89,7 +90,8 @@ func Run(ctx context.Context, cfg config.Config) []Check { } func checkDependencies(_ context.Context, _ config.Config) []Check { - deps := []string{"tmux", "codex", "git"} + deps := append([]string{"tmux"}, agent.Registered()...) + deps = append(deps, "git") out := make([]Check, 0, len(deps)) for _, dep := range deps { c := Check{Name: "dep:" + dep} diff --git a/internal/doctor/doctor_test.go b/internal/doctor/doctor_test.go index 2c72924..f26215c 100644 --- a/internal/doctor/doctor_test.go +++ b/internal/doctor/doctor_test.go @@ -6,6 +6,8 @@ import ( "testing" "github.com/RandomCodeSpace/ctm/internal/config" + _ "github.com/RandomCodeSpace/ctm/internal/agent/codex" // register codex + _ "github.com/RandomCodeSpace/ctm/internal/agent/hermes" // register hermes ) // TestRun_Shape asserts Run() returns at least one Check of each status @@ -123,3 +125,21 @@ func find(checks []Check, name string) *Check { } return nil } + +// TestCheckDependencies_IncludesAllRegisteredAgents asserts the dep list +// walks the agent registry so future agents (hermes, etc.) appear in +// doctor output without code edits to this package. +func TestCheckDependencies_IncludesAllRegisteredAgents(t *testing.T) { + checks := checkDependencies(context.Background(), config.Config{}) + + gotNames := map[string]bool{} + for _, c := range checks { + gotNames[c.Name] = true + } + + for _, want := range []string{"dep:tmux", "dep:codex", "dep:hermes", "dep:git"} { + if !gotNames[want] { + t.Errorf("checkDependencies missing %q; got names = %v", want, gotNames) + } + } +} diff --git a/main.go b/main.go index a006d8b..57accf6 100644 --- a/main.go +++ b/main.go @@ -8,6 +8,7 @@ import ( // would link with no agents registered and any attach / yolo / check // would fail at agent.For lookup. _ "github.com/RandomCodeSpace/ctm/internal/agent/codex" + _ "github.com/RandomCodeSpace/ctm/internal/agent/hermes" ) func main() { From dac2a3dd8389dab2dabf82935e059d3f531a73f5 Mon Sep 17 00:00:00 2001 From: aksops Date: Fri, 15 May 2026 16:59:04 +0000 Subject: [PATCH 5/6] docs: document hermes agent support in README + CHANGELOG README gains a 'Multi-agent' feature bullet pointing at --agent hermes. CHANGELOG [Unreleased] gets a new Added section describing the agent package, resume semantics, discovery strategy, and the doctor refactor. --- CHANGELOG.md | 11 +++++++++++ README.md | 1 + 2 files changed, 12 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 27f6032..4afbd55 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 ` (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 diff --git a/README.md b/README.md index 53d5880..127faaa 100644 --- a/README.md +++ b/README.md @@ -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. From 42473de01d80ec38ba7f0406aa2ee90292d7d30c Mon Sep 17 00:00:00 2001 From: aksops Date: Fri, 15 May 2026 17:00:15 +0000 Subject: [PATCH 6/6] style: gofmt internal/agent/hermes + internal/doctor test files --- internal/agent/hermes/command_test.go | 4 ++-- internal/doctor/doctor_test.go | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/internal/agent/hermes/command_test.go b/internal/agent/hermes/command_test.go index 4797244..6e865e7 100644 --- a/internal/agent/hermes/command_test.go +++ b/internal/agent/hermes/command_test.go @@ -44,8 +44,8 @@ func TestBuildCommand(t *testing.T) { want: "hermes -c --yolo || hermes --yolo", }, { - name: "env-prelude-fresh-safe", - mode: "safe", resume: false, + name: "env-prelude-fresh-safe", + mode: "safe", resume: false, envExports: "export FOO='bar'", want: "export FOO='bar'; hermes", }, diff --git a/internal/doctor/doctor_test.go b/internal/doctor/doctor_test.go index f26215c..046809d 100644 --- a/internal/doctor/doctor_test.go +++ b/internal/doctor/doctor_test.go @@ -5,9 +5,9 @@ import ( "encoding/json" "testing" - "github.com/RandomCodeSpace/ctm/internal/config" _ "github.com/RandomCodeSpace/ctm/internal/agent/codex" // register codex _ "github.com/RandomCodeSpace/ctm/internal/agent/hermes" // register hermes + "github.com/RandomCodeSpace/ctm/internal/config" ) // TestRun_Shape asserts Run() returns at least one Check of each status