Files
wehub-resource-sync f99010fae1
CI / lint (push) Failing after 1s
CI / frontend (push) Failing after 1s
CI / scripts (push) Failing after 1s
CI / Go Test (ubuntu-latest) (push) Failing after 0s
CI / frontend-node-25 (push) Failing after 1s
CI / docs (push) Failing after 0s
CI / coverage (push) Failing after 0s
CI / e2e (push) Failing after 0s
Docker / build-and-push (push) Failing after 1s
CI / integration (push) Failing after 4m43s
CI / Go Test (windows-latest) (push) Has been cancelled
CI / Desktop Unit Tests (Windows) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Linux (arm64)) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Linux) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Windows) (push) Has been cancelled
Desktop Artifacts (macOS) / Desktop Build (macOS (aarch64)) (push) Has been cancelled
Desktop Artifacts (macOS) / Desktop Build (macOS (x86_64)) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:30:36 +08:00

829 lines
23 KiB
Go

package server
import (
"bufio"
"encoding/base64"
"encoding/binary"
"fmt"
"net/url"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"unicode/utf16"
"github.com/google/shlex"
"github.com/tidwall/gjson"
"go.kenn.io/agentsview/internal/config"
"go.kenn.io/agentsview/internal/db"
"go.kenn.io/agentsview/internal/parser"
)
// resumeRequest is the JSON body for POST /api/v1/sessions/{id}/resume.
type resumeRequest struct {
SkipPermissions bool `json:"skip_permissions,omitempty"`
ForkSession bool `json:"fork_session,omitempty"`
FromOrdinal *int `json:"from_ordinal,omitempty"`
CommandOnly bool `json:"command_only,omitempty"`
OpenerID string `json:"opener_id,omitempty"`
}
// resumeResponse is the JSON response for a resume request.
type resumeResponse struct {
Launched bool `json:"launched"`
Terminal string `json:"terminal,omitempty"`
Command string `json:"command"`
Cwd string `json:"cwd,omitempty"`
Error string `json:"error,omitempty"`
}
// resumeAgents maps agent type strings to their resume command templates.
// The %s placeholder is replaced with the (quoted) session ID.
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",
"kiro": "kiro-cli chat --resume-id %s",
}
// terminalCandidates lists terminal emulators to try on Linux, in
// preference order. Each entry is {binary, args-before-command...}.
// The resume command is appended after the last arg.
var terminalCandidates = []struct {
bin string
args []string
}{
{"kitty", []string{"--"}},
{"alacritty", []string{"-e"}},
{"wezterm", []string{"start", "--"}},
{"gnome-terminal", []string{"--", "bash", "-c"}},
{"konsole", []string{"-e"}},
{"xfce4-terminal", []string{"-e"}},
{"tilix", []string{"-e"}},
{"xterm", []string{"-e"}},
{"x-terminal-emulator", []string{"-e"}},
}
// shellQuote applies POSIX single-quote escaping.
func shellQuote(s string) string {
// Simple IDs: alphanumeric + hyphens need no quoting,
// but a leading '-' must always be quoted to prevent
// the value being interpreted as a CLI flag.
safe := len(s) == 0 || s[0] != '-'
if safe {
for _, c := range s {
if (c < 'a' || c > 'z') && (c < 'A' || c > 'Z') &&
(c < '0' || c > '9') && c != '-' && c != '_' {
safe = false
break
}
}
}
if safe {
return s
}
return "'" + strings.ReplaceAll(s, "'", `'"'"'`) + "'"
}
func commandWithCwd(cmd, cwd string) string {
if !isDir(cwd) {
return cmd
}
return fmt.Sprintf("cd %s && %s", shellQuote(cwd), cmd)
}
func commandWithCleanup(cmd, cleanupPath string) string {
if cleanupPath == "" {
return cmd
}
return cmd + "; rm -f -- " + shellQuote(cleanupPath)
}
func claudeMessagePointLaunchCommand(
promptPath string, skipPermissions bool,
) string {
cmd := "claude"
if skipPermissions {
cmd += " --dangerously-skip-permissions"
}
cmd += " < " + shellQuote(promptPath)
return commandWithCleanup(cmd, promptPath)
}
func claudeMessagePointResponseCommand(
promptPath string, skipPermissions bool, cwd string, goos string,
) string {
if goos == "windows" {
return claudeMessagePointWindowsCommand(
promptPath, skipPermissions, cwd,
)
}
return commandWithCwd(
claudeMessagePointLaunchCommand(promptPath, skipPermissions),
cwd,
)
}
func claudeMessagePointWindowsCommand(
promptPath string, skipPermissions bool, cwd string,
) string {
script := claudeMessagePointWindowsScript(
promptPath, skipPermissions, cwd,
)
return "powershell.exe -NoProfile -EncodedCommand " +
powerShellEncodedCommand(script)
}
func claudeMessagePointWindowsScript(
promptPath string, skipPermissions bool, cwd string,
) string {
var b strings.Builder
b.WriteString("try { ")
if cwd != "" {
b.WriteString("Set-Location -LiteralPath ")
b.WriteString(powerShellSingleQuote(cwd))
b.WriteString("; ")
}
b.WriteString("Get-Content -Raw -Encoding UTF8 -LiteralPath ")
b.WriteString(powerShellSingleQuote(promptPath))
b.WriteString(" | & 'claude'")
if skipPermissions {
b.WriteString(" --dangerously-skip-permissions")
}
b.WriteString(" } finally { Remove-Item -LiteralPath ")
b.WriteString(powerShellSingleQuote(promptPath))
b.WriteString(" -Force -ErrorAction SilentlyContinue }")
return b.String()
}
func powerShellSingleQuote(s string) string {
return "'" + strings.ReplaceAll(s, "'", "''") + "'"
}
func powerShellEncodedCommand(script string) string {
codeUnits := utf16.Encode([]rune(script))
raw := make([]byte, len(codeUnits)*2)
for i, unit := range codeUnits {
binary.LittleEndian.PutUint16(raw[i*2:], unit)
}
return base64.StdEncoding.EncodeToString(raw)
}
func claudeMessagePointPromptPath(sessionID string, ordinal int) (string, error) {
cacheDir, err := os.UserCacheDir()
if err != nil {
return "", err
}
dir := filepath.Join(cacheDir, "agentsview", "claude-message-points")
if err := os.MkdirAll(dir, 0o755); err != nil {
return "", err
}
prefix := sanitizeFilename(
fmt.Sprintf("%s-ordinal-%d-", sessionID, ordinal),
)
f, err := os.CreateTemp(dir, prefix+"*.txt")
if err != nil {
return "", err
}
if err := f.Close(); err != nil {
_ = os.Remove(f.Name())
return "", err
}
return f.Name(), nil
}
func renderClaudeMessagePointPrompt(
session *db.Session, msgs []db.Message, ordinal int,
) string {
var b strings.Builder
b.WriteString("You are continuing a Claude Code conversation forked from AgentsView.\n")
b.WriteString("Treat the transcript below as the full context through the selected message ordinal.\n")
b.WriteString("Continue the conversation from the next turn.\n\n")
b.WriteString("Session: ")
b.WriteString(session.ID)
b.WriteString("\n")
if session.Project != "" {
b.WriteString("Project: ")
b.WriteString(session.Project)
b.WriteString("\n")
}
fmt.Fprintf(&b, "Fork point ordinal: %d\n\n", ordinal)
b.WriteString("Transcript:\n")
for _, msg := range msgs {
fmt.Fprintf(&b, "\n## Message %d\n", msg.Ordinal)
b.WriteString("Role: ")
b.WriteString(msg.Role)
b.WriteString("\n")
if msg.Timestamp != "" {
b.WriteString("Timestamp: ")
b.WriteString(msg.Timestamp)
b.WriteString("\n")
}
if msg.Model != "" {
b.WriteString("Model: ")
b.WriteString(msg.Model)
b.WriteString("\n")
}
if body := renderClaudeMessagePointMessage(msg); body != "" {
b.WriteString("\n")
b.WriteString(body)
b.WriteString("\n")
}
}
return b.String()
}
func renderClaudeMessagePointMessage(msg db.Message) string {
var parts []string
if msg.Content != "" {
parts = append(parts, msg.Content)
}
for _, tc := range msg.ToolCalls {
header := tc.ToolName
if header == "" {
header = "Tool"
}
parts = append(parts, "["+header+"]")
if tc.InputJSON != "" {
parts = append(parts, tc.InputJSON)
}
if tc.ResultContent != "" {
parts = append(parts, tc.ResultContent)
}
}
return strings.Join(parts, "\n\n")
}
func writeClaudeMessagePointPrompt(
session *db.Session, msgs []db.Message, ordinal int,
) (string, error) {
path, err := claudeMessagePointPromptPath(session.ID, ordinal)
if err != nil {
return "", err
}
prompt := renderClaudeMessagePointPrompt(session, msgs, ordinal)
if err := os.WriteFile(path, []byte(prompt), 0o600); err != nil {
return "", err
}
return path, nil
}
// 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.
func detectTerminal(
cmd string, cwd string, tc config.TerminalConfig,
) (bin string, args []string, name string, err error) {
// Custom terminal mode — use the user-configured binary + args.
if tc.Mode == "custom" && tc.CustomBin != "" {
path, lookErr := exec.LookPath(tc.CustomBin)
if lookErr != nil {
return "", nil, "", fmt.Errorf(
"custom terminal %q not found: %w",
tc.CustomBin, lookErr,
)
}
displayName := filepath.Base(tc.CustomBin)
if tc.CustomArgs != "" {
// Shell-aware split so that quoted args like
// --title "My Terminal" are kept together.
parts, splitErr := shlex.Split(tc.CustomArgs)
if splitErr != nil {
return "", nil, "", fmt.Errorf(
"parsing custom_args: %w", splitErr,
)
}
a := make([]string, 0, len(parts))
for _, p := range parts {
a = append(a, strings.ReplaceAll(p, "{cmd}", cmd))
}
return path, a, displayName, nil
}
// No args template — default pattern.
return path, []string{"-e", "bash", "-c", cmd + "; exec bash"}, displayName, nil
}
switch runtime.GOOS {
case "darwin":
return detectTerminalDarwin(cmd, cwd)
case "linux":
return detectTerminalLinux(cmd)
default:
return "", nil, "", fmt.Errorf(
"unsupported OS %q for terminal launch", runtime.GOOS,
)
}
}
func detectTerminalDarwin(
cmd string, cwd string,
) (string, []string, string, error) {
// 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 := commandWithCwd(cmd, cwd)
// Try iTerm2 first.
if _, err := exec.LookPath("osascript"); err == nil {
safe := escapeForAppleScript(script)
// Check if iTerm is installed.
iterm := "/Applications/iTerm.app"
if _, err := os.Stat(iterm); err == nil {
appleScript := fmt.Sprintf(
`tell application "System Events"
set isRunning to (exists (processes whose name is "iTerm2"))
end tell
tell application "iTerm"
activate
if isRunning and (count of windows) > 0 then
tell current window
create tab with default profile
end tell
else
create window with default profile
end if
tell current window
tell current session
write text "%s"
end tell
end tell
end tell`,
safe,
)
return "osascript", []string{"-e", appleScript}, "iTerm2", nil
}
// Fall back to Terminal.app.
appleScript := fmt.Sprintf(
`tell application "Terminal"
activate
do script "%s"
end tell`,
safe,
)
return "osascript", []string{"-e", appleScript}, "Terminal", nil
}
return "", nil, "", fmt.Errorf("osascript not found on macOS")
}
// readSessionCwd reads the first few lines of a session JSONL file
// 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 {
// Kiro CLI stores cwd in a companion .json metadata file
// alongside the .jsonl session file.
if before, ok := strings.CutSuffix(path, ".jsonl"); ok {
metaPath := before + ".json"
if data, err := os.ReadFile(metaPath); err == nil {
if cwd := gjson.GetBytes(data, "cwd").Str; cwd != "" {
return cwd
}
}
}
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
}
defer f.Close()
reader := bufio.NewReader(f)
for lineNum := 0; maxLines <= 0 || lineNum < maxLines; lineNum++ {
line, err := reader.ReadBytes('\n')
if len(line) > 0 && !visit(line) {
return
}
if err != nil {
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
}
func isVirtualSessionPath(path string) bool {
if _, _, ok := parser.ParseVirtualSourcePathForBase(path, "data.sqlite3"); ok {
return true
}
if _, _, ok := parser.ParseVirtualSourcePathForBase(path, "opencode.db"); ok {
return true
}
return false
}
// resolveSessionDir determines the project directory for a session.
// It tries the session file's embedded cwd first, then the cached cwd,
// then Cursor's transcript-derived workspace path, then falls back to
// the session's project field. Virtual DB-backed file paths are storage
// locators only, so they skip source-file cwd reads and use cached cwd.
// All returned candidates must be absolute paths pointing to existing
// directories.
func resolveSessionDir(session *db.Session) string {
if session.FilePath != nil && !isVirtualSessionPath(*session.FilePath) {
if cwd := readSessionCwd(*session.FilePath); isDir(cwd) {
return cwd
}
}
if isDir(session.Cwd) {
return session.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
}
info, err := os.Stat(path)
if err != nil || info == nil {
return false
}
return info.IsDir()
}
func detectTerminalLinux(cmd string) (string, []string, string, error) {
// Check $TERMINAL env var first. The value may contain
// arguments (e.g. "kitty --single-instance"), so split it
// with a shell lexer and use the first token for LookPath.
if envTerm := os.Getenv("TERMINAL"); envTerm != "" {
parts, splitErr := shlex.Split(envTerm)
if splitErr == nil && len(parts) > 0 {
if path, err := exec.LookPath(parts[0]); err == nil {
base := filepath.Base(parts[0])
args := buildTerminalArgs(base, cmd)
// Prepend extra tokens from $TERMINAL before
// the template args (e.g. --single-instance).
if len(parts) > 1 {
args = append(parts[1:], args...)
}
return path, args, base, nil
}
}
}
// Try each candidate in preference order.
for _, c := range terminalCandidates {
path, err := exec.LookPath(c.bin)
if err != nil {
continue
}
return path, buildTerminalArgs(c.bin, cmd), c.bin, nil
}
return "", nil, "", fmt.Errorf(
"no terminal emulator found; install kitty, alacritty, " +
"gnome-terminal, or set $TERMINAL",
)
}
// buildTerminalArgs returns the argument list for launching a command
// in a named terminal. The bin parameter is the terminal basename
// (e.g. "kitty", "gnome-terminal"). Used by both $TERMINAL and the
// auto-detection loop.
func buildTerminalArgs(bin, cmd string) []string {
switch bin {
case "gnome-terminal":
return []string{"--", "bash", "-c", cmd + "; exec bash"}
case "kitty":
return []string{"--", "bash", "-c", cmd + "; exec bash"}
case "alacritty":
return []string{"-e", "bash", "-c", cmd + "; exec bash"}
case "wezterm":
return []string{"start", "--", "bash", "-c", cmd + "; exec bash"}
case "konsole":
return []string{"-e", "bash", "-c", cmd + "; exec bash"}
case "xfce4-terminal":
return []string{"-e", "bash -c '" + strings.ReplaceAll(cmd, "'", `'"'"'`) + "; exec bash'"}
case "tilix":
return []string{"-e", "bash -c '" + strings.ReplaceAll(cmd, "'", `'"'"'`) + "; exec bash'"}
case "xterm":
return []string{"-e", "bash", "-c", cmd + "; exec bash"}
default:
return []string{"-e", "bash", "-c", cmd + "; exec bash"}
}
}
// launchResumeInOpener builds an exec.Cmd that runs a shell command
// inside the terminal identified by the opener. Returns nil if the
// opener kind is not "terminal" (or "action" for special openers like
// Claude Desktop) or the terminal is not supported.
func launchResumeInOpener(
o Opener, cmd string, cwd string,
) *exec.Cmd {
if o.ID == "claude-desktop" {
return nil // handled separately via launchClaudeDesktop
}
if o.Kind != "terminal" {
return nil
}
if runtime.GOOS == "darwin" {
return launchResumeDarwin(o, cmd, cwd)
}
// Linux: launch via CLI binary with per-terminal arg patterns.
// Wrap the resume command so the shell stays open after it exits.
args := buildTerminalArgs(o.ID, cmd+"; exec bash")
proc := exec.Command(o.Bin, args...)
if cwd != "" {
proc.Dir = cwd
}
proc.Stdout = nil
proc.Stderr = nil
proc.Stdin = nil
return proc
}
// launchResumeDarwin launches a resume command in a macOS terminal
// app. Uses AppleScript for iTerm2/Terminal.app and `open -na` with
// appropriate flags for others.
func launchResumeDarwin(
o Opener, cmd string, cwd string,
) *exec.Cmd {
// For AppleScript-based terminals, build a single shell command
// 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 {
case "iterm2":
script := fmt.Sprintf(
`tell application "System Events"
set isRunning to (exists (processes whose name is "iTerm2"))
end tell
tell application "iTerm"
activate
if isRunning and (count of windows) > 0 then
tell current window
create tab with default profile
end tell
else
create window with default profile
end if
tell current window
tell current session
write text "%s"
end tell
end tell
end tell`, safe,
)
return exec.Command("osascript", "-e", script)
case "terminal":
script := fmt.Sprintf(
`tell application "Terminal"
activate
do script "%s"
end tell`, safe,
)
return exec.Command("osascript", "-e", script)
case "ghostty":
var args []string
if cwd != "" {
args = append(args, "--working-directory="+cwd)
}
args = append(args, "-e", "bash", "-c",
cmd+"; exec bash")
return macExecCommand(o.Bin, args...)
case "kitty":
var args []string
if cwd != "" {
args = append(args, "-d", cwd)
}
args = append(args, "bash", "-c", cmd+"; exec bash")
return macExecCommand(o.Bin, args...)
case "alacritty":
var args []string
if cwd != "" {
args = append(args, "--working-directory", cwd)
}
args = append(args, "-e", "bash", "-c",
cmd+"; exec bash")
return macExecCommand(o.Bin, args...)
case "wezterm":
args := []string{"start"}
if cwd != "" {
args = append(args, "--cwd", cwd)
}
args = append(args, "--", "bash", "-c",
cmd+"; exec bash")
return macExecCommand(o.Bin, args...)
default:
return nil
}
}
// launchClaudeDesktop builds an exec.Cmd that opens a Claude Code
// session in Claude Desktop via the claude:// URL scheme. The URL
// format is claude://resume?session={id}&cwd={path}.
func launchClaudeDesktop(sessionID string, cwd string) *exec.Cmd {
u := "claude://resume?session=" + url.QueryEscape(sessionID)
if cwd != "" {
u += "&cwd=" + url.QueryEscape(cwd)
}
return exec.Command("open", u)
}