Files
vibe-foreman/HubKit/Sources/HubUI/SessionStore.swift
T
ef76328ec8 滞留守望:从「事件通知」升级成「状态守望」 (#5)
* feat: 演示模式(合成会话 + 真 git 工作区)用于脱敏截图

真实数据里全是内部项目名、分支名和 commit 标题,不能对外;而且要等真实
会话凑出"1 个在等你 + 2 个在忙 + 1 个跑命令 + 几个闲着"这种展示效果好的
组合纯靠运气。合成数据同时解决不泄露和每次截图完全一致两件事。

实现上往临时目录写 sessions/<PID>.json 再让 reader 指过去,而不是在 store
里塞假数据 —— 这样走的是和生产完全一样的解析路径,截出来的图能反映真实
渲染结果。git 工作区也建真仓库(真分支、真提交、真未提交改动),理由相同。

ClaudeSessionReader 因此需要 requiresLiveProcess 开关:合成会话的进程当然
不存在,kill(pid,0) 会把它们全过滤掉。生产必须保持 true。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(hubctl): 加 HUB_JUDGE 守卫,封死 AI 判定的 hook 回环

Hub 接下来要自己起 claude -p 给滞留的会话生成摘要。那个子进程会照常
触发 Stop/SessionEnd hook,于是 hook → Hub → claude -p → hook 形成
无限回环,每判定一次多生一个会话。

守卫见到 HUB_JUDGE=1 就在发 socket 之前 exit(0)。这道防线不依赖 Claude
CLI 的任何行为开关,是唯一保证有效的。

顺带加 HUB_TRACE:指向文件路径时每次执行追加一行。这是本机唯一能把
"Hub 起的那个 claude"和"用户另外八个会话"区分开的探针 —— 环境变量会被
子进程继承,别人的会话没有它。之前试过用 hubctl 的 atime 做间接探测,
全是其他会话的噪音,毫无结论。

实测(trace 文件为证):
  claude -p --setting-sources ''         → 0 行,hook 根本没被调起
  claude -p(无开关)+ HUB_JUDGE=1        → 2 行 stop/sessionend,均 judge=true
两道防线各自独立有效,实现时两条都上。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(probe): 滞留检测的确定性探针与五类判定

岛现在只在事件发生那一瞬间提醒(闯入 2 秒然后回落成一个静止徽章),
之后再也不喊。用户给的五张实拍截图都是"那一瞬没看到就永远错过"。

这一层负责回答"哪个会话卡住了、为什么",纯逻辑、不碰 UI。

TranscriptTail —— 只读文件尾部 64 KB。这些 jsonl 单个几十 MB、全目录实测
945 MB,UsageStats 早期整个读进内存导致用量页永远转不出来的教训不能重犯。
窗口切在行中间时残缺的首行必须丢掉,否则会解析出半截内容。
定位不猜编码规则:主路径按"非字母数字换 -"拼(30 个真实样本全中),
拼不中就按 sessionId 全局 glob 兜底 —— UUID 唯一,兜底是精确的。

TaskStateReader —— 读 ~/.claude/tasks/。对过账:gateway-config-management
读出 {completed:2, in_progress:1, pending:1},与 Claude 自己界面上的
"4 tasks (2 done, 1 in progress, 1 open)" 完全一致。

StallDetector —— 五类原因按优先级判定:
  断线 > 在提问 > 等授权 > 有后续 > 待验收
断线是异常,立即报不等阈值。提问的启发式只看**最后一个非空行**是否以问号
结尾,不扫全文 —— 正文中间的反问句非常常见,扫全文必然误判。
拿真实数据验证:storefront-a-47 被正确判为"❓ 在问你 · 需要我帮你把这两个提交
精准推送到 live 吗?",正是用户截图里那一条,且全程没用 AI。

分级:只有"干了没多久就安静收工"降级成不主动闯入。干活时长必须自己观测
(Claude 显示的 "Crunched for 29m 54s" 不落盘),观测时排除 waiting ——
否则等了两小时授权的会话会被记成"干了两小时"。

hubprobe stall 子命令:滞留提醒是时间驱动的,靠 UI 验证意味着干等几分钟。
这个子命令把每个会话的判定和判据一次打出来,理由同已有的 usage 子命令。

164 个测试通过(原 131)。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(probe): StallJudge —— 用订阅额度给滞留会话生成一句人话

职责刻意收窄:**不判断要不要提醒**,只把事情说清楚(摘要、是不是在提问、
回答选项、建议动作)。让模型决定要不要打扰用户是高风险的,误报一次用户
就会把整个功能关掉。收窄之后它失败的代价很低 —— 解析不出来就退回
"完成了一轮,去看看",提醒照发,只是文案糙一点。

走 claude 二进制而不是直接调 API,用的是用户已登录的订阅额度,
不需要另配 ANTHROPIC_API_KEY。不能用 --bare:它跳过 hooks 的同时也跳过
OAuth(帮助原文写明 keychain is never read),等于用不上订阅额度。

踩到的三个坑,都写进注释了:

1. **--disallowedTools 是变长参数,会吞掉后面的位置参数。**
   提示词放在它后面时被当成又一个工具名吃掉,模型压根收不到指令。
   表现极具迷惑性:三次调用全部"成功"(有返回、扣了钱),但模型只收到
   正文,于是直接去回答正文里的问题,返回散文而不是 JSON。
   提示词必须紧跟 -p,排在所有变长选项之前。

2. **正文不能走 stdin。** 待分析的文本经常是直接对着读者提问的
   ("需要我帮你推送到 live 吗?"),和指令混在一条消息里时模型分不清
   边界,会把它当成对自己说的话去执行。改成全塞进提示词、用 <待分析>
   标签把数据围死。顺带 stdin 接 /dev/null,省掉 claude -p 干等 3 秒。

3. **字数限制不能指望模型守约。** 实测它给 5 个选项(要求最多 4 个)、
   14 字的 nextAction(要求 ≤10 字)。岛上位置是死的,解析时必须再截一次。

围栏也必须剥:即使明说"只输出 JSON 对象",它照样包 ```json。

实测(hubprobe stall --ai):2 次调用 0 失败,$0.024,约 9 秒/次。
storefront-a-47 拿到 4 个可直接做成岛上按钮的选项。
HUB_TRACE 回环探针全程 0 行 —— 守卫有效。

175 个测试通过(原 164)。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(probe): NudgeCoordinator —— 会闭嘴的升级阶梯

0 / 3 / 8 / 20 分钟四档递增,之后每 20 分钟。递增而不是固定间隔:
固定间隔在用户"知道但暂时不想处理"时会变成必须关掉的骚扰。
第一轮静默(刚停下来就叫太急躁,而且这时用户多半就在机器前面),
第二轮起出声,第三轮起换 identifier 重发通知 —— 同 id 的通知在通知中心
里是静默替换,不换 id 用户根本看不到"又催了一次"。

**加了计划里没有的两条封顶**,是拿真实数据逼出来的:
本机有个会话已经 idle 46 小时,按"每 20 分钟一轮、不再加码"会主动弹
138 次。那不是提醒,那是必须被关掉的东西。

  maxActiveRounds = 5     主动闯入最多五次
  giveUpAfter = 2 小时     之后只留跑马灯和下唇染色

放弃"闯入"不等于放弃"告知"—— passive 列表照常带着它,信息一直在岛上。
giveUpAfter 顺手解决了另一个场景:app 重启时会一次性看到几个隔夜会话,
它们的 since 早就超过阈值,所以启动瞬间不会被一串闯入淹没。

多个会话合并成一次提醒,按原因优先级排序,不排队弹 N 次。
用户跳转或应答只压制一轮 —— 跳过去看一眼不等于处理完了。真正的处理完是
会话重新开工,那时它就从 findings 里消失,状态自然清掉,下次再停是新的一件事。

纯状态机,now 显式传入,10 个时序测试跑完整条阶梯不用真等 20 分钟。
185 个测试通过(原 175)。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(island): 滞留提醒的岛上表现 + 应答 + 跨屏跟随

## 两个新形态

.nudge —— 卡住的会话的像素小人从刘海下沿弹出排开,逐个延迟 60ms
(一起弹是一整块在动,错开才读得出"一个一个冒出来")。每个小人上方一个
原因图标、下方截断的会话名,整格可点。刻意不复用 .intrusion:后者是
"某件事刚发生",单会话两秒划过;前者是"你有 N 件事卡着",聚合、停 5 秒、可交互。

.answer —— 显示 AI 的问题和它给的快捷回答。**点了按钮不直接发**,
先出"将发送到 iTerm · xxx:是"让用户确认一次。多这一步是因为刘海很小、
用户可能在剔背景的情况下随手点一下,而那一下可能是往生产推代码。
两态取同一个高度:不同的话点按钮时岛会跳,而那一跳恰好发生在用户
正要去点"确认发送"的时候,按钮会在指针底下移位。

折叠态:有东西卡着时点阵换成跑马灯(点阵回答的是"跑着几个",
卡住时要知道的是"哪个、为什么",这时点阵是纯噪音),整条下唇按最高优先级
原因染色。只在有 stall 时才滚,平时回到静态点阵,不引入常驻动画耗电。

## 回写终端(唯一不可逆的动作)

TerminalReply 的五条护栏:发送前重读会话状态(变 busy 说明用户已经自己
在那边动手了,取消并如实说,绝不硬发);只发一行纯文本 + 回车(换行会被
当成多敲一次回车,控制字符能触发终端快捷键);绑定只走 PID 祖先链,
绝不用窗口名匹配;定位不到就退化成跳转;每次发送留痕。

## 跨屏跟随

岛以前锁死在内建刘海屏,用户看着外接屏时提醒在物理上不在他视野里 ——
这条不解决,跑马灯和主动闯入全是白做。screenUnderCursor() 其实一开始
就写好了,只是全项目零调用。只在折叠态搬家:展开/审批/应答态搬家会把
用户正在点的东西从手底下挪走,尤其应答态按钮一移位,本来要点"取消"的
那一下可能落到"确认发送"上。

## 实机验证发现并修掉

点快捷回答 → 出预览而不是直接发(护栏生效);确认发送 → 目标会话不存在时
如实报错。但失败提示在点"取消"回到选项后仍挂着,读起来像这一次也失败了 ——
加 onClearFeedback,任何新动作先抹掉上一次的回显。

199 个测试通过(原 185),含 nudge/answer 的尺寸回归 ——
SwiftUI 的布局裁切是完全静默的失败,只能靠算术断言挡住。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: 小人多长了一只右手;滞留信息补进展开态;钉住不再卡死界面

## 挥手的小人有两只右手(用户在 48pt 详情卡上看出来的)

waiting 用挥手姿势,bodyRows 在肩膀那行画了举起来的右手,而 laptopRows
仍然把两只手都画在笔记本两侧 —— 同一个人两只右手。而 waiting 恰恰是
最需要被看清的状态,偏偏是它画错。

    9   ##ssssssss##f     ← 举起来挥的
   13   #LLLggggLLL#f     ← 同一只手又画了一遍

新增 SpriteAnatomyTests 断言每侧最多一只手。像素画的错误在 32pt 下肉眼
几乎发现不了,但在字符网格上可以精确断言。**回插 bug 验证过测试会红**
(24 条断言失败),不然写了等于没写。

## 滞留信息只活在 nudge 和跑马灯里

用户一展开列表 —— 也就是他真正要动手的地方 —— 滞留信息就消失了,
反而在最该看到的位置看不到。现在:

- 详情卡加滞留横幅(原因 + 具体内容),卡住时**顶掉**提交记录:
  两者都要位置,而"这件事在等你"永远比"三天前提交了什么"更急;
  是提问的话直接给「回答」按钮进应答态。
- 列表行加原因徽章,优先于笼统的 waitingFor("在问你"比 "input needed" 具体)。
- 折叠态点跑马灯**直达那件事**,不再跳到通用列表让用户自己再找一遍。
- 非提问的滞留点开是展开+选中,不直接把人甩到终端 ——
  用户点这一下是想先看清楚是什么事。

## HUB_ISLAND_STATE 会把界面卡死

实机暴露:岛钉在展开态、又跟着光标跑到了外接屏,用户点收起没反应,
只能去杀进程。钉住的本意是挡掉自动迁移(悬停会把它收回去),
不该把用户主动的操作也冻掉。现在 setState 分 userInitiated,
收起和展开一律放行。

顺带:收起时强制重新对齐到光标所在屏 —— 岛跟随只在折叠态生效,
而刚收起正是最该对齐的时刻(用户很可能已经换屏了)。

203 个测试通过(原 199)。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat: 换掉占位图标,并入 agentx 系列视觉

原来那个三机器人是占位图,和岛里的像素小人、刘海形态完全是两套语言。

沿用 agentx 系列的记号(青→蓝→紫→品红→橙的斜向渐变 + 四角星芒 + 外辉光,
对着 AgentX 和浏览器扩展的图标取的),但主体换成**灵动岛的胶囊形** ——
那是这个 app 唯一的门面,也是它在无刘海屏上的真实形态。

极简的判据是"16pt 下还认得出":一个胶囊 + 一颗星,没有任何细节要分辨。
实测 16pt 下胶囊清楚,但星芒只剩两三个像素、糊成一个污点反而像脏了,
所以 <32pt 不画星芒 —— 光学尺寸的常规做法,小尺寸减细节。

图标用代码生成(Resources/make-icon.swift)而不是塞一个二进制资产:
可以 diff、可以 code review、改一个参数不用重新导出,
和这个项目里像素小人 art 写在代码里是同一个理由。

    swift Resources/make-icon.swift AppIcon.iconset
    iconutil -c icns AppIcon.iconset -o Resources/AppIcon.icns

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(nudge): 升级阶梯按第一次提醒计时,不是按会话卡住的时刻

实机验证抓出来的。修复前的真实时间线:

  17:38:10  第 0 轮
  17:38:26  第 1 轮 有声    ← 隔 16 秒,阶梯说该隔 3 分钟

延迟原来从 since(会话变 idle 的时刻)算。product-library-fixes 被发现时
已经 idle 4 分钟(240 秒),于是一次就同时满足第 0 轮(≥0 秒)和
第 1 轮(≥180 秒),下一次扫描(30 秒后)立刻又喊。

关键在于这不是边角情况:**"被发现时已经卡了一会儿"才是常态** ——
app 刚启动会看到一批、刚满阈值才被判出来的也都是。等于阶梯对绝大多数
会话都是失效的,会连着把前几轮一次性喊完。

改成记 firstNudgedAt,阶梯从第一次喊开始计时。giveUpAfter 仍然按 since
算 —— 它要挡的正是"这事早就在那儿了,你显然不打算现在处理"。

原来那条阶梯测试让 since 和 now 从同一时刻起步,正好绕开了这个场景,
所以一直是绿的。新测试直接构造"发现时已卡 4 分钟",并**回插 bug 验证过
它会红**(2 条断言失败)。

顺带给 present() 加了提醒日志。滞留提醒是时间驱动的,没有日志根本没法
验证"到底喊没喊、第几轮、为什么"—— 这次的 bug 就是靠它才看出来的。

实机同时确认:多会话合并成一次弹出;idle 了 446 分钟的会话一次都没喊
(闭嘴上限有效);折叠态跑马灯在真实数据上正常滚动。

204 个测试通过(原 203)。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(nudge): 封住"反复变原因"绕过两道闭嘴上限的洞

"原因变了就重新计时"本身是对的("断线了"和"待验收"是两件事,
不该沿用旧轮次),但它同时开了两个洞:

1. 每翻一次从第 0 轮重来,于是既不升级、也永远撞不到 maxActiveRounds;
2. since 跟着被推到现在,giveUpAfter 那条"卡了两小时"永远够不着。

结果是一个在 idle / waiting 之间来回翻的会话可以无限期地每隔几分钟
弹一次同一个项目 —— 而这正是用户最容易直接关掉整个功能的那种行为。

两处修正:

  maxLadderRestarts = 2   重置本身封顶,超过之后原因照常更新但轮次不再
                          归零,阶梯继续往上走直到闭嘴
  firstSeenAt             记"连续卡着从何时开始",原因变了也不重置;
                          giveUpAfter 改按它算

会话真的恢复(从 findings 里消失)时整条记录被丢弃,重置额度也跟着清零 ——
否则一个用了很久的会话会因为历史上的几次翻转而再也不能重新计时。

测试断言的是**最终会安静**这个性质,不是某个具体次数:90 分钟里每 5 分钟
翻一次原因,断言最后半小时完全无声、且进入只做被动提示的状态。
回插两个洞验证过会红 —— 无防护时 18 次翻转全部触发,且换原因能绕过 giveUpAfter。

208 个测试通过(原 204)。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore: 版本升到 2.1.0

滞留守望、岛上应答、跨屏跟随、新图标 —— 都是新能力,走 minor。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore: 截图模式不启动守望;钉住日志打实际形态

守望 30 秒后会用自己对演示会话的判定覆盖预置的那组,而演示会话没有真实
transcript 和任务清单,判出来清一色待验收—— 五类原因的区分恰恰是要展示的。

钉住日志原来只打环境变量的值,钉住失效时照样显示已钉住,把排查带偏过一次。
改成同时打实际形态和滞留条数。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: wuguangan <anjaihd0206@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 19:43:01 +08:00

329 lines
14 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] = [:]
/// 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
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()
}
/// 观测 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 panes = TmuxProbe().bindPanes(to: pids, tree: tree)
if !panes.isEmpty {
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 }
let tmuxLabel = "tmux · \(pane.sessionName):\(pane.windowName)"
// 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)
}
}
}
}