37 Commits
Author SHA1 Message Date
wuguanganandClaude Opus 5 e3b9153b66 feat: 拦住 AI 把密钥搬出去;README 补 AI 时代的隐私那一节
## 密钥泄漏闸

把活交给 agent 之后,泄密的形态变了。以前是人手滑,现在是**每一步都很合理的
一串操作**:git add . 顺手把 .env 提了、把真实 token 内联进配置文件当"示例"、
为了确认配置 cat 一下密钥(值就进了 transcript,0644、永久、每轮重发给 API)。

**没有一条能靠「这条命令危不危险」判断出来。** 它们都不危险。唯一可靠的信号是
这次调用的内容里有没有出现真实的密钥值。

所以加了一道闸,默认开:参数里出现库里的真实值 → 拒;git add/commit 会把
.env 一类文件或含真实值的文件带进仓库 → 拒。拒绝理由里写明正确做法
(用 $KEY、先加 .gitignore),否则模型只会换个写法再试一次。

**它跑在 hubctl 本地,不依赖 app 在不在跑。** 仓库其它拦截都是「app 没开就放行」
(一个监控工具不该让你的命令跑不起来),这一条反过来 —— 密钥漏出去不可逆。

几条守住的边界,判宽了比判窄了危险:值短于 8 字符不参与匹配(abc/true/test
会命中一切);.example/.template/.sample 结尾放行(模板文件本来就该提交,
误拦一次用户就把整个功能关掉了);只认 EnvRenderer 写出来的格式,不做通用
dotenv 解析;不是 git add/commit 就一个子进程都不起。

拒了之后事件照发给 app(不等应答),进审批日志 —— 一道看不见的闸,
用户第一次被拦时只会觉得 Claude 抽风。开关在密钥页底部,
落成 ~/.vibe-foreman/guard.json,且**只在关掉时才写**:
默认开,「文件不存在 = 开着」,这样没打开过密钥页的人也受保护。

明确写了它挡不住什么:python -c 读、软链、base64 转一道、库外手写的值。
它是一道减少手滑的闸,不是沙箱。

## 截图

主窗口现在也能自动截了:HUB_WINDOW_SECTION 钉住打开哪一页,
配 DemoFixtures.seed 造的假密钥和假账号(刻意编得一眼假 —— 一个像真的
token 混进去,读者会去试)。

两个坑:find-window 按面积取第一个会截到岛(它比主窗口先出现一瞬),
改成按宽度 ≥900 筛;.accessory 的进程从后台 shell 起来抢不到焦点,
而非活跃窗口里的系统控件会被去饱和 —— 截出来开关全是灰的,
正好把要展示的东西说反了,所以截图模式下改用 .regular。

顺带:进密钥页自动选中第一组。有组却什么都不选,右边一大片空白,
那是"还没建组"的样子而不是"有四组等你挑"的样子。

646 个测试全过(原 625)。真 hubctl 二进制端到端验过四种情况:
写入真值被拒、约定的 source 用法放行、git add . 带 .env 被拒、普通命令放行。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 10:46:46 +08:00
wuguanganandClaude Opus 5 7b5dbbce95 feat: 给 AI 的那段话摊在界面上,不再藏在复制按钮背后
原来只有一个「复制给 AI 的话」按钮 —— 复制之前你根本不知道它会往剪贴板里
塞什么。一个内容不可见的复制按钮,用户要么不敢点,要么点了粘出来才发现
不是想要的。而这段话恰恰是要替他去管住 AI 的,他更有理由先看一眼。

