Files
wuguanganandClaude Opus 5 ed287a992e feat: 关掉终端窗口就结束会话,并且接得回来
用户选了「关窗即结束,一律 kill」,紧接着补了一句「那我也打不开了呀」。
两件事一起做才成立 —— 只会杀不会还的回收器是把一个显示错误的会话
换成一个找不回来的会话。

## SessionReaper

检测到 tmux 会话没客户端连着就结束它。四条边界:

- 杀之前先把 sessionId 记进 ClosedSessionStore(落盘)
- 只记接得回来的:claude --resume 认的是磁盘上那份 transcript,不是 id。
  一次都没说过话的会话没有 transcript —— 实机验证过,一个只停在提示符
  上的会话 ~/.claude/projects 里一个字节都没有。不查就记会长出一个
  点下去报错的按钮。判据用 TranscriptReader.locate。
- 绑不到 pane 的一个都不碰:不在 tmux 里的会话判断不了"有没有终端连着",
  杀它们等于拿"不知道"当处决理由
- 新起的会话 90 秒宽限 + 连续两轮确认(30 秒):createSession 是先建
  detached 再让终端 attach 的,iTerm 冷启动那几秒客户端数就是 0,
  没宽限期会把用户刚点开的项目当场杀掉

设置里能关(默认开)。这是全案第二个会主动杀进程的组件。

## 接回来

项目行和岛的启动菜单长出「接着上次(Nm 前结束)」,走 claude --resume <id>。
main.swift 的 launch() 在 mode == .resumeSession 时自己去名册取 id ——
不取的话会退化成 claude --resume 的交互列表,而那个列表里全是一模一样的
项目名,用户得人肉认自己刚关掉的是哪一个。

实测:pid 6450(acme-admin,detach 2h)被正确结束,tmux server 随之退出,
名册记下了 sessionId/cwd/name。18 个新测试,500 个全过。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 14:04:51 +08:00

110 lines
4.2 KiB
Swift

import Foundation
import Observation
/// 一个被结束掉的会话。
public struct ClosedSession: Codable, Sendable, Equatable, Identifiable {
/// Claude 的 session UUID。**这是能不能接回来的全部依据** ——
/// `claude --resume <id>` 认的就是它。
public let sessionId: String
public let name: String
public let cwd: String
public let endedAt: Date
public var id: String { sessionId }
public init(sessionId: String, name: String, cwd: String, endedAt: Date) {
self.sessionId = sessionId
self.name = name
self.cwd = cwd
self.endedAt = endedAt
}
}
/// 被结束掉的会话名册。
///
/// ## 为什么杀之前必须先记下来
///
/// 「关窗即结束」这个选择只有在**还能再打开**的前提下才成立。用户的原话是
/// 「那我也打不开了呀」—— 一个只会杀不会还的回收器,把一个显示错误的会话
/// 换成了一个找不回来的会话,那不是修好,是换了个方式弄丢东西。
///
/// 好在会话本体不在进程里:Claude Code 一直把 transcript 写在
/// `~/.claude/projects/…/<sessionId>.jsonl`,进程杀掉不影响它。
/// 缺的只是**那个 id**,杀之前不记就真的没了 ——
/// 之后只能靠 `claude --resume` 的交互列表去人肉认,而那个列表里
/// 全是一模一样的项目名。
///
/// 所以这份名册是回收器的前置条件,不是附加功能。
@Observable
@MainActor
public final class ClosedSessionStore {
/// 最近结束的在前。
public private(set) var sessions: [ClosedSession] = []
/// 留多少条。名册是给人「接着上次」用的,不是审计日志 ——
/// 一个项目回头去接的几乎永远是最后那一个。
private let limit = 40
@ObservationIgnored private let url: URL?
public nonisolated static var defaultURL: URL {
FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent("Library/Application Support/claude-hub/closed-sessions.json")
}
/// nil = 不落盘(测试用)。同仓库里其它几个 store 的理由:
/// 路径写死会让测试直接改用户的真实数据。
public init(url: URL? = ClosedSessionStore.defaultURL) {
self.url = url
load()
}
/// 记一笔。同一个 sessionId 只留最新的一条 —— 一个会话可以被
/// resume、再被关掉、再 resume,重复记只会把名册塞满同一个东西。
public func record(_ session: ClosedSession) {
sessions.removeAll { $0.sessionId == session.sessionId }
sessions.insert(session, at: 0)
if sessions.count > limit { sessions.removeLast(sessions.count - limit) }
persist()
}
/// 这个项目最近结束的那一个。
///
/// 按 cwd 前缀匹配而不是相等:会话可能开在项目的子目录或 worktree 里,
/// 只认相等会让「接着上次」在那些会话上凭空消失。
public func latest(forProject path: String) -> ClosedSession? {
sessions.first { Self.belongs(cwd: $0.cwd, project: path) }
}
static func belongs(cwd: String, project: String) -> Bool {
cwd == project || cwd.hasPrefix(project + "/")
}
/// 已经重新开起来了就从名册里划掉 —— 留着会让界面同时显示
/// 「在跑」和「上次结束于…」,用户不知道该信哪个。
public func forget(sessionId: String) {
guard sessions.contains(where: { $0.sessionId == sessionId }) else { return }
sessions.removeAll { $0.sessionId == sessionId }
persist()
}
// MARK: - 持久化
private func load() {
guard let url,
let data = try? Data(contentsOf: url),
let decoded = try? JSONDecoder().decode([ClosedSession].self, from: data)
else { return }
sessions = decoded
}
private func persist() {
guard let url, let data = try? JSONEncoder().encode(sessions) else { return }
try? FileManager.default.createDirectory(
at: url.deletingLastPathComponent(), withIntermediateDirectories: true
)
try? data.write(to: url, options: .atomic)
}
}