SLOPSHOPPER

dev-statusline

A two-line status line: repo, worktree, branch and the branch's PR (checks, resolved review threads), then Claude's context and 5-hour window beside Codex's…

newstatusprocesstimer
v0.4.1no licenseupdated 2026-10-10ekcm/agent-toolkit/mods/dev-statusline
A shopper browsing a rack in a slop shop
README

agent-toolkit

Shared skills, mods and plugins for coding agents — Claude Code, Codex, pi, and anything else that reads the Agent Skills format.

Install

git clone https://github.com/ekcm/agent-toolkit.git ~/Documents/GitHub/agent-toolkit
cd ~/Documents/GitHub/agent-toolkit
./install.sh --dry-run   # preview
./install.sh

Then restart any running agents so they pick up the skills.

install.sh symlinks each folder in skills/ into the user-level skill folder of every supported agent, so this repo stays the only copy:

FolderRead by
~/.agents/skills/<name>Codex, pi, and other Agent Skills tools
~/.claude/skills/<name>Claude Code (it does not read ~/.agents/skills)

It also:

  • Backs up instead of deleting. Anything already at a target path is moved to ~/.agent-toolkit-backups/<timestamp>/.
  • Removes old copies from ~/.codex/skills. Codex still reads that legacy folder as well as ~/.agents/skills, so a copy left there makes each skill appear twice.
  • Links skill by skill, never replacing a whole folder, so skills installed by other tools (e.g. npx skills) are left alone.
  • Is safe to re-run. Links that are already correct are left as they are.

Don't also link skills into ~/.pi/agent/skills: pi already reads ~/.agents/skills and warns about duplicate names.

Updating

Edits to a skill take effect immediately, since the agents read it through the symlink — just restart the agent. After adding, removing or renaming a skill, run ./install.sh again. If the repo moves, run it again from the new location.

Uninstalling

./install.sh --uninstall

This removes only the symlinks that point into this repo. Backups stay in ~/.agent-toolkit-backups/; move a folder back from there to restore it.

The target folders can be overridden with AGENTS_SKILLS_DIR, CLAUDE_SKILLS_DIR, CODEX_HOME and AGENT_TOOLKIT_BACKUP_DIR.

Skills

Each folder in skills/ is a standard SKILL.md skill: frontmatter with only name and description, and plain instructions with no agent-specific tools.

SkillWhat it does
code-review-uncommitted-changesStrict review of uncommitted changes
verify-reviewSecond-pass check that a prior review was implemented, plus a regression scan
review-pull-requestDeep review of a GitHub PR via gh
retrieve-pull-request-commentsFetch and summarise review comments on the current branch's PR
draft-pr-descriptionWrite PR.md from the branch's commits and changes
handover-session-notesSave session context to NOTES.md for the next session

Invoke a skill by name — /name in Claude Code, $name in Codex.

Claude Code mods

Mods change Claude Code itself (panels, commands, hooks), so unlike the skills they work in Claude Code only. Each lives in mods/<name>/, and this repo is a plugin marketplace that lists them.

To install from your local clone instead of GitHub, add it as a marketplace once with claude plugin marketplace add ~/Documents/GitHub/agent-toolkit, then install each mod as <name>@agent-toolkit. An install runs from a cached copy of the mod, so editing the clone changes nothing until you bump version in the mod's plugin.json, run claude plugin marketplace update agent-toolkit and claude plugin update <name>@agent-toolkit, and restart Claude Code. While working on a mod, start Claude Code with claude --plugin-dir ~/Documents/GitHub/agent-toolkit/mods/<name> instead: it loads the clone directly, and /reload-plugins picks up edits.

pr-review-board

Reviews several PRs at once with Claude and Codex side by side, in a live panel. For each PR it runs both reviewers independently, combines their findings, runs the PR's install, tests and build, then has the two agents debate whatever they disagree on, and shows what both agree on, what was confirmed, withdrawn or still disputed, colour-coded by severity and tagged by source.

The PR review panel reviewing two PRs: each reviewer's live activity, verification results and a debate in progress

Install it in Claude Code:

/plugin install pr-review-board --marketplace ekcm/agent-toolkit

Or install from your local clone:

claude plugin marketplace add ~/Documents/GitHub/agent-toolkit
/plugin install pr-review-board@agent-toolkit

It needs gh logged in, the claude and codex CLIs on your PATH, and the review-pull-request skill installed with ./install.sh.

CommandWhat it does
/review-prs ~/path/to/repo 19 22Review PRs 19 and 22 of that repo (inside the repo, /review-prs 19 22 is enough)
/review-prsOpen the panel
/review-prs trust owner/repoLet PRs of a repo you don't own run their install, tests and build
/review-prs untrust owner/repoUndo that; /review-prs trust lists the approved repos

