用户选了「关窗即结束,一律 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>
110 lines
4.2 KiB
Swift
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)
|
|
}
|
|
}
|