feat: add model-aware status-line badge (web-jam-tools#688) - #691
feat: add model-aware status-line badge (web-jam-tools#688)#691JoshuaVSherman wants to merge 3 commits into
Conversation
- scripts/statusline.sh: new wrapper that reads the Claude Code status-line
stdin payload, extracts .model.display_name, and prints a color-coded
Opus/Sonnet/Haiku badge (case-insensitive family-word match, uncolored
fallback for anything unrecognized) before passing the payload through
unmodified to the downstream status-line command.
- scripts/merge-hooks-into-settings.ts: add --status-line section, merging
a single statusLine {type, command} value idempotently, with --check
drift detection matching the existing --deny/--ask pattern.
- scripts/install-hooks.sh: wire the new --status-line arg into the
$SETTINGS_PATH invocations only; $AGY_HOOKS_PATH invocations are
untouched, since agy has no status-line surface.
- test/statusline_script.test.ts: new, covers all three tiers, the
unrecognized/malformed-JSON/missing-model fallback cases, and unmodified
pass-through to the downstream command.
- test/install_hooks_merge.test.ts: extend with --status-line merge/no-op/
update/--check coverage.
- test/install_hooks_script.test.ts: teach the synthetic worktree fixture
to also copy scripts/statusline.sh, now a dependency of install-hooks.sh.
- docs/scripts.md: document the new script.
04651c0 to
fef9bcb
Compare
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
PR Review Summary
🛑 Changes Requested
Reviewed by Opus at Josh's direct instruction (outside the usual cross-model pairing). Reviewed at head aa35db593d8352e69704ec0e12b977aafc67f941; no prior automated review exists on this PR.
Amended 2026-08-21. This review originally carried a second Must Fix item saying the PR was not a draft. That finding is withdrawn — it was wrong. Josh takes his own PRs out of draft; flagging that is flagging his deliberate workflow action as a defect in the work. Draft/ready state is not a review finding and never should have been raised. The
pr-reviewskill is being amended to exclude it explicitly.
The implementation is good and the scope is exactly right — eight files, every one named in web-jam-tools#688 "Add a model-aware status-line badge so Opus and Sonnet sessions are visually distinguishable", nothing stray. scripts/statusline.sh handles its failure modes deliberately (unrecognized tier, missing .model, malformed JSON all produce usable output), the tier match is on the family word so a version bump won't break it, and the agy isolation is real rather than asserted: I checked lines 711 and 794 of scripts/install-hooks.sh and neither $AGY_HOOKS_PATH invocation receives --status-line.
One thing blocks merge.
🛑 Must Fix Items
- 🛑 The status line is registered at a raw working-tree path and is invisible to drift detection.
scripts/install-hooks.sh:631setsSTATUS_LINE_COMMAND="$REPO_DIR/scripts/statusline.sh"and registers that directly intosettings.json. Every hook in this repo instead gets a stable installed path — the live denial messages on this machine name them as$HOME/.claude/hooks/<name>.sh— andscripts/statusline.shis never linked into that directory at all. Two consequences: (1) the registered command points into whichever checkout ran the installer, so it breaks if the repo moves or if a branch lacking the file is checked out, and (2) because it never lands in the hooks directory,hooks/hook-install-drift-reminder.shcannot see it — a dead or stale status-line path will never be reported, unlike every other installed artifact. Install it the same way hooks are installed and register the stable path.
Checklist Verification
- Mergeability:
⚠️ UNKNOWN— GitHub is still recomputing after the re-bump push. Re-check before merging; do not readUNKNOWNas ready. - CircleCI:
⚠️ ci/circleci: gateis pending (build 867). Build 866 failed, but that was against the intermediate commit before the version re-bump, so it is not evidence against the current head. Must be green before merge. - Snyk: ✅ No Snyk check configured on this PR.
- Issue acceptance criteria & scope: ✅ All eight changed files are named in the issue:
scripts/statusline.sh,scripts/merge-hooks-into-settings.ts,scripts/install-hooks.sh,test/statusline_script.test.ts,test/install_hooks_merge.test.ts,test/install_hooks_script.test.ts,docs/scripts.md,deno.json. No stray refactors. - Single semver bump: ✅ Branch is at 1.30.61,
devat 1.30.60 — strictly exceeds. The re-bump was required becausedevadvanced to 1.30.60 after this branch's first commit, which the criteria explicitly call correct rather than a gratuitous double bump. - Package-lock engine alignment: ✅ Not applicable — Deno repository, no
package.json. - Test plan integrity: ✅ Goes well beyond suite invocations — feeds synthetic per-tier payloads through the script and inspects the ANSI output under
cat -v, and runs the installer twice against throwaway paths to prove idempotency and that the agy target keepsstatusLine: null. - AGENTS.md guardrails: ✅ No raw
anyin the added TypeScript (checked the*.tsdiff). No UI, forms, OAuth, footer, or headers surface touched.
🟡 Actionable Feedback & Suggestions
- 🟡
scripts/statusline.shsetsset -uo pipefailand ends on the pipelineprintf '%s' "$payload" | bash -c "$downstream_cmd", so the script's exit status is the downstream command's. The default downstream isnpx -y ccusage statusline, which reaches the network and will fail offline or if the package resolve fails. The script's own header states it must never make the status line disappear; an explicitexit 0after the pipeline would guarantee that regardless of how Claude Code treats a non-zero exit from a status-line command. I did not verify what Claude Code actually does with a non-zero exit — which is the point: the fix removes the dependency on that answer. - 🟡
bash -c "$downstream_cmd"executes the contents of theSTATUSLINE_DOWNSTREAM_CMDenvironment variable as shell. It is a legitimate and well-documented test seam, and the risk is low since it is the user's own environment, but a status line runs on every render — worth a note that anything setting that variable gets arbitrary execution on each redraw. - 🟡 The badge occupies fixed width per tier (
[Opus],[Sonnet],[Haiku]). If the fallback fires, the text is the full unrecognizeddisplay_name, which could be long and push the cost/usage segment off-screen on a narrow terminal. Truncating the fallback to a sensible width would keep the line stable.
Final merge remains Josh's call.
Review finding on web-jam-tools#691 "feat: add model-aware status-line badge (web-jam-tools#688)": install-hooks.sh registered STATUS_LINE_COMMAND as the raw working-tree path $REPO_DIR/scripts/statusline.sh, so the registered command pointed into whichever checkout ran the installer and broke if the repo moved or a branch lacking the file was checked out. It was also never linked into the hooks directory, so hook-install-drift-reminder.sh could not see it and a dead status-line path would never be reported. statusline.sh is now installed to a stable destination alongside the hooks and registered at that path, and the drift checker covers it. Version bumped to 1.30.64 to clear dev at 1.30.62. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Summary
scripts/statusline.sh, a new wrapper that reads the Claude Code status-line JSON payload from stdin (a different payload from the hook payload — it carries.model.display_name, which hooks never see), extracts the model tier, and prints a color-coded[Opus]/[Sonnet]/[Haiku]badge in front of the existing status line.display_name, matched case-insensitively, so a version bump (Opus 5→Opus 6) keeps matching without a script change. An unrecognizeddisplay_name, a missing.modelkey, and malformed JSON on stdin all still produce usable, non-empty, exit-0 output rather than an error or a blank line.npx -y ccusage statuslineby default, overridable viaSTATUSLINE_DOWNSTREAM_CMDfor testing) — the badge is a prefix, never a replacement.scripts/merge-hooks-into-settings.tswith a new--status-linesection that idempotently merges a singlestatusLine: { type: "command", command: "<path>" }value, following the same pattern as the existing--stop/--pre-tool-use/--post-tool-use/--deny/--asksections, including--checkdrift-detection behavior.scripts/install-hooks.shon the$SETTINGS_PATHinvocations only (both the--checkand the write-mode call) — the two$AGY_HOOKS_PATHinvocations are untouched, since agy has no status-line surface for this to install into.test/statusline_script.test.ts, covering all three tiers (with distinct ANSI codes), the unrecognized/malformed-JSON/missing-.model-key fallback cases, and that the original payload reaches the downstream command unmodified and after the badge.test/install_hooks_merge.test.tswith--status-linemerge/no-op/update/--checkcoverage, and teachtest/install_hooks_script.test.ts's synthetic worktree fixture to copyscripts/statusline.sh, now a dependency ofinstall-hooks.sh.docs/scripts.md.This closes the gap where an Opus session and a Sonnet session look identical in the terminal — the only prior signal was remembering what a session was launched with, which is exactly the condition that lets Opus tokens get spent on work that belonged on a cheaper tier.
Not closing the issue on merge. Per the newly-merged web-jam-tools#689 "A PR for a hook issue must never close it on merge — the hook has to be installed and confirmed firing first" rule: this status line is installed into
~/.claude/settings.jsonbyscripts/install-hooks.shand does nothing until that installer runs and Claude Code restarts. Merging this PR alone does not put the badge in force, so the issue stays open (Part of) for Josh to close by hand after he installs it and confirms the badge actually appears.How to test locally
Sourced from the issue's "How to test locally" section, corrected to what was actually run.
Expect: both green. Actually ran:
deno task test→ok | 1333 passed | 0 failed;deno task check→ clean (no type errors acrosssrc/andtest/). Also randeno task fmt:checkanddeno task lint, both clean, since the format gate ispart of CI here too.
Expect: three visibly different badges (
^[[1;35m[Opus]^[[0m,^[[1;36m[Sonnet]^[[0m,^[[1;32m[Haiku]^[[0mundercat -v), each followed by the unmodified input payload.Actually observed exactly that.
Expect: the first run adds
statusLine; the second run's stdout for the settingsfile reads "already up to date (no-op)";
jq '.statusLine' .../settings.jsonshows{"type":"command","command":"<repo>/scripts/statusline.sh"};jq '.statusLine'onthe agy
hooks.jsontarget returnsnull. Actually ran this against throwawaypaths under
/tmpand observed exactly that on both runs.Expect: all green (9, 49, and 18 tests respectively — all passed).
Part of #688
🤖 Work by Claude Code — Sonnet 5