Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 29 additions & 3 deletions coordinator.go
Original file line number Diff line number Diff line change
Expand Up @@ -293,6 +293,19 @@ func sessionCwd(id string) string {
return c.Cwd
}

// sessionTitle is the name a session was given, "" if none.
func sessionTitle(id string) string {
paths, _ := filepath.Glob(filepath.Join(getProjectsDir(), "*", id+".jsonl"))
if len(paths) == 0 {
return ""
}
c, err := parseConversationFile(paths[0], time.Time{}, 0)
if err != nil || c == nil || !c.IsCustomTitle {
return ""
}
return c.Title
}

// mcpRun runs claude's or codex's mcp commands, detached so it can't prompt
// and with stderr, where claude says a server is missing. Stubbed in tests.
var mcpRun = func(bin string, args ...string) ([]byte, error) {
Expand Down Expand Up @@ -479,14 +492,18 @@ func adoptCoordinator(who string) (string, error) {
return "", err
}
rec := loadCoordinator()
id, name := "", ""
id, name, matches := "", "", 0
for sid, s := range scanSessions(nil) {
if !s.codex && (s.stat.name == who || sid == who) {
id, name = sid, s.stat.name
matches++
}
}
if matches > 1 {
return "", fmt.Errorf("%d running sessions are named %q: give the id of the one you mean", matches, who)
}
if id == "" && resumable(who, false) {
id = who
id, name = who, sessionTitle(who)
}
if id == "" {
id = namedTranscript(who)
Expand All @@ -495,6 +512,11 @@ func adoptCoordinator(who string) (string, error) {
if id == "" {
return "", fmt.Errorf("no Claude Code session named %q, and no session with that id", who)
}
// Coming back goes through launch, which takes only names like these.
if name != "" && !sessionName.MatchString(name) {
return "", fmt.Errorf("ccs couldn't reopen a session named %q: rename it with letters, digits, '.', '_' and '-' first (/rename there, or Ctrl+X E in the list)", name)
}
replaced := rec.SessionID != "" && rec.SessionID != id
rec.SessionID, rec.Offered = id, true
rec.Name = ""
if name != "" && name != coordinatorName {
Expand All @@ -503,7 +525,11 @@ func adoptCoordinator(who string) (string, error) {
if err := rec.save(); err != nil {
return "", err
}
return fmt.Sprintf("%s (%s) is now the coordinator", cmp.Or(name, id), id), nil
msg := fmt.Sprintf("%s (%s) is now the coordinator", cmp.Or(name, id), id)
if replaced {
msg += "; the one before it stays as an ordinary session"
}
return msg, nil
}

// runCoordinator is `ccs coordinator`.
Expand Down
63 changes: 63 additions & 0 deletions coordinator_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -493,6 +493,69 @@ func TestCoordinatorAdopt(t *testing.T) {
}
}

func TestCoordinatorAdoptOnlyWhatItCanBringBack(t *testing.T) {
session := func(t *testing.T, id, title string) {
t.Helper()
proj := filepath.Join(getProjectsDir(), "-w")
os.MkdirAll(proj, 0o700)
os.WriteFile(filepath.Join(proj, id+".jsonl"), []byte(`{"type":"user","cwd":"/tmp","sessionId":"`+id+`","timestamp":"2026-11-04T10:00:00Z","message":{"role":"user","content":"hi"}}`+"\n"+`{"type":"custom-title","customTitle":"`+title+`","sessionId":"`+id+`"}`+"\n"), 0o600)
}
t.Run("two running with the name: never a guess", func(t *testing.T) {
coordinatorFixture(t)
for i, id := range []string{"one", "two"} {
os.WriteFile(filepath.Join(getSessionsDir(), itoa(i)+".json"), []byte(`{"pid":`+itoa(os.Getpid())+`,"sessionId":"`+id+`","name":"hub"}`), 0o600)
}
out, code := runCoord("--adopt", "hub")
if code != 1 || !strings.Contains(out, "2 running sessions") || loadCoordinator().SessionID != "" {
t.Errorf("%d %s %+v", code, out, loadCoordinator())
}
if out, code := runCoord("--adopt", "two"); code != 0 || loadCoordinator().SessionID != "two" {
t.Errorf("by id: %d %s", code, out)
}
})
t.Run("a name launch can't take is refused now, not when reopening", func(t *testing.T) {
coordinatorFixture(t)
os.WriteFile(filepath.Join(getSessionsDir(), "7.json"), []byte(`{"pid":`+itoa(os.Getpid())+`,"sessionId":"their-id","name":"my hub"}`), 0o600)
out, code := runCoord("--adopt", "my hub")
if code != 1 || !strings.Contains(out, "rename it") || loadCoordinator().SessionID != "" {
t.Errorf("%d %s %+v", code, out, loadCoordinator())
}
})
t.Run("by id, closed: it keeps its name", func(t *testing.T) {
coordinatorFixture(t)
scripts := startsAs(t, "hub")
session(t, "their-id", "hub")
if out, code := runCoord("--adopt", "their-id"); code != 0 || !strings.Contains(out, "hub (their-id)") {
t.Fatalf("%d %s", code, out)
}
if r := loadCoordinator(); r.Name != "hub" {
t.Errorf("%+v", r)
}
if out, code := runCoord(); code != 0 || !strings.Contains(out, "Resumed hub") {
t.Fatalf("%d %s", code, out)
}
script, _ := os.ReadFile((*scripts)[0])
if !strings.Contains(string(script), "--name 'hub'") {
t.Errorf("renamed it:\n%s", script)
}
})
t.Run("by id, closed, with a name launch can't take", func(t *testing.T) {
coordinatorFixture(t)
session(t, "their-id", "my hub")
if out, code := runCoord("--adopt", "their-id"); code != 1 || !strings.Contains(out, "rename it") {
t.Errorf("%d %s", code, out)
}
})
t.Run("says when it replaces one", func(t *testing.T) {
coordinatorFixture(t)
coordinatorRecord{SessionID: "old"}.save()
session(t, "their-id", "hub")
if out, _ := runCoord("--adopt", "their-id"); !strings.Contains(out, "stays as an ordinary session") {
t.Error(out)
}
})
}

func TestCoordinatorInPlaceOutsideTabs(t *testing.T) {
coordinatorFixture(t)
t.Setenv("ITERM_SESSION_ID", "")
Expand Down
Loading