package main import ( "crypto/sha256" "encoding/json" "errors" "fmt" "io" "os" "path/filepath" "strings" "syscall" "time" "reasonix/internal/agent" "reasonix/internal/config" "reasonix/internal/control" "reasonix/internal/fileutil" "reasonix/internal/store" ) // sessions.go holds the desktop-only session-management state that the shared // kernel doesn't model: custom display titles. A session on disk is just a JSONL // transcript named by timestamp+model, with no title slot — so the history panel // stores user-chosen names in a sidecar map (basename → title) next to the .jsonl // files. The preview (first user message) is the default name; a title overrides // it. Deleting a session also drops its title entry. const sessionTitlesFile = ".titles.json" const sessionDisplayFile = ".display.json" const sessionPlannerDisplayFile = ".planner-display.json" const sessionTrashDir = ".trash" const sessionTrashMetaFile = ".trash-meta.json" func sessionTitlesPath(dir string) string { return filepath.Join(dir, sessionTitlesFile) } func sessionDisplayPath(dir string) string { return filepath.Join(dir, sessionDisplayFile) } func sessionTrashPath(dir string) string { return filepath.Join(dir, sessionTrashDir) } func desktopSessionDir(root string) string { root = strings.TrimSpace(root) if root == "" { cwd, err := os.Getwd() if err != nil { return config.SessionDir() } root = cwd } if dir := config.ProjectSessionDir(root); dir != "" { return dir } return config.SessionDir() } // loadSessionTitles reads the basename→title map (missing/corrupt → empty). func loadSessionTitles(dir string) map[string]string { m := map[string]string{} b, err := readFileWithTimeout(sessionTitlesPath(dir), topicFileReadTimeout) if err != nil { return m } _ = json.Unmarshal(b, &m) return m } func loadSessionTitlesForUpdate(dir string) (map[string]string, error) { return loadStringMapForUpdate(sessionTitlesPath(dir)) } // saveSessionTitles writes the map atomically (temp file + rename). func saveSessionTitles(dir string, m map[string]string) error { b, err := json.MarshalIndent(m, "", " ") if err != nil { return err } if err := os.MkdirAll(dir, 0o755); err != nil { return err } tmp, err := os.CreateTemp(dir, ".titles.*.tmp") if err != nil { return err } tmpPath := tmp.Name() if _, err := tmp.Write(b); err != nil { tmp.Close() os.Remove(tmpPath) return err } if err := tmp.Close(); err != nil { os.Remove(tmpPath) return err } return fileutil.ReplaceFile(tmpPath, sessionTitlesPath(dir)) } // setSessionTitle sets (or, with an empty title, clears) a session's custom name. func setSessionTitle(dir, sessionPath, title string) error { sessionPath, _, err := validateSessionPath(dir, sessionPath) if err != nil { return err } m, err := loadSessionTitlesForUpdate(dir) if err != nil { return err } key := filepath.Base(sessionPath) if strings.TrimSpace(title) == "" { delete(m, key) } else { m[key] = strings.TrimSpace(title) } return saveSessionTitles(dir, m) } // deleteSessionFile moves a session's .jsonl and file sidecars into the local // trash. Title/display sidecars stay in place so trash previews and restores can // preserve the user's labels. func deleteSessionFile(dir, sessionPath string) error { sessionPath, key, err := validateSessionPath(dir, sessionPath) if err != nil { return err } return trashSessionArtifacts(dir, sessionPath, key) } type trashedSessionMeta struct { Key string `json:"key"` DeletedAt int64 `json:"deletedAt"` } type sessionTrashArtifact struct { src string name string } func sessionTelemetryPath(sessionPath string) string { if strings.TrimSpace(sessionPath) == "" { return "" } return sessionPath + ".telemetry.json" } func sessionTrashArtifacts(sessionPath, key string) []sessionTrashArtifact { stem := strings.TrimSuffix(key, ".jsonl") return []sessionTrashArtifact{ {src: sessionPath, name: key}, {src: store.SessionMeta(sessionPath), name: key + ".meta"}, {src: store.SessionGoalState(sessionPath), name: stem + ".goal-state.json"}, {src: store.SessionEventLog(sessionPath), name: stem + ".events.jsonl"}, {src: store.SessionEventIndex(sessionPath), name: stem + ".event-index.json"}, {src: store.SessionConflictLog(sessionPath), name: stem + ".conflicts.jsonl"}, {src: sessionTelemetryPath(sessionPath), name: key + ".telemetry.json"}, {src: store.SessionCheckpointDir(sessionPath), name: stem + ".ckpt"}, {src: store.SessionJobsDir(sessionPath), name: stem + ".jobs"}, } } // errSessionBusyElsewhere is the sanitized error surfaced when a destructive // session operation is blocked by a live owner. It intentionally carries no // writer id, hostname, or path. var errSessionBusyElsewhere = errors.New("session is in use by another Reasonix window or process") // acquireSessionRemovalGuard wraps agent.TryAcquireSessionRemovalGuard with // the sanitized busy error. The guard holds the session's save and lease // locks across the destructive operation and deletes the lock files // atomically with the release — a one-shot busy probe followed by RemoveAll // would let another process acquire the lease in between and then lose its // freshly locked lease file, breaking cross-process mutual exclusion. func acquireSessionRemovalGuard(sessionPath string) (*agent.SessionRemovalGuard, error) { guard, err := agent.TryAcquireSessionRemovalGuard(sessionPath) if err != nil { if errors.Is(err, agent.ErrSessionLeaseHeld) { return nil, errSessionBusyElsewhere } return nil, err } return guard, nil } func sessionOwnedArtifactPaths(sessionPath string) []string { key := filepath.Base(sessionPath) artifacts := sessionTrashArtifacts(sessionPath, key) paths := make([]string, 0, len(artifacts)) for _, artifact := range artifacts { if strings.TrimSpace(artifact.src) != "" { paths = append(paths, artifact.src) } } return paths } func trashSessionArtifacts(dir, sessionPath, key string) error { return trashSessionArtifactsBeforeMove(dir, sessionPath, key, nil) } func reconcileDesktopCleanupPending(dir string) error { return agent.ReconcileCleanupPending(dir, func(item agent.CleanupPendingInfo) error { if strings.TrimSpace(item.Meta.Operation) == "delete" { sessionPath, key, err := validateSessionPath(dir, item.SessionPath) if err != nil { return err } return reconcileDesktopTrashSessionArtifacts(dir, sessionPath, key) } return removeDesktopSessionArtifacts(item.SessionPath) }) } func reconcileDesktopTrashSessionArtifacts(dir, sessionPath, key string) error { // Hold the removal guard across the whole move so no runtime can acquire // the session (or save into it) while its artifacts are relocated; the // lock sidecars are deleted atomically with the guard release. guard, err := acquireSessionRemovalGuard(sessionPath) if err != nil { return err } defer guard.Release() itemDir := filepath.Join(sessionTrashPath(dir), key) if info, err := os.Stat(itemDir); err == nil { if !info.IsDir() { return fmt.Errorf("session trash target is not a directory: %s", key) } trashPath := filepath.Join(itemDir, key) if trashInfo, err := os.Stat(trashPath); err == nil && !trashInfo.IsDir() { matches, err := trashSessionMatchesLive(sessionPath, trashPath) if err != nil { return err } if !matches { itemDir, err = reserveUniqueSessionTrashItemDir(dir, key) if err != nil { return err } } } else if err != nil && !os.IsNotExist(err) { return err } } else if os.IsNotExist(err) { if err := os.MkdirAll(itemDir, 0o755); err != nil { return err } } else { return err } for _, artifact := range sessionTrashArtifacts(sessionPath, key) { if err := movePathIfExists(artifact.src, filepath.Join(itemDir, artifact.name)); err != nil { return err } } if err := trashSubagentArtifacts(dir, sessionPath, itemDir); err != nil { return err } if err := guard.RemoveSidecarsAndRelease(); err != nil { return err } meta := trashedSessionMeta{Key: key, DeletedAt: time.Now().UnixMilli()} b, err := json.MarshalIndent(meta, "", " ") if err != nil { return err } if err := os.WriteFile(filepath.Join(itemDir, sessionTrashMetaFile), b, 0o644); err != nil { return err } return agent.ClearCleanupPending(sessionPath) } func validateSessionTrashTarget(dir, sessionPath, key string) error { if _, err := os.Stat(sessionPath); os.IsNotExist(err) { return nil } else if err != nil { return err } itemDir := filepath.Join(sessionTrashPath(dir), key) if info, err := os.Stat(itemDir); err == nil { if !info.IsDir() { return fmt.Errorf("session trash target is not a directory: %s", key) } trashPath := filepath.Join(itemDir, key) if trashInfo, err := os.Stat(trashPath); err == nil && !trashInfo.IsDir() { removable, err := liveSessionRemovableWithExistingTrash(sessionPath, trashPath) if err != nil { return err } if removable { return nil } if agent.SessionLeaseHeldByOtherRuntime(sessionPath) { return errSessionBusyElsewhere } return nil } else if err != nil && !os.IsNotExist(err) { return err } return nil } else if !os.IsNotExist(err) { return err } return nil } type preparedSessionTrashTarget struct { shouldMove bool itemDir string allocateUnique bool } func prepareSessionTrashTarget(dir, sessionPath, key string) (preparedSessionTrashTarget, error) { if _, err := os.Stat(sessionPath); os.IsNotExist(err) { return preparedSessionTrashTarget{}, nil } else if err != nil { return preparedSessionTrashTarget{}, err } itemDir := filepath.Join(sessionTrashPath(dir), key) if info, err := os.Stat(itemDir); err == nil { if !info.IsDir() { return preparedSessionTrashTarget{}, fmt.Errorf("session trash target is not a directory: %s", key) } trashPath := filepath.Join(itemDir, key) if trashInfo, err := os.Stat(trashPath); err == nil && !trashInfo.IsDir() { removable, err := liveSessionRemovableWithExistingTrash(sessionPath, trashPath) if err != nil { return preparedSessionTrashTarget{}, err } if removable { return preparedSessionTrashTarget{}, removeDesktopSessionArtifacts(sessionPath) } if agent.SessionLeaseHeldByOtherRuntime(sessionPath) { return preparedSessionTrashTarget{}, errSessionBusyElsewhere } return preparedSessionTrashTarget{shouldMove: true, allocateUnique: true}, nil } else if err != nil && !os.IsNotExist(err) { return preparedSessionTrashTarget{}, err } if err := os.RemoveAll(itemDir); err != nil { return preparedSessionTrashTarget{}, err } } else if !os.IsNotExist(err) { return preparedSessionTrashTarget{}, err } return preparedSessionTrashTarget{shouldMove: true, itemDir: itemDir}, nil } func reserveUniqueSessionTrashItemDir(dir, key string) (string, error) { root := sessionTrashPath(dir) if err := os.MkdirAll(root, 0o755); err != nil { return "", err } stem := strings.TrimSuffix(key, ".jsonl") for i := 0; i < 100; i++ { name := fmt.Sprintf("%s.jsonl-deleted-%d-%02d", stem, time.Now().UnixNano(), i) itemDir := filepath.Join(root, name) if err := os.Mkdir(itemDir, 0o755); err == nil { return itemDir, nil } else if !os.IsExist(err) { return "", err } } return "", fmt.Errorf("could not allocate unique trash target for session: %s", key) } // liveSessionRemovableWithExistingTrash reports whether a live session file may // be removed even though a trash copy already exists under the same key: the // live file must be discardable (empty stub) or byte-identical to the trash // copy, and no other runtime may hold its session lease — another process could // be mid-write, and removing the file would silently drop its next save. func liveSessionRemovableWithExistingTrash(sessionPath, trashPath string) (bool, error) { discardable, err := liveSessionDiscardable(sessionPath) if err != nil { return false, err } duplicate := false if !discardable { duplicate, err = trashSessionMatchesLive(sessionPath, trashPath) if err != nil { return false, err } } if !discardable && !duplicate { return false, nil } return !agent.SessionLeaseHeldByOtherRuntime(sessionPath), nil } func liveSessionDiscardable(sessionPath string) (bool, error) { if agent.IsCleanupPending(sessionPath) { return true, nil } info, err := os.Stat(sessionPath) if os.IsNotExist(err) { return true, nil } if err != nil { return false, err } if info.IsDir() { return false, nil } if info.Size() == 0 { return true, nil } session, err := agent.LoadSession(sessionPath) if err != nil { return false, nil } return !session.HasContent(), nil } func trashSessionMatchesLive(sessionPath, trashPath string) (bool, error) { if _, err := os.Stat(sessionPath); err != nil { if os.IsNotExist(err) { return true, nil } return false, err } // Compare decoded transcripts, not .jsonl bytes: the checkpoint only // changes at checkpoints, so two byte-identical .jsonl files can hide // diverged event logs — and treating them as duplicates would delete the // live session's newer history. return agent.SessionsShareContent(sessionPath, trashPath) } func sessionFileHasConversationContent(sessionPath string) bool { if strings.TrimSpace(sessionPath) == "" || agent.IsCleanupPending(sessionPath) { return false } info, err := os.Stat(sessionPath) if err != nil || info.IsDir() || info.Size() == 0 { return false } session, err := agent.LoadSession(sessionPath) if err != nil { return false } return session.HasContent() } func trashSessionArtifactsBeforeMove(dir, sessionPath, key string, beforeMove func()) error { if err := validateSessionTrashTarget(dir, sessionPath, key); err != nil { return err } target, err := prepareSessionTrashTarget(dir, sessionPath, key) if err != nil { return err } if !target.shouldMove { return nil } // Acquired after prepareSessionTrashTarget: the duplicate-trash path in // there takes its own removal guard, and the guard is not reentrant. guard, err := acquireSessionRemovalGuard(sessionPath) if err != nil { return err } defer guard.Release() if err := invalidateTopicDirMarkers(dir); err != nil { return err } itemDir := target.itemDir if target.allocateUnique { itemDir, err = reserveUniqueSessionTrashItemDir(dir, key) if err != nil { return err } } else if err := os.MkdirAll(itemDir, 0o755); err != nil { return err } if beforeMove != nil { beforeMove() } for _, artifact := range sessionTrashArtifacts(sessionPath, key) { if err := movePathIfExists(artifact.src, filepath.Join(itemDir, artifact.name)); err != nil { return err } } if err := trashSubagentArtifacts(dir, sessionPath, itemDir); err != nil { return err } if err := guard.RemoveSidecarsAndRelease(); err != nil { return err } meta := trashedSessionMeta{Key: key, DeletedAt: time.Now().UnixMilli()} b, err := json.MarshalIndent(meta, "", " ") if err != nil { return err } if err := os.WriteFile(filepath.Join(itemDir, sessionTrashMetaFile), b, 0o644); err != nil { return err } if err := agent.ClearCleanupPending(sessionPath); err != nil { return err } return nil } func listTrashedSessionFiles(dir string) ([]string, error) { root := sessionTrashPath(dir) entries, err := os.ReadDir(root) if err != nil { if os.IsNotExist(err) { return []string{}, nil } return nil, err } paths := []string{} for _, e := range entries { if !e.IsDir() { continue } itemDir := filepath.Join(root, e.Name()) keys := []string{} if b, err := readFileUTF8(filepath.Join(itemDir, sessionTrashMetaFile)); err == nil { var meta trashedSessionMeta if json.Unmarshal(b, &meta) == nil && store.IsSessionTranscriptName(meta.Key) { keys = append(keys, meta.Key) } } if store.IsSessionTranscriptName(e.Name()) { keys = append(keys, e.Name()) } for _, key := range keys { path := filepath.Join(itemDir, key) validPath, _, _, err := validateTrashedSessionPath(dir, path) if err != nil { continue } if info, err := os.Stat(validPath); err == nil && !info.IsDir() { paths = append(paths, validPath) break } } } return paths, nil } func trashedSessionDeletedAt(path string) int64 { b, err := readFileUTF8(filepath.Join(filepath.Dir(path), sessionTrashMetaFile)) if err != nil { return 0 } var meta trashedSessionMeta if err := json.Unmarshal(b, &meta); err != nil { return 0 } return meta.DeletedAt } func restoreTrashedSessionFile(dir, path string) error { _, key, itemDir, err := validateTrashedSessionPath(dir, path) if err != nil { return err } target := filepath.Join(dir, key) if _, err := os.Stat(target); err == nil { discardable, err := liveSessionDiscardable(target) if err != nil { return err } if !discardable { return fmt.Errorf("session already exists: %s", key) } if err := removeDesktopSessionArtifacts(target); err != nil { return err } } else if !os.IsNotExist(err) { return err } if err := os.MkdirAll(dir, 0o755); err != nil { return err } if err := checkRestoreSubagentConflicts(dir, itemDir); err != nil { return err } for _, artifact := range sessionTrashArtifacts(target, key) { if err := movePathIfExists(filepath.Join(itemDir, artifact.name), artifact.src); err != nil { return err } } if err := restoreSubagentArtifacts(dir, itemDir); err != nil { return err } return os.RemoveAll(itemDir) } func purgeTrashedSessionFile(dir, path string) error { _, key, itemDir, err := validateTrashedSessionPath(dir, path) if err != nil { return err } if err := os.RemoveAll(itemDir); err != nil { return err } m, err := loadSessionTitlesForUpdate(dir) if err != nil { return err } if _, ok := m[key]; ok { delete(m, key) if err := saveSessionTitles(dir, m); err != nil { return err } } if err := removeSessionDisplayKey(dir, key); err != nil { return err } return nil } func movePathIfExists(src, dst string) error { if _, err := os.Lstat(src); os.IsNotExist(err) { return nil } else if err != nil { return err } if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil { return err } // Try os.Rename first — it's atomic and fast when it works. if err := os.Rename(src, dst); err == nil { return nil } else if sourcePathMissing(src) { return nil } else if !isRenameCrossDeviceOrBusy(err) { return err } // Fallback: copy then remove. This handles cross-device moves and the // Windows case where a directory rename fails because a handle is briefly // held open (e.g. antivirus scan, indexing, or a just-closed file). return copyAndRemove(src, dst) } // isRenameCrossDeviceOrBusy reports whether err is a cross-device rename or // a "file busy" error that a copy+remove fallback can recover from. func isRenameCrossDeviceOrBusy(err error) bool { if err == nil { return false } // Cross-device link. if le, ok := err.(*os.LinkError); ok { if le.Err == syscall.EXDEV { return true } // Windows: "The process cannot access the file because it is being used by another process." if errno, ok := le.Err.(syscall.Errno); ok { return errno == 32 // ERROR_SHARING_VIOLATION } } return false } func sourcePathMissing(src string) bool { if strings.TrimSpace(src) == "" { return true } _, err := os.Lstat(src) return os.IsNotExist(err) } // copyPathFn is a seam for tests to simulate a source vanishing mid-copy. var copyPathFn = copyPath // copyAndRemove recursively copies src to dst, then removes src. Used as a // fallback when os.Rename fails (cross-device or Windows file-lock races). func copyAndRemove(src, dst string) error { if err := copyPathFn(src, dst); err != nil { if sourcePathMissing(src) { // The source vanished mid-copy; drop the partial destination so // the trash never keeps a truncated artifact that a later restore // would resurrect as a corrupted transcript. _ = os.RemoveAll(dst) return nil } return err } // On Windows, wait briefly for any file handle release. time.Sleep(10 * time.Millisecond) return os.RemoveAll(src) } func copyPath(src, dst string) error { info, err := os.Lstat(src) if err != nil { if os.IsNotExist(err) { return nil } return err } mode := info.Mode() switch { case mode&os.ModeSymlink != 0: return copySymlink(src, dst) case mode.IsDir(): return copyDir(src, dst, mode.Perm()) case mode.IsRegular(): return copyFile(src, dst, mode.Perm()) default: return fmt.Errorf("unsupported file type in rename fallback: %s", src) } } func copyDir(src, dst string, mode os.FileMode) error { if err := os.MkdirAll(dst, mode); err != nil { return err } entries, err := os.ReadDir(src) if err != nil { if os.IsNotExist(err) { _ = os.RemoveAll(dst) return nil } return err } for _, e := range entries { srcPath := filepath.Join(src, e.Name()) dstPath := filepath.Join(dst, e.Name()) if err := copyPath(srcPath, dstPath); err != nil { return err } } return nil } func copyFile(src, dst string, mode os.FileMode) error { // Open source file. in, err := os.Open(src) if err != nil { if os.IsNotExist(err) { return nil } return err } // Create destination file. out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, mode) if err != nil { in.Close() return err } // Copy content. _, err = io.Copy(out, in) // Close both files before any removal. closeErr := out.Close() in.Close() if err != nil { return err } if closeErr != nil { return closeErr } return nil } func copySymlink(src, dst string) error { target, err := os.Readlink(src) if err != nil { if os.IsNotExist(err) { return nil } return err } return os.Symlink(target, dst) } func trashSubagentArtifacts(dir, sessionPath, itemDir string) error { artifacts, err := agent.ListSubagentsByParent(dir, agent.BranchID(sessionPath)) if err != nil { return err } trashSubagentDir := filepath.Join(itemDir, "subagents") for _, artifact := range artifacts { paths := []string{artifact.SessionPath, artifact.MetaPath} paths = append(paths, store.SessionSidecarFiles(artifact.SessionPath)...) for _, src := range paths { if strings.TrimSpace(src) == "" { continue } if err := movePathIfExists(src, filepath.Join(trashSubagentDir, filepath.Base(src))); err != nil { return err } } } return nil } func checkRestoreSubagentConflicts(dir, itemDir string) error { trashSubagentDir := filepath.Join(itemDir, "subagents") entries, err := os.ReadDir(trashSubagentDir) if err != nil { if os.IsNotExist(err) { return nil } return err } for _, entry := range entries { if entry.IsDir() { continue } target := filepath.Join(dir, "subagents", entry.Name()) if _, err := os.Stat(target); err == nil { return fmt.Errorf("subagent artifact already exists: %s", entry.Name()) } else if !os.IsNotExist(err) { return err } } return nil } func restoreSubagentArtifacts(dir, itemDir string) error { trashSubagentDir := filepath.Join(itemDir, "subagents") entries, err := os.ReadDir(trashSubagentDir) if err != nil { if os.IsNotExist(err) { return nil } return err } for _, entry := range entries { if entry.IsDir() { continue } if err := movePathIfExists(filepath.Join(trashSubagentDir, entry.Name()), filepath.Join(dir, "subagents", entry.Name())); err != nil { return err } } return nil } func validateSessionPath(dir, sessionPath string) (string, string, error) { if strings.TrimSpace(sessionPath) == "" { return "", "", fmt.Errorf("empty session path") } absDir, err := filepath.Abs(dir) if err != nil { return "", "", err } path := sessionPath if !filepath.IsAbs(path) { path = filepath.Join(absDir, path) } absPath, err := filepath.Abs(path) if err != nil { return "", "", err } if !store.IsSessionTranscriptName(filepath.Base(absPath)) { return "", "", fmt.Errorf("not a session file: %s", sessionPath) } rel, err := filepath.Rel(absDir, absPath) if err != nil || rel == "." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || rel == ".." || filepath.IsAbs(rel) { return "", "", fmt.Errorf("session path outside session dir: %s", sessionPath) } if info, err := os.Lstat(absPath); err == nil { if info.IsDir() { return "", "", fmt.Errorf("not a session file: %s", sessionPath) } realDir, dirErr := filepath.EvalSymlinks(absDir) if dirErr != nil { realDir = absDir } realPath, err := filepath.EvalSymlinks(absPath) if err != nil { return "", "", err } rel, err := filepath.Rel(realDir, realPath) if err != nil || rel == "." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || rel == ".." || filepath.IsAbs(rel) { return "", "", fmt.Errorf("session path escapes session dir: %s", sessionPath) } } else if !os.IsNotExist(err) { return "", "", err } return absPath, filepath.Base(absPath), nil } func validateTrashedSessionPath(dir, sessionPath string) (string, string, string, error) { if strings.TrimSpace(sessionPath) == "" { return "", "", "", fmt.Errorf("empty session path") } root, err := filepath.Abs(sessionTrashPath(dir)) if err != nil { return "", "", "", err } path := sessionPath if !filepath.IsAbs(path) { path = filepath.Join(root, path) } absPath, err := filepath.Abs(path) if err != nil { return "", "", "", err } if !store.IsSessionTranscriptName(filepath.Base(absPath)) { return "", "", "", fmt.Errorf("not a session file: %s", sessionPath) } rel, err := filepath.Rel(root, absPath) if err != nil || rel == "." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || rel == ".." || filepath.IsAbs(rel) { return "", "", "", fmt.Errorf("session path outside trash dir: %s", sessionPath) } parts := strings.Split(rel, string(filepath.Separator)) if len(parts) != 2 { return "", "", "", fmt.Errorf("invalid trash session path: %s", sessionPath) } if parts[0] != parts[1] { b, err := readFileUTF8(filepath.Join(root, parts[0], sessionTrashMetaFile)) if err != nil { return "", "", "", fmt.Errorf("invalid trash session path: %s", sessionPath) } var meta trashedSessionMeta if err := json.Unmarshal(b, &meta); err != nil || meta.Key != parts[1] { return "", "", "", fmt.Errorf("invalid trash session path: %s", sessionPath) } } if info, err := os.Lstat(absPath); err == nil { if info.IsDir() { return "", "", "", fmt.Errorf("not a session file: %s", sessionPath) } realRoot, dirErr := filepath.EvalSymlinks(root) if dirErr != nil { realRoot = root } realPath, err := filepath.EvalSymlinks(absPath) if err != nil { return "", "", "", err } rel, err := filepath.Rel(realRoot, realPath) if err != nil || rel == "." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || rel == ".." || filepath.IsAbs(rel) { return "", "", "", fmt.Errorf("session path escapes trash dir: %s", sessionPath) } } else if !os.IsNotExist(err) { return "", "", "", err } return absPath, filepath.Base(absPath), filepath.Dir(absPath), nil } type sessionDisplayMap map[string]map[string]string type sessionPlannerDisplayMap map[string][]plannerDisplayTurn type plannerDisplayTurn struct { UserHash string `json:"userHash"` Messages []HistoryMessage `json:"messages"` } func messageDisplayKey(content string) string { sum := sha256.Sum256([]byte(content)) return fmt.Sprintf("%x", sum[:]) } func loadSessionDisplays(dir string) sessionDisplayMap { m := sessionDisplayMap{} b, err := readFileUTF8(sessionDisplayPath(dir)) if err != nil { return m } _ = json.Unmarshal(b, &m) return m } func sessionPlannerDisplayPath(dir string) string { return filepath.Join(dir, sessionPlannerDisplayFile) } func loadSessionPlannerDisplays(dir string) sessionPlannerDisplayMap { m := sessionPlannerDisplayMap{} if strings.TrimSpace(dir) == "" { return m } b, err := readFileUTF8(sessionPlannerDisplayPath(dir)) if err != nil { return m } _ = json.Unmarshal(b, &m) return m } func saveSessionPlannerDisplays(dir string, m sessionPlannerDisplayMap) error { b, err := json.MarshalIndent(m, "", " ") if err != nil { return err } if err := os.MkdirAll(dir, 0o755); err != nil { return err } tmp, err := os.CreateTemp(dir, ".planner-display.*.tmp") if err != nil { return err } tmpPath := tmp.Name() if _, err := tmp.Write(b); err != nil { tmp.Close() os.Remove(tmpPath) return err } if err := tmp.Close(); err != nil { os.Remove(tmpPath) return err } return fileutil.ReplaceFile(tmpPath, sessionPlannerDisplayPath(dir)) } func recordSessionPlannerDisplay(dir, sessionPath, userContent string, messages []HistoryMessage) error { if strings.TrimSpace(sessionPath) == "" || strings.TrimSpace(userContent) == "" || len(messages) == 0 { return nil } m := loadSessionPlannerDisplays(dir) key := filepath.Base(sessionPath) turn := plannerDisplayTurn{ UserHash: messageDisplayKey(userContent), Messages: cloneHistoryMessages(messages), } m[key] = append(m[key], turn) return saveSessionPlannerDisplays(dir, m) } func sessionPlannerDisplayTurns(dir, sessionPath string) []plannerDisplayTurn { if strings.TrimSpace(dir) == "" || strings.TrimSpace(sessionPath) == "" { return nil } turns := loadSessionPlannerDisplays(dir)[filepath.Base(sessionPath)] if len(turns) == 0 { return nil } out := make([]plannerDisplayTurn, 0, len(turns)) for _, turn := range turns { if strings.TrimSpace(turn.UserHash) == "" || len(turn.Messages) == 0 { continue } out = append(out, plannerDisplayTurn{ UserHash: turn.UserHash, Messages: cloneHistoryMessages(turn.Messages), }) } return out } func saveSessionDisplays(dir string, m sessionDisplayMap) error { b, err := json.MarshalIndent(m, "", " ") if err != nil { return err } if err := os.MkdirAll(dir, 0o755); err != nil { return err } tmp, err := os.CreateTemp(dir, ".display.*.tmp") if err != nil { return err } tmpPath := tmp.Name() if _, err := tmp.Write(b); err != nil { tmp.Close() os.Remove(tmpPath) return err } if err := tmp.Close(); err != nil { os.Remove(tmpPath) return err } return fileutil.ReplaceFile(tmpPath, sessionDisplayPath(dir)) } func saveOrRemoveSessionDisplays(dir string, m sessionDisplayMap) error { if len(m) == 0 { err := os.Remove(sessionDisplayPath(dir)) if os.IsNotExist(err) { return nil } return err } return saveSessionDisplays(dir, m) } func removeSessionDisplayKey(dir, key string) error { key = strings.TrimSpace(key) if key == "" { return nil } m := loadSessionDisplays(dir) if m[key] == nil { return nil } delete(m, key) return saveOrRemoveSessionDisplays(dir, m) } func removeSessionDisplay(dir, sessionPath string) error { if strings.TrimSpace(sessionPath) == "" { return nil } return removeSessionDisplayKey(dir, filepath.Base(sessionPath)) } func pruneSessionDisplays(dir string, protected map[string]struct{}) error { m := loadSessionDisplays(dir) if len(m) == 0 { return nil } changed := false for key := range m { if sessionDisplayKeyStillOwned(dir, key, protected) { continue } delete(m, key) changed = true } if !changed { return nil } return saveOrRemoveSessionDisplays(dir, m) } func sessionDisplayKeyStillOwned(dir, key string, protected map[string]struct{}) bool { key = strings.TrimSpace(key) if key == "" || filepath.Base(key) != key || !store.IsSessionTranscriptName(key) { return false } if protected != nil { if _, ok := protected[key]; ok { return true } } sessionPath := filepath.Join(dir, key) if info, err := os.Stat(sessionPath); err == nil && !info.IsDir() { return true } trashPath := filepath.Join(sessionTrashPath(dir), key, key) if info, err := os.Stat(trashPath); err == nil && !info.IsDir() { return true } if paths, err := listTrashedSessionFiles(dir); err == nil { for _, path := range paths { if filepath.Base(path) == key { return true } } } return false } func recordSessionDisplay(dir, sessionPath, content, display string) error { if strings.TrimSpace(sessionPath) == "" || content == display || strings.TrimSpace(display) == "" { return nil } m := loadSessionDisplays(dir) key := filepath.Base(sessionPath) if m[key] == nil { m[key] = map[string]string{} } m[key][messageDisplayKey(content)] = display return saveSessionDisplays(dir, m) } // sessionDisplayResolver loads the sidecar once and returns a per-message // resolver, so a transcript of N messages doesn't re-read .display.json N times. func sessionDisplayResolver(dir, sessionPath string) func(content string) string { return sessionDisplayResolverFromMap(loadSessionDisplays(dir), sessionPath) } func sessionDisplayResolverFromMap(displays sessionDisplayMap, sessionPath string) func(content string) string { byHash := displays[filepath.Base(sessionPath)] return func(content string) string { if byHash != nil { if display := byHash[messageDisplayKey(content)]; strings.TrimSpace(display) != "" { return display } } return control.StripComposePrefixes(content) } } func resolveSessionDisplay(dir, sessionPath, content string) string { return sessionDisplayResolver(dir, sessionPath)(content) }