Skip to content
Open
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: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,9 +61,12 @@ Omit `<agent>` and catchup uses whichever agent has the newest session in this d
**For you:** run in your terminal to re-enter a session:

```bash
catchup --list # where was I — recent sessions, every agent
catchup fork # fork the newest session across agents
catchup fork <agent> # fork that agent's newest session
catchup fork codex --into claude # continue a Codex session in Claude
catchup fork claude --into claude --since-compact
# restart the same agent clean, keeping the work
```

**For agents:** run inside a session to read prior work:
Expand All @@ -80,14 +83,15 @@ catchup <agent> --last 4 # read last 4 exchanges
catchup <agent> --json # render JSON; also --html
```

Use `fork` to continue with the same agent and keep native session state. Use `fork --into` to start another agent with the transcript. Use read commands when you want old work in a clean context.
Use `fork` to continue with the same agent and keep native session state. Use `fork --into` to start another agent with the transcript — or the same agent with `--last`/`--since-compact`, which restarts it clean on a trimmed transcript when the context is spent but the work isn't. Use read commands when you want old work in a clean context.

## Boundaries

- One agent at a time. It does not merge histories.
- Conversation only. It strips tool calls, command output, and reasoning traces.
- Read-only, except `fork`.
- Same-agent `fork` uses the agent's native resume path, so it keeps real session state.
- Same-agent `fork --into` is the opposite trade: native state is dropped for a clean context.
- Cross-agent `fork --into` seeds the new agent with a transcript, not native state.

## License
Expand Down
109 changes: 101 additions & 8 deletions internal/cli/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,12 @@ import (
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
"syscall"
"time"

"github.com/mattn/go-runewidth"
"github.com/wilbeibi/catchup/internal/agy"
"github.com/wilbeibi/catchup/internal/claude"
"github.com/wilbeibi/catchup/internal/cline"
Expand Down Expand Up @@ -48,7 +50,7 @@ RECAP — how much of the session (default: all of it)
-i, --info metadata only, no messages

FIND — which session (default: newest here)
--list list recent sessions
--list list recent sessions here, across every agent
-q, --query <text> search by keyword (implies --list)
<agent>/<rank> the Nth newest, e.g. codex/3
--id <id> an exact session id
Expand All @@ -57,10 +59,11 @@ FIND — which session (default: newest here)

HAND OFF — continue the work
fork [agent] native resume, full state
--into <agent> seed a different agent with the transcript
--into <agent> seed a different agent with the transcript; the same
agent, with --last/--since-compact, restarts it clean
--from <src> with --into: seed from a file, - (stdin), or http(s) URL
--model <name> launch it on a specific model, e.g. a cheaper one
(the target agent's own model name, e.g. gpt-5.6)
(the target agent's own model name, passed as-is)

OUTPUT — as what (default: Markdown)
--md, --markdown Markdown (the default)
Expand All @@ -76,6 +79,7 @@ Examples:
catchup claude --dir ~/src/proj latest session from another directory
catchup fork --into codex hand the newest session here to Codex
catchup fork claude --into codex --model gpt-5.6 ...on a specific model
catchup fork claude --into claude --since-compact ...clean, same agent
catchup fork --into claude --from handoff.md ...from a saved transcript
catchup install-skill install the SKILL.md for every agent

Expand Down Expand Up @@ -149,6 +153,10 @@ func Run(ctx context.Context, args []string, roots session.Roots, current map[st
if !ok {
return nil // several query matches: the listing printed is the answer
}
// The source is usually inferred, and this is the one command that
// spends money on the guess, so name it before the launch — early
// enough to interrupt while a large transcript is still being read.
announceFork(stderr, src, cmd.Into)
if cmd.Into != "" {
return forkInto(ctx, src, cmd, stdin, stdout, stderr)
}
Expand All @@ -159,9 +167,17 @@ func Run(ctx context.Context, args []string, roots session.Roots, current map[st
return installSkill(cmd.Target.Provider, skillDirs, skillMD, version, stdout)
}

// A listing with no agent named spans every agent: "where was I" is a
// cross-agent question by nature — the whole premise of catchup is that
// more than one agent works here — and the table's handles carry the agent
// name, so its rows stay selectable.
if cmd.Target.Provider == "" && cmd.List {
return listAcross(ctx, roots, cmd, cwd, stdout, stderr)
}

// With no agent named, the agent owning the newest session in cwd is the
// target; the normal locate below then re-selects within that provider, so
// --list, -q, and the trims all work against the detected agent.
// -q and the trims all work against the detected agent.
if cmd.Target.Provider == "" {
src, err := newestAcross(ctx, roots, cwd)
if err != nil {
Expand Down Expand Up @@ -209,6 +225,76 @@ func Run(ctx context.Context, args []string, roots session.Roots, current map[st
return render.Thread(stdout, thread, cmd.Format)
}

// listAcross renders one time-ordered table spanning every provider's cwd
// listing. Ranks stay per-agent — claude/2 selects the same session here as it
// does in catchup claude --list — so only row order is merged, never the
// numbering.
func listAcross(ctx context.Context, roots session.Roots, cmd Command, cwd string, stdout, stderr io.Writer) error {
opts := session.ListOptions{Query: cmd.Target.Query, Cwd: cwd, Limit: cmd.Limit}
var merged []session.Summary
for _, name := range providerNames() {
prov, _ := selectProvider(name) // providerNames is the closed set selectProvider switches on
sums, err := prov.List(ctx, roots, opts)
if err != nil {
// A store that cannot be read means missing rows, not a failed
// listing: say so and show what the other agents have.
fmt.Fprintf(stderr, "catchup: %s sessions omitted: %v\n", name, err)
continue
}
for _, s := range sums {
if s.Ref.Provider == "" {
s.Ref.Provider = name
}
merged = append(merged, s)
}
}
sort.SliceStable(merged, func(i, j int) bool { return merged[i].UpdatedAt.After(merged[j].UpdatedAt) })
if len(merged) > cmd.Limit {
merged = merged[:cmd.Limit]
}
// Nothing at all in this directory is a diagnosis, not an empty table:
// newestAcross's error names where each agent's sessions actually are. A
// query finding nothing is just a query finding nothing.
if len(merged) == 0 && cmd.Target.Query == "" {
if _, err := newestAcross(ctx, roots, cwd); err != nil {
return err
}
}
return render.List(stdout, "", merged, cmd.Format)
}

// announceFork names the session a fork is about to continue. The source is
// inferred whenever the agent is omitted, and fork is the one action that acts
// on the guess rather than printing it — with --into a wrong guess spends real
// tokens seeding the wrong transcript — so the guess is always shown, on
// stderr, before the launch.
func announceFork(stderr io.Writer, src session.Source, into string) {
line := "catchup: forking " + src.Ref.Provider
if into != "" {
line += " → " + into
}
var facts []string
if age := render.Age(src.UpdatedAt); age != "" {
facts = append(facts, age)
}
// Fields collapses a multi-line title; the announce is one line. A title
// that is just the directory name is a provider's fallback for a session
// its agent never named, and identifies nothing here — same as in a
// listing, where those rows show their opening message instead.
// Providers that title a session from its first user message can produce
// hundreds of characters — a pasted prompt, or catchup's own seed
// preamble when the session was itself seeded — so it is truncated by
// display width, like a table cell.
title := strings.Join(strings.Fields(src.Metadata["title"]), " ")
if title != "" && title != filepath.Base(src.Metadata["cwd"]) {
facts = append(facts, runewidth.Truncate(title, 60, "…"))
}
if len(facts) > 0 {
line += " (" + strings.Join(facts, ": ") + ")"
}
fmt.Fprintln(stderr, line)
}

// expandTilde resolves a leading ~ against this process's home. The shell
// normally does this, but --dir=~/x (the inline = form) and values quoted in
// scripts arrive with the tilde intact.
Expand Down Expand Up @@ -503,11 +589,18 @@ func execInto(ctx context.Context, name string, args []string, stdin io.Reader,

// forkInto is the cross-agent half of fork: it cannot transplant one agent's
// native state into another, so it renders the source session's transcript and
// launches the target agent with that transcript as its opening prompt. The
// same-agent case is rejected because the native fork is strictly better there.
// launches the target agent with that transcript as its opening prompt.
//
// Same-agent --into is the deliberate exception: seeding one agent from its own
// session is a reseed, not a resume — it sheds a bloated context and keeps the
// knowledge, which is what someone wants after finishing a task rather than
// after losing one. A trim is what distinguishes it from a mistyped native
// fork: --last and --since-compact are rejected on a native fork, so their
// presence can only mean "seed me a shorter version of this".
func forkInto(ctx context.Context, src session.Source, cmd Command, stdin io.Reader, stdout, stderr io.Writer) error {
if cmd.Into == src.Ref.Provider {
return fmt.Errorf("--into %s: the session is already %s's; use catchup fork %s for a native fork with full state", cmd.Into, cmd.Into, cmd.Into)
if cmd.Into == src.Ref.Provider && cmd.LastN == 0 && !cmd.SinceCompact {
return fmt.Errorf("--into %s: the session is already %s's; use catchup fork %s to resume it with full state, or add --since-compact/--last N to start a clean %s seeded with its transcript",
cmd.Into, cmd.Into, cmd.Into, cmd.Into)
}
if _, err := selectProvider(cmd.Into); err != nil {
return err
Expand Down
124 changes: 116 additions & 8 deletions internal/cli/cli_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -207,8 +207,9 @@ func TestRunCwdFiltering(t *testing.T) {
}
roots := session.Roots{Codex: root}

// With cwd: only the matching session appears.
out := runWithCwd(t, roots, "/home/u/src/catchup", "codex", "--list")
// With cwd: only the matching session appears. Asserted against --json,
// which is where session ids live; the table shows handles and titles.
out := runWithCwd(t, roots, "/home/u/src/catchup", "codex", "--list", "--json")
if !strings.Contains(out, "sess-catchup") {
t.Errorf("expected sess-catchup in cwd-filtered listing, got:\n%s", out)
}
Expand All @@ -217,7 +218,7 @@ func TestRunCwdFiltering(t *testing.T) {
}

// --dir substitutes another directory for the cwd.
out = runWithCwd(t, roots, "/somewhere/else", "codex", "--list", "--dir", "/home/u/src/other")
out = runWithCwd(t, roots, "/somewhere/else", "codex", "--list", "--json", "--dir", "/home/u/src/other")
if !strings.Contains(out, "sess-other") || strings.Contains(out, "sess-catchup") {
t.Errorf("--dir should select exactly the named directory, got:\n%s", out)
}
Expand Down Expand Up @@ -546,6 +547,85 @@ func TestRunInstallSkillAllProviders(t *testing.T) {
}
}

// runBoth is runWithCwd when stderr matters: the guesses, hints, and warnings
// that keep stdout a clean wire format all land there.
func runBoth(t *testing.T, roots session.Roots, cwd string, args ...string) (string, string) {
t.Helper()
var out, errOut bytes.Buffer
if err := Run(context.Background(), args, roots, nil, nil, nil, "test", cwd, nil, &out, &errOut); err != nil {
t.Fatalf("Run(%v) error: %v (stderr: %s)", args, err, errOut.String())
}
return out.String(), errOut.String()
}

// A bare listing spans every agent: "where was I" is a cross-agent question,
// and each row names the agent that owns it so the handles stay selectable.
func TestRunListAcrossAgents(t *testing.T) {
roots := codexRoot(t)
roots.Claude = claudeRoot(t).Claude

out, _ := runBoth(t, roots, "", "--list")
for _, want := range []string{"codex/1", "claude/1", "claude/2"} {
if !strings.Contains(out, want) {
t.Errorf("cross-agent listing missing %q:\n%s", want, out)
}
}

// Naming an agent keeps the listing scoped to it.
out, _ = runBoth(t, roots, "", "claude", "--list")
if strings.Contains(out, "codex/") {
t.Errorf("catchup claude --list should not list codex:\n%s", out)
}

// The JSON listing carries the agent per row too, so scripts can tell the
// merged rows apart.
out, _ = runBoth(t, roots, "", "--list", "--json")
for _, want := range []string{`"agent": "codex"`, `"agent": "claude"`} {
if !strings.Contains(out, want) {
t.Errorf("cross-agent JSON listing missing %s:\n%s", want, out)
}
}
}

// fork acts on an inferred source, so it says which one, before the launch.
func TestRunForkAnnouncesSource(t *testing.T) {
roots := codexRoot(t)

withForkRunner(t, func(ctx context.Context, src session.Source, model string, stdin io.Reader, stdout, stderr io.Writer) error {
return nil
})
_, errOut := runBoth(t, roots, "/home/u/src/proj", "fork")
if !strings.Contains(errOut, "catchup: forking codex") {
t.Errorf("native fork should name its source:\n%s", errOut)
}
if strings.Contains(errOut, "→") {
t.Errorf("native fork has no target agent to announce:\n%s", errOut)
}
// The fixture's cwd is /home/u/src/proj and its agent never titled the
// session, so codex falls back to "proj" — a name that identifies nothing.
if strings.Contains(errOut, "proj") {
t.Errorf("announce should drop a directory-name title:\n%s", errOut)
}

// Providers that title a session from its first user message produce
// titles of any length: a real cline session was titled with catchup's
// own 100-character seed preamble. The announce stays one line.
long := session.Source{Ref: session.Ref{Provider: "codex"}, Metadata: map[string]string{"title": strings.Repeat("long ", 40)}}
var b bytes.Buffer
announceFork(&b, long, "claude")
if got := len([]rune(b.String())); got > 100 {
t.Errorf("announce line is %d runes, want one line:\n%s", got, b.String())
}

withIntoRunner(t, func(ctx context.Context, name string, args []string, stdin io.Reader, stdout, stderr io.Writer) error {
return nil
})
_, errOut = runBoth(t, roots, "/home/u/src/proj", "fork", "--into", "claude")
if !strings.Contains(errOut, "catchup: forking codex → claude") {
t.Errorf("--into should announce both ends:\n%s", errOut)
}
}

func withForkRunner(t *testing.T, runner forkRunner) {
t.Helper()
old := runFork
Expand Down Expand Up @@ -584,17 +664,45 @@ func TestRunForkInto(t *testing.T) {
}
}

// A bare same-agent --into is far more likely a mistyped native fork, so it is
// refused — but a trimmed one is a deliberate reseed: shed the context, keep
// the knowledge.
func TestRunForkIntoSameAgent(t *testing.T) {
roots := codexRoot(t)
withIntoRunner(t, func(ctx context.Context, name string, args []string, stdin io.Reader, stdout, stderr io.Writer) error {
t.Fatal("runner should not be called for a same-agent --into")
t.Fatal("runner should not be called for a bare same-agent --into")
return nil
})

var out, errOut bytes.Buffer
err := Run(context.Background(), []string{"fork", "codex", "--into", "codex"}, roots, nil, nil, nil, "test", "/home/u/src/proj", nil, &out, &errOut)
if err == nil || !strings.Contains(err.Error(), "native fork") {
t.Fatalf("want same-agent rejection pointing at native fork, got %v", err)
if err == nil || !strings.Contains(err.Error(), "resume it with full state") {
t.Fatalf("want same-agent rejection pointing at the native fork, got %v", err)
}
if !strings.Contains(err.Error(), "--since-compact") {
t.Errorf("rejection should name the reseed it was probably meant to be: %v", err)
}
}

func TestRunForkIntoSameAgentReseed(t *testing.T) {
roots := codexRoot(t)
for _, trim := range [][]string{{"--since-compact"}, {"--last", "5"}} {
var gotArgs []string
withIntoRunner(t, func(ctx context.Context, name string, args []string, stdin io.Reader, stdout, stderr io.Writer) error {
gotArgs = args
return nil
})
var out, errOut bytes.Buffer
args := append([]string{"fork", "codex", "--into", "codex"}, trim...)
if err := Run(context.Background(), args, roots, nil, nil, nil, "test", "/home/u/src/proj", nil, &out, &errOut); err != nil {
t.Fatalf("fork codex --into codex %v: %v", trim, err)
}
if len(gotArgs) == 0 {
t.Fatalf("fork codex --into codex %v seeded nothing", trim)
}
if !strings.Contains(gotArgs[len(gotArgs)-1], "prior codex session") {
t.Errorf("reseed prompt missing the transcript preamble: %q", gotArgs[len(gotArgs)-1])
}
}
}

Expand Down Expand Up @@ -699,9 +807,9 @@ func TestRunForkSelectors(t *testing.T) {
if forked {
t.Error("ambiguous fork query must not fork")
}
for _, want := range []string{"sess-auth", "sess-parser"} {
for _, want := range []string{"fix the auth flow", "refactor the parser pipeline"} {
if !strings.Contains(out, want) {
t.Errorf("ambiguous fork listing missing %s:\n%s", want, out)
t.Errorf("ambiguous fork listing missing %q:\n%s", want, out)
}
}
if !strings.Contains(errOut, "rerun as catchup fork codex/<rank>") {
Expand Down
Loading
Loading