Files
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

187 lines
6.7 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import HubCore
import HubProbe
import SwiftUI
/// 岛上应答要回答的那个问题,以及它能怎么答。
public struct AnswerRequest: Equatable, Sendable {
public let sessionId: String
public let sessionName: String
public let question: String
/// 快捷回答。来自 AI,没有就用通用四件套。
public let options: [String]
/// 人能看懂的目标位置("iTerm · storefront-a-47")。发送预览要显示它。
public let target: String?
public init(
sessionId: String, sessionName: String, question: String,
options: [String], target: String?
) {
self.sessionId = sessionId
self.sessionName = sessionName
self.question = question
self.options = options
self.target = target
}
/// AI 没给选项时的兜底。刻意包含"稍后"——
/// 只给"是/否"会逼用户在没准备好的时候二选一,而他真正想做的往往是
/// "先不回答,但别再问我了"。
public static let genericOptions = ["继续", "是", "否", "稍后"]
}
/// 应答态。**点了按钮不直接发,先出预览让用户确认一次。**
///
/// 多这一步的理由:刘海很小,而用户很可能在剔需要背景知识的情况下随手点一下 ——
/// 而那一下可能是往生产环境推代码(用户实拍的例子里,那个"是"意味着
/// 真往 Shopify live 推)。预览把"我要发什么、发到哪"摊开,
/// 把一次随手点变成一次明确决定。
struct AnswerContent: View {
let request: AnswerRequest
/// 确认发送。参数是最终要发出去的那一行。
let onSend: (String) -> Void
let onJump: () -> Void
let onDismiss: () -> Void
/// 发送结果的回显。发完不告诉用户成没成,他只能自己切过去看。
let feedback: String?
/// 清掉上一次的回显。
///
/// 失败之后用户点「取消」回到选项界面,那条"发送失败"如果还挂着,
/// 读起来就像**这一次**也失败了。任何新动作都要先把它抹掉。
let onClearFeedback: () -> Void
/// 已经选好、正在等确认的那一条。`nil` 表示还在选。
@State private var pending: String?
var body: some View {
VStack(alignment: .leading, spacing: IslandMetrics.answerGap) {
header
if let pending {
preview(for: pending)
confirmButtons(for: pending)
} else {
question
optionButtons
}
}
.padding(.horizontal, 18)
.padding(.vertical, IslandMetrics.answerVerticalInset)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
}
private var header: some View {
HStack(spacing: 6) {
Text("❓")
.font(.system(size: 11))
Text(request.sessionName)
.font(IslandTheme.label(12, .semibold))
.foregroundStyle(.white.opacity(0.92))
.lineLimit(1)
Spacer(minLength: 8)
if let feedback {
Text(feedback)
.font(IslandTheme.label(10, .medium))
.foregroundStyle(IslandTheme.waiting)
.lineLimit(1)
}
}
.frame(height: IslandMetrics.answerHeaderHeight)
}
private var question: some View {
Text(request.question)
.font(IslandTheme.label(12, .regular))
.foregroundStyle(.white.opacity(0.78))
.lineLimit(3)
.fixedSize(horizontal: false, vertical: true)
.frame(
maxWidth: .infinity,
minHeight: IslandMetrics.answerQuestionHeight,
alignment: .topLeading
)
}
private var optionButtons: some View {
HStack(spacing: 6) {
ForEach(options, id: \.self) { option in
Button {
onClearFeedback()
pending = option
} label: {
// 样式本身只给 frame 不给内边距,横向留白得自己加,
// 否则文字会贴到圆角边上。
Text(option).padding(.horizontal, 10)
}
.buttonStyle(IslandButtonStyle(emphasis: .secondary, height: 26))
}
Spacer(minLength: 0)
Button {
onJump()
} label: {
Text("去终端").padding(.horizontal, 8)
}
.buttonStyle(IslandButtonStyle(emphasis: .secondary, height: 26))
}
.frame(height: IslandMetrics.answerButtonsHeight)
}
private var options: [String] {
request.options.isEmpty ? AnswerRequest.genericOptions : request.options
}
/// 发送预览。**目标位置必须显示** —— 同一个项目开三个会话时,
/// "发到 storefront-a" 和 "发到 storefront-a 的哪个窗口" 是两回事。
private func preview(for text: String) -> some View {
VStack(alignment: .leading, spacing: 3) {
Text("将发送到 \(request.target ?? "该会话的终端"):")
.font(IslandTheme.label(10, .medium))
.foregroundStyle(.white.opacity(0.55))
Text(text)
.font(IslandTheme.label(13, .semibold))
.foregroundStyle(.white)
.lineLimit(1)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(
RoundedRectangle(cornerRadius: 6, style: .continuous)
.fill(.white.opacity(0.08))
)
}
.frame(
maxWidth: .infinity,
minHeight: IslandMetrics.answerPreviewHeight,
alignment: .topLeading
)
}
private func confirmButtons(for text: String) -> some View {
HStack(spacing: 6) {
Button {
onSend(text)
} label: {
Text("确认发送").padding(.horizontal, 12)
}
.buttonStyle(IslandButtonStyle(
emphasis: .prominent, tint: IslandTheme.waiting, height: 26
))
Button {
onClearFeedback()
pending = nil
} label: {
Text("取消").padding(.horizontal, 10)
}
.buttonStyle(IslandButtonStyle(emphasis: .secondary, height: 26))
Spacer(minLength: 0)
Button {
onDismiss()
} label: {
Text("关掉").padding(.horizontal, 8)
}
.buttonStyle(IslandButtonStyle(emphasis: .secondary, height: 26))
}
.frame(height: IslandMetrics.answerButtonsHeight)
}
}