| 1 | package cli |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "errors" |
| 6 | "fmt" |
| 7 | "path/filepath" |
| 8 | "sort" |
| 9 | "strconv" |
| 10 | "strings" |
| 11 | |
| 12 | "reasonix/internal/agent" |
| 13 | "reasonix/internal/control" |
| 14 | "reasonix/internal/i18n" |
| 15 | "reasonix/internal/session" |
| 16 | ) |
| 17 | |
| 18 | const resumeListCap = 10 |
| 19 | |
| 20 | // resumeEntry is one picker row: a session plus, for cross-project rows, the |
| 21 | // project it belongs to. The current directory's sessions keep project empty |
| 22 | // so existing labels are unchanged. The target carries whether the row is a |
| 23 | // legacy transcript or a final-format session identity. |
| 24 | type resumeEntry struct { |
| 25 | session agent.SessionInfo |
| 26 | project string |
| 27 | target cliResumeTarget |
| 28 | } |
| 29 | |
| 30 | const resumeOtherProjectsCap = 5 |
| 31 | |
| 32 | // resumeEntries lists the current directory's resumable conversations — both |
| 33 | // legacy transcripts and final-format catalog rows — then the newest session |
| 34 | // of other known projects. A user who worked on this machine over SSH resumes |
| 35 | // from any directory, not only the workspace root (#9477), and must see the |
| 36 | // same conversations the desktop tree shows after a migration. |
| 37 | func resumeEntries(dir string) []resumeEntry { |
| 38 | base := mergedResumeEntries(dir, resumeListCap) |
| 39 | out := make([]resumeEntry, 0, len(base)+resumeOtherProjectsCap) |
| 40 | out = append(out, base...) |
| 41 | out = append(out, otherProjectResumeEntries(dir)...) |
| 42 | return out |
| 43 | } |
| 44 | |
| 45 | func otherProjectResumeEntries(excludeDir string) []resumeEntry { |
| 46 | type target struct { |
| 47 | path string |
| 48 | root string |
| 49 | } |
| 50 | var targets []target |
| 51 | for _, t := range defaultSessionCatalogTargets() { |
| 52 | if t.Scope != "project" || t.Path == "" { |
| 53 | continue |
| 54 | } |
| 55 | targets = append(targets, target{path: t.Path, root: t.WorkspaceRoot}) |
| 56 | } |
| 57 | exclude := filepath.Clean(excludeDir) |
| 58 | var out []resumeEntry |
| 59 | for _, t := range targets { |
| 60 | if filepath.Clean(t.path) == exclude { |
| 61 | continue |
| 62 | } |
| 63 | // Cross-project rows are legacy transcripts only: a canonical identity |
| 64 | // belongs to another project's session service. Migrated sources stay |
| 65 | // hidden so a row always points at a still-live transcript. |
| 66 | rows := foreignProjectResumeRows(t.path) |
| 67 | if len(rows) == 0 { |
| 68 | continue |
| 69 | } |
| 70 | name := filepath.Base(strings.TrimRight(t.root, string(filepath.Separator))) |
| 71 | if name == "" || name == "." { |
| 72 | name = t.root |
| 73 | } |
| 74 | out = append(out, resumeEntry{session: rows[0], project: name, target: cliResumeTarget{path: rows[0].Path}}) |
| 75 | } |
| 76 | sort.SliceStable(out, func(i, j int) bool { |
| 77 | return out[i].session.ModTime.After(out[j].session.ModTime) |
| 78 | }) |
| 79 | if len(out) > resumeOtherProjectsCap { |
| 80 | out = out[:resumeOtherProjectsCap] |
| 81 | } |
| 82 | return out |
| 83 | } |
| 84 | |
| 85 | // orderResumeSessions keeps conflict-recovery copies next to the session they |
| 86 | // came from. Groups remain newest-first, while the newest visible leaf is first |
| 87 | // within each group so interactive picker and numbered resume surfaces present |
| 88 | // the most likely writable continuation before its ancestors. |
| 89 | func orderResumeSessions(sessions []agent.SessionInfo) []agent.SessionInfo { |
| 90 | if len(sessions) < 2 { |
| 91 | return sessions |
| 92 | } |
| 93 | byID := make(map[string]agent.SessionInfo, len(sessions)) |
| 94 | for _, session := range sessions { |
| 95 | byID[agent.BranchID(session.Path)] = session |
| 96 | } |
| 97 | type resumeGroup struct { |
| 98 | items []agent.SessionInfo |
| 99 | newest int |
| 100 | activity int64 |
| 101 | } |
| 102 | groups := make(map[string]*resumeGroup, len(sessions)) |
| 103 | order := make([]*resumeGroup, 0, len(sessions)) |
| 104 | for i, session := range sessions { |
| 105 | key := recoveryResumeGroupKey(session, byID) |
| 106 | group := groups[key] |
| 107 | if group == nil { |
| 108 | group = &resumeGroup{newest: i} |
| 109 | groups[key] = group |
| 110 | order = append(order, group) |
| 111 | } |
| 112 | group.items = append(group.items, session) |
| 113 | if stamp := session.ModTime.UnixNano(); stamp > group.activity { |
| 114 | group.activity = stamp |
| 115 | } |
| 116 | } |
| 117 | sort.SliceStable(order, func(i, j int) bool { |
| 118 | if order[i].activity == order[j].activity { |
| 119 | return order[i].newest < order[j].newest |
| 120 | } |
| 121 | return order[i].activity > order[j].activity |
| 122 | }) |
| 123 | |
| 124 | out := make([]agent.SessionInfo, 0, len(sessions)) |
| 125 | for _, group := range order { |
| 126 | children := make(map[string]bool, len(group.items)) |
| 127 | members := make(map[string]bool, len(group.items)) |
| 128 | for _, session := range group.items { |
| 129 | members[agent.BranchID(session.Path)] = true |
| 130 | } |
| 131 | for _, session := range group.items { |
| 132 | parentID := strings.TrimSpace(session.ParentID) |
| 133 | if members[parentID] { |
| 134 | children[parentID] = true |
| 135 | } |
| 136 | } |
| 137 | sort.SliceStable(group.items, func(i, j int) bool { |
| 138 | iLeaf := !children[agent.BranchID(group.items[i].Path)] |
| 139 | jLeaf := !children[agent.BranchID(group.items[j].Path)] |
| 140 | if iLeaf != jLeaf { |
| 141 | return iLeaf |
| 142 | } |
| 143 | return group.items[i].ModTime.After(group.items[j].ModTime) |
| 144 | }) |
| 145 | out = append(out, group.items...) |
| 146 | } |
| 147 | return out |
| 148 | } |
| 149 | |
| 150 | func recoveryResumeGroupKey(session agent.SessionInfo, byID map[string]agent.SessionInfo) string { |
| 151 | id := agent.BranchID(session.Path) |
| 152 | if !session.Recovered { |
| 153 | return id |
| 154 | } |
| 155 | seen := map[string]bool{id: true} |
| 156 | current := session |
| 157 | for { |
| 158 | parentID := strings.TrimSpace(current.ParentID) |
| 159 | if parentID == "" { |
| 160 | return agent.BranchID(current.Path) |
| 161 | } |
| 162 | if seen[parentID] { |
| 163 | return "recovery-cycle:" + parentID |
| 164 | } |
| 165 | seen[parentID] = true |
| 166 | parent, ok := byID[parentID] |
| 167 | if !ok { |
| 168 | return "recovery-parent:" + parentID |
| 169 | } |
| 170 | if !parent.Recovered { |
| 171 | return parentID |
| 172 | } |
| 173 | current = parent |
| 174 | } |
| 175 | } |
| 176 | |
| 177 | // runResumeCommand handles "/resume": with no argument it opens the recent |
| 178 | // session picker; "/resume <n>" loads that |
| 179 | // session into the running controller in place — keeping the current model and |
| 180 | // replaying the transcript into scrollback. |
| 181 | func (m *chatTUI) runResumeCommand(input string) { |
| 182 | args := tokenizeArgs(input) // args[0] == "/resume" |
| 183 | if len(args) < 2 { |
| 184 | m.openResumePicker() |
| 185 | return |
| 186 | } |
| 187 | // Never run recovery GC between displaying a numeric index and resolving it |
| 188 | // here: dropping an earlier row would silently retarget the number the user |
| 189 | // already picked. Bare /resume and startup do that cleanup instead. |
| 190 | entries := resumeEntries(m.ctrl.SessionDir()) |
| 191 | if len(entries) == 0 { |
| 192 | m.notice(i18n.M.NoSessionToResume) |
| 193 | return |
| 194 | } |
| 195 | if m.ctrl.Running() { |
| 196 | m.notice(i18n.M.ResumeBusy) |
| 197 | return |
| 198 | } |
| 199 | idx, err := strconv.Atoi(strings.TrimSpace(args[1])) |
| 200 | if err != nil || idx < 1 || idx > len(entries) { |
| 201 | m.notice(fmt.Sprintf(i18n.M.ResumeBadIndexFmt, len(entries))) |
| 202 | return |
| 203 | } |
| 204 | target := entries[idx-1] |
| 205 | if resumeEntryIsActive(m.ctrl, target) { |
| 206 | m.notice(i18n.M.ResumeAlreadyActive) |
| 207 | return |
| 208 | } |
| 209 | detached := m.sessionReclaimed || m.takeover != nil && m.takeover.Returned() |
| 210 | if !detached { |
| 211 | // Persist the conversation we're leaving so switching back later restores it. |
| 212 | // Snapshot before moving the lease: the outgoing session must be written |
| 213 | // while this process still owns it. |
| 214 | if err := m.ctrl.Snapshot(); err != nil { |
| 215 | m.notice("resume: snapshot current session: " + err.Error()) |
| 216 | return |
| 217 | } |
| 218 | m.followSessionLease() |
| 219 | } |
| 220 | if target.target.canonical() { |
| 221 | if err := m.commitCanonicalSessionSwitch(target.target.ref); err != nil { |
| 222 | if !detached { |
| 223 | m.restoreSessionLease() |
| 224 | } |
| 225 | if errors.Is(err, session.ErrWriterOwned) { |
| 226 | m.pendingTakeoverPath = cliCanonicalRoute(target.target.ref.SessionID) |
| 227 | m.notice("resume: " + sessionWriterHeldNotice()) |
| 228 | m.notice("run /takeover to take this session over") |
| 229 | return |
| 230 | } |
| 231 | m.notice("resume: " + err.Error()) |
| 232 | return |
| 233 | } |
| 234 | } else if err := m.commitSessionSwitch(target.session.Path); err != nil { |
| 235 | m.notice("resume: " + sessionLeaseHeldNotice(err)) |
| 236 | if cliSessionTakeoverCandidate(err) { |
| 237 | m.pendingTakeoverPath = target.session.Path |
| 238 | m.notice("run /takeover to take this session over") |
| 239 | } |
| 240 | return |
| 241 | } |
| 242 | m.resumeAfterReclaim() |
| 243 | m.replayActiveBranch(i18n.M.ResumedTitle) |
| 244 | } |
| 245 | |
| 246 | // resumeEntryIsActive reports whether a picker row is the controller's current |
| 247 | // conversation. Canonical rows carry no live path, so they compare session |
| 248 | // identities instead of transcript files. |
| 249 | func resumeEntryIsActive(ctrl control.SessionAPI, entry resumeEntry) bool { |
| 250 | if entry.target.canonical() { |
| 251 | if identity, ok := ctrl.(control.IdentityLifecycle); ok { |
| 252 | if ref, bound := identity.SessionRef(); bound && ref == entry.target.ref { |
| 253 | return true |
| 254 | } |
| 255 | } |
| 256 | return false |
| 257 | } |
| 258 | return entry.session.Path == ctrl.SessionPath() |
| 259 | } |
| 260 | |
| 261 | // commitCanonicalSessionSwitch attaches the controller to an existing |
| 262 | // final-format session identity. Writer ownership is enforced by the session |
| 263 | // service's directory lease, so unlike a legacy switch there is no transcript |
| 264 | // path lease to move: the outgoing legacy lease is released and authority |
| 265 | // follows the controller binding. The order matches the legacy switch: secure |
| 266 | // the target, return the mirror of the session being left, then publish. |
| 267 | func (m *chatTUI) commitCanonicalSessionSwitch(ref session.SessionRef) error { |
| 268 | identity, ok := m.ctrl.(control.IdentityLifecycle) |
| 269 | if !ok || !identity.UsesExclusiveSession() { |
| 270 | return errors.New("final-format session resume requires the session engine") |
| 271 | } |
| 272 | service := identity.SessionService() |
| 273 | if service == nil { |
| 274 | return errors.New("session service unavailable") |
| 275 | } |
| 276 | ctx := context.Background() |
| 277 | // The probe grant secures the target writer before anything changes hands, |
| 278 | // so a held target (ErrWriterOwned) leaves this session, its lease and its |
| 279 | // mirror untouched. A retired codec has no writer; OpenSession upgrades it. |
| 280 | probe, err := service.Open(ctx, ref) |
| 281 | if err != nil && !errors.Is(err, session.ErrUnsupportedVersion) { |
| 282 | return err |
| 283 | } |
| 284 | if m.takeover != nil { |
| 285 | if err := m.takeover.leaveMirror("", nil); err != nil { |
| 286 | _ = probe.Release(ctx) |
| 287 | return err |
| 288 | } |
| 289 | } |
| 290 | if _, err := identity.OpenSession(ctx, ref); err != nil { |
| 291 | _ = probe.Release(ctx) |
| 292 | return err |
| 293 | } |
| 294 | // The controller now holds its own grant on the runtime; the probe's |
| 295 | // release cannot retire it. |
| 296 | if err := probe.Release(ctx); err != nil { |
| 297 | return fmt.Errorf("release session probe grant: %w", err) |
| 298 | } |
| 299 | if m.leases != nil { |
| 300 | if err := m.leases.Rebind(""); err != nil { |
| 301 | return err |
| 302 | } |
| 303 | } |
| 304 | return bindChatTUIAuthority(m) |
| 305 | } |
| 306 | |
| 307 | // runTakeoverCommand handles "/takeover": it force-takes the last refused |
| 308 | // resume target (or an explicit index/path argument) from the resident serve |
| 309 | // on this machine, then resumes it. |
| 310 | func (m *chatTUI) runTakeoverCommand(input string) { |
| 311 | m.echoLocalCommand(input) |
| 312 | args := tokenizeArgs(input) // args[0] == "/takeover" |
| 313 | target := strings.TrimSpace(m.pendingTakeoverPath) |
| 314 | if len(args) >= 2 { |
| 315 | target = strings.TrimSpace(args[1]) |
| 316 | if idx, err := strconv.Atoi(target); err == nil { |
| 317 | entries := resumeEntries(m.ctrl.SessionDir()) |
| 318 | if idx < 1 || idx > len(entries) { |
| 319 | m.notice(fmt.Sprintf(i18n.M.ResumeBadIndexFmt, len(entries))) |
| 320 | return |
| 321 | } |
| 322 | picked := entries[idx-1] |
| 323 | if picked.target.canonical() { |
| 324 | target = cliCanonicalRoute(picked.target.ref.SessionID) |
| 325 | } else { |
| 326 | target = picked.session.Path |
| 327 | } |
| 328 | } |
| 329 | } |
| 330 | if target == "" && m.sessionReclaimed { |
| 331 | // The reclaim notice promises "/takeover takes it back": the session |
| 332 | // the desktop re-claimed is remembered in reclaimedTarget, not in |
| 333 | // pendingTakeoverPath (which only a refused resume populates). |
| 334 | if m.reclaimedTarget.canonical() { |
| 335 | target = cliCanonicalRoute(m.reclaimedTarget.ref.SessionID) |
| 336 | } else { |
| 337 | target = m.reclaimedTarget.path |
| 338 | } |
| 339 | } |
| 340 | if target == "" { |
| 341 | m.notice("takeover: no refused session; run /resume <n> first or pass an index") |
| 342 | return |
| 343 | } |
| 344 | if isCLICanonicalRoute(target) { |
| 345 | m.runCanonicalTakeoverCommand(target) |
| 346 | return |
| 347 | } |
| 348 | if m.ctrl.Running() { |
| 349 | m.notice(i18n.M.ResumeBusy) |
| 350 | return |
| 351 | } |
| 352 | _, err := loadResumableSession(target) |
| 353 | if err != nil { |
| 354 | m.notice("takeover: " + err.Error()) |
| 355 | return |
| 356 | } |
| 357 | // A reclaimed session's runtime is already released; snapshotting it |
| 358 | // would fail and there is no lease of ours to follow. |
| 359 | detached := m.sessionDetached() |
| 360 | if !detached { |
| 361 | if err := m.ctrl.Snapshot(); err != nil { |
| 362 | m.notice("takeover: snapshot current session: " + err.Error()) |
| 363 | return |
| 364 | } |
| 365 | m.followSessionLease() |
| 366 | } |
| 367 | binding, bindErr := cliAcquireFreeSession(target, m.leases, m.takeover) |
| 368 | if bindErr != nil { |
| 369 | if !cliSessionTakeoverCandidate(bindErr) { |
| 370 | m.notice("takeover: " + sessionLeaseHeldNotice(bindErr)) |
| 371 | return |
| 372 | } |
| 373 | m.notice("taking the session over from the resident serve…") |
| 374 | binding, err = cliTakeoverHeldSession(target, bindErr, m.leases, m.takeover) |
| 375 | if err != nil { |
| 376 | m.notice("takeover: " + err.Error()) |
| 377 | return |
| 378 | } |
| 379 | } |
| 380 | loaded, err := cliPrepareTakeoverCandidate(binding, m.leases) |
| 381 | if err != nil { |
| 382 | _ = cliReturnFailedTakeover(binding, m.leases, m.takeover) |
| 383 | m.notice("takeover: " + err.Error()) |
| 384 | return |
| 385 | } |
| 386 | if err := binding.commitPrevious(m.takeover); err != nil { |
| 387 | _ = cliReturnFailedTakeover(binding, m.leases, m.takeover) |
| 388 | m.notice("takeover: " + err.Error()) |
| 389 | return |
| 390 | } |
| 391 | m.ctrl.Resume(loaded, target) |
| 392 | if err := bindChatTUIAuthority(m); err != nil { |
| 393 | m.notice("takeover: " + err.Error()) |
| 394 | return |
| 395 | } |
| 396 | m.pendingTakeoverPath = "" |
| 397 | m.resumeAfterReclaim() |
| 398 | m.replayActiveBranch(i18n.M.ResumedTitle) |
| 399 | if m.takeover != nil && binding.grant.MirrorID != "" { |
| 400 | m.takeover.AttachController(m.ctrl) |
| 401 | m.takeover.Activate(binding) |
| 402 | m.notice("session taken over; the remote side is now read-only and can take it back") |
| 403 | return |
| 404 | } |
| 405 | m.notice("session resumed; no other runtime held it") |
| 406 | } |
| 407 | |
| 408 | // resumeArgItems completes the index argument of "/resume <n>": once past the |
| 409 | // command word it lists recent sessions, inserting the 1-based index and |
| 410 | // showing timestamp + turn count + preview as the hint. Indices match |
| 411 | // the picker because both window through recentSessions. |
| 412 | func (m *chatTUI) resumeArgItems(val string) ([]compItem, int, bool) { |
| 413 | cmdEnd := strings.IndexAny(val, " \t") |
| 414 | if cmdEnd < 0 || val[:cmdEnd] != "/resume" { |
| 415 | return nil, 0, false |
| 416 | } |
| 417 | from := strings.LastIndexAny(val, " \t") + 1 |
| 418 | if len(strings.Fields(val[:from])) != 1 || m.ctrl == nil { |
| 419 | return nil, from, true |
| 420 | } |
| 421 | cur := val[from:] |
| 422 | var out []compItem |
| 423 | for i, entry := range resumeEntries(m.ctrl.SessionDir()) { |
| 424 | idx := strconv.Itoa(i + 1) |
| 425 | if cur != "" && !strings.HasPrefix(idx, cur) { |
| 426 | continue |
| 427 | } |
| 428 | hint := fmt.Sprintf("%s · %s", entry.session.ModTime.Local().Format("01-02 15:04"), sessionSummary(entry.session)) |
| 429 | if entry.project != "" { |
| 430 | hint = fmt.Sprintf("[%s] %s", entry.project, hint) |
| 431 | } |
| 432 | out = append(out, compItem{label: idx, insert: idx, hint: hint}) |
| 433 | } |
| 434 | return out, from, true |
| 435 | } |
| 436 | |
| 437 | // sessionSummary is the "N turns · display title" line shared by the /resume |
| 438 | // list and its argument completion. Explicit session renames win, then topic |
| 439 | // titles, then the raw preview so the user can identify sessions at a glance. |
| 440 | func sessionSummary(s agent.SessionInfo) string { |
| 441 | preview := s.CustomTitle |
| 442 | if preview == "" { |
| 443 | preview = s.TopicTitle |
| 444 | } |
| 445 | if preview == "" { |
| 446 | preview = s.Preview |
| 447 | } |
| 448 | if preview == "" { |
| 449 | preview = "(no user message yet)" |
| 450 | } |
| 451 | return recoverySessionBadge(s) + fmt.Sprintf("%d turns · %s", s.Turns, preview) |
| 452 | } |
| 453 | |
| 454 | func recoverySessionBadge(s agent.SessionInfo) string { |
| 455 | if !s.Recovered { |
| 456 | return "" |
| 457 | } |
| 458 | parent := strings.TrimSpace(s.ParentID) |
| 459 | if len(parent) > 8 { |
| 460 | parent = parent[:8] |
| 461 | } |
| 462 | if parent == "" { |
| 463 | parent = "?" |
| 464 | } |
| 465 | return fmt.Sprintf(i18n.M.ResumeRecoveryBadgeFmt, parent) + " " |
| 466 | } |
| 467 |