From 261b8a5769b67f0203a1f34c3f0f4fe5e2b3a0d9 Mon Sep 17 00:00:00 2001 From: Christian Bush Date: Sun, 5 Apr 2026 20:36:16 -0500 Subject: [PATCH 1/3] fix: restore cursor session resume paths - fix: disambiguate cursor workspace paths - fix: cache cursor resume path resolution - fix: disambiguate cursor workspace lookup lazily - refactor: share cursor resume path resolution - fix: preserve Cursor resume cwd in fallbacks - test: make resume tests portable on Windows - fix: preserve Cursor macOS resume cwd - fix: revert Ghostty to direct CLI, skip guess on ambiguous Cursor paths - fix: prefer Ghostty CLI over app bundle, reject stale hints - fix: normalize paths before hint validation in Cursor resolver --- .../layout/SessionBreadcrumb.svelte | 10 +- frontend/src/lib/utils/keyboard.ts | 14 +- frontend/src/lib/utils/resume.test.ts | 59 +- frontend/src/lib/utils/resume.ts | 31 +- internal/parser/cursor_paths.go | 36 + internal/parser/discovery_test.go | 69 ++ internal/server/cursor_dir.go | 290 ++++++ internal/server/ghostty_test.go | 115 +++ internal/server/openers.go | 5 +- internal/server/resume.go | 337 +++++-- internal/server/resume_handler_test.go | 154 +++- internal/server/resume_test.go | 831 +++++++++++++++++- internal/sync/engine.go | 38 +- internal/sync/engine_integration_test.go | 78 ++ 14 files changed, 1954 insertions(+), 113 deletions(-) create mode 100644 internal/parser/cursor_paths.go create mode 100644 internal/server/cursor_dir.go create mode 100644 internal/server/ghostty_test.go diff --git a/frontend/src/lib/components/layout/SessionBreadcrumb.svelte b/frontend/src/lib/components/layout/SessionBreadcrumb.svelte index cd057b2b..4b3256b0 100644 --- a/frontend/src/lib/components/layout/SessionBreadcrumb.svelte +++ b/frontend/src/lib/components/layout/SessionBreadcrumb.svelte @@ -16,6 +16,7 @@ import { supportsResume, buildResumeCommand, + formatResumeResponseCommand, } from "../../utils/resume.js"; import { inSessionSearch } from "../../stores/inSessionSearch.svelte.js"; @@ -195,7 +196,8 @@ } // Launch failed — fall back to clipboard copy. if (resp.command) { - const ok = await copyToClipboard(resp.command); + const cmd = formatResumeResponseCommand(session.agent, resp); + const ok = cmd ? await copyToClipboard(cmd) : false; showFeedback(ok ? "Command copied!" : "Failed"); return; } @@ -217,7 +219,8 @@ try { const resp = await resumeSession(session.id, { command_only: true }); if (resp.command) { - const ok = await copyToClipboard(resp.command); + const cmd = formatResumeResponseCommand(session.agent, resp); + const ok = cmd ? await copyToClipboard(cmd) : false; showFeedback(ok ? "Command copied!" : "Failed"); return; } @@ -266,7 +269,8 @@ return; } if (resp.command) { - const ok = await copyToClipboard(resp.command); + const cmd = formatResumeResponseCommand(session.agent, resp); + const ok = cmd ? await copyToClipboard(cmd) : false; showFeedback(ok ? "Command copied!" : "Failed"); return; } diff --git a/frontend/src/lib/utils/keyboard.ts b/frontend/src/lib/utils/keyboard.ts index bafc6ec9..67866db7 100644 --- a/frontend/src/lib/utils/keyboard.ts +++ b/frontend/src/lib/utils/keyboard.ts @@ -8,7 +8,11 @@ import { getExportUrl, resumeSession, } from "../api/client.js"; -import { supportsResume, buildResumeCommand } from "./resume.js"; +import { + supportsResume, + buildResumeCommand, + formatResumeResponseCommand, +} from "./resume.js"; import { copyToClipboard } from "./clipboard.js"; function isInputFocused(): boolean { @@ -179,10 +183,12 @@ export function registerShortcuts( c: () => { const session = sessions.activeSession; if (session && supportsResume(session.agent)) { - // Copy resume command to clipboard. Use backend-built command - // (includes cd to project dir) with local fallback. + // Copy a runnable resume command. Cursor needs the backend cwd + // applied client-side so the copied command is self-contained. resumeSession(session.id, { command_only: true }).then((resp) => { - const cmd = resp.command || buildResumeCommand( + const cmd = formatResumeResponseCommand( + session.agent, resp, + ) || buildResumeCommand( session.agent, session.id, ); diff --git a/frontend/src/lib/utils/resume.test.ts b/frontend/src/lib/utils/resume.test.ts index 155811c7..bdb695c7 100644 --- a/frontend/src/lib/utils/resume.test.ts +++ b/frontend/src/lib/utils/resume.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect } from "vitest"; import { buildResumeCommand, + formatResumeResponseCommand, supportsResume, } from "./resume.js"; @@ -9,13 +10,13 @@ describe("supportsResume", () => { expect(supportsResume("claude")).toBe(true); expect(supportsResume("codex")).toBe(true); expect(supportsResume("copilot")).toBe(true); + expect(supportsResume("cursor")).toBe(true); expect(supportsResume("gemini")).toBe(true); expect(supportsResume("opencode")).toBe(true); expect(supportsResume("amp")).toBe(true); }); it("returns false for unsupported agents", () => { - expect(supportsResume("cursor")).toBe(false); expect(supportsResume("vscode-copilot")).toBe(false); expect(supportsResume("unknown")).toBe(false); }); @@ -46,6 +47,12 @@ describe("buildResumeCommand", () => { ).toBe("gemini --resume sess-2"); }); + it("generates cursor resume command", () => { + expect( + buildResumeCommand("cursor", "cursor:chat-7"), + ).toBe("cursor agent --resume chat-7"); + }); + it("generates opencode resume command", () => { expect( buildResumeCommand("opencode", "opencode:s3"), @@ -71,7 +78,6 @@ describe("buildResumeCommand", () => { }); it("returns null for unsupported agents", () => { - expect(buildResumeCommand("cursor", "id")).toBeNull(); expect(buildResumeCommand("unknown", "id")).toBeNull(); }); @@ -171,3 +177,52 @@ describe("buildResumeCommand", () => { expect(cmd).toBe("amp --resume '$HOME/evil'"); }); }); + +describe("formatResumeResponseCommand", () => { + it("keeps non-cursor backend commands unchanged", () => { + expect( + formatResumeResponseCommand("claude", { + command: "claude --resume sess-1", + cwd: "/tmp/project", + }), + ).toBe("claude --resume sess-1"); + }); + + it("prepends cwd for cursor clipboard copy", () => { + expect( + formatResumeResponseCommand("cursor", { + command: "cursor agent --resume chat-7 --workspace '/tmp/project'", + cwd: "/tmp/project/frontend", + }), + ).toBe( + "cd '/tmp/project/frontend' && " + + "cursor agent --resume chat-7 --workspace '/tmp/project'", + ); + }); + + it("quotes cursor cwd when needed", () => { + expect( + formatResumeResponseCommand("cursor", { + command: "cursor agent --resume chat-7 --workspace '/tmp/project dir'", + cwd: "/tmp/project dir/frontend", + }), + ).toBe( + "cd '/tmp/project dir/frontend' && " + + "cursor agent --resume chat-7 --workspace '/tmp/project dir'", + ); + }); + + it("returns bare cursor command when cwd is unavailable", () => { + expect( + formatResumeResponseCommand("cursor", { + command: "cursor agent --resume chat-7", + }), + ).toBe("cursor agent --resume chat-7"); + }); + + it("returns null for missing backend command", () => { + expect( + formatResumeResponseCommand("cursor", null), + ).toBeNull(); + }); +}); diff --git a/frontend/src/lib/utils/resume.ts b/frontend/src/lib/utils/resume.ts index 7acc151a..26ff3486 100644 --- a/frontend/src/lib/utils/resume.ts +++ b/frontend/src/lib/utils/resume.ts @@ -9,6 +9,8 @@ RESUME_AGENTS["codex"] = (id) => `codex resume ${shellQuote(id)}`; RESUME_AGENTS["copilot"] = (id) => `copilot --resume=${shellQuote(id)}`; +RESUME_AGENTS["cursor"] = (id) => + `cursor agent --resume ${shellQuote(id)}`; RESUME_AGENTS["gemini"] = (id) => `gemini --resume ${shellQuote(id)}`; RESUME_AGENTS["opencode"] = (id) => @@ -23,6 +25,12 @@ export interface ClaudeResumeFlags { print?: boolean; } +/** Minimal shape of a backend resume response used for clipboard copy. */ +export interface ResumeCommandResponse { + command: string; + cwd?: string; +} + /** * POSIX-safe shell quoting using single quotes. * Any embedded single quotes are escaped as '"'"'. @@ -33,6 +41,11 @@ function shellQuote(s: string): string { return "'" + s.replace(/'/g, "'\"'\"'") + "'"; } +function commandWithCwd(cmd: string, cwd?: string): string { + if (!cwd) return cmd; + return `cd ${shellQuote(cwd)} && ${cmd}`; +} + /** * Strip the agent-type prefix from a compound session ID, but only * when the prefix matches a known agent. Raw IDs that happen to @@ -56,7 +69,7 @@ export function supportsResume(agent: string): boolean { /** * Build a CLI command to resume the given session in a terminal. * - * @param agent - The agent type (e.g. "claude", "codex", "gemini") + * @param agent - The agent type (e.g. "claude", "codex", "cursor") * @param sessionId - The session ID (may include agent prefix) * @param flags - Optional Claude-specific resume flags * @returns The shell command string, or null if the agent is not supported @@ -81,3 +94,19 @@ export function buildResumeCommand( return cmd; } + +/** + * Format a backend-built resume response for clipboard copy. + * + * Cursor keeps `command` and `cwd` separate in the API so callers can + * choose whether to apply the cwd directly. Clipboard copy needs a + * runnable one-liner, so rebuild it here only for Cursor. + */ +export function formatResumeResponseCommand( + agent: string, + response: ResumeCommandResponse | null | undefined, +): string | null { + if (!response?.command) return null; + if (agent !== "cursor") return response.command; + return commandWithCwd(response.command, response.cwd); +} diff --git a/internal/parser/cursor_paths.go b/internal/parser/cursor_paths.go new file mode 100644 index 00000000..11bf9f56 --- /dev/null +++ b/internal/parser/cursor_paths.go @@ -0,0 +1,36 @@ +package parser + +import ( + "path/filepath" + "strings" +) + +// ParseCursorTranscriptRelPath validates a path relative to a +// Cursor projects dir and returns the encoded project directory +// name for recognized transcript layouts. +func ParseCursorTranscriptRelPath(rel string) (string, bool) { + rel = filepath.Clean(rel) + parts := strings.Split(rel, string(filepath.Separator)) + if len(parts) < 3 || parts[1] != "agent-transcripts" { + return "", false + } + + switch len(parts) { + case 3: + if !IsCursorTranscriptExt(parts[2]) { + return "", false + } + return parts[0], true + case 4: + if !IsCursorTranscriptExt(parts[3]) { + return "", false + } + stem := strings.TrimSuffix(parts[3], filepath.Ext(parts[3])) + if stem != parts[2] { + return "", false + } + return parts[0], true + default: + return "", false + } +} diff --git a/internal/parser/discovery_test.go b/internal/parser/discovery_test.go index 0de2984e..4ada6eba 100644 --- a/internal/parser/discovery_test.go +++ b/internal/parser/discovery_test.go @@ -1295,6 +1295,75 @@ func TestDiscoverCursorSessions_DedupPrefersJsonl(t *testing.T) { } } +func TestParseCursorTranscriptRelPath(t *testing.T) { + tests := []struct { + name string + rel string + wantProject string + wantOK bool + }{ + { + name: "flat txt", + rel: filepath.Join("proj-dir", "agent-transcripts", "sess.txt"), + wantProject: "proj-dir", + wantOK: true, + }, + { + name: "flat jsonl", + rel: filepath.Join("proj-dir", "agent-transcripts", "sess.jsonl"), + wantProject: "proj-dir", + wantOK: true, + }, + { + name: "nested jsonl", + rel: filepath.Join("proj-dir", "agent-transcripts", "sess", "sess.jsonl"), + wantProject: "proj-dir", + wantOK: true, + }, + { + name: "nested txt", + rel: filepath.Join("proj-dir", "agent-transcripts", "sess", "sess.txt"), + wantProject: "proj-dir", + wantOK: true, + }, + { + name: "nested mismatched filename", + rel: filepath.Join("proj-dir", "agent-transcripts", "sess", "other.jsonl"), + wantOK: false, + }, + { + name: "nested auxiliary file", + rel: filepath.Join("proj-dir", "agent-transcripts", "sess", "notes.txt"), + wantOK: false, + }, + { + name: "subagent file ignored", + rel: filepath.Join("proj-dir", "agent-transcripts", "sess", "subagents", "child.jsonl"), + wantOK: false, + }, + { + name: "wrong extension", + rel: filepath.Join("proj-dir", "agent-transcripts", "sess.json"), + wantOK: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotProject, gotOK := ParseCursorTranscriptRelPath(tt.rel) + if gotOK != tt.wantOK { + t.Fatalf("ok = %v, want %v", gotOK, tt.wantOK) + } + if gotProject != tt.wantProject { + t.Errorf( + "project = %q, want %q", + gotProject, tt.wantProject, + ) + } + }) + } +} + func TestFindCursorSourceFile(t *testing.T) { cursorTranscripts := filepath.Join( "proj-dir", "agent-transcripts", diff --git a/internal/server/cursor_dir.go b/internal/server/cursor_dir.go new file mode 100644 index 00000000..31ad1742 --- /dev/null +++ b/internal/server/cursor_dir.go @@ -0,0 +1,290 @@ +package server + +import ( + "os" + "path/filepath" + "runtime" + "sort" + "strings" +) + +// resolveCursorProjectDirFromSessionFile derives the real workspace +// directory for a Cursor session from the stored transcript path. +// The bool reports whether multiple matching paths exist on disk. +func resolveCursorProjectDirFromSessionFile( + filePath string, +) (string, bool) { + projectDir := cursorProjectDirNameFromTranscriptPath(filePath) + if projectDir == "" { + return "", false + } + return resolveCursorProjectDirName(projectDir) +} + +// resolveCursorProjectDirFromSessionFileHint derives the real workspace +// directory for a Cursor session from the stored transcript path, +// preferring candidates that contain the provided hint. +func resolveCursorProjectDirFromSessionFileHint( + filePath, hint string, +) string { + projectDir := cursorProjectDirNameFromTranscriptPath(filePath) + if projectDir == "" { + return "" + } + return resolveCursorProjectDirNameHint(projectDir, hint) +} + +// cursorProjectDirNameFromTranscriptPath extracts the encoded Cursor +// project directory name from either flat or nested transcript paths. +func cursorProjectDirNameFromTranscriptPath(path string) string { + path = filepath.Clean(path) + dir := filepath.Dir(path) + for { + base := filepath.Base(dir) + if base == "." || base == string(filepath.Separator) { + return "" + } + if base == "agent-transcripts" { + parent := filepath.Dir(dir) + name := filepath.Base(parent) + if name == "." || name == string(filepath.Separator) { + return "" + } + return name + } + next := filepath.Dir(dir) + if next == dir { + return "" + } + dir = next + } +} + +// resolveCursorProjectDirName derives a real workspace path from a +// Cursor-encoded directory name. The bool reports whether more than +// one matching path exists on disk. +func resolveCursorProjectDirName(dirName string) (string, bool) { + matches := resolveCursorProjectDirNameMatches(dirName, "", 2) + switch len(matches) { + case 0: + return "", false + case 1: + return matches[0], false + default: + return matches[0], true + } +} + +// resolveCursorProjectDirNameHint derives a real workspace path from a +// Cursor-encoded directory name, preferring candidates that contain the +// provided hint. +func resolveCursorProjectDirNameHint(dirName, hint string) string { + matches := resolveCursorProjectDirNameMatches(dirName, hint, 1) + if len(matches) == 0 { + return "" + } + nh := normalizeCursorDir(hint) + if nh != "" && !cursorPathContainsHint( + normalizeCursorDir(matches[0]), nh, + ) { + return "" + } + return matches[0] +} + +func resolveCursorProjectDirNameMatches( + dirName, hint string, limit int, +) []string { + dirName = strings.TrimSpace(dirName) + if dirName == "" { + return nil + } + hint = normalizeCursorDir(hint) + + if runtime.GOOS == "windows" { + return resolveCursorProjectDirNameFromRootMatches( + "", dirName, hint, limit, + ) + } + return resolveCursorProjectDirNameFromRootMatches( + string(filepath.Separator), dirName, hint, limit, + ) +} + +// resolveCursorProjectDirNameFromRoot reconstructs a real path from a +// Cursor-encoded project directory name by walking an existing +// filesystem tree and matching each component against the encoded token +// stream. The root parameter is mainly for tests; empty means use the +// OS default root. +func resolveCursorProjectDirNameFromRoot( + root, dirName string, +) string { + return resolveCursorProjectDirNameFromRootHint(root, dirName, "") +} + +// resolveCursorProjectDirNameFromRootHint reconstructs a real path from +// a Cursor-encoded project directory name. It backtracks across matching +// path components instead of committing to the first greedy match, and +// prefers candidates that contain the latest transcript cwd when one is +// available. +func resolveCursorProjectDirNameFromRootHint( + root, dirName, hint string, +) string { + matches := resolveCursorProjectDirNameFromRootMatches( + root, dirName, hint, 1, + ) + if len(matches) == 0 { + return "" + } + nh := normalizeCursorDir(hint) + if nh != "" && !cursorPathContainsHint( + normalizeCursorDir(matches[0]), nh, + ) { + return "" + } + return matches[0] +} + +func resolveCursorProjectDirNameFromRootMatches( + root, dirName, hint string, limit int, +) []string { + tokens := cursorEncodedTokens(dirName) + if len(tokens) == 0 { + return nil + } + + current := root + if runtime.GOOS == "windows" { + if len(tokens[0]) == 1 { + drive := tokens[0][0] + if (drive >= 'A' && drive <= 'Z') || + (drive >= 'a' && drive <= 'z') { + current = strings.ToUpper(tokens[0]) + ":" + + string(filepath.Separator) + tokens = tokens[1:] + } + } + } + if current == "" { + current = string(filepath.Separator) + } + if !isDir(current) { + return nil + } + if len(tokens) == 0 { + return []string{current} + } + + var matches []string + collectCursorPathMatches( + current, tokens, hint, limit, &matches, + ) + return matches +} + +type cursorPathMatch struct { + name string + path string + consumed int + hinted bool +} + +func collectCursorPathMatches( + dir string, tokens []string, hint string, limit int, + matches *[]string, +) { + if limit > 0 && len(*matches) >= limit { + return + } + if len(tokens) == 0 { + if isDir(dir) { + *matches = append(*matches, dir) + } + return + } + + for _, match := range matchCursorPathComponents(dir, tokens, hint) { + collectCursorPathMatches( + match.path, tokens[match.consumed:], hint, + limit, matches, + ) + if limit > 0 && len(*matches) >= limit { + return + } + } +} + +func matchCursorPathComponents( + dir string, tokens []string, hint string, +) []cursorPathMatch { + entries, err := os.ReadDir(dir) + if err != nil { + return nil + } + + matches := make([]cursorPathMatch, 0, len(entries)) + for _, entry := range entries { + fullPath := filepath.Join(dir, entry.Name()) + if !isDir(fullPath) { + continue + } + candidate := cursorComponentTokens(entry.Name()) + if len(candidate) == 0 || len(candidate) > len(tokens) { + continue + } + if !cursorTokenPrefixMatch(tokens, candidate) { + continue + } + matches = append(matches, cursorPathMatch{ + name: entry.Name(), + path: fullPath, + consumed: len(candidate), + hinted: cursorPathContainsHint(fullPath, hint), + }) + } + + sort.Slice(matches, func(i, j int) bool { + if matches[i].hinted != matches[j].hinted { + return matches[i].hinted + } + if matches[i].consumed != matches[j].consumed { + return matches[i].consumed > matches[j].consumed + } + return matches[i].name < matches[j].name + }) + return matches +} + +func cursorPathContainsHint(path, hint string) bool { + if path == "" || hint == "" { + return false + } + rel, err := filepath.Rel(path, hint) + if err != nil { + return false + } + return rel == "." || + (rel != ".." && + !strings.HasPrefix(rel, ".."+string(filepath.Separator))) +} + +func cursorTokenPrefixMatch(tokens, candidate []string) bool { + for i := range candidate { + if tokens[i] != candidate[i] { + return false + } + } + return true +} + +func cursorEncodedTokens(s string) []string { + return strings.FieldsFunc(s, func(r rune) bool { + return r == '-' + }) +} + +func cursorComponentTokens(s string) []string { + return strings.FieldsFunc(s, func(r rune) bool { + return r == '-' || r == '.' || r == '_' + }) +} diff --git a/internal/server/ghostty_test.go b/internal/server/ghostty_test.go new file mode 100644 index 00000000..c49ea86b --- /dev/null +++ b/internal/server/ghostty_test.go @@ -0,0 +1,115 @@ +package server + +import ( + "runtime" + "slices" + "strings" + "testing" +) + +func TestLaunchResumeDarwinGhosttyDirectCli(t *testing.T) { + cwd := t.TempDir() + proc := launchResumeDarwin( + Opener{ + ID: "ghostty", + Name: "Ghostty", + Kind: "terminal", + Bin: "/usr/local/bin/ghostty", + }, + "cursor agent --resume chat-1", + cwd, + ) + if proc == nil { + t.Fatal("launchResumeDarwin returned nil") + } + if strings.HasSuffix(proc.Args[0], "osascript") { + t.Fatalf("expected direct CLI, got osascript: %v", + proc.Args) + } + wantWD := "--working-directory=" + cwd + if !sliceContains(proc.Args, wantWD) { + t.Fatalf("missing %q in args: %v", wantWD, proc.Args) + } +} + +func TestLaunchResumeDarwinGhosttyAppBundle(t *testing.T) { + cwd := t.TempDir() + proc := launchResumeDarwin( + Opener{ + ID: "ghostty", + Name: "Ghostty", + Kind: "terminal", + Bin: "/Applications/Ghostty.app", + }, + "cursor agent --resume chat-1", + cwd, + ) + if proc == nil { + t.Fatal("launchResumeDarwin returned nil") + } + // App bundle wraps with `open -na`. + if !strings.HasSuffix(proc.Args[0], "open") { + t.Fatalf("expected open for app bundle, got %q", + proc.Args[0]) + } + if !sliceContains(proc.Args, "-na") { + t.Fatalf("missing -na flag: %v", proc.Args) + } + wantWD := "--working-directory=" + cwd + if !sliceContains(proc.Args, wantWD) { + t.Fatalf("missing %q in args: %v", wantWD, proc.Args) + } +} + +func TestLaunchResumeDarwinGhosttyNoCwd(t *testing.T) { + proc := launchResumeDarwin( + Opener{ + ID: "ghostty", + Name: "Ghostty", + Kind: "terminal", + Bin: "/usr/local/bin/ghostty", + }, + "cursor agent --resume chat-1", + "", + ) + if proc == nil { + t.Fatal("launchResumeDarwin returned nil") + } + for _, arg := range proc.Args { + if strings.HasPrefix(arg, "--working-directory") { + t.Fatalf("unexpected --working-directory with empty cwd: %v", + proc.Args) + } + } +} + +func TestLaunchTerminalInDirGhosttyDirectCliOnDarwin(t *testing.T) { + if runtime.GOOS != "darwin" { + t.Skip("macOS-specific Ghostty launch path") + } + dir := t.TempDir() + proc := launchTerminalInDir( + Opener{ + ID: "ghostty", + Name: "Ghostty", + Kind: "terminal", + Bin: "/Applications/Ghostty.app", + }, + dir, + ) + if proc == nil { + t.Fatal("launchTerminalInDir returned nil") + } + if strings.HasSuffix(proc.Args[0], "osascript") { + t.Fatalf("expected direct launch, got osascript: %v", + proc.Args) + } + wantWD := "--working-directory=" + dir + if !sliceContains(proc.Args, wantWD) { + t.Fatalf("missing %q in args: %v", wantWD, proc.Args) + } +} + +func sliceContains(ss []string, s string) bool { + return slices.Contains(ss, s) +} diff --git a/internal/server/openers.go b/internal/server/openers.go index 57964f1c..860744f3 100644 --- a/internal/server/openers.go +++ b/internal/server/openers.go @@ -80,7 +80,10 @@ var darwinOpenerCandidates = []openerCandidate{ // Terminals — Ghostty, iTerm2, kitty, and Terminal.app are macOS // GUI apps; detect via app bundle first, fall back to PATH binary // for non-default installs (e.g. Homebrew formula). - {"ghostty", "Ghostty", "terminal", []string{"ghostty"}, "/Applications/Ghostty.app"}, + {"ghostty", "Ghostty", "terminal", []string{ + "ghostty", + "/Applications/Ghostty.app/Contents/MacOS/ghostty", + }, ""}, {"iterm2", "iTerm2", "terminal", nil, "/Applications/iTerm.app"}, {"kitty", "kitty", "terminal", []string{"kitty"}, "/Applications/kitty.app"}, {"alacritty", "Alacritty", "terminal", []string{"alacritty"}, ""}, diff --git a/internal/server/resume.go b/internal/server/resume.go index 934083db..c5e20e36 100644 --- a/internal/server/resume.go +++ b/internal/server/resume.go @@ -45,6 +45,7 @@ var resumeAgents = map[string]string{ "claude": "claude --resume %s", "codex": "codex resume %s", "copilot": "copilot --resume=%s", + "cursor": "cursor agent --resume %s", "gemini": "gemini --resume %s", "opencode": "opencode --session %s", "amp": "amp --resume %s", @@ -130,16 +131,18 @@ func (s *Server) handleResumeSession( } } - // Resolve the project directory from the session file or - // project field for use in cd prefix and response metadata. - sessionDir := resolveSessionDir(session) + // Resolve the terminal launch directory. Cursor resume needs the + // shell to start in the latest session cwd so the resumed chat + // inherits the same working directory it last used. + launchDir, workspaceDir := resolveResumePaths(session) + if string(session.Agent) == "cursor" && workspaceDir != "" { + cmd += " --workspace " + shellQuote(workspaceDir) + } - // Claude Code and Kiro CLI scope sessions by the working - // directory the session was started from. Prepend cd so - // the resume works from any terminal location. - agent := string(session.Agent) - if (agent == "claude" || agent == "kiro") && sessionDir != "" { - cmd = fmt.Sprintf("cd %s && %s", shellQuote(sessionDir), cmd) + responseCmd := cmd + switch string(session.Agent) { + case "claude", "kiro": + responseCmd = commandWithCwd(cmd, launchDir) } // If the caller only wants the command string (e.g. for @@ -147,8 +150,8 @@ func (s *Server) handleResumeSession( if req.CommandOnly { writeJSON(w, http.StatusOK, resumeResponse{ Launched: false, - Command: cmd, - Cwd: sessionDir, + Command: responseCmd, + Cwd: launchDir, }) return } @@ -184,13 +187,13 @@ func (s *Server) handleResumeSession( "Claude Desktop resume only supports Claude sessions") return } - proc := launchClaudeDesktop(rawID, sessionDir) + proc := launchClaudeDesktop(rawID, launchDir) if err := proc.Start(); err != nil { log.Printf("resume: Claude Desktop launch failed: %v", err) writeJSON(w, http.StatusOK, resumeResponse{ Launched: false, - Command: cmd, - Cwd: sessionDir, + Command: responseCmd, + Cwd: launchDir, Error: "desktop_launch_failed", }) return @@ -199,18 +202,21 @@ func (s *Server) handleResumeSession( writeJSON(w, http.StatusOK, resumeResponse{ Launched: true, Terminal: opener.Name, - Command: cmd, - Cwd: sessionDir, + Command: responseCmd, + Cwd: launchDir, }) return } - proc := launchResumeInOpener(*opener, cmd, sessionDir) + openerCwd := resumeLaunchCwd( + string(session.Agent), opener.ID, runtime.GOOS, launchDir, + ) + proc := launchResumeInOpener(*opener, cmd, openerCwd) if proc == nil { writeJSON(w, http.StatusOK, resumeResponse{ Launched: false, - Command: cmd, - Cwd: sessionDir, + Command: responseCmd, + Cwd: launchDir, Error: "unsupported_opener", }) return @@ -219,8 +225,8 @@ func (s *Server) handleResumeSession( log.Printf("resume: opener start failed: %v", err) writeJSON(w, http.StatusOK, resumeResponse{ Launched: false, - Command: cmd, - Cwd: sessionDir, + Command: responseCmd, + Cwd: launchDir, Error: "terminal_launch_failed", }) return @@ -229,8 +235,8 @@ func (s *Server) handleResumeSession( writeJSON(w, http.StatusOK, resumeResponse{ Launched: true, Terminal: opener.Name, - Command: cmd, - Cwd: sessionDir, + Command: responseCmd, + Cwd: launchDir, }) return } @@ -244,20 +250,27 @@ func (s *Server) handleResumeSession( // User explicitly chose clipboard-only mode. writeJSON(w, http.StatusOK, resumeResponse{ Launched: false, - Command: cmd, + Command: responseCmd, + Cwd: launchDir, }) return } // Detect and launch a terminal. - termBin, termArgs, termName, termErr := detectTerminal(cmd, sessionDir, termCfg) + detectCwd := launchDir + if termCfg.Mode == "auto" { + detectCwd = resumeLaunchCwd( + string(session.Agent), "auto", runtime.GOOS, launchDir, + ) + } + termBin, termArgs, termName, termErr := detectTerminal(cmd, detectCwd, termCfg) if termErr != nil { // Can't launch — return the command for clipboard fallback. log.Printf("resume: terminal detection failed: %v", termErr) writeJSON(w, http.StatusOK, resumeResponse{ Launched: false, - Command: cmd, - Cwd: sessionDir, + Command: responseCmd, + Cwd: launchDir, Error: "no_terminal_found", }) return @@ -269,16 +282,16 @@ func (s *Server) handleResumeSession( proc.Stdout = nil proc.Stderr = nil proc.Stdin = nil - if sessionDir != "" { - proc.Dir = sessionDir + if detectCwd != "" { + proc.Dir = detectCwd } if err := proc.Start(); err != nil { log.Printf("resume: terminal start failed: %v", err) writeJSON(w, http.StatusOK, resumeResponse{ Launched: false, - Command: cmd, - Cwd: sessionDir, + Command: responseCmd, + Cwd: launchDir, Error: "terminal_launch_failed", }) return @@ -290,8 +303,8 @@ func (s *Server) handleResumeSession( writeJSON(w, http.StatusOK, resumeResponse{ Launched: true, Terminal: termName, - Command: cmd, - Cwd: sessionDir, + Command: responseCmd, + Cwd: launchDir, }) } @@ -316,6 +329,21 @@ func shellQuote(s string) string { return "'" + strings.ReplaceAll(s, "'", `'"'"'`) + "'" } +func commandWithCwd(cmd, cwd string) string { + if !isDir(cwd) { + return cmd + } + return fmt.Sprintf("cd %s && %s", shellQuote(cwd), cmd) +} + +// resumeLaunchCwd returns the cwd a terminal launcher should apply for +// a resume command. Cursor resumes still need the terminal shell to +// start in the last working directory even when --workspace points the +// CLI at the session's workspace root. +func resumeLaunchCwd(agent, openerID, goos, cwd string) string { + return cwd +} + // detectTerminal finds a suitable terminal emulator and builds the // full argument list to launch the given command. Returns the // executable path, args, a user-facing display name, and any error. @@ -369,23 +397,11 @@ func detectTerminalDarwin( // Check for iTerm2 first, then fall back to Terminal.app. // Use osascript to tell the app to open a new window and run // the command. - script := cmd - if cwd != "" { - if info, err := os.Stat(cwd); err == nil && info.IsDir() { - script = fmt.Sprintf("cd %s && %s", shellQuote(cwd), cmd) - } - } + script := commandWithCwd(cmd, cwd) // Try iTerm2 first. if _, err := exec.LookPath("osascript"); err == nil { - // Sanitize for AppleScript: escape backslashes, then quotes, - // and reject newlines to prevent multi-line injection. - safe := strings.NewReplacer( - "\n", " ", - "\r", " ", - `\`, `\\`, - `"`, `\"`, - ).Replace(script) + safe := escapeForAppleScript(script) // Check if iTerm is installed. iterm := "/Applications/iTerm.app" @@ -493,7 +509,7 @@ func (s *Server) handleSetTerminalConfig( } // readSessionCwd reads the first few lines of a session JSONL file -// and extracts the "cwd" field. Claude Code stores the working +// and extracts the initial "cwd" field. Claude Code stores the working // directory in early conversation entries; some agents (e.g. Codex) // store it under payload.cwd. Returns "" if not found. func readSessionCwd(path string) string { @@ -508,39 +524,176 @@ func readSessionCwd(path string) string { } } + var cwd string + scanJSONLLines(path, 20, func(line []byte) bool { + for _, jsonPath := range []string{ + "cwd", + "payload.cwd", + // Copilot stores cwd under data.context.cwd on the + // session.start event. + "data.context.cwd", + } { + if value := gjson.GetBytes(line, jsonPath).Str; value != "" { + cwd = value + return false + } + } + return true + }) + return cwd +} + +// readCursorLastWorkingDir scans a Cursor transcript for the most +// recent tool invocation that recorded a working_directory. Returns +// the latest existing absolute directory, or "" if not found. +func readCursorLastWorkingDir(path string) string { + last := "" + scanJSONLLines(path, 0, func(line []byte) bool { + content := gjson.GetBytes(line, "message.content") + if content.IsArray() { + content.ForEach(func(_, item gjson.Result) bool { + if item.Get("type").Str != "tool_use" { + return true + } + for _, jsonPath := range []string{ + "input.working_directory", + "parameters.working_directory", + } { + wd := normalizeCursorDir(item.Get(jsonPath).Str) + if wd != "" { + last = wd + } + } + return true + }) + } + return true + }) + return last +} + +func scanJSONLLines( + path string, maxLines int, visit func([]byte) bool, +) { f, err := os.Open(path) if err != nil { - return "" + return } defer f.Close() reader := bufio.NewReader(f) - for range 20 { + for lineNum := 0; maxLines <= 0 || lineNum < maxLines; lineNum++ { line, err := reader.ReadBytes('\n') - if len(line) > 0 { - s := string(line) - if cwd := gjson.Get(s, "cwd").Str; cwd != "" { - return cwd - } - if cwd := gjson.Get(s, "payload.cwd").Str; cwd != "" { - return cwd - } - // Copilot stores cwd under data.context.cwd on the - // session.start event. - if cwd := gjson.Get(s, "data.context.cwd").Str; cwd != "" { - return cwd - } + if len(line) > 0 && !visit(line) { + return } if err != nil { - break + return } } - return "" +} + +func cursorLastWorkingDir(session *db.Session) string { + if session.Agent != "cursor" || session.FilePath == nil { + return "" + } + return readCursorLastWorkingDir(*session.FilePath) +} + +func resolveCursorResumePaths( + session *db.Session, lastCwd string, +) (launchDir, workspaceDir string) { + workspaceDir = resolveCursorWorkspaceDirWithHint( + session, + func() string { return lastCwd }, + ) + if workspaceDir == "" { + workspaceDir = lastCwd + } + if lastCwd != "" { + return lastCwd, workspaceDir + } + return workspaceDir, workspaceDir +} + +func resolveResumePaths(session *db.Session) (launchDir, workspaceDir string) { + if session.Agent != "cursor" { + return resolveSessionDir(session), "" + } + return resolveCursorResumePaths( + session, cursorLastWorkingDir(session), + ) +} + +func resolveCursorWorkspaceDirFromTranscriptPath( + session *db.Session, +) (string, bool) { + if session.FilePath == nil { + return "", false + } + dir, ambiguous := resolveCursorProjectDirFromSessionFile( + *session.FilePath, + ) + if canonical := normalizeCursorDir(dir); canonical != "" { + return canonical, ambiguous + } + return "", false +} + +func resolveCursorWorkspaceDirFromTranscriptPathHint( + session *db.Session, hint string, +) string { + if session.FilePath == nil { + return "" + } + dir := resolveCursorProjectDirFromSessionFileHint( + *session.FilePath, hint, + ) + return normalizeCursorDir(dir) +} + +func resolveCursorWorkspaceDirWithHint( + session *db.Session, hintFn func() string, +) string { + projectDir := normalizeCursorDir(session.Project) + if dir, ambiguous := resolveCursorWorkspaceDirFromTranscriptPath( + session, + ); dir != "" { + if ambiguous { + hint := projectDir + if hintFn != nil { + if value := hintFn(); value != "" { + hint = value + } + } + if hint != "" { + if hinted := resolveCursorWorkspaceDirFromTranscriptPathHint( + session, hint, + ); hinted != "" { + return hinted + } + } + // Ambiguous with no useful hint — don't guess. + return projectDir + } + return dir + } + return projectDir +} + +// resolveResumeDir determines the terminal launch directory for a +// session resume. Cursor sessions prefer the latest recorded +// working_directory so resumed chats reopen in the same shell cwd +// they last used instead of a generic workspace root. +func resolveResumeDir(session *db.Session) string { + launchDir, _ := resolveResumePaths(session) + return launchDir } // resolveSessionDir determines the project directory for a session. -// It tries the session file's embedded cwd first, then falls back to -// the session's project field. Both candidates must be absolute paths +// It tries the session file's embedded cwd first, then Cursor's +// transcript-derived workspace path, then falls back to the session's +// project field. All returned candidates must be absolute paths // pointing to existing directories. func resolveSessionDir(session *db.Session) string { if session.FilePath != nil { @@ -548,12 +701,51 @@ func resolveSessionDir(session *db.Session) string { return cwd } } + if session.Agent == "cursor" { + if dir := resolveCursorWorkspaceDir(session); dir != "" { + return dir + } + } if isDir(session.Project) { return session.Project } return "" } +// resolveCursorWorkspaceDir returns the real workspace root for a +// Cursor session, preferring the transcript path and falling back to +// an absolute project field when available. It only scans transcript +// contents when the transcript path maps to multiple plausible +// workspace roots. +func resolveCursorWorkspaceDir(session *db.Session) string { + return resolveCursorWorkspaceDirWithHint( + session, + func() string { return cursorLastWorkingDir(session) }, + ) +} + +func normalizeCursorDir(path string) string { + if !isDir(path) { + return "" + } + clean := filepath.Clean(path) + resolved, err := filepath.EvalSymlinks(clean) + if err != nil || !isDir(resolved) { + return clean + } + resolved = filepath.Clean(resolved) + if runtime.GOOS == "darwin" && + strings.HasPrefix(resolved, "/private/") { + publicPath := filepath.Clean( + strings.TrimPrefix(resolved, "/private"), + ) + if isDir(publicPath) { + return publicPath + } + } + return resolved +} + func isDir(path string) bool { if !filepath.IsAbs(path) { return false @@ -662,13 +854,10 @@ func launchResumeDarwin( o Opener, cmd string, cwd string, ) *exec.Cmd { // For AppleScript-based terminals, build a single shell command - // that cd's and then runs the resume command. - shellCmd := cmd - if cwd != "" { - shellCmd = fmt.Sprintf( - "cd %s && %s", shellQuote(cwd), cmd, - ) - } + // that enters the requested directory and then runs the resume + // command. The caller passes the raw resume command without a + // leading `cd` so terminal-specific launchers only add it once. + shellCmd := commandWithCwd(cmd, cwd) safe := escapeForAppleScript(shellCmd) switch o.ID { diff --git a/internal/server/resume_handler_test.go b/internal/server/resume_handler_test.go index a441a12b..bfccbfa2 100644 --- a/internal/server/resume_handler_test.go +++ b/internal/server/resume_handler_test.go @@ -5,11 +5,39 @@ import ( "net/http" "os" "path/filepath" + "runtime" + "strings" "testing" "github.com/wesm/agentsview/internal/db" ) +func canonicalTestPath(path string) string { + if path == "" { + return "" + } + clean := filepath.Clean(path) + if resolved, err := filepath.EvalSymlinks(clean); err == nil { + clean = filepath.Clean(resolved) + } + if runtime.GOOS == "darwin" && strings.HasPrefix(clean, "/private/") { + publicPath := filepath.Clean(strings.TrimPrefix(clean, "/private")) + if info, err := os.Stat(publicPath); err == nil && info.IsDir() { + return publicPath + } + } + return clean +} + +func assertSamePath(t *testing.T, label, got, want string) { + t.Helper() + got = canonicalTestPath(got) + want = canonicalTestPath(want) + if got != want { + t.Errorf("%s = %q, want %q", label, got, want) + } +} + func TestResumeSession(t *testing.T) { te := setup(t) @@ -39,9 +67,7 @@ func TestResumeSession(t *testing.T) { if resp.Command == "" { t.Error("expected non-empty command") } - if resp.Cwd != projectDir { - t.Errorf("cwd = %q, want %q", resp.Cwd, projectDir) - } + assertSamePath(t, "cwd", resp.Cwd, projectDir) }) t.Run("not found", func(t *testing.T) { @@ -91,12 +117,93 @@ func TestResumeSession(t *testing.T) { assertStatus(t, w, http.StatusBadRequest) }) - t.Run("unsupported agent", func(t *testing.T) { - te.seedSession(t, "cursor-1", "/tmp", 3, func(s *db.Session) { + t.Run("cursor command only", func(t *testing.T) { + projectDir := t.TempDir() + runDir := filepath.Join(projectDir, "frontend") + if err := os.MkdirAll(runDir, 0o755); err != nil { + t.Fatal(err) + } + runDirJSON, _ := json.Marshal(runDir) + sessionFile := filepath.Join(t.TempDir(), "cursor.jsonl") + content := `{"role":"assistant","message":{"content":[{"type":"tool_use","name":"Shell","input":{"command":"pwd","working_directory":` + + string(runDirJSON) + `}}]}}` + "\n" + if err := os.WriteFile(sessionFile, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + te.seedSession(t, "cursor:chat-1", projectDir, 3, func(s *db.Session) { s.Agent = "cursor" + s.FilePath = &sessionFile }) w := te.post(t, - "/api/v1/sessions/cursor-1/resume", + "/api/v1/sessions/cursor:chat-1/resume", + `{"command_only":true}`, + ) + assertStatus(t, w, http.StatusOK) + var resp struct { + Launched bool `json:"launched"` + Command string `json:"command"` + Cwd string `json:"cwd"` + } + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode: %v", err) + } + if resp.Launched { + t.Error("expected launched=false for command_only") + } + wantProjectDir := canonicalTestPath(projectDir) + wantCmd := "cursor agent --resume chat-1 --workspace '" + wantProjectDir + "'" + if resp.Command != wantCmd { + t.Errorf("command = %q, want %q", resp.Command, wantCmd) + } + assertSamePath(t, "cwd", resp.Cwd, runDir) + }) + + t.Run("cursor command only falls back workspace to cwd", func(t *testing.T) { + runDir := filepath.Join(t.TempDir(), "frontend") + if err := os.MkdirAll(runDir, 0o755); err != nil { + t.Fatal(err) + } + runDirJSON, _ := json.Marshal(runDir) + sessionFile := filepath.Join(t.TempDir(), "cursor.jsonl") + content := `{"role":"assistant","message":{"content":[{"type":"tool_use","name":"Shell","input":{"command":"pwd","working_directory":` + + string(runDirJSON) + `}}]}}` + "\n" + if err := os.WriteFile(sessionFile, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + te.seedSession(t, "cursor:chat-2", "li_tools", 3, func(s *db.Session) { + s.Agent = "cursor" + s.FilePath = &sessionFile + }) + w := te.post(t, + "/api/v1/sessions/cursor:chat-2/resume", + `{"command_only":true}`, + ) + assertStatus(t, w, http.StatusOK) + var resp struct { + Launched bool `json:"launched"` + Command string `json:"command"` + Cwd string `json:"cwd"` + } + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode: %v", err) + } + if resp.Launched { + t.Error("expected launched=false for command_only") + } + wantRunDir := canonicalTestPath(runDir) + wantCmd := "cursor agent --resume chat-2 --workspace '" + wantRunDir + "'" + if resp.Command != wantCmd { + t.Errorf("command = %q, want %q", resp.Command, wantCmd) + } + assertSamePath(t, "cwd", resp.Cwd, runDir) + }) + + t.Run("unsupported agent", func(t *testing.T) { + te.seedSession(t, "vscode-1", "/tmp", 3, func(s *db.Session) { + s.Agent = "vscode-copilot" + }) + w := te.post(t, + "/api/v1/sessions/vscode-1/resume", `{"command_only":true}`, ) assertStatus(t, w, http.StatusBadRequest) @@ -132,9 +239,7 @@ func TestGetSessionDirectory(t *testing.T) { if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { t.Fatalf("decode: %v", err) } - if resp.Path != projectDir { - t.Errorf("path = %q, want %q", resp.Path, projectDir) - } + assertSamePath(t, "path", resp.Path, projectDir) }) t.Run("empty path for relative project", func(t *testing.T) { @@ -179,9 +284,36 @@ func TestGetSessionDirectory(t *testing.T) { if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { t.Fatalf("decode: %v", err) } - if resp.Path != cwdDir { - t.Errorf("path = %q, want %q", resp.Path, cwdDir) + assertSamePath(t, "path", resp.Path, cwdDir) + }) + + t.Run("cursor directory returns workspace root", func(t *testing.T) { + projectDir := t.TempDir() + runDir := filepath.Join(projectDir, "frontend") + if err := os.MkdirAll(runDir, 0o755); err != nil { + t.Fatal(err) + } + runDirJSON, _ := json.Marshal(runDir) + sessionFile := filepath.Join(t.TempDir(), "cursor.jsonl") + content := `{"role":"assistant","message":{"content":[{"type":"tool_use","name":"Shell","input":{"command":"pwd","working_directory":` + + string(runDirJSON) + `}}]}}` + "\n" + if err := os.WriteFile(sessionFile, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + te.seedSession(t, "dir-cursor", projectDir, 3, func(s *db.Session) { + s.Agent = "cursor" + s.FilePath = &sessionFile + }) + + w := te.get(t, "/api/v1/sessions/dir-cursor/directory") + assertStatus(t, w, http.StatusOK) + var resp struct { + Path string `json:"path"` + } + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode: %v", err) } + assertSamePath(t, "path", resp.Path, projectDir) }) } diff --git a/internal/server/resume_test.go b/internal/server/resume_test.go index 742b3628..4fdb00e3 100644 --- a/internal/server/resume_test.go +++ b/internal/server/resume_test.go @@ -11,6 +11,25 @@ import ( "github.com/wesm/agentsview/internal/db" ) +func canonicalTestDir(path string) string { + if path == "" { + return "" + } + if normalized := normalizeCursorDir(path); normalized != "" { + return normalized + } + return filepath.Clean(path) +} + +func assertSameDir(t *testing.T, label, got, want string) { + t.Helper() + got = canonicalTestDir(got) + want = canonicalTestDir(want) + if got != want { + t.Errorf("%s = %q, want %q", label, got, want) + } +} + func TestShellQuote(t *testing.T) { tests := []struct { name string @@ -204,6 +223,334 @@ func TestReadSessionCwd_CopilotFormat(t *testing.T) { } } +func TestReadCursorLastWorkingDir(t *testing.T) { + dir := t.TempDir() + workspaceDir := filepath.Join(dir, "project") + lastDir := filepath.Join(workspaceDir, "frontend") + if err := os.MkdirAll(lastDir, 0o755); err != nil { + t.Fatal(err) + } + + firstJSON, _ := json.Marshal(workspaceDir) + lastJSON, _ := json.Marshal(lastDir) + sessionFile := filepath.Join(dir, "cursor.jsonl") + content := "" + + `{"role":"assistant","message":{"content":[{"type":"tool_use","name":"ReadFile","input":{"path":"/tmp/file.txt"}}]}}` + "\n" + + `{"role":"assistant","message":{"content":[{"type":"tool_use","name":"Shell","input":{"command":"pwd","working_directory":` + string(firstJSON) + `}}]}}` + "\n" + + `{"role":"assistant","message":{"content":[{"type":"tool_use","name":"Shell","input":{"command":"pwd","working_directory":"relative/path"}}]}}` + "\n" + + `{"role":"assistant","message":{"content":[{"type":"tool_use","name":"Shell","input":{"command":"pwd","working_directory":` + string(lastJSON) + `}}]}}` + "\n" + if err := os.WriteFile(sessionFile, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + + got := readCursorLastWorkingDir(sessionFile) + assertSameDir(t, "readCursorLastWorkingDir()", got, lastDir) +} + +func TestCursorProjectDirNameFromTranscriptPath(t *testing.T) { + tests := []struct { + name string + path string + want string + }{ + { + name: "flat transcript", + path: filepath.Join( + "/tmp", ".cursor", "projects", + "Users-alice-code-my-app", + "agent-transcripts", "sess.jsonl", + ), + want: "Users-alice-code-my-app", + }, + { + name: "nested transcript", + path: filepath.Join( + "/tmp", ".cursor", "projects", + "Users-alice-code-my-app", + "agent-transcripts", "sess", "sess.jsonl", + ), + want: "Users-alice-code-my-app", + }, + { + name: "missing agent transcripts ancestor", + path: filepath.Join( + "/tmp", ".cursor", "projects", + "Users-alice-code-my-app", "other", "sess.jsonl", + ), + want: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := cursorProjectDirNameFromTranscriptPath(tt.path) + if got != tt.want { + t.Errorf( + "cursorProjectDirNameFromTranscriptPath(%q) = %q, want %q", + tt.path, got, tt.want, + ) + } + }) + } +} + +func TestResolveCursorProjectDirNameFromRoot(t *testing.T) { + root := t.TempDir() + want := filepath.Join( + root, "Users", "alice", "code", "li", + "project-cache-hdfs", + ) + if err := os.MkdirAll(want, 0o755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll( + filepath.Join(root, "Users", "alice", "code", "li"), + 0o755, + ); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll( + filepath.Join(root, "Users", "alice", "code", "li", "project"), + 0o755, + ); err != nil { + t.Fatal(err) + } + + got := resolveCursorProjectDirNameFromRoot( + root, "Users-alice-code-li-project-cache-hdfs", + ) + if got != want { + t.Errorf( + "resolveCursorProjectDirNameFromRoot() = %q, want %q", + got, want, + ) + } +} + +func TestResolveCursorProjectDirNameFromRootMatchesUnderscoreComponents( + t *testing.T, +) { + root := t.TempDir() + want := filepath.Join( + root, "Users", "alice", "code", "li", + "project_cache_hdfs", + ) + if err := os.MkdirAll(want, 0o755); err != nil { + t.Fatal(err) + } + + got := resolveCursorProjectDirNameFromRoot( + root, "Users-alice-code-li-project-cache-hdfs", + ) + if got != want { + t.Errorf( + "resolveCursorProjectDirNameFromRoot() = %q, want %q", + got, want, + ) + } +} + +func TestResolveCursorProjectDirFromSessionFileDetectsAmbiguity( + t *testing.T, +) { + root := t.TempDir() + want := filepath.Join( + root, "Users", "alice", "code", "li-tools", + ) + if err := os.MkdirAll(want, 0o755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll( + filepath.Join(root, "Users", "alice", "code", "li", "tools"), + 0o755, + ); err != nil { + t.Fatal(err) + } + + filePath := filepath.Join( + root, ".cursor", "projects", + "Users-alice-code-li-tools", + "agent-transcripts", "sess", "sess.jsonl", + ) + dirName := cursorProjectDirNameFromTranscriptPath(filePath) + matches := resolveCursorProjectDirNameFromRootMatches( + root, dirName, "", 2, + ) + got := "" + if len(matches) > 0 { + got = matches[0] + } + ambiguous := len(matches) > 1 + if got != want { + t.Errorf( + "resolveCursorProjectDirFromSessionFile() = %q, want %q", + got, want, + ) + } + if !ambiguous { + t.Error("expected ambiguous transcript path") + } +} + +func TestResolveCursorProjectDirFromSessionFileUnambiguous( + t *testing.T, +) { + root := t.TempDir() + want := filepath.Join( + root, "Users", "alice", "code", "li-openhouse", + ) + if err := os.MkdirAll(want, 0o755); err != nil { + t.Fatal(err) + } + + filePath := filepath.Join( + root, ".cursor", "projects", + "Users-alice-code-li-openhouse", + "agent-transcripts", "sess", "sess.jsonl", + ) + dirName := cursorProjectDirNameFromTranscriptPath(filePath) + matches := resolveCursorProjectDirNameFromRootMatches( + root, dirName, "", 2, + ) + got := "" + if len(matches) > 0 { + got = matches[0] + } + ambiguous := len(matches) > 1 + if got != want { + t.Errorf( + "resolveCursorProjectDirFromSessionFile() = %q, want %q", + got, want, + ) + } + if ambiguous { + t.Error("expected unambiguous transcript path") + } +} + +func TestResolveCursorProjectDirNameFromRootBacktracksOnDeadEnd( + t *testing.T, +) { + root := t.TempDir() + want := filepath.Join( + root, "Users", "alice", "code", "li", "tools-app", + ) + if err := os.MkdirAll(want, 0o755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll( + filepath.Join(root, "Users", "alice", "code", "li-tools"), + 0o755, + ); err != nil { + t.Fatal(err) + } + + got := resolveCursorProjectDirNameFromRoot( + root, "Users-alice-code-li-tools-app", + ) + if got != want { + t.Errorf( + "resolveCursorProjectDirNameFromRoot() = %q, want %q", + got, want, + ) + } +} + +func TestResolveCursorProjectDirNameFromRootHintPrefersContainingPath( + t *testing.T, +) { + root := t.TempDir() + want := filepath.Join( + root, "Users", "alice", "code", "li", "tools", + ) + hint := filepath.Join(want, "frontend") + if err := os.MkdirAll(hint, 0o755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll( + filepath.Join(root, "Users", "alice", "code", "li-tools"), + 0o755, + ); err != nil { + t.Fatal(err) + } + + got := resolveCursorProjectDirNameFromRootHint( + root, "Users-alice-code-li-tools", hint, + ) + if got != want { + t.Errorf( + "resolveCursorProjectDirNameFromRootHint() = %q, want %q", + got, want, + ) + } +} + +func TestResolveCursorProjectDirNameFromRootHintStaleReturnsEmpty( + t *testing.T, +) { + root := t.TempDir() + if err := os.MkdirAll( + filepath.Join(root, "Users", "alice", "code", "li-tools"), + 0o755, + ); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll( + filepath.Join(root, "Users", "alice", "code", "li", "tools"), + 0o755, + ); err != nil { + t.Fatal(err) + } + + staleHint := filepath.Join(root, "unrelated") + if err := os.MkdirAll(staleHint, 0o755); err != nil { + t.Fatal(err) + } + + got := resolveCursorProjectDirNameFromRootHint( + root, "Users-alice-code-li-tools", staleHint, + ) + if got != "" { + t.Errorf( + "with stale hint = %q, want empty", got, + ) + } +} + +func TestResolveCursorProjectDirNameFromRootHintSymlinkMatch( + t *testing.T, +) { + root := t.TempDir() + + // Real project with a hint subdir. + realProject := filepath.Join(root, "repos", "li-tools") + hintDir := filepath.Join(realProject, "src") + if err := os.MkdirAll(hintDir, 0o755); err != nil { + t.Fatal(err) + } + // Second ambiguous path. + if err := os.MkdirAll( + filepath.Join(root, "repos", "li", "tools"), + 0o755, + ); err != nil { + t.Fatal(err) + } + + // Symlink: root/code -> root/repos. The DFS walks through + // the symlink but the hint uses the resolved real path. + if err := os.Symlink( + filepath.Join(root, "repos"), + filepath.Join(root, "code"), + ); err != nil { + t.Skipf("symlink not supported: %v", err) + } + + got := resolveCursorProjectDirNameFromRootHint( + root, "code-li-tools", hintDir, + ) + assertSameDir(t, "result", got, realProject) +} + func TestResolveSessionDir(t *testing.T) { // Create a real temp directory for the "absolute path" cases. tmpDir := t.TempDir() @@ -220,6 +567,50 @@ func TestResolveSessionDir(t *testing.T) { t.Fatal(err) } + cursorProject := filepath.Join( + tmpDir, "workspace-root", "li-openhouse", + ) + if err := os.MkdirAll(cursorProject, 0o755); err != nil { + t.Fatal(err) + } + cursorTranscript := filepath.Join( + tmpDir, ".cursor", "projects", + encodeCursorProjectPathForTest(cursorProject), + "agent-transcripts", "cursor-sess", + "cursor-sess.jsonl", + ) + if err := os.MkdirAll( + filepath.Dir(cursorTranscript), 0o755, + ); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(cursorTranscript, []byte("{}\n"), 0o644); err != nil { + t.Fatal(err) + } + cursorLastDir := filepath.Join(cursorProject, "frontend") + if err := os.MkdirAll(cursorLastDir, 0o755); err != nil { + t.Fatal(err) + } + cursorLastDirJSON, _ := json.Marshal(cursorLastDir) + cursorTranscriptWithLastDir := filepath.Join( + tmpDir, ".cursor", "projects", + encodeCursorProjectPathForTest(cursorProject), + "agent-transcripts", "cursor-sess-last", + "cursor-sess-last.jsonl", + ) + if err := os.MkdirAll( + filepath.Dir(cursorTranscriptWithLastDir), 0o755, + ); err != nil { + t.Fatal(err) + } + lastDirContent := `{"role":"assistant","message":{"content":[{"type":"tool_use","name":"Shell","input":{"command":"pwd","working_directory":` + + string(cursorLastDirJSON) + `}}]}}` + "\n" + if err := os.WriteFile( + cursorTranscriptWithLastDir, []byte(lastDirContent), 0o644, + ); err != nil { + t.Fatal(err) + } + tests := []struct { name string session *db.Session @@ -274,17 +665,455 @@ func TestResolveSessionDir(t *testing.T) { }(), want: tmpDir, }, + { + name: "cursor transcript path resolves workspace dir", + session: &db.Session{ + Agent: "cursor", + Project: cursorProject, + FilePath: &cursorTranscript, + }, + want: cursorProject, + }, + { + name: "cursor transcript with last shell dir still resolves workspace", + session: &db.Session{ + Agent: "cursor", + Project: cursorProject, + FilePath: &cursorTranscriptWithLastDir, + }, + want: cursorProject, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := resolveSessionDir(tt.session) - if got != tt.want { + if tt.want == "" { + if got != "" { + t.Errorf( + "resolveSessionDir() = %q, want %q", + got, tt.want, + ) + } + return + } + if canonicalTestDir(got) != canonicalTestDir(tt.want) { t.Errorf( "resolveSessionDir() = %q, want %q", + canonicalTestDir(got), canonicalTestDir(tt.want), + ) + } + }) + } +} + +func TestResolveResumeDir(t *testing.T) { + tmpDir := t.TempDir() + + cursorProject := filepath.Join( + tmpDir, "workspace-root", "li-openhouse", + ) + cursorLastDir := filepath.Join(cursorProject, "frontend") + if err := os.MkdirAll(cursorLastDir, 0o755); err != nil { + t.Fatal(err) + } + + cursorLastDirJSON, _ := json.Marshal(cursorLastDir) + cursorTranscript := filepath.Join( + tmpDir, ".cursor", "projects", + encodeCursorProjectPathForTest(cursorProject), + "agent-transcripts", "cursor-sess-last", + "cursor-sess-last.jsonl", + ) + if err := os.MkdirAll( + filepath.Dir(cursorTranscript), 0o755, + ); err != nil { + t.Fatal(err) + } + lastDirContent := `{"role":"assistant","message":{"content":[{"type":"tool_use","name":"Shell","input":{"command":"pwd","working_directory":` + + string(cursorLastDirJSON) + `}}]}}` + "\n" + if err := os.WriteFile( + cursorTranscript, []byte(lastDirContent), 0o644, + ); err != nil { + t.Fatal(err) + } + + got := resolveResumeDir(&db.Session{ + Agent: "cursor", + Project: "li_openhouse", + FilePath: &cursorTranscript, + }) + assertSameDir(t, "resolveResumeDir()", got, cursorLastDir) +} + +func TestResolveCursorWorkspaceDirUsesLastWorkingDirHint(t *testing.T) { + tmpDir := t.TempDir() + + cursorProject := filepath.Join( + tmpDir, "workspace-root", "li", "tools", + ) + cursorLastDir := filepath.Join(cursorProject, "frontend") + if err := os.MkdirAll(cursorLastDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll( + filepath.Join(tmpDir, "workspace-root", "li-tools"), + 0o755, + ); err != nil { + t.Fatal(err) + } + + cursorLastDirJSON, _ := json.Marshal(cursorLastDir) + cursorTranscript := filepath.Join( + tmpDir, ".cursor", "projects", + encodeCursorProjectPathForTest(cursorProject), + "agent-transcripts", "cursor-sess-last", + "cursor-sess-last.jsonl", + ) + if err := os.MkdirAll( + filepath.Dir(cursorTranscript), 0o755, + ); err != nil { + t.Fatal(err) + } + lastDirContent := `{"role":"assistant","message":{"content":[{"type":"tool_use","name":"Shell","input":{"command":"pwd","working_directory":` + + string(cursorLastDirJSON) + `}}]}}` + "\n" + if err := os.WriteFile( + cursorTranscript, []byte(lastDirContent), 0o644, + ); err != nil { + t.Fatal(err) + } + + got := resolveCursorWorkspaceDir(&db.Session{ + Agent: "cursor", + Project: cursorProject, + FilePath: &cursorTranscript, + }) + assertSameDir(t, "resolveCursorWorkspaceDir()", got, cursorProject) +} + +func TestResolveCursorWorkspaceDirAmbiguousWithoutHintReturnsEmpty( + t *testing.T, +) { + tmpDir := t.TempDir() + + // Create two paths that decode from the same encoded name. + pathA := filepath.Join(tmpDir, "li-tools") + pathB := filepath.Join(tmpDir, "li", "tools") + if err := os.MkdirAll(pathA, 0o755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(pathB, 0o755); err != nil { + t.Fatal(err) + } + + encoded := encodeCursorProjectPathForTest(pathA) + cursorTranscript := filepath.Join( + tmpDir, ".cursor", "projects", + encoded, + "agent-transcripts", "cursor-sess", + "cursor-sess.jsonl", + ) + + got := resolveCursorWorkspaceDir(&db.Session{ + Agent: "cursor", + Project: "li_tools", // Not absolute — no hint. + FilePath: &cursorTranscript, + }) + if got != "" { + t.Errorf( + "resolveCursorWorkspaceDir() = %q, want empty "+ + "(ambiguous without hint)", + got, + ) + } +} + +func TestResolveCursorWorkspaceDirStaleHintReturnsEmpty( + t *testing.T, +) { + tmpDir := t.TempDir() + + pathA := filepath.Join(tmpDir, "li-tools") + pathB := filepath.Join(tmpDir, "li", "tools") + if err := os.MkdirAll(pathA, 0o755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(pathB, 0o755); err != nil { + t.Fatal(err) + } + + // Stale hint: exists on disk but not under either candidate. + staleDir := filepath.Join(tmpDir, "unrelated-project") + if err := os.MkdirAll(staleDir, 0o755); err != nil { + t.Fatal(err) + } + + encoded := encodeCursorProjectPathForTest(pathA) + staleDirJSON, _ := json.Marshal(staleDir) + cursorTranscript := filepath.Join( + tmpDir, ".cursor", "projects", + encoded, + "agent-transcripts", "cursor-sess", + "cursor-sess.jsonl", + ) + if err := os.MkdirAll( + filepath.Dir(cursorTranscript), 0o755, + ); err != nil { + t.Fatal(err) + } + content := `{"role":"assistant","message":{"content":[` + + `{"type":"tool_use","name":"Shell","input":{` + + `"command":"pwd","working_directory":` + + string(staleDirJSON) + `}}]}}` + "\n" + if err := os.WriteFile( + cursorTranscript, []byte(content), 0o644, + ); err != nil { + t.Fatal(err) + } + + got := resolveCursorWorkspaceDir(&db.Session{ + Agent: "cursor", + Project: "li_tools", + FilePath: &cursorTranscript, + }) + if got != "" { + t.Errorf( + "resolveCursorWorkspaceDir() with stale hint = %q, "+ + "want empty", + got, + ) + } +} + +func TestResolveCursorWorkspaceDirWithoutTranscriptContents( + t *testing.T, +) { + tmpDir := t.TempDir() + + cursorProject := filepath.Join( + tmpDir, "workspace-root", "li-openhouse", + ) + if err := os.MkdirAll(cursorProject, 0o755); err != nil { + t.Fatal(err) + } + + cursorTranscript := filepath.Join( + tmpDir, ".cursor", "projects", + encodeCursorProjectPathForTest(cursorProject), + "agent-transcripts", "cursor-sess", + "cursor-sess.jsonl", + ) + + got := resolveCursorWorkspaceDir(&db.Session{ + Agent: "cursor", + Project: cursorProject, + FilePath: &cursorTranscript, + }) + assertSameDir(t, "resolveCursorWorkspaceDir()", got, cursorProject) +} + +func TestResolveCursorResumePathsUsesProvidedLastWorkingDir( + t *testing.T, +) { + tmpDir := t.TempDir() + + cursorProject := filepath.Join( + tmpDir, "workspace-root", "li", "tools", + ) + cursorLastDir := filepath.Join(cursorProject, "frontend") + if err := os.MkdirAll(cursorLastDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll( + filepath.Join(tmpDir, "workspace-root", "li-tools"), + 0o755, + ); err != nil { + t.Fatal(err) + } + + cursorTranscript := filepath.Join( + tmpDir, ".cursor", "projects", + encodeCursorProjectPathForTest(cursorProject), + "agent-transcripts", "cursor-sess-last", + "cursor-sess-last.jsonl", + ) + + launchDir, workspaceDir := resolveCursorResumePaths( + &db.Session{ + Agent: "cursor", + Project: cursorProject, + FilePath: &cursorTranscript, + }, + cursorLastDir, + ) + assertSameDir(t, "launchDir", launchDir, cursorLastDir) + assertSameDir(t, "workspaceDir", workspaceDir, cursorProject) +} + +func TestResolveCursorResumePathsFallbackWorkspaceToLastWorkingDir( + t *testing.T, +) { + lastCwd := filepath.Join(t.TempDir(), "frontend") + if err := os.MkdirAll(lastCwd, 0o755); err != nil { + t.Fatal(err) + } + + launchDir, workspaceDir := resolveCursorResumePaths( + &db.Session{ + Agent: "cursor", + Project: "li_tools", + FilePath: nil, + }, + lastCwd, + ) + assertSameDir(t, "launchDir", launchDir, lastCwd) + assertSameDir(t, "workspaceDir", workspaceDir, lastCwd) +} + +func TestResolveResumeDirCanonicalizesSymlink(t *testing.T) { + tmpDir := t.TempDir() + + realProject := filepath.Join(tmpDir, "repos", "openhouse") + if err := os.MkdirAll(realProject, 0o755); err != nil { + t.Fatal(err) + } + cacheDir := filepath.Join(tmpDir, "project_cache_hdfs") + if err := os.MkdirAll(cacheDir, 0o755); err != nil { + t.Fatal(err) + } + linkProject := filepath.Join(cacheDir, "openhouse") + if err := os.Symlink(realProject, linkProject); err != nil { + t.Skipf("symlink not supported: %v", err) + } + + linkJSON, _ := json.Marshal(linkProject) + sessionFile := filepath.Join(tmpDir, "cursor-symlink.jsonl") + content := `{"role":"assistant","message":{"content":[{"type":"tool_use","name":"Shell","input":{"command":"pwd","working_directory":` + + string(linkJSON) + `}}]}}` + "\n" + if err := os.WriteFile(sessionFile, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + + got := resolveResumeDir(&db.Session{ + Agent: "cursor", + Project: "li_openhouse", + FilePath: &sessionFile, + }) + assertSameDir(t, "resolveResumeDir()", got, realProject) +} + +func TestResolveSessionDirCursorProjectFallbackCanonicalizesSymlink(t *testing.T) { + tmpDir := t.TempDir() + + realProject := filepath.Join(tmpDir, "repos", "openhouse") + if err := os.MkdirAll(realProject, 0o755); err != nil { + t.Fatal(err) + } + cacheDir := filepath.Join(tmpDir, "project_cache_hdfs") + if err := os.MkdirAll(cacheDir, 0o755); err != nil { + t.Fatal(err) + } + linkProject := filepath.Join(cacheDir, "openhouse") + if err := os.Symlink(realProject, linkProject); err != nil { + t.Skipf("symlink not supported: %v", err) + } + + got := resolveSessionDir(&db.Session{ + Agent: "cursor", + Project: linkProject, + }) + assertSameDir(t, "resolveSessionDir()", got, realProject) +} + +func TestResumeLaunchCwd(t *testing.T) { + cwd := t.TempDir() + + tests := []struct { + name string + agent string + openerID string + goos string + want string + }{ + { + name: "claude keeps cwd for auto darwin launch", + agent: "claude", + openerID: "auto", + goos: "darwin", + want: cwd, + }, + { + name: "cursor auto darwin launch keeps cwd", + agent: "cursor", + openerID: "auto", + goos: "darwin", + want: cwd, + }, + { + name: "cursor iterm2 darwin launch keeps cwd", + agent: "cursor", + openerID: "iterm2", + goos: "darwin", + want: cwd, + }, + { + name: "cursor terminal darwin launch keeps cwd", + agent: "cursor", + openerID: "terminal", + goos: "darwin", + want: cwd, + }, + { + name: "cursor ghostty darwin launch keeps cwd", + agent: "cursor", + openerID: "ghostty", + goos: "darwin", + want: cwd, + }, + { + name: "cursor kitty darwin launch keeps cwd flag", + agent: "cursor", + openerID: "kitty", + goos: "darwin", + want: cwd, + }, + { + name: "cursor linux launch keeps cwd", + agent: "cursor", + openerID: "ghostty", + goos: "linux", + want: cwd, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := resumeLaunchCwd( + tt.agent, tt.openerID, tt.goos, cwd, + ) + if got != tt.want { + t.Errorf( + "resumeLaunchCwd() = %q, want %q", got, tt.want, ) } }) } } + +func encodeCursorProjectPathForTest(path string) string { + clean := filepath.Clean(path) + var tokens []string + if volume := filepath.VolumeName(clean); volume != "" { + tokens = append(tokens, strings.TrimSuffix(volume, ":")) + clean = strings.TrimPrefix(clean, volume) + } + parts := strings.SplitSeq(clean, string(filepath.Separator)) + for part := range parts { + if part == "" { + continue + } + tokens = append(tokens, cursorComponentTokens(part)...) + } + return strings.Join(tokens, "-") +} diff --git a/internal/sync/engine.go b/internal/sync/engine.go index 703f03a6..c83ec9bc 100644 --- a/internal/sync/engine.go +++ b/internal/sync/engine.go @@ -347,23 +347,19 @@ func (e *Engine) classifyOnePath( } } - // Cursor: //agent-transcripts/.{txt,jsonl} + // Cursor: + // //agent-transcripts/.{txt,jsonl} + // //agent-transcripts//.{txt,jsonl} for _, cursorDir := range e.agentDirs[parser.AgentCursor] { if cursorDir == "" { continue } if rel, ok := isUnder(cursorDir, path); ok { - parts := strings.Split(rel, sep) - if len(parts) != 3 { - continue - } - if parts[1] != "agent-transcripts" { + projectDir, ok := parser.ParseCursorTranscriptRelPath(rel) + if !ok { continue } - if !parser.IsCursorTranscriptExt(parts[2]) { - continue - } - project := parser.DecodeCursorProjectDir(parts[0]) + project := parser.DecodeCursorProjectDir(projectDir) if project == "" { project = "unknown" } @@ -2385,12 +2381,22 @@ func (e *Engine) SyncSingleSession(sessionID string) error { file.Project = filepath.Base(filepath.Dir(path)) } case parser.AgentCursor: - // path is //agent-transcripts/.txt - // Extract project dir name from two levels up - projDir := filepath.Base( - filepath.Dir(filepath.Dir(path)), - ) - file.Project = parser.DecodeCursorProjectDir(projDir) + // Support both flat and nested transcript layouts. + for _, cursorDir := range e.agentDirs[parser.AgentCursor] { + rel, ok := isUnder(cursorDir, path) + if !ok { + continue + } + projDir, ok := parser.ParseCursorTranscriptRelPath(rel) + if !ok { + continue + } + file.Project = parser.DecodeCursorProjectDir(projDir) + break + } + if file.Project == "" { + file.Project = "unknown" + } case parser.AgentIflow: // path is //session-.jsonl // Extract project dir name from parent directory diff --git a/internal/sync/engine_integration_test.go b/internal/sync/engine_integration_test.go index fd7cb486..d552b1f4 100644 --- a/internal/sync/engine_integration_test.go +++ b/internal/sync/engine_integration_test.go @@ -199,6 +199,23 @@ func (e *testEnv) writeCursorSession( ) } +// writeNestedCursorSession creates a Cursor transcript file under +// the nested layout /agent-transcripts//. +func (e *testEnv) writeNestedCursorSession( + t *testing.T, cursorDir, project, sessionID, ext, + content string, +) string { + t.Helper() + return e.writeSession( + t, cursorDir, + filepath.Join( + project, "agent-transcripts", sessionID, + sessionID+ext, + ), + content, + ) +} + func TestSyncEngineIntegration(t *testing.T) { env := setupTestEnv(t) @@ -1752,6 +1769,67 @@ func TestSyncEngineMultiCursorDir(t *testing.T) { } } +func TestSyncPathsCursorNestedLayout(t *testing.T) { + env := setupTestEnv(t) + + path := env.writeNestedCursorSession( + t, env.cursorDir, + "Users-alice-code-nested-proj", + "nested-sync", ".jsonl", + "user:\nHello nested cursor\nassistant:\nHi there!\n", + ) + + env.engine.SyncPaths([]string{path}) + + assertSessionProject( + t, env.db, "cursor:nested-sync", "nested_proj", + ) + assertSessionMessageCount( + t, env.db, "cursor:nested-sync", 2, + ) +} + +func TestSyncSingleSessionCursorNestedLayoutPreservesProject( + t *testing.T, +) { + env := setupTestEnv(t) + + path := env.writeNestedCursorSession( + t, env.cursorDir, + "Users-alice-code-nested-proj", + "nested-resync", ".jsonl", + "user:\nHello nested cursor\nassistant:\nHi there!\n", + ) + + runSyncAndAssert(t, env.engine, sync.SyncStats{ + TotalSessions: 1, Synced: 1, Skipped: 0, + }) + assertSessionProject( + t, env.db, "cursor:nested-resync", "nested_proj", + ) + + updated := "user:\nHello nested cursor\n" + + "assistant:\nHi there!\n" + + "user:\nFollow-up\n" + + "assistant:\nGot it.\n" + if err := os.WriteFile(path, []byte(updated), 0o644); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + if err := env.engine.SyncSingleSession( + "cursor:nested-resync", + ); err != nil { + t.Fatalf("SyncSingleSession: %v", err) + } + + assertSessionProject( + t, env.db, "cursor:nested-resync", "nested_proj", + ) + assertSessionMessageCount( + t, env.db, "cursor:nested-resync", 4, + ) +} + func TestSyncForkDetection(t *testing.T) { env := setupTestEnv(t) From 367dfeed332124b3ef907b3f88bb81115777568c Mon Sep 17 00:00:00 2001 From: Wes McKinney Date: Sun, 5 Apr 2026 20:45:38 -0500 Subject: [PATCH 2/3] fix: prevent incomplete Cursor fallback resume commands buildResumeCommand now returns null for Cursor since the resume command requires server-resolved --workspace and cwd parameters. Without those, the copied fallback command points to the wrong workspace. The server API path (formatResumeResponseCommand) still builds the full command. Co-Authored-By: Claude Opus 4.6 (1M context) --- frontend/src/lib/utils/resume.test.ts | 4 ++-- frontend/src/lib/utils/resume.ts | 9 +++++++++ 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/frontend/src/lib/utils/resume.test.ts b/frontend/src/lib/utils/resume.test.ts index bdb695c7..8d228581 100644 --- a/frontend/src/lib/utils/resume.test.ts +++ b/frontend/src/lib/utils/resume.test.ts @@ -47,10 +47,10 @@ describe("buildResumeCommand", () => { ).toBe("gemini --resume sess-2"); }); - it("generates cursor resume command", () => { + it("returns null for cursor (server-only resume)", () => { expect( buildResumeCommand("cursor", "cursor:chat-7"), - ).toBe("cursor agent --resume chat-7"); + ).toBeNull(); }); it("generates opencode resume command", () => { diff --git a/frontend/src/lib/utils/resume.ts b/frontend/src/lib/utils/resume.ts index 26ff3486..d275a763 100644 --- a/frontend/src/lib/utils/resume.ts +++ b/frontend/src/lib/utils/resume.ts @@ -18,6 +18,14 @@ RESUME_AGENTS["opencode"] = (id) => RESUME_AGENTS["amp"] = (id) => `amp --resume ${shellQuote(id)}`; +/** + * Agents whose resume commands require server-resolved parameters + * (e.g. --workspace, cwd) that the client cannot compute locally. + * buildResumeCommand returns null for these agents so callers + * don't produce incomplete fallback commands. + */ +const SERVER_ONLY_RESUME = new Set(["cursor"]); + /** Flags available for Claude Code resume. */ export interface ClaudeResumeFlags { skipPermissions?: boolean; @@ -79,6 +87,7 @@ export function buildResumeCommand( sessionId: string, flags?: ClaudeResumeFlags, ): string | null { + if (SERVER_ONLY_RESUME.has(agent)) return null; const builder = RESUME_AGENTS[agent]; if (!builder) return null; From 2381a427864d92d14ac259f541a4c7ddf579b748 Mon Sep 17 00:00:00 2001 From: Wes McKinney Date: Mon, 6 Apr 2026 09:54:23 -0500 Subject: [PATCH 3/3] test: fix flaky minimap scroll test on webkit CI Guard the scroll assertion on the container being scrollable and increase the poll timeout from 3s to 5s for slower webkit rendering. Co-Authored-By: Claude Opus 4.6 (1M context) --- frontend/e2e/activity-minimap.spec.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/frontend/e2e/activity-minimap.spec.ts b/frontend/e2e/activity-minimap.spec.ts index 503d38bb..a083b947 100644 --- a/frontend/e2e/activity-minimap.spec.ts +++ b/frontend/e2e/activity-minimap.spec.ts @@ -66,12 +66,15 @@ test.describe("Activity Minimap", () => { const lastBar = clickableBars.nth(barCount - 1); await lastBar.click(); - if (barCount > 1) { + const isScrollable = await sp.scroller.evaluate( + (el) => el.scrollHeight > el.clientHeight, + ); + if (barCount > 1 && isScrollable) { await expect .poll( () => sp.scroller.evaluate((el) => el.scrollTop), - { timeout: 3000 }, + { timeout: 5000 }, ) .not.toBe(scrollBefore); }