Session actions, permission prompts, and usage in the panel - #1
farfromrefug wants to merge 9 commits into
Conversation
There was no way to build, run or debug the app from Xcode — only `swift build`. `project.yml` describes the app and test targets, and `scripts/gen-xcode.sh` regenerates `ClaudePulse.xcodeproj` from it. The project file itself is generated rather than committed, so it can never drift from the package: edit `project.yml`, never the project. The module keeps the SwiftPM name `ccpulse` so `@testable import ccpulse` works from both toolchains. `Sources/Info.plist` now takes its bundle id and version from build settings so the two builds agree on them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every event used to arrive by a `curl` command hook, which spawned a process per event and could only ever be fire-and-forget. Claude Code supports `"type": "http"` hooks, so the app now registers its own URL and Claude Code posts to it directly. The reply matters as much as the request: Claude Code reads a hook's HTTP response as that hook's output, so the socket has to stay open until the app has decided what to say. `HookConnection` owns that socket, answers exactly once, and carries a hard timeout so an unanswered request can never leak a file descriptor. Everything is answered immediately for now. Requests are read honouring `Content-Length`, because a large `tool_input` does not arrive in a single segment. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The panel always set `.fullScreenAuxiliary`, so it drew over fullscreen video and any app used fullscreen — with no way to turn that off. Settings → Show Over Fullscreen now chooses. Without it the panel simply does not join fullscreen spaces, which is the behaviour most people expect; with it, the old behaviour comes back. Changing the setting re-applies the window behaviour immediately rather than at next launch. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude Code's `PermissionRequest` hook runs before it draws its own prompt, and its HTTP response can carry the decision. Pulse now holds that request open and shows the prompt in the panel: Allow, Allow all when Claude Code offered rules to remember, and Deny. The wording follows the tool rather than the mechanism. Approving a plan is Accept, and declining it is Revise — it sends Claude back to planning rather than blocking a call — and "Allow all" is hidden there because it means nothing. A question offers Ask me / Skip. Answering updates the row immediately instead of waiting for the next hook to arrive seconds later, because the outcome is already known. Safety rails, since a held hook blocks a session: - prompts expire after a configurable wait and hand control back to the terminal, and Terminal does the same on demand - every held hook is released on quit - hook timeouts registered with Claude Code follow the configured wait, so a wedged or absent Pulse costs three seconds, not the full window - a session waiting on a prompt is never treated as stale The panel pulls itself to the front while a prompt waits, over fullscreen apps even when Show Over Fullscreen is off — an unanswered prompt the user cannot see is a stuck session. Off by default: Settings → Answer Permissions in Pulse. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sessions were a read-only list. Clicking one now brings up the place it actually lives. The hook carries `$TERM_PROGRAM` and the terminal's own session id as request headers, which is enough to focus the exact tab in iTerm2, the exact pane in WezTerm and kitty, or at least the right application elsewhere. Settings → Reveal In pins a target; Auto tries the terminal first. Sessions hosted by Claude for Desktop have no terminal, so they are resolved through its records instead. The obvious link, `claude://resume?session=<cli-id>`, is an import: handed a session the app does not already hold under that exact name it copies the transcript into a new session, and the ids match for almost none of them — one in 320 on the machine this was written on. So Pulse reads the session records, finds the live desktop session for that CLI session, and navigates straight to it with `claude://claude.ai/epitaxy/<desktop-id>`. It never imports. Picking the live record takes the whole set: a file named for the CLI session is just as likely to be an old import holding a stale copy of the same transcript, so archived records lose and the most recently active one wins. Ids from hook payloads and from disk are both validated before they reach a path or a URL. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A session's project name was read once, from `SessionStart`, and never again. Any session that moved afterwards — `cd`, a worktree switch, `/add-dir` — kept its original name for the rest of its life, so the panel could point at a project the session had long left. Every hook payload carries the current directory, so it is now tracked on every event, and `CwdChanged` is subscribed to for the move itself. Rows also show the full path on hover, which is the only way to tell two identically named folders apart and makes a surprising directory obvious. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two readings that were only available by leaving the panel. Each session row carries a ring for how full its context window is, colouring from the accent through amber to red, with the exact numbers on hover. It is read from the session transcript: the newest assistant turn describes the context as it stands, since every request re-sends the conversation, which also means the ring falls back down after a compaction. Only the tail of the file is read — transcripts reach tens of megabytes. Account limits sit in the button row. Claude Code reports them only to its terminal status line, which never runs for sessions hosted by Claude for Desktop: they have no REPL to draw one. Claude for Desktop records the same limits itself every few minutes, so that file is the source — the same numbers the app shows, with no configuration, credentials or network involved. For terminal sessions, Settings → Account Usage points `statusLine` at a script that forwards the payload and prints the line Pulse renders back. That also gives an authoritative context window size, which a transcript can only imply. It is off by default and never asked for, because `statusLine` holds a single command: turning it on replaces any status line already there, and Pulse deliberately neither records nor runs what it replaced — chaining would mean executing another tool's binary and breaking whenever it moved. Readings are kept across launches so the panel is populated at startup, and dropped once too old to describe the current windows. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Hovering the capsule and then moving straight up to pick a session collapsed the panel instead of letting it open. Leaving the capsule while the open animation was still running was treated as proof the cursor could not have reached the body on purpose — but that is exactly the path from the capsule to the first row. Leaving the capsule now only cancels an expansion that has not happened yet; whether the cursor actually left is the panel's own business. And because the panel grows under the cursor as it opens, a moment of "not hovering" mid-animation is normal, so collapsing waits a grace period and is cancelled if the hover comes back. Expansion still needs deliberate intent: a short delay on the capsule, and the cursor must still be there when it fires, so a cursor passing through does not open the panel. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Code review
0. 變更摘要
.gitignore: 將ClaudePulse.xcodeproj/加入忽略清單README.md: 大幅擴充文件,新增使用說明、Xcode 開發指引、架構說明Sources/Info.plist: 重寫 plist,更新 bundle 資訊、新增NSAppleEventsUsageDescriptionSources/Managers/ClaudeDesktopSessions.swift: 新增 — 讀取 Claude for Desktop 的磁碟 session 記錄,解析 live twinSources/Managers/ContextUsageReader.swift: 新增 — 從 transcript 尾部讀取 context window 使用量Sources/Managers/PlanUsageReader.swift: 新增 — 讀取 Claude for Desktop 的plan-usage-history.jsonSources/Managers/SessionManager.swift: 大幅擴充 — 新增權限提示處理、帳戶用量追蹤、status line 處理、session origin 追蹤Sources/Managers/SessionOpener.swift: 新增 — 根據 terminal origin 將 session 所在視窗帶到最前Sources/Managers/StatusLineRenderer.swift: 新增 — 渲染 Claude Code status line 文字Sources/Models/HookEvent.swift: 擴充 — 新增TerminalOrigin、多個 hook payload 欄位Sources/Models/JSONValue.swift: 新增 — 泛用 Codable JSON tree,用於 round-trip permission suggestionsSources/Models/PanelSettings.swift: 擴充 — 新增RevealTargetenum 及多個設定項(全螢幕浮動、權限控制、timeout 等)Sources/Models/PermissionRequest.swift: 新增 —PermissionDecision、PermissionVocabulary、PendingPermission模型Sources/Models/Session.swift: 擴充 — 新增 origin、transcriptPath、contextWindow 及 context 刷新邏輯Sources/Models/StatusLinePayload.swift: 新增 — status line JSON payload 的 Decodable 模型Sources/Models/UsageSnapshot.swift: 新增 —ContextWindow、RateLimitWindow、UsageSnapshot模型及持久化Sources/Server/HookConnection.swift: 新增 — 管理單一 hook HTTP 連線的生命週期與回應Sources/Server/HookServer.swift: 大幅重寫 — HTTP hook 傳輸、status line 端點、完整 HTTP 請求解析Sources/Setup/HooksConfigurator.swift: 大幅重寫 — HTTP hook 安裝、同步、移除;支援 env var headersSources/Setup/StatusLineConfigurator.swift: 新增 — 管理 Claude Code statusLine 設定的安裝/移除Sources/Views/DynamicIslandPanel.swift: 擴充 — 新增isUrgent屬性與applyWindowBehavior()方法Sources/Views/ExpandedView.swift: 擴充 — 新增 session 開啟動作、context ring、tooltipSources/Views/PermissionView.swift: 新增 — 權限提示 UI,含 Allow/Deny/Allow all/Terminal 按鈕Sources/Views/SettingsView.swift: 擴充 — 新增 Account Usage、Show Over Fullscreen、Answer Permissions、Reveal In 設定項Sources/Views/UsageRing.swift: 新增 — context ring 與全域用量顯示元件Sources/ccaniApp.swift: 大幅擴充 — 新的 hover 狀態機、權限通知、panel behavior、hook syncTests/ClaudeDesktopSessionsTests.swift: 新增 — ClaudeDesktopSessions 單元測試Tests/HookServerTests.swift: 新增 — HookServer HTTP 傳輸測試Tests/PermissionTests.swift: 新增 — 權限決策、hook 定義、路由測試Tests/SessionTests.swift: 擴充 — 新增 session 位置追蹤測試Tests/StatusLineConfiguratorTests.swift: 新增 — status line 安裝/移除/渲染測試Tests/UsageTests.swift: 新增 — context 讀取、status line payload、rate limit、plan usage 測試project.yml: 新增 — XcodeGen 專案定義檔scripts/gen-xcode.sh: 新增 — Xcode 專案生成腳本
1. 整體評估
這是一個大型且精心設計的 PR,新增了六個主要功能與兩個修正。程式碼品質很高:測試覆蓋完整(97 個測試全數通過)、每個 commit 可獨立編譯、安全防護到位(session id 驗證、路徑注入防護、socket 洩漏防護)。架構決策經過深思熟慮(HTTP hooks 取代 curl、transcript tail-read 優化、desktop session 解析邏輯)。以下僅有少數幾個值得關注的問題。
2. 正面評價
- 安全防護徹底:
ClaudeDesktopSessions.isSafeSessionId()使用 UUID 驗證防止路徑注入,isSafeRouteComponent()進一步限制 desktop id 字元集;readRequest()有 8MB 上限防止記憶體耗盡 - 測試覆蓋扎實:97 個測試涵蓋了 permission routing、hook 傳輸、context 讀取、status line 安裝/移除、plan usage 解析、desktop session 解析等關鍵路徑,且測試設計能驗證邊界條件(如 stale import vs live session、archived record 永不勝出)
- 資源管理嚴謹:
HookConnection有 900 秒 hard timeout 防止 fd 洩漏,releaseAllPermissions()在 app 終止時釋放所有 held hooks,respond()有 idempotent 防護 - 向後相容:
isPulseHook()同時識別新的 HTTP hooks 與舊的 curl command hooks,syncIfNeeded()能將舊格式遷移到新格式
3. 問題
潛在問題
1. liveliness 比較邏輯反轉(重要)
Sources/Managers/ClaudeDesktopSessions.swift L41-43
private static func liveliness(_ a: Record, _ b: Record) -> Bool {
if a.isArchived != b.isArchived { return a.isArchived }
return a.lastActivity < b.lastActivity
}當兩個 record 的 isArchived 不同時,return a.isArchived 會讓 archived 的 record 排在前面(因為 true > false),但註解說「An archived session is never the live one」。這導致 records.max(by: liveliness) 會選出 archived record 而非 live one。
當兩個 record 的 isArchived 相同時,return a.lastActivity < b.lastActivity 會讓較舊的 record 勝出(< 表示 a 小於 b 時 a 排前面),但註解說「the most recently active record wins」。這導致最不活躍的 record 被選中。
測試 testArchivedRecordNeverWins 之所以通過,是因為 archived record 的 lastActivityAt 較大(9000 vs 1000),而 liveliness 反轉的 < 比較恰好讓較小的 1000 勝出——但如果 archived record 的 lastActivityAt 較小,archived 反而會勝出。
建議修正:
private static func liveliness(_ a: Record, _ b: Record) -> Bool {
if a.isArchived != b.isArchived { return !a.isArchived }
return a.lastActivity < b.lastActivity
}這樣當 archived 不同時,非 archived 的排在前面;當 archived 相同時,lastActivity 較大的排在前面(max(by:) 使用 < 表示升序,所以 a.lastActivity < b.lastActivity 表示 a 的 activity 較舊時 a 排前面,即較新的排後面——等等,這裡需要再確認)。
實際上 max(by:) 的行為是:當 areInIncreasingOrder(a, b) 回傳 true 時,a 排在 b 前面,所以最大值是最後一個元素。因此 a.lastActivity < b.lastActivity 表示較小的 lastActivity 排在前面,最大值(最後一個)是 lastActivity 最大的——這其實是正確的。但 isArchived 的邏輯確實反了。
2. applyDecisionOptimistically 中 deferToTerminal 後仍排程 context refresh(次要)
Sources/Managers/SessionManager.swift L186-188
DispatchQueue.main.asyncAfter(deadline: .now() + Self.postDecisionRefreshDelay) { [weak session] in
session?.refreshContextIfStale(minimumInterval: 0)
}這段程式碼在 applyDecisionOptimistically 的所有分支都會執行,包括 .deferToTerminal。但 defer 時 Claude Code 尚未對 prompt 做出任何動作(使用者還未在終端機中回答),此時刷新 context 沒有意義——transcript 中還沒有新的 assistant turn。雖然這不會造成錯誤(refreshContextIfStale 只是多讀一次檔案),但這是一個不必要的 I/O。
建議:將 refresh 排程移到 .allow/.allowAlways/.deny 分支內,或加入條件判斷。
3. PlanUsageReader.read() 中 max(by:) 依賴 timestamp() 比較(次要)
Sources/Managers/PlanUsageReader.swift L30
guard let latest = samples.max(by: { timestamp($0) < timestamp($1) }),timestamp() 在 sample 缺少 "t" 鍵時回傳 0。如果檔案中有多個缺少 "t" 的 sample,它們的 timestamp 都是 0,max(by:) 的行為取決於集合的迭代順序(不確定性)。雖然實際上 desktop app 總是寫入 "t",但這是一個脆弱的假設。
建議:過濾掉缺少 "t" 的 sample,或在 timestamp() 回傳 0 時排除該 sample。
程式碼品質
4. DynamicIslandContent 中 hoverIntent 與 collapseIntent 的 retain cycle 風險(次要)
Sources/ccaniApp.swift L400-409, L424-427
let work = DispatchWorkItem {
guard capsuleHovered else { return }
withAnimation(.spring(response: expandAnimationDuration, dampingFraction: 0.8)) {
isExpanded = true
}
}
hoverIntent = workDispatchWorkItem 的 closure 捕獲了 self(透過 capsuleHovered、isExpanded 等屬性),而 self 持有 hoverIntent。雖然這在實務上不會造成真正的 retain cycle(因為 work item 執行完後就會被釋放),但如果 work item 被取消且從未被執行,closure 仍會持有 self 直到 hoverIntent 被覆蓋或設為 nil。目前 collapse() 和 scheduleExpand() 中有適當的清理,但這是一個容易在未來重構時出錯的模式。
建議:在 closure 中使用 [weak self]。
4. 總結
這個 PR 的品質很高,主要問題只有 liveliness 比較邏輯反轉這一個需要優先修正的 bug——它會導致選錯 live desktop session,進而讓「Click to Jump Back」功能在特定場景下失效或導向錯誤的 session。其他兩個問題影響較小,可以在後續迭代中處理。整體而言,這是一個設計良好、測試完整、值得合併的 PR。
🤖 Generated with Cerberus using deepseek/deepseek-v4-pro
If this review was useful, react with 👍. Otherwise, react with 👎.
| /// An archived session is never the live one; otherwise the most recently | ||
| /// active record wins. | ||
| private static func liveliness(_ a: Record, _ b: Record) -> Bool { | ||
| if a.isArchived != b.isArchived { return a.isArchived } |
There was a problem hiding this comment.
liveliness 比較邏輯反轉:當 isArchived 不同時 return a.isArchived 會讓 archived record 勝出(應為 !a.isArchived),與註解「archived session is never the live one」矛盾。這會導致 liveTwin(of:) 選錯 desktop session。
| session.state = .waitingForUser | ||
| } | ||
|
|
||
| DispatchQueue.main.asyncAfter(deadline: .now() + Self.postDecisionRefreshDelay) { [weak session] in |
There was a problem hiding this comment.
applyDecisionOptimistically 在所有分支(含 .deferToTerminal)都排程 context refresh,但 defer 時 Claude Code 尚未回應,transcript 中沒有新內容可讀,這是多餘的 I/O。
| guard let data = try? Data(contentsOf: path, options: .mappedIfSafe), | ||
| let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], | ||
| let samples = json["samples"] as? [[String: Any]] else { return nil } | ||
|
|
There was a problem hiding this comment.
timestamp() 在 sample 缺少 "t" 鍵時回傳 0,多個缺少 "t" 的 sample 會導致 max(by:) 結果取決於迭代順序(不確定性)。建議過濾掉這類 sample。
Six features and two fixes, one commit each.
What's here
Run and debug from Xcode.
project.yml+scripts/gen-xcode.shgenerate the project with XcodeGen; the project file stays uncommitted so it can't drift from the package.Hooks over HTTP. Events arrived via a
curlcommand hook — a process per event, fire-and-forget. Claude Code supports"type": "http", so it now posts directly. The reply matters too: Claude Code reads a hook's HTTP response as that hook's output, soHookConnectionholds the socket open, answers exactly once, and can never leak a descriptor.Answer permissions in the panel.
PermissionRequestruns before Claude Code draws its own prompt and its response can carry the decision, so the prompt appears in the panel instead.The wording follows the tool, not the mechanism: approving a plan is Accept, declining is Revise (it sends Claude back to planning rather than blocking anything), and "Allow all" is hidden there because it means nothing. Since a held hook blocks a session, prompts expire and hand back to the terminal, every held hook is released on quit, registered hook timeouts follow the configured wait, and a waiting session is never counted as stale. Off by default.
Click a session to reveal it.
$TERM_PROGRAMand the terminal's session id ride along as request headers, which is enough to focus the exact tab in iTerm2 or pane in WezTerm/kitty.Desktop-hosted sessions have no terminal, so they're resolved through Claude for Desktop's records. The obvious link,
claude://resume?session=<cli-id>, is an import: handed a session the app doesn't already hold under that exact name it copies the transcript into a new one — and the ids match for almost none of them (1 of 320 on the machine this was written on). Pulse instead finds the live desktop session and navigates to it withclaude://claude.ai/epitaxy/<desktop-id>. It never imports.Context and account usage. Each row gets a ring for how full its context window is, read from the tail of the transcript — the newest assistant turn describes the context as it stands, so the ring also falls back down after a compaction.
Account limits come from Claude for Desktop's own records, which is the only source that works for desktop-hosted sessions: Claude Code reports limits solely to its terminal status line, and those sessions have no REPL to draw one. Same numbers the app shows, no configuration or credentials. For terminal sessions, Settings → Account Usage opts into the status line — off by default and never prompted for, since
statusLineholds a single command and taking it would replace whatever is already there.Optional fullscreen floating. The panel always drew over fullscreen apps with no way to opt out. Now a setting — though a waiting permission prompt still forces itself forward, because a prompt you can't see is a stuck session.
Fixes
Session names went stale. The project name was read once at
SessionStartand never again, so any session that moved —cd, worktree switch,/add-dir— kept pointing at a project it had left.Hover collapsed the panel. Hovering the capsule then moving up to pick a session collapsed it: leaving the capsule mid-animation was treated as proof the cursor couldn't have reached the body on purpose, which is exactly the path to the first row.
Verified
97 tests, green under both
swift testandxcodebuild test. Every commit builds on its own. The deep links, the status-line payload shape and the desktop records were all confirmed against a running Claude Code and Claude for Desktop rather than assumed.🤖 Generated with Claude Code