绑定的项目行上多一个「看话术」,展开就是原文,等宽字体、可选中 ——
整段复制走按钮,只想拿其中一句就直接划走。下面再补一句说明为什么
第二句是重点(source 只把值放进那条命令的环境,Read 会把值留在对话记录里)。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 13:41:33 +08:00
wuguanganandClaude Opus 5 8510e1de28 feat: 读密钥文件的 Read/Grep/Glob 拦一下
`RiskClassifier` 里 readOnlyTools 是无条件早退的(注释写明理由:Read 一个讲
`rm -rf` 的 README 不该触发审批)。但「读密钥文件」恰恰是要拦的那件事——
共用密钥交给 AI 的契约是 `set -a; source`,值只进子进程环境;改用 Read
之后文件内容就进了 ~/.claude/projects/*.jsonl(0644、永久、每轮重发给 API)。

判断**只放在 readOnlyTools 那个分支里,不放在 classify 开头**。放开头的话,
约定用法里那条 source 命令每次都会弹审批,正常流程当场堵死。Bash 走下面的
模式匹配,`\.env\b` 已经把它定成 dangerous(记录不拦截),不动。

写清楚它挡不住什么:python -c 读、软链、base64 转一道都绕得过,
而且 hubctl 超时是 fail-open——app 没开着的时候这道闸是关的。
它是一次提醒,不是安全边界,README 和注释都按这个写。

路径常量抄在 HubIPC 里(那个模块刻意不依赖 HubProjects,hubctl 要小而快)。
抄就会漂,漂了的症状是静默的——改个文件名,闸就再也命不中而界面毫无变化。
所以加了 SensitivePathsAgreementTests,在同时看得见两个模块的测试目录里
把两边的真实路径对一遍。

625 个测试全过。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 12:48:30 +08:00
wuguanganandClaude Opus 5 a9363e43c9 feat: 共用密钥中心 + 项目账号密码库
两个功能来自同一个痛点,但威胁模型正好相反,这是整个设计的支点。

**共用密钥**:平台密钥(飞书、领星、OpenAI)在十几个项目里要各配一遍,
而最省事的做法——直接粘进聊天框告诉 AI——是最糟的那个。现在按平台建组、
勾给项目,物化成 ~/.vibe-foreman/env/by-project/<项目>-<哈希>.env
(目录 0700、文件 0600),复制路径给 AI。这份数据**故意是明文、故意给 AI 用**。

**账号密码**:本地/测试/线上分开记,只给人看。AES-GCM 加密,
查看和复制过 Touch ID。这份数据**绝不能被任何自动化读到**。

两者不共用存储、不共用目录、不共用界面,并用一条哨兵测试钉死
「密码的值不会出现在给 AI 的那个目录里」。

三个决定了做法的发现:

1. 「让 AI Read 那个文件」会把密钥写进两个明文文件——
   ~/.claude/projects/*.jsonl(0644、永久、每轮重发给 API),
   以及 Hub 自己的验收清单(Evidence.ran 会存命令和输出尾巴,同样不过期)。
   所以「复制给 AI」给的不是裸路径,是带 `set -a; source` 用法和
   「不要用 Read/cat/grep」禁令的三句话。换成 source 之后,
   transcript 里只剩路径和变量名,值一次都不出现。

2. 按项目分文件**是分装不是隔离**——同 uid 下 cat ../别的.env 照样拿得到。
   README 和代码注释都按分装写,不吹成边界。

3. Keychain 的授权弹窗**就是那道防线**。SecItemAdd 不传 kSecAttrAccess 时,
   默认 ACL 只允许创建它的 app 无提示读取,而在「攻击者 = 同 uid 的自动化」
   这个模型下,能区分「Hub 在读」和「security 命令在读」的只有它——
   文件权限做不到(同 uid),Touch ID 也做不到(LAContext 不参与取密钥)。
   为了消弹窗放宽 ACL,等于一条 Bash 就能取走主密钥。

几个不写下来一定会再踩的坑(已进 NOTES.md):
- .atomic 写换 inode,chmod 必须每次都做,只在创建时设一次从第二次写起就是 0644
- GCM 的 AAD 必须用文件里存下来的那段字节,重新序列化会让历史文件永久打不开
- 「钥匙不对」和「文件坏了」处置完全相反,靠 header 里的 keyID 分辨;
  EncryptedFile.save() 的 guard state.canWrite 是这套区分唯一的落地点
- LAContext 复用才有 reuse duration,每次新建等于每条密码按一次指纹
- 剪贴板定时清空必须比对 changeCount,否则用户复制的别的东西会被莫名清掉
- .env 的引号:shell / node dotenv / python-dotenv 三家的交集只有单引号;
  多行值用字面换行不能用 \n;含单引号的值没有共同解,改写成 _B64 显式降级

617 个测试全过(原 530)。真 bash `set -a; source` 验过含空格、$、反引号、
#、多行 PEM 和含单引号的值。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 12:45:20 +08:00
wuguanganandClaude Opus 5 11786954cf docs: 功能长图 + 全部截图用演示数据重截
## 长图

docs/poster.png:六段式,一张图讲完这个工具是什么。
生成脚本 Resources/make-poster.swift,改文案或调顺序改那个数组即可。

修了个坑:NSImage.size 返回的是**点**不是像素,这批截图是 2x 抓的,
1880px 的图它报 940,于是大图被当小图处理,第一版只占了半幅。

## 截图

scripts/shoot-screenshots.sh:用 HUB_DEMO=1 + HUB_ISLAND_STATE 把形态钉死,
自动截 + 自动裁掉透明留白,五种形态全自动。

之前那批是改名前截的,岛头还写着旧名。重截之后顺带修好三件事:
- 演示工作区路径 claude-hub-demo → vibe-foreman-demo
- 新图带上了「在问你」横幅和状态徽章,比旧图更能说明问题
- 删掉 04-main-window.png:验收页本来就带主窗口侧边栏,一张顶两张,
  而且那张的标题栏是旧名

两个配套小工具:
- scripts/find-window.swift  拿窗口 id 给 screencapture -l 用。
  CGWindowList 报的是显示名不是可执行名,写死 ClaudeHub 一个都找不到。
- scripts/crop-alpha.swift   裁到不透明内容的外接矩形。岛画在 640×720 的
  透明容器窗口里,不裁的话拼进长图就是一大块空白。

截图脚本踩了 NOTES.md 里记过的那个 bash 坑:变量后面紧跟中文标点
(`$state)`)会被算进变量名,set -u 下直接报未定义。加花括号。

窗口探测改成轮询而不是固定 sleep —— 实测 4 秒对 hover 够、对 expanded 不够。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-15 00:04:25 +08:00
wuguanganandClaude Opus 5 3af92f27cf chore: 仓库改名 vibe-foreman,代码认新旧两个目录名
仓库 gulugulu-cn/claude-hub → gulugulu-cn/vibe-foreman。
GitHub 自动重定向旧地址,6 个 Release 全部保留。

代码里按 ~/Documents/code/claude-hub 找脚本和 projects.yaml 的地方,
改成新旧两个目录名都试 —— 老用户的工作区还叫旧名,新 clone 的是新名,
少认一个的表现是"从界面开项目时项目配置没被补齐",而那是静默的。

**Application Support 下的 claude-hub 目录不改名。** 那是用户数据落盘的
地方(验收清单、盯梢配置、已结束会话名册、置顶、审批日志),
改了等于把现有配置全孤立掉。

530 个测试全过。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 23:05:03 +08:00
wuguanganandClaude Opus 5 172ae46515 chore: 界面和提示语里的产品名也改过来
上一版只改了 app 显示名,Swift 里的字面量还写着旧名 —— 而那批是
**用户直接看得到的**:验收守望的提示头、审批被拦时的理由、
岛上作答回给 Claude 的那句、验收页的空状态。

正文用短名 Vibe Foreman,app 显示名保持 Vibe Foreman Free:
全名带 Free 夹在句子中间很别扭,而且以后真出付费版时正文不用再改一遍。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 22:51:42 +08:00
wuguanganandClaude Opus 5 bae4f9af86 feat: 零配置装 hook;改名 Vibe Foreman Free
## 零配置

装法从三步变两步:拖进应用程序 → 打开。中间那步「双击安装 hook.command」
没有任何用户能做的决定,漏点它的后果却是**静默的** —— app 开着、
界面正常、一条事件都收不到,看起来像"装了但没用"。

HookInstaller 在启动时自己装。四条边界,都有测试钉着:

- 只碰自己的条目:只删命令里含 `hubctl hook` / `hub-hook-` 的(我们的旧版),
  用户手写的 hook 和 permissions / env 等其它键一律原样保留
- 幂等:内容没变一个字节都不写。`.sortedKeys` 是这条的前提 ——
  不排序时 JSON 键序跟着哈希走,"没变就不写"的判断会随机失效
- 坏 JSON 不覆盖:用户可能只是手改时漏了个逗号,覆盖等于删了他的配置
- 失败不阻断启动:hook 没装好的 app 仍能显示会话,起不来的 app 什么都不是

hubctl 每次比内容再决定要不要拷 —— 升级 app 后 hook 还指着旧二进制的话,
协议一变整条链路静默失效。设置页显示状态 + 一键修复。

dmg 里那个 command 文件改名成「手工修复 hook(通常用不到)」,留作兜底。

实测:本机 settings.json 装前装后逐键对比,顶层键无增无减、
permissions 等非 hooks 值全部原样、9 类 hook 各 1 条无重复;
第二次启动 sha256 未变。

## 改名

显示名 Claude Hub → Vibe Foreman Free。

**bundle id、签名证书名、可执行文件名 ClaudeHub 全部不动** ——
换任何一个都会让已授予的 TCC 权限(控制 iTerm 的 Automation、通知)失效,
而 `pgrep -x ClaudeHub` 那条判活约束也依赖可执行名(见 NOTES.md)。
安装时清掉旧名字那份,避免 /Applications 里同时躺着两个共用 bundle id 的 app。

530 个测试全过。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 22:27:28 +08:00
wuguanganandClaude Opus 5 bfc4ebaecb fix: 失效项目静默开到家目录;补上删除和"添加任意目录"
## 根因

tmux 对不存在的 `-c` 目录**返回 0 并悄悄用家目录**,`open /不存在` 则
静默什么都不做。实测:

    tmux new-session -d -s t -c /不存在 ; echo $?   → 0
    pane_current_path                              → /Users/dev

projects.yaml 里 40 条有 6 条指向已删除的目录(agentx-agent 那一批),
于是表现成「Finder / VS Code 全没反应」+「Claude 开到 /Users/dev」——
看起来像刚加的右键菜单坏了,实际是数据坏了。

两个静默回落把数据问题伪装成了功能问题。我连猜三轮代码都没猜中,
最后靠"派发参数落盘 + tmux 实测"定位。

## 改动

- TerminalDispatch.open 派发前查 fileExists(isDirectory:),不通过就拒绝
- 派发日志写 launch.log(log show 对这个 app 是瞎的,ad-hoc 签名)
- hubprobe launch <项目名> [mode]:走完全相同那条路,把"界面传了哪个项目"
  和"派发本身对不对"分开,这次就是没有它才只能猜
- ProjectStore.missingPaths:失效项目在两个界面上都标出来(岛上尤其必要,
  那里根本不显示路径),菜单只留「从列表移除」——目录没了时每一个"打开"
  类选项都是静默失败,给假的可点项等于把刚修好的问题再造一遍
- ProjectStore.remove / 工具栏「清理失效 N」:只动清单,不碰磁盘
- ProjectStore.addExisting + 「添加目录」:扫描只认 .git,monorepo 子目录和
  没 git init 的目录永远进不来(实机 7 条),这是缺的那个入口

删除是文本手术,不是解析完重写 —— 保住手写的注释/别名/描述。
边界是删到下一个同级列表项或顶层键;只删 name 那行会让剩下的字段被挂到
前一条项目上,删一条弄坏两条且完全静默。11 个测试钉着。

518 个测试全过。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 15:58:14 +08:00
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
wuguanganandClaude Opus 5 5ce8c9893e fix: 重启后开项目弹系统终端;终端关了会话还显示在线
两个都是「看起来对、其实一直错」的判断。

## 1. iTerm 检测

iTerm2 的包叫 iTerm.app,只有可执行文件叫 iTerm2。旧的 isInstalled 拿
AppleScript 名拼 /Applications/iTerm2.app,永远不存在,detectTerminal()
一路降级到 macOS 终端。

平时看不出来:hub session 通常已存在且有客户端连着,走 addWindow 根本
不碰终端检测。只有开机后第一次建 session 才暴露 —— 所以现象是"重启后",
其实和重启无关。

改成 bundle id 走 LaunchServices,路径只做兜底且两个候选包名都试。

顺带修 pgrep:macOS 的 pgrep 默认排除自己的祖先进程(要 -a),所以
从 iTerm 里跑的 hubctl/hubprobe 问"iTerm 在跑吗"恒为否。改用
NSRunningApplication(也不会像 AppleScript 那样触发冷启动)。

## 2. detached 会话

关掉终端不结束会话 —— claude 的父进程是 tmux server,关窗口只是 detach。
kill(pid, 0) 依然为真,项目行一直亮着蓝点写"1 个会话"。

不是把它报成已退出(那会掩盖一个还在烧 token 的进程),而是把两半都说:
还在跑,但终端已经关了。空心点 + "1 个会话 · 终端已关",
会话详情卡的位置标注也加上"(终端已关,仍在后台跑)"。

边界:按 tmux 会话名判而不是"有没有任何客户端";绑不到 pane 的不算
detached —— 那是根本不在 tmux 里,判断不了的事不能当结论下。

14 个新测试,482 个全过。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 13:47:06 +08:00
wuguanganandClaude Opus 5 d55941561f fix: 验收把训练卡死了 —— 加冷却、降条数、追问收窄
实测:agentx 清单涨到 175 条,它每次收工被拦下答 6 条,答完又被拦。
22:02–22:08 六分钟全在答验收,一点活没干。
**验收本来是为了让工作更实,不是替代工作。**

三处一起收:
- 拦截加 15 分钟冷却(卸膛仍在最前面,防死循环结构不变)
- 注入上限 6 → 3
- 追问清单 77 条 → 5 条,全部以「继续」开头,只保证不停 + 目标通道打通
  (完整版备份在 scripts/probes/agentx-training-full.txt.bak)

用户原话:「只要保证最后的目标通道打通,让他围绕训练就好,
不要加另外的要求。」

468 个测试。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 03:39:28 +08:00
wuguanganandClaude Opus 5 a6186e9505 feat: 主动去仓库核对,不等它自报
补的是最大的缺口:旁路复核只在它自报做完时才跑,**它压根不提的事情
没有任何东西会去查**。三份训练素材发过去两小时一个都没接,
系统里没有任何机制发现,是用户先问的。

「它停了就催」防偷懒;「它说做完了就核」防虚报;
**都防不住「它假装这事不存在」** —— 这一层补这个。

HubProbe/RepoTrace.swift:从要点文本挑关键词(引号里的名字、
路径、带连字符的标识符),去 commit 信息 / 改动文件名 / 现存文件名
三处找痕迹。一个都找不到的排到追问最前面,问法是
「我去仓库里搜过了三处都找不到任何痕迹,这条是漏了还是判断不该做还是卡住了」。

两条刻意的取舍:
- 挑不出关键词就返回空、跳过这条。拿「训练」「验证」这种词去搜什么都能命中,
  会把「有痕迹」这个信号稀释成噪音 —— 比不查更糟,因为让人以为查过了。
- 多个关键词只要一个有痕迹就不报。宁可漏报不可误告:误告一次用户就不信了。

判据只回答「有没有痕迹」,不判「做得对不对」(那是 auditor 的事)。
零痕迹是极强的信号 —— 干过的事不可能一点印子都不留。

468 个测试。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 02:26:07 +08:00
wuguanganandClaude Opus 5 048cec7953 docs: 观察者交接(上下文压缩前)
这套系统存在的理由就是「压缩会让人忘事」,所以它自己的状态必须落盘。

记了六件事:系统现状(含自己查状态的命令)、还没做的 8 项(按用户关注度排)、
agentx 今天的真实结论(32 commit / 6 版本落地了,但全天 feishu 样本 0 条、
方向存疑)、已写进 trainer 规范的两节、我自己这两天犯的 7 个错、
下次接手先做什么。

那 7 个错是同一个形状:验证的是「我做了这个动作」,不是「这个动作产生了效果」。
和 agentx 那边「改了代码但没打包发布」一模一样。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 22:14:18 +08:00
wuguanganandClaude Opus 5 6b73747ea5 feat: 盯梢改成事件驱动;追问计数按会话不按项目
用户指出的两个问题。

## 反应力:那边停了这边没马上跟上

Hub 在 Stop hook 里第一时间就知道会话收工了,而盯梢却在 45 秒一轮地
轮询、还要连续两次确认 —— 最少 90 秒才会追问。架构选错了。

HookCoordinator 加 onSessionStopped,收工那一刻直接叫醒盯梢;
事件驱动这条路不需要反复确认(Stop hook 已经是确凿证据),
只等 12 秒让用户有插话的余地。轮询保留作兜底 —— Stop hook 没装、
或者会话被别的方式打断时还得靠它。

## 一个项目会开很多会话

probeIndex 原来按项目计数,同项目的多个会话共用一个游标 ——
每个会话只拿到清单的一个片段,跳着问的半截,没有一个能被完整问一遍。
改成按 sessionId。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 15:58:39 +08:00
wuguanganandClaude Opus 5 1c4cf0ff5e fix: 追问显示成别的项目的;手改清单被 app 盖掉
用户从界面上看出来的两个问题,都是真的。

## 上次追问显示的是别的项目

取的是 history.first(全局最近一条),于是 agentx 的页面上显示着发给
demo-android 会话的追问。NudgeRecord 加 projectPath,按项目过滤。

## 手改的清单被盖回去

我往 watchdog.json 写了 47 条,app 用内存里的 25 条盖了回去。
**这个 bug 我今天早上在 AcceptanceStore 上修过一次**(写盘前先重读),
当时没想到 SessionWatchdog 有一模一样的一份。

merge 只捡清单不捡 watching:watching 是界面开关,内存里的才是用户最新
意图,合并的话「关掉盯梢」会被盘上的旧值又打开 —— 开关静默失灵。
这一条是 testCanStopWatching 抓到的,不是我想到的。

455 个测试。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 15:55:47 +08:00
wuguanganandClaude Opus 5 9ba277ccaf fix: 修回自己改掉的 pid 和进度计数
两处都是我改别的东西时顺手删的 —— 正是刚提醒 agentx 那边
「别为了改一处顺手把好东西改坏」的同一个错,当场自己犯了一遍。

- 找不到 pane 时把 pid 带回来:没有它就没法人工核查是哪个进程够不着。
  我为了区分「故障」和「正常情况」改措辞时删掉了。
- 追问带回「第 N/M 条」:看不出问到第几条、总共几条,就没法判断
  它是不是在原地打转。改成自动生成追问时换格式删掉了。

455 个测试。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 15:51:19 +08:00
wuguanganandClaude Opus 5 9cd6f7d331 fix: 分不清「停了」和「命令跑得慢」;它提问时不只是踢回球
## 画面在变就别打扰

原来只看转圈符号。但慢命令跑起来时状态可能是 shell、转圈行可能被滚屏顶掉,
而输出一直在往外吐 —— 会被误判成停了,然后在它跑到一半时插一句话。

改成对比上一轮的画面:不一样就说明有东西在动,不管状态字段说什么。
这比"有没有转圈符号"可靠得多,因为它不依赖任何特定的显示形态。

## 它提问时顺带派活

原来只回一句"自己判断着办" —— 那是把球踢回去,它下一轮很可能又来问一次。
现在从验收清单里挑一条还没做的一起带上,让它有明确的下一步。

455 个测试。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 15:45:42 +08:00
wuguanganandClaude Opus 5 929f1ab06e fix: 盯梢照稿子念,不看对面说了什么
它问「要我从第 1 步开始吗?」时,盯梢照常甩出轮到的那条追问,
问题被直接埋掉 —— 它在等的决定没人回,用户看到一句驴唇不对马嘴的话。

现在发之前先看最后一句是不是在提问。是的话不发清单里的问题,
改成回应它并把决定权交回去(原问题原样带上,用户能在历史里看到它问过什么)。

判据刻意保守:只认最后一个非空正文行以问号结尾,中间的反问句不算 ——
误判会让它正常干活时也被插一句废话。同 StallDetector 的窄启发式,
不另发明一套。

455 个测试。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 15:38:49 +08:00
wuguanganandClaude Opus 5 5b4293b54c feat: 追问自动从验收清单生成,不再只问通用问题
手写的通用清单问几轮之后就被摸清套路了,答案越来越像模板
("落到哪些文件了" → 每次都能编出一份路径)。而清单里明明躺着
18 条待验收、7 条存疑 —— **那些才是该问的具体问题**,
而且每一条都问不出模板答案。

generatedProbes 当场从 ledger 生成:

  存疑:「你说做完了『X』,但真实 diff 里找不到对应改动(复核说:...)。
        具体是哪个文件的哪几行实现的?找不到就直说没做。」
  待验收:「『X』这条做了吗?验收条件是:Y。做了给文件路径和行号。」

存疑的排最前 —— 那一档是「自报做完但 diff 里找不到」,
整个清单里最该当面对质的一类,排后面会被 12 条的上限挤掉。

通用和清单生成**交替**:只问清单会让它一直在对答案,
只问通用又摸不到具体的漏项。

界面上两个数字分开显示("通用 8 条 + 清单 25 条 · 交替问")——
用户要能看出这是两回事,否则改了文本框却发现问出来的对不上,会以为坏了。

已定论的、已标「疑似误拆」的都不再问。超长要点截断:
注入那层有单行和长度限制,整段贴进去会被拒发。

450 个测试。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 12:19:50 +08:00
wuguanganandClaude Opus 5 a66f8a6ca9 fix: 盯梢记着"追问了",终端里一个字都没有
同一个 shell 误解的第二份副本,在另一个文件里。

TerminalReply.send 里有 guard !session.status.isWorking,而 isWorking
包含 shell。那个判断对它本来的用途(答终端弹出的权限对话框)是对的 ——
会话在忙时往输入框敲字会串进别的地方。

但对盯梢来说 **shell 恰恰是最该催的状态**(挂着后台 shell、人却停在提示符
上)。于是每次追问都被静默挡掉返回 .sessionBecameBusy,而我把返回值扔了
(`_ = await reply.send(...)`),历史里记着"已追问第 1/25 条",
终端 300 行历史里那句话出现 0 次。

我在 PaneActivity 里修过这个误解,却没想到 TerminalReply 有自己的一份。

改法:盯梢走自己的注入路径(它本来就已经定位到 pane 了),
并且**把结果记进心跳** —— 原来扔掉返回值才是根因,
不知道成功失败的操作等于没做。

心跳现在长这样:
  追问 xxx 第 1/25 条 —— 已送达
  追问 xxx 第 1/25 条 —— 敲字失败:can't find pane

独立验证过两步注入在真实 pane 上有效(先 send-keys -l 文字、
停 0.5 秒、再 Enter;合在一起时终端偶尔把回车吃进上一段输入)。

441 个测试。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 11:36:23 +08:00
wuguanganandClaude Opus 5 f37e39ba69 fix: 盯梢三处静默失效 —— 定位、落盘、可观测性
实机上盯梢整整 83 分钟一次都没催,用户先发现的,不是程序。三个 bug
互相掩盖,而且我用其中一个坏掉的东西去诊断另一个,得出了错误结论。

## 1. locate() 返回的是标签,不是 pane 目标

TerminalReply.locate 给的是 "tmux · hub:agentx-dev-kit"(给人看的),
我拿它去 capture-pane -t 必然失败 —— 而失败是静默的:抓到空字符串,
代码接着往下判断。改成从 pid 往上找真正的 pane(%15),
并且**抓不到画面时明确跳过**,不再拿抓取失败当结论。

## 2. history 从来没落盘

我读文件看到"追问 0 次"就下了结论 —— 但那个数字本来就永远是 0。
用一个坏掉的仪表去判断另一个坏掉的东西。追问后补 persist()。

## 3. 没有心跳,"死了"和"活着但还没到时候"长得一模一样

都是没有任何输出。昨晚一个死掉的 bash 盯梢挂了快 4 小时没被发现,
就是因为分不清这两种。

心跳写文件不写日志:本机实测 log show 完全看不到这个 app 的输出
(连启动那条都没有)。**一个看不见的仪表比没有仪表更糟**——
我拿它做过诊断。

心跳内容是"这轮为什么没催",可能值全部拆开:找不到 pane / 抓不到画面 /
不打扰(原因) / 第 N/2 次确认 / 冷却期内 / 刚追问了第 N/M 条。
下次失灵时是哪一环坏的会直接写在文件里。

顺带:心跳原来只记最后一个会话的结果,一个残留的死会话会把真正在跑的
那个整个盖掉 —— 第一条心跳就撞上了。改成每个会话各记一句。

441 个测试。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 11:14:21 +08:00
wuguanganandClaude Opus 5 415f2e7caf feat: 会话观察者做进 Hub —— 停了就按清单追问
原来是个飘在后台的 bash 脚本:关机就没了,也没人知道它在不在跑。
现在是 app 的一部分,配置落盘、开关在界面上、每次追问都留痕。

## 它解决的不是「停了」,是「做浅了」

只会说「继续」的盯梢防不了它每次都继续、每次都做表面功夫,最后交一份
看起来很完整的报告。所以追问是一份轮换的清单:落到哪些文件了、
发布后还在不在、有哪些卡点没解决、勾了框但没动代码的直接说 ——
每次问一条,问完从头再来。默认清单有一条测试专门守着"不能只有继续"。

## 三条硬约束,都是实机撞出来的

1. 只有 busy 算在干活。shell 表示"挂着后台 shell",和 Claude 在不在思考
   无关 —— bash 版把它当成在干活,会话停了 20 分钟一次没催到,
   是用户先发现的,不是程序。
2. 用户在输入框里打字时绝不发。注入是往当前光标位置敲字,会拼到他没发完的
   那句后面 —— 真发生过,把用户的话和追问接成了一句。
3. 发送分两步(先文字、停一下、再回车)。合在一起时终端偶尔会把回车吃进
   上一段输入,消息卡在输入框发不出去,看起来催过了其实一个字没送。

前两条的判断抽成了 PaneActivity 的纯函数,16 条测试。这是整套里唯一
容易写错、而且写错了不报错只静默失灵的部分 —— 转圈检测刻意只匹配
"(数字s · "这个计时格式,不匹配 Wandering/Boondoggling 那些动词:
那是一份永远补不完的名单,漏一个就是一次误催。

排队提示("Press up to edit queued messages")不算草稿 ——
当成草稿的话一旦有排队盯梢就再也不敢发言,而排队恰恰说明它正忙,
忙完就会读,这时候补一条完全没问题。

截图模式下不启动:那会往演示会话里敲字。

441 个测试。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 02:50:15 +08:00
wuguanganandClaude Opus 5 501334cb65 fix: 老实回答「没做」的要点会被永远重复问;盯梢升级成追问
## 诚实反而被惩罚

答 done:false 的项保持 .open,而 .open 正是注入的筛选条件 ——
于是每一轮问的都是同一批 6 条,后面的要点一次都轮不到。
实测在我自己身上撞到:连着两轮被拦,问的是一模一样的 6 条。

加 lastAskedAt / askCount,按「最久没问过」轮换。问过 3 次还说没做的
标成「疑似误拆」并停止再问 —— 多半不是它偷懒,是这条拆偏了。
真实例子:用户说「加个人工确认吧 不然都清理不掉了」(意思是要一个手动
确认的入口),被拆成了「清理操作前增加确认步骤」和「取消确认时中止清理」
两条根本不存在的需求。

「疑似误拆」必须在界面上标出来:它已经被排除出注入了,不标的话就是
安静地消失,用户永远不知道有一条要点再也不会被问。

## 盯梢:从「说继续」升级成「追问」

「继续」只能防它停,防不了它做浅。它完全可以每次都继续、每次都做表面功夫,
最后给一份看起来很完整的报告。

scripts/keep-going.sh 改成读追问清单轮着问;
scripts/probes/agentx-training.txt 是 agentx 训练的 11 条:
训练包逐条对照、AI 浏览器实际验证、定时任务、handoff 覆盖、
测了几轮调了什么参数、卡点落点、有没有落成 skills、有没有新增子员工、
上下文被压缩后有没有遗漏、拿给真实用户最可能在哪翻车。

413 个测试。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 02:36:31 +08:00
wuguanganandClaude Opus 5 b8520c3b14 fix: Claude 勾掉的框不算证据;一条 todo 里的多件事要拆开标注
两条都来自用户的观察,两条我都犯了自己一直在防的错误。

## 勾掉 ≠ 做完

先前把 Claude 勾了 completed 的 todo 直接记成 .confirmed(已验收),
理由是"勾状态是它边干边写进文件的,比嘴上说可信"。这个理由站不住 ——
它勾自己的框和它嘴上说"做完了"是同一性质的东西:都是声明,都没有外部证据。
拿它自己勾的框当证据,正是这整个功能存在的理由所要防的事。

改成落到 .claimed(待复核),证据记成 .claimed(...)(isProof = false),
由旁路复核拿真实 git diff 判 confirmed / disputed。
用户的原话:「他勾掉的到底有没有做」—— 只有 diff 能回答。

顺带补上 baselineCommit:缺了它复核只能拿未提交的改动去比,结论全是噪音。

## 一条 todo 里常常不止一件事

「批次2:设备自动连接(Android 地基修复→状态机→UI→iOS)」是四件。
整条勾掉时里面做了三件还是四件,从条目本身看不出来 ——
而漏掉的往往正是最后那件。

拆分是机械的(按 →、;+/ 等明确分隔符),不调模型:这一步要对每条 todo
都跑,而它要解决的问题很窄。切不出来的("深色模式双端"其实是 iOS+Android)
就不切 —— 切错比不切更糟,用户会对着一堆碎片然后不再相信这个标注。

界面上:行内直接显示「2/4 项」,展开逐项勾。藏在展开区里等于没标,
用户不会为了看有没有分项去逐条展开。

全部分项勾完**不自动**把整条标成已验收 —— 那等于绕过"拿 diff 核对"这一步。

修 bug:标题前缀门槛原来是 12 字,把「把整个权限体系重构一遍:改A、改B」
的前半句当标题剥掉了。中文里 11 个字是实打实的内容,压到 6 字。

411 个测试。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 22:51:03 +08:00
wuguanganandClaude Opus 5 920ad7505f feat: 追踪 AI 自己答应的事,并能推回对应 session
用户一眼看出的缺口:清单里全是他说过的话,**AI 自己列的 todo 一条都没有**。

我先前的判断是"Claude 的 todo 不能当基线"——这没错,翻译那一步就已经丢
东西了。但我顺手把它整个排除了,而「它自己列了 6 项做完 3 项就说完事了」
恰恰是遗漏最直接的证据,且这类承诺不在用户原话里,别处根本抓不到。

现在收,但单独标成「AI 计划」,Origin.isFromUser 把"用户的权威基线"和
"待核对的 AI 自述"在类型层面分开 —— 混成一档就等于把自述当成了基线。

- TaskStateReader.readTasks 逐条读 ~/.claude/tasks/(零成本,不调模型)。
  原来只给计数,而验收要的是"没做的那几项分别是什么"。
- Claude 自己勾掉的跟着同步成已确认(否则永远挂在未验收,用户手动清不动,
  清不动的清单最后没人看)。只动 .assistantTask 那一档:它勾掉自己的 todo
  不代表用户提的需求做完了,同名时尤其危险("发布新版本")。
- 按**会话**统计而不是按项目:用户问的是"这一轮它答应的做全了没"。
- 「告知这个会话」走 TerminalReply.send 把未做项注入回那个终端 ——
  不是发通知让用户自己切过去,是直接把话说到那个 Claude 面前。

实测本机某会话读出 6 项完成 + 1 项 pending「真机验证」——
正是典型的遗漏形态:活干完了,验证挂着,而它已经说"完成了"。

401 个测试。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 22:31:44 +08:00
wuguanganandClaude Opus 5 f1cdcd2547 feat: 验收清单行内直接确认/划掉
原来只有展开单条才有确认按钮。一份清单几十条,逐条展开再确认根本清不动 ——
清不掉的清单会一直堆着,堆到没人看,那时这个功能就等于不存在了。

按钮放在可点击区域**外面**:套在同一个 onTapGesture 里的话,
点「确认」会顺手把行也展开。

展开区里原来的接受/划掉去掉了,只留删除 —— 同一个动作在两处出现,
用户会以为它们不是一回事。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 21:03:01 +08:00
wuguanganandClaude Opus 5 78d24c934f fix: 加字段会把用户已有的验收清单整个抹掉
给 AcceptanceLedger 加了非可选的 authorizedCommands 之后,所有已存在的
清单文件当场解码失败 —— Swift 合成的 Codable 要求非可选字段必须存在。
而读取是 try? 静默吞错误的,于是下一次写入从空清单开始,把内容整个覆盖掉,
全程无声无息。

真丢了数据:本机 9 个项目里,claude-hub 的 13 条和 storefront-a 的 1 条没了,
另有 demo-release 的 7 条正等着被同样地清空(赶在它之前修好了,
实测那 7 条经历一次真实写入后完整存活)。

我在 HookEvent 那边特意把新字段都设成可选并写了兼容性测试,却没对持久化的
ledger 做同样的事。而 ledger 的后果严重得多:hook 事件丢一条无所谓,
用户攒几周的清单没了是真损失。

三层修复:

1. 手写 init(from:),每个字段 decodeIfPresent —— 加字段不再让老数据全废。
2. 逐条解码要点,坏的跳过。原来一条坏的会让整个数组抛错,整份清单报废。
   同 TaskStateReader 的处理:边跑边写的数据撞上半条是正常情况。
3. **解不出来的文件先改名保住,绝不静默覆盖。** 丢数据和读不出来是两个
   量级的事故 —— 后者还能捞回来。

391 个测试,三条都有对应的回归测试。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 20:33:55 +08:00
wuguanganandClaude Opus 5 a5ba3f7573 feat: Hub 自己跑验收命令 —— 实际功能证明
阶段 4 的教训直接催生了这一步:diff 复核把 `case ran(command:exitCode:)`
这个**枚举声明**当成了功能已实现,而没有任何代码产生它。读代码判断不了
"功能是否真的能用",只有跑一遍能。

执行者必须是 Hub 而不是 Claude —— 让它跑完再转述,就又回到了「说说而已」,
转述那一步正是失真发生的地方。产出 Evidence.ran(isProof = true)。

三道闸,全部有测试,元字符那道做过变异验证:

1. 白名单:只放行构建/测试类。刻意排除 npx / pnpm dlx(会下载执行任意包,
   放行它等于白名单形同虚设)、npm install(跑 postinstall 等于执行任意
   代码)、git 的写操作。bash 限定在项目的 scripts/ 下。
2. 元字符:16 个字符逐个测。**这道检查必须在白名单之前** ——
   变异验证时把它挪到后面,`swift test | tee /tmp/x` 立刻因为前缀匹配上
   `swift test` 而被放行。配合 Shell.run 走 execve 数组参数(不是 sh -c)。
3. 总开关默认 off + 逐条授权。授权按命令原文精确匹配,改一个字要重新授权;
   而且手工往 authorized 里塞危险命令也绕不过前两道(单独一条测试守)。

顺带修了注入正文太长:真实项目上拦了 12 条带完整验收条件,糊了满屏。
要点一多就没人看,反而比不拦更容易被忽略。上限 6 条,存疑的排前面,
省略了多少明确说出来。

Shell.run 加 currentDirectory —— 验收命令必须在项目目录里跑,
否则 swift test 找不到 Package.swift。

388 个测试。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 19:37:45 +08:00
wuguanganandClaude Opus 5 d93a624b4e feat: 旁路 diff 复核 —— 拿真实改动核对它的自报
自查治「忘」,但自查仍然是它在说自己,治不了「骗」,也治不了
「它真心以为自己做了」。这一层读 git diff 里到底有没有对应改动。
结论只有 confirmed / disputed 两档:中间档会变成模型拿不准时的垃圾桶,
而用户看到"部分完成"也不知道该做什么。

新增 GitDiff(numstat/patch/balancedPatch)和 AcceptanceAuditor。
UI 上点 .diff 证据能看那个文件从基线到现在的真实 diff。

三次真机实验(一条真做了的 + 一条我故意谎报的),前两次都判错,
两个 bug 都只有在真机上才暴露得出来:

1. 整份 diff 截断 → git diff 按字母序输出,前几个文件吃光 24KB 预算,
   MainWindow.swift(真改了 79 行)一行都没进模型眼里,于是判"存疑,
   实际 diff 未展示"。任何真实规模的改动都会这样,不是调大上限能解决的。
   改成按文件均分预算(balancedPatch)。

2. 均分之后每文件只有 1200 字符,只露出第一个 hunk,对应那条要点的改动
   在后面 —— 同一类问题降了一层。提到 120k 总预算 / 6k 每文件。
   省这点 token 换来的是不可信的结论,而不可信的结论比没有结论更糟:
   用户会照着它去改本来没问题的东西。

3. 谎报的那条被判成 confirmed,理由是"Evidence.ran 真实执行命令"——
   模型把**类型声明**当成了功能存在。而 .ran 只是个 enum case,
   没有任何代码产生它。声明读起来非常像功能已经实现,这是 diff 复核
   最容易翻的一类车。提示词里单独加了一条。

第三次两条都判对,理由精确到代码位置("SessionsPane 会话行新增
Button(\"清单\") 调 onOpenLedger" / "仅见 Evidence.ran 声明,
未见实际执行验收命令并填充 exitCode/tail 的调用点")。

**但这仍然是一个弱信号,不该被当成结论。** 第 3 个 bug 恰好证明了
读 diff 判断不了"功能是否真的能用"——这正是阶段 5(Hub 自己跑验收命令)
存在的理由。

369 个测试。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 19:10:05 +08:00
wuguanganandClaude Opus 5 fe3febbfd2 feat: Stop 拦截注入 —— 收工时拿清单逼它逐条报证据
Stop 从「非阻塞、输出被忽略」改成阻塞式,有待验收项时输出
{"decision":"block","reason":<清单>} 让 Claude 不停下来,先逐条核对。
下一轮的 lastAssistantMessage 里的自查 JSON 被收回来,把 open 推到 claimed。

Stop 的超时阶梯和审批那条**完全分开**,方向也相反:

  3s HookCoordinator / 5s hubctl / 10s settings.json,超时一律 = 不拦。

审批超时 = 拒绝(安全刹车);这里超时 = 放行。写反的话 Hub 一出问题
所有会话就再也收不了工,比"少提醒一次验收"严重得多。实测 hook 本身
6-11ms,Hub 在不在都一样。

查出来三个真问题,都不是靠读代码发现的:

1. 服务端从不给 stop 回写应答。我改了 hubctl 让它等,却没动
   HubSocketServer 里写死的 guard kind == .preToolUse —— 拦截一次都没生效
   过,而且不报错、不超时、10ms 就返回,表现得和"清单里没有待办"
   一模一样。端到端测了三轮才找到。修法不是补一个 if,是加
   Kind.expectsDecision 让两边共用同一个判断。

   这和上次「grep activeScreen() 漏了一个调用点」是同一类错误:
   改了协议的一端,假设另一端会跟上。

2. SIGPIPE 会杀掉 Hub。服务端开始给 stop 回写之后,遇到不等应答就关连接
   的客户端(旧版 hubctl、被 Ctrl-C 的 hook),那次 write 直接终止进程 ——
   不是报错,是进程没了。测试崩在 signal 13 上才暴露。
   先加了 per-socket 的 SO_NOSIGPIPE(看起来更讲究),结果没生效,
   照样崩;改成进程级 signal(SIGPIPE, SIG_IGN) 才好。
   更讲究的写法如果不生效,就只是更讲究的 bug。

3. stop 事件不能去重。缓存决策只有在决策是事件的纯函数时才成立,
   而 stop 的结论取决于会变的上膛状态:同样内容的两条 stop,
   一条在用户说话前、一条在之后,结论截然相反。实测被这个坑到过。

4. {"done":1} 被判成做完了。JSONSerialization 把数字 1 桥接成 NSNumber,
   as? Bool 对它成立 —— 模型只要把 done 写成数字就能绕过"含糊按没做算"。
   改用 CFBooleanGetTypeID 严格判定。

端到端实测(真实 hubctl 二进制):未上膛不拦、上膛后拦下 13 条要点、
连着两次收工都放行、Hub 存活、自查回答(带散文和围栏)正确回收成
claimed 且 hasProof 仍为 false。

346 个测试。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 18:10:35 +08:00
wuguanganandClaude Opus 5 f0f6cd7454 feat: 验收守望 —— 独立于 Claude 自述的需求清单
Claude 会把用户需求翻译成自己的 TodoWrite,翻译那一步就已经丢东西了:
「修复 apple-header sticky」是施工步骤,不是原始需求里的功能要点。
再拿它自己的 todo 去验收,等于让它自己判自己的卷子。

清单挂在项目上跨会话累积,基线取自用户原话和用户批准过的计划。

几个刻意的设计:

- Evidence 在类型层面就分开 claimed / diff / ran。全塞进 [String] 等于在
  数据结构上承认「它说改了」和「它真改了」是一回事,而这正是要解决的问题。
  汇报条的「已证」只数 isProof 的,想把自述渲染成已验证得先改类型。

- 拦截的「上膛」只有 UserPromptSubmit 能做,Stop 拦过立即卸膛。Claude
  没有任何路径能给自己上膛,所以「拦一次 → 它回一轮 → 又拦」这个死循环
  是构造不出来的,不是被 if 挡住的。

- applyAudit 跳过用户终裁过的项。勾了「接受」一转身被机器打回 disputed,
  那个勾就白点了。

- extract 区分「失败」(nil) 和「没有新要点」([])。混在一起的话模型抽风
  一次就会清掉原话缓冲,而那是这个功能唯一的基线来源。

hook 从 5 类补到 9 类。PostToolUse 是最大的收获:Write/Edit 之后 Hub
第一手知道哪个文件被改了,不用回头问 Claude——问这个问题本身就给了
转述失真的机会。

实测确认(都不是推断):
- hubctl hook userprompt 的 stdout 是 0 字节。这条必须验,因为
  UserPromptSubmit 的 stdout 会被当成附加 context 注入进对话。
- 项目级和全局 hook:命令字符串相同时 Claude Code 自己去重,不同才叠加。
  第一次实验用了两条不同命令,据此普遍化成「一定会重复」是错的。
  HookDedup 保留为深度防御(路径不一致时才真用得上),不是必需品。
- 真实拆解链路 20 秒出结果,正确忽略了缓冲里的闲聊、正确去重了已有条目。

ensure-project-config.sh 接了三个调用点。其中 TerminalDispatch.open() 是
关键:app 从岛/主窗口启动项目完全不经过 project-menu.sh,漏了那里等于
最常用的入口一个都没检查。

326 个测试。上膛、自述不算证明、外部编辑不被覆盖三条做过变异验证。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 17:50:25 +08:00
wuguanganandClaude Opus 5 61aaea6062 fix: 关掉「跟随光标」后灵动岛仍然跟着光标跑
加这个设置时只改了三处调用 activeScreen() 的地方,漏了控制器里的
followCursorAcrossScreens() —— 它直接调 screenUnderCursor(),名字对不上
所以 grep 没找到。用户在界面上关掉跟随,岛照样跑到外接屏压住浏览器标签栏。

除了补 guard,把 NotchGeometry.screenUnderCursor() 收成 private:
绕过设置去取屏现在在编译期就不成立,而不是靠下一个人记得加判断。

新增的那条测试特意标注了它的局限——检出能力取决于跑测试时光标在哪块屏,
单屏机器上永远绿。真正的护栏是 private 那条,别把它当保证。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 17:49:57 +08:00
wuguangan 0b4cec785b fix: 灵动岛默认固定在刘海屏,跟随外接屏改成可选
岛以前写死跟着光标跑。动机没错(用户在外接屏工作时看不见岛),但代价没算清:
外接屏没有刘海,岛走「悬浮胶囊」形态,正好压在浏览器标签栏那一带 —— 命中测试
虽然是按形状精确做的,但那块形状底下本来就有东西,于是外接屏上点标签页点不动。

这不是 hit-test 的 bug,是岛出现在了一个它不该占位的地方。所以默认改回固定在
刘海屏,想跟随的人去设置里开,并把代价写在开关下面。

- 新增 IslandPlacementStore,落 island-placement.json,URL 可注入(测试不碰真实设置)
- NotchGeometry.activeScreen() 改成显式的 screen(followsCursor:),不再藏全局行为
- 设置面板加「灵动岛位置」卡片
- relocate() 不复用 realign():后者有 geo != geometry 的去重 guard,两块同型号
  外接屏时会让手动切设置看起来没生效

7 条新测试,其中默认值那条做过变异验证(改成 true 立刻红)。全量 264 绿。
2026-08-06 16:34:38 +08:00
078f6f5e46 feat: 岛上作答交互卡 + 项目置顶/排序 + 建仓与克隆 (#6)
## 岛上能直接作答 Claude 的交互请求

AskUserQuestion / ExitPlanMode 的 tool_input 里没有 command/path,
RiskClassifier 提不出 summary,风险判定为 normal 直接放行 ——
岛上从来不弹任何东西。这是这次要修的核心 bug。

现在按 toolName 在风险判定**之前**分流,岛上弹可作答的卡:

- 选择题:单题单选点选即答;多题表单逐题作答,多选勾完提交,
  最后合成一条回传。答案借 deny 的 reason 送回模型(PreToolUse
  没有「替用户作答」的通道,这是唯一的口子)。
- 计划审批:显式 allow 压不掉 plan 状态机自己的确认框(实测
  2.1.220),所以「批准」= 放行后替用户在终端框里按 1/2,带重试
  等对话框渲染出来;驳回走 hook deny 有效。
- 进入计划模式(EnterPlanMode)同样接管。
- 终端权限框("Do you want to...")走 Notification hook 弹卡,
  允许=注入 1、拒绝=Esc,复用 TerminalReply 的全套护栏。

超时方向和审批卡**相反**:审批超时必须 deny(唯一的安全刹车),
交互卡超时 allow(不输出决策 = 交还终端原生对话框,用户零损失)。
这条镜像关系有专门的测试钉住。

## 项目列表

- 置顶:岛和主窗口菜单里都能置顶,落盘 pinned.json。
- 排序统一成 置顶 > 正在开发 > 最近提交 > 名字。GitInfo 加
  lastCommitAt(38 个仓库合计约 300ms,后台队列)—— 三十多个项目
  按 yaml 顺序排,找一个要翻半屏。
- 岛上无会话的行点击弹启动菜单,不再直接起 claude(误触成本太高)。
  用原生 NSMenu:SwiftUI Menu 的两种整行方案都翻过车(label 被
  压扁 / 透明盖层收不到点击)。

## 建仓与克隆

- scripts/new-project.sh:目录 + git + GitHub 远程 + 防污染
  settings.json + 注册 projects.yaml,一条命令。app、/new-project
  skill、命令行三个入口共用同一份逻辑。
- scripts/clone-project.sh + 克隆表单:列出账号可见的全部仓库
  (含组织仓,之前 gh repo list 只列个人仓),按 owner 分组筛选,
  多账号可切换(gh 只用活跃账号的凭据,不切就拉不了另一个账号的
  私有仓)。列表按账号分开缓存 + 后台静默刷新。
- 防污染 deny 刻意**不禁** ~/.claude/plans/:plan mode 的计划文件
  写在那里,禁了所有 plan 会话都会报 Error writing file(真踩过)。

## 顺带修掉

add-project.sh 最后一行是 `[ ${#ALIASES[@]} -gt 0 ] && echo ...`,
没有别名时测试为假,整个脚本退出码变成 1 —— 明明写进去了,调用方
却报「注册 projects.yaml 失败」。改成显式 exit 0。

Co-authored-by: wuguangan <anjaihd0206@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 17:19:39 +08:00
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
b6d90cac2b feat: 重写为原生 Swift 灵动岛,修掉通知跳转与主窗口数据链路 (#4)
删掉 Tauri 版(app/),全部重写为 SwiftUI + SwiftPM(HubKit/,8.2k 行源码 +
2k 行测试,131 个测试)。核心动机是旧版"点通知跳不到对应终端"——
根因是身份链路全程用会漂移的字符串:项目名 → tmux 窗口名 → pane_title →
AppleScript 子串匹配。实测 9 个 tmux 窗口只有 2 个能和 projects.yaml 对上,
其中一个窗口名是 `2.1.173`(tmux automatic-rename 把它改成了 claude 的版本号)。

主要变化
- 会话状态直接读 ~/.claude/sessions/<PID>.json(Claude Code 自己维护的权威源),
  不再用 spinner 字符猜 busy;死进程的残留 json 用 kill(pid,0) 过滤
- 跳转改用 iTerm jobPid + 进程祖先链求交,全程只有整数 PID 和 UUID;
  bg 会话不在任何终端里,单独用稳定 job 名兜底。实测 9/9 绑定成功
- hook 从 bash 换成 Swift 的 hubctl,走 Unix socket;Hook 没运行时立即放行
- 高风险操作在岛上审批,六重防误点;拒绝是超时和异常的默认值
- 主窗口:会话 / 项目 / 用量 / 审批日志 / 设置

修掉的真实缺陷(详见 HubKit/NOTES.md)
- 超时阶梯是反的:ApprovalCoordinator 和 hubctl 读超时都是 60s,同时到期时
  hubctl 按 fail-open 放行了一条已被服务端判定拒绝的 git push --force origin main。
  改成 60/65/75/90 严格递增,并区分「连不上」(放行)与「连上但没应答」(拒绝)
- 点击全部被吞:app 是 .accessory + panel 是 .nonactivatingPanel,
  NSHostingView.acceptsFirstMouse 默认 false,每次点击都算 first mouse 被丢弃
- hitTest 假设 NSHostingView 恒为 flipped,而 isFlipped 是 final 的可读写属性
- 形态切换靠替换 rootView,导致动画不可能发生且 @State 每次归零
- 全局 leftMouseDown 监听会收到点在自己身上的点击,展开态一点就收起
- hook 进程死后审批卡不消失,且会挡住后面的请求(按 clientPid 扫描清理)
- app 读的 projects.yaml 不是脚本维护的那份,且那份被写坏成
  `projects: []` + 117 条内容,解析器静默返回 0 —— 三个页面因此全空
- 用量对 945MB/341 个 jsonl 整份读进内存,跑不完;改流式 + 增量缓存后
  7 天首扫 3.6s、命中缓存 0.22s、全量 4s
- 辅助二进制放 Contents/MacOS/ 且共用 bundle id 会被 exec 瞬间 SIGKILL
- 同一 bundle id 对应多个 .app 让存活判断和 TCC 授权互相打架

刻意的取舍
- 放弃 Liquid Glass 做岛的基底:它会跟着背景亮度双向补偿,而刘海恒为纯黑,
  两者物理上互斥。改用固定 86% 暗色基底 + 背景模糊 + 边缘高光
- 用 SwiftPM + 打包脚本而不是 .xcodeproj:手写 xcodeproj 的 XML 无法 review,
  且 SwiftPM 保住了 swift test

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