Using the panel:

  • Press ctrl+x tab to focus the panel, then a PR's number key to open its results (or click its title). Inside a PR, 1 shows the combined result, 2 Claude's review and 3 Codex's; b goes back.
  • x (or close) closes the panel. Reviews keep running while it is closed, and /review-prs opens it again.
  • Each row has cancel and retry per reviewer, recombine to redo the combine, verify and debate from the saved reviews, and remove to drop a finished review from the panel (its files stay).
  • A PR number the repo doesn't have is reported in the command's reply and not added. Running a PR again after it finished starts a fresh review and overwrites its saved results.

What it touches:

  • Each PR is checked out as a detached worktree at ~/.agent-toolkit/pr-reviews/<repo>/pr-<n>/worktree, fetched into refs/pr-review/<n> of your local clone. Your own checkout and uncommitted changes are left alone.
  • Both reviewers and the debate are read-only. Verification runs the PR's code, so it runs only for your own repos or ones you approved, and never for a branch from someone else's fork.
  • Reviews, logs and the combined result (reconciled.md) are saved in ~/.agent-toolkit/pr-reviews/<repo>/pr-<n>/, next to the worktree. To remove a review's worktree and ref:
git -C <repo> worktree remove --force ~/.agent-toolkit/pr-reviews/<repo>/pr-<n>/worktree
git -C <repo> update-ref -d refs/pr-review/<n>

dev-statusline

A two-line status line for working across Claude Code and Codex: the repo, branch and the branch's PR on top, and how much room each agent has left below.

agent-toolkit (feat/statusline) · ✓ checks · 4/7 resolved
ctx ▰▰▰▱▱▱▱▱▱▱ 32% 64k/200k · claude 5h 58% left (2h10m) · codex 5h 55% left (1h05m)

Install it in Claude Code:

/plugin install dev-statusline --marketplace ekcm/agent-toolkit

Then point Claude Code's status line at it, in ~/.claude/settings.json (this replaces any statusLine you already have):

"statusLine": {
  "type": "command",
  "command": "sh ~/.agent-toolkit/dev-statusline/statusline.sh"
}

It needs macOS or Linux (it runs sh, sed and find), gh logged in for the PR part, the codex CLI logged in for the Codex figure, and jq for the subagent rows (macOS includes it); a part whose tool is missing is left out or shows —.

The mod keeps the figures and writes the two lines to ~/.agent-toolkit/dev-statusline/<session id>.txt; that script, which the mod writes on start-up, prints the file for the session asking. Each session's file is removed when it ends, and leftovers older than a day are cleared on start-up. The lines show under the prompt, below Claude Code's own line with the permission mode, and update whenever Claude Code redraws its status line.

What each part shows:

  • PR: the open PR for the current branch, from gh (its number is left out, since Claude Code's own line under the prompt shows it): ✓ checks, ◷ 3 running or ✗ 2 failing, then resolved review threads out of all of them. It shows no PR when the branch has none, and drops the PR part when gh is missing, logged out or offline. It refreshes at the end of each turn and every minute.
  • ctx: a gauge of the context window used, ▰ for used and ▱ for free, then the share used and tokens used out of the window (32% 64k/200k), straight from Claude Code.
  • claude 5h: how much of Claude's 5-hour usage window is left, and the time until it resets, straight from Claude Code. It shows — off a subscription.
  • codex 5h: how much of Codex's 5-hour window is left, and the time until it resets, live from your Codex account: the mod starts codex app-server for about a second, asks it for the account's rate limits (no tokens spent) and stops it. It needs the codex CLI on your PATH, logged in. If that fails, it falls back to the newest session log in ~/.codex/sessions (or $CODEX_HOME/sessions), which is only as fresh as your last interactive Codex turn. It shows — when there is no reading.

Worktrees: inside a linked git worktree the first line still leads with the project, the main repository's folder name, followed by the worktree's folder, dim: Muse/DEV-67 (feat/DEV-67-wiki-link-resolver). A detached checkout shows (detached <commit>), and its PR is the open one containing that commit, so pr-review-board's review checkouts show their PR too.

Subagents: when Claude Code runs subagents, for example one per worktree, the mod also draws their rows in the agent panel under the prompt, as a tree:

├─ DEV-67   running · ✓ checks · 0/5 resolved · 48k tokens
└─ DEV-70   done    · ✗ 1 failing · no threads · 112k tokens

Each row names the worktree the subagent works in (or the subagent, outside any worktree), its status, that worktree's PR and its tokens. The mod ships this as the plugin's subagentStatusLine setting, so it needs no settings entry; it runs ~/.agent-toolkit/dev-statusline/subagents.sh, which the mod writes on start-up. The PR states come from one gh call listing the repository's open PRs, made only when the repository has more than one worktree.

Several sessions share one Codex reading: the first to need it asks Codex and saves it to ~/.agent-toolkit/dev-statusline/codex.json, and the others reuse it for a minute.

Colours: checks and review threads are green, yellow or red, in your terminal's own colours. The repo name is bold in a darker Claude orange. The gauge's used segments, its percentage and claude 5h with its share left are Claude orange, and codex 5h with its share left is Codex lilac; a share left under 10% is also bold and underlined. left, the reset times, the free segments and the token count stay plain, and the separators, the branch's brackets and empty states (no PR, no checks, no threads, —) are dim. The brand colours need a terminal with 24-bit colour. The colours are set in one table, COLORS in hooks/format.ts.

Source 3 files
hooks/register.ts 237 lines
1import type { EngineInterface, Register } from 'claude-code'
2import { READER_SCRIPT, SUBAGENT_SCRIPT } from './reader'
3import {
4  formatStatus, parseChecks, parseCodexLine, parseCodexLive, parseOpenPrs, parseThreads, parseWorktrees, prText, worktreeName,
5  type StatusData, type Window,
6} from './format'
7
8type $ = EngineInterface
9
10// gh and the Codex logs are slow or unchanging between turns, so they are
11// read at the end of each turn and once a minute, never on every redraw.
12const REFRESH_MS = 60_000
13
14// Every open PR of the repository with its checks and review threads, for the
15// subagent rows of the repo's other worktrees.
16const OPEN_PRS_QUERY = 'query($owner: String!, $name: String!) { repository(owner: $owner, name: $name) { pullRequests(states: OPEN, first: 50, orderBy: { field: UPDATED_AT, direction: DESC }) { nodes { number headRefName headRefOid reviewThreads(first: 100) { totalCount nodes { isResolved } } commits(last: 1) { nodes { commit { statusCheckRollup { contexts(first: 100) { nodes { __typename ... on CheckRun { status conclusion } ... on StatusContext { state } } } } } } } } } } }'
17
18// One Codex reading serves every session for this long, so several open
19// sessions don't each start a Codex app server every minute.
20const CODEX_SHARED_MS = 55_000
21
22const THREADS_QUERY = 'query($owner: String!, $name: String!, $pr: Int!) { repository(owner: $owner, name: $name) { pullRequest(number: $pr) { reviewThreads(first: 100) { totalCount nodes { isResolved } } } } }'
23
24// Asks a short-lived Codex app server for the account's live rate limits,
25// which spends no tokens. The server quits when its input closes, before it
26// answers, so the input is held open until the answer comes, then the server
27// is stopped.
28const CODEX_LIVE_SCRIPT = `
29d=$(mktemp -d) || exit 1
30trap 'exec 3>&-; kill "$pid" 2>/dev/null; rm -rf "$d"' EXIT
31mkfifo "$d/in"
32codex app-server < "$d/in" > "$d/out" 2>/dev/null &
33pid=$!
34exec 3> "$d/in"
35printf '%s\\n' '{"id":1,"method":"initialize","params":{"clientInfo":{"name":"dev-statusline","version":"1"}}}' '{"method":"initialized"}' '{"id":2,"method":"account/rateLimits/read"}' >&3
36i=0
37while [ "$i" -lt 100 ] && ! grep -q '"id":2' "$d/out"; do sleep 0.1; i=$((i + 1)); done
38grep -m 1 '"id":2' "$d/out"`
39
40// The fallback when the live query fails (an older Codex, no network): the
41// newest session logs, newest first; the first token_count line with rate
42// limits in any of them is the latest reading Codex wrote down.
43const CODEX_LOG_SCRIPT = `
44for f in $(ls -t "$1"/sessions/*/*/*/rollout-*.jsonl 2>/dev/null | head -5); do
45  line=$(tail -c 4000000 "$f" | grep '"rate_limits":{' | tail -1)
46  if [ -n "$line" ]; then printf '%s\\n' "$line"; exit 0; fi
47done`
48
49const data: StatusData = { repo: null, worktree: null, branch: null, isDetached: false, pr: undefined, context: null, claude: null, codex: null }
50let ticker: { cancel: () => void } | null = null
51let isRefreshing = false
52
53async function run($: $, argv: string[], cwd?: string): Promise<string | null> {
54  try {
55    const result = await $.process.run(argv, { cwd, timeoutMs: 20_000 })
56    return result.exitCode === 0 ? result.stdout.trim() : null
57  } catch {
58    return null
59  }
60}
61
62// ~/.agent-toolkit/dev-statusline: the reader script and one file of lines
63// per session, named by the session's id.
64async function linesDir($: $): Promise<string> {
65  return `${(await $.env.get('HOME')) ?? ''}/.agent-toolkit/dev-statusline`
66}
67
68// Writes the latest lines for the status line to print. The id is read each
69// time, since /clear goes on under a new one.
70async function show($: $) {
71  const text = formatStatus(data, await $.clock.now())
72  await $.fs.write(`${await linesDir($)}/${await $.session.id()}.txt`, `${text}\n`)
73}
74
75// The project is named after the folder holding the shared .git, so a linked
76// worktree still shows as its repository; git's own directory differing from
77// the shared one is what marks a linked worktree.
78async function readGit($: $, cwd: string) {
79  const paths = await run($, ['git', '-C', cwd, 'rev-parse', '--path-format=absolute', '--show-toplevel', '--git-dir', '--git-common-dir'])
80  const [top, gitDir, commonDir] = paths ? paths.split('\n') : []
81  if (!top || !gitDir || !commonDir) {
82    Object.assign(data, { repo: null, worktree: null, branch: null, isDetached: false })
83    return
84  }
85  data.repo = commonDir.endsWith('/.git') ? worktreeName(commonDir.slice(0, -'/.git'.length)) : worktreeName(commonDir).replace(/\.git$/, '')
86  data.worktree = gitDir === commonDir ? null : worktreeName(top)
87  const branch = await run($, ['git', '-C', cwd, 'symbolic-ref', '--short', 'HEAD'])
88  data.isDetached = !branch
89  data.branch = branch ?? await run($, ['git', '-C', cwd, 'rev-parse', '--short', 'HEAD'])
90}
91
92// Leaves the PR unknown (undefined) when gh is missing, logged out or offline,
93// so the line drops the PR part instead of claiming there is none. A detached
94// checkout has no branch to look up, so its PR is the open one containing its
95// commit (how pr-review-board's review checkouts find theirs).
96async function readPr($: $, cwd: string) {
97  if (!data.repo || !data.branch) {
98    data.pr = undefined
99    return
100  }
101  let view: { number: number; statusCheckRollup?: unknown } | null
102  if (data.isDetached) {
103    const sha = await run($, ['git', '-C', cwd, 'rev-parse', 'HEAD'])
104    const found = sha ? await run($, ['gh', 'pr', 'list', '--state', 'open', '--search', sha, '--json', 'number,statusCheckRollup'], cwd) : null
105    if (found === null) {
106      data.pr = undefined
107      return
108    }
109    view = (JSON.parse(found) as { number: number; statusCheckRollup?: unknown }[])[0] ?? null
110  } else {
111    const result = await $.process.run(['gh', 'pr', 'view', '--json', 'number,statusCheckRollup'], { cwd, timeoutMs: 20_000 }).catch(() => null)
112    if (!result || (result.exitCode !== 0 && !/no pull requests found/i.test(result.stderr))) {
113      data.pr = undefined
114      return
115    }
116    view = result.exitCode === 0 ? JSON.parse(result.stdout) as { number: number; statusCheckRollup?: unknown } : null
117  }
118  if (!view) {
119    data.pr = null
120    return
121  }
122  const threads = await run($, ['gh', 'api', 'graphql', '-F', 'owner={owner}', '-F', 'name={repo}', '-F', `pr=${view.number}`, '-f', `query=${THREADS_QUERY}`], cwd)
123  data.pr = { number: view.number, checks: parseChecks(view.statusCheckRollup), threads: threads ? parseThreads(JSON.parse(threads)) : null }
124}
125
126// Writes, for the subagent rows, each of the repository's worktrees with its
127// name and its PR's state, keyed by path. A detached worktree's PR is the one
128// whose head is its commit; with none, its PR is left unknown rather than
129// "no PR", since its commit may sit inside a PR that has moved on. With only
130// one worktree there are no other worktrees to describe, so gh isn't asked.
131async function writeWorktrees($: $, cwd: string) {
132  const file = `${await linesDir($)}/${await $.session.id()}.worktrees.json`
133  const porcelain = data.repo ? await run($, ['git', '-C', cwd, 'worktree', 'list', '--porcelain']) : null
134  const worktrees = porcelain ? parseWorktrees(porcelain) : []
135  const rows: Record<string, { name: string; pr: string }> = {}
136  if (worktrees.length > 1) {
137    const answer = await run($, ['gh', 'api', 'graphql', '-F', 'owner={owner}', '-F', 'name={repo}', '-f', `query=${OPEN_PRS_QUERY}`], cwd)
138    const prs = answer ? parseOpenPrs(JSON.parse(answer)) : null
139    for (const worktree of worktrees) {
140      const pr = !prs ? undefined
141        : worktree.branch ? prs.byBranch.get(worktree.branch) ?? null
142        : worktree.head ? prs.byCommit.get(worktree.head)
143        : undefined
144      rows[worktree.path] = { name: worktreeName(worktree.path), pr: prText(pr) ?? '' }
145    }
146  }
147  await $.fs.write(file, JSON.stringify(rows))
148}
149
150// Reuses another session's reading when it is fresh, else asks Codex live,
151// falling back to its session logs, and shares what it found.
152async function readCodex($: $) {
153  const shared = `${await linesDir($)}/codex.json`
154  const now = await $.clock.now()
155  try {
156    const saved = JSON.parse(await $.fs.read(shared)) as { at: number; codex: Window | null }
157    if (now - saved.at < CODEX_SHARED_MS) {
158      data.codex = saved.codex
159      return
160    }
161  } catch {
162    // No reading yet, or an unreadable one: ask Codex.
163  }
164  const live = await run($, ['sh', '-c', CODEX_LIVE_SCRIPT])
165  let codex = live ? parseCodexLive(live) : null
166  if (!codex) {
167    const home = (await $.env.get('CODEX_HOME')) ?? `${(await $.env.get('HOME')) ?? ''}/.codex`
168    const line = await run($, ['sh', '-c', CODEX_LOG_SCRIPT, 'sh', home])
169    codex = line ? parseCodexLine(line) : null
170  }
171  data.codex = codex
172  await $.fs.write(shared, JSON.stringify({ at: now, codex }))
173}
174
175async function refresh($: $) {
176  if (isRefreshing) return
177  isRefreshing = true
178  try {
179    const cwd = await $.session.cwd()
180    await readGit($, cwd)
181    await Promise.all([
182      readPr($, cwd).catch(() => { data.pr = undefined }),
183      readCodex($),
184      writeWorktrees($, cwd).catch(() => undefined),
185    ])
186  } finally {
187    isRefreshing = false
188  }
189  await show($)
190}
191
192function claudeWindow(limits: { kind: string; percentUsed: number; resetsAt?: string }[]): Window | null {
193  const five = limits.find(limit => limit.kind === 'five_hour')
194  return five ? { percentUsed: five.percentUsed, resetsAt: five.resetsAt ? Date.parse(five.resetsAt) : null } : null
195}
196
197export const register: Register = on => {
198  on('session.start', async ($, e, next) => {
199    // Clears a notice pinned by 0.1.0, which drew the line with $.ui.status.
200    $.ui.status(undefined)
201    const dir = await linesDir($)
202    await $.fs.write(`${dir}/statusline.sh`, READER_SCRIPT)
203    await $.fs.write(`${dir}/subagents.sh`, SUBAGENT_SCRIPT)
204    // Files of sessions that ended without cleaning up (a crash, a kill).
205    await run($, ['find', dir, '(', '-name', '*.txt', '-o', '-name', '*.worktrees.json', ')', '-mtime', '+1', '-delete'])
206    const usage = await $.session.usage()
207    data.context = usage.context
208    data.claude = claudeWindow(usage.rateLimits)
209    await show($)
210    void refresh($)
211    ticker?.cancel()
212    ticker = $.clock.every(REFRESH_MS, () => void refresh($))
213    return next(e)
214  })
215
216  // Context and the Claude window come pushed from the engine after each turn.
217  on('session.measure', async ($, e, next) => {
218    data.context = e.context
219    data.claude = claudeWindow(e.rateLimits)
220    await show($)
221    return next(e)
222  })
223
224  on('turn.complete', async ($, e, next) => {
225    void refresh($)
226    return next(e)
227  })
228
229  on('session.end', async ($, e, next) => {
230    ticker?.cancel()
231    ticker = null
232    const dir = await linesDir($)
233    await run($, ['rm', '-f', `${dir}/${e.sessionId}.txt`, `${dir}/${e.sessionId}.worktrees.json`])
234    return next(e)
235  })
236}
237
hooks/reader.ts 46 lines
1// Claude Code's status line runs this with the session as JSON on stdin; it
2// prints the lines the mod last wrote for that session.
3export const READER_SCRIPT = `#!/bin/sh
4# Written by the dev-statusline mod. Prints the lines it wrote for this session.
5id=$(sed -n 's/.*"session_id"[[:space:]]*:[[:space:]]*"\\([^"]*\\)".*/\\1/p' | head -n 1)
6[ -n "$id" ] && cat "$(dirname "$0")/$id.txt" 2>/dev/null
7exit 0
8`
9
10// Claude Code's subagent status line runs this with every subagent row as JSON
11// on stdin. It draws each row as a tree: a dim ├─ (└─ for the last), the
12// worktree the subagent works in (or its name), its status, the PR the mod
13// last wrote for that worktree, and its tokens. Rows it can't draw keep
14// Claude Code's own; without jq it draws none.
15export const SUBAGENT_SCRIPT = `#!/bin/sh
16# Written by the dev-statusline mod. Draws one row per subagent.
17command -v jq >/dev/null 2>&1 || exit 0
18input=$(cat)
19id=$(printf '%s' "$input" | jq -r '.session_id // empty')
20map="$(dirname "$0")/$id.worktrees.json"
21{ [ -n "$id" ] && [ -f "$map" ]; } || map=/dev/null
22printf '%s' "$input" | jq -c --slurpfile wt "$map" '
23  def tokens: if . >= 1000000 then "\\((. / 100000 | floor) / 10)M" elif . >= 1000 then "\\(. / 1000 | round)k" else tostring end;
24  def dim: "\\u001b[2m" + . + "\\u001b[0m";
25  def pad($n): . + ((" " * ($n - length)) // "");
26  ($wt[0] // {}) as $m
27  | (.tasks // []) as $t
28  | [$t | to_entries[] | .key as $i | .value as $task
29      | ($task.cwd // "") as $cwd
30      | ([$m | to_entries[] | .key as $k | select($cwd == $k or ($cwd | startswith($k + "/")))] | max_by(.key | length)) as $w
31      | { i: $i, id: $task.id,
32          label: ((if $w then $w.value.name else ($task.name // $task.description // "agent") end)[:24]),
33          pr: ($w.value.pr // ""),
34          status: ({"completed": "done", "killed": "stopped"}[$task.status] // $task.status // ""),
35          tokens: (($task.tokenCount // 0) | tokens) }] as $rows
36  | ($rows | map(.label | length) | max // 0) as $lw
37  | ($rows | map(.status | length) | max // 0) as $sw
38  | $rows[]
39  | { id, content: (
40      ((if .i == ($rows | length) - 1 then "└─" else "├─" end) | dim) + " "
41      + (.label | pad($lw)) + "  "
42      + (if .status == "failed" then "\\u001b[31m" + (.status | pad($sw)) + "\\u001b[0m" else (.status | pad($sw)) end)
43      + (if .pr != "" then " " + ("·" | dim) + " " + .pr else "" end)
44      + " " + ("·" | dim) + " " + .tokens + " tokens") }'
45`
46
hooks/format.ts 233 lines
1// Pure parsing and formatting for the status line, kept apart from the hooks
2// so it can be tested without a session.
3
4// Segments in the context gauge.
5const GAUGE_WIDTH = 10
6
7export type Checks = { passing: number; failing: number; running: number }
8export type Threads = { resolved: number; total: number }
9export type Pr = { number: number; checks: Checks; threads: Threads | null }
10
11// A rate-limit window: percent used and when it resets (ms since epoch).
12export type Window = { percentUsed: number; resetsAt: number | null }
13
14export type StatusData = {
15  // The project: the main repository's folder name, also inside a worktree.
16  repo: string | null
17  // The linked worktree's folder name; null in the main checkout.
18  worktree?: string | null
19  // The branch, or the short commit when the checkout is detached.
20  branch: string | null
21  isDetached?: boolean
22  // undefined: not looked up yet (or gh unavailable); null: no PR for the branch.
23  pr: Pr | null | undefined
24  context: { tokens?: number; percent?: number; window: number } | null
25  claude: Window | null
26  codex: Window | null
27}
28
29const FAILED = new Set(['FAILURE', 'ERROR', 'TIMED_OUT', 'CANCELLED', 'ACTION_REQUIRED', 'STARTUP_FAILURE'])
30const SKIPPED = new Set(['SKIPPED', 'NEUTRAL', 'STALE'])
31
32// Reads `gh pr view --json statusCheckRollup`: check runs carry status and
33// conclusion, commit statuses carry state.
34export function parseChecks(rollup: unknown): Checks {
35  const checks: Checks = { passing: 0, failing: 0, running: 0 }
36  if (!Array.isArray(rollup)) return checks
37  for (const item of rollup as { status?: string; conclusion?: string; state?: string }[]) {
38    if (item.state !== undefined) {
39      if (item.state === 'SUCCESS') checks.passing += 1
40      else if (FAILED.has(item.state)) checks.failing += 1
41      else checks.running += 1
42    } else if (item.status !== 'COMPLETED') checks.running += 1
43    else if (FAILED.has(item.conclusion ?? '')) checks.failing += 1
44    else if (!SKIPPED.has(item.conclusion ?? '')) checks.passing += 1
45  }
46  return checks
47}
48
49// Reads the GraphQL answer for a PR's review threads.
50export function parseThreads(answer: unknown): Threads | null {
51  const threads = (answer as { data?: { repository?: { pullRequest?: { reviewThreads?: { totalCount?: number; nodes?: { isResolved?: boolean }[] } } } } })
52    ?.data?.repository?.pullRequest?.reviewThreads
53  if (!threads || typeof threads.totalCount !== 'number' || !Array.isArray(threads.nodes)) return null
54  return { resolved: threads.nodes.filter(n => n.isResolved).length, total: threads.totalCount }
55}
56
57// Reads the last Codex `token_count` line that carries rate limits; the
58// primary window is the 5-hour one.
59export function parseCodexLine(line: string): Window | null {
60  try {
61    const event = JSON.parse(line) as { payload?: { rate_limits?: { primary?: { used_percent?: number; resets_at?: number } } } }
62    const primary = event.payload?.rate_limits?.primary
63    if (!primary || typeof primary.used_percent !== 'number') return null
64    return { percentUsed: primary.used_percent, resetsAt: typeof primary.resets_at === 'number' ? primary.resets_at * 1000 : null }
65  } catch {
66    return null
67  }
68}
69
70// Reads the app server's answer to account/rateLimits/read; the primary
71// window is the 5-hour one.
72export function parseCodexLive(line: string): Window | null {
73  try {
74    const answer = JSON.parse(line) as { result?: { rateLimits?: { primary?: { usedPercent?: number; resetsAt?: number } } } }
75    const primary = answer.result?.rateLimits?.primary
76    if (!primary || typeof primary.usedPercent !== 'number') return null
77    return { percentUsed: primary.usedPercent, resetsAt: typeof primary.resetsAt === 'number' ? primary.resetsAt * 1000 : null }
78  } catch {
79    return null
80  }
81}
82
83// Terminal colour codes. Green, yellow and red are the terminal's own, so
84// they follow its theme; the brand colours need a terminal with 24-bit colour
85// (most current ones). `repo` is bold in a darker Claude orange.
86export const COLORS = {
87  good: '32',
88  warn: '33',
89  bad: '31',
90  claude: '38;2;215;119;87',
91  codex: '38;2;180;160;255',
92  repo: '1;38;2;193;95;60',
93  dim: '2',
94} as const
95
96// Below this share left, a 5-hour figure is bold and underlined as well.
97export const LOW_LEFT = 10
98
99// `extra` adds styles to the colour, such as '1;4' for bold and underlined.
100export function paint(color: keyof typeof COLORS, text: string, extra = ''): string {
101  if (!text) return ''
102  return `\x1b[${extra ? `${extra};` : ''}${COLORS[color]}m${text}\x1b[0m`
103}
104
105// Parts are joined by a dim dot, so they read apart without competing.
106const SEPARATOR = ` ${paint('dim', '·')} `
107
108// Removes colour codes, for tests and for anything that needs the plain text.
109export function plain(text: string): string {
110  return text.replace(/\x1b\[[0-9;]*m/g, '')
111}
112
113function checksText(checks: Checks): string {
114  if (checks.failing) return paint('bad', `✗ ${checks.failing} failing`)
115  if (checks.running) return paint('warn', `◷ ${checks.running} running`)
116  if (checks.passing) return paint('good', '✓ checks')
117  return paint('dim', 'no checks')
118}
119
120// All resolved is good, some is a warning, none is bad.
121function threadsText(threads: Threads | null): string | null {
122  if (!threads) return null
123  if (!threads.total) return paint('dim', 'no threads')
124  const color = threads.resolved === threads.total ? 'good' : threads.resolved ? 'warn' : 'bad'
125  return paint(color, `${threads.resolved}/${threads.total} resolved`)
126}
127
128export function prText(pr: Pr | null | undefined): string | null {
129  if (pr === undefined) return null
130  if (pr === null) return paint('dim', 'no PR')
131  // No PR number: Claude Code's own line under the prompt already shows it.
132  return [checksText(pr.checks), threadsText(pr.threads)].filter(Boolean).join(SEPARATOR)
133}
134
135// 950 → 950, 222_345 → 222k, 1_000_000 → 1M, 1_250_000 → 1.3M.
136export function tokensText(tokens: number): string {
137  if (tokens >= 1_000_000) return `${Number((tokens / 1_000_000).toFixed(1))}M`
138  if (tokens >= 1_000) return `${Math.round(tokens / 1_000)}k`
139  return String(tokens)
140}
141
142// A gauge of the context window used: the used segments and the share in
143// Claude orange, the free segments and 60k/200k plain.
144function contextText(context: StatusData['context']): string {
145  if (!context || !context.window) return `ctx ${paint('dim', '—')}`
146  const tokens = context.tokens ?? 0
147  const used = Math.max(0, Math.min(100, Math.round(context.percent ?? (tokens / context.window) * 100)))
148  // Rounded down, so the gauge is full only when the window is.
149  const filled = Math.floor((used / 100) * GAUGE_WIDTH)
150  const gauge = `${paint('claude', '▰'.repeat(filled))}${'▱'.repeat(GAUGE_WIDTH - filled)}`
151  return `ctx ${gauge} ${paint('claude', `${used}%`)} ${tokensText(tokens)}/${tokensText(context.window)}`
152}
153
154function untilText(resetsAt: number, now: number): string {
155  const minutes = Math.max(0, Math.round((resetsAt - now) / 60_000))
156  return `${Math.floor(minutes / 60)}h${String(minutes % 60).padStart(2, '0')}m`
157}
158
159// `claude 5h 37%` in the agent's colour, then `left (3h20m)` plain. Below
160// LOW_LEFT the share is bold and underlined too.
161function windowText(agent: 'claude' | 'codex', window: Window | null, now: number): string {
162  if (!window) return `${paint(agent, `${agent} 5h`)} ${paint('dim', '—')}`
163  // A window that has reset since it was read is whole again.
164  if (window.resetsAt !== null && window.resetsAt <= now) return `${paint(agent, `${agent} 5h 100%`)} left`
165  const left = Math.max(0, Math.round(100 - window.percentUsed))
166  const reset = window.resetsAt !== null ? ` (${untilText(window.resetsAt, now)})` : ''
167  return `${paint(agent, `${agent} 5h`)} ${paint(agent, `${left}%`, left < LOW_LEFT ? '1;4' : '')} left${reset}`
168}
169
170export function formatStatus(data: StatusData, now: number): string {
171  const head = data.isDetached ? `detached ${data.branch}` : data.branch
172  const branch = head ? ` ${paint('dim', '(')}${head}${paint('dim', ')')}` : ''
173  const worktree = data.worktree ? paint('dim', `/${data.worktree}`) : ''
174  const where = data.repo ? `${paint('repo', data.repo)}${worktree}${branch}` : null
175  const first = [where, data.repo ? prText(data.pr) : null].filter(Boolean).join(SEPARATOR)
176  const second = [contextText(data.context), windowText('claude', data.claude, now), windowText('codex', data.codex, now)].join(SEPARATOR)
177  return first ? `${first}\n${second}` : second
178}
179
180export type Worktree = { path: string; head: string | null; branch: string | null }
181
182// Reads `git worktree list --porcelain`: a `worktree <path>` line opens each
183// entry, `HEAD <sha>` gives its commit, and `branch refs/heads/<name>` names
184// its branch unless detached.
185export function parseWorktrees(porcelain: string): Worktree[] {
186  const worktrees: Worktree[] = []
187  for (const line of porcelain.split('\n')) {
188    if (line.startsWith('worktree ')) worktrees.push({ path: line.slice('worktree '.length), head: null, branch: null })
189    else if (line.startsWith('HEAD ') && worktrees.length) worktrees[worktrees.length - 1]!.head = line.slice('HEAD '.length)
190    else if (line.startsWith('branch refs/heads/') && worktrees.length) worktrees[worktrees.length - 1]!.branch = line.slice('branch refs/heads/'.length)
191  }
192  return worktrees
193}
194
195type OpenPrNode = {
196  number?: number
197  headRefName?: string
198  headRefOid?: string
199  reviewThreads?: { totalCount?: number; nodes?: { isResolved?: boolean }[] }
200  commits?: { nodes?: { commit?: { statusCheckRollup?: { contexts?: { nodes?: unknown[] } } | null } }[] }
201}
202
203// Reads the GraphQL answer listing a repository's open PRs, each with its
204// checks and review threads, keyed by branch and by head commit (for detached
205// checkouts, which have no branch to go by).
206export function parseOpenPrs(answer: unknown): { byBranch: Map<string, Pr>; byCommit: Map<string, Pr> } {
207  const byBranch = new Map<string, Pr>()
208  const byCommit = new Map<string, Pr>()
209  const nodes = (answer as { data?: { repository?: { pullRequests?: { nodes?: OpenPrNode[] } } } })?.data?.repository?.pullRequests?.nodes
210  for (const node of Array.isArray(nodes) ? nodes : []) {
211    if (typeof node.number !== 'number' || !node.headRefName) continue
212    const threads = node.reviewThreads
213    const pr: Pr = {
214      number: node.number,
215      checks: parseChecks(node.commits?.nodes?.[0]?.commit?.statusCheckRollup?.contexts?.nodes ?? []),
216      threads: threads && typeof threads.totalCount === 'number' && Array.isArray(threads.nodes)
217        ? { resolved: threads.nodes.filter(n => n.isResolved).length, total: threads.totalCount }
218        : null,
219    }
220    byBranch.set(node.headRefName, pr)
221    if (node.headRefOid) byCommit.set(node.headRefOid, pr)
222  }
223  return { byBranch, byCommit }
224}
225
226// The name a worktree's row goes by: its folder, or for a folder named just
227// `worktree` (as pr-review-board's are, under pr-<n>/) the folder above it.
228export function worktreeName(path: string): string {
229  const parts = path.split('/').filter(Boolean)
230  const last = parts[parts.length - 1] ?? path
231  return last === 'worktree' && parts.length > 1 ? parts[parts.length - 2]! : last
232}
233