diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8f0e91e..0cfa5dc 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -11,9 +11,46 @@ permissions: jobs: release-please: runs-on: ubuntu-latest + outputs: + release_created: ${{ steps.release.outputs.release_created }} + tag_name: ${{ steps.release.outputs.tag_name }} steps: - uses: googleapis/release-please-action@45996ed1f6d02564a971a2fa1b5860e934307cf7 # v5.0.0 + id: release with: token: ${{ secrets.GITHUB_TOKEN }} config-file: release-please-config.json manifest-file: .release-please-manifest.json + + # Attach static builder-tui binaries (Linux, macOS, Windows) to the release release-please just cut; + # skills/tui/setup.sh downloads them and checks builder-tui-checksums.txt. + tui-binaries: + needs: release-please + if: ${{ needs.release-please.outputs.release_created == 'true' }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version-file: skills/tui/go.mod + cache-dependency-path: skills/tui/go.sum + - name: Build + working-directory: skills/tui + env: + TAG: ${{ needs.release-please.outputs.tag_name }} + run: | + mkdir -p dist + for os in linux darwin windows; do + for arch in amd64 arm64; do + ext=""; [ "$os" = windows ] && ext=.exe + CGO_ENABLED=0 GOOS=$os GOARCH=$arch go build -trimpath \ + -ldflags="-s -w -X main.version=${TAG}" -o "dist/builder-tui-$os-$arch$ext" . + done + done + (cd dist && sha256sum builder-tui-*-* > builder-tui-checksums.txt) + - name: Upload to the release + working-directory: skills/tui + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG: ${{ needs.release-please.outputs.tag_name }} + run: gh release upload "$TAG" dist/* --clobber diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index e7d5aaf..bee382b 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -13,5 +13,9 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - - name: Validate plugin manifests, skills, and scripts + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version-file: skills/tui/go.mod + cache-dependency-path: skills/tui/go.sum + - name: Validate plugin manifests, skills, scripts and Go tools run: bash scripts/validate.sh diff --git a/.gitignore b/.gitignore index 850f2d7..f4dc0fa 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,7 @@ __pycache__/ *.pyc usage.json ctxwire-last.json +skills/tui/builder-tui +skills/tui/dist/ +*.test +*.out diff --git a/AGENTS.md b/AGENTS.md index 93cff62..bb03e98 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,6 +16,7 @@ Pick the skill whose lever matches the gap you observe: | Adopt that graph-first workflow in a new repo end to end | `harness` | Both, as a loop | | Know whether you'll blow your weekly usage limit, and which idle session to compact | `watch-limits` | Effectiveness / economics | | Reconcile a task tracker against work already in flight and groom the rest | `intake` | Feedback into infrastructure | +| See every agent session, machine load and git cost in one place, or read it as JSON (`builder-tui status`) | `tui` | Environment | ## Working on builder @@ -23,7 +24,12 @@ Pick the skill whose lever matches the gap you observe: to edit. Each must open with a `---` frontmatter block carrying `name:` and `description:` (see any existing skill). - Run `bash scripts/validate.sh` before opening a PR. It checks manifest JSON, - skill frontmatter, and Python compilation. CI runs it too. + skill frontmatter, Python compilation, and gofmt/vet/tests plus a fixture + smoke run for Go tools (`skills/tui`). CI runs it too. +- Go tools are single static binaries: no cgo. Linux readers are rooted at a + directory so tests run against `testdata/` fixtures, never the live machine; + macOS and Windows read through gopsutil (`internal/host`) behind the same + `procfs.Host` interface, and `validate.sh` cross-builds both. - Keep skills self-contained and composable: a new application skill should call an existing capability skill, not re-implement it. - Conventional Commits; release is automated via release-please. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0ba9f83..b40bea5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -24,7 +24,10 @@ bash scripts/validate.sh ``` It checks: manifests parse, skills have valid frontmatter, Python tools compile, -and `burn-proj.py`'s output shape is stable. +`burn-proj.py`'s output shape is stable, and Go tools (`skills/tui`) are +gofmt-clean, vetted, tested, and pass a smoke run against the fixture machine. +It needs Go for that last part (the version in `skills/tui/go.mod`; with +`GOTOOLCHAIN=auto` an older Go fetches it). ## Commit convention @@ -63,5 +66,8 @@ Fully automated: 2. release-please opens/updates a **release PR** with the next version + changelog. 3. Merging the release PR tags the version, cuts a GitHub release, and bumps `.claude-plugin/plugin.json`. +4. The same workflow then builds static `builder-tui` binaries (Linux, + macOS and Windows, amd64/arm64) and attaches them with a checksum file, which + `skills/tui/setup.sh` downloads and verifies. Maintainers: do not bump versions manually. diff --git a/README.md b/README.md index 82c4e74..a065b4a 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,14 @@ Built for [Claude Code](https://claude.com/claude-code) + PR, existing issue), and groom the raw leftovers into issues. Annotates by default; creates issues only when you mark a task for it. Pluggable source adapter — bring your own MCP for other trackers. +- `/builder:tui` — Install `builder-tui`, a terminal cockpit you leave open in + a tmux window: every agent session by pane with its context use, machine load + and CPU power-limit drift, detached busy loops with the pane that leaked them, + git dirt and removable worktrees, and a ranked list of what to do with the + exact command for each. Read-only by default; destructive actions need a typed + `y`. Agents read the same state with `builder-tui status`. One static binary for + Linux, macOS and Windows; on Linux it stays under 1% of one core at idle, + including the commands it runs. - `/builder:code-graph` — Wire a local [codebase-memory-mcp](https://github.com/DeusData/codebase-memory-mcp) graph into a repo so agents answer structural questions (where is X, what calls Y, @@ -62,6 +70,7 @@ Each skill is one intervention on those levers, not a standalone gadget: | `harness` | Applies that loop to a repo end to end: baseline → provision → route → verify. | | `watch-limits` | Optimizes for measured effectiveness — tokens and attention against your real usage limits. | | `intake` | Turns feedback into infrastructure — captured work reconciled into durable issues. | +| `tui` | Curates the environment the agents share — one view of sessions, load and git, so the costs agents impose on each other (a dirty checkout, a leaked loop) surface with their fix. | The framing derives from Ryan Lopopolo's [Harness Engineering](https://github.com/lopopolo/harness-engineering) corpus diff --git a/scripts/validate.sh b/scripts/validate.sh index dfa1d78..6ebdc3d 100755 --- a/scripts/validate.sh +++ b/scripts/validate.sh @@ -1,6 +1,7 @@ #!/usr/bin/env bash # Validate the builder plugin: manifests parse, skills have frontmatter, -# Python tools compile. Run locally before opening a PR; CI runs it too. +# Python tools compile, Go tools are formatted, vetted and tested. Run locally +# before opening a PR; CI runs it too. set -euo pipefail cd "$(dirname "$0")/.." @@ -45,5 +46,27 @@ grep -qE 'elapsed=.* naive=.* profile_aware=.* reset_in=' <<<"$out" \ || err "burn-proj.py output shape changed: $out" note "✓ burn-proj smoke: $out" +echo "→ go tools" +for mod in $(find skills -name go.mod -not -path '*/testdata/*'); do + dir="$(dirname "$mod")" + if ! command -v go >/dev/null 2>&1; then err "$dir needs Go to validate (https://go.dev/dl)"; continue; fi + unformatted="$(cd "$dir" && gofmt -l .)" + [ -z "$unformatted" ] && note "✓ gofmt: $dir" || err "$dir: gofmt needed: $unformatted" + (cd "$dir" && go vet ./...) && note "✓ vet: $dir" || err "$dir: go vet failed" + (cd "$dir" && go test ./...) >/dev/null && note "✓ tests: $dir" || err "$dir: go test failed (run: cd $dir && go test ./...)" +done +# builder-tui smoke: the binary reads the fixture machine and the example config +if [ -f skills/tui/go.mod ] && command -v go >/dev/null 2>&1; then + for target in darwin/arm64 darwin/amd64 windows/amd64; do + (cd skills/tui && CGO_ENABLED=0 GOOS="${target%/*}" GOARCH="${target#*/}" go build -o /dev/null .) \ + && note "✓ builder-tui builds for $target" || err "builder-tui does not build for $target" + done + bin="$(mktemp)" + (cd skills/tui && CGO_ENABLED=0 go build -o "$bin" .) + out="$("$bin" status --text --fixtures skills/tui/testdata/machine --config skills/tui/config.example.toml)" + grep -q 'sessions: 3 (busy 1, idle 2)' <<<"$out" && note "✓ builder-tui smoke" || err "builder-tui smoke output changed: $out" + rm -f "$bin" +fi + echo if [ "$fail" -eq 0 ]; then echo "✓ all checks passed"; else echo "✗ validation failed"; exit 1; fi diff --git a/skills/tui/SKILL.md b/skills/tui/SKILL.md new file mode 100644 index 0000000..c459344 --- /dev/null +++ b/skills/tui/SKILL.md @@ -0,0 +1,97 @@ +--- +name: tui +description: Use when you want one place to watch a machine that runs many AI coding agents — every agent session by tmux pane with its context use, machine load, detached busy loops, git worktrees, and a ranked list of what to do — or when an agent needs to read that state. Triggers: "open the cockpit", "what is loading the machine", "which session should I compact or clear", "find leaked processes", "which worktrees can I remove", "builder tui". +--- + +# builder tui — a cockpit for agent sessions, tmux, git and machine load + +`builder-tui` is a single static Go binary (Linux, macOS, Windows) that a builder leaves open in +a tmux window. It answers, in one screen: + +- **Sessions** — every agent process mapped pane → process → worktree (from + the pane the agent records in its session file, else `TMUX_PANE` in its + environment, so a re-parented process still finds its pane), busy/idle, context use and 30-minute burn, CPU of its whole process + tree, and a suggestion: `compact`, `clear`, or nothing. +- **Machine** — CPU per core, load, PSI, memory and swap, package temperature, + CPU power limits (alerting on drift), GPUs, containers and the CPU share of + container groups you name (CI runners, say). +- **Procs** — the top processes by CPU attributed to their owner (pane, + container or system), and **detached busy loops**: shells re-parented to init + or a user service manager that keep burning CPU, with the pane that started + them. +- **Git** — per checkout an agent uses: dirty paths, the default branch behind + its remote, worktrees, which ones are merged, clean and unused, and how many + cores the agents spend running git there. +- **Recs** — a ranked list, each with the evidence behind it and the exact + command that resolves it. +- **History** — 24 hours of CPU, load, pressure, temperature, memory and swap + from its own log (plus any CSV you point it at). + +It is read-only by default. Every action prints its exact command first; `Enter` +runs a non-destructive one, and anything destructive (kill, remove a worktree, +type into a session) needs `x` and then a typed `y`. + +## Install + +```bash +bash "${CLAUDE_PLUGIN_ROOT}/skills/tui/setup.sh" +``` + +`setup.sh` downloads the release binary for this machine and checks its +SHA-256, or builds from the plugin checkout when Go is installed. It installs to +`~/.local/bin/builder-tui` (override with `BUILDER_BIN_DIR`). It runs on Linux +and macOS; on Windows, run it inside WSL, or download +`builder-tui-windows-amd64.exe` (or `-arm64.exe`) from the release page. + +## Run + +```bash +tmux new-window -d -n cockpit builder-tui +``` + +Keys: `1`–`6` or `tab` switch panels, `↑↓` select, `Enter` the panel's safe +action (jump to a pane, open a shell in a worktree), `x` the destructive one, +`r` rescans git, `q` quits. + +It runs at `nice 19`. It reads cheap counters every 2 s, the process table every +10 s, `docker` and `nvidia-smi` every minute, and git every 5 minutes. It +re-checks merged worktrees at most every 2 h, because git refuses a dirty removal +anyway. The renderer is capped at 15 fps. On a workstation with ~1,100 processes, +25 agent sessions and ~190 worktrees it used 0.84% of one core over 10 minutes, +child commands included. + +## For agents + +```bash +builder-tui status # JSON: sessions, machine, alerts, git, recommendations +builder-tui status --text # the same as a short summary with exact commands +``` + +`status` reads the snapshot a running cockpit writes to +`~/.local/state/builder/tui.json` each sample; with no cockpit running it samples +on its own. Recommendations carry `primary` (safe) and `secondary` (destructive) +actions as argv steps. Ask the user before running a destructive step, and +check its `guards` right before each step: the same pid with the same +`start_ticks`, a worktree still clean and unused, a session still idle. The +cockpit does this itself and skips a step whose guard no longer holds. + +## Configure + +Machine goals live in a local file, never in a repository: +`~/.config/builder/tui.toml`. Every key is optional; see +[`config.example.toml`](config.example.toml) for all of them. + +## Requirements + +tmux for the Sessions panel. Optional: `docker` (containers), `nvidia-smi` +(GPUs), `gh` (merged pull requests, so squash merges count as merged). + +| | Linux | macOS | Windows | +|---|---|---|---| +| Sessions, Git, Recs, History | yes | yes | in WSL (no native tmux) | +| CPU, memory, swap, network, disk, load, processes | yes | yes | yes | +| Pressure (PSI), power limits, temperature, container CPU | yes | no | no | + +Linux reads `/proc` and `/sys` directly. macOS and Windows read through the +system's own APIs, which cost more per process scan, so the scan stays on its +10-second cadence. Under WSL the cockpit is the Linux build. diff --git a/skills/tui/config.example.toml b/skills/tui/config.example.toml new file mode 100644 index 0000000..78af54e --- /dev/null +++ b/skills/tui/config.example.toml @@ -0,0 +1,50 @@ +# builder-tui — copy to ~/.config/builder/tui.toml and keep it local. +# Every key is optional; the values below are the defaults unless noted. + +interval = "2s" # cheap counters; processes every 3x, git every git.interval +agents = ["claude"] # process names that are agent sessions +latency_target = "" # host:port to time a TCP connect against every 10 s, e.g. "example.com:443" + +[sessions] +compact_at = 85 # context % at which a session should compact +clear_idle = "30m" # idle this long while holding clear_min_pct context: clear +clear_min_pct = 20 +clear_stale = "24h" # idle this long: clear whatever it holds +# [sessions.windows] # context window by model-name prefix; default 200k, or +# "some-model" = 1000000 # 1M when the session's model setting ends in [1m] or it passed 200k + +[alerts] # a zero threshold is off +temp_over_c = 95 +load1_over = 0 +load1_for = "2m" # the condition must hold this long +psi_cpu_over = 0 # PSI cpu "some avg10", percent +psi_cpu_for = "2m" +mem_available_under_gb = 0 +swap_over_gb = 0 # swap held although RAM is free: +swap_when_available_over_gb = 0 +power_limits_w = [] # expected CPU package limits, long term then short term; + # empty alerts when a limit changes from its value at startup + +[orphans] # detached busy loops +cpu_over = 80 # percent of one core +for = "30s" +min_age = "2m" +commands = ["sh", "bash", "zsh", "dash", "fish"] # empty: any command + +# [[groups]] # CPU share of containers whose name matches +# name = "CI runners" +# container = "runner" # regular expression + +[git] +interval = "5m" +# main = "main" # default: the branch the remote's HEAD names +remote = "origin" +repos = [] # checkouts to watch besides the ones sessions use + +[history] +log = "" # default ~/.local/share/builder/tui-history.csv; "-" disables +source = "" # another CSV with a ts column to chart alongside + +[hooks] +alert = [] # argv run on each new alert, title and evidence appended, + # e.g. ["notify-send", "builder-tui"] diff --git a/skills/tui/go.mod b/skills/tui/go.mod new file mode 100644 index 0000000..253a15d --- /dev/null +++ b/skills/tui/go.mod @@ -0,0 +1,38 @@ +module github.com/Lutherwaves/builder/skills/tui + +go 1.26.8 + +require ( + charm.land/bubbletea/v2 v2.0.10 + charm.land/lipgloss/v2 v2.0.6 + github.com/BurntSushi/toml v1.6.0 + github.com/NimbleMarkets/ntcharts/v2 v2.7.0 + github.com/charmbracelet/x/ansi v0.11.8 + github.com/shirou/gopsutil/v4 v4.26.9 +) + +require ( + charm.land/bubbles/v2 v2.2.1 // indirect + github.com/charmbracelet/colorprofile v0.4.3 // indirect + github.com/charmbracelet/ultraviolet v0.0.0-20260928045949-bbf040aedf25 // indirect + github.com/charmbracelet/x/term v0.2.2 // indirect + github.com/charmbracelet/x/termios v0.1.1 // indirect + github.com/charmbracelet/x/windows v0.2.2 // indirect + github.com/clipperhouse/displaywidth v0.11.0 // indirect + github.com/clipperhouse/uax29/v2 v2.7.0 // indirect + github.com/ebitengine/purego v0.11.1 // indirect + github.com/go-ole/go-ole v1.2.6 // indirect + github.com/lrstanley/bubblezone/v2 v2.0.0 // indirect + github.com/lucasb-eyer/go-colorful v1.4.1 // indirect + github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect + github.com/mattn/go-runewidth v0.0.30 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/power-devops/perfstat v0.0.0-20260805114148-88456608a4f6 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/tklauser/go-sysconf v0.4.0 // indirect + github.com/tklauser/numcpus v0.12.0 // indirect + github.com/xo/terminfo v1.2.0 // indirect + github.com/yusufpapurcu/wmi v1.2.4 // indirect + golang.org/x/sync v0.23.0 // indirect + golang.org/x/sys v0.48.0 // indirect +) diff --git a/skills/tui/go.sum b/skills/tui/go.sum new file mode 100644 index 0000000..6c5246e --- /dev/null +++ b/skills/tui/go.sum @@ -0,0 +1,72 @@ +charm.land/bubbles/v2 v2.2.1 h1:Fq1+qm5hV6GkvzLQDhCBpXXE5tLgvh1PRriCLwSvIQU= +charm.land/bubbles/v2 v2.2.1/go.mod h1:wdMgn+sje1KNXdwFizIWjbf328fIUBxqEmJ/vYPo8yc= +charm.land/bubbletea/v2 v2.0.10 h1:oolvo20VBpI0PfqE7iFjkZ1bx0WpmXGfnKz5Yldjq5o= +charm.land/bubbletea/v2 v2.0.10/go.mod h1:QOatcnhOjYIfxzUSTz6raF7Ex4R/rIuHa3SnBdCCpMc= +charm.land/lipgloss/v2 v2.0.6 h1:EaGKeuA8FvF+v2BT5VmZd2LoYLaMZJXA5n34th8nCIQ= +charm.land/lipgloss/v2 v2.0.6/go.mod h1:ipDDJNSGa1hlwDtSfW1s2/xR8Vdhbut4PXh2zEKZd0Q= +github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= +github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/NimbleMarkets/ntcharts/v2 v2.7.0 h1:sOCinwfnS/vhBFXJ/7PkwUStzXHnnSuqw8bMa8LUAvs= +github.com/NimbleMarkets/ntcharts/v2 v2.7.0/go.mod h1:w+5yLPICduptuEeOk8ogPi2gYU9qmaim1Elol3TUI1k= +github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o= +github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w= +github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q= +github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q= +github.com/charmbracelet/ultraviolet v0.0.0-20260928045949-bbf040aedf25 h1:LhbbjWaAXqCMLcb/44togwDILxAMCnBAfs/o0H3rxAw= +github.com/charmbracelet/ultraviolet v0.0.0-20260928045949-bbf040aedf25/go.mod h1:D9SVXVpAsD1pP6xR6BiERbe4+dUU6EW9US1TFrNutGA= +github.com/charmbracelet/x/ansi v0.11.8 h1:JMFwp0CgDC2+jcOB162HH5k7I3FVbgFSMMYg7dSPBQQ= +github.com/charmbracelet/x/ansi v0.11.8/go.mod h1:ZNN+3mXny/516oTQPLMPIBeSINvNJJQ8uQXDgbeJxY0= +github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA= +github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I= +github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= +github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= +github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= +github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= +github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM= +github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k= +github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8= +github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= +github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= +github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= +github.com/ebitengine/purego v0.11.1 h1:2zpWRSQNVKN4eKsKO9eM1ILDgWfYMY9GwqRmK6XeQ/0= +github.com/ebitengine/purego v0.11.1/go.mod h1:DCHPP08djqhNSoTfImcnHYQRZmd0qhakvrozqaEYhGQ= +github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= +github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= +github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/lrstanley/bubblezone/v2 v2.0.0 h1:pMb9fHKs0slJF6OrzQ2hEgWusqyl9VU/S0UZ5hyh7ZA= +github.com/lrstanley/bubblezone/v2 v2.0.0/go.mod h1:yV/QTjcm4Zu5cqvGvdHi7xVUfnB36w/SafOuDp57dgY= +github.com/lucasb-eyer/go-colorful v1.4.1 h1:1EO+WB73+EH8EVbzlrG3KLAfEypQWVHIBqlTf+2hNss= +github.com/lucasb-eyer/go-colorful v1.4.1/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4= +github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= +github.com/mattn/go-runewidth v0.0.30 h1:+KUuiDA4fF0R1p5FeueHefjDm+GIM+kWfFnDjybOPgk= +github.com/mattn/go-runewidth v0.0.30/go.mod h1:3qAiGCV4Koz/yuveO58qUefmUTRm8r0IGEXZ9jeHp/8= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/power-devops/perfstat v0.0.0-20260805114148-88456608a4f6 h1:jL3a8soXdzuTCcRnKhOmtcsVOObdDTFf4O2B403HPRU= +github.com/power-devops/perfstat v0.0.0-20260805114148-88456608a4f6/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/shirou/gopsutil/v4 v4.26.9 h1:CaBo/hBFqvlJoLvAQEKdHdf5GD97/MN2ACfyv63+2Ig= +github.com/shirou/gopsutil/v4 v4.26.9/go.mod h1:nKH+8wX2zxr/mDbsR+AAy6Qd2z4ZOQ7wfQaEp+yrYmg= +github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= +github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= +github.com/tklauser/go-sysconf v0.4.0 h1:7H0uAN+7RkwWRaxhYXDLqa5V3LPrJeV8wmD9dRUgPQU= +github.com/tklauser/go-sysconf v0.4.0/go.mod h1:8mTNWyog7H+MpKijp4VmKJAd2bbYQ2zuUwkYRbUArPI= +github.com/tklauser/numcpus v0.12.0 h1:NR85qdvHA9pFse3x3weVZ0r0ST8R6l5RHbZrlRaqob4= +github.com/tklauser/numcpus v0.12.0/go.mod h1:ABHeXzJnr/qqwguhClkZKT1/8VABcYrsyUiUGobwWJg= +github.com/xo/terminfo v1.2.0 h1:d0ZTOCpuGE0lwSAOs0zcJjwz3jWQyqcRt9XbGJpCOl4= +github.com/xo/terminfo v1.2.0/go.mod h1:lGzkSo8Fe7IRh/w+Gqz7n5mDog4FVXCj3gy/DYTBqio= +github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= +github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= +go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= +go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= +golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk= +golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= +golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= +golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/skills/tui/internal/advice/advice.go b/skills/tui/internal/advice/advice.go new file mode 100644 index 0000000..4c1208b --- /dev/null +++ b/skills/tui/internal/advice/advice.go @@ -0,0 +1,229 @@ +// Package advice ranks what to do next. Every recommendation carries the +// evidence that triggered it and an action that resolves it; a finding with +// nothing to do about it is an alert, not a recommendation. +package advice + +import ( + "fmt" + "path/filepath" + "sort" + "strconv" + "strings" + + "github.com/Lutherwaves/builder/skills/tui/internal/gitscan" + "github.com/Lutherwaves/builder/skills/tui/internal/sample" +) + +type Action struct { + Label string `json:"label"` + Steps [][]string `json:"steps"` // run in order + Destructive bool `json:"destructive"` + // Guards[i], when present, must still hold right before Steps[i] runs: + // the evidence can be minutes old. A step whose guard fails is skipped. + Guards []Guard `json:"guards,omitempty"` +} + +// Guard is a fact a step depends on, re-checked when it runs. +type Guard struct { + PID int `json:"pid,omitempty"` // this pid is still the process + StartTicks uint64 `json:"start_ticks,omitempty"` // that started at this time + Worktree string `json:"worktree,omitempty"` // has no changes and no process inside + IdlePane string `json:"idle_pane,omitempty"` // the agent in this pane is still idle +} + +type Rec struct { + Level string `json:"level"` // crit | warn | info + Title string `json:"title"` + Evidence string `json:"evidence"` + Primary *Action `json:"primary,omitempty"` // Enter + Second *Action `json:"secondary,omitempty"` // x + score float64 +} + +// Thresholds for the git findings. +const ( + pollingCores = 0.5 // agents' git use worth a look + sharedDirty = 200 // dirty paths in a checkout two or more sessions share + behindWorthPull = 1 +) + +func one(argv ...string) [][]string { return [][]string{argv} } + +func Build(snap sample.Snapshot, git *gitscan.Report) []Rec { + var out []Rec + + for _, o := range snap.Orphans { + where := "no pane" + if o.Pane != "" { + where = "from pane " + o.Pane + if o.PaneAlive { + where += " (" + o.Target + ")" + } else { + where += ", now closed" + } + } + r := Rec{ + Level: "crit", score: 1000 + o.CPU, + Title: fmt.Sprintf("Kill detached busy loop %d (%s)", o.PID, o.Comm), + Evidence: fmt.Sprintf("%.0f%% CPU for %s, parent %s, %s: %s", o.CPU, sample.Human(o.HotFor), o.Parent, where, o.Cmdline), + Second: &Action{Label: "kill", Steps: one(o.Kill...), Destructive: true, + Guards: []Guard{{PID: o.PID, StartTicks: o.Start}}}, + } + if o.PaneAlive { + r.Primary = &Action{Label: "jump to its pane", Steps: one("tmux", "switch-client", "-t", o.Pane)} + } + out = append(out, r) + } + + for _, s := range snap.Sessions { + if s.Do == "" || s.Pane == "" { + continue + } + r := Rec{ + Level: "warn", + Title: fmt.Sprintf("%s %s (%s)", capital(s.Do), s.Name, s.Pane), + Evidence: s.Why, + Primary: &Action{Label: "jump to the session", Steps: one("tmux", "switch-client", "-t", s.Pane)}, + score: 500 + s.ContextPct, + } + if s.Do == "clear" { + r.Level, r.score = "info", 100+s.ContextPct + } + // Type only into a session that says it is idle: a busy one would + // queue the command, and an Enter at a permission prompt would answer it. + if s.Status == "idle" { + r.Second = &Action{ + Label: "send /" + s.Do, + Steps: one("tmux", "send-keys", "-t", s.Pane, "/"+s.Do, "Enter"), + Destructive: true, + Guards: []Guard{{IdlePane: s.Pane}}, + } + } + out = append(out, r) + } + + out = append(out, alertRecs(snap)...) + out = append(out, gitRecs(snap, git)...) + sort.SliceStable(out, func(i, j int) bool { return out[i].score > out[j].score }) + return out +} + +// swapMargin is the RAM that must stay free after swap is read back in. +const swapMargin = 2 << 30 + +// alertRecs turns alerts that have a fix into recommendations. +func alertRecs(snap sample.Snapshot) []Rec { + var out []Rec + m := snap.Machine + for _, a := range snap.Alerts { + switch a.Key { + case "swap-stuck": + // Reading swap back needs room for all of it plus a margin. + if m.MemAvail < m.SwapUsed+swapMargin { + continue + } + out = append(out, Rec{ + Level: "info", score: 120, + Title: "Move swap back into RAM", + Evidence: a.Evidence + "; the RAM is free, swapped pages make the next access slow", + Second: &Action{ + Label: "swapoff and swapon in a new window (sudo asks there)", + Steps: one("tmux", "new-window", "-n", "swap", "sudo", "sh", "-c", "swapoff -a && swapon -a"), + Destructive: true, + }, + }) + case "load1", "psi-cpu": + if len(snap.Top) == 0 { + continue + } + top := snap.Top[0] + pane, ok := strings.CutPrefix(top.Owner, "pane ") + if !ok { + continue + } + out = append(out, Rec{ + Level: "warn", score: 450 + top.CPU/10, + Title: fmt.Sprintf("Look at %s in pane %s", top.Comm, pane), + Evidence: fmt.Sprintf("%s; the biggest CPU user is %s (pid %d) at %.0f%%", a.Evidence, top.Comm, top.PID, top.CPU), + Primary: &Action{Label: "jump to its pane", Steps: one("tmux", "switch-client", "-t", pane)}, + }) + } + } + return out +} + +func gitRecs(snap sample.Snapshot, git *gitscan.Report) []Rec { + var out []Rec + polling := map[string]float64{} + for _, p := range snap.GitPolling { + polling[p.Checkout] = p.Cores + } + if git == nil { + for _, p := range snap.GitPolling { + if p.Cores >= pollingCores { + out = append(out, pollingRec(p.Checkout, p.Cores, -1, 0)) + } + } + return out + } + for _, repo := range git.Repos { + var safe []gitscan.Worktree + for i, wt := range repo.Worktrees { + if wt.Safe { + safe = append(safe, wt) + } + cores := polling[wt.Path] + if cores >= pollingCores || (wt.Sessions >= 2 && wt.Dirty >= sharedDirty) { + out = append(out, pollingRec(wt.Path, cores, wt.Dirty, wt.Sessions)) + } + if i == 0 && wt.Branch == repo.Main && repo.Behind >= behindWorthPull && wt.Sessions > 0 { + out = append(out, Rec{ + Level: "info", score: 200 + float64(repo.Behind), + Title: fmt.Sprintf("Update %s in %s", repo.Main, filepath.Base(repo.Path)), + Evidence: fmt.Sprintf("%s is %d commits behind its remote and %d sessions work on it", repo.Main, repo.Behind, wt.Sessions), + // It changes a checkout agents work in. The runner never lets + // git ask for credentials and stops it after two minutes. + Second: &Action{Label: "fast-forward", Destructive: true, Steps: one( + "git", "-C", wt.Path, "pull", "--ff-only")}, + }) + } + } + if len(safe) > 0 { + steps := make([][]string, len(safe)) + guards := make([]Guard, len(safe)) + for i, wt := range safe { + steps[i], guards[i] = wt.Remove, Guard{Worktree: wt.Path} + } + out = append(out, Rec{ + Level: "info", score: 150 + float64(len(safe)), + Title: fmt.Sprintf("Remove %d merged worktrees of %s", len(safe), filepath.Base(repo.Path)), + Evidence: fmt.Sprintf("their branches merged, they have no tracked or untracked changes and no process works inside (each is checked again right before its removal); ignored files such as build output go with them; branches and commits stay; %d worktrees in total", len(repo.Worktrees)), + Second: &Action{Label: "remove " + strconv.Itoa(len(safe)), Steps: steps, Destructive: true, Guards: guards}, + }) + } + } + return out +} + +func pollingRec(checkout string, cores float64, dirty, sessions int) Rec { + ev := fmt.Sprintf("agents spend about %.1f cores running git here", cores) + if dirty >= 0 { + ev += fmt.Sprintf("; %d dirty paths", dirty) + } + if sessions > 0 { + ev += fmt.Sprintf("; %d sessions share it", sessions) + } + return Rec{ + Level: "warn", score: 400 + 100*cores + float64(dirty)/100, + Title: "Clean up " + checkout, + Evidence: ev + ". Every agent's git status walks the whole dirty tree.", + Primary: &Action{Label: "open a shell there", Steps: one("tmux", "new-window", "-c", checkout)}, + } +} + +func capital(s string) string { + if s == "" { + return s + } + return strings.ToUpper(s[:1]) + s[1:] +} diff --git a/skills/tui/internal/advice/advice_test.go b/skills/tui/internal/advice/advice_test.go new file mode 100644 index 0000000..661ae1f --- /dev/null +++ b/skills/tui/internal/advice/advice_test.go @@ -0,0 +1,111 @@ +package advice + +import ( + "strings" + "testing" + "time" + + "github.com/Lutherwaves/builder/skills/tui/internal/gitscan" + "github.com/Lutherwaves/builder/skills/tui/internal/sample" +) + +func TestBuildRanksAndActs(t *testing.T) { + snap := sample.Snapshot{ + Orphans: []sample.Orphan{{PID: 42, Comm: "bash", CPU: 99, HotFor: time.Hour, Parent: "init", + Pane: "%3", PaneAlive: true, Target: "w:1.1", Kill: []string{"kill", "42"}}}, + Sessions: []sample.Session{ + {Pane: "%1", Name: "busy-one", Busy: true, Do: "compact", ContextPct: 90, Why: "context 90%"}, + {Pane: "%2", Name: "idle-one", Status: "idle", Do: "clear", ContextPct: 40, Why: "idle 2h"}, + {Pane: "%4", Name: "fine"}, + }, + GitPolling: []sample.GitPolling{{Checkout: "/w/app", Cores: 2}}, + } + git := &gitscan.Report{Repos: []gitscan.Repo{{ + Path: "/w/app", Main: "main", Behind: 3, + Worktrees: []gitscan.Worktree{ + {Path: "/w/app", Branch: "main", Sessions: 3, Dirty: 900}, + {Path: "/w/wt/a", Safe: true, Remove: []string{"git", "-C", "/w/app", "worktree", "remove", "/w/wt/a"}}, + {Path: "/w/wt/b", Safe: true, Remove: []string{"git", "-C", "/w/app", "worktree", "remove", "/w/wt/b"}}, + }, + }}} + recs := Build(snap, git) + + var titles []string + for _, r := range recs { + titles = append(titles, r.Title) + } + // Two cores of git polling cost the machine more than one session's + // context, so it ranks above the compact. + want := []string{ + "Kill detached busy loop 42 (bash)", + "Clean up /w/app", + "Compact busy-one (%1)", + "Update main in app", + "Remove 2 merged worktrees of app", + "Clear idle-one (%2)", + } + if strings.Join(titles, "|") != strings.Join(want, "|") { + t.Fatalf("order:\n got %q\nwant %q", titles, want) + } + + kill := recs[0] + if kill.Second == nil || !kill.Second.Destructive || strings.Join(kill.Second.Steps[0], " ") != "kill 42" || kill.Primary == nil || + len(kill.Second.Guards) != 1 || kill.Second.Guards[0].PID != 42 { + t.Errorf("kill rec = %+v", kill) + } + if recs[2].Second != nil { + t.Error("a busy session must not be offered typed input") + } + if c := recs[5].Second; c == nil || !c.Destructive || strings.Join(c.Steps[0], " ") != "tmux send-keys -t %2 /clear Enter" { + t.Errorf("clear action = %+v", c) + } + if rm := recs[4].Second; rm == nil || !rm.Destructive || len(rm.Steps) != 2 || len(rm.Guards) != 2 || rm.Guards[1].Worktree != "/w/wt/b" { + t.Errorf("bulk remove = %+v", rm) + } + if ff := recs[3].Second; ff == nil || !ff.Destructive || !strings.HasSuffix(strings.Join(ff.Steps[0], " "), "git -C /w/app pull --ff-only") { + t.Errorf("fast-forward = %+v", ff) + } + for _, r := range recs { + if r.Primary == nil && r.Second == nil { + t.Errorf("%q has no action", r.Title) + } + } +} + +func TestNeverTypesIntoANonIdleSession(t *testing.T) { + for _, status := range []string{"busy", "shell", ""} { + snap := sample.Snapshot{Sessions: []sample.Session{{Pane: "%1", Name: "x", Status: status, Do: "clear"}}} + if r := Build(snap, nil); len(r) != 1 || r[0].Second != nil { + t.Errorf("status %q: %+v", status, r) + } + } +} + +func TestNothingToDo(t *testing.T) { + if recs := Build(sample.Snapshot{Sessions: []sample.Session{{Pane: "%1"}}}, nil); len(recs) != 0 { + t.Fatalf("got %+v", recs) + } +} + +func TestAlertRecs(t *testing.T) { + const gb = 1 << 30 + snap := sample.Snapshot{ + Machine: sample.Machine{MemAvail: 40 * gb, SwapUsed: 12 * gb}, + Alerts: []sample.Alert{{Key: "swap-stuck", Evidence: "swap 12 GB"}, {Key: "load1", Evidence: "load1 50"}}, + Top: []sample.ProcRow{{PID: 7, Comm: "node", CPU: 300, Owner: "pane %5"}}, + } + recs := Build(snap, nil) + if len(recs) != 2 || recs[0].Primary == nil || strings.Join(recs[0].Primary.Steps[0], " ") != "tmux switch-client -t %5" { + t.Fatalf("load rec: %+v", recs) + } + if sw := recs[1].Second; sw == nil || !sw.Destructive || sw.Steps[0][len(sw.Steps[0])-1] != "swapoff -a && swapon -a" { + t.Fatalf("swap rec: %+v", recs[1]) + } + + // Not enough free RAM to take the swap back: no offer. + snap.Machine.MemAvail = 13 * gb + snap.Top[0].Owner = "system" + if recs := Build(snap, nil); len(recs) != 0 { + t.Fatalf("got %+v", recs) + } +} diff --git a/skills/tui/internal/config/config.go b/skills/tui/internal/config/config.go new file mode 100644 index 0000000..0270281 --- /dev/null +++ b/skills/tui/internal/config/config.go @@ -0,0 +1,148 @@ +// Package config loads the per-machine goals. The file is local and never +// part of the repository: thresholds describe one machine, not the tool. +package config + +import ( + "errors" + "io/fs" + "os" + "path/filepath" + "regexp" + "time" + + "github.com/BurntSushi/toml" +) + +// Duration reads "30s", "2m", "24h" from TOML. +type Duration struct{ time.Duration } + +func (d *Duration) UnmarshalText(b []byte) error { + v, err := time.ParseDuration(string(b)) + d.Duration = v + return err +} + +type Config struct { + Interval Duration `toml:"interval"` + Agents []string `toml:"agents"` + LatencyTarget string `toml:"latency_target"` + Sessions Sessions `toml:"sessions"` + Alerts Alerts `toml:"alerts"` + Orphans Orphans `toml:"orphans"` + Groups []Group `toml:"groups"` + Git Git `toml:"git"` + History History `toml:"history"` + Hooks Hooks `toml:"hooks"` +} + +// Hooks run a command when something happens. The command is an argv list, +// never a shell string; the event's title and evidence are appended as two +// more arguments. +type Hooks struct { + Alert []string `toml:"alert"` // on each new alert, e.g. ["notify-send", "builder"] +} + +type Sessions struct { + CompactAt float64 `toml:"compact_at"` + ClearIdle Duration `toml:"clear_idle"` + ClearMinPct float64 `toml:"clear_min_pct"` + ClearStale Duration `toml:"clear_stale"` + Windows map[string]int `toml:"windows"` // model prefix -> context tokens +} + +// Alerts: a zero threshold is off. "For" durations make an alert wait until +// the condition has held that long, so a spike does not page anyone. +type Alerts struct { + TempOverC float64 `toml:"temp_over_c"` + Load1Over float64 `toml:"load1_over"` + Load1For Duration `toml:"load1_for"` + PSICPUOver float64 `toml:"psi_cpu_over"` + PSICPUFor Duration `toml:"psi_cpu_for"` + MemAvailableUnderGB float64 `toml:"mem_available_under_gb"` + SwapOverGB float64 `toml:"swap_over_gb"` + SwapWhenAvailableOverGB float64 `toml:"swap_when_available_over_gb"` + // PowerLimitsW are the expected package limits in watts, long term then + // short term. Empty: alert when a limit changes from its value at startup. + PowerLimitsW []float64 `toml:"power_limits_w"` +} + +// Orphans are processes re-parented to init or a user service manager that +// keep burning CPU: leaked test fixtures, forgotten loops. +type Orphans struct { + CPUOver float64 `toml:"cpu_over"` // percent of one core + For Duration `toml:"for"` + MinAge Duration `toml:"min_age"` + Commands []string `toml:"commands"` // empty: any command +} + +// Group sums the CPU of containers whose name matches a pattern. +type Group struct { + Name string `toml:"name"` + Container string `toml:"container"` // regular expression on the container name + re *regexp.Regexp +} + +func (g Group) Match(name string) bool { return g.re != nil && g.re.MatchString(name) } + +type Git struct { + Repos []string `toml:"repos"` // checkouts to watch besides the ones sessions use + Interval Duration `toml:"interval"` + Main string `toml:"main"` // default branch name; empty: what the remote's HEAD names + Remote string `toml:"remote"` // remote to compare against +} + +type History struct { + Log string `toml:"log"` // append-only CSV the cockpit writes; "-" disables + Source string `toml:"source"` // extra CSV with a ts column to chart (another logger) +} + +func Default() Config { + return Config{ + Interval: Duration{2 * time.Second}, + Agents: []string{"claude"}, + Sessions: Sessions{ + CompactAt: 85, + ClearIdle: Duration{30 * time.Minute}, + ClearMinPct: 20, + ClearStale: Duration{24 * time.Hour}, + }, + Alerts: Alerts{ + TempOverC: 95, + Load1For: Duration{2 * time.Minute}, + PSICPUFor: Duration{2 * time.Minute}, + }, + Orphans: Orphans{ + CPUOver: 80, + For: Duration{30 * time.Second}, + MinAge: Duration{2 * time.Minute}, + Commands: []string{"sh", "bash", "zsh", "dash", "fish"}, + }, + Git: Git{Interval: Duration{5 * time.Minute}, Remote: "origin"}, + } +} + +// Path is $XDG_CONFIG_HOME/builder/tui.toml, defaulting to ~/.config. +func Path() string { + dir := os.Getenv("XDG_CONFIG_HOME") + if dir == "" { + home, _ := os.UserHomeDir() + dir = filepath.Join(home, ".config") + } + return filepath.Join(dir, "builder", "tui.toml") +} + +// Load overlays the file on the defaults. A missing file is not an error. +func Load(path string) (Config, error) { + c := Default() + if _, err := toml.DecodeFile(path, &c); err != nil && !errors.Is(err, fs.ErrNotExist) { + return c, err + } + for i := range c.Groups { + re, err := regexp.Compile(c.Groups[i].Container) + if err != nil { + return c, errors.New("groups." + c.Groups[i].Name + ": " + err.Error()) + } + c.Groups[i].re = re + } + return c, nil +} diff --git a/skills/tui/internal/config/config_test.go b/skills/tui/internal/config/config_test.go new file mode 100644 index 0000000..85f8148 --- /dev/null +++ b/skills/tui/internal/config/config_test.go @@ -0,0 +1,76 @@ +package config + +import ( + "os" + "path/filepath" + "testing" + "time" +) + +func write(t *testing.T, body string) string { + t.Helper() + path := filepath.Join(t.TempDir(), "tui.toml") + os.WriteFile(path, []byte(body), 0o644) + return path +} + +func TestMissingFileIsDefaults(t *testing.T) { + c, err := Load(filepath.Join(t.TempDir(), "none.toml")) + if err != nil || c.Interval.Duration != 2*time.Second || c.Orphans.CPUOver != 80 { + t.Fatalf("%+v %v", c, err) + } +} + +func TestOverlay(t *testing.T) { + c, err := Load(write(t, ` +interval = "5s" +[alerts] +temp_over_c = 85 +load1_over = 40 +load1_for = "3m" +power_limits_w = [100, 150] +[orphans] +cpu_over = 70 +[[groups]] +name = "CI" +container = "^ci-" +[sessions.windows] +"model-x" = 1000000 +[hooks] +alert = ["notify-send", "cockpit"] +`)) + if err != nil { + t.Fatal(err) + } + if c.Interval.Duration != 5*time.Second || c.Alerts.TempOverC != 85 || c.Alerts.Load1For.Duration != 3*time.Minute || + len(c.Alerts.PowerLimitsW) != 2 || c.Orphans.CPUOver != 70 || c.Sessions.Windows["model-x"] != 1_000_000 || + len(c.Hooks.Alert) != 2 { + t.Fatalf("%+v", c) + } + // Unset values keep their defaults. + if c.Orphans.For.Duration != 30*time.Second || c.Sessions.CompactAt != 85 { + t.Fatalf("defaults lost: %+v", c) + } + if !c.Groups[0].Match("ci-runner-2") || c.Groups[0].Match("app-ci-") { + t.Fatal("group pattern") + } +} + +func TestBadInput(t *testing.T) { + if _, err := Load(write(t, "[[groups]]\nname = \"x\"\ncontainer = \"(\"\n")); err == nil { + t.Error("bad regexp accepted") + } + if _, err := Load(write(t, "interval = \"soon\"\n")); err == nil { + t.Error("bad duration accepted") + } +} + +func TestExampleLoads(t *testing.T) { + c, err := Load("../../config.example.toml") + if err != nil { + t.Fatal(err) + } + if d := Default(); c.Interval != d.Interval || c.Sessions.CompactAt != d.Sessions.CompactAt || c.Orphans.CPUOver != d.Orphans.CPUOver { + t.Fatalf("the example must state the defaults: %+v", c) + } +} diff --git a/skills/tui/internal/gitscan/gitscan.go b/skills/tui/internal/gitscan/gitscan.go new file mode 100644 index 0000000..1d796ba --- /dev/null +++ b/skills/tui/internal/gitscan/gitscan.go @@ -0,0 +1,318 @@ +// Package gitscan reports on the checkouts agents work in: dirty paths, how +// far the default branch is behind its remote, and which worktrees are safe +// to remove. Every git call is read-only and skips optional locks, so it +// never contends with the agents' own git use. +package gitscan + +import ( + "bytes" + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "sort" + "strconv" + "strings" + "time" +) + +type Report struct { + At time.Time `json:"at"` + Took time.Duration `json:"took_ns"` // this scan's own wall time + Repos []Repo `json:"repos"` + Errors []string `json:"errors,omitempty"` + Watching []string `json:"watching"` +} + +type Repo struct { + Path string `json:"path"` // the main checkout + Main string `json:"main"` + Behind int `json:"main_behind"` // commits /
has that
lacks + Worktrees []Worktree `json:"worktrees"` +} + +type Worktree struct { + Path string `json:"path"` + Branch string `json:"branch"` + Dirty int `json:"dirty"` // -1: not checked + Sessions int `json:"sessions"` + Merged bool `json:"merged"` + InUse bool `json:"in_use"` // a process has its cwd inside + Safe bool `json:"safe"` // merged, clean, unused + Remove []string `json:"remove,omitempty"` +} + +// Input is what the live sampler knows: which directories sessions and +// other processes sit in. +type Input struct { + SessionCwds []string + ProcessCwds []string + Extra []string // configured repositories + Main string + Remote string +} + +// Runner runs git with the given arguments in dir. +type Runner func(ctx context.Context, dir string, args ...string) ([]byte, error) + +func Git(ctx context.Context, dir string, args ...string) ([]byte, error) { + cmd := exec.CommandContext(ctx, "git", append([]string{"-C", dir}, args...)...) + cmd.Env = append(os.Environ(), "GIT_OPTIONAL_LOCKS=0", "LC_ALL=C") + return cmd.Output() +} + +// Merged lists branch names whose pull requests merged. Nil when unknown. +type Merged func(ctx context.Context, repo string) map[string]bool + +// GHMerged asks the GitHub CLI; squash merges leave no trace in git itself. +func GHMerged(ctx context.Context, repo string) map[string]bool { + if _, err := exec.LookPath("gh"); err != nil { + return nil + } + cmd := exec.CommandContext(ctx, "gh", "pr", "list", "--state", "merged", "--limit", "1000", + "--json", "headRefName", "--jq", ".[].headRefName") + cmd.Dir = repo + out, err := cmd.Output() + if err != nil { + return nil + } + set := map[string]bool{} + for _, b := range strings.Fields(string(out)) { + set[b] = true + } + return set +} + +// Toplevel walks up from dir to the directory holding .git (a directory for a +// main checkout, a file for a linked worktree) without running git. +func Toplevel(dir string) string { + for d := filepath.Clean(dir); d != "/" && d != "."; d = filepath.Dir(d) { + if _, err := os.Stat(filepath.Join(d, ".git")); err == nil { + return d + } + } + return "" +} + +// mainCheckout resolves a linked worktree to its main checkout by reading +// the gitdir line of its .git file. +func mainCheckout(top string) string { + b, err := os.ReadFile(filepath.Join(top, ".git")) + if err != nil { + return top // .git is a directory: already the main checkout + } + gitdir, ok := strings.CutPrefix(strings.TrimSpace(string(b)), "gitdir: ") + if !ok { + return top + } + if !filepath.IsAbs(gitdir) { + gitdir = filepath.Join(top, gitdir) + } + //
/.git/worktrees/ + if i := strings.Index(gitdir, string(filepath.Separator)+".git"+string(filepath.Separator)+"worktrees"+string(filepath.Separator)); i >= 0 { + return gitdir[:i] + } + return top +} + +func within(path, dir string) bool { + return path == dir || strings.HasPrefix(path, dir+string(filepath.Separator)) +} + +// Scanner remembers dirty counts between scans. Checkouts sessions use are +// counted every scan; a merged worktree only every RecheckIdle, because it +// can number in the hundreds and `git worktree remove` (without --force) +// refuses a dirty or untracked worktree at action time anyway. +type Scanner struct { + Git Runner + Merged Merged + RecheckIdle time.Duration + Now func() time.Time + dirty map[string]dirtyAt + merged map[string]mergedAt +} + +// mergedEvery bounds how often merged-branch data is fetched per repository. +const mergedEvery = 10 * time.Minute + +type mergedAt struct { + set map[string]bool + at time.Time +} + +type dirtyAt struct { + n int + at time.Time +} + +// Scan reports on every repository a session works in plus the extra ones. +func Scan(ctx context.Context, in Input, git Runner, merged Merged) Report { + return (&Scanner{Git: git, Merged: merged}).Scan(ctx, in) +} + +func (s *Scanner) Scan(ctx context.Context, in Input) Report { + if s.dirty == nil { + s.dirty, s.merged = map[string]dirtyAt{}, map[string]mergedAt{} + } + if s.Now == nil { + s.Now = time.Now + } + git, merged := s.Git, s.Merged + start := time.Now() + rep := Report{At: start} + if in.Remote == "" { + in.Remote = "origin" + } + + sessionsIn := map[string]int{} + repos := map[string]bool{} + for _, cwd := range in.SessionCwds { + if top := Toplevel(cwd); top != "" { + sessionsIn[top]++ + repos[mainCheckout(top)] = true + } + } + for _, r := range in.Extra { + if top := Toplevel(r); top != "" { + repos[mainCheckout(top)] = true + } + } + for r := range repos { + rep.Watching = append(rep.Watching, r) + } + sort.Strings(rep.Watching) + + for _, path := range rep.Watching { + r, err := s.scanRepo(ctx, path, in, sessionsIn, git, merged) + if err != nil { + rep.Errors = append(rep.Errors, path+": "+err.Error()) + continue + } + rep.Repos = append(rep.Repos, r) + } + rep.Took = time.Since(start) + return rep +} + +func (s *Scanner) scanRepo(ctx context.Context, path string, in Input, sessionsIn map[string]int, git Runner, merged Merged) (Repo, error) { + def := in.Main + if def == "" { + def = defaultBranch(ctx, git, path, in.Remote) + } + r := Repo{Path: path, Main: def} + if out, err := git(ctx, path, "rev-list", "--count", "refs/heads/"+def+"..refs/remotes/"+in.Remote+"/"+def); err == nil { + r.Behind, _ = strconv.Atoi(strings.TrimSpace(string(out))) + } + out, err := git(ctx, path, "worktree", "list", "--porcelain") + if err != nil { + return r, err + } + r.Worktrees = parseWorktrees(out) + + var mergedSet map[string]bool + if c, ok := s.merged[path]; ok && s.Now().Sub(c.at) < mergedEvery { + mergedSet = c.set + } else if merged != nil { + mergedSet = merged(ctx, path) + s.merged[path] = mergedAt{mergedSet, s.Now()} + } + if mergedSet == nil { + mergedSet = map[string]bool{} + if out, err := git(ctx, path, "branch", "--format=%(refname:short)", "--merged", "refs/remotes/"+in.Remote+"/"+def); err == nil { + for _, b := range strings.Fields(string(out)) { + mergedSet[b] = true + } + } + } + + for i := range r.Worktrees { + wt := &r.Worktrees[i] + wt.Dirty = -1 + wt.Sessions = sessionsIn[wt.Path] + for _, cwd := range append(in.SessionCwds, in.ProcessCwds...) { + if within(cwd, wt.Path) { + wt.InUse = true + break + } + } + main := i == 0 + wt.Merged = !main && wt.Branch != "" && wt.Branch != def && mergedSet[wt.Branch] + switch { + case wt.Sessions > 0 || main: + wt.Dirty = s.count(ctx, git, wt.Path, 0) + case wt.Merged: + wt.Dirty = s.count(ctx, git, wt.Path, s.RecheckIdle) + } + wt.Safe = wt.Merged && wt.Dirty == 0 && !wt.InUse + if wt.Safe { + wt.Remove = []string{"git", "-C", path, "worktree", "remove", wt.Path} + } + } + return r, nil +} + +// defaultBranch is the branch the remote's HEAD points at (main, master, +// develop...), or "main" when the clone never recorded it. +func defaultBranch(ctx context.Context, git Runner, path, remote string) string { + out, err := git(ctx, path, "symbolic-ref", "--short", "refs/remotes/"+remote+"/HEAD") + if b, ok := strings.CutPrefix(strings.TrimSpace(string(out)), remote+"/"); err == nil && ok && b != "" { + return b + } + return "main" +} + +// count returns a cached dirty count younger than maxAge, or counts again. +func (s *Scanner) count(ctx context.Context, git Runner, dir string, maxAge time.Duration) int { + if c, ok := s.dirty[dir]; ok && s.Now().Sub(c.at) < maxAge { + return c.n + } + n := dirty(ctx, git, dir) + s.dirty[dir] = dirtyAt{n, s.Now()} + return n +} + +// dirty counts changed and untracked paths; -1 when git fails. +func dirty(ctx context.Context, git Runner, dir string) int { + out, err := git(ctx, dir, "status", "--porcelain=v1", "-z", "--untracked-files=normal", "--no-renames") + if err != nil { + return -1 + } + return bytes.Count(out, []byte{0}) +} + +func parseWorktrees(b []byte) []Worktree { + var out []Worktree + for _, block := range strings.Split(strings.TrimSpace(string(b)), "\n\n") { + var wt Worktree + for _, line := range strings.Split(block, "\n") { + switch { + case strings.HasPrefix(line, "worktree "): + wt.Path = strings.TrimPrefix(line, "worktree ") + case strings.HasPrefix(line, "branch "): + wt.Branch = strings.TrimPrefix(strings.TrimPrefix(line, "branch "), "refs/heads/") + } + } + if wt.Path != "" { + out = append(out, wt) + } + } + return out +} + +// VerifyRemovable checks, now, that a worktree has no tracked or untracked +// changes and that no process works inside it. +func VerifyRemovable(ctx context.Context, git Runner, path string, processCwds []string) error { + for _, cwd := range processCwds { + if within(cwd, path) { + return fmt.Errorf("a process works in %s", cwd) + } + } + switch n := dirty(ctx, git, path); { + case n < 0: + return fmt.Errorf("git status failed in %s", path) + case n > 0: + return fmt.Errorf("%s has %d changed or untracked paths", path, n) + } + return nil +} diff --git a/skills/tui/internal/gitscan/gitscan_test.go b/skills/tui/internal/gitscan/gitscan_test.go new file mode 100644 index 0000000..6733772 --- /dev/null +++ b/skills/tui/internal/gitscan/gitscan_test.go @@ -0,0 +1,164 @@ +package gitscan + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" +) + +func run(t *testing.T, dir string, args ...string) string { + t.Helper() + cmd := exec.Command("git", append([]string{"-C", dir}, args...)...) + cmd.Env = append(os.Environ(), "GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@example.com", + "GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@example.com", "GIT_CONFIG_GLOBAL=/dev/null") + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("git %v: %v\n%s", args, err, out) + } + return strings.TrimSpace(string(out)) +} + +// newRepo builds a main checkout one commit behind origin/main, with four +// linked worktrees: merged+clean, merged+untracked file, merged+in use, and +// an open branch a session works in. +func newRepo(t *testing.T) (root, repo string) { + t.Helper() + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git not installed") + } + root, _ = filepath.EvalSymlinks(t.TempDir()) + repo = filepath.Join(root, "app") + run(t, root, "init", "-q", "-b", "main", repo) + os.WriteFile(filepath.Join(repo, "a.txt"), []byte("a"), 0o644) + run(t, repo, "add", ".") + run(t, repo, "commit", "-qm", "a") + run(t, repo, "commit", "-q", "--allow-empty", "-m", "b") + run(t, repo, "update-ref", "refs/remotes/origin/main", "HEAD") + run(t, repo, "reset", "-q", "--hard", "HEAD~1") + for _, b := range []string{"done", "untracked", "busy", "open"} { + run(t, repo, "worktree", "add", "-q", "-b", b, filepath.Join(root, "wt", b)) + } + os.WriteFile(filepath.Join(root, "wt", "untracked", "new.txt"), []byte("x"), 0o644) + os.WriteFile(filepath.Join(root, "wt", "open", "a.txt"), []byte("changed"), 0o644) + return root, repo +} + +func TestScan(t *testing.T) { + root, repo := newRepo(t) + merged := func(context.Context, string) map[string]bool { + return map[string]bool{"done": true, "untracked": true, "busy": true} + } + in := Input{ + SessionCwds: []string{filepath.Join(root, "wt", "open", "sub"), repo}, + ProcessCwds: []string{filepath.Join(root, "wt", "busy")}, + } + os.MkdirAll(filepath.Join(root, "wt", "open", "sub"), 0o755) + rep := Scan(context.Background(), in, Git, merged) + if len(rep.Errors) > 0 || len(rep.Repos) != 1 { + t.Fatalf("report = %+v", rep) + } + r := rep.Repos[0] + if r.Path != repo || r.Behind != 1 || len(r.Worktrees) != 5 { + t.Fatalf("repo = %+v", r) + } + got := map[string]Worktree{} + for _, wt := range r.Worktrees { + got[filepath.Base(wt.Path)] = wt + } + if wt := got["done"]; !wt.Safe || wt.Dirty != 0 || strings.Join(wt.Remove, " ") != "git -C "+repo+" worktree remove "+wt.Path { + t.Errorf("done = %+v", wt) + } + if wt := got["untracked"]; wt.Safe || wt.Dirty != 1 { + t.Errorf("an untracked file must block removal: %+v", wt) + } + if wt := got["busy"]; wt.Safe || !wt.InUse { + t.Errorf("a process inside must block removal: %+v", wt) + } + if wt := got["open"]; wt.Safe || wt.Merged || wt.Sessions != 1 || wt.Dirty != 1 { + t.Errorf("open = %+v", wt) + } + if wt := got["app"]; wt.Sessions != 1 || wt.Dirty != 0 || wt.Branch != "main" { + t.Errorf("main checkout = %+v", wt) + } +} + +func TestScanFallsBackToGitMerged(t *testing.T) { + root, repo := newRepo(t) + // No PR data: only branches git itself sees merged count. All four + // branches point at main's commit, which origin/main contains. + rep := Scan(context.Background(), Input{SessionCwds: []string{repo}}, Git, func(context.Context, string) map[string]bool { return nil }) + n := 0 + for _, wt := range rep.Repos[0].Worktrees { + if wt.Merged { + n++ + } + } + if n != 4 { + t.Fatalf("want 4 merged by git, got %d (%s)", n, root) + } +} + +func TestToplevelAndMainCheckout(t *testing.T) { + root, repo := newRepo(t) + wt := filepath.Join(root, "wt", "open") + os.MkdirAll(filepath.Join(wt, "deep", "er"), 0o755) + if got := Toplevel(filepath.Join(wt, "deep", "er")); got != wt { + t.Fatalf("toplevel = %q", got) + } + if got := mainCheckout(wt); got != repo { + t.Fatalf("main checkout = %q", got) + } + if got := Toplevel(root); got != "" { + t.Fatalf("no repo above %s, got %q", root, got) + } +} + +func TestScannerCachesIdleWorktrees(t *testing.T) { + root, repo := newRepo(t) + calls := map[string]int{} + counting := func(ctx context.Context, dir string, args ...string) ([]byte, error) { + if args[0] == "status" { + calls[filepath.Base(dir)]++ + } + return Git(ctx, dir, args...) + } + merged := func(context.Context, string) map[string]bool { return map[string]bool{"done": true} } + s := &Scanner{Git: counting, Merged: merged, RecheckIdle: 30 * time.Minute} + in := Input{SessionCwds: []string{filepath.Join(root, "wt", "open")}} + s.Scan(context.Background(), in) + s.Scan(context.Background(), in) + if calls["open"] != 2 || calls["app"] != 2 { + t.Errorf("checkouts in use must be counted every scan: %v", calls) + } + if calls["done"] != 1 { + t.Errorf("a merged idle worktree must come from the cache: %v (%s)", calls, repo) + } +} + +func TestScanFindsTheRemotesDefaultBranch(t *testing.T) { + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git not installed") + } + root, _ := filepath.EvalSymlinks(t.TempDir()) + repo := filepath.Join(root, "app") + run(t, root, "init", "-q", "-b", "trunk", repo) + run(t, repo, "commit", "-q", "--allow-empty", "-m", "a") + run(t, repo, "commit", "-q", "--allow-empty", "-m", "b") + run(t, repo, "update-ref", "refs/remotes/origin/trunk", "HEAD") + run(t, repo, "symbolic-ref", "refs/remotes/origin/HEAD", "refs/remotes/origin/trunk") + run(t, repo, "reset", "-q", "--hard", "HEAD~1") + run(t, repo, "worktree", "add", "-q", "-b", "done", filepath.Join(root, "wt", "done")) + + rep := Scan(context.Background(), Input{SessionCwds: []string{repo}}, Git, func(context.Context, string) map[string]bool { return nil }) + r := rep.Repos[0] + if r.Main != "trunk" || r.Behind != 1 { + t.Fatalf("want trunk 1 behind, got %s %d behind", r.Main, r.Behind) + } + if wt := r.Worktrees[1]; !wt.Merged { + t.Fatalf("a branch merged into trunk must count as merged: %+v", wt) + } +} diff --git a/skills/tui/internal/history/history.go b/skills/tui/internal/history/history.go new file mode 100644 index 0000000..6e8d26f --- /dev/null +++ b/skills/tui/internal/history/history.go @@ -0,0 +1,191 @@ +// Package history keeps machine trends across restarts: an append-only CSV +// the cockpit writes, plus any other CSV with a ts column to read alongside. +package history + +import ( + "bufio" + "encoding/csv" + "fmt" + "io" + "os" + "path/filepath" + "sort" + "strconv" + "time" + + "github.com/Lutherwaves/builder/skills/tui/internal/sample" +) + +// Every is the spacing of history points; finer samples are dropped. +const Every = 30 * time.Second + +// Keep bounds what the panel holds in memory. +const Keep = 24 * time.Hour + +type Point struct { + At time.Time + CPU float64 + Load1 float64 + PSICPU float64 + PSIIO float64 + TempC float64 + MemAvailGB float64 + SwapGB float64 +} + +// Columns is the CSV header. Readers match columns by name, so a file from +// another logger that uses some of these names can be charted too. +var Columns = []string{"ts", "cpu_pct", "load1", "psi_cpu10", "psi_io10", "pkg_temp_c", "mem_avail_gb", "swap_used_gb"} + +func FromSnapshot(s sample.Snapshot) Point { + m := s.Machine + return Point{ + At: s.At, CPU: m.CPU, Load1: m.Load[0], PSICPU: m.PSI.CPU, PSIIO: m.PSI.IO, TempC: m.TempC, + MemAvailGB: float64(m.MemAvail) / (1 << 30), SwapGB: float64(m.SwapUsed) / (1 << 30), + } +} + +func (p Point) row() []string { + f := func(v float64) string { return strconv.FormatFloat(v, 'f', 1, 64) } + return []string{p.At.Format(time.RFC3339), f(p.CPU), f(p.Load1), f(p.PSICPU), f(p.PSIIO), f(p.TempC), f(p.MemAvailGB), f(p.SwapGB)} +} + +type Series struct{ Points []Point } + +// Append adds a point if it is at least Every after the last one. +func (s *Series) Append(p Point) { + if n := len(s.Points); n > 0 && p.At.Sub(s.Points[n-1].At) < Every { + return + } + s.Points = append(s.Points, p) + s.trim(p.At) +} + +// Merge folds newer points in, keeping time order. +func (s *Series) Merge(o Series) { + s.Points = append(s.Points, o.Points...) + sort.SliceStable(s.Points, func(i, j int) bool { return s.Points[i].At.Before(s.Points[j].At) }) + if n := len(s.Points); n > 0 { + s.trim(s.Points[n-1].At) + } +} + +func (s *Series) trim(now time.Time) { + cut := 0 + for cut < len(s.Points) && now.Sub(s.Points[cut].At) > Keep { + cut++ + } + s.Points = s.Points[cut:] +} + +// Logger appends one row every Every. It is called from one goroutine at a +// time (after each sample), so it needs no lock. +type Logger struct { + Path string + last time.Time +} + +func (l *Logger) Log(p Point) error { + if l.Path == "" || p.At.Sub(l.last) < Every { + return nil + } + l.last = p.At + if err := os.MkdirAll(filepath.Dir(l.Path), 0o700); err != nil { + return err + } + f, err := os.OpenFile(l.Path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600) + if err != nil { + return err + } + defer f.Close() + w := csv.NewWriter(f) + if st, err := f.Stat(); err == nil && st.Size() == 0 { + w.Write(Columns) + } + w.Write(p.row()) + w.Flush() + return w.Error() +} + +// Load reads the last Keep of every file, matching columns by name. Missing +// files are skipped; a file without a ts column is an error. +func Load(now time.Time, paths ...string) (Series, error) { + var s Series + for _, path := range paths { + if path == "" { + continue + } + pts, err := readCSV(path, now.Add(-Keep)) + if os.IsNotExist(err) { + continue + } + if err != nil { + return s, fmt.Errorf("%s: %w", path, err) + } + s.Points = append(s.Points, pts...) + } + s.Merge(Series{}) + return s, nil +} + +func readCSV(path string, since time.Time) ([]Point, error) { + f, err := os.Open(path) + if err != nil { + return nil, err + } + defer f.Close() + r := csv.NewReader(bufio.NewReader(f)) + r.FieldsPerRecord = -1 + head, err := r.Read() + if err != nil { + return nil, err + } + col := map[string]int{} + for i, h := range head { + col[h] = i + } + ts, ok := col["ts"] + if !ok { + return nil, fmt.Errorf("no ts column") + } + var out []Point + for { + rec, err := r.Read() + if err == io.EOF { + break + } + if err != nil { + continue + } + get := func(name string) float64 { + i, ok := col[name] + if !ok || i >= len(rec) { + return 0 + } + v, _ := strconv.ParseFloat(rec[i], 64) + return v + } + if ts >= len(rec) { + continue + } + at, err := time.Parse(time.RFC3339, rec[ts]) + if err != nil || at.Before(since) { + continue + } + out = append(out, Point{ + At: at, CPU: get("cpu_pct"), Load1: get("load1"), PSICPU: get("psi_cpu10"), PSIIO: get("psi_io10"), + TempC: get("pkg_temp_c"), MemAvailGB: get("mem_avail_gb"), SwapGB: get("swap_used_gb"), + }) + } + return out, nil +} + +// DefaultLog is $XDG_DATA_HOME/builder/tui-history.csv. +func DefaultLog() string { + dir := os.Getenv("XDG_DATA_HOME") + if dir == "" { + home, _ := os.UserHomeDir() + dir = filepath.Join(home, ".local", "share") + } + return filepath.Join(dir, "builder", "tui-history.csv") +} diff --git a/skills/tui/internal/history/history_test.go b/skills/tui/internal/history/history_test.go new file mode 100644 index 0000000..14ce224 --- /dev/null +++ b/skills/tui/internal/history/history_test.go @@ -0,0 +1,73 @@ +package history + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +var t0 = time.Date(2026, 1, 2, 3, 4, 0, 0, time.UTC) + +func TestLoggerSpacingAndHeader(t *testing.T) { + path := filepath.Join(t.TempDir(), "h.csv") + l := &Logger{Path: path} + for i := 0; i < 5; i++ { + l.Log(Point{At: t0.Add(time.Duration(i) * 10 * time.Second), CPU: float64(i)}) + } + b, _ := os.ReadFile(path) + lines := strings.Split(strings.TrimSpace(string(b)), "\n") + if lines[0] != strings.Join(Columns, ",") || len(lines) != 3 { + t.Fatalf("want header + 2 rows (0s, 30s), got:\n%s", b) + } +} + +func TestLoadMatchesColumnsByName(t *testing.T) { + dir := t.TempDir() + other := filepath.Join(dir, "other.csv") + // Another logger: different order, extra columns, one row too old. + os.WriteFile(other, []byte("ts,gpu0_pct,load1,cpu_pct\n"+ + t0.Add(-48*time.Hour).Format(time.RFC3339)+",1,9,9\n"+ + t0.Add(-time.Hour).Format(time.RFC3339)+",5,12.5,80\n"), 0o644) + own := filepath.Join(dir, "own.csv") + l := &Logger{Path: own} + l.Log(Point{At: t0.Add(-2 * time.Hour), CPU: 10, TempC: 70}) + + s, err := Load(t0, other, own, filepath.Join(dir, "missing.csv")) + if err != nil { + t.Fatal(err) + } + if len(s.Points) != 2 { + t.Fatalf("points = %+v", s.Points) + } + if p := s.Points[0]; p.CPU != 10 || p.TempC != 70 { + t.Errorf("own row first by time: %+v", p) + } + if p := s.Points[1]; p.CPU != 80 || p.Load1 != 12.5 { + t.Errorf("foreign row: %+v", p) + } +} + +func TestLoadRejectsFileWithoutTs(t *testing.T) { + path := filepath.Join(t.TempDir(), "bad.csv") + os.WriteFile(path, []byte("cpu\n1\n"), 0o644) + if _, err := Load(t0, path); err == nil { + t.Fatal("want an error for a CSV without ts") + } +} + +func TestSeriesKeepsSpacingAndWindow(t *testing.T) { + var s Series + for i := 0; i < 100; i++ { + s.Append(Point{At: t0.Add(time.Duration(i) * 20 * time.Minute)}) + } + if first := s.Points[0].At; s.Points[len(s.Points)-1].At.Sub(first) > Keep { + t.Fatalf("kept more than %s", Keep) + } + n := len(s.Points) + s.Append(Point{At: s.Points[n-1].At.Add(time.Second)}) + if len(s.Points) != n { + t.Fatal("a point closer than Every was kept") + } +} diff --git a/skills/tui/internal/host/host.go b/skills/tui/internal/host/host.go new file mode 100644 index 0000000..fd7833d --- /dev/null +++ b/skills/tui/internal/host/host.go @@ -0,0 +1,204 @@ +// Package host reads the machine through gopsutil, for systems without a +// Linux /proc (macOS, Windows). It returns the procfs types so the sampler +// does not care which one it reads. +package host + +import ( + "os" + "strings" + "time" + + "github.com/shirou/gopsutil/v4/cpu" + "github.com/shirou/gopsutil/v4/disk" + "github.com/shirou/gopsutil/v4/load" + "github.com/shirou/gopsutil/v4/mem" + "github.com/shirou/gopsutil/v4/net" + "github.com/shirou/gopsutil/v4/process" + + "github.com/Lutherwaves/builder/skills/tui/internal/procfs" +) + +// Host counts process start times in ticks since the Unix epoch, and Uptime +// is seconds since the epoch, so ages work out as they do from /proc. +type Host struct{} + +var _ procfs.Host = Host{} + +const tick = float64(procfs.ClockTicks) + +func (Host) Procs() (map[int]procfs.Proc, error) { + ps, err := process.Processes() + if err != nil { + return nil, err + } + out := make(map[int]procfs.Proc, len(ps)) + for _, p := range ps { + if pr, ok := read(p); ok { + out[pr.PID] = pr + } + } + return out, nil +} + +func (Host) Proc(pid int) (procfs.Proc, bool) { + p, err := process.NewProcess(int32(pid)) + if err != nil { + return procfs.Proc{}, false + } + return read(p) +} + +// read skips a process that exits or denies access while it is read. +func read(p *process.Process) (procfs.Proc, bool) { + name, err := p.Name() + if err != nil { + return procfs.Proc{}, false + } + created, err := p.CreateTime() + if err != nil { + return procfs.Proc{}, false + } + pr := procfs.Proc{ + PID: int(p.Pid), + Comm: strings.TrimSuffix(name, ".exe"), + StartTime: uint64(created) / (1000 / procfs.ClockTicks), + State: 'S', + } + if ppid, err := p.Ppid(); err == nil { + pr.PPID = int(ppid) + } + if t, err := p.Times(); err == nil { + pr.Ticks = uint64((t.User + t.System) * tick) + } + if m, err := p.MemoryInfo(); err == nil { + pr.RSSPages = int64(m.RSS) / int64(os.Getpagesize()) + } + if st, err := p.Status(); err == nil && len(st) > 0 && st[0] == process.Running { + pr.State = 'R' + } + return pr, true +} + +func (Host) Environ(pid int, key string) (string, bool) { + p, err := process.NewProcess(int32(pid)) + if err != nil { + return "", false + } + env, err := p.Environ() + if err != nil { + return "", false + } + for _, kv := range env { + if v, ok := strings.CutPrefix(kv, key+"="); ok { + return v, true + } + } + return "", false +} + +func (Host) Cwd(pid int) string { + if p, err := process.NewProcess(int32(pid)); err == nil { + s, _ := p.Cwd() + return s + } + return "" +} + +func (Host) Cmdline(pid int) string { + if p, err := process.NewProcess(int32(pid)); err == nil { + s, _ := p.Cmdline() + return s + } + return "" +} + +// Cgroup is Linux only: containers run inside a VM on other systems. +func (Host) Cgroup(int) string { return "" } + +func (Host) UID(pid int) (int, bool) { + p, err := process.NewProcess(int32(pid)) + if err != nil { + return 0, false + } + uids, err := p.Uids() + if err != nil || len(uids) == 0 { + return 0, false + } + return int(uids[0]), true +} + +func (Host) Uptime() float64 { return float64(time.Now().UnixMilli()) / 1000 } + +func (Host) Stat() (procfs.CPUTimes, []procfs.CPUTimes, error) { + all, err := cpu.Times(false) + if err != nil || len(all) == 0 { + return procfs.CPUTimes{}, nil, err + } + per, _ := cpu.Times(true) + cores := make([]procfs.CPUTimes, len(per)) + for i, c := range per { + cores[i] = times(c) + } + return times(all[0]), cores, nil +} + +func times(c cpu.TimesStat) procfs.CPUTimes { + idle := c.Idle + c.Iowait + total := c.User + c.System + c.Nice + c.Irq + c.Softirq + c.Steal + idle + return procfs.CPUTimes{Busy: uint64((total - idle) * tick), Total: uint64(total * tick)} +} + +func (Host) Loadavg() ([3]float64, error) { + a, err := load.Avg() + if err != nil { + return [3]float64{}, err + } + return [3]float64{a.Load1, a.Load5, a.Load15}, nil +} + +func (Host) Meminfo() (procfs.Mem, error) { + v, err := mem.VirtualMemory() + if err != nil { + return procfs.Mem{}, err + } + m := procfs.Mem{Total: v.Total, Available: v.Available} + if s, err := mem.SwapMemory(); err == nil { + m.SwapTotal, m.SwapFree = s.Total, s.Free + } + return m, nil +} + +// Pressure stall information is Linux only. +func (Host) Pressure() procfs.PSI { return procfs.PSI{} } + +func (Host) NetBytes() (rx, tx uint64, err error) { + ifs, err := net.IOCounters(true) + if err != nil { + return 0, 0, err + } + for _, i := range ifs { + if loopback(i.Name) { + continue + } + rx += i.BytesRecv + tx += i.BytesSent + } + return rx, tx, nil +} + +func loopback(name string) bool { + n := strings.ToLower(name) + return n == "lo" || n == "lo0" || strings.Contains(n, "loopback") +} + +func (Host) DiskBytes() (read, written uint64, err error) { + ds, err := disk.IOCounters() + if err != nil { + return 0, 0, err + } + for _, d := range ds { + read += d.ReadBytes + written += d.WriteBytes + } + return read, written, nil +} diff --git a/skills/tui/internal/host/host_test.go b/skills/tui/internal/host/host_test.go new file mode 100644 index 0000000..92ef20b --- /dev/null +++ b/skills/tui/internal/host/host_test.go @@ -0,0 +1,55 @@ +package host + +import ( + "math" + "os" + "runtime" + "testing" + + "github.com/Lutherwaves/builder/skills/tui/internal/procfs" +) + +// On Linux the gopsutil reader must agree with /proc, so what it shows on +// other systems means the same thing. +func TestAgreesWithProc(t *testing.T) { + if runtime.GOOS != "linux" { + t.Skip("/proc is Linux only") + } + fs, h := procfs.FS{Root: "/proc"}, Host{} + pid := os.Getpid() + want, ok := fs.Proc(pid) + got, ok2 := h.Proc(pid) + if !ok || !ok2 { + t.Fatalf("read self: proc %v host %v", ok, ok2) + } + if got.PPID != want.PPID || got.Comm != want.Comm { + t.Fatalf("host %+v, proc %+v", got, want) + } + age := func(f procfs.Host, p procfs.Proc) float64 { return f.Uptime() - float64(p.StartTime)/procfs.ClockTicks } + if d := math.Abs(age(h, got) - age(fs, want)); d > 2 { + t.Fatalf("process ages differ by %.1f s", d) + } + if h.Cwd(pid) != fs.Cwd(pid) || h.Cmdline(pid) != fs.Cmdline(pid) { + t.Fatalf("cwd or cmdline differ: %q %q", h.Cwd(pid), h.Cmdline(pid)) + } + if v, ok := h.Environ(pid, "PATH"); !ok || v != os.Getenv("PATH") { + t.Fatalf("environ PATH = %q %v", v, ok) + } + if u, ok := h.UID(pid); !ok || u != os.Getuid() { + t.Fatalf("uid = %d %v", u, ok) + } + _, wantCores, err := fs.Stat() + all, cores, err2 := h.Stat() + if err != nil || err2 != nil || len(cores) != len(wantCores) || all.Total == 0 || all.Busy > all.Total { + t.Fatalf("stat: %d cores vs %d, %+v (%v %v)", len(cores), len(wantCores), all, err, err2) + } + wm, _ := fs.Meminfo() + hm, err := h.Meminfo() + if err != nil || hm.Total != wm.Total { + t.Fatalf("mem total %d vs %d (%v)", hm.Total, wm.Total, err) + } + procs, err := h.Procs() + if err != nil || procs[pid].PID != pid { + t.Fatalf("Procs misses self (%d processes, %v)", len(procs), err) + } +} diff --git a/skills/tui/internal/procfs/host.go b/skills/tui/internal/procfs/host.go new file mode 100644 index 0000000..abdea7b --- /dev/null +++ b/skills/tui/internal/procfs/host.go @@ -0,0 +1,25 @@ +package procfs + +// Host is what the sampler reads about the machine and its processes. FS +// implements it from /proc on Linux and over fixtures; other systems get an +// implementation from their own APIs. +type Host interface { + Procs() (map[int]Proc, error) + Proc(pid int) (Proc, bool) + Environ(pid int, key string) (string, bool) + Cwd(pid int) string + Cmdline(pid int) string + Cgroup(pid int) string + UID(pid int) (int, bool) + // Uptime is in seconds on the same clock as Proc.StartTime, so + // Uptime() - StartTime/ClockTicks is a process's age. + Uptime() float64 + Stat() (all CPUTimes, cores []CPUTimes, err error) + Loadavg() ([3]float64, error) + Meminfo() (Mem, error) + Pressure() PSI + NetBytes() (rx, tx uint64, err error) + DiskBytes() (read, written uint64, err error) +} + +var _ Host = FS{} diff --git a/skills/tui/internal/procfs/machine.go b/skills/tui/internal/procfs/machine.go new file mode 100644 index 0000000..f851293 --- /dev/null +++ b/skills/tui/internal/procfs/machine.go @@ -0,0 +1,161 @@ +package procfs + +import ( + "bufio" + "os" + "strconv" + "strings" +) + +// Mem is /proc/meminfo in bytes. +type Mem struct{ Total, Available, SwapTotal, SwapFree uint64 } + +func (fs FS) Meminfo() (Mem, error) { + f, err := os.Open(fs.path("meminfo")) + if err != nil { + return Mem{}, err + } + defer f.Close() + var m Mem + sc := bufio.NewScanner(f) + for sc.Scan() { + k, rest, ok := strings.Cut(sc.Text(), ":") + if !ok { + continue + } + fields := strings.Fields(rest) + if len(fields) == 0 { + continue + } + kb, _ := strconv.ParseUint(fields[0], 10, 64) + switch k { + case "MemTotal": + m.Total = kb << 10 + case "MemAvailable": + m.Available = kb << 10 + case "SwapTotal": + m.SwapTotal = kb << 10 + case "SwapFree": + m.SwapFree = kb << 10 + } + } + return m, sc.Err() +} + +// Loadavg returns load1, load5, load15. +func (fs FS) Loadavg() (l [3]float64, err error) { + b, err := os.ReadFile(fs.path("loadavg")) + if err != nil { + return l, err + } + f := strings.Fields(string(b)) + for i := 0; i < 3 && i < len(f); i++ { + l[i], _ = strconv.ParseFloat(f[i], 64) + } + return l, nil +} + +// NetBytes sums rx/tx bytes over physical-looking interfaces. Loopback and +// virtual links (containers, bridges, tunnels) are skipped so container +// traffic is not counted twice. +func (fs FS) NetBytes() (rx, tx uint64, err error) { + f, err := os.Open(fs.path("net", "dev")) + if err != nil { + return 0, 0, err + } + defer f.Close() + sc := bufio.NewScanner(f) + for sc.Scan() { + name, rest, ok := strings.Cut(sc.Text(), ":") + if !ok { + continue + } + name = strings.TrimSpace(name) + if virtualIface(name) { + continue + } + fields := strings.Fields(rest) + if len(fields) < 9 { + continue + } + r, _ := strconv.ParseUint(fields[0], 10, 64) + t, _ := strconv.ParseUint(fields[8], 10, 64) + rx += r + tx += t + } + return rx, tx, sc.Err() +} + +func virtualIface(name string) bool { + for _, p := range []string{"lo", "veth", "docker", "br-", "virbr", "tailscale", "tun", "wg", "cni", "flannel"} { + if strings.HasPrefix(name, p) { + return true + } + } + return false +} + +// DiskBytes sums sectors read/written on whole disks (partitions and virtual +// devices are skipped so nothing is counted twice). +func (fs FS) DiskBytes() (read, written uint64, err error) { + f, err := os.Open(fs.path("diskstats")) + if err != nil { + return 0, 0, err + } + defer f.Close() + sc := bufio.NewScanner(f) + for sc.Scan() { + fields := strings.Fields(sc.Text()) + if len(fields) < 10 || !wholeDisk(fields[2]) { + continue + } + r, _ := strconv.ParseUint(fields[5], 10, 64) + w, _ := strconv.ParseUint(fields[9], 10, 64) + read += r * 512 + written += w * 512 + } + return read, written, sc.Err() +} + +func wholeDisk(name string) bool { + switch { + case strings.HasPrefix(name, "nvme"): + return !strings.Contains(name, "p") // nvme0n1, not nvme0n1p1 + case strings.HasPrefix(name, "sd"), strings.HasPrefix(name, "vd"), strings.HasPrefix(name, "xvd"): + last := name[len(name)-1] + return last < '0' || last > '9' + case strings.HasPrefix(name, "mmcblk"): + return !strings.Contains(name, "p") + } + return false +} + +// PSI is the "some avg10" pressure for cpu, io and memory, in percent. +type PSI struct{ CPU, IO, Memory float64 } + +func (fs FS) Pressure() PSI { + return PSI{ + CPU: fs.psiSome("cpu"), + IO: fs.psiSome("io"), + Memory: fs.psiSome("memory"), + } +} + +func (fs FS) psiSome(res string) float64 { + b, err := os.ReadFile(fs.path("pressure", res)) + if err != nil { + return 0 + } + for _, line := range strings.Split(string(b), "\n") { + if !strings.HasPrefix(line, "some ") { + continue + } + for _, kv := range strings.Fields(line)[1:] { + if v, ok := strings.CutPrefix(kv, "avg10="); ok { + f, _ := strconv.ParseFloat(v, 64) + return f + } + } + } + return 0 +} diff --git a/skills/tui/internal/procfs/proc.go b/skills/tui/internal/procfs/proc.go new file mode 100644 index 0000000..f6b218a --- /dev/null +++ b/skills/tui/internal/procfs/proc.go @@ -0,0 +1,185 @@ +// Package procfs reads the Linux /proc files the cockpit needs, rooted at a +// configurable directory so tests can run against fixtures. +package procfs + +import ( + "bufio" + "bytes" + "os" + "path/filepath" + "strconv" + "strings" + "syscall" +) + +// ClockTicks is USER_HZ. It is 100 on every mainstream Linux build; reading it +// properly needs cgo (sysconf), which would break the static binary. +const ClockTicks = 100 + +type FS struct{ Root string } + +func (fs FS) path(parts ...string) string { + return filepath.Join(append([]string{fs.Root}, parts...)...) +} + +// Proc is one line of /proc//stat, reduced to what the cockpit uses. +type Proc struct { + PID int + PPID int + Comm string + State byte + Ticks uint64 // utime + stime + StartTime uint64 // clock ticks after boot + RSSPages int64 +} + +// Procs returns every process that can be read. Processes that exit between +// the directory listing and the read are skipped. +func (fs FS) Procs() (map[int]Proc, error) { + dir, err := os.Open(fs.path()) + if err != nil { + return nil, err + } + names, err := dir.Readdirnames(-1) + dir.Close() + if err != nil { + return nil, err + } + // This runs every tick over every process, so it skips os.ReadFile's + // fstat and allocation: one open, one read into a reused buffer, close. + buf := make([]byte, 1024) + out := make(map[int]Proc, len(names)) + for _, name := range names { + pid, err := strconv.Atoi(name) + if err != nil { + continue + } + n, ok := readSmall(fs.path(name, "stat"), buf) + if !ok { + continue + } + if p, ok := parseStat(pid, buf[:n]); ok { + out[pid] = p + } + } + return out, nil +} + +// Proc reads one process. +func (fs FS) Proc(pid int) (Proc, bool) { + buf := make([]byte, 1024) + n, ok := readSmall(fs.path(strconv.Itoa(pid), "stat"), buf) + if !ok { + return Proc{}, false + } + return parseStat(pid, buf[:n]) +} + +func readSmall(path string, buf []byte) (int, bool) { + fd, err := syscall.Open(path, syscall.O_RDONLY|syscall.O_CLOEXEC, 0) + if err != nil { + return 0, false + } + defer syscall.Close(fd) + n, err := syscall.Read(fd, buf) + return n, err == nil && n > 0 +} + +func parseStat(pid int, b []byte) (Proc, bool) { + // comm may contain spaces and parentheses, so split on the last ')'. + open, end := bytes.IndexByte(b, '('), bytes.LastIndexByte(b, ')') + if open < 0 || end < open { + return Proc{}, false + } + f := strings.Fields(string(b[end+1:])) + if len(f) < 22 { + return Proc{}, false + } + ppid, _ := strconv.Atoi(f[1]) + ut, _ := strconv.ParseUint(f[11], 10, 64) + st, _ := strconv.ParseUint(f[12], 10, 64) + start, _ := strconv.ParseUint(f[19], 10, 64) + rss, _ := strconv.ParseInt(f[21], 10, 64) + return Proc{ + PID: pid, PPID: ppid, Comm: string(b[open+1 : end]), State: f[0][0], + Ticks: ut + st, StartTime: start, RSSPages: rss, + }, true +} + +// Environ returns one variable from /proc//environ. +func (fs FS) Environ(pid int, key string) (string, bool) { + b, err := os.ReadFile(fs.path(strconv.Itoa(pid), "environ")) + if err != nil { + return "", false + } + prefix := []byte(key + "=") + for _, kv := range bytes.Split(b, []byte{0}) { + if bytes.HasPrefix(kv, prefix) { + return string(kv[len(prefix):]), true + } + } + return "", false +} + +// Cwd returns the target of /proc//cwd. +func (fs FS) Cwd(pid int) string { + s, _ := os.Readlink(fs.path(strconv.Itoa(pid), "cwd")) + return s +} + +// Cmdline returns argv joined by spaces. +func (fs FS) Cmdline(pid int) string { + b, _ := os.ReadFile(fs.path(strconv.Itoa(pid), "cmdline")) + return strings.TrimSpace(string(bytes.ReplaceAll(b, []byte{0}, []byte{' '}))) +} + +// CPUTimes is the aggregate "cpu" line of /proc/stat. +type CPUTimes struct{ Busy, Total uint64 } + +// Stat returns the aggregate line followed by one entry per core. +func (fs FS) Stat() (all CPUTimes, cores []CPUTimes, err error) { + f, err := os.Open(fs.path("stat")) + if err != nil { + return all, nil, err + } + defer f.Close() + sc := bufio.NewScanner(f) + for sc.Scan() { + line := sc.Text() + if !strings.HasPrefix(line, "cpu") { + break + } + fields := strings.Fields(line) + var t CPUTimes + for i, v := range fields[1:] { + n, _ := strconv.ParseUint(v, 10, 64) + if i >= 8 { // guest time is already counted in user/nice + break + } + t.Total += n + if i != 3 && i != 4 { // idle, iowait + t.Busy += n + } + } + if fields[0] == "cpu" { + all = t + } else { + cores = append(cores, t) + } + } + return all, cores, sc.Err() +} + +// Uptime returns seconds since boot. +func (fs FS) Uptime() float64 { + b, err := os.ReadFile(fs.path("uptime")) + if err != nil { + return 0 + } + f := strings.Fields(string(b)) + if len(f) == 0 { + return 0 + } + v, _ := strconv.ParseFloat(f[0], 64) + return v +} diff --git a/skills/tui/internal/procfs/proc_test.go b/skills/tui/internal/procfs/proc_test.go new file mode 100644 index 0000000..cb560c9 --- /dev/null +++ b/skills/tui/internal/procfs/proc_test.go @@ -0,0 +1,105 @@ +package procfs + +import "testing" + +const fixture = "../../testdata/machine/proc" + +func TestParseStatCommWithSpacesAndParens(t *testing.T) { + line := []byte("42 (tmux: server (1)) S 7 42 42 0 -1 0 0 0 0 0 30 12 0 0 20 0 1 0 555 1000 77") + p, ok := parseStat(42, line) + if !ok { + t.Fatal("not parsed") + } + if p.Comm != "tmux: server (1)" || p.PPID != 7 || p.Ticks != 42 || p.StartTime != 555 || p.RSSPages != 77 || p.State != 'S' { + t.Fatalf("got %+v", p) + } +} + +func TestProcs(t *testing.T) { + procs, err := FS{fixture}.Procs() + if err != nil { + t.Fatal(err) + } + if got := procs[310]; got.Comm != "claude" || got.PPID != 300 { + t.Fatalf("310 = %+v", got) + } + if len(procs) != 11 { + t.Fatalf("want 11 procs, got %d", len(procs)) + } +} + +func TestEnvironAndCwd(t *testing.T) { + fs := FS{fixture} + if v, ok := fs.Environ(400, "TMUX_PANE"); !ok || v != "%3" { + t.Fatalf("TMUX_PANE = %q %v", v, ok) + } + if _, ok := fs.Environ(1, "TMUX_PANE"); ok { + t.Fatal("pid 1 has no TMUX_PANE") + } + if got := fs.Cwd(200); got != "/work/app" { + t.Fatalf("cwd = %q", got) + } +} + +func TestMachineFiles(t *testing.T) { + fs := FS{fixture} + all, cores, err := fs.Stat() + if err != nil || len(cores) != 2 || all.Total != 9600 || all.Busy != 1500 { + t.Fatalf("stat = %+v %d %v", all, len(cores), err) + } + m, err := fs.Meminfo() + if err != nil || m.Available != 8192000<<10 || m.SwapTotal-m.SwapFree != 1024000<<10 { + t.Fatalf("meminfo = %+v %v", m, err) + } + rx, tx, err := fs.NetBytes() + if err != nil || rx != 1000 || tx != 2000 { + t.Fatalf("net = %d %d %v (loopback and veth must be skipped)", rx, tx, err) + } + r, w, err := fs.DiskBytes() + if err != nil || r != 200*512 || w != 400*512 { + t.Fatalf("disk = %d %d %v (partitions must be skipped)", r, w, err) + } + if p := fs.Pressure(); p.CPU != 12.5 || p.IO != 12.5 { + t.Fatalf("psi = %+v", p) + } + if l, _ := fs.Loadavg(); l[0] != 3.5 { + t.Fatalf("load = %v", l) + } +} + +func TestWholeDisk(t *testing.T) { + for name, want := range map[string]bool{ + "nvme0n1": true, "nvme0n1p2": false, "sda": true, "sda1": false, + "vdb": true, "mmcblk0": true, "mmcblk0p1": false, "loop0": false, "dm-0": false, + } { + if got := wholeDisk(name); got != want { + t.Errorf("wholeDisk(%q) = %v", name, got) + } + } +} + +func TestPowerLimitsSkipSubzones(t *testing.T) { + got := Sys{"../../testdata/machine/sys"}.PowerLimits() + if len(got) != 2 || got[0] != (PowerLimit{"package-0", "long_term", 125}) || got[1].Watts != 188 { + t.Fatalf("got %+v", got) + } +} + +func TestPackageTempIgnoresCores(t *testing.T) { + // Core 0 reads 99 °C in the fixture; only the package sensor counts. + if got := (Sys{"../../testdata/machine/sys"}).PackageTemp(); got != 91 { + t.Fatalf("got %v", got) + } +} + +func TestContainerCgroup(t *testing.T) { + s := Sys{"../../testdata/machine/sys"} + id := "aaaabbbbccccddddeeeeffff0000111122223333444455556666777788889999" + dir := s.ContainerCgroup(id) + if u, ok := s.CgroupCPU(dir); !ok || u != 1000000 { + t.Fatalf("dir %q usage %d %v", dir, u, ok) + } + if s.ContainerCgroup("missing") != "" { + t.Fatal("missing container found") + } +} diff --git a/skills/tui/internal/procfs/sys.go b/skills/tui/internal/procfs/sys.go new file mode 100644 index 0000000..d461e84 --- /dev/null +++ b/skills/tui/internal/procfs/sys.go @@ -0,0 +1,169 @@ +package procfs + +import ( + "os" + "path/filepath" + "strconv" + "strings" +) + +// Sys reads /sys, rooted like FS so tests can use fixtures. +type Sys struct{ Root string } + +func (s Sys) path(parts ...string) string { + return filepath.Join(append([]string{s.Root}, parts...)...) +} + +func readTrim(path string) (string, bool) { + b, err := os.ReadFile(path) + if err != nil { + return "", false + } + return strings.TrimSpace(string(b)), true +} + +func readInt(path string) (int64, bool) { + s, ok := readTrim(path) + if !ok { + return 0, false + } + v, err := strconv.ParseInt(s, 10, 64) + return v, err == nil +} + +// PowerLimit is one RAPL constraint of a package zone. +type PowerLimit struct { + Zone string `json:"zone"` // package-0 + Name string `json:"name"` // long_term, short_term + Watts float64 `json:"watts"` +} + +// PowerLimits lists the constraints of every top-level powercap zone +// (packages, not their core/uncore subzones). Readable without root. +func (s Sys) PowerLimits() []PowerLimit { + zones, _ := filepath.Glob(s.path("class", "powercap", "*-rapl:*")) + var out []PowerLimit + for _, z := range zones { + if strings.Count(filepath.Base(z), ":") != 1 { + continue + } + zone, _ := readTrim(filepath.Join(z, "name")) + for i := 0; ; i++ { + c := filepath.Join(z, "constraint_"+strconv.Itoa(i)+"_") + uw, ok := readInt(c + "power_limit_uw") + if !ok { + break + } + name, _ := readTrim(c + "name") + out = append(out, PowerLimit{Zone: zone, Name: name, Watts: float64(uw) / 1e6}) + } + } + return out +} + +// TempSensors lists the files that report a CPU package temperature: +// coretemp/k10temp package sensors, else the x86_pkg_temp thermal zone. +// Finding them walks /sys, so callers keep the list and re-read only these. +func (s Sys) TempSensors() []string { + var out []string + mons, _ := filepath.Glob(s.path("class", "hwmon", "hwmon*")) + for _, h := range mons { + name, _ := readTrim(filepath.Join(h, "name")) + if name != "coretemp" && name != "k10temp" && name != "zenpower" { + continue + } + labels, _ := filepath.Glob(filepath.Join(h, "temp*_label")) + for _, l := range labels { + label, _ := readTrim(l) + if strings.HasPrefix(label, "Package id") || label == "Tctl" || label == "Tdie" { + out = append(out, strings.TrimSuffix(l, "_label")+"_input") + } + } + } + if len(out) > 0 { + return out + } + zones, _ := filepath.Glob(s.path("class", "thermal", "thermal_zone*")) + for _, z := range zones { + if t, _ := readTrim(filepath.Join(z, "type")); t == "x86_pkg_temp" { + out = append(out, filepath.Join(z, "temp")) + } + } + return out +} + +// ReadTemp returns the hottest of the sensor files in °C, 0 when none reads. +func ReadTemp(sensors []string) float64 { + best := 0.0 + for _, f := range sensors { + if mc, ok := readInt(f); ok { + best = max(best, float64(mc)/1000) + } + } + return best +} + +// PackageTemp is the hottest CPU package in °C, or 0 when no sensor is found. +func (s Sys) PackageTemp() float64 { return ReadTemp(s.TempSensors()) } + +// CgroupCPU returns usage_usec from a cgroup v2 directory's cpu.stat. +func (s Sys) CgroupCPU(dir string) (uint64, bool) { + b, err := os.ReadFile(s.path("fs", "cgroup", dir, "cpu.stat")) + if err != nil { + return 0, false + } + for _, line := range strings.Split(string(b), "\n") { + if v, ok := strings.CutPrefix(line, "usage_usec "); ok { + n, err := strconv.ParseUint(v, 10, 64) + return n, err == nil + } + } + return 0, false +} + +// ContainerCgroup finds a container's cgroup under the systemd or the +// cgroupfs driver layout. Empty when neither exists. +func (s Sys) ContainerCgroup(id string) string { + for _, dir := range []string{ + filepath.Join("system.slice", "docker-"+id+".scope"), + filepath.Join("docker", id), + } { + if _, err := os.Stat(s.path("fs", "cgroup", dir, "cpu.stat")); err == nil { + return dir + } + } + return "" +} + +// UID returns the real uid from /proc//status. +func (fs FS) UID(pid int) (int, bool) { + b, err := os.ReadFile(fs.path(strconv.Itoa(pid), "status")) + if err != nil { + return 0, false + } + for _, line := range strings.Split(string(b), "\n") { + if v, ok := strings.CutPrefix(line, "Uid:"); ok { + f := strings.Fields(v) + if len(f) == 0 { + return 0, false + } + uid, err := strconv.Atoi(f[0]) + return uid, err == nil + } + } + return 0, false +} + +// Cgroup returns the cgroup v2 path of a process ("/system.slice/x.scope"). +func (fs FS) Cgroup(pid int) string { + b, err := os.ReadFile(fs.path(strconv.Itoa(pid), "cgroup")) + if err != nil { + return "" + } + for _, line := range strings.Split(string(b), "\n") { + if v, ok := strings.CutPrefix(line, "0::"); ok { + return v + } + } + return "" +} diff --git a/skills/tui/internal/sample/agent.go b/skills/tui/internal/sample/agent.go new file mode 100644 index 0000000..def2dbd --- /dev/null +++ b/skills/tui/internal/sample/agent.go @@ -0,0 +1,238 @@ +package sample + +import ( + "bytes" + "encoding/json" + "io" + "os" + "path/filepath" + "strconv" + "strings" + "time" +) + +// registryEntry is the per-process file Claude Code keeps at +// ~/.claude/sessions/.json. Only the fields the cockpit reads. +type registryEntry struct { + SessionID string `json:"sessionId"` + Name string `json:"name"` + Cwd string `json:"cwd"` + Status string `json:"status"` + StartedAt int64 `json:"startedAt"` + StatusUpdatedAt int64 `json:"statusUpdatedAt"` + Tmux string `json:"tmux"` // session:@window.%pane +} + +// Usage is the context state read from the end of a transcript. +type Usage struct { + Model string `json:"model"` + Tokens int `json:"tokens"` + At time.Time `json:"at"` +} + +type fileKey struct { + size int64 + mtime time.Time +} + +// agentReader caches everything keyed on file size and mtime, so an idle +// session costs one stat per tick and no reads. +type agentReader struct { + home string + registry map[string]cached[registryEntry] + transcripts map[string]string // sessionId -> path + missing map[string]time.Time // sessionId -> when the last search found nothing + usage map[string]cached[Usage] +} + +type cached[T any] struct { + key fileKey + val T +} + +func newAgentReader(home string) *agentReader { + return &agentReader{ + home: home, + registry: map[string]cached[registryEntry]{}, + transcripts: map[string]string{}, + missing: map[string]time.Time{}, + usage: map[string]cached[Usage]{}, + } +} + +func statKey(path string) (fileKey, bool) { + st, err := os.Stat(path) + if err != nil { + return fileKey{}, false + } + return fileKey{st.Size(), st.ModTime()}, true +} + +func (r *agentReader) entry(pid int) (registryEntry, bool) { + path := filepath.Join(r.home, ".claude", "sessions", strconv.Itoa(pid)+".json") + key, ok := statKey(path) + if !ok { + return registryEntry{}, false + } + if c, ok := r.registry[path]; ok && c.key == key { + return c.val, true + } + b, err := os.ReadFile(path) + if err != nil { + return registryEntry{}, false + } + var e registryEntry + if json.Unmarshal(b, &e) != nil { + return registryEntry{}, false + } + r.registry[path] = cached[registryEntry]{key, e} + return e, true +} + +// transcript finds the session's .jsonl: first at the path derived from its +// working directory, then by searching every project. A session that has not +// written one yet is searched again at most every 30 s, because the search +// lists every transcript on the machine. +func (r *agentReader) transcript(sessionID, cwd string) string { + if p, ok := r.transcripts[sessionID]; ok { + return p + } + direct := filepath.Join(r.home, ".claude", "projects", projectDir(cwd), sessionID+".jsonl") + if _, err := os.Stat(direct); err == nil { + r.transcripts[sessionID] = direct + return direct + } + if at, ok := r.missing[sessionID]; ok && time.Since(at) < 30*time.Second { + return "" + } + matches, _ := filepath.Glob(filepath.Join(r.home, ".claude", "projects", "*", sessionID+".jsonl")) + if len(matches) == 0 { + r.missing[sessionID] = time.Now() + return "" + } + delete(r.missing, sessionID) + r.transcripts[sessionID] = matches[0] + return matches[0] +} + +// projectDir is how the agent names a project directory: every character +// outside [A-Za-z0-9] becomes '-'. +func projectDir(cwd string) string { + b := []byte(cwd) + for i, c := range b { + if !(c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9') { + b[i] = '-' + } + } + return string(b) +} + +func (r *agentReader) usageOf(sessionID, cwd string) (Usage, bool) { + path := r.transcript(sessionID, cwd) + if path == "" { + return Usage{}, false + } + key, ok := statKey(path) + if !ok { + delete(r.transcripts, sessionID) + return Usage{}, false + } + if c, ok := r.usage[path]; ok && c.key == key { + return c.val, true + } + u, ok := lastUsage(path, key.size) + if !ok { + return Usage{}, false + } + r.usage[path] = cached[Usage]{key, u} + return u, true +} + +// tailBytes bounds the read: a transcript can be hundreds of megabytes and the +// last assistant turn is always near the end. +const tailBytes = 512 << 10 + +type transcriptLine struct { + Type string `json:"type"` + IsSidechain bool `json:"isSidechain"` + Timestamp time.Time `json:"timestamp"` + Message struct { + Model string `json:"model"` + Usage *struct { + Input int `json:"input_tokens"` + CacheRead int `json:"cache_read_input_tokens"` + CacheCreation int `json:"cache_creation_input_tokens"` + Output int `json:"output_tokens"` + } `json:"usage"` + } `json:"message"` +} + +func lastUsage(path string, size int64) (Usage, bool) { + f, err := os.Open(path) + if err != nil { + return Usage{}, false + } + defer f.Close() + off := max(size-tailBytes, 0) + buf := make([]byte, size-off) + if _, err := f.ReadAt(buf, off); err != nil && err != io.EOF { + return Usage{}, false + } + lines := bytes.Split(buf, []byte{'\n'}) + for i := len(lines) - 1; i >= 0; i-- { + l := lines[i] + if !bytes.Contains(l, []byte(`"usage"`)) || !bytes.Contains(l, []byte(`"assistant"`)) { + continue + } + var t transcriptLine + // turns are local messages that report zero usage. + if json.Unmarshal(l, &t) != nil || t.Type != "assistant" || t.IsSidechain || t.Message.Usage == nil || t.Message.Model == "" { + continue + } + u := t.Message.Usage + return Usage{ + Model: t.Message.Model, + Tokens: u.Input + u.CacheRead + u.CacheCreation + u.Output, + At: t.Timestamp, + }, true + } + return Usage{}, false +} + +// modelSetting is the model a Claude Code process was started with, in the +// order Claude Code resolves it: the --model flag, ANTHROPIC_MODEL, then the +// project's local and shared settings and the user's settings. A change made +// with /model inside the session is not visible here. +func modelSetting(cmdline string, env func(string) (string, bool), cwd, home string) string { + args := strings.Fields(cmdline) + for i, a := range args { + if v, ok := strings.CutPrefix(a, "--model="); ok { + return v + } + if a == "--model" && i+1 < len(args) { + return args[i+1] + } + } + if v, ok := env("ANTHROPIC_MODEL"); ok && v != "" { + return v + } + for _, f := range []string{ + filepath.Join(cwd, ".claude", "settings.local.json"), + filepath.Join(cwd, ".claude", "settings.json"), + filepath.Join(home, ".claude", "settings.json"), + } { + var s struct { + Model string `json:"model"` + } + if b, err := os.ReadFile(f); err == nil && json.Unmarshal(b, &s) == nil && s.Model != "" { + return s.Model + } + } + return "" +} + +// longContext reports a model setting that asks for the 1M window, such as +// "opus[1m]". The transcript names the model without that suffix. +func longContext(model string) bool { + return strings.HasSuffix(strings.ToLower(model), "[1m]") +} diff --git a/skills/tui/internal/sample/alerts.go b/skills/tui/internal/sample/alerts.go new file mode 100644 index 0000000..8a5d925 --- /dev/null +++ b/skills/tui/internal/sample/alerts.go @@ -0,0 +1,92 @@ +package sample + +import ( + "fmt" + "math" + "time" + + "github.com/Lutherwaves/builder/skills/tui/internal/procfs" +) + +type Alert struct { + Key string `json:"key"` + Level string `json:"level"` // warn | crit + Title string `json:"title"` + Evidence string `json:"evidence"` + Since time.Time `json:"since"` +} + +// alerts evaluates the configured thresholds. A threshold with a "for" +// duration fires only once its condition has held that long. +func (s *Sampler) alerts(snap *Snapshot, now time.Time) { + a, m := s.Cfg.Alerts, snap.Machine + held := map[string]time.Time{} + add := func(key, level, title, evidence string, cond bool, wait time.Duration) { + if !cond { + return + } + since, ok := s.condSince[key] + if !ok { + since = now + } + held[key] = since + if now.Sub(since) >= wait { + snap.Alerts = append(snap.Alerts, Alert{key, level, title, evidence, since}) + } + } + const gb = 1 << 30 + + add("temp", "crit", "CPU package hot", + fmt.Sprintf("package %.0f °C, limit %.0f °C", m.TempC, a.TempOverC), + a.TempOverC > 0 && m.TempC > a.TempOverC, 0) + add("load1", "warn", "Load stays high", + fmt.Sprintf("load1 %.1f over %.0f for %s", m.Load[0], a.Load1Over, Human(a.Load1For.Duration)), + a.Load1Over > 0 && m.Load[0] > a.Load1Over, a.Load1For.Duration) + add("psi-cpu", "warn", "CPU pressure stays high", + fmt.Sprintf("PSI cpu some avg10 %.0f%% over %.0f%% for %s", m.PSI.CPU, a.PSICPUOver, Human(a.PSICPUFor.Duration)), + a.PSICPUOver > 0 && m.PSI.CPU > a.PSICPUOver, a.PSICPUFor.Duration) + avail, swap := float64(m.MemAvail)/gb, float64(m.SwapUsed)/gb + add("mem-low", "crit", "Memory almost gone", + fmt.Sprintf("available %.1f GB, under %.0f GB", avail, a.MemAvailableUnderGB), + a.MemAvailableUnderGB > 0 && m.MemTotal > 0 && avail < a.MemAvailableUnderGB, 0) + add("swap-stuck", "warn", "Swap held while RAM is free", + fmt.Sprintf("swap %.1f GB over %.0f GB while %.1f GB is available", swap, a.SwapOverGB, avail), + a.SwapOverGB > 0 && swap > a.SwapOverGB && avail > a.SwapWhenAvailableOverGB, 0) + + for _, d := range s.powerDrift(m.Power) { + add("power-"+d.key, "crit", "CPU power limit changed", d.evidence, true, 0) + } + s.condSince = held +} + +type drift struct{ key, evidence string } + +// powerDrift compares each limit with the configured value (long term, short +// term, by constraint order) or, when none is configured, with its value at +// the first sample. +func (s *Sampler) powerDrift(limits []procfs.PowerLimit) []drift { + if s.powerBase == nil { + s.powerBase = map[string]float64{} + } + for _, l := range limits { + // A zone first seen now (a driver loaded late) starts its baseline now. + if _, ok := s.powerBase[l.Zone+"/"+l.Name]; !ok { + s.powerBase[l.Zone+"/"+l.Name] = l.Watts + } + } + var out []drift + idx := map[string]int{} + for _, l := range limits { + key := l.Zone + "/" + l.Name + i := idx[l.Zone] + idx[l.Zone]++ + want, from := s.powerBase[key], "at startup" + if exp := s.Cfg.Alerts.PowerLimitsW; i < len(exp) { + want, from = exp[i], "expected" + } + if math.Abs(l.Watts-want) >= 1 { + out = append(out, drift{key, fmt.Sprintf("%s %s is %.0f W, %s %.0f W", l.Zone, l.Name, l.Watts, from, want)}) + } + } + return out +} diff --git a/skills/tui/internal/sample/hardware.go b/skills/tui/internal/sample/hardware.go new file mode 100644 index 0000000..baf9c68 --- /dev/null +++ b/skills/tui/internal/sample/hardware.go @@ -0,0 +1,128 @@ +package sample + +import ( + "os/exec" + "sort" + "strconv" + "strings" + "time" +) + +type GPU struct { + Index int `json:"index"` + Util float64 `json:"util_pct"` + TempC float64 `json:"temp_c"` + MemUsed float64 `json:"mem_used_mib"` + MemTot float64 `json:"mem_total_mib"` +} + +type Container struct { + ID string `json:"id"` + Name string `json:"name"` + CPU float64 `json:"cpu_pct"` // 100 = one core +} + +// GroupShare is the CPU of the containers a configured group matches. +type GroupShare struct { + Name string `json:"name"` + Containers int `json:"containers"` + CPU float64 `json:"cpu_pct"` // 100 = one core + Share float64 `json:"share_pct"` // of the whole machine +} + +// slowEvery is the cadence of the external commands (nvidia-smi, docker): +// each costs tens of milliseconds of CPU, far more than reading /proc. +const slowEvery = 60 * time.Second + +func NvidiaSMI() ([]byte, error) { + if _, err := exec.LookPath("nvidia-smi"); err != nil { + return nil, nil + } + return exec.Command("nvidia-smi", "--query-gpu=index,utilization.gpu,temperature.gpu,memory.used,memory.total", + "--format=csv,noheader,nounits").Output() +} + +func DockerPS() ([]byte, error) { + if _, err := exec.LookPath("docker"); err != nil { + return nil, nil + } + return exec.Command("docker", "ps", "--no-trunc", "--format", "{{.ID}}\t{{.Names}}").Output() +} + +func parseGPUs(b []byte) []GPU { + var out []GPU + for _, line := range strings.Split(strings.TrimSpace(string(b)), "\n") { + f := strings.Split(line, ",") + if len(f) < 5 { + continue + } + num := func(i int) float64 { v, _ := strconv.ParseFloat(strings.TrimSpace(f[i]), 64); return v } + out = append(out, GPU{Index: int(num(0)), Util: num(1), TempC: num(2), MemUsed: num(3), MemTot: num(4)}) + } + return out +} + +type containerRef struct{ id, name, cgroup string } + +func (s *Sampler) sampleGPUs(now time.Time) []GPU { + if s.GPU == nil || now.Sub(s.gpuAt) < slowEvery { + return s.gpus + } + s.gpuAt = now + b, err := s.GPU() + if err != nil { + return nil + } + s.gpus = parseGPUs(b) + return s.gpus +} + +// sampleContainers lists containers on the slow cadence and reads their +// cgroup CPU counters on every process scan. +func (s *Sampler) sampleContainers(now time.Time, dt float64, cores int) ([]Container, []GroupShare) { + if s.Docker != nil && now.Sub(s.dockerAt) >= slowEvery { + s.dockerAt = now + if b, err := s.Docker(); err == nil { + s.containers = s.containers[:0] + for _, line := range strings.Split(strings.TrimSpace(string(b)), "\n") { + id, name, ok := strings.Cut(line, "\t") + if !ok { + continue + } + s.containers = append(s.containers, containerRef{id, name, s.Sys.ContainerCgroup(id)}) + } + } + } + var out []Container + usage := map[string]uint64{} + for _, c := range s.containers { + u, ok := s.Sys.CgroupCPU(c.cgroup) + if c.cgroup == "" || !ok { + continue + } + usage[c.id] = u + cpu := 0.0 + if prev, ok := s.prevCgroup[c.id]; ok && dt > 0 && u >= prev { + cpu = 100 * float64(u-prev) / 1e6 / dt + } + out = append(out, Container{ID: c.id[:min(12, len(c.id))], Name: c.name, CPU: cpu}) + } + s.prevCgroup = usage + sort.Slice(out, func(i, j int) bool { return out[i].CPU > out[j].CPU }) + + var groups []GroupShare + for _, g := range s.Cfg.Groups { + gs := GroupShare{Name: g.Name} + for _, c := range out { + if g.Match(c.Name) { + gs.Containers++ + gs.CPU += c.CPU + } + } + if cores > 0 { + gs.Share = gs.CPU / float64(cores) + } + groups = append(groups, gs) + } + return out, groups +} diff --git a/skills/tui/internal/sample/live_bench_test.go b/skills/tui/internal/sample/live_bench_test.go new file mode 100644 index 0000000..0e6d968 --- /dev/null +++ b/skills/tui/internal/sample/live_bench_test.go @@ -0,0 +1,22 @@ +package sample + +import ( + "os" + "testing" + + "github.com/Lutherwaves/builder/skills/tui/internal/procfs" +) + +// BenchmarkLiveSample measures one sample of this machine. Opt-in: LIVE=1. +func BenchmarkLiveSample(b *testing.B) { + if os.Getenv("LIVE") == "" { + b.Skip("set LIVE=1 to sample this machine") + } + home, _ := os.UserHomeDir() + s := &Sampler{FS: procfs.FS{Root: "/proc"}, Home: home, Rules: DefaultRules} + s.Sample() + b.ResetTimer() + for i := 0; i < b.N; i++ { + s.Sample() + } +} diff --git a/skills/tui/internal/sample/polling.go b/skills/tui/internal/sample/polling.go new file mode 100644 index 0000000..7a25ecf --- /dev/null +++ b/skills/tui/internal/sample/polling.go @@ -0,0 +1,64 @@ +package sample + +import ( + "sort" + "time" + + "github.com/Lutherwaves/builder/skills/tui/internal/gitscan" + "github.com/Lutherwaves/builder/skills/tui/internal/procfs" +) + +// GitPolling estimates the CPU agents spend running git in one checkout. +// Every process scan counts the git processes on a CPU in each checkout; +// the mean over the window approximates cores spent, short-lived calls +// included, without tracing anything. +type GitPolling struct { + Checkout string `json:"checkout"` + Cores float64 `json:"cores"` + Samples int `json:"samples"` +} + +const pollWindow = 10 * time.Minute + +type pollPoint struct { + at time.Time + counts map[string]int +} + +func (s *Sampler) samplePolling(procs map[int]procfs.Proc, now time.Time) []GitPolling { + counts := map[string]int{} + for pid, p := range procs { + if p.Comm != "git" || (p.State != 'R' && p.State != 'D') { + continue + } + cwd := s.FS.Cwd(pid) + top, ok := s.toplevel[cwd] + if !ok { + top = gitscan.Toplevel(cwd) + s.toplevel[cwd] = top + } + if top != "" { + counts[top]++ + } + } + s.polls = append(s.polls, pollPoint{now, counts}) + for len(s.polls) > 1 && now.Sub(s.polls[0].at) > pollWindow { + s.polls = s.polls[1:] + } + if len(s.toplevel) > 4096 { + s.toplevel = map[string]string{} + } + + sum := map[string]int{} + for _, pt := range s.polls { + for top, n := range pt.counts { + sum[top] += n + } + } + var out []GitPolling + for top, n := range sum { + out = append(out, GitPolling{Checkout: top, Cores: float64(n) / float64(len(s.polls)), Samples: len(s.polls)}) + } + sort.Slice(out, func(i, j int) bool { return out[i].Cores > out[j].Cores }) + return out +} diff --git a/skills/tui/internal/sample/procs.go b/skills/tui/internal/sample/procs.go new file mode 100644 index 0000000..a8d6c8d --- /dev/null +++ b/skills/tui/internal/sample/procs.go @@ -0,0 +1,160 @@ +package sample + +import ( + "os" + "sort" + "strconv" + "strings" + "time" + + "github.com/Lutherwaves/builder/skills/tui/internal/procfs" +) + +// ProcRow is one process with the owner its CPU is attributed to. +type ProcRow struct { + PID int `json:"pid"` + Comm string `json:"comm"` + CPU float64 `json:"cpu_pct"` // 100 = one core + RSS uint64 `json:"rss_bytes"` + Owner string `json:"owner"` // "pane %3", "container ci-runner-1", "system" +} + +// Orphan is a process that outlived whoever started it and keeps burning CPU. +type Orphan struct { + PID int `json:"pid"` + Start uint64 `json:"start_ticks"` // /proc//stat field 22: tells a reused pid apart + Comm string `json:"comm"` + Cmdline string `json:"cmdline"` + Cwd string `json:"cwd"` + CPU float64 `json:"cpu_pct"` + Age time.Duration `json:"age_ns"` + HotFor time.Duration `json:"hot_for_ns"` + Parent string `json:"parent"` // init | user service manager + Pane string `json:"pane,omitempty"` + PaneAlive bool `json:"pane_alive"` + Target string `json:"target,omitempty"` + Kill []string `json:"kill"` +} + +const topN = 15 + +func (s *Sampler) procCPU(p procfs.Proc, dt float64) float64 { + prev, ok := s.prevProc[p.PID] + if !ok || dt == 0 || prev.StartTime != p.StartTime || p.Ticks < prev.Ticks { + return 0 + } + return 100 * float64(p.Ticks-prev.Ticks) / procfs.ClockTicks / dt +} + +func (s *Sampler) sampleProcs(snap *Snapshot, procs map[int]procfs.Proc, panes []Pane, dt float64, now time.Time) { + cpu := make(map[int]float64, len(procs)) + for pid, p := range procs { + cpu[pid] = s.procCPU(p, dt) + } + + pids := make([]int, 0, len(procs)) + for pid := range procs { + pids = append(pids, pid) + } + sort.Slice(pids, func(i, j int) bool { return cpu[pids[i]] > cpu[pids[j]] }) + for _, pid := range pids[:min(topN, len(pids))] { + p := procs[pid] + snap.Top = append(snap.Top, ProcRow{ + PID: pid, Comm: p.Comm, CPU: cpu[pid], RSS: uint64(p.RSSPages) * uint64(os.Getpagesize()), + Owner: s.owner(p), + }) + } + + paneByID := map[string]Pane{} + for _, p := range panes { + paneByID[p.ID] = p + } + s.orphans(snap, procs, cpu, paneByID, now) +} + +// owner names what a process's CPU belongs to: its tmux pane, its container, +// or the system. Cached per process: it needs environ and cgroup reads. +func (s *Sampler) owner(p procfs.Proc) string { + key := procKey{p.PID, p.StartTime} + if o, ok := s.owners[key]; ok { + return o + } + o := "system" + if pane, ok := s.FS.Environ(p.PID, "TMUX_PANE"); ok { + o = "pane " + pane + } else if cg := s.FS.Cgroup(p.PID); strings.Contains(cg, "docker") { + o = "container" + for _, c := range s.containers { + if strings.Contains(cg, c.id) { + o = "container " + c.name + break + } + } + } + s.owners[key] = o + return o +} + +var uid = os.Getuid() + +// orphans flags processes that (1) were re-parented to init or to a user +// service manager, (2) belong to us, (3) match the watched commands, and +// (4) have burned at least CPUOver for the configured time. +func (s *Sampler) orphans(snap *Snapshot, procs map[int]procfs.Proc, cpu map[int]float64, panes map[string]Pane, now time.Time) { + cfg := s.Cfg.Orphans + uptime := s.FS.Uptime() + hot := map[procKey]time.Time{} + for pid, p := range procs { + if cpu[pid] < cfg.CPUOver || !s.watched(p.Comm) { + continue + } + parent := "" + switch pp, ok := procs[p.PPID]; { + case p.PPID == 1: + parent = "init" + case ok && pp.Comm == "systemd": + parent = "user service manager" + default: + continue + } + if u, ok := s.FS.UID(pid); !ok || u != uid { + continue + } + key := procKey{pid, p.StartTime} + since, ok := s.hotSince[key] + if !ok { + since = now + } + hot[key] = since + age := time.Duration((uptime - float64(p.StartTime)/procfs.ClockTicks) * float64(time.Second)) + if now.Sub(since) < cfg.For.Duration || age < cfg.MinAge.Duration { + continue + } + o := Orphan{ + PID: pid, Start: p.StartTime, Comm: p.Comm, Cmdline: s.FS.Cmdline(pid), Cwd: s.FS.Cwd(pid), + CPU: cpu[pid], Age: age.Truncate(time.Second), HotFor: now.Sub(since).Truncate(time.Second), + Parent: parent, Kill: []string{"kill", strconv.Itoa(pid)}, + } + if pane, ok := s.FS.Environ(pid, "TMUX_PANE"); ok { + o.Pane = pane + if pp, alive := panes[pane]; alive { + o.PaneAlive, o.Target = true, pp.Target + } + } + snap.Orphans = append(snap.Orphans, o) + } + s.hotSince = hot + sort.Slice(snap.Orphans, func(i, j int) bool { return snap.Orphans[i].CPU > snap.Orphans[j].CPU }) +} + +func (s *Sampler) watched(comm string) bool { + if len(s.Cfg.Orphans.Commands) == 0 { + return true + } + for _, c := range s.Cfg.Orphans.Commands { + if comm == c { + return true + } + } + return false +} diff --git a/skills/tui/internal/sample/sampler.go b/skills/tui/internal/sample/sampler.go new file mode 100644 index 0000000..8bf00f5 --- /dev/null +++ b/skills/tui/internal/sample/sampler.go @@ -0,0 +1,553 @@ +// Package sample turns /proc, tmux and agent session files into Snapshots. +// A Sampler keeps the previous counters so rates (CPU, network, disk) are +// deltas between two calls; it is not safe for concurrent use. +package sample + +import ( + "net" + "sort" + "strconv" + "strings" + "time" + + "github.com/Lutherwaves/builder/skills/tui/internal/config" + "github.com/Lutherwaves/builder/skills/tui/internal/procfs" +) + +// Session is one agent process and the pane that owns it. +type Session struct { + PID int `json:"pid"` + Pane string `json:"pane"` + Target string `json:"target"` + Tab string `json:"tab"` // tmux window name + Name string `json:"name"` + SessionID string `json:"session_id"` + Cwd string `json:"cwd"` + Busy bool `json:"busy"` + Status string `json:"status"` // as the agent reports it: busy, idle, ... + Idle time.Duration `json:"idle_ns"` + Started time.Time `json:"started"` + Model string `json:"model,omitempty"` + Context string `json:"context"` // known | fresh (no turn since start or clear) | unknown (no session file) + Tokens int `json:"tokens"` + Window int `json:"window"` + ContextPct float64 `json:"context_pct"` + Burn30m int `json:"burn_30m_tokens"` + CPU float64 `json:"cpu_pct"` // whole process tree, 100 = one core + Procs int `json:"procs"` + Do string `json:"do,omitempty"` // compact | clear + Why string `json:"why,omitempty"` +} + +// IdlePane is a pane that hosts no agent. +type IdlePane struct { + Pane string `json:"pane"` + Target string `json:"target"` + Command string `json:"command"` + Path string `json:"path"` +} + +type Machine struct { + CPU float64 `json:"cpu_pct"` // 0-100 over all cores + Cores []float64 `json:"cores_pct"` + Load [3]float64 `json:"load"` + MemTotal uint64 `json:"mem_total"` + MemAvail uint64 `json:"mem_available"` + SwapUsed uint64 `json:"swap_used"` + PSI procfs.PSI `json:"psi"` + NetRx float64 `json:"net_rx_bps"` + NetTx float64 `json:"net_tx_bps"` + DiskRead float64 `json:"disk_read_bps"` + DiskWrite float64 `json:"disk_write_bps"` + Latency time.Duration `json:"latency_ns"` + LatencyErr string `json:"latency_error,omitempty"` + TempC float64 `json:"temp_c"` + Power []procfs.PowerLimit `json:"power_limits"` + GPUs []GPU `json:"gpus"` +} + +type Snapshot struct { + At time.Time `json:"at"` + Machine Machine `json:"machine"` + Sessions []Session `json:"sessions"` + IdlePanes []IdlePane `json:"idle_panes"` + Orphans []Orphan `json:"orphans"` + Top []ProcRow `json:"top"` + Containers []Container `json:"containers"` + Groups []GroupShare `json:"groups"` + GitPolling []GitPolling `json:"git_polling"` + Alerts []Alert `json:"alerts"` + Errors []string `json:"errors,omitempty"` +} + +// Rules decide the per-session suggestion. +type Rules struct { + CompactAt float64 // context percent at which a busy session should compact + ClearIdle time.Duration // idle this long with ClearMinPct context: clear + ClearMinPct float64 + ClearStale time.Duration // idle this long: clear regardless of context + Windows map[string]int +} + +var DefaultRules = Rules{ + CompactAt: 85, + ClearIdle: 30 * time.Minute, + ClearMinPct: 20, + ClearStale: 24 * time.Hour, +} + +type Sampler struct { + FS procfs.Host + Sys procfs.Sys + Home string + Tmux func() ([]byte, error) + GPU func() ([]byte, error) // nil: no GPUs + Docker func() ([]byte, error) // nil: no containers + Cfg config.Config + Agents []string // process names that are agent sessions + Rules Rules + LatencyTarget string // host:port, empty disables + // ProcEvery is how often the process table, tmux and sessions are + // re-read. Reading every /proc//stat is most of a sample's cost, so + // it runs slower than the cheap machine counters. Zero: every sample. + ProcEvery time.Duration + Now func() time.Time + + agents *agentReader + panes map[procKey]string // environ is read once per process + prevAt time.Time + procAt time.Time + prevProc map[int]procfs.Proc + last Snapshot // sessions and panes from the last process scan + prevCPU procfs.CPUTimes + prevCore []procfs.CPUTimes + prevNet [2]uint64 + prevDisk [2]uint64 + latAt time.Time + lat time.Duration + latErr string + burn map[string][]tokenPoint + bigSession map[string]bool // sessions seen past 200k tokens, so 1M windows + longCtx map[procKey]bool // processes started with a 1M model setting + + owners map[procKey]string + hotSince map[procKey]time.Time + condSince map[string]time.Time + powerBase map[string]float64 + gpuAt time.Time + gpus []GPU + dockerAt time.Time + containers []containerRef + prevCgroup map[string]uint64 + toplevel map[string]string + sensors []string // temperature files, found once + power []procfs.PowerLimit // read on the scan cadence + polls []pollPoint +} + +// procKey tells a reused PID apart from the process that had it before. +type procKey struct { + pid int + start uint64 +} + +type tokenPoint struct { + at time.Time + tokens int +} + +func (s *Sampler) init() { + if s.agents == nil { + s.agents = newAgentReader(s.Home) + s.panes = map[procKey]string{} + s.burn = map[string][]tokenPoint{} + s.bigSession = map[string]bool{} + s.longCtx = map[procKey]bool{} + s.owners = map[procKey]string{} + s.toplevel = map[string]string{} + if s.Cfg.Interval.Duration == 0 && s.Cfg.Orphans.CPUOver == 0 { + s.Cfg = config.Default() // an unset config, not a partly set one + } + if s.Rules.CompactAt == 0 { + s.Rules = DefaultRules + } + if s.Now == nil { + s.Now = time.Now + } + if s.Tmux == nil { + s.Tmux = TmuxPanes + } + if len(s.Agents) == 0 { + s.Agents = []string{"claude"} + } + } +} + +func (s *Sampler) Sample() Snapshot { + s.init() + now := s.Now() + snap := Snapshot{At: now} + s.sampleMachine(&snap, seconds(s.prevAt, now)) + snap.Machine.GPUs = s.sampleGPUs(now) + s.prevAt = now + + if s.procAt.IsZero() || now.Sub(s.procAt) >= s.ProcEvery { + s.scan(now) + } + l := s.last + snap.Sessions, snap.IdlePanes, snap.Orphans, snap.Top = l.Sessions, l.IdlePanes, l.Orphans, l.Top + snap.Containers, snap.Groups, snap.GitPolling = l.Containers, l.Groups, l.GitPolling + snap.Errors = append(snap.Errors, l.Errors...) + s.alerts(&snap, now) + return snap +} + +// scan reads the process table, tmux and containers: the expensive half of a +// sample, run every ProcEvery. +func (s *Sampler) scan(now time.Time) { + dt := seconds(s.procAt, now) + var l Snapshot + s.power = s.Sys.PowerLimits() + procs, err := s.FS.Procs() + if err != nil { + l.Errors = append(l.Errors, "proc: "+err.Error()) + } + out, err := s.Tmux() + if err != nil { + l.Errors = append(l.Errors, "tmux: "+err.Error()) + } + panes := ParsePanes(out) + s.sampleSessions(&l, panes, procs, dt, now) + l.Containers, l.Groups = s.sampleContainers(now, dt, len(s.prevCore)) + s.sampleProcs(&l, procs, panes, dt, now) + l.GitPolling = s.samplePolling(procs, now) + for key := range s.owners { + if p, ok := procs[key.pid]; !ok || p.StartTime != key.start { + delete(s.owners, key) + } + } + s.procAt, s.prevProc, s.last = now, procs, l +} + +func seconds(prev, now time.Time) float64 { + if prev.IsZero() || !now.After(prev) { + return 0 + } + return now.Sub(prev).Seconds() +} + +func (s *Sampler) sampleMachine(snap *Snapshot, dt float64) { + m := &snap.Machine + if all, cores, err := s.FS.Stat(); err == nil { + m.CPU = busyPct(s.prevCPU, all) + m.Cores = make([]float64, len(cores)) + for i, c := range cores { + if i < len(s.prevCore) { + m.Cores[i] = busyPct(s.prevCore[i], c) + } + } + s.prevCPU, s.prevCore = all, cores + } + m.Load, _ = s.FS.Loadavg() + if mem, err := s.FS.Meminfo(); err == nil { + m.MemTotal, m.MemAvail = mem.Total, mem.Available + m.SwapUsed = mem.SwapTotal - mem.SwapFree + } + m.PSI = s.FS.Pressure() + if rx, tx, err := s.FS.NetBytes(); err == nil { + if dt > 0 { + m.NetRx, m.NetTx = rate(s.prevNet[0], rx, dt), rate(s.prevNet[1], tx, dt) + } + s.prevNet = [2]uint64{rx, tx} + } + if r, w, err := s.FS.DiskBytes(); err == nil { + if dt > 0 { + m.DiskRead, m.DiskWrite = rate(s.prevDisk[0], r, dt), rate(s.prevDisk[1], w, dt) + } + s.prevDisk = [2]uint64{r, w} + } + s.sampleLatency() + m.Latency, m.LatencyErr = s.lat, s.latErr + if s.sensors == nil { + s.sensors = s.Sys.TempSensors() + } + m.TempC = procfs.ReadTemp(s.sensors) + m.Power = s.power +} + +// sampleLatency measures a TCP connect at most every 10 s: no root and no +// ICMP needed, and it reflects what a git push or an API call will feel. +func (s *Sampler) sampleLatency() { + if s.LatencyTarget == "" || s.Now().Sub(s.latAt) < 10*time.Second { + return + } + s.latAt = s.Now() + start := time.Now() + c, err := net.DialTimeout("tcp", s.LatencyTarget, 2*time.Second) + if err != nil { + s.lat, s.latErr = 0, err.Error() + return + } + c.Close() + s.lat, s.latErr = time.Since(start), "" +} + +func busyPct(prev, cur procfs.CPUTimes) float64 { + if prev.Total == 0 || cur.Total <= prev.Total { + return 0 + } + return 100 * float64(cur.Busy-prev.Busy) / float64(cur.Total-prev.Total) +} + +func rate(prev, cur uint64, dt float64) float64 { + if prev == 0 || cur < prev { + return 0 + } + return float64(cur-prev) / dt +} + +func (s *Sampler) sampleSessions(snap *Snapshot, panes []Pane, procs map[int]procfs.Proc, dt float64, now time.Time) { + children := map[int][]int{} + for pid, p := range procs { + children[p.PPID] = append(children[p.PPID], pid) + } + byID := map[string]Pane{} + byPID := map[int]Pane{} + for _, p := range panes { + byID[p.ID] = p + byPID[p.PID] = p + } + + hosting := map[string]bool{} + alive := map[procKey]bool{} + for pid, p := range procs { + if !s.isAgent(p.Comm) { + continue + } + // An agent started anywhere below another agent (helpers, a nested + // `claude -p` in its shell) inherits its pane: count only the top one. + if s.underAgent(p, procs) { + continue + } + key := procKey{pid, p.StartTime} + alive[key] = true + paneID, ok := s.panes[key] + if !ok { + paneID = s.ownerPane(pid, procs, byPID) + s.panes[key] = paneID + } + pane := byID[paneID] + hosting[paneID] = true + + cpu, n := s.treeCPU(pid, procs, children, dt) + sess := Session{ + PID: pid, Pane: paneID, Target: pane.Target, Tab: pane.Window, Cwd: s.FS.Cwd(pid), + CPU: cpu, Procs: n, Window: 200_000, Context: "unknown", + } + if e, ok := s.agents.entry(pid); ok { + sess.Name, sess.SessionID = e.Name, e.SessionID + sess.Busy, sess.Status = e.Status == "busy", e.Status + if e.StartedAt > 0 { + sess.Started = time.UnixMilli(e.StartedAt) + } + if !sess.Busy && e.StatusUpdatedAt > 0 { + sess.Idle = now.Sub(time.UnixMilli(e.StatusUpdatedAt)).Truncate(time.Second) + } + sess.Context = "fresh" + if u, ok := s.agents.usageOf(e.SessionID, e.Cwd); ok { + sess.Context = "known" + sess.Model, sess.Tokens = u.Model, u.Tokens + long, ok := s.longCtx[key] + if !ok { + env := func(k string) (string, bool) { return s.FS.Environ(pid, k) } + long = longContext(modelSetting(s.FS.Cmdline(pid), env, e.Cwd, s.Home)) + s.longCtx[key] = long + } + sess.Window = s.window(e.SessionID, u.Model, u.Tokens, long) + sess.ContextPct = 100 * float64(u.Tokens) / float64(sess.Window) + sess.Burn30m = s.trackBurn(e.SessionID, now, u.Tokens) + } + } + if sess.Name == "" { + sess.Name = p.Comm + } + sess.Do, sess.Why = s.Rules.suggest(sess) + snap.Sessions = append(snap.Sessions, sess) + } + for key := range s.panes { + if !alive[key] { + delete(s.panes, key) + } + } + for key := range s.longCtx { + if !alive[key] { + delete(s.longCtx, key) + } + } + seen := map[string]bool{} + for _, x := range snap.Sessions { + seen[x.SessionID] = true + } + for id := range s.burn { + if !seen[id] { + delete(s.burn, id) + } + } + for id := range s.bigSession { + if !seen[id] { + delete(s.bigSession, id) + } + } + for _, p := range panes { + if !hosting[p.ID] { + snap.IdlePanes = append(snap.IdlePanes, IdlePane{p.ID, p.Target, p.Command, p.Path}) + } + } + sort.Slice(snap.Sessions, func(i, j int) bool { + a, b := snap.Sessions[i], snap.Sessions[j] + if a.ContextPct != b.ContextPct { + return a.ContextPct > b.ContextPct + } + return a.Pane < b.Pane + }) +} + +func (s *Sampler) isAgent(comm string) bool { + for _, a := range s.Agents { + if comm == a { + return true + } + } + return false +} + +// ownerPane prefers the pane the agent recorded in its session file, then +// TMUX_PANE from its environment (both survive re-parenting), and falls back +// to walking up to a pane's shell. +func (s *Sampler) ownerPane(pid int, procs map[int]procfs.Proc, byPID map[int]Pane) string { + if e, ok := s.agents.entry(pid); ok && e.Tmux != "" { + if i := strings.LastIndexByte(e.Tmux, '.'); i >= 0 && strings.HasPrefix(e.Tmux[i+1:], "%") { + return e.Tmux[i+1:] + } + } + if v, ok := s.FS.Environ(pid, "TMUX_PANE"); ok { + return v + } + for cur, hops := pid, 0; cur > 1 && hops < 64; hops++ { + if p, ok := byPID[cur]; ok { + return p.ID + } + cur = procs[cur].PPID + } + return "" +} + +func (s *Sampler) treeCPU(root int, procs map[int]procfs.Proc, children map[int][]int, dt float64) (float64, int) { + var ticks uint64 + n := 0 + stack := []int{root} + for len(stack) > 0 { + pid := stack[len(stack)-1] + stack = stack[:len(stack)-1] + p := procs[pid] + n++ + if prev, ok := s.prevProc[pid]; ok && prev.StartTime == p.StartTime && p.Ticks >= prev.Ticks { + ticks += p.Ticks - prev.Ticks + } + stack = append(stack, children[pid]...) + } + if dt == 0 { + return 0, n + } + return 100 * float64(ticks) / procfs.ClockTicks / dt, n +} + +// window is the session's context size. A configured value for its model +// wins; otherwise a session started with a 1M model setting, or seen past +// 200k, has a 1M window. Sessions of one model can differ, so this is decided +// per session. +func (s *Sampler) window(sessionID, model string, tokens int, long bool) int { + for prefix, w := range s.Rules.Windows { + if strings.HasPrefix(model, prefix) { + return w + } + } + if long || tokens > 200_000 { + s.bigSession[sessionID] = true + } + if s.bigSession[sessionID] { + return 1_000_000 + } + return 200_000 +} + +func (s *Sampler) underAgent(p procfs.Proc, procs map[int]procfs.Proc) bool { + for cur, hops := p.PPID, 0; cur > 1 && hops < 64; hops++ { + pp, ok := procs[cur] + if !ok { + return false + } + if s.isAgent(pp.Comm) { + return true + } + cur = pp.PPID + } + return false +} + +// trackBurn returns how many tokens the session's context grew in the last +// 30 minutes, keeping at most one point a minute. +func (s *Sampler) trackBurn(id string, now time.Time, tokens int) int { + pts := s.burn[id] + if n := len(pts); n > 0 && now.Sub(pts[n-1].at) < time.Minute { + pts[n-1] = tokenPoint{now, tokens} + } else { + pts = append(pts, tokenPoint{now, tokens}) + } + for len(pts) > 1 && now.Sub(pts[1].at) >= 30*time.Minute { + pts = pts[1:] + } + s.burn[id] = pts + return max(tokens-pts[0].tokens, 0) +} + +// suggest: a session left idle while holding context should be cleared; +// one near its window should compact before its next turn hits the limit. +func (r Rules) suggest(s Session) (do, why string) { + switch { + case s.Tokens == 0: + return "", "" + case !s.Busy && s.Idle >= r.ClearStale: + return "clear", "idle " + Human(s.Idle) + " (clear after " + Human(r.ClearStale) + ")" + case !s.Busy && s.Idle >= r.ClearIdle && s.ContextPct >= r.ClearMinPct: + return "clear", "idle " + Human(s.Idle) + " holding " + pct(s.ContextPct) + " context" + case s.ContextPct >= r.CompactAt: + return "compact", "context " + pct(s.ContextPct) + " (compact at " + pct(r.CompactAt) + ")" + } + return "", "" +} + +func pct(f float64) string { return strconv.Itoa(int(f+0.5)) + "%" } + +// Human renders a duration as its two largest units, dropping a zero +// second unit: 2h14m, 6d2h, 1d, 41m, 3s. +func Human(d time.Duration) string { + d = d.Truncate(time.Second) + two := func(a time.Duration, ua string, b time.Duration, ub string) string { + out := strconv.Itoa(int(a)) + ua + if b > 0 { + out += strconv.Itoa(int(b)) + ub + } + return out + } + day := 24 * time.Hour + switch { + case d >= day: + return two(d/day, "d", d%day/time.Hour, "h") + case d >= time.Hour: + return two(d/time.Hour, "h", d%time.Hour/time.Minute, "m") + case d >= time.Minute: + return strconv.Itoa(int(d/time.Minute)) + "m" + } + return strconv.Itoa(int(d/time.Second)) + "s" +} diff --git a/skills/tui/internal/sample/sampler_test.go b/skills/tui/internal/sample/sampler_test.go new file mode 100644 index 0000000..e617f5b --- /dev/null +++ b/skills/tui/internal/sample/sampler_test.go @@ -0,0 +1,444 @@ +package sample + +import ( + "fmt" + "os" + "path/filepath" + "strconv" + "strings" + "testing" + "time" + + "github.com/Lutherwaves/builder/skills/tui/internal/config" + "github.com/Lutherwaves/builder/skills/tui/internal/procfs" +) + +const fixture = "../../testdata/machine" + +// fixtureNow is one hour after the fixture sessions started. +var fixtureNow = time.UnixMilli(1790003600000 + 10_000) + +func newFixtureSampler(root string) *Sampler { + return &Sampler{ + FS: procfs.FS{Root: filepath.Join(root, "proc")}, + Home: filepath.Join(root, "home"), + Tmux: func() ([]byte, error) { return os.ReadFile(filepath.Join(root, "tmux-panes.txt")) }, + Rules: DefaultRules, + Cfg: config.Default(), + Now: func() time.Time { return fixtureNow }, + } +} + +// copyFixture copies the fixture tree, keeping the cwd symlinks that +// os.CopyFS refuses. +func copyFixture(t *testing.T) string { + t.Helper() + root := t.TempDir() + err := filepath.WalkDir(fixture, func(path string, d os.DirEntry, err error) error { + if err != nil { + return err + } + rel, _ := filepath.Rel(fixture, path) + dst := filepath.Join(root, rel) + switch { + case d.Type()&os.ModeSymlink != 0: + target, err := os.Readlink(path) + if err != nil { + return err + } + return os.Symlink(target, dst) + case d.IsDir(): + return os.MkdirAll(dst, 0o755) + } + b, err := os.ReadFile(path) + if err != nil { + return err + } + return os.WriteFile(dst, b, 0o644) + }) + if err != nil { + t.Fatal(err) + } + // Every fixture process belongs to whoever runs the test. + dirs, _ := filepath.Glob(filepath.Join(root, "proc", "[0-9]*")) + for _, d := range dirs { + status := fmt.Sprintf("Name:\tx\nUid:\t%d\t%d\t%d\t%d\n", os.Getuid(), os.Getuid(), os.Getuid(), os.Getuid()) + os.WriteFile(filepath.Join(d, "status"), []byte(status), 0o644) + } + return root +} + +// setTicks rewrites a fixture process's utime. +func setTicks(t *testing.T, root string, pid, ticks int) { + t.Helper() + path := filepath.Join(root, "proc", strconv.Itoa(pid), "stat") + b, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + f := strings.Fields(string(b)) + f[13] = strconv.Itoa(ticks) + os.WriteFile(path, []byte(strings.Join(f, " ")), 0o644) +} + +func byPane(snap Snapshot) map[string]Session { + out := map[string]Session{} + for _, s := range snap.Sessions { + out[s.Pane] = s + } + return out +} + +func TestSessionsMapToPanes(t *testing.T) { + snap := newFixtureSampler(fixture).Sample() + if len(snap.Errors) > 0 { + t.Fatal(snap.Errors) + } + got := byPane(snap) + if len(got) != 3 { + t.Fatalf("want 3 agent sessions, got %+v", snap.Sessions) + } + + feature := got["%1"] + if feature.PID != 200 || feature.Name != "app-feature" || !feature.Busy || feature.Procs != 2 { + t.Fatalf("%%1 = %+v", feature) + } + // The last main-thread turn counts; the sidechain line after it does not. + if feature.Tokens != 180000 || feature.ContextPct != 90 || feature.Do != "compact" { + t.Fatalf("%%1 context = %d %.1f %q", feature.Tokens, feature.ContextPct, feature.Do) + } + + review := got["%2"] + if review.PID != 310 || review.Busy || review.Idle != time.Hour+10*time.Second || review.Do != "clear" { + t.Fatalf("%%2 = %+v", review) + } + + // pid 400 was re-parented to init; TMUX_PANE still names its pane. + tools := got["%3"] + if tools.PID != 400 || tools.Context != "fresh" || tools.Do != "" { + t.Fatalf("%%3 = %+v", tools) + } + + if len(snap.IdlePanes) != 1 || snap.IdlePanes[0].Pane != "%4" { + t.Fatalf("idle panes = %+v", snap.IdlePanes) + } +} + +func TestTreeCPUAndRates(t *testing.T) { + root := copyFixture(t) + s := newFixtureSampler(root) + s.Sample() + + // One second later the agent's child has burned 50 ticks: half a core. + s.Now = func() time.Time { return fixtureNow.Add(time.Second) } + setTicks(t, root, 210, 150) + os.WriteFile(filepath.Join(root, "proc", "net", "dev"), + []byte("Inter-|\n face |\n eth0: 3000 10 0 0 0 0 0 0 2500 20 0 0 0 0 0 0\n"), 0o644) + + snap := s.Sample() + if cpu := byPane(snap)["%1"].CPU; cpu != 50 { + t.Fatalf("tree cpu = %v, want 50", cpu) + } + if snap.Machine.NetRx != 2000 || snap.Machine.NetTx != 500 { + t.Fatalf("net rate = %v %v", snap.Machine.NetRx, snap.Machine.NetTx) + } +} + +func TestReusedPIDIsRemapped(t *testing.T) { + root := copyFixture(t) + s := newFixtureSampler(root) + s.Sample() + // pid 400 exits and a new process with the same pid starts in pane %4. + os.WriteFile(filepath.Join(root, "proc", "400", "stat"), + []byte("400 (claude) S 1 400 400 0 -1 0 0 0 0 0 1 0 0 0 20 0 1 0 9999 100000 2500"), 0o644) + os.WriteFile(filepath.Join(root, "proc", "400", "environ"), []byte("TMUX_PANE=%4\x00"), 0o644) + if got := byPane(s.Sample())["%4"].PID; got != 400 { + t.Fatalf("reused pid not remapped to %%4") + } +} + +func TestSuggest(t *testing.T) { + r := DefaultRules + cases := []struct { + name string + s Session + want string + }{ + {"fresh session", Session{Tokens: 0, Idle: 48 * time.Hour}, ""}, + {"busy and near the window", Session{Tokens: 1, Busy: true, ContextPct: 90}, "compact"}, + {"briefly idle and near the window", Session{Tokens: 1, Idle: 5 * time.Minute, ContextPct: 94}, "compact"}, + {"long idle beats compact", Session{Tokens: 1, Idle: 5 * time.Hour, ContextPct: 95}, "clear"}, + {"idle holding context", Session{Tokens: 1, Idle: 31 * time.Minute, ContextPct: 25}, "clear"}, + {"idle but small", Session{Tokens: 1, Idle: 2 * time.Hour, ContextPct: 10}, ""}, + {"stale", Session{Tokens: 1, Idle: 25 * time.Hour, ContextPct: 1}, "clear"}, + {"busy and fine", Session{Tokens: 1, Busy: true, ContextPct: 50}, ""}, + } + for _, c := range cases { + if got, _ := r.suggest(c.s); got != c.want { + t.Errorf("%s: got %q want %q", c.name, got, c.want) + } + } +} + +func TestWindowIsPerSession(t *testing.T) { + s := &Sampler{} + s.init() + if w := s.window("a", "model-a", 150_000, false); w != 200_000 { + t.Fatalf("got %d", w) + } + s.window("a", "model-a", 250_000, false) + if w := s.window("a", "model-a", 150_000, false); w != 1_000_000 { + t.Fatalf("a session past 200k once must stay 1M, got %d", w) + } + if w := s.window("b", "model-a", 150_000, false); w != 200_000 { + t.Fatalf("another session of the same model is not 1M: %d", w) + } + s.Rules.Windows = map[string]int{"model-b": 500_000} + if w := s.window("c", "model-b-large", 10, false); w != 500_000 { + t.Fatalf("configured window ignored: %d", w) + } + if w := s.window("d", "model-a", 150_000, true); w != 1_000_000 { + t.Fatalf("a 1M model setting must give a 1M window below 200k: %d", w) + } +} + +func TestModelSetting(t *testing.T) { + home, cwd := t.TempDir(), t.TempDir() + noEnv := func(string) (string, bool) { return "", false } + write := func(dir, name, body string) { + t.Helper() + if err := os.MkdirAll(filepath.Join(dir, ".claude"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, ".claude", name), []byte(body), 0o644); err != nil { + t.Fatal(err) + } + } + if m := modelSetting("claude", noEnv, cwd, home); m != "" { + t.Fatalf("no setting anywhere: %q", m) + } + write(home, "settings.json", `{"model":"big[1m]"}`) + if m := modelSetting("claude --resume x", noEnv, cwd, home); !longContext(m) { + t.Fatalf("user settings ignored: %q", m) + } + write(cwd, "settings.local.json", `{"model":"small"}`) + if m := modelSetting("claude", noEnv, cwd, home); m != "small" { + t.Fatalf("project local settings must win over user settings: %q", m) + } + env := func(k string) (string, bool) { return "env[1M]", k == "ANTHROPIC_MODEL" } + if m := modelSetting("claude", env, cwd, home); !longContext(m) { + t.Fatalf("ANTHROPIC_MODEL must win over settings: %q", m) + } + for _, cmd := range []string{"claude --model flag[1m]", "claude --model=flag[1m]"} { + if m := modelSetting(cmd, env, cwd, home); m != "flag[1m]" { + t.Fatalf("%s: the flag must win: %q", cmd, m) + } + } +} + +func TestNestedAgentFoldsIntoTopSession(t *testing.T) { + root := copyFixture(t) + // A shell under pane %1's agent runs another agent with the same pane. + os.MkdirAll(filepath.Join(root, "proc", "220"), 0o755) + os.MkdirAll(filepath.Join(root, "proc", "221"), 0o755) + os.WriteFile(filepath.Join(root, "proc", "220", "stat"), []byte("220 (bash) S 200 1 1 0 -1 0 0 0 0 0 1 0 0 0 20 0 1 0 1000 1 1"), 0o644) + os.WriteFile(filepath.Join(root, "proc", "221", "stat"), []byte("221 (claude) S 220 1 1 0 -1 0 0 0 0 0 1 0 0 0 20 0 1 0 1000 1 1"), 0o644) + os.WriteFile(filepath.Join(root, "proc", "221", "environ"), []byte("TMUX_PANE=%1\x00"), 0o644) + n := 0 + for _, x := range newFixtureSampler(root).Sample().Sessions { + if x.Pane == "%1" { + n++ + } + } + if n != 1 { + t.Fatalf("pane %%1 has %d sessions, want 1", n) + } +} + +func TestPowerZoneSeenLateIsNotDrift(t *testing.T) { + s := &Sampler{} + s.init() + s.powerDrift(nil) + if d := s.powerDrift([]procfs.PowerLimit{{Zone: "package-0", Name: "long_term", Watts: 125}}); len(d) != 0 { + t.Fatalf("late zone flagged: %+v", d) + } +} + +func TestBurn(t *testing.T) { + s := &Sampler{} + s.init() + t0 := time.Unix(0, 0) + s.trackBurn("x", t0, 1000) + s.trackBurn("x", t0.Add(10*time.Minute), 5000) + if got := s.trackBurn("x", t0.Add(20*time.Minute), 9000); got != 8000 { + t.Fatalf("burn = %d", got) + } + if got := s.trackBurn("x", t0.Add(45*time.Minute), 9000); got != 4000 { + t.Fatalf("burn after window slides = %d", got) + } +} + +func TestHuman(t *testing.T) { + for d, want := range map[time.Duration]string{ + 3 * time.Second: "3s", + 41 * time.Minute: "41m", + 2*time.Hour + 14*time.Minute: "2h14m", + time.Hour: "1h", + 24 * time.Hour: "1d", + 6*24*time.Hour + 2*time.Hour: "6d2h", + } { + if got := Human(d); got != want { + t.Errorf("Human(%v) = %q want %q", d, got, want) + } + } +} + +func TestParsePanesSkipsJunk(t *testing.T) { + got := ParsePanes([]byte("%1\ta:1.1\tw\t10\tzsh\t/x\n\ngarbage\n")) + if len(got) != 1 || got[0].PID != 10 || got[0].Path != "/x" { + t.Fatalf("got %+v", got) + } +} + +func TestOrphanFlaggedAfterSustainedCPU(t *testing.T) { + root := copyFixture(t) + s := newFixtureSampler(root) + at := func(sec int) { s.Now = func() time.Time { return fixtureNow.Add(time.Duration(sec) * time.Second) } } + + s.Sample() + // Both shells burn 90% of a core; 601 is still inside pane %2's tree. + for i, sec := range []int{40, 80} { + at(sec) + setTicks(t, root, 600, 1000+3600*(i+1)) + setTicks(t, root, 601, 1000+3600*(i+1)) + snap := s.Sample() + if i == 0 && len(snap.Orphans) != 0 { + t.Fatalf("flagged before it stayed hot for 30s: %+v", snap.Orphans) + } + if i == 1 { + if len(snap.Orphans) != 1 { + t.Fatalf("want only pid 600, got %+v", snap.Orphans) + } + o := snap.Orphans[0] + if o.PID != 600 || o.Pane != "%2" || !o.PaneAlive || o.Target != "work:2.1" || + o.Parent != "user service manager" || o.CPU != 90 || strings.Join(o.Kill, " ") != "kill 600" { + t.Fatalf("orphan = %+v", o) + } + } + } +} + +func TestOwnerAttribution(t *testing.T) { + root := copyFixture(t) + s := newFixtureSampler(root) + s.Sample() + s.Now = func() time.Time { return fixtureNow.Add(time.Second) } + setTicks(t, root, 210, 180) + snap := s.Sample() + if snap.Top[0].PID != 210 || snap.Top[0].Owner != "pane %1" { + t.Fatalf("top = %+v", snap.Top[0]) + } +} + +func TestAlerts(t *testing.T) { + s := newFixtureSampler(fixture) + s.Cfg.Alerts.PowerLimitsW = []float64{125, 188} + s.Cfg.Alerts.TempOverC = 85 + s.Cfg.Alerts.Load1Over = 3 + s.Cfg.Alerts.Load1For.Duration = time.Minute + s.Cfg.Alerts.SwapOverGB = 0.5 + s.Cfg.Alerts.SwapWhenAvailableOverGB = 4 + s.Cfg.Alerts.MemAvailableUnderGB = 10 + s.Sys = procfs.Sys{Root: filepath.Join(fixture, "sys")} + + keys := func(snap Snapshot) string { + var k []string + for _, a := range snap.Alerts { + k = append(k, a.Key) + } + return strings.Join(k, ",") + } + // load1 3.5 > 3 has not held for a minute yet; the others fire at once. + if got := keys(s.Sample()); got != "temp,mem-low,swap-stuck" { + t.Fatalf("first sample alerts = %q", got) + } + s.Now = func() time.Time { return fixtureNow.Add(61 * time.Second) } + if got := keys(s.Sample()); got != "temp,load1,mem-low,swap-stuck" { + t.Fatalf("after a minute alerts = %q", got) + } +} + +func TestPowerDrift(t *testing.T) { + s := &Sampler{} + s.init() + limits := []procfs.PowerLimit{{Zone: "package-0", Name: "long_term", Watts: 125}, {Zone: "package-0", Name: "short_term", Watts: 188}} + if d := s.powerDrift(limits); len(d) != 0 { + t.Fatalf("baseline drifted: %+v", d) + } + limits[0].Watts = 253 + if d := s.powerDrift(limits); len(d) != 1 || !strings.Contains(d[0].evidence, "is 253 W, at startup 125 W") { + t.Fatalf("change from startup not caught: %+v", d) + } + s.Cfg.Alerts.PowerLimitsW = []float64{253, 188} + if d := s.powerDrift(limits); len(d) != 0 { + t.Fatalf("expected value must win over startup: %+v", d) + } +} + +func TestContainerGroups(t *testing.T) { + root := copyFixture(t) + id := "aaaabbbbccccddddeeeeffff0000111122223333444455556666777788889999" + s := newFixtureSampler(root) + s.Sys = procfs.Sys{Root: filepath.Join(root, "sys")} + s.Docker = func() ([]byte, error) { return []byte(id + "\tci-runner-1\n"), nil } + cfg, err := config.Load(writeTOML(t, "[[groups]]\nname = \"CI\"\ncontainer = \"runner\"\n")) + if err != nil { + t.Fatal(err) + } + s.Cfg = cfg + s.Sample() + s.Now = func() time.Time { return fixtureNow.Add(2 * time.Second) } + os.WriteFile(filepath.Join(root, "sys/fs/cgroup/system.slice/docker-"+id+".scope/cpu.stat"), []byte("usage_usec 4000000\n"), 0o644) + snap := s.Sample() + if len(snap.Containers) != 1 || snap.Containers[0].CPU != 150 || snap.Containers[0].Name != "ci-runner-1" { + t.Fatalf("containers = %+v", snap.Containers) + } + // 150% of a core on a two-core fixture is 75% of the machine. + if g := snap.Groups; len(g) != 1 || g[0].Containers != 1 || g[0].Share != 75 { + t.Fatalf("groups = %+v", g) + } +} + +func writeTOML(t *testing.T, body string) string { + t.Helper() + path := filepath.Join(t.TempDir(), "tui.toml") + if err := os.WriteFile(path, []byte(body), 0o644); err != nil { + t.Fatal(err) + } + return path +} + +// The session file's pane wins over the environment: it is the only source +// on systems where another process's environment cannot be read. +func TestPaneFromSessionFile(t *testing.T) { + root := copyFixture(t) + f := filepath.Join(root, "home", ".claude", "sessions", "200.json") + b, err := os.ReadFile(f) + if err != nil { + t.Fatal(err) + } + b = []byte(strings.Replace(string(b), `"pid":200,`, `"pid":200,"tmux":"notes:@4.%4",`, 1)) + if err := os.WriteFile(f, b, 0o644); err != nil { + t.Fatal(err) + } + snap := newFixtureSampler(root).Sample() + for _, s := range snap.Sessions { + if s.PID == 200 { + if s.Pane != "%4" || s.Target != "notes:1.1" { + t.Fatalf("want %%4 notes:1.1 from the session file, got %s %s", s.Pane, s.Target) + } + return + } + } + t.Fatal("session 200 not found") +} diff --git a/skills/tui/internal/sample/tmux.go b/skills/tui/internal/sample/tmux.go new file mode 100644 index 0000000..cde378c --- /dev/null +++ b/skills/tui/internal/sample/tmux.go @@ -0,0 +1,49 @@ +package sample + +import ( + "os/exec" + "strconv" + "strings" +) + +// Pane is one tmux pane. +type Pane struct { + ID string `json:"id"` // %12 + Target string `json:"target"` // session:window.pane + Window string `json:"window"` + PID int `json:"pid"` + Command string `json:"command"` + Path string `json:"path"` +} + +// PaneFormat is the -F format ParsePanes expects; fields are tab separated. +const PaneFormat = "#{pane_id}\t#{session_name}:#{window_index}.#{pane_index}\t#{window_name}\t#{pane_pid}\t#{pane_current_command}\t#{pane_current_path}" + +// TmuxPanes lists every pane on the default tmux server. No server is not an +// error: the cockpit still shows the machine. +func TmuxPanes() ([]byte, error) { + if _, err := exec.LookPath("tmux"); err != nil { + return nil, nil + } + out, err := exec.Command("tmux", "list-panes", "-a", "-F", PaneFormat).Output() + if err != nil { + if _, ok := err.(*exec.ExitError); ok { + return nil, nil + } + return nil, err + } + return out, nil +} + +func ParsePanes(b []byte) []Pane { + var panes []Pane + for _, line := range strings.Split(string(b), "\n") { + f := strings.Split(line, "\t") + if len(f) < 6 { + continue + } + pid, _ := strconv.Atoi(f[3]) + panes = append(panes, Pane{ID: f[0], Target: f[1], Window: f[2], PID: pid, Command: f[4], Path: f[5]}) + } + return panes +} diff --git a/skills/tui/internal/state/state.go b/skills/tui/internal/state/state.go new file mode 100644 index 0000000..396c30a --- /dev/null +++ b/skills/tui/internal/state/state.go @@ -0,0 +1,64 @@ +// Package state is the snapshot file a running cockpit leaves behind so an +// agent can read the same facts without sampling again. +package state + +import ( + "encoding/json" + "os" + "path/filepath" + + "github.com/Lutherwaves/builder/skills/tui/internal/advice" + "github.com/Lutherwaves/builder/skills/tui/internal/gitscan" + "github.com/Lutherwaves/builder/skills/tui/internal/sample" +) + +// Doc is everything the cockpit knows at one moment. +type Doc struct { + sample.Snapshot + Git *gitscan.Report `json:"git,omitempty"` + Recommendations []advice.Rec `json:"recommendations"` +} + +// Path is $XDG_STATE_HOME/builder/tui.json, defaulting to ~/.local/state. +func Path() string { + dir := os.Getenv("XDG_STATE_HOME") + if dir == "" { + home, _ := os.UserHomeDir() + dir = filepath.Join(home, ".local", "state") + } + return filepath.Join(dir, "builder", "tui.json") +} + +// Write replaces the file atomically so a reader never sees half a snapshot. +func Write(path string, doc Doc) error { + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return err + } + b, err := json.MarshalIndent(doc, "", " ") + if err != nil { + return err + } + tmp, err := os.CreateTemp(filepath.Dir(path), ".tui-*.json") + if err != nil { + return err + } + if _, err := tmp.Write(b); err != nil { + tmp.Close() + os.Remove(tmp.Name()) + return err + } + if err := tmp.Close(); err != nil { + os.Remove(tmp.Name()) + return err + } + return os.Rename(tmp.Name(), path) +} + +func Read(path string) (Doc, error) { + var doc Doc + b, err := os.ReadFile(path) + if err != nil { + return doc, err + } + return doc, json.Unmarshal(b, &doc) +} diff --git a/skills/tui/internal/ui/live_bench_test.go b/skills/tui/internal/ui/live_bench_test.go new file mode 100644 index 0000000..1e46464 --- /dev/null +++ b/skills/tui/internal/ui/live_bench_test.go @@ -0,0 +1,28 @@ +package ui + +import ( + "os" + "testing" + + tea "charm.land/bubbletea/v2" + + "github.com/Lutherwaves/builder/skills/tui/internal/procfs" + "github.com/Lutherwaves/builder/skills/tui/internal/sample" +) + +// BenchmarkLiveRender renders this machine's snapshot. Opt-in: LIVE=1. +func BenchmarkLiveRender(b *testing.B) { + if os.Getenv("LIVE") == "" { + b.Skip("set LIVE=1 to render this machine") + } + home, _ := os.UserHomeDir() + s := &sample.Sampler{FS: procfs.FS{Root: "/proc"}, Sys: procfs.Sys{Root: "/sys"}, Home: home} + s.Sample() + m := New(Options{Source: s.Sample}) + next, _ := m.Update(tea.WindowSizeMsg{Width: 200, Height: 50}) + next, _ = next.Update(snapMsg(s.Sample())) + b.ResetTimer() + for i := 0; i < b.N; i++ { + _ = next.(Model).render() + } +} diff --git a/skills/tui/internal/ui/model.go b/skills/tui/internal/ui/model.go new file mode 100644 index 0000000..89fb459 --- /dev/null +++ b/skills/tui/internal/ui/model.go @@ -0,0 +1,395 @@ +// Package ui is the Bubble Tea program. Sampling and git scans run off the +// update loop in commands; the model only renders their latest results. +package ui + +import ( + "context" + "fmt" + "os" + "os/exec" + "strings" + "time" + + tea "charm.land/bubbletea/v2" + + "github.com/Lutherwaves/builder/skills/tui/internal/advice" + "github.com/Lutherwaves/builder/skills/tui/internal/gitscan" + "github.com/Lutherwaves/builder/skills/tui/internal/history" + "github.com/Lutherwaves/builder/skills/tui/internal/sample" + "github.com/Lutherwaves/builder/skills/tui/internal/state" +) + +// Source produces one snapshot per call. The sampler is never called again +// before the previous call returned, so it needs no locking. +type Source func() sample.Snapshot + +// GitSource scans the repositories the given session directories belong to. +type GitSource func(sessionCwds []string) gitscan.Report + +type Options struct { + Source Source + Interval time.Duration + Git GitSource // nil: no git panel + GitEvery time.Duration + // CompactAt colours context at the compact threshold. + CompactAt float64 + // OnSnapshot runs after each sample, off the update loop: the state file + // for `status` and the history log. + OnSnapshot func(state.Doc) + // History loads past samples for the history panel. Nil: none. + History func() history.Series + // Run executes one confirmed step. Nil means exec.Command. + Run func(argv []string) error + // Check re-verifies a guard right before its step runs (same process, + // worktree still clean and unused). Nil checks nothing. + Check func(advice.Guard) error +} + +type snapMsg sample.Snapshot +type gitMsg gitscan.Report +type historyMsg history.Series +type tickMsg struct{} +type gitTickMsg struct{} +type ranMsg struct { + cmd string + err error +} + +// action is a command waiting for the user's confirm. +type action struct { + label string + steps [][]string + destructive bool + guards []advice.Guard // guards[i] holds for steps[i]; may be empty +} + +func (a action) String() string { + s := strings.Join(a.steps[0], " ") + if n := len(a.steps); n > 1 { + s += fmt.Sprintf(" (+%d more)", n-1) + } + return s +} + +func fromAdvice(a *advice.Action) *action { + if a == nil || len(a.Steps) == 0 { + return nil + } + return &action{label: a.Label, steps: a.Steps, destructive: a.Destructive, guards: a.Guards} +} + +type Model struct { + opt Options + st styles + width int + height int + snap sample.Snapshot + have bool + git *gitscan.Report + gitRunning bool + recs []advice.Rec + hist history.Series + selPID int + orphanPID int + recSel int + gitSel int + tab int + pending *action + flash string + history *series + quitting bool +} + +func New(opt Options) Model { + if opt.Interval <= 0 { + opt.Interval = 2 * time.Second + } + if opt.GitEvery <= 0 { + opt.GitEvery = 5 * time.Minute + } + if opt.CompactAt <= 0 { + opt.CompactAt = sample.DefaultRules.CompactAt + } + if opt.Run == nil { + opt.Run = run + } + return Model{opt: opt, st: newStyles(true), width: 100, height: 30, history: newSeries(60)} +} + +// stepTimeout bounds every confirmed step; a git fetch over a dead network +// would otherwise hang the action forever. +const stepTimeout = 2 * time.Minute + +// run executes one step without a terminal: git and ssh fail instead of +// prompting for credentials on the cockpit's screen. +func run(argv []string) error { + ctx, cancel := context.WithTimeout(context.Background(), stepTimeout) + defer cancel() + c := exec.CommandContext(ctx, argv[0], argv[1:]...) + c.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0", "GIT_SSH_COMMAND=ssh -o BatchMode=yes") + return c.Run() +} + +func (m Model) Init() tea.Cmd { + cmds := []tea.Cmd{tea.RequestBackgroundColor, m.sampleCmd()} + if m.opt.History != nil { + load := m.opt.History + cmds = append(cmds, func() tea.Msg { return historyMsg(load()) }) + } + return tea.Batch(cmds...) +} + +func (m Model) doc() state.Doc { + return state.Doc{Snapshot: m.snap, Git: m.git, Recommendations: m.recs} +} + +func (m Model) sampleCmd() tea.Cmd { + src, on, git := m.opt.Source, m.opt.OnSnapshot, m.git + return func() tea.Msg { + s := src() + if on != nil { + on(state.Doc{Snapshot: s, Git: git, Recommendations: advice.Build(s, git)}) + } + return snapMsg(s) + } +} + +func (m *Model) gitCmd() tea.Cmd { + if m.opt.Git == nil || m.gitRunning { + return nil + } + m.gitRunning = true + var cwds []string + for _, s := range m.snap.Sessions { + cwds = append(cwds, s.Cwd) + } + scan := m.opt.Git + return func() tea.Msg { return gitMsg(scan(cwds)) } +} + +func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.BackgroundColorMsg: + m.st = newStyles(msg.IsDark()) + case tea.WindowSizeMsg: + m.width, m.height = msg.Width, msg.Height + case snapMsg: + first := !m.have + m.snap, m.have = sample.Snapshot(msg), true + m.history.push(m.snap.Machine) + if !first { // the first sample has no rates yet + m.hist.Append(history.FromSnapshot(m.snap)) + } + m.keepSelection() + m.recs = advice.Build(m.snap, m.git) + cmds := []tea.Cmd{tea.Tick(m.opt.Interval, func(time.Time) tea.Msg { return tickMsg{} })} + if first { + cmds = append(cmds, m.gitCmd()) + } + return m, tea.Batch(cmds...) + case tickMsg: + return m, m.sampleCmd() + case gitMsg: + r := gitscan.Report(msg) + m.git, m.gitRunning = &r, false + m.recs = advice.Build(m.snap, m.git) + return m, tea.Tick(m.opt.GitEvery, func(time.Time) tea.Msg { return gitTickMsg{} }) + case gitTickMsg: + return m, m.gitCmd() + case historyMsg: + loaded := history.Series(msg) + loaded.Merge(m.hist) + m.hist = loaded + case ranMsg: + if msg.err != nil { + m.flash = "failed: " + msg.cmd + ": " + msg.err.Error() + } else { + m.flash = "ran: " + msg.cmd + } + // The running tick loop picks up the result; starting another sample + // here would add a second loop and run two samples at once. + case tea.KeyPressMsg: + return m.key(msg.String()) + } + return m, nil +} + +func (m Model) key(k string) (tea.Model, tea.Cmd) { + if m.pending != nil { + a := *m.pending + m.pending = nil + // A destructive action needs a typed y; Enter alone is not consent. + if k == "y" || (k == "enter" && !a.destructive) { + idle := map[string]bool{} + for _, s := range m.snap.Sessions { + idle[s.Pane] = s.Status == "idle" + } + run, check := m.opt.Run, m.opt.Check + return m, func() tea.Msg { return runAction(a, run, check, idle) } + } + m.flash = "cancelled: " + a.String() + return m, nil + } + m.flash = "" + switch k { + case "q", "ctrl+c": + m.quitting = true + return m, tea.Quit + case "tab": + m.tab = (m.tab + 1) % len(tabNames) + case "shift+tab": + m.tab = (m.tab + len(tabNames) - 1) % len(tabNames) + case "up", "k": + m.move(-1) + case "down", "j": + m.move(1) + case "enter": + m.pending = m.primary() + case "x": + m.pending = m.secondary() + case "r": + if m.tab == tabGit { + if cmd := m.gitCmd(); cmd != nil { + m.flash = "scanning git…" + return m, cmd + } + } + default: + if len(k) == 1 && k[0] >= '1' && int(k[0]-'1') < len(tabNames) { + m.tab = int(k[0] - '1') + } + } + return m, nil +} + +func jump(pane string) *action { + return &action{label: "jump", steps: [][]string{{"tmux", "switch-client", "-t", pane}}} +} + +// primary is what Enter does on the current panel: never destructive. +func (m Model) primary() *action { + switch m.tab { + case tabSessions: + if s, ok := m.selected(); ok && s.Pane != "" { + return jump(s.Pane) + } + case tabProcs: + if o, ok := m.selectedOrphan(); ok && o.PaneAlive { + return jump(o.Pane) + } + case tabGit: + if wt, ok := m.selectedWorktree(); ok { + return &action{label: "shell", steps: [][]string{{"tmux", "new-window", "-c", wt.Path}}} + } + case tabRecs: + if r, ok := m.selectedRec(); ok { + return fromAdvice(r.Primary) + } + } + return nil +} + +// secondary is what x does: the destructive action, behind a typed y. +func (m Model) secondary() *action { + switch m.tab { + case tabProcs: + if o, ok := m.selectedOrphan(); ok { + return &action{label: "kill", steps: [][]string{o.Kill}, destructive: true, + guards: []advice.Guard{{PID: o.PID, StartTicks: o.Start}}} + } + case tabGit: + if wt, ok := m.selectedWorktree(); ok && wt.Safe { + return &action{label: "remove", steps: [][]string{wt.Remove}, destructive: true, + guards: []advice.Guard{{Worktree: wt.Path}}} + } + case tabRecs: + if r, ok := m.selectedRec(); ok { + return fromAdvice(r.Second) + } + } + return nil +} + +func clampMove(i, d, n int) int { return min(max(i+d, 0), max(n-1, 0)) } + +func (m *Model) move(d int) { + switch m.tab { + case tabProcs: + if o := m.snap.Orphans; len(o) > 0 { + m.orphanPID = o[clampMove(m.orphanIndex(), d, len(o))].PID + } + case tabGit: + m.gitSel = clampMove(m.gitSel, d, len(m.worktreeRows())) + case tabRecs: + m.recSel = clampMove(m.recSel, d, len(m.recs)) + case tabSessions: + if ss := m.snap.Sessions; len(ss) > 0 { + m.selPID = ss[clampMove(m.selIndex(), d, len(ss))].PID + } + } +} + +func (m Model) selIndex() int { + for i, s := range m.snap.Sessions { + if s.PID == m.selPID { + return i + } + } + return 0 +} + +func (m Model) selected() (sample.Session, bool) { + if len(m.snap.Sessions) == 0 { + return sample.Session{}, false + } + return m.snap.Sessions[m.selIndex()], true +} + +func (m Model) selectedRec() (advice.Rec, bool) { + if len(m.recs) == 0 { + return advice.Rec{}, false + } + return m.recs[min(m.recSel, len(m.recs)-1)], true +} + +// keepSelection follows the selected session by PID across re-sorts. +func (m *Model) keepSelection() { + if _, ok := m.selected(); ok { + m.selPID = m.snap.Sessions[m.selIndex()].PID + } +} + +func (m Model) View() tea.View { + v := tea.NewView(m.render()) + v.AltScreen = true + return v +} + +// runAction runs each step whose guard still holds, skipping the others. +func runAction(a action, run func([]string) error, check func(advice.Guard) error, idle map[string]bool) ranMsg { + ran, skipped, why := 0, 0, "" + for i, step := range a.steps { + if i < len(a.guards) { + g := a.guards[i] + err := error(nil) + if g.IdlePane != "" && !idle[g.IdlePane] { + err = fmt.Errorf("the session in %s is no longer idle", g.IdlePane) + } else if check != nil { + err = check(g) + } + if err != nil { + skipped++ + why = err.Error() + continue + } + } + if err := run(step); err != nil { + return ranMsg{strings.Join(step, " "), err} + } + ran++ + } + if skipped > 0 { + return ranMsg{a.String(), fmt.Errorf("ran %d, skipped %d: %s", ran, skipped, why)} + } + return ranMsg{a.String(), nil} +} diff --git a/skills/tui/internal/ui/panels.go b/skills/tui/internal/ui/panels.go new file mode 100644 index 0000000..7c35db0 --- /dev/null +++ b/skills/tui/internal/ui/panels.go @@ -0,0 +1,179 @@ +package ui + +import ( + "fmt" + "os" + "strings" + + "github.com/NimbleMarkets/ntcharts/v2/sparkline" + + "github.com/Lutherwaves/builder/skills/tui/internal/sample" +) + +func (m Model) region(label string, w int) []string { + return []string{"", m.st.label.Render(fit(" "+strings.ToUpper(label), w))} +} + +// regionPath is a region label that starts with a path: paths keep their case. +func (m Model) regionPath(path, rest string, w int) []string { + return []string{"", fit(" "+m.st.muted.Render(tilde(path))+m.st.label.Render(strings.ToUpper(rest)), w)} +} + +var home, _ = os.UserHomeDir() + +// tilde shortens the home directory to ~ for display. +func tilde(s string) string { + if home == "" || home == "/" { + return s + } + return strings.ReplaceAll(s, home, "~") +} + +// alertLines puts what needs attention first; nothing renders when all is well. +func (m Model) alertLines(w int) []string { + if len(m.snap.Alerts) == 0 { + return nil + } + out := m.region("alerts", w) + for _, a := range m.snap.Alerts { + mark := m.st.warn.Render("▲ ") + if a.Level == "crit" { + mark = m.st.neg.Render("! ") + } + out = append(out, fit(" "+mark+m.st.text.Render(a.Title)+m.st.muted.Render(" "+a.Evidence), w)) + } + return out +} + +func (m Model) chart(xs []float64, w, h int, top float64) []string { + sl := sparkline.New(w, h, sparkline.WithStyle(m.st.muted), sparkline.WithMaxValue(top)) + sl.PushAll(xs) + sl.Draw() + return strings.Split(sl.View(), "\n") +} + +func (m Model) machine(w int) []string { + mc, st, h := m.snap.Machine, m.st, m.history + out := m.alertLines(w) + + out = append(out, m.region("cpu", w)...) + out = append(out, fit(" "+st.text.Render(fmt.Sprintf("%3.0f%%", mc.CPU))+st.muted.Render(fmt.Sprintf( + " load %.1f / %.1f / %.1f pressure cpu %.0f%% io %.0f%% memory %.0f%%", + mc.Load[0], mc.Load[1], mc.Load[2], mc.PSI.CPU, mc.PSI.IO, mc.PSI.Memory)), w)) + if len(mc.Cores) > 0 { + var b strings.Builder + for _, c := range mc.Cores { + b.WriteRune(blocks[min(int(c/100*float64(len(blocks)-1)+0.5), len(blocks)-1)]) + } + out = append(out, fit(" "+st.label.Render("cores ")+st.muted.Render(b.String()), w)) + } + for _, line := range m.chart(h.cpu, min(w-2, 120), 3, 100) { + out = append(out, " "+line) + } + + out = append(out, m.region("network and disk", w)...) + netLine := fmt.Sprintf("↓ %s/s ↑ %s/s disk read %s/s write %s/s", size(mc.NetRx), size(mc.NetTx), size(mc.DiskRead), size(mc.DiskWrite)) + switch { + case mc.LatencyErr != "": + netLine += " " + st.neg.Render("! connect failed: "+mc.LatencyErr) + case mc.Latency > 0: + netLine += fmt.Sprintf(" connect %d ms", mc.Latency.Milliseconds()) + } + out = append(out, fit(" "+st.text.Render(netLine), w)) + + out = append(out, m.region("memory", w)...) + out = append(out, fit(" "+st.text.Render(gib(mc.MemTotal-mc.MemAvail)+" used")+st.muted.Render( + fmt.Sprintf(" of %s %s available swap %s", gib(mc.MemTotal), gib(mc.MemAvail), gib(mc.SwapUsed))), w)) + + out = append(out, m.region("thermal and power", w)...) + facts := []string{} + if mc.TempC > 0 { + facts = append(facts, fmt.Sprintf("package %.0f °C", mc.TempC)) + } + for _, p := range mc.Power { + if p.Watts > 0 { + facts = append(facts, fmt.Sprintf("%s %.0f W", strings.ReplaceAll(p.Name, "_", " "), p.Watts)) + } + } + if len(facts) == 0 { + facts = append(facts, "no package sensors readable") + } + out = append(out, fit(" "+st.text.Render(strings.Join(facts, st.faint.Render(" · "))), w)) + for _, g := range mc.GPUs { + out = append(out, fit(" "+st.muted.Render(fmt.Sprintf("gpu %d %3.0f%% %.0f °C %.0f/%.0f MiB", g.Index, g.Util, g.TempC, g.MemUsed, g.MemTot)), w)) + } + + if len(m.snap.Containers) > 0 { + out = append(out, m.region(fmt.Sprintf("containers %d", len(m.snap.Containers)), w)...) + for _, g := range m.snap.Groups { + out = append(out, fit(" "+st.text.Render(fmt.Sprintf("%s %.0f%% cpu", g.Name, g.CPU))+ + st.muted.Render(fmt.Sprintf(" %.1f%% of the machine · %d containers", g.Share, g.Containers)), w)) + } + for _, c := range m.snap.Containers[:min(5, len(m.snap.Containers))] { + out = append(out, fit(" "+st.muted.Render(pad(fmt.Sprintf("%3.0f%%", c.CPU), 7)+c.Name), w)) + } + } + return out +} + +func (m Model) procs(w int) []string { + st := m.st + out := m.region("detached busy loops", w) + if len(m.snap.Orphans) == 0 { + out = append(out, st.faint.Render(" none")) + } + sel := m.orphanIndex() + for i, o := range m.snap.Orphans { + pane := "pane gone" + if o.Pane == "" { + pane = "no pane" + } else if o.PaneAlive { + pane = o.Pane + " " + o.Target + } else { + pane = o.Pane + " (gone)" + } + line := " " + st.neg.Render("! ") + st.text.Render(pad(fmt.Sprintf("%d %s", o.PID, o.Comm), 16)) + + st.text.Render(pad(fmt.Sprintf("%3.0f%%", o.CPU), 7)) + st.muted.Render(pad("for "+sample.Human(o.HotFor), 12)) + + st.muted.Render(pad(pane, 22)) + st.faint.Render(o.Cmdline) + if i == sel { + line = st.selected.Render(fit(line, w)) + } + out = append(out, fit(line, w)) + } + if o, ok := m.selectedOrphan(); ok { + out = append(out, "", fit(" "+st.muted.Render(fmt.Sprintf("parent %s · age %s · cwd %s", o.Parent, sample.Human(o.Age), o.Cwd)), w)) + out = append(out, fit(" "+st.label.Render("x ")+st.muted.Render(strings.Join(o.Kill, " ")), w)) + } + + out = append(out, m.region("top processes", w)...) + out = append(out, st.label.Render(fit(" "+pad("PID", 9)+pad("COMMAND", 18)+pad("CPU", 7)+pad("MEM", 8)+"OWNER", w))) + names := map[string]string{} + for _, s := range m.snap.Sessions { + names["pane "+s.Pane] = s.Name + } + for _, p := range m.snap.Top { + owner := p.Owner + if n, ok := names[owner]; ok { + owner += " · " + n + } + out = append(out, fit(" "+st.muted.Render(pad(fmt.Sprint(p.PID), 9))+st.text.Render(pad(p.Comm, 18))+ + st.text.Render(pad(fmt.Sprintf("%3.0f%%", p.CPU), 7))+st.muted.Render(pad(size(float64(p.RSS)), 8))+st.muted.Render(owner), w)) + } + return out +} + +func (m Model) orphanIndex() int { + for i, o := range m.snap.Orphans { + if o.PID == m.orphanPID { + return i + } + } + return 0 +} + +func (m Model) selectedOrphan() (sample.Orphan, bool) { + if len(m.snap.Orphans) == 0 { + return sample.Orphan{}, false + } + return m.snap.Orphans[m.orphanIndex()], true +} diff --git a/skills/tui/internal/ui/panels2.go b/skills/tui/internal/ui/panels2.go new file mode 100644 index 0000000..7e79d69 --- /dev/null +++ b/skills/tui/internal/ui/panels2.go @@ -0,0 +1,257 @@ +package ui + +import ( + "fmt" + "path/filepath" + "strings" + "time" + + "github.com/Lutherwaves/builder/skills/tui/internal/advice" + "github.com/Lutherwaves/builder/skills/tui/internal/gitscan" + "github.com/Lutherwaves/builder/skills/tui/internal/history" + "github.com/Lutherwaves/builder/skills/tui/internal/sample" +) + +type wtRow struct { + repo string + wt gitscan.Worktree + main bool +} + +// worktreeRows are the worktrees worth a line: the main checkout, the ones +// sessions work in, and merged ones. The rest are counted, not listed. +func (m Model) worktreeRows() []wtRow { + if m.git == nil { + return nil + } + var rows []wtRow + for _, r := range m.git.Repos { + for i, wt := range r.Worktrees { + if i == 0 || wt.Sessions > 0 || wt.Merged { + rows = append(rows, wtRow{r.Path, wt, i == 0}) + } + } + } + return rows +} + +func (m Model) selectedWorktree() (gitscan.Worktree, bool) { + rows := m.worktreeRows() + if len(rows) == 0 { + return gitscan.Worktree{}, false + } + return rows[min(m.gitSel, len(rows)-1)].wt, true +} + +func (m Model) gitPanel(w int) []string { + st := m.st + var out []string + if len(m.snap.GitPolling) > 0 { + out = append(out, m.region("git run by agents, last 10 min", w)...) + for _, p := range m.snap.GitPolling[:min(5, len(m.snap.GitPolling))] { + style := st.muted + if p.Cores >= 0.5 { + style = st.warn + } + out = append(out, fit(" "+style.Render(pad(fmt.Sprintf("%.2f cores", p.Cores), 12))+st.muted.Render(tilde(p.Checkout)), w)) + } + } + if m.git == nil { + return append(out, "", st.faint.Render(" scanning repositories…")) + } + rows := m.worktreeRows() + sel := min(m.gitSel, max(len(rows)-1, 0)) + i := 0 + for _, r := range m.git.Repos { + safe := 0 + for _, wt := range r.Worktrees { + if wt.Safe { + safe++ + } + } + rest := fmt.Sprintf(" · %d worktrees", len(r.Worktrees)) + if safe > 0 { + rest += fmt.Sprintf(" · %d safe to remove", safe) + } + out = append(out, m.regionPath(r.Path, rest, w)...) + if r.Behind > 0 { + out = append(out, fit(" "+st.warn.Render("▲ ")+st.text.Render(fmt.Sprintf("%s is %d commits behind its remote", r.Main, r.Behind)), w)) + } + out = append(out, st.label.Render(fit(" "+pad("WORKTREE", 36)+pad("BRANCH", 28)+pad("AGENTS", 8)+pad("DIRTY", 8)+"STATE", w))) + for ; i < len(rows) && rows[i].repo == r.Path; i++ { + wt := rows[i].wt + name := filepath.Base(wt.Path) + if rows[i].main { + name += " (main)" + } + dirty := st.faint.Render(pad("—", 8)) + if wt.Dirty >= 0 { + dirty = st.muted.Render(pad(fmt.Sprint(wt.Dirty), 8)) + if wt.Dirty >= 200 { + dirty = st.warn.Render(pad(fmt.Sprint(wt.Dirty), 8)) + } + } + state := "" + switch { + case wt.Safe: + state = st.accent.Render("✓ safe to remove") + case wt.Merged && wt.InUse: + state = st.muted.Render("merged, in use") + case wt.Merged && wt.Dirty != 0: + state = st.muted.Render("merged, has changes") + } + agents := "" + if wt.Sessions > 0 { + agents = fmt.Sprint(wt.Sessions) + } + line := " " + st.text.Render(pad(name, 36)) + st.muted.Render(pad(wt.Branch, 28)) + st.text.Render(pad(agents, 8)) + dirty + state + if i == sel { + line = st.selected.Render(fit(line, w)) + } + out = append(out, fit(line, w)) + } + } + for _, e := range m.git.Errors { + out = append(out, fit(" "+st.neg.Render("! ")+st.muted.Render(e), w)) + } + out = append(out, "", fit(" "+st.faint.Render(fmt.Sprintf("scanned %s ago in %s · r rescans", + sample.Human(time.Since(m.git.At)), m.git.Took.Round(time.Millisecond))), w)) + if wt, ok := m.selectedWorktree(); ok { + out = append(out, fit(" "+st.label.Render("⏎ ")+st.muted.Render("tmux new-window -c "+wt.Path), w)) + if wt.Safe { + out = append(out, fit(" "+st.label.Render("x ")+st.muted.Render(strings.Join(wt.Remove, " ")), w)) + } + } + return out +} + +func (m Model) recsPanel(w int) []string { + st := m.st + out := m.alertLines(w) + out = append(out, m.region(fmt.Sprintf("%d recommendations", len(m.recs)), w)...) + if len(m.recs) == 0 { + return append(out, st.faint.Render(" nothing to do")) + } + sel := min(m.recSel, len(m.recs)-1) + for i, r := range m.recs { + mark := st.faint.Render("○ ") + switch r.Level { + case "crit": + mark = st.neg.Render("! ") + case "warn": + mark = st.warn.Render("▲ ") + } + line := " " + mark + st.text.Render(tilde(r.Title)) + if i == sel { + line = st.selected.Render(fit(line, w)) + } + out = append(out, fit(line, w)) + } + r := m.recs[sel] + out = append(out, "", st.hair.Render(strings.Repeat("─", w)), fit(" "+st.text.Render(tilde(r.Title)), w)) + out = append(out, wrap(" "+tilde(r.Evidence), w, st.muted.Render)...) + for _, a := range []struct { + key string + act *advice.Action + }{{"⏎", r.Primary}, {"x", r.Second}} { + if a.act == nil { + continue + } + cmd := strings.Join(a.act.Steps[0], " ") + if n := len(a.act.Steps); n > 1 { + cmd += fmt.Sprintf(" (+%d more)", n-1) + } + label := a.act.Label + if a.act.Destructive { + label += ", asks y" + } + out = append(out, fit(" "+st.label.Render(a.key+" ")+st.text.Render(label+": ")+st.muted.Render(cmd), w)) + } + return out +} + +func wrap(s string, w int, render func(...string) string) []string { + var out []string + line := "" + for _, word := range strings.Fields(s) { + if len(line)+len(word)+1 > w-2 && line != "" { + out = append(out, render(" "+line)) + line = "" + } + if line != "" { + line += " " + } + line += word + } + if line != "" { + out = append(out, render(" "+line)) + } + return out +} + +// bucket reduces points to n values keeping each bucket's peak: a spike +// matters more than an average on a history chart. +func bucket(pts []history.Point, n int, f func(history.Point) float64) []float64 { + if len(pts) == 0 || n <= 0 { + return nil + } + out := make([]float64, 0, n) + per := max((len(pts)+n-1)/n, 1) // ceil: never more columns than n + for i := 0; i < len(pts); i += per { + peak := 0.0 + for _, p := range pts[i:min(i+per, len(pts))] { + peak = max(peak, f(p)) + } + out = append(out, peak) + } + return out +} + +func (m Model) historyPanel(w int) []string { + st := m.st + pts := m.hist.Points + if len(pts) < 2 { + return []string{"", st.faint.Render(" collecting history: one point every 30 s")} + } + span := pts[len(pts)-1].At.Sub(pts[0].At) + out := m.region(fmt.Sprintf("last %s · peak per column", sample.Human(span)), w) + cw := min(w-28, 140) + charts := []struct { + name string + unit string + f func(history.Point) float64 + }{ + {"cpu", "%", func(p history.Point) float64 { return p.CPU }}, + {"load1", "", func(p history.Point) float64 { return p.Load1 }}, + {"pressure cpu", "%", func(p history.Point) float64 { return p.PSICPU }}, + {"package temp", "°C", func(p history.Point) float64 { return p.TempC }}, + {"mem available", "G", func(p history.Point) float64 { return p.MemAvailGB }}, + {"swap", "G", func(p history.Point) float64 { return p.SwapGB }}, + } + for _, c := range charts { + xs := bucket(pts, cw, c.f) + lo, hi := c.f(pts[0]), c.f(pts[0]) + for _, p := range pts { + lo, hi = min(lo, c.f(p)), max(hi, c.f(p)) + } + now := c.f(pts[len(pts)-1]) + // Scale min to max so two rows show the shape; the labels carry the + // absolute values. The floor sits just under min so min still draws. + floor := lo - (hi-lo)*0.05 + shifted := make([]float64, len(xs)) + for i, x := range xs { + shifted[i] = x - floor + } + lines := m.chart(shifted, len(shifted), 2, hi-floor) + out = append(out, "") + label := []string{ + st.label.Render(pad(strings.ToUpper(c.name), 24)), + st.text.Render(pad(fmt.Sprintf("now %.1f%s", now, c.unit), 24)), + } + for i, l := range lines { + out = append(out, fit(" "+label[min(i, 1)]+l, w)) + } + out = append(out, fit(" "+pad("", 24)+st.faint.Render(fmt.Sprintf("min %.1f%s max %.1f%s", lo, c.unit, hi, c.unit)), w)) + } + return out +} diff --git a/skills/tui/internal/ui/summary.go b/skills/tui/internal/ui/summary.go new file mode 100644 index 0000000..678f8ce --- /dev/null +++ b/skills/tui/internal/ui/summary.go @@ -0,0 +1,53 @@ +package ui + +import ( + "fmt" + + "github.com/Lutherwaves/builder/skills/tui/internal/advice" + "strings" + + "github.com/Lutherwaves/builder/skills/tui/internal/state" +) + +// Summary is the plain-text form for an agent or a log: the machine line, +// alerts, then the ranked recommendations with their exact commands. +func Summary(s state.Doc) string { + var b strings.Builder + mc := s.Machine + fmt.Fprintf(&b, "at %s\n", s.At.Format("2006-01-02 15:04:05")) + fmt.Fprintf(&b, "machine: cpu %.0f%% load %.1f mem %s/%s swap %s psi cpu %.0f io %.0f mem %.0f\n", + mc.CPU, mc.Load[0], gib(mc.MemTotal-mc.MemAvail), gib(mc.MemTotal), gib(mc.SwapUsed), mc.PSI.CPU, mc.PSI.IO, mc.PSI.Memory) + busy := 0 + for _, x := range s.Sessions { + if x.Busy { + busy++ + } + } + fmt.Fprintf(&b, "sessions: %d (busy %d, idle %d), %d panes without an agent\n", len(s.Sessions), busy, len(s.Sessions)-busy, len(s.IdlePanes)) + for _, a := range s.Alerts { + fmt.Fprintf(&b, "alert %s: %s (%s)\n", a.Level, a.Title, a.Evidence) + } + for _, r := range s.Recommendations { + fmt.Fprintf(&b, "%s: %s\n why: %s\n", r.Level, r.Title, r.Evidence) + for _, a := range []*advice.Action{r.Primary, r.Second} { + if a == nil { + continue + } + confirm := "" + if a.Destructive { + confirm = " (destructive)" + } + for i, step := range a.Steps { + if i == 3 { + fmt.Fprintf(&b, " … %d more like these (status JSON lists every step)\n", len(a.Steps)-3) + break + } + fmt.Fprintf(&b, " %s%s: %s\n", a.Label, confirm, strings.Join(step, " ")) + } + } + } + for _, e := range s.Errors { + fmt.Fprintf(&b, "error: %s\n", e) + } + return b.String() +} diff --git a/skills/tui/internal/ui/theme.go b/skills/tui/internal/ui/theme.go new file mode 100644 index 0000000..28ebbd5 --- /dev/null +++ b/skills/tui/internal/ui/theme.go @@ -0,0 +1,63 @@ +package ui + +import ( + "image/color" + + "charm.land/lipgloss/v2" +) + +// Theme is near-monochrome and warm. Color is an event: the accent marks the +// selected row and the active panel only; status uses muted tints and always +// carries a glyph and a word, never hue alone. +type Theme struct { + Text, Muted, Faint, Hair, Accent, AccentWash, Warn, Neg color.Color +} + +var darkTheme = Theme{ + Text: lipgloss.Color("#EBE6DC"), + Muted: lipgloss.Color("#A69D8D"), + Faint: lipgloss.Color("#776F60"), + Hair: lipgloss.Color("#2E2921"), + Accent: lipgloss.Color("#C3A56A"), + AccentWash: lipgloss.Color("#221D14"), + Warn: lipgloss.Color("#B98F44"), + Neg: lipgloss.Color("#B0563F"), +} + +var lightTheme = Theme{ + Text: lipgloss.Color("#1C1813"), + Muted: lipgloss.Color("#5F5A4C"), + Faint: lipgloss.Color("#8A8170"), + Hair: lipgloss.Color("#DDD5C3"), + Accent: lipgloss.Color("#9A7B3F"), + AccentWash: lipgloss.Color("#ECE3CF"), + Warn: lipgloss.Color("#8A6420"), + Neg: lipgloss.Color("#9E4A34"), +} + +type styles struct { + theme Theme + text, muted, faint, hair, accent lipgloss.Style + label, selected, warn, neg, tabActive lipgloss.Style +} + +func newStyles(dark bool) styles { + t := lightTheme + if dark { + t = darkTheme + } + fg := func(c color.Color) lipgloss.Style { return lipgloss.NewStyle().Foreground(c) } + return styles{ + theme: t, + text: fg(t.Text), + muted: fg(t.Muted), + faint: fg(t.Faint), + hair: fg(t.Hair), + accent: fg(t.Accent), + label: fg(t.Faint), + selected: lipgloss.NewStyle().Foreground(t.Text).Background(t.AccentWash), + warn: fg(t.Warn), + neg: fg(t.Neg), + tabActive: fg(t.Accent).Underline(true), + } +} diff --git a/skills/tui/internal/ui/ui_test.go b/skills/tui/internal/ui/ui_test.go new file mode 100644 index 0000000..877e704 --- /dev/null +++ b/skills/tui/internal/ui/ui_test.go @@ -0,0 +1,219 @@ +package ui + +import ( + "bytes" + "context" + "fmt" + "os" + "path/filepath" + "runtime" + "strings" + "sync" + "testing" + "time" + + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + + "github.com/Lutherwaves/builder/skills/tui/internal/advice" + "github.com/Lutherwaves/builder/skills/tui/internal/procfs" + "github.com/Lutherwaves/builder/skills/tui/internal/sample" + "github.com/Lutherwaves/builder/skills/tui/internal/state" +) + +const fixture = "../../testdata/machine" + +func fixtureSource() Source { + s := &sample.Sampler{ + FS: procfs.FS{Root: filepath.Join(fixture, "proc")}, + Home: filepath.Join(fixture, "home"), + Tmux: func() ([]byte, error) { return os.ReadFile(filepath.Join(fixture, "tmux-panes.txt")) }, + Rules: sample.DefaultRules, + } + return s.Sample +} + +func loaded(t *testing.T, width int) Model { + t.Helper() + m := New(Options{Source: fixtureSource()}) + next, _ := m.Update(tea.WindowSizeMsg{Width: width, Height: 30}) + next, _ = next.Update(snapMsg(fixtureSource()())) + return next.(Model) +} + +func TestViewFitsWidth(t *testing.T) { + for _, w := range []int{80, 100, 160} { + out := loaded(t, w).render() + for i, line := range strings.Split(out, "\n") { + if lw := lipgloss.Width(line); lw > w { + t.Fatalf("width %d: line %d is %d wide: %q", w, i, lw, line) + } + } + for _, want := range []string{"app-feature", "app-review", "! compact", "▲ clear", "fresh", "1 without an agent: notes:1.1 zsh"} { + if !strings.Contains(out, want) { + t.Fatalf("width %d: view lacks %q:\n%s", w, want, out) + } + } + } +} + +func key(m tea.Model, k string) (tea.Model, tea.Cmd) { + r := []rune(k) + msg := tea.KeyPressMsg{Text: k, Code: r[0]} + switch k { + case "enter": + msg = tea.KeyPressMsg{Code: tea.KeyEnter} + case "down": + msg = tea.KeyPressMsg{Code: tea.KeyDown} + case "esc": + msg = tea.KeyPressMsg{Code: tea.KeyEsc} + } + return m.Update(msg) +} + +func TestJumpPrintsCommandThenRunsOnConfirm(t *testing.T) { + var ran []string + m := loaded(t, 100) + m.opt.Run = func(argv []string) error { ran = argv; return nil } + + next, _ := key(m, "enter") + out := next.(Model).render() + if !strings.Contains(out, "tmux switch-client -t %1") || ran != nil { + t.Fatalf("enter must print the command and wait; ran=%v\n%s", ran, out) + } + next, cmd := key(next, "enter") + if cmd == nil { + t.Fatal("confirm returned no command") + } + next.Update(cmd()) + if strings.Join(ran, " ") != "tmux switch-client -t %1" { + t.Fatalf("ran %v", ran) + } +} + +func TestEscCancelsAction(t *testing.T) { + m := loaded(t, 100) + m.opt.Run = func([]string) error { t.Fatal("must not run"); return nil } + next, _ := key(m, "enter") + next, cmd := key(next, "esc") + if cmd != nil || !strings.Contains(next.(Model).render(), "cancelled") { + t.Fatal("esc must cancel without running") + } +} + +func TestDestructiveNeedsTypedYes(t *testing.T) { + m := loaded(t, 100) + m.opt.Run = func([]string) error { t.Fatal("enter must not confirm a destructive action"); return nil } + m.pending = &action{steps: [][]string{{"kill", "123"}}, destructive: true} + if _, cmd := key(m, "enter"); cmd != nil { + t.Fatal("enter ran a destructive action") + } +} + +func TestSelectionFollowsPID(t *testing.T) { + m := loaded(t, 100) + next, _ := key(m, "down") + sel, _ := next.(Model).selected() + next, _ = next.Update(snapMsg(fixtureSource()())) + again, _ := next.(Model).selected() + if sel.PID != again.PID { + t.Fatalf("selection moved from %d to %d on resample", sel.PID, again.PID) + } +} + +// syncBuffer lets the program write while the test reads. +type syncBuffer struct { + mu sync.Mutex + b bytes.Buffer +} + +func (s *syncBuffer) Write(p []byte) (int, error) { + s.mu.Lock() + defer s.mu.Unlock() + return s.b.Write(p) +} + +func (s *syncBuffer) String() string { + s.mu.Lock() + defer s.mu.Unlock() + return s.b.String() +} + +// TestSmoke runs the real program against the fixture machine, waits for the +// first rendered sample, and quits. +func TestSmoke(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + var out syncBuffer + sampled := make(chan struct{}, 1) + m := New(Options{ + Source: fixtureSource(), + Interval: 50 * time.Millisecond, + OnSnapshot: func(state.Doc) { + select { + case sampled <- struct{}{}: + default: + } + }, + }) + p := tea.NewProgram(m, tea.WithContext(ctx), tea.WithInput(nil), tea.WithOutput(&out), tea.WithWindowSize(100, 30)) + done := make(chan error, 1) + go func() { _, err := p.Run(); done <- err }() + + select { + case <-sampled: + case <-ctx.Done(): + t.Fatal("no sample within 10s") + } + deadline := time.Now().Add(5 * time.Second) + for !strings.Contains(out.String(), "app-feature") && time.Now().Before(deadline) { + time.Sleep(20 * time.Millisecond) + } + p.Quit() + if err := <-done; err != nil { + t.Fatal(err) + } + if !strings.Contains(out.String(), "app-feature") { + t.Fatalf("rendered output lacks the fixture session:\n%q", out.String()) + } +} + +func TestGuardsSkipStaleSteps(t *testing.T) { + var ran []string + run := func(argv []string) error { ran = append(ran, argv[len(argv)-1]); return nil } + check := func(g advice.Guard) error { + if g.Worktree == "/w/b" { + return fmt.Errorf("/w/b has 1 changed or untracked paths") + } + return nil + } + a := action{ + steps: [][]string{{"rm", "/w/a"}, {"rm", "/w/b"}, {"type", "%1"}, {"type", "%2"}}, + guards: []advice.Guard{{Worktree: "/w/a"}, {Worktree: "/w/b"}, {IdlePane: "%1"}, {IdlePane: "%2"}}, + } + msg := runAction(a, run, check, map[string]bool{"%1": true, "%2": false}) + if strings.Join(ran, ",") != "/w/a,%1" { + t.Fatalf("ran %v", ran) + } + if msg.err == nil || !strings.Contains(msg.err.Error(), "ran 2, skipped 2") { + t.Fatalf("result %v", msg.err) + } +} + +func TestRanMsgStartsNoSecondSampleLoop(t *testing.T) { + m := loaded(t, 100) + if _, cmd := m.Update(ranMsg{cmd: "x"}); cmd != nil { + t.Fatal("a finished action must not start another sample; the tick loop already runs") + } +} + +// A confirmed step must never stop to ask for credentials on the cockpit's +// terminal. +func TestRunNeverPrompts(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("uses sh") + } + if err := run([]string{"sh", "-c", `test "$GIT_TERMINAL_PROMPT" = 0 && test -n "$GIT_SSH_COMMAND"`}); err != nil { + t.Fatalf("step ran with prompts allowed: %v", err) + } +} diff --git a/skills/tui/internal/ui/view.go b/skills/tui/internal/ui/view.go new file mode 100644 index 0000000..8cc7a0d --- /dev/null +++ b/skills/tui/internal/ui/view.go @@ -0,0 +1,393 @@ +package ui + +import ( + "fmt" + "path/filepath" + "strings" + "time" + + "charm.land/lipgloss/v2" + "github.com/charmbracelet/x/ansi" + + "github.com/Lutherwaves/builder/skills/tui/internal/sample" +) + +// series keeps the last n machine samples for the header sparklines. +type series struct { + n int + cpu, mem, net, io, lat, load []float64 +} + +func newSeries(n int) *series { return &series{n: n} } + +func (s *series) push(m sample.Machine) { + add := func(xs *[]float64, v float64) { + *xs = append(*xs, v) + if len(*xs) > s.n { + *xs = (*xs)[len(*xs)-s.n:] + } + } + add(&s.cpu, m.CPU) + if m.MemTotal > 0 { + add(&s.mem, float64(m.MemTotal-m.MemAvail)) + } + add(&s.net, m.NetRx+m.NetTx) + add(&s.io, m.DiskRead+m.DiskWrite) + add(&s.lat, float64(m.Latency)) + add(&s.load, m.Load[0]) +} + +var blocks = []rune("▁▂▃▄▅▆▇█") + +// spark draws the last w values scaled to their own max (or ceil if larger). +func spark(xs []float64, w int, ceil float64) string { + if len(xs) > w { + xs = xs[len(xs)-w:] + } + top := ceil + for _, x := range xs { + top = max(top, x) + } + var b strings.Builder + for _, x := range xs { + i := 0 + if top > 0 { + i = min(int(x/top*float64(len(blocks)-1)+0.5), len(blocks)-1) + } + b.WriteRune(blocks[i]) + } + return b.String() +} + +func (m Model) render() string { + if m.quitting { + return "" + } + w := max(m.width, 60) + var out []string + out = append(out, m.header(w), m.tabs(w), m.st.hair.Render(strings.Repeat("─", w))) + if !m.have { + return strings.Join(append(out, "", m.st.faint.Render(" sampling…")), "\n") + } + var body []string + switch m.tab { + case tabSessions: + body = m.sessions(w, m.height-len(out)-2) + case tabMachine: + body = m.machine(w) + case tabProcs: + body = m.procs(w) + case tabGit: + body = m.gitPanel(w) + case tabRecs: + body = m.recsPanel(w) + case tabHistory: + body = m.historyPanel(w) + } + if len(body) > m.height-len(out)-1 { + body = body[:max(m.height-len(out)-1, 0)] + } + out = append(out, body...) + for len(out) < m.height-1 { + out = append(out, "") + } + out = append(out, m.footer(w)) + return strings.Join(out, "\n") +} + +// header shows every metric with its trend when the terminal is wide, drops +// the trends when it is not, then drops metrics from the right. +func (m Model) header(w int) string { + mc, h, st := m.snap.Machine, m.history, m.st + sep := st.hair.Render(" │ ") + type metric struct{ label, value, trend string } + ms := []metric{ + {"cpu", fmt.Sprintf("%3.0f%%", mc.CPU), spark(h.cpu, 8, 100)}, + {"mem", fmt.Sprintf("%s/%s", gib(mc.MemTotal-mc.MemAvail), gib(mc.MemTotal)), spark(h.mem, 8, float64(mc.MemTotal))}, + {"load", fmt.Sprintf("%.1f", mc.Load[0]), spark(h.load, 8, 0)}, + {"net", "↓" + size(mc.NetRx) + " ↑" + size(mc.NetTx), spark(h.net, 8, 0)}, + {"io", "r " + size(mc.DiskRead) + " w " + size(mc.DiskWrite), spark(h.io, 8, 0)}, + } + if mc.LatencyErr != "" { + ms = append(ms, metric{"lat", st.neg.Render("! down"), ""}) + } else if mc.Latency > 0 { + ms = append(ms, metric{"lat", fmt.Sprintf("%dms", mc.Latency.Milliseconds()), spark(h.lat, 8, 0)}) + } + build := func(n int, trends bool) string { + parts := []string{st.accent.Render("builder")} + for _, x := range ms[:n] { + p := st.label.Render(x.label) + " " + st.text.Render(x.value) + if trends && x.trend != "" { + p += " " + st.faint.Render(x.trend) + } + parts = append(parts, p) + } + return " " + strings.Join(parts, sep) + } + for _, trends := range []bool{true, false} { + for n := len(ms); n > 0; n-- { + if line := build(n, trends); lipgloss.Width(line) <= w { + return fit(line, w) + } + } + } + return fit(build(1, false), w) +} + +const ( + tabSessions = iota + tabMachine + tabProcs + tabGit + tabRecs + tabHistory +) + +var tabNames = []string{"sessions", "machine", "procs", "git", "recs", "history"} + +func (m Model) tabs(w int) string { + var parts []string + for i, name := range tabNames { + label := fmt.Sprintf("%d %s", i+1, strings.ToUpper(name)) + switch { + case i == m.tab: + parts = append(parts, m.st.tabActive.Render(label)) + case i == tabProcs && len(m.snap.Orphans) > 0: + parts = append(parts, m.st.neg.Render(label+" !")) + case i == tabRecs && len(m.recs) > 0: + parts = append(parts, m.st.muted.Render(fmt.Sprintf("%s %d", label, len(m.recs)))) + default: + parts = append(parts, m.st.faint.Render(label)) + } + } + line := " " + strings.Join(parts, " ") + if n := len(m.snap.Alerts); n > 0 { + alert := m.st.warn.Render(fmt.Sprintf("▲ %d alert", n)) + if n > 1 { + alert += m.st.warn.Render("s") + } + line += strings.Repeat(" ", max(w-lipgloss.Width(line)-lipgloss.Width(alert)-1, 2)) + alert + } + return fit(line, w) +} + +func (m Model) footer(w int) string { + st := m.st + if m.pending != nil { + confirm := "⏎ run esc cancel" + if m.pending.destructive { + confirm = st.neg.Render("y") + st.muted.Render(" run any other key cancels") + } + return fit(" "+st.label.Render("RUN ")+st.text.Render(m.pending.String())+" "+st.muted.Render(confirm), w) + } + keys := map[int]string{ + tabSessions: "↑↓ select ⏎ jump", + tabMachine: "", + tabProcs: "↑↓ select ⏎ jump x kill", + tabGit: "↑↓ select ⏎ shell x remove r rescan", + tabRecs: "↑↓ select ⏎ do x the other action", + tabHistory: "", + }[m.tab] + " 1-6 panel q quit" + left := " " + st.muted.Render(keys) + if m.flash != "" { + left = " " + st.text.Render(m.flash) + } + right := st.faint.Render("sampled " + sample.Human(time.Since(m.snap.At)) + " ago ") + return left + strings.Repeat(" ", max(w-lipgloss.Width(left)-lipgloss.Width(right), 1)) + right +} + +const ( + colPane = 11 // session:window.pane; widened to show the tab name when there is room + colTab = 12 + colName = 18 + colSt = 6 + colIdle = 7 + colBar = 10 + colPct = 9 + colCPU = 6 + colDo = 10 +) + +func (m Model) sessions(w, h int) []string { + st := m.st + ss := m.snap.Sessions + busy := 0 + for _, s := range ss { + if s.Busy { + busy++ + } + } + region := fmt.Sprintf(" %d AGENTS · BUSY %d · IDLE %d", len(ss), busy, len(ss)-busy) + lines := []string{"", st.label.Render(region)} + + tab := 0 + if w >= 100 { + tab = colTab + } + tree := min(max(w-(1+colPane+tab+colName+colSt+colIdle+colBar+1+colPct+colCPU+colDo+1), 12), 48) + head := " " + pad("TMUX", colPane+tab) + pad("NAME", colName) + pad("STATE", colSt) + pad("IDLE", colIdle) + + pad("CONTEXT", colBar+1+colPct) + pad("CPU", colCPU) + pad("DO", colDo+1) + "WORKTREE" + lines = append(lines, st.label.Render(fit(head, w))) + + detail := m.detail(w) + room := max(h-len(lines)-len(detail)-2, 3) + sel := m.selIndex() + first := max(min(sel-room/2, len(ss)-room), 0) + for i := first; i < len(ss) && i < first+room; i++ { + lines = append(lines, m.row(ss[i], i == sel, w, tab, tree)) + } + if n := len(m.snap.IdlePanes); n > 0 { + var ids []string + for _, p := range m.snap.IdlePanes { + ids = append(ids, place(p.Target, p.Pane)+" "+p.Command) + } + lines = append(lines, st.faint.Render(fit(fmt.Sprintf(" %d without an agent: %s", n, strings.Join(ids, ", ")), w))) + } + lines = append(lines, "", st.hair.Render(strings.Repeat("─", w))) + return append(lines, detail...) +} + +func (m Model) row(s sample.Session, selected bool, w, tab, tree int) string { + st := m.st + state, idle := st.text.Render(pad("busy", colSt)), pad("", colIdle) + if !s.Busy { + state = st.faint.Render(pad("idle", colSt)) + idle = st.muted.Render(pad(sample.Human(s.Idle), colIdle)) + } + ctx := st.faint.Render(pad(s.Context, colBar+1+colPct)) + if s.Context == "known" { + pctStyle := st.text + switch { + case s.ContextPct >= m.compactAt(): + pctStyle = st.neg + case s.ContextPct >= 70: + pctStyle = st.warn + } + win := "" + if s.Window >= 1_000_000 { + win = " 1M" + } + ctx = st.muted.Render(bar(s.ContextPct, colBar)) + " " + pctStyle.Render(pad(fmt.Sprintf("%3.0f%%%s", s.ContextPct, win), colPct)) + } + cpu := st.faint.Render(pad(fmt.Sprintf("%3.0f%%", s.CPU), colCPU)) + if s.CPU >= 5 { + cpu = st.text.Render(pad(fmt.Sprintf("%3.0f%%", s.CPU), colCPU)) + } + do := pad("", colDo+1) + switch s.Do { + case "compact": + do = st.neg.Render(pad("! compact", colDo+1)) + case "clear": + do = st.warn.Render(pad("▲ clear", colDo+1)) + } + name := st.text.Render(pad(s.Name, colName)) + where := place(s.Target, s.Pane) + if selected { + where = "▌" + where + } + pane := st.muted.Render(pad(where, colPane)) + if selected { + pane = st.accent.Render(pad(where, colPane)) + } + if tab > 0 { + pane += st.muted.Render(pad(s.Tab, tab)) + } + line := " " + pane + name + state + idle + ctx + cpu + do + st.muted.Render(pad(worktree(s.Cwd), tree)) + if selected { + return st.selected.Render(fit(line, w)) + } + return fit(line, w) +} + +func (m Model) compactAt() float64 { return m.opt.CompactAt } + +func (m Model) detail(w int) []string { + st := m.st + s, ok := m.selected() + if !ok { + return []string{st.faint.Render(" no agent sessions found in tmux")} + } + lines := []string{st.label.Render(" " + strings.ToUpper(place(s.Target, s.Pane)+" "+s.Tab+" "+s.Name))} + facts := []string{fmt.Sprintf("pid %d", s.PID)} + if s.Pane != "" { + facts = append(facts, "pane "+s.Pane) + } + if !s.Started.IsZero() { + facts = append(facts, "started "+sample.Human(time.Since(s.Started))+" ago") + } + if s.Model != "" { + facts = append(facts, s.Model) + } + if s.Tokens > 0 { + facts = append(facts, fmt.Sprintf("%s / %s tokens", kTok(s.Tokens), kTok(s.Window)), "+"+kTok(s.Burn30m)+" in 30 min") + } + facts = append(facts, fmt.Sprintf("%d processes", s.Procs)) + lines = append(lines, fit(" "+st.text.Render(strings.Join(facts, st.faint.Render(" · "))), w)) + lines = append(lines, fit(" "+st.muted.Render(s.Cwd), w)) + if s.Why != "" { + lines = append(lines, fit(" "+st.label.Render("WHY ")+st.text.Render(s.Do+": "+s.Why), w)) + } + if s.Pane != "" { + lines = append(lines, fit(" "+st.label.Render("⏎ ")+st.muted.Render("tmux switch-client -t "+s.Pane), w)) + } + return lines +} + +func bar(pct float64, w int) string { + full := min(int(pct/100*float64(w)+0.5), w) + return strings.Repeat("█", full) + strings.Repeat("░", w-full) +} + +// worktree shortens a cwd to what tells sessions apart: the checkout name, +// or the worktree name for paths under a */worktrees/* directory. +func worktree(cwd string) string { + if cwd == "" { + return "?" + } + dir, base := filepath.Split(filepath.Clean(cwd)) + if strings.Contains(filepath.Base(dir), "worktrees") { + return "wt/" + base + } + return base +} + +// place names a pane the way the tmux status bar does (session:window.pane), +// falling back to its id when tmux did not list it. +func place(target, id string) string { + if target != "" { + return target + } + return id +} + +func pad(s string, w int) string { + s = ansi.Truncate(s, w-1, "…") + return s + strings.Repeat(" ", max(w-lipgloss.Width(s), 0)) +} + +func fit(s string, w int) string { + s = ansi.Truncate(s, w, "…") + return s + strings.Repeat(" ", max(w-lipgloss.Width(s), 0)) +} + +func gib(b uint64) string { return fmt.Sprintf("%.0fG", float64(b)/(1<<30)) } + +func kTok(n int) string { + if n >= 1_000_000 { + return fmt.Sprintf("%.1fM", float64(n)/1e6) + } + return fmt.Sprintf("%dk", (n+500)/1000) +} + +// size renders bytes or bytes per second compactly: 512B, 12K, 3.4M, 1.2G. +func size(b float64) string { + switch { + case b >= 1<<30: + return fmt.Sprintf("%.1fG", b/(1<<30)) + case b >= 1<<20: + return fmt.Sprintf("%.1fM", b/(1<<20)) + case b >= 1<<10: + return fmt.Sprintf("%.0fK", b/(1<<10)) + } + return fmt.Sprintf("%.0fB", b) +} diff --git a/skills/tui/main.go b/skills/tui/main.go new file mode 100644 index 0000000..218a2bc --- /dev/null +++ b/skills/tui/main.go @@ -0,0 +1,264 @@ +// Command builder-tui is a terminal cockpit for a machine that runs many AI +// coding agents: sessions by tmux pane, machine load, git, and what to do. +// +// builder-tui open the cockpit +// builder-tui status print what it knows as JSON (for agents) +// builder-tui status --text the same, as a short summary +package main + +import ( + "context" + "encoding/json" + "flag" + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "runtime/pprof" + "time" + + tea "charm.land/bubbletea/v2" + + "github.com/Lutherwaves/builder/skills/tui/internal/advice" + "github.com/Lutherwaves/builder/skills/tui/internal/config" + "github.com/Lutherwaves/builder/skills/tui/internal/gitscan" + "github.com/Lutherwaves/builder/skills/tui/internal/history" + "github.com/Lutherwaves/builder/skills/tui/internal/host" + "github.com/Lutherwaves/builder/skills/tui/internal/procfs" + "github.com/Lutherwaves/builder/skills/tui/internal/sample" + "github.com/Lutherwaves/builder/skills/tui/internal/state" + "github.com/Lutherwaves/builder/skills/tui/internal/ui" +) + +var version = "dev" + +func main() { + // BUILDER_TUI_CPUPROFILE=file profiles the cockpit itself, to keep its + // own cost honest. + if path := os.Getenv("BUILDER_TUI_CPUPROFILE"); path != "" { + if f, err := os.Create(path); err == nil { + _ = pprof.StartCPUProfile(f) + defer pprof.StopCPUProfile() + } + } + if err := run(os.Args[1:]); err != nil { + pprof.StopCPUProfile() + fmt.Fprintln(os.Stderr, "builder-tui:", err) + os.Exit(1) + } +} + +func run(args []string) error { + cmd := "tui" + if len(args) > 0 && (args[0] == "status" || args[0] == "version") { + cmd, args = args[0], args[1:] + } + fl := flag.NewFlagSet("builder-tui", flag.ContinueOnError) + cfgPath := fl.String("config", config.Path(), "machine goals (TOML); a missing file means defaults") + interval := fl.Duration("interval", 0, "sampling interval (default from config, else 2s)") + root := fl.String("fixtures", "", "read proc/, sys/, home/ and tmux-panes.txt from this directory instead of the live machine") + text := fl.Bool("text", false, "status: print a summary instead of JSON") + if err := fl.Parse(args); err != nil { + return err + } + if cmd == "version" { + fmt.Println(version) + return nil + } + cfg, err := config.Load(*cfgPath) + if err != nil { + return fmt.Errorf("%s: %w", *cfgPath, err) + } + if *interval > 0 { + cfg.Interval.Duration = *interval + } + + // The cockpit must not add load: lowest CPU priority for us and every + // command we start. + lowestPriority() + + fixtures := *root != "" + smp := newSampler(cfg, *root) + var scanGit ui.GitSource + if !fixtures { + scanGit = gitSource(cfg) + } + if cmd == "status" { + return status(smp, scanGit, cfg.Interval.Duration, fixtures, *text) + } + + opt := ui.Options{ + Check: checkGuard, + Source: smp.Sample, + Interval: cfg.Interval.Duration, + Git: scanGit, + GitEvery: cfg.Git.Interval.Duration, + CompactAt: cfg.Sessions.CompactAt, + } + if !fixtures { + logPath := cfg.History.Log + if logPath == "" { + logPath = history.DefaultLog() + } else if logPath == "-" { + logPath = "" + } + opt.OnSnapshot = recorder(state.Path(), logPath, cfg.Hooks.Alert) + opt.History = func() history.Series { + s, _ := history.Load(time.Now(), cfg.History.Source, logPath) + return s + } + } + // The screen changes on a sample or a key press, so 15 frames a second + // is still instant to the eye and wakes the process 4x less than 60. + _, err = tea.NewProgram(ui.New(opt), tea.WithFPS(15)).Run() + return err +} + +// recorder persists each sample: the state file for `status`, one history +// row every 30 s, and the alert hook for alerts that were not firing before. +func recorder(statePath, logPath string, hook []string) func(state.Doc) { + logger := &history.Logger{Path: logPath} + firing := map[string]bool{} + first := true + var wrote time.Time + return func(d state.Doc) { + // The file is for agents asking now and then; every few seconds is fresh. + if d.At.Sub(wrote) >= stateEvery { + _ = state.Write(statePath, d) + wrote = d.At + } + if !first { // the first sample has no rates yet + _ = logger.Log(history.FromSnapshot(d.Snapshot)) + } + first = false + now := map[string]bool{} + for _, a := range d.Alerts { + now[a.Key] = true + if !firing[a.Key] && len(hook) > 0 { + c := exec.Command(hook[0], append(hook[1:], a.Title, a.Evidence)...) + if c.Start() == nil { + go c.Wait() + } + } + } + firing = now + } +} + +func gitSource(cfg config.Config) ui.GitSource { + scanner := &gitscan.Scanner{Git: gitscan.Git, Merged: gitscan.GHMerged, RecheckIdle: 2 * time.Hour} + return func(sessionCwds []string) gitscan.Report { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + in := gitscan.Input{ + SessionCwds: sessionCwds, + ProcessCwds: processCwds(), + Extra: cfg.Git.Repos, + Main: cfg.Git.Main, + Remote: cfg.Git.Remote, + } + return scanner.Scan(ctx, in) + } +} + +// checkGuard re-verifies, right before a step runs, what its evidence said. +func checkGuard(g advice.Guard) error { + if g.PID > 0 { + p, ok := newHost().Proc(g.PID) + if !ok || p.StartTime != g.StartTicks { + return fmt.Errorf("pid %d is no longer the process that was flagged", g.PID) + } + } + if g.Worktree != "" { + ctx, cancel := context.WithTimeout(context.Background(), time.Minute) + defer cancel() + return gitscan.VerifyRemovable(ctx, gitscan.Git, g.Worktree, processCwds()) + } + return nil +} + +// processCwds is where every process of ours sits, so a worktree someone +// still works in is never offered for removal. +func processCwds() []string { + fs := newHost() + procs, _ := fs.Procs() + out := make([]string, 0, len(procs)) + for pid := range procs { + if cwd := fs.Cwd(pid); cwd != "" { + out = append(out, cwd) + } + } + return out +} + +// newHost reads /proc on Linux and the system's own APIs elsewhere. +func newHost() procfs.Host { + if runtime.GOOS == "linux" { + return procfs.FS{Root: "/proc"} + } + return host.Host{} +} + +// stateEvery is how often the state file is rewritten; status treats a file +// older than staleAfter as no cockpit running. +const ( + stateEvery = 5 * time.Second + staleAfter = 15 * time.Second +) + +func newSampler(cfg config.Config, root string) *sample.Sampler { + smp := &sample.Sampler{ + Cfg: cfg, + Agents: cfg.Agents, + Rules: sample.Rules{ + CompactAt: cfg.Sessions.CompactAt, + ClearIdle: cfg.Sessions.ClearIdle.Duration, + ClearMinPct: cfg.Sessions.ClearMinPct, + ClearStale: cfg.Sessions.ClearStale.Duration, + Windows: cfg.Sessions.Windows, + }, + } + if root != "" { + smp.FS = procfs.FS{Root: filepath.Join(root, "proc")} + smp.Sys = procfs.Sys{Root: filepath.Join(root, "sys")} + smp.Home = filepath.Join(root, "home") + smp.Tmux = func() ([]byte, error) { return os.ReadFile(filepath.Join(root, "tmux-panes.txt")) } + return smp + } + smp.FS, smp.Sys = newHost(), procfs.Sys{Root: "/sys"} + smp.Home, _ = os.UserHomeDir() + smp.GPU, smp.Docker = sample.NvidiaSMI, sample.DockerPS + smp.LatencyTarget = cfg.LatencyTarget + smp.ProcEvery = 5 * cfg.Interval.Duration + return smp +} + +// status prints the running cockpit's state when it is fresh. Otherwise it +// samples on its own: two samples a second apart so rates are real, and one +// git scan. +func status(smp *sample.Sampler, scanGit ui.GitSource, interval time.Duration, fixtures, text bool) error { + doc, err := state.Read(state.Path()) + if fixtures || err != nil || time.Since(doc.At) > max(staleAfter, 3*interval) { + smp.ProcEvery = 0 + smp.Sample() + time.Sleep(time.Second) + doc = state.Doc{Snapshot: smp.Sample()} + if scanGit != nil { + var cwds []string + for _, s := range doc.Sessions { + cwds = append(cwds, s.Cwd) + } + r := scanGit(cwds) + doc.Git = &r + } + doc.Recommendations = advice.Build(doc.Snapshot, doc.Git) + } + if text { + fmt.Print(ui.Summary(doc)) + return nil + } + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + return enc.Encode(doc) +} diff --git a/skills/tui/nice_unix.go b/skills/tui/nice_unix.go new file mode 100644 index 0000000..67d6c9f --- /dev/null +++ b/skills/tui/nice_unix.go @@ -0,0 +1,8 @@ +//go:build !windows + +package main + +import "syscall" + +// lowestPriority runs the cockpit, and every command it starts, at nice 19. +func lowestPriority() { _ = syscall.Setpriority(syscall.PRIO_PROCESS, 0, 19) } diff --git a/skills/tui/nice_windows.go b/skills/tui/nice_windows.go new file mode 100644 index 0000000..ac8715f --- /dev/null +++ b/skills/tui/nice_windows.go @@ -0,0 +1,12 @@ +package main + +import "syscall" + +// lowestPriority runs the cockpit, and every command it starts, in the idle +// priority class. +func lowestPriority() { + const idlePriorityClass = 0x40 + k := syscall.NewLazyDLL("kernel32.dll") + self, _, _ := k.NewProc("GetCurrentProcess").Call() + _, _, _ = k.NewProc("SetPriorityClass").Call(self, idlePriorityClass) +} diff --git a/skills/tui/setup.sh b/skills/tui/setup.sh new file mode 100755 index 0000000..afe36c1 --- /dev/null +++ b/skills/tui/setup.sh @@ -0,0 +1,63 @@ +#!/usr/bin/env bash +# setup.sh — install builder-tui: download the release binary matching this +# plugin's version and verify its checksum, or build from this checkout when +# Go is installed (or when BUILDER_TUI_BUILD=1). Idempotent. +# +# Env: BUILDER_BIN_DIR install directory (default ~/.local/bin) +# BUILDER_TUI_BUILD=1 always build from source +set -euo pipefail + +HERE="$(cd "$(dirname "$0")" && pwd)" +ROOT="$(cd "$HERE/../.." && pwd)" +BIN_DIR="${BUILDER_BIN_DIR:-$HOME/.local/bin}" +DEST="$BIN_DIR/builder-tui" +REPO="Lutherwaves/builder" + +case "$(uname -s)" in + Linux) os=linux ;; + Darwin) os=darwin ;; + *) echo "error: on Windows, run this in WSL, or download builder-tui-windows-.exe from the release page." >&2; exit 2 ;; +esac +case "$(uname -m)" in + x86_64 | amd64) arch=amd64 ;; + aarch64 | arm64) arch=arm64 ;; + *) echo "error: no build for $(uname -m)" >&2; exit 2 ;; +esac +mkdir -p "$BIN_DIR" + +build() { + command -v go >/dev/null 2>&1 || { echo "error: no release binary and no Go to build one (https://go.dev/dl)." >&2; exit 2; } + echo "→ building from $HERE" + (cd "$HERE" && CGO_ENABLED=0 GOTOOLCHAIN=auto go build -trimpath -ldflags="-s -w -X main.version=source" -o "$DEST.new" .) + mv "$DEST.new" "$DEST" +} + +# sha256 checks " " lines: sha256sum on Linux, shasum on macOS. +sha256() { if command -v sha256sum >/dev/null 2>&1; then sha256sum --quiet "$@"; else shasum -a 256 --quiet "$@"; fi; } + +download() { + version="$(jq -r .version "$ROOT/.claude-plugin/plugin.json" 2>/dev/null)" || return 1 + [ -n "$version" ] && [ "$version" != null ] || return 1 + # release-please tags carry the package name: builder-v1.2.3 + base="https://github.com/$REPO/releases/download/builder-v$version" + asset="builder-tui-$os-$arch" + tmp="$(mktemp -d)" + trap 'rm -rf "$tmp"' RETURN + echo "→ downloading $asset v$version" + curl -fsSL -o "$tmp/$asset" "$base/$asset" || return 1 + curl -fsSL -o "$tmp/checksums.txt" "$base/builder-tui-checksums.txt" || return 1 + (cd "$tmp" && grep " $asset\$" checksums.txt | sha256 -c) || { echo "error: checksum mismatch for $asset" >&2; exit 3; } + install -m 0755 "$tmp/$asset" "$DEST" +} + +if [ "${BUILDER_TUI_BUILD:-}" = 1 ]; then + build +elif ! download; then + echo "→ no release binary for this version; building instead" + build +fi + +echo "✓ installed $DEST ($("$DEST" version))" +case ":$PATH:" in *":$BIN_DIR:"*) ;; *) echo " note: $BIN_DIR is not on PATH" ;; esac +echo " run: tmux new-window -d -n cockpit builder-tui" +echo " goals: ~/.config/builder/tui.toml (see $HERE/config.example.toml)" diff --git a/skills/tui/testdata/machine/home/.claude/projects/-work-app/11111111-aaaa-4000-8000-000000000001.jsonl b/skills/tui/testdata/machine/home/.claude/projects/-work-app/11111111-aaaa-4000-8000-000000000001.jsonl new file mode 100644 index 0000000..47e4b3a --- /dev/null +++ b/skills/tui/testdata/machine/home/.claude/projects/-work-app/11111111-aaaa-4000-8000-000000000001.jsonl @@ -0,0 +1,5 @@ +{"type":"user","message":{"content":"hi"}} +{"type":"assistant","isSidechain":false,"timestamp":"2026-09-21T20:00:00Z","message":{"model":"model-a","usage":{"input_tokens":10,"cache_read_input_tokens":90000,"cache_creation_input_tokens":0,"output_tokens":0}}} +{"type":"assistant","isSidechain":false,"timestamp":"2026-09-21T20:00:00Z","message":{"model":"model-a","usage":{"input_tokens":10,"cache_read_input_tokens":179990,"cache_creation_input_tokens":0,"output_tokens":0}}} +{"type":"assistant","isSidechain":true,"timestamp":"2026-09-21T20:00:00Z","message":{"model":"model-a","usage":{"input_tokens":5,"cache_read_input_tokens":5,"cache_creation_input_tokens":0,"output_tokens":0}}} +{"type":"user","message":{"content":"next"}} diff --git a/skills/tui/testdata/machine/home/.claude/projects/-work-app/11111111-aaaa-4000-8000-000000000002.jsonl b/skills/tui/testdata/machine/home/.claude/projects/-work-app/11111111-aaaa-4000-8000-000000000002.jsonl new file mode 100644 index 0000000..9af61d9 --- /dev/null +++ b/skills/tui/testdata/machine/home/.claude/projects/-work-app/11111111-aaaa-4000-8000-000000000002.jsonl @@ -0,0 +1 @@ +{"type":"assistant","isSidechain":false,"timestamp":"2026-09-21T20:00:00Z","message":{"model":"model-a","usage":{"input_tokens":10,"cache_read_input_tokens":59990,"cache_creation_input_tokens":0,"output_tokens":0}}} diff --git a/skills/tui/testdata/machine/home/.claude/projects/-work-app/11111111-aaaa-4000-8000-000000000003.jsonl b/skills/tui/testdata/machine/home/.claude/projects/-work-app/11111111-aaaa-4000-8000-000000000003.jsonl new file mode 100644 index 0000000..9082bc7 --- /dev/null +++ b/skills/tui/testdata/machine/home/.claude/projects/-work-app/11111111-aaaa-4000-8000-000000000003.jsonl @@ -0,0 +1,2 @@ +{"type":"system","subtype":"local_command"} +{"type":"user","message":{"content":"x"}} diff --git a/skills/tui/testdata/machine/home/.claude/sessions/200.json b/skills/tui/testdata/machine/home/.claude/sessions/200.json new file mode 100644 index 0000000..6abcd23 --- /dev/null +++ b/skills/tui/testdata/machine/home/.claude/sessions/200.json @@ -0,0 +1 @@ +{"pid":200,"sessionId":"11111111-aaaa-4000-8000-000000000001","cwd":"/work/app","name":"app-feature","status":"busy","startedAt":1790000000000,"statusUpdatedAt":1790003600000} diff --git a/skills/tui/testdata/machine/home/.claude/sessions/310.json b/skills/tui/testdata/machine/home/.claude/sessions/310.json new file mode 100644 index 0000000..8bea94b --- /dev/null +++ b/skills/tui/testdata/machine/home/.claude/sessions/310.json @@ -0,0 +1 @@ +{"pid":310,"sessionId":"11111111-aaaa-4000-8000-000000000002","cwd":"/work/app-review","name":"app-review","status":"idle","startedAt":1790000000000,"statusUpdatedAt":1790000000000} diff --git a/skills/tui/testdata/machine/home/.claude/sessions/400.json b/skills/tui/testdata/machine/home/.claude/sessions/400.json new file mode 100644 index 0000000..6493fcf --- /dev/null +++ b/skills/tui/testdata/machine/home/.claude/sessions/400.json @@ -0,0 +1 @@ +{"pid":400,"sessionId":"11111111-aaaa-4000-8000-000000000003","cwd":"/work/tools","name":"tools","status":"idle","startedAt":1790000000000,"statusUpdatedAt":1790003000000} diff --git a/skills/tui/testdata/machine/proc/1/cmdline b/skills/tui/testdata/machine/proc/1/cmdline new file mode 100644 index 0000000..cc015be Binary files /dev/null and b/skills/tui/testdata/machine/proc/1/cmdline differ diff --git a/skills/tui/testdata/machine/proc/1/cwd b/skills/tui/testdata/machine/proc/1/cwd new file mode 120000 index 0000000..35ec3b9 --- /dev/null +++ b/skills/tui/testdata/machine/proc/1/cwd @@ -0,0 +1 @@ +/ \ No newline at end of file diff --git a/skills/tui/testdata/machine/proc/1/environ b/skills/tui/testdata/machine/proc/1/environ new file mode 100644 index 0000000..0d5ec0c Binary files /dev/null and b/skills/tui/testdata/machine/proc/1/environ differ diff --git a/skills/tui/testdata/machine/proc/1/stat b/skills/tui/testdata/machine/proc/1/stat new file mode 100644 index 0000000..8f18eba --- /dev/null +++ b/skills/tui/testdata/machine/proc/1/stat @@ -0,0 +1 @@ +1 (systemd) S 0 1 1 0 -1 0 0 0 0 0 0 0 0 0 20 0 1 0 1000 100000 2500 diff --git a/skills/tui/testdata/machine/proc/200/cmdline b/skills/tui/testdata/machine/proc/200/cmdline new file mode 100644 index 0000000..f5089ce Binary files /dev/null and b/skills/tui/testdata/machine/proc/200/cmdline differ diff --git a/skills/tui/testdata/machine/proc/200/cwd b/skills/tui/testdata/machine/proc/200/cwd new file mode 120000 index 0000000..767e7fe --- /dev/null +++ b/skills/tui/testdata/machine/proc/200/cwd @@ -0,0 +1 @@ +/work/app \ No newline at end of file diff --git a/skills/tui/testdata/machine/proc/200/environ b/skills/tui/testdata/machine/proc/200/environ new file mode 100644 index 0000000..0e02d31 Binary files /dev/null and b/skills/tui/testdata/machine/proc/200/environ differ diff --git a/skills/tui/testdata/machine/proc/200/stat b/skills/tui/testdata/machine/proc/200/stat new file mode 100644 index 0000000..68986c5 --- /dev/null +++ b/skills/tui/testdata/machine/proc/200/stat @@ -0,0 +1 @@ +200 (claude) S 1 200 200 0 -1 0 0 0 0 0 400 0 0 0 20 0 1 0 1000 100000 2500 diff --git a/skills/tui/testdata/machine/proc/210/cmdline b/skills/tui/testdata/machine/proc/210/cmdline new file mode 100644 index 0000000..0a34356 Binary files /dev/null and b/skills/tui/testdata/machine/proc/210/cmdline differ diff --git a/skills/tui/testdata/machine/proc/210/cwd b/skills/tui/testdata/machine/proc/210/cwd new file mode 120000 index 0000000..767e7fe --- /dev/null +++ b/skills/tui/testdata/machine/proc/210/cwd @@ -0,0 +1 @@ +/work/app \ No newline at end of file diff --git a/skills/tui/testdata/machine/proc/210/environ b/skills/tui/testdata/machine/proc/210/environ new file mode 100644 index 0000000..0e02d31 Binary files /dev/null and b/skills/tui/testdata/machine/proc/210/environ differ diff --git a/skills/tui/testdata/machine/proc/210/stat b/skills/tui/testdata/machine/proc/210/stat new file mode 100644 index 0000000..9639a58 --- /dev/null +++ b/skills/tui/testdata/machine/proc/210/stat @@ -0,0 +1 @@ +210 (node) S 200 210 210 0 -1 0 0 0 0 0 100 0 0 0 20 0 1 0 1000 100000 2500 diff --git a/skills/tui/testdata/machine/proc/300/cmdline b/skills/tui/testdata/machine/proc/300/cmdline new file mode 100644 index 0000000..481f562 Binary files /dev/null and b/skills/tui/testdata/machine/proc/300/cmdline differ diff --git a/skills/tui/testdata/machine/proc/300/cwd b/skills/tui/testdata/machine/proc/300/cwd new file mode 120000 index 0000000..22046e1 --- /dev/null +++ b/skills/tui/testdata/machine/proc/300/cwd @@ -0,0 +1 @@ +/work/app-review \ No newline at end of file diff --git a/skills/tui/testdata/machine/proc/300/environ b/skills/tui/testdata/machine/proc/300/environ new file mode 100644 index 0000000..6dccd76 Binary files /dev/null and b/skills/tui/testdata/machine/proc/300/environ differ diff --git a/skills/tui/testdata/machine/proc/300/stat b/skills/tui/testdata/machine/proc/300/stat new file mode 100644 index 0000000..c02e248 --- /dev/null +++ b/skills/tui/testdata/machine/proc/300/stat @@ -0,0 +1 @@ +300 (zsh) S 1 300 300 0 -1 0 0 0 0 0 5 0 0 0 20 0 1 0 1000 100000 2500 diff --git a/skills/tui/testdata/machine/proc/310/cmdline b/skills/tui/testdata/machine/proc/310/cmdline new file mode 100644 index 0000000..f5089ce Binary files /dev/null and b/skills/tui/testdata/machine/proc/310/cmdline differ diff --git a/skills/tui/testdata/machine/proc/310/cwd b/skills/tui/testdata/machine/proc/310/cwd new file mode 120000 index 0000000..22046e1 --- /dev/null +++ b/skills/tui/testdata/machine/proc/310/cwd @@ -0,0 +1 @@ +/work/app-review \ No newline at end of file diff --git a/skills/tui/testdata/machine/proc/310/environ b/skills/tui/testdata/machine/proc/310/environ new file mode 100644 index 0000000..6dccd76 Binary files /dev/null and b/skills/tui/testdata/machine/proc/310/environ differ diff --git a/skills/tui/testdata/machine/proc/310/stat b/skills/tui/testdata/machine/proc/310/stat new file mode 100644 index 0000000..f44b69a --- /dev/null +++ b/skills/tui/testdata/machine/proc/310/stat @@ -0,0 +1 @@ +310 (claude) S 300 310 310 0 -1 0 0 0 0 0 50 0 0 0 20 0 1 0 1000 100000 2500 diff --git a/skills/tui/testdata/machine/proc/400/cmdline b/skills/tui/testdata/machine/proc/400/cmdline new file mode 100644 index 0000000..f5089ce Binary files /dev/null and b/skills/tui/testdata/machine/proc/400/cmdline differ diff --git a/skills/tui/testdata/machine/proc/400/cwd b/skills/tui/testdata/machine/proc/400/cwd new file mode 120000 index 0000000..ad82d70 --- /dev/null +++ b/skills/tui/testdata/machine/proc/400/cwd @@ -0,0 +1 @@ +/work/tools \ No newline at end of file diff --git a/skills/tui/testdata/machine/proc/400/environ b/skills/tui/testdata/machine/proc/400/environ new file mode 100644 index 0000000..a359538 Binary files /dev/null and b/skills/tui/testdata/machine/proc/400/environ differ diff --git a/skills/tui/testdata/machine/proc/400/stat b/skills/tui/testdata/machine/proc/400/stat new file mode 100644 index 0000000..3bf70ba --- /dev/null +++ b/skills/tui/testdata/machine/proc/400/stat @@ -0,0 +1 @@ +400 (claude) S 1 400 400 0 -1 0 0 0 0 0 20 0 0 0 20 0 1 0 1000 100000 2500 diff --git a/skills/tui/testdata/machine/proc/401/cmdline b/skills/tui/testdata/machine/proc/401/cmdline new file mode 100644 index 0000000..481f562 Binary files /dev/null and b/skills/tui/testdata/machine/proc/401/cmdline differ diff --git a/skills/tui/testdata/machine/proc/401/cwd b/skills/tui/testdata/machine/proc/401/cwd new file mode 120000 index 0000000..ad82d70 --- /dev/null +++ b/skills/tui/testdata/machine/proc/401/cwd @@ -0,0 +1 @@ +/work/tools \ No newline at end of file diff --git a/skills/tui/testdata/machine/proc/401/environ b/skills/tui/testdata/machine/proc/401/environ new file mode 100644 index 0000000..a359538 Binary files /dev/null and b/skills/tui/testdata/machine/proc/401/environ differ diff --git a/skills/tui/testdata/machine/proc/401/stat b/skills/tui/testdata/machine/proc/401/stat new file mode 100644 index 0000000..6044929 --- /dev/null +++ b/skills/tui/testdata/machine/proc/401/stat @@ -0,0 +1 @@ +401 (zsh) S 1 401 401 0 -1 0 0 0 0 0 1 0 0 0 20 0 1 0 1000 100000 2500 diff --git a/skills/tui/testdata/machine/proc/50/cmdline b/skills/tui/testdata/machine/proc/50/cmdline new file mode 100644 index 0000000..725830d Binary files /dev/null and b/skills/tui/testdata/machine/proc/50/cmdline differ diff --git a/skills/tui/testdata/machine/proc/50/cwd b/skills/tui/testdata/machine/proc/50/cwd new file mode 120000 index 0000000..35ec3b9 --- /dev/null +++ b/skills/tui/testdata/machine/proc/50/cwd @@ -0,0 +1 @@ +/ \ No newline at end of file diff --git a/skills/tui/testdata/machine/proc/50/environ b/skills/tui/testdata/machine/proc/50/environ new file mode 100644 index 0000000..0d5ec0c Binary files /dev/null and b/skills/tui/testdata/machine/proc/50/environ differ diff --git a/skills/tui/testdata/machine/proc/50/stat b/skills/tui/testdata/machine/proc/50/stat new file mode 100644 index 0000000..1ee05b5 --- /dev/null +++ b/skills/tui/testdata/machine/proc/50/stat @@ -0,0 +1 @@ +50 (systemd) S 1 50 50 0 -1 0 0 0 0 0 10 0 0 0 20 0 1 0 500 100000 2500 diff --git a/skills/tui/testdata/machine/proc/500/cmdline b/skills/tui/testdata/machine/proc/500/cmdline new file mode 100644 index 0000000..481f562 Binary files /dev/null and b/skills/tui/testdata/machine/proc/500/cmdline differ diff --git a/skills/tui/testdata/machine/proc/500/cwd b/skills/tui/testdata/machine/proc/500/cwd new file mode 120000 index 0000000..21f7fb9 --- /dev/null +++ b/skills/tui/testdata/machine/proc/500/cwd @@ -0,0 +1 @@ +/work/notes \ No newline at end of file diff --git a/skills/tui/testdata/machine/proc/500/environ b/skills/tui/testdata/machine/proc/500/environ new file mode 100644 index 0000000..850a9d7 Binary files /dev/null and b/skills/tui/testdata/machine/proc/500/environ differ diff --git a/skills/tui/testdata/machine/proc/500/stat b/skills/tui/testdata/machine/proc/500/stat new file mode 100644 index 0000000..38b04cd --- /dev/null +++ b/skills/tui/testdata/machine/proc/500/stat @@ -0,0 +1 @@ +500 (zsh) S 1 500 500 0 -1 0 0 0 0 0 1 0 0 0 20 0 1 0 1000 100000 2500 diff --git a/skills/tui/testdata/machine/proc/600/cmdline b/skills/tui/testdata/machine/proc/600/cmdline new file mode 100644 index 0000000..2d232bf Binary files /dev/null and b/skills/tui/testdata/machine/proc/600/cmdline differ diff --git a/skills/tui/testdata/machine/proc/600/cwd b/skills/tui/testdata/machine/proc/600/cwd new file mode 120000 index 0000000..22046e1 --- /dev/null +++ b/skills/tui/testdata/machine/proc/600/cwd @@ -0,0 +1 @@ +/work/app-review \ No newline at end of file diff --git a/skills/tui/testdata/machine/proc/600/environ b/skills/tui/testdata/machine/proc/600/environ new file mode 100644 index 0000000..ea4f589 Binary files /dev/null and b/skills/tui/testdata/machine/proc/600/environ differ diff --git a/skills/tui/testdata/machine/proc/600/stat b/skills/tui/testdata/machine/proc/600/stat new file mode 100644 index 0000000..92a3734 --- /dev/null +++ b/skills/tui/testdata/machine/proc/600/stat @@ -0,0 +1 @@ +600 (bash) S 50 600 600 0 -1 0 0 0 0 0 1000 0 0 0 20 0 1 0 1000 100000 2500 diff --git a/skills/tui/testdata/machine/proc/601/cmdline b/skills/tui/testdata/machine/proc/601/cmdline new file mode 100644 index 0000000..2d232bf Binary files /dev/null and b/skills/tui/testdata/machine/proc/601/cmdline differ diff --git a/skills/tui/testdata/machine/proc/601/cwd b/skills/tui/testdata/machine/proc/601/cwd new file mode 120000 index 0000000..22046e1 --- /dev/null +++ b/skills/tui/testdata/machine/proc/601/cwd @@ -0,0 +1 @@ +/work/app-review \ No newline at end of file diff --git a/skills/tui/testdata/machine/proc/601/environ b/skills/tui/testdata/machine/proc/601/environ new file mode 100644 index 0000000..ea4f589 Binary files /dev/null and b/skills/tui/testdata/machine/proc/601/environ differ diff --git a/skills/tui/testdata/machine/proc/601/stat b/skills/tui/testdata/machine/proc/601/stat new file mode 100644 index 0000000..51eca96 --- /dev/null +++ b/skills/tui/testdata/machine/proc/601/stat @@ -0,0 +1 @@ +601 (bash) S 300 601 601 0 -1 0 0 0 0 0 1000 0 0 0 20 0 1 0 1000 100000 2500 diff --git a/skills/tui/testdata/machine/proc/diskstats b/skills/tui/testdata/machine/proc/diskstats new file mode 100644 index 0000000..a6c61e7 --- /dev/null +++ b/skills/tui/testdata/machine/proc/diskstats @@ -0,0 +1,2 @@ + 259 0 nvme0n1 10 0 200 0 20 0 400 0 0 0 0 + 259 1 nvme0n1p1 10 0 200 0 20 0 400 0 0 0 0 diff --git a/skills/tui/testdata/machine/proc/loadavg b/skills/tui/testdata/machine/proc/loadavg new file mode 100644 index 0000000..0aea0a6 --- /dev/null +++ b/skills/tui/testdata/machine/proc/loadavg @@ -0,0 +1 @@ +3.50 2.00 1.00 2/300 999 diff --git a/skills/tui/testdata/machine/proc/meminfo b/skills/tui/testdata/machine/proc/meminfo new file mode 100644 index 0000000..8c055c2 --- /dev/null +++ b/skills/tui/testdata/machine/proc/meminfo @@ -0,0 +1,5 @@ +MemTotal: 16384000 kB +MemFree: 2000000 kB +MemAvailable: 8192000 kB +SwapTotal: 4096000 kB +SwapFree: 3072000 kB diff --git a/skills/tui/testdata/machine/proc/net/dev b/skills/tui/testdata/machine/proc/net/dev new file mode 100644 index 0000000..e4f2c9e --- /dev/null +++ b/skills/tui/testdata/machine/proc/net/dev @@ -0,0 +1,5 @@ +Inter-| Receive | Transmit + face |bytes packets errs drop fifo frame compressed multicast|bytes + lo: 999 1 0 0 0 0 0 0 999 1 0 0 0 0 0 0 + eth0: 1000 10 0 0 0 0 0 0 2000 20 0 0 0 0 0 0 +veth1a: 5000 5 0 0 0 0 0 0 5000 5 0 0 0 0 0 0 diff --git a/skills/tui/testdata/machine/proc/pressure/cpu b/skills/tui/testdata/machine/proc/pressure/cpu new file mode 100644 index 0000000..0e22e7c --- /dev/null +++ b/skills/tui/testdata/machine/proc/pressure/cpu @@ -0,0 +1,2 @@ +some avg10=12.50 avg60=5.00 avg300=1.00 total=1 +full avg10=0.00 avg60=0.00 avg300=0.00 total=0 diff --git a/skills/tui/testdata/machine/proc/pressure/io b/skills/tui/testdata/machine/proc/pressure/io new file mode 100644 index 0000000..0e22e7c --- /dev/null +++ b/skills/tui/testdata/machine/proc/pressure/io @@ -0,0 +1,2 @@ +some avg10=12.50 avg60=5.00 avg300=1.00 total=1 +full avg10=0.00 avg60=0.00 avg300=0.00 total=0 diff --git a/skills/tui/testdata/machine/proc/pressure/memory b/skills/tui/testdata/machine/proc/pressure/memory new file mode 100644 index 0000000..0e22e7c --- /dev/null +++ b/skills/tui/testdata/machine/proc/pressure/memory @@ -0,0 +1,2 @@ +some avg10=12.50 avg60=5.00 avg300=1.00 total=1 +full avg10=0.00 avg60=0.00 avg300=0.00 total=0 diff --git a/skills/tui/testdata/machine/proc/stat b/skills/tui/testdata/machine/proc/stat new file mode 100644 index 0000000..1de4455 --- /dev/null +++ b/skills/tui/testdata/machine/proc/stat @@ -0,0 +1,4 @@ +cpu 1000 0 500 8000 100 0 0 0 0 0 +cpu0 500 0 250 4000 50 0 0 0 0 0 +cpu1 500 0 250 4000 50 0 0 0 0 0 +intr 0 diff --git a/skills/tui/testdata/machine/proc/uptime b/skills/tui/testdata/machine/proc/uptime new file mode 100644 index 0000000..b42dc2e --- /dev/null +++ b/skills/tui/testdata/machine/proc/uptime @@ -0,0 +1 @@ +12345.6 50000.0 diff --git a/skills/tui/testdata/machine/sys/class/hwmon/hwmon0/name b/skills/tui/testdata/machine/sys/class/hwmon/hwmon0/name new file mode 100644 index 0000000..77ce462 --- /dev/null +++ b/skills/tui/testdata/machine/sys/class/hwmon/hwmon0/name @@ -0,0 +1 @@ +coretemp diff --git a/skills/tui/testdata/machine/sys/class/hwmon/hwmon0/temp1_input b/skills/tui/testdata/machine/sys/class/hwmon/hwmon0/temp1_input new file mode 100644 index 0000000..d708551 --- /dev/null +++ b/skills/tui/testdata/machine/sys/class/hwmon/hwmon0/temp1_input @@ -0,0 +1 @@ +91000 diff --git a/skills/tui/testdata/machine/sys/class/hwmon/hwmon0/temp1_label b/skills/tui/testdata/machine/sys/class/hwmon/hwmon0/temp1_label new file mode 100644 index 0000000..7d67615 --- /dev/null +++ b/skills/tui/testdata/machine/sys/class/hwmon/hwmon0/temp1_label @@ -0,0 +1 @@ +Package id 0 diff --git a/skills/tui/testdata/machine/sys/class/hwmon/hwmon0/temp2_input b/skills/tui/testdata/machine/sys/class/hwmon/hwmon0/temp2_input new file mode 100644 index 0000000..7eaa76c --- /dev/null +++ b/skills/tui/testdata/machine/sys/class/hwmon/hwmon0/temp2_input @@ -0,0 +1 @@ +99000 diff --git a/skills/tui/testdata/machine/sys/class/hwmon/hwmon0/temp2_label b/skills/tui/testdata/machine/sys/class/hwmon/hwmon0/temp2_label new file mode 100644 index 0000000..09f8cbd --- /dev/null +++ b/skills/tui/testdata/machine/sys/class/hwmon/hwmon0/temp2_label @@ -0,0 +1 @@ +Core 0 diff --git a/skills/tui/testdata/machine/sys/class/hwmon/hwmon1/name b/skills/tui/testdata/machine/sys/class/hwmon/hwmon1/name new file mode 100644 index 0000000..9158fff --- /dev/null +++ b/skills/tui/testdata/machine/sys/class/hwmon/hwmon1/name @@ -0,0 +1 @@ +nvme diff --git a/skills/tui/testdata/machine/sys/class/powercap/intel-rapl:0/constraint_0_name b/skills/tui/testdata/machine/sys/class/powercap/intel-rapl:0/constraint_0_name new file mode 100644 index 0000000..a34c99d --- /dev/null +++ b/skills/tui/testdata/machine/sys/class/powercap/intel-rapl:0/constraint_0_name @@ -0,0 +1 @@ +long_term diff --git a/skills/tui/testdata/machine/sys/class/powercap/intel-rapl:0/constraint_0_power_limit_uw b/skills/tui/testdata/machine/sys/class/powercap/intel-rapl:0/constraint_0_power_limit_uw new file mode 100644 index 0000000..9452c12 --- /dev/null +++ b/skills/tui/testdata/machine/sys/class/powercap/intel-rapl:0/constraint_0_power_limit_uw @@ -0,0 +1 @@ +125000000 diff --git a/skills/tui/testdata/machine/sys/class/powercap/intel-rapl:0/constraint_1_name b/skills/tui/testdata/machine/sys/class/powercap/intel-rapl:0/constraint_1_name new file mode 100644 index 0000000..595e008 --- /dev/null +++ b/skills/tui/testdata/machine/sys/class/powercap/intel-rapl:0/constraint_1_name @@ -0,0 +1 @@ +short_term diff --git a/skills/tui/testdata/machine/sys/class/powercap/intel-rapl:0/constraint_1_power_limit_uw b/skills/tui/testdata/machine/sys/class/powercap/intel-rapl:0/constraint_1_power_limit_uw new file mode 100644 index 0000000..279e49d --- /dev/null +++ b/skills/tui/testdata/machine/sys/class/powercap/intel-rapl:0/constraint_1_power_limit_uw @@ -0,0 +1 @@ +188000000 diff --git a/skills/tui/testdata/machine/sys/class/powercap/intel-rapl:0/name b/skills/tui/testdata/machine/sys/class/powercap/intel-rapl:0/name new file mode 100644 index 0000000..a35383c --- /dev/null +++ b/skills/tui/testdata/machine/sys/class/powercap/intel-rapl:0/name @@ -0,0 +1 @@ +package-0 diff --git a/skills/tui/testdata/machine/sys/class/powercap/intel-rapl:0:0/constraint_0_name b/skills/tui/testdata/machine/sys/class/powercap/intel-rapl:0:0/constraint_0_name new file mode 100644 index 0000000..a34c99d --- /dev/null +++ b/skills/tui/testdata/machine/sys/class/powercap/intel-rapl:0:0/constraint_0_name @@ -0,0 +1 @@ +long_term diff --git a/skills/tui/testdata/machine/sys/class/powercap/intel-rapl:0:0/constraint_0_power_limit_uw b/skills/tui/testdata/machine/sys/class/powercap/intel-rapl:0:0/constraint_0_power_limit_uw new file mode 100644 index 0000000..447a331 --- /dev/null +++ b/skills/tui/testdata/machine/sys/class/powercap/intel-rapl:0:0/constraint_0_power_limit_uw @@ -0,0 +1 @@ +5000000 diff --git a/skills/tui/testdata/machine/sys/class/powercap/intel-rapl:0:0/name b/skills/tui/testdata/machine/sys/class/powercap/intel-rapl:0:0/name new file mode 100644 index 0000000..f5bd37c --- /dev/null +++ b/skills/tui/testdata/machine/sys/class/powercap/intel-rapl:0:0/name @@ -0,0 +1 @@ +core diff --git a/skills/tui/testdata/machine/sys/fs/cgroup/system.slice/docker-aaaabbbbccccddddeeeeffff0000111122223333444455556666777788889999.scope/cpu.stat b/skills/tui/testdata/machine/sys/fs/cgroup/system.slice/docker-aaaabbbbccccddddeeeeffff0000111122223333444455556666777788889999.scope/cpu.stat new file mode 100644 index 0000000..11dce61 --- /dev/null +++ b/skills/tui/testdata/machine/sys/fs/cgroup/system.slice/docker-aaaabbbbccccddddeeeeffff0000111122223333444455556666777788889999.scope/cpu.stat @@ -0,0 +1,2 @@ +usage_usec 1000000 +user_usec 800000 diff --git a/skills/tui/testdata/machine/tmux-panes.txt b/skills/tui/testdata/machine/tmux-panes.txt new file mode 100644 index 0000000..df7dc0d --- /dev/null +++ b/skills/tui/testdata/machine/tmux-panes.txt @@ -0,0 +1,4 @@ +%1 work:1.1 app 200 claude /work/app +%2 work:2.1 review 300 claude /work/app-review +%3 work:3.1 tools 401 zsh /work/tools +%4 notes:1.1 notes 500 zsh /work/notes