Skip to content

Session actions, permission prompts, and usage in the panel - #1

Open
farfromrefug wants to merge 9 commits into
tzangms:mainfrom
farfromrefug:feat/session-actions-and-usage
Open

farfromrefug wants to merge 9 commits into
tzangms:mainfrom
farfromrefug:feat/session-actions-and-usage

Conversation

@farfromrefug

Copy link
Copy Markdown

Six features and two fixes, one commit each.

Panel

What's here

Run and debug from Xcode. project.yml + scripts/gen-xcode.sh generate the project with XcodeGen; the project file stays uncommitted so it can't drift from the package.

Hooks over HTTP. Events arrived via a curl command 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, so HookConnection holds the socket open, answers exactly once, and can never leak a descriptor.

Answer permissions in the panel. PermissionRequest runs before Claude Code draws its own prompt and its response can carry the decision, so the prompt appears in the panel instead.

Prompt wording

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_PROGRAM and 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 with claude://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 statusLine holds 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 SessionStart and 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 test and xcodebuild 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

farfromrefug and others added 9 commits August 25, 2026 17:08
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>

@cerberus-development-suite cerberus-development-suite Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code review

0. 變更摘要

  • .gitignore: 將 ClaudePulse.xcodeproj/ 加入忽略清單
  • README.md: 大幅擴充文件,新增使用說明、Xcode 開發指引、架構說明
  • Sources/Info.plist: 重寫 plist,更新 bundle 資訊、新增 NSAppleEventsUsageDescription
  • Sources/Managers/ClaudeDesktopSessions.swift: 新增 — 讀取 Claude for Desktop 的磁碟 session 記錄,解析 live twin
  • Sources/Managers/ContextUsageReader.swift: 新增 — 從 transcript 尾部讀取 context window 使用量
  • Sources/Managers/PlanUsageReader.swift: 新增 — 讀取 Claude for Desktop 的 plan-usage-history.json
  • Sources/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 suggestions
  • Sources/Models/PanelSettings.swift: 擴充 — 新增 RevealTarget enum 及多個設定項(全螢幕浮動、權限控制、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 headers
  • Sources/Setup/StatusLineConfigurator.swift: 新增 — 管理 Claude Code statusLine 設定的安裝/移除
  • Sources/Views/DynamicIslandPanel.swift: 擴充 — 新增 isUrgent 屬性與 applyWindowBehavior() 方法
  • Sources/Views/ExpandedView.swift: 擴充 — 新增 session 開啟動作、context ring、tooltip
  • Sources/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 sync
  • Tests/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 = work

DispatchWorkItem 的 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 }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

timestamp() 在 sample 缺少 "t" 鍵時回傳 0,多個缺少 "t" 的 sample 會導致 max(by:) 結果取決於迭代順序(不確定性)。建議過濾掉這類 sample。

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant