用户选了「关窗即结束,一律 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>
404 lines
17 KiB
Swift
404 lines
17 KiB
Swift
import Foundation
|
|
import HubCore
|
|
import HubJump
|
|
import HubProbe
|
|
import HubProjects
|
|
|
|
/// 全部 agent 会话的实时快照。岛和主窗口共享同一个实例。
|
|
///
|
|
/// 数据来自 `~/.claude/sessions/*.json` —— Claude Code 自己维护的权威状态源。
|
|
/// 这里只做读取、排序和跳转派发,不做任何状态推断。
|
|
@Observable
|
|
@MainActor
|
|
public final class SessionStore {
|
|
|
|
public private(set) var sessions: [AgentSession] = []
|
|
public private(set) var lastRefresh: Date = .distantPast
|
|
|
|
/// sessionId → 人能看懂的终端位置("iTerm · storefront-a"、"tmux · @12")。
|
|
///
|
|
/// 详情卡要回答"这个会话在哪儿能找到",但解析它要跑 AppleScript + tmux 子进程,
|
|
/// 每次渲染都算是不可接受的。所以在后台按需解析、缓存下来,视图只读缓存。
|
|
public private(set) var terminalLabels: [String: String] = [:]
|
|
|
|
/// 进程还活着、但承载它的 tmux 会话已经没有任何客户端连着的会话。
|
|
///
|
|
/// ## 为什么必须单独有这一档
|
|
///
|
|
/// 关掉终端窗口**不会**结束会话:claude 的父进程是 tmux server,关窗口
|
|
/// 只是 detach,进程照跑。于是 `kill(pid, 0)` 依然为真,界面上一直亮着
|
|
/// 「1 个会话」的蓝点,而用户明明已经把窗口关了 —— 他会认为界面在撒谎。
|
|
///
|
|
/// 真相是「还在跑,只是没终端连着」。这两件事都要说,少说哪一半都不对:
|
|
/// 报成已退出会掩盖一个还在烧 token 的进程,报成在线又和用户看到的相反。
|
|
public private(set) var detachedSessionIds: Set<String> = []
|
|
|
|
/// cwd → 该工作区的 git 快照(分支、变更数、最近提交)。
|
|
///
|
|
/// 按 **cwd** 而不是按 projects.yaml 里的项目采集:会话可能开在任意目录、
|
|
/// 任意 worktree,甚至根本没登记过。同一个项目多开时,分支和仓库根目录
|
|
/// 是区分它们的主要依据 —— 只显示项目名会把几个会话糊成一个。
|
|
public private(set) var workspaceGit: [String: WorkspaceGit] = [:]
|
|
|
|
/// 最近一次跳转的结果。跳转是异步的,成功与否必须回显,
|
|
/// 否则用户点完只能靠"终端有没有跳过去"自己猜。
|
|
public private(set) var lastJump: (sessionId: String, outcome: JumpOutcome)?
|
|
|
|
/// sessionId → 上一轮活干了多久(一次完整的 busy/shell → idle/waiting)。
|
|
///
|
|
/// 滞留提醒**按这个分级**:干了半小时的活值得主动打断你去验收,
|
|
/// 干了半分钟的不值得 —— 同时跑七八个会话时,小活也弹会立刻变成骚扰。
|
|
///
|
|
/// 为什么必须自己观测:Claude 自己在界面上显示的 "Crunched for 29m 54s"
|
|
/// 不落到任何我们能读的文件里。`sessions/<PID>.json` 只有当前状态和
|
|
/// 状态变更时刻,推不出上一段状态持续了多久。
|
|
///
|
|
/// app 重启会丢,这是可以接受的 —— 丢了就退化成"不知道",
|
|
/// 而 `StallDetector` 对"不知道"的处理是按高优,不会漏报。
|
|
public private(set) var lastWorkDuration: [String: TimeInterval] = [:]
|
|
|
|
/// sessionId → 这一轮 busy 是什么时候开始的。只在观测期间有值。
|
|
private var workStartedAt: [String: Date] = [:]
|
|
|
|
private let reader: ClaudeSessionReader
|
|
private let jumpEngine: JumpEngine
|
|
private var timer: Timer?
|
|
private var resolvingTerminals = false
|
|
private var lastTerminalResolve: Date = .distantPast
|
|
private var resolvingGit = false
|
|
private var lastGitResolve: Date = .distantPast
|
|
private var resolvingDetached = false
|
|
private var lastDetachedResolve: Date = .distantPast
|
|
|
|
public init(
|
|
reader: ClaudeSessionReader = ClaudeSessionReader(),
|
|
jumpEngine: JumpEngine = JumpEngine()
|
|
) {
|
|
self.reader = reader
|
|
self.jumpEngine = jumpEngine
|
|
}
|
|
|
|
// MARK: - 派生数据
|
|
|
|
public var waitingCount: Int { sessions.count { $0.status == .waiting } }
|
|
public var busyCount: Int { sessions.count { $0.status == .busy } }
|
|
public var shellCount: Int { sessions.count { $0.status == .shell } }
|
|
public var idleCount: Int { sessions.count { $0.status == .idle } }
|
|
|
|
/// 有没有活跃会话。决定像素小人的时钟频率和轮询节奏。
|
|
public var hasActiveSessions: Bool { sessions.contains { $0.status.isActive } }
|
|
|
|
/// 最紧急的那个会话。折叠态的徽章和闯入态都用它。
|
|
public var mostUrgent: AgentSession? { sessions.first { $0.status == .waiting } }
|
|
|
|
// MARK: - 刷新
|
|
|
|
/// 轮询间隔自适应。
|
|
///
|
|
/// `sessions/*.json` 的写入频率跟着 Claude 的活动走,全空闲时几分钟才动一次,
|
|
/// 这时候还每秒扫一遍纯属浪费 —— 常驻 app 的耗电就是这么攒出来的。
|
|
///
|
|
/// 后续会换成 `DispatchSource` 监听目录做到近实时;轮询是先跑起来的过渡实现。
|
|
private var refreshInterval: TimeInterval {
|
|
if waitingCount > 0 { return 1.0 }
|
|
if hasActiveSessions { return 2.0 }
|
|
return 5.0
|
|
}
|
|
|
|
public func start() {
|
|
refresh()
|
|
scheduleNext()
|
|
}
|
|
|
|
public func stop() {
|
|
timer?.invalidate()
|
|
timer = nil
|
|
}
|
|
|
|
private func scheduleNext() {
|
|
timer?.invalidate()
|
|
timer = Timer.scheduledTimer(
|
|
withTimeInterval: refreshInterval, repeats: false
|
|
) { [weak self] _ in
|
|
Task { @MainActor in
|
|
self?.refresh()
|
|
self?.scheduleNext()
|
|
}
|
|
}
|
|
}
|
|
|
|
public func refresh() {
|
|
let fresh = reader.readAll()
|
|
let sorted = Self.sort(fresh)
|
|
observeWorkDurations(sorted)
|
|
// 只在真的变了时才赋值:@Observable 的写入会触发 SwiftUI 重绘,
|
|
// 每秒无脑重绘 9 行列表是没必要的开销。
|
|
if sorted != sessions {
|
|
sessions = sorted
|
|
}
|
|
lastRefresh = Date()
|
|
resolveDetached()
|
|
}
|
|
|
|
/// 刷新「哪些会话已经没终端连着」。
|
|
///
|
|
/// 跟着 refresh 走而不是等界面展开:这个标记决定项目列表上那颗点的颜色,
|
|
/// 而项目列表在主窗口一打开就在看着 —— 等展开才算的话,用户看到的
|
|
/// 第一眼仍然是错的。
|
|
///
|
|
/// 要跑两个 tmux 子进程,所以 5 秒节流。refresh 最快 1 秒一轮,
|
|
/// 不节流就是每秒 fork 两次。
|
|
private func resolveDetached() {
|
|
if DemoFixtures.isEnabled { return }
|
|
guard !resolvingDetached else { return }
|
|
guard Date().timeIntervalSince(lastDetachedResolve) >= 5 else { return }
|
|
|
|
let snapshot = sessions
|
|
guard !snapshot.isEmpty else {
|
|
if !detachedSessionIds.isEmpty { detachedSessionIds = [] }
|
|
return
|
|
}
|
|
resolvingDetached = true
|
|
|
|
Task.detached(priority: .utility) { [weak self] in
|
|
let probe = TmuxProbe()
|
|
let ids = Self.detachedIds(
|
|
sessions: snapshot,
|
|
panes: probe.listPanes(),
|
|
attached: probe.attachedSessionNames(),
|
|
tree: ProcessTree.snapshot()
|
|
)
|
|
await MainActor.run {
|
|
guard let self else { return }
|
|
if self.detachedSessionIds != ids { self.detachedSessionIds = ids }
|
|
self.lastDetachedResolve = Date()
|
|
self.resolvingDetached = false
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 纯计算:哪些会话所在的 tmux 会话没有客户端连着。
|
|
///
|
|
/// 判定本体在 `DetachedSessions` —— **回收器用的必须是同一份**,
|
|
/// 否则会出现"它杀了一个界面上显示正常的会话"。
|
|
nonisolated static func detachedIds(
|
|
sessions: [AgentSession],
|
|
panes: [TmuxPane],
|
|
attached: Set<String>,
|
|
tree: ProcessTree
|
|
) -> Set<String> {
|
|
Set(
|
|
DetachedSessions.pairs(
|
|
sessions: sessions, panes: panes, attached: attached, tree: tree
|
|
).map(\.session.sessionId)
|
|
)
|
|
}
|
|
|
|
/// 观测 busy → idle 的跃迁,记下这一轮活干了多久。
|
|
///
|
|
/// 必须在 `sessions` 被覆盖**之前**调用 —— 它要拿旧快照做对比。
|
|
///
|
|
/// 用 `statusUpdatedAt` 而不是"我们发现它变了"的墙钟时间:轮询间隔在
|
|
/// 全空闲时是 5 秒,用发现时刻算会把误差累进时长里;而 `statusUpdatedAt`
|
|
/// 是 Claude 自己写的状态变更时刻,精确得多。
|
|
private func observeWorkDurations(_ fresh: [AgentSession]) {
|
|
let previous = Dictionary(
|
|
sessions.map { ($0.sessionId, $0.status) }, uniquingKeysWith: { a, _ in a }
|
|
)
|
|
|
|
for session in fresh {
|
|
let wasWorking = previous[session.sessionId]?.isWorking ?? false
|
|
let isWorking = session.status.isWorking
|
|
|
|
switch (wasWorking, isWorking) {
|
|
case (false, true):
|
|
// 开工。first-seen 的会话也会走这里,但它可能已经跑了很久了,
|
|
// 那种情况下算出来的时长偏短 —— 宁可偏短(判成低优、只上跑马灯),
|
|
// 也不要凭空估一个大数把用户吵醒。
|
|
workStartedAt[session.sessionId] =
|
|
session.statusUpdatedAt ?? session.updatedAt ?? Date()
|
|
|
|
case (true, false):
|
|
// 收工。
|
|
guard let started = workStartedAt.removeValue(forKey: session.sessionId)
|
|
else { break }
|
|
let ended = session.statusUpdatedAt ?? session.updatedAt ?? Date()
|
|
let duration = ended.timeIntervalSince(started)
|
|
if duration > 0 { lastWorkDuration[session.sessionId] = duration }
|
|
|
|
default:
|
|
break
|
|
}
|
|
}
|
|
|
|
// 会话没了就把它的记录清掉,别让字典无限涨。
|
|
let alive = Set(fresh.map(\.sessionId))
|
|
workStartedAt = workStartedAt.filter { alive.contains($0.key) }
|
|
lastWorkDuration = lastWorkDuration.filter { alive.contains($0.key) }
|
|
}
|
|
|
|
/// 排序即 UI 优先级。
|
|
///
|
|
/// 不按项目分组作默认:9 个会话跨 5 个项目,按项目排会让
|
|
/// 「哪个在等我」这个最高频的问题需要扫描整个列表。
|
|
static func sort(_ input: [AgentSession]) -> [AgentSession] {
|
|
input.sorted { lhs, rhs in
|
|
// ① 状态优先级:waiting → busy → shell → idle → unknown
|
|
if lhs.status != rhs.status { return lhs.status < rhs.status }
|
|
|
|
// ② waiting 组内:等得最久的排最上(催得最急的先处理)
|
|
if lhs.status == .waiting {
|
|
let l = lhs.statusUpdatedAt ?? .distantPast
|
|
let r = rhs.statusUpdatedAt ?? .distantPast
|
|
if l != r { return l < r }
|
|
}
|
|
|
|
// ③ 其余:最近活跃的在上
|
|
let l = lhs.updatedAt ?? .distantPast
|
|
let r = rhs.updatedAt ?? .distantPast
|
|
if l != r { return l > r }
|
|
|
|
// ④ interactive 优先于 bg
|
|
if lhs.kind != rhs.kind { return lhs.kind == .interactive }
|
|
|
|
// ⑤ 兜底用 sessionId 保证顺序稳定,避免列表无谓抖动
|
|
return lhs.sessionId < rhs.sessionId
|
|
}
|
|
}
|
|
|
|
// MARK: - 终端定位
|
|
|
|
/// 解析每个会话在哪个终端里,结果缓存到 `terminalLabels`。
|
|
///
|
|
/// 展开岛的时候调一次即可。有 12 秒的节流:解析要跑 AppleScript 遍历 iTerm
|
|
/// 全部 session 再加一次 `tmux list-panes`,几十到几百毫秒,不该跟着渲染走。
|
|
public func resolveTerminalLabels(force: Bool = false) {
|
|
// 演示模式的会话不在任何终端里,真解析只会得到"未定位到终端"。
|
|
if DemoFixtures.isEnabled {
|
|
terminalLabels = DemoFixtures.terminalLabels()
|
|
return
|
|
}
|
|
guard !resolvingTerminals else { return }
|
|
if !force, Date().timeIntervalSince(lastTerminalResolve) < 12 { return }
|
|
resolvingTerminals = true
|
|
|
|
let snapshot = sessions
|
|
Task.detached(priority: .utility) { [weak self] in
|
|
let labels = Self.computeTerminalLabels(for: snapshot)
|
|
await MainActor.run {
|
|
guard let self else { return }
|
|
self.terminalLabels = labels
|
|
self.lastTerminalResolve = Date()
|
|
self.resolvingTerminals = false
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 纯计算部分,跑在后台线程。
|
|
///
|
|
/// 两条线索:iTerm 的 `jobPid` 祖先链(能定位到具体 tab),
|
|
/// 以及 tmux 的 `pane_pid` 祖先链(能给出窗口名)。两者都拿不到的
|
|
/// `bg` 会话本来就不在任何终端里 —— 如实说,别编一个位置出来。
|
|
nonisolated static func computeTerminalLabels(
|
|
for sessions: [AgentSession]
|
|
) -> [String: String] {
|
|
guard !sessions.isEmpty else { return [:] }
|
|
|
|
let tree = ProcessTree.snapshot()
|
|
var labels: [String: String] = [:]
|
|
|
|
if ITermLocator.isRunning() {
|
|
let terms = ITermLocator().snapshot()
|
|
let bound = JumpEngine.bind(
|
|
itermSessions: terms, claudeSessions: sessions, tree: tree
|
|
)
|
|
let nameByUUID = Dictionary(
|
|
terms.map { ($0.sessionUUID, $0.name) }, uniquingKeysWith: { a, _ in a }
|
|
)
|
|
for (sessionId, uuid) in bound {
|
|
let name = nameByUUID[uuid] ?? ""
|
|
labels[sessionId] = name.isEmpty ? "iTerm" : "iTerm · \(name)"
|
|
}
|
|
}
|
|
|
|
let pids = Set(sessions.map(\.pid))
|
|
let probe = TmuxProbe()
|
|
let panes = probe.bindPanes(to: pids, tree: tree)
|
|
if !panes.isEmpty {
|
|
let attached = probe.attachedSessionNames()
|
|
let sessionIdByPid = Dictionary(
|
|
sessions.map { ($0.pid, $0.sessionId) }, uniquingKeysWith: { a, _ in a }
|
|
)
|
|
for (pid, pane) in panes {
|
|
guard let sessionId = sessionIdByPid[pid] else { continue }
|
|
// 没客户端连着的必须说出来。「tmux · hub:acme-erp」读起来像
|
|
// 「去那个 tab 就能找到它」,可那个 tab 已经被关掉了 ——
|
|
// 用户照着找会扑空,然后怀疑整个界面。
|
|
let suffix = attached.contains(pane.sessionName) ? "" : "(终端已关,仍在后台跑)"
|
|
let tmuxLabel = "tmux · \(pane.sessionName):\(pane.windowName)\(suffix)"
|
|
// iTerm 的定位更精确(能点到具体 tab),tmux 信息作为补充拼在后面。
|
|
labels[sessionId] = labels[sessionId].map { "\($0) · \(tmuxLabel)" }
|
|
?? tmuxLabel
|
|
}
|
|
}
|
|
|
|
for session in sessions where labels[session.sessionId] == nil {
|
|
labels[session.sessionId] = session.kind == .bg ? "后台任务,不在终端里" : "未定位到终端"
|
|
}
|
|
return labels
|
|
}
|
|
|
|
// MARK: - 工作区 git
|
|
|
|
/// 解析这些 cwd 的 git 快照。展开岛时调,内部按 cwd 去重 + 10 秒节流。
|
|
public func resolveWorkspaceGit(for paths: [String], force: Bool = false) {
|
|
let wanted = Set(paths)
|
|
let missing = force ? wanted : wanted.filter { workspaceGit[$0] == nil }
|
|
let stale = force || Date().timeIntervalSince(lastGitResolve) > 10
|
|
guard !missing.isEmpty || stale else { return }
|
|
guard !resolvingGit else { return }
|
|
|
|
let targets = Array(stale ? wanted : missing)
|
|
guard !targets.isEmpty else { return }
|
|
resolvingGit = true
|
|
|
|
Task.detached(priority: .utility) { [weak self] in
|
|
var collected: [String: WorkspaceGit] = [:]
|
|
for path in targets {
|
|
guard let root = GitStatus.repositoryRoot(at: path),
|
|
let info = GitStatus.collect(at: path)
|
|
else { continue }
|
|
collected[path] = WorkspaceGit(
|
|
root: root, info: info, recent: GitStatus.log(at: path, limit: 3)
|
|
)
|
|
}
|
|
await MainActor.run { [collected] in
|
|
guard let self else { return }
|
|
self.workspaceGit.merge(collected) { _, new in new }
|
|
self.lastGitResolve = Date()
|
|
self.resolvingGit = false
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - 动作
|
|
|
|
/// 跳到会话所在的终端 tab。
|
|
///
|
|
/// AppleScript 有跨进程成本,放后台线程跑,别卡住 UI。结果写回
|
|
/// `lastJump` 供 UI 回显 —— 只写日志的话用户看不到任何反馈。
|
|
public func jump(to sessionId: String, then: (@MainActor (JumpOutcome) -> Void)? = nil) {
|
|
let engine = jumpEngine
|
|
Task.detached(priority: .userInitiated) { [weak self] in
|
|
let outcome = engine.jump(to: sessionId)
|
|
if case .failed(let reason) = outcome {
|
|
NSLog("[ClaudeHub] 跳转失败: \(reason)")
|
|
}
|
|
await MainActor.run {
|
|
self?.lastJump = (sessionId, outcome)
|
|
then?(outcome)
|
|
}
|
|
}
|
|
}
|
|
}
|