Skip to content

THI-448: show memory footprint and CPU for periscope, its webview and each pane - #53

Merged
tdody merged 52 commits into
mainfrom
thibaultdody/thi-448-show-memory-footprint-and-cpu-for-periscope-its-webview-and
Nov 1, 2026
Merged

tdody merged 52 commits into
mainfrom
thibaultdody/thi-448-show-memory-footprint-and-cpu-for-periscope-its-webview-and

Conversation

@tdody

@tdody tdody commented Oct 31, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

With ~10 agents running, periscope gets laggy. This adds the measurements needed to see where memory and CPU go before optimizing anything (THI-448).

  • New periscope/resources.py, stdlib-only (ctypes on libproc/libSystem). Once per /api/state tick it reads every process's phys_footprint and CPU time and puts each process in exactly one group: each pane's process tree, the periscope server, the webview, and elsewhere.
    • Memory is footprint, not RSS. Under swap, RSS undercounted the webview at 276 MB against 2.8 GB of real footprint.
    • The webview is periscope-app plus every process macOS holds it responsible for: the live WebContent, any WebContent the recycle left parked, GPU and Networking. The app is matched by the kernel's process name, because ps cuts comm to 16 characters.
    • CPU is the change in each process's CPU time since the previous sample, summed per group. Reaped children count too, so short git/gh/tsc runs and Bash-tool commands show up. A process that ended has its last total charged back to its nearest surviving ancestor (the one whose children time just grew), so nothing is counted twice, including a zsh -c wrapper and its command ending together. It's shown as a share of the whole machine (100% = every core busy), not per core like top, so groups listed side by side read as parts of one total. Samples are keyed by (pid, start time), taken under a lock, at most every 0.5 s. CPU is null until a previous sample exists, and when a reading exceeds every core for the interval.
    • Cost: no extra subprocess. build_state takes the tick's process snapshot once and passes it in. About 4–6 ms per pass on ~750–790 processes.
  • /api/state: each row gets res: {mem_mb, cpu_pct} and the blob gets resources: {server, webview, panes, elsewhere, machine}. Both are null when the tick's ps snapshot fails.
  • UI:
    • A ▣ 12.6G header pill shows what periscope and its panes hold.
    • Its popover lists the server, the webview, all panes together, each pane heaviest first (a line selects its pane; the list scrolls), elsewhere, and a machine line: 16.0G RAM · swap 8.6G/10.0G · 39% free · load 9.7. RAM leads because footprints include compressed and swapped pages, so the owners can add up past physical RAM, and that overcommitment is what the line shows.
    • The detail header shows · 630M mem · 0.4% cpu, in a fixed-width box so the header can't re-wrap between ticks.
  • ↻ cycle hint: it reads claude's footprint instead of RSS (same 2 / 4 GB thresholds) and says which limit tripped.

Design: docs/superpowers/specs/2026-10-30-resource-monitor-design.md. Plan: docs/superpowers/plans/2026-10-30-resource-monitor.md. Related tickets: THI-449 (the webview recycle never fires while you're active) and THI-450 (subprocesses per tick).

Test plan

  • uv run pytest -q: 2080 passed. tests/test_resources.py covers:

    • group assignment, including overlapping subtrees;
    • CPU accounting: reaped children, a Bash-tool zsh -c chain ending, a closed pane's shell, orphans launchd reaped, pid reuse, slow and stale ticks, an impossible delta;
    • the throttle, two callers forced to overlap, and a failed pass.

    Each new test was checked to fail when its fix is undone. An autouse conftest stub keeps every other test off the real machine.

  • npm test: 1313 passed. Covers formatting, the pill and popover render, the detail-header part, and memHint.

  • bin/check is clean, and static/dist is rebuilt.

  • On the dev server (:17375) with live panes:

    • The webview showed at 4.7 GB, and every row carried res.
    • One pane's 794 MB covered its claude's 567 MB in top.
    • In the browser, the pill sits next to the usage meters, the popover lists every group, clicking a line selects the pane, and Escape closes it and returns focus to the pill.
    • A 5 s one-core burn run through a zsh -c wrapper reads ~10% while it runs, with no spike on the tick it ends.
  • Thorough multi-agent review: 17 findings, none blocking merge, all fixed and re-verified.

Assumptions to challenge

  • macOS only. resources.py loads libproc/libSystem at import, and session_status now imports it, so the planned Linux port needs a seam here.
  • Private API. responsibility_get_pid_responsible_for_pid is private libSystem API, verified on macOS 27.
  • Possible double count. Summing phys_footprint across the WebKit helpers is assumed not to double count. That's unproven for the GPU process.
  • Measurement adds no ps. It uses the snapshot build_state takes before the fan-out. On a fan-out slower than the snapshot's 1 s cache, Codex detection still re-forks ps. That predates this PR and belongs to THI-450.
  • Rare one-tick under-counts. A process reparented to launchd that dies in the same interval as its parent loses its last stretch of CPU. A child reaped in the few milliseconds between its parent's reading and its own, within one pass, is under-counted for that tick. That race predates this PR.
  • Panes are active panes only. Non-active panes of split windows, the tmux server and the periscope-input pane count as elsewhere.
  • The ↻ label during a deploy. A webview running JS older than the server shows a malformed ↻ label until it reloads.
  • Claude's age. It still comes from ps etime. Moving liveness and age off ps is THI-450.

🤖 Generated with Claude Code

tdody and others added 30 commits October 30, 2026 19:55
… server and webview (THI-448)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ere group, child CPU, sampling lock; no healthz or shell change (THI-448)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…sctl, no subprocess (THI-448)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…onsible pid, each pane's subtree, elsewhere (THI-448)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…iffs a reused pid (THI-448)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…at most every 0.5 s (THI-448)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ates comm to 16 characters (THI-448)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…her, so a failed pass leaves no half-built state (THI-448)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…t; _name's docstring states proc_name's 32-character cap (THI-448)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…als (THI-448)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ich limit tripped (THI-448)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…r (THI-448)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…rst (THI-448)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…here measurement lives (THI-448)

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…nes, and local open state (THI-448)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…uilt (THI-448)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…le, and the no-app test stubs _name (THI-448)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ng forks ps and what a 0% can mean (THI-448)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… listed side by side no longer read past 100% (THI-448)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… up past it read as overcommitment (THI-448)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…-out, and measure stamps its sample when it reads (THI-448)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
tdody and others added 22 commits October 31, 2026 09:04
…cess charged to its parent's group, and a delta past every core is null (THI-448)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…r the same panes (THI-448)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…icks on a loaded machine still read CPU (THI-448)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…mem_signal's gb is always a number (THI-448)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…without the lock (THI-448)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…hange to it fails (THI-448)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…HI-448)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…d cost, the all-panes line and measure(snapshot, panes) (THI-448)

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

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…popovers (THI-448)

ResourcePill, Header's Dropdown, FilterBar and UpdatePill each repeated
open/Escape/outside-click wiring; ResourcePill also closed itself when the
pill vanished instead of unmounting. Split ResourcePopover out so its open
state unmounts with the pill, and replace .update-dd/.res-dd with one
neutral .pill-dd class.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…THI-448)

The server sends resources.panes every tick but the popover never showed
it, so comparing agents against the webview meant adding a dozen per-pane
lines by hand.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
.res-menu had no max-width, so the label ellipsis never took effect, and
no max-height, so a long pane list ran off the screen with no way to
scroll to elsewhere/machine below it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…448)

Picking a pane line or pressing Escape hid the menu out from under the
focused element, dropping focus to <body>; the dialog also had no
accessible name for screen readers.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…e (THI-448)

--fg-3 on the menu's --bg-1 at 11px was about 2.6:1, below WCAG AA's 4.5:1,
for text that is data, not decoration. --fg-2 gives about 4.7:1.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
.header-res had no CSS rule, so crossing 10% CPU or 1 GB (where fmtCpu/
fmtMem's format breaks) could wrap the header and resize the terminal by
a row on that tick. Also moved it after ↻ and the API-error chip so its
own width can't shift either of them.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…-448)

claude_proc_for's contract already returns None for a gone process, so the
nullable gb path was bloat for a sub-second exit race, never a real state
the frontend needed to word around.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
It blamed a missing res on the server's snapshot failing, but resources
is null in that case and ResourcePill returns before paneLines runs; the
real causes are no pane_pid or the 0.5s throttle, which the backend now
keys by pane set.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
… 100% cpu (THI-448)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…rged to its nearest surviving ancestor, floored per ancestor, so a Bash tool command's zsh -c chain isn't counted twice (THI-448)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…y by pid alone fails it; the spec states what the floor protects (THI-448)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@tdody
tdody merged commit 732a1c8 into main Nov 1, 2026
2 checks passed
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