diff --git a/coordinator.go b/coordinator.go index 4ec3640..40613fb 100644 --- a/coordinator.go +++ b/coordinator.go @@ -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) { @@ -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) @@ -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 { @@ -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`. diff --git a/coordinator_test.go b/coordinator_test.go index 736d894..00a5955 100644 --- a/coordinator_test.go +++ b/coordinator_test.go @@ -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", "")