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
22 changes: 22 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,28 @@ Amp reads local JSON caches in `$XDG_DATA_HOME/amp/threads` (default `~/.local/s
Set `AMP_DATA_HOME` to override the Amp data directory. Cloud-only threads are unavailable until cached locally.
Amp tool results and reasoning are omitted. Native Amp launch and resume are unsupported.

### Devin web sessions

Create an **organization-scoped service-user API key** in Devin's **Settings > Devin API**.
The key needs read access to the session. Set `DEVIN_API_KEY`, then paste the session URL:

```bash
export DEVIN_API_KEY='your-api-key'
catchup fork --into codex --from 'https://app.devin.ai/sessions/YOUR_SESSION_ID'
```

Enterprise URLs such as `https://your-team.devinenterprise.com/sessions/YOUR_SESSION_ID`
also work. Catchup discovers the organization from the key and imports the conversation
and failed shell commands. It sends the key only to `api.devin.ai` and does not store it.
Personal access tokens and enterprise-scoped keys need explicit organization selection,
which this importer does not implement.

The importer uses the `v3beta1` event endpoint behind [Devin MCP](https://docs.devin.ai/work-with-devin/devin-mcp).
That endpoint is absent from the public v3 OpenAPI specification and may change.
Shell output can be truncated; warnings also identify event fields truncated by the API.
Attachments, non-shell tool failures, and compaction history are unavailable.
Devin URL imports use `fork --from`; local discovery, search, and native Devin resume do not apply.

## Boundaries

- One agent at a time. It does not merge histories.
Expand Down
20 changes: 15 additions & 5 deletions internal/cli/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import (
"github.com/wilbeibi/catchup/internal/copilot"
"github.com/wilbeibi/catchup/internal/cursor"
"github.com/wilbeibi/catchup/internal/deepseek"
"github.com/wilbeibi/catchup/internal/devin"
"github.com/wilbeibi/catchup/internal/grok"
"github.com/wilbeibi/catchup/internal/grokbot"
"github.com/wilbeibi/catchup/internal/kimi"
Expand Down Expand Up @@ -74,6 +75,7 @@ HAND OFF — continue the work
--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
Devin session URLs require DEVIN_API_KEY (org service key)
--model <name> launch it on a specific model, e.g. a cheaper one
(the target agent's own model name, passed as-is)

Expand Down Expand Up @@ -755,9 +757,9 @@ func forkInto(ctx context.Context, src session.Source, cmd Command, launchDir st

// forkFrom is the artifact half of fork: it seeds the --into
// agent from a document that did not come from a provider store — a file,
// stdin, or a plain-GET URL. The artifact is seeded verbatim (any text
// document works, not only catchup output), so no Thread is built and the
// trims cannot apply; parse.go rejects those combinations. Same-agent --into
// stdin, or a URL. Devin URLs are exported through its API before seeding;
// other artifacts are seeded verbatim. The trims cannot apply to arbitrary
// documents; parse.go rejects those combinations. Same-agent --into
// is allowed by construction: no native state stands behind an artifact, so
// the seed is the best continuation there is.
func forkFrom(ctx context.Context, cmd Command, launchDir string, stdin io.Reader, stdout, stderr io.Writer) error {
Expand Down Expand Up @@ -821,8 +823,7 @@ func fromLabel(from string) string {
return from
}

// readArtifact fetches the --from artifact's bytes. The spelling was
// validated at parse time; the three shapes just dispatch here.
// readArtifact loads --from bytes, rendering Devin's API events as a transcript.
func readArtifact(ctx context.Context, from string, stdin io.Reader) ([]byte, error) {
switch {
case from == "-":
Expand All @@ -835,6 +836,15 @@ func readArtifact(ctx context.Context, from string, stdin io.Reader) ([]byte, er
}
return b, nil
case isHTTPURL(from):
if devin.IsURL(from) {
thread, err := devin.Read(ctx, from, os.Getenv("DEVIN_API_KEY"), artifactClient)
if err != nil {
return nil, err
}
var buf bytes.Buffer
err = render.Thread(&buf, thread, session.FormatAgent)
return buf.Bytes(), err
}
return fetchArtifact(ctx, from)
default:
b, err := os.ReadFile(expandTilde(from))
Expand Down
151 changes: 151 additions & 0 deletions internal/cli/cli_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"io"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"regexp"
Expand Down Expand Up @@ -1429,7 +1430,11 @@ func TestRunForkFromStdinNoTTY(t *testing.T) {
}

func TestRunForkFromURL(t *testing.T) {
t.Setenv("DEVIN_API_KEY", "unrelated-secret")
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") != "Basic bGFiZWwtdXNlcjpsYWJlbC1wYXNzd29yZA==" {
t.Error("generic URL authorization changed")
}
fmt.Fprint(w, "remote transcript body")
}))
defer srv.Close()
Expand Down Expand Up @@ -1479,6 +1484,152 @@ func TestRunForkFromURL(t *testing.T) {
}
}

type devinTransport func(*http.Request) (*http.Response, error)

func (f devinTransport) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) }

func TestRunForkFromDevin(t *testing.T) {
// Selected real events from Devin MCP 2.14.3's v3beta1 HTTP endpoint,
// observed 2026-10-15. IDs/text are sanitized; order and wire keys remain.
// Padding, duplicate completion and truncation markers exercise boundaries.
fixture, err := os.ReadFile("../devin/testdata/events.jsonl")
if err != nil {
t.Fatal(err)
}
rows := bytes.Split(bytes.TrimSpace(fixture), []byte("\n"))
first := make([]json.RawMessage, 0, 50)
for _, row := range rows[:11] {
first = append(first, json.RawMessage(row))
}
for len(first) < 50 {
first = append(first, json.RawMessage(rows[2])) // ignored reasoning events
}
last := []json.RawMessage{rows[11], bytes.ReplaceAll(rows[11], []byte("shell_process_completed"), []byte("shell_process_completed_background")), rows[12]}
last[2] = bytes.Replace(last[2], []byte(`"truncated_fields":[]`), []byte(`"truncated_fields":["message"]`), 1)
for _, tc := range []struct {
name, from, key, fault, wantErr string
}{
{"app", "https://app.devin.ai/sessions/abc?token=url-secret#fragment-secret", "test-key", "", ""},
{"enterprise", "https://team.devinenterprise.com/sessions/devin-abc/", "test-key", "", ""},
{"attachment", "https://app.devin.ai/attachments/note.txt", "test-key", "artifact", ""},
{"missing key", "https://app.devin.ai/sessions/abc", "", "", "DEVIN_API_KEY"},
{"wrong path", "https://app.devin.ai/sessions/abc/extra", "test-key", "", "session URL"},
{"http", "http://app.devin.ai/sessions/abc", "test-key", "", "HTTPS"},
{"unauthorized", "https://app.devin.ai/sessions/abc", "test-key", "auth", "HTTP 401"},
{"unscoped key", "https://app.devin.ai/sessions/abc", "test-key", "org", "organization-scoped"},
{"redirect", "https://app.devin.ai/sessions/abc", "test-key", "redirect", "HTTP 302"},
{"second page fails", "https://app.devin.ai/sessions/abc", "test-key", "page", "HTTP 403"},
{"schema drift", "https://app.devin.ai/sessions/abc", "test-key", "schema", "no items"},
{"invalid JSON", "https://app.devin.ai/sessions/abc", "test-key", "json", "decode API response"},
{"empty session", "https://app.devin.ai/sessions/abc", "test-key", "empty", "no conversation"},
} {
t.Run(tc.name, func(t *testing.T) {
t.Setenv("DEVIN_API_KEY", tc.key)
requests := 0
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requests++
if tc.fault == "artifact" {
if r.Header.Get("Authorization") != "" {
t.Error("API key sent to attachment URL")
}
fmt.Fprint(w, "remote transcript body")
return
}
if r.Method != "GET" || r.Header.Get("Authorization") != "Bearer test-key" {
t.Errorf("unexpected method or authorization: %s", r.Method)
}
if tc.fault == "auth" {
http.Error(w, "test-key must not reach error text", 401)
return
}
if tc.fault == "redirect" {
http.Redirect(w, r, "https://elsewhere.invalid/leak", 302)
return
}
if r.URL.Path == "/v3/self" {
if tc.fault == "org" {
fmt.Fprint(w, `{}`)
} else {
fmt.Fprint(w, `{"org_id":"org-example"}`)
}
return
}
if r.URL.Path != "/v3beta1/organizations/org-example/sessions/devin-abc/events/details" || r.URL.Query().Get("limit") != "50" || r.URL.Query().Get("max_content_length") != "100000" {
t.Errorf("unexpected API request: %s", r.URL)
}
switch tc.fault {
case "schema":
fmt.Fprint(w, `{}`)
return
case "json":
fmt.Fprint(w, `{`)
return
case "empty":
fmt.Fprint(w, `{"items":[]}`)
return
}
page := first
switch r.URL.Query().Get("offset") {
case "0":
case "50":
if tc.fault == "page" {
http.Error(w, "forbidden", 403)
return
}
page = last
default:
t.Error("incorrect pagination offset")
page = nil
}
json.NewEncoder(w).Encode(map[string]any{"items": page})
}))
defer srv.Close()
base, _ := url.Parse(srv.URL)
old := artifactClient
t.Cleanup(func() { artifactClient = old })
artifactClient = &http.Client{Transport: devinTransport(func(r *http.Request) (*http.Response, error) {
if r.URL.Host != "api.devin.ai" && !(tc.fault == "artifact" && r.URL.Host == "app.devin.ai") {
t.Errorf("credential sent to unexpected host: %s", r.URL.Host)
}
r.URL.Scheme, r.URL.Host = base.Scheme, base.Host
return srv.Client().Transport.RoundTrip(r)
})}
name, prompt, _, err := forkFromRun(t, nil, "fork", "--into", "codex", "--from", tc.from)
if tc.fault == "artifact" {
if err != nil || name != "codex" || requests != 1 || !strings.Contains(prompt, "remote transcript body") {
t.Fatalf("attachment import changed: agent=%q requests=%d err=%v", name, requests, err)
}
return
}
if tc.wantErr != "" {
if err == nil || !strings.Contains(err.Error(), tc.wantErr) || name != "" || strings.Contains(err.Error(), "test-key") {
t.Fatalf("want %q without launch or credential leak, got agent=%q err=%v", tc.wantErr, name, err)
}
return
}
if err != nil || name != "codex" || requests != 3 {
t.Fatalf("agent=%q requests=%d err=%v", name, requests, err)
}
previous := -1
for _, text := range []string{"Check the cache.", "I will inspect it.", "Keep the public API unchanged.", "I will run the tests.", "go test ./...", "FAIL: configuration missing", "The tests need a configuration fix."} {
index := strings.Index(prompt, text)
if index <= previous {
t.Fatalf("missing or reordered %q in transcript", text)
}
previous = index
}
if strings.Count(prompt, "failure: shell") != 1 || !strings.Contains(prompt, "Devin truncated devin_message fields: message") || !strings.Contains(prompt, "compaction history") {
t.Fatalf("missing failure deduplication or loss warnings: %s", prompt)
}
for _, excluded := range []string{"test-key", "url-secret", "fragment-secret", "private reasoning", "successful output"} {
if strings.Contains(prompt, excluded) {
t.Errorf("unexpected %q in transcript", excluded)
}
}
})
}
}

func TestRunForkFromEmptyArtifact(t *testing.T) {
path := filepath.Join(t.TempDir(), "empty.md")
if err := os.WriteFile(path, []byte(" \n\t\n"), 0o644); err != nil {
Expand Down
Loading
Loading