Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
name: ci

on:
push:
branches: [main]
pull_request:

permissions:
contents: read

jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: gofmt
run: |
unformatted=$(gofmt -l .)
if [ -n "$unformatted" ]; then
echo "::error::these files are not gofmt-clean:"
echo "$unformatted"
exit 1
fi
- run: go vet ./...

test:
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
- run: go build ./...
- run: go test ./...
39 changes: 34 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
# ccbit

[![ci](https://github.com/livlign/ccbit/actions/workflows/ci.yml/badge.svg)](https://github.com/livlign/ccbit/actions/workflows/ci.yml)

A session-awareness status line for [Claude Code](https://claude.com/claude-code), led by a stateful kaomoji face named **Bit**. On every render Bit reads the session transcript, works out what's happening, and narrates it in a line or two — like a small secretary sitting on the bottom row of your terminal.

![ccbit — a status line that reads your session. One coding turn narrated by Bit: working in cyan, a build failure with a table-flip face, recovery with "Build green again.", a done summary with files edited and tests passed, idle with alerts from other sessions — ending on all 8 states: working, agents running, waiting on you, failed, done, done-recovered, stopped, idle.](docs/hero.gif)
Expand All @@ -21,6 +23,8 @@ Requires Claude Code ≥ v2.1.153 (for the `COLUMNS` width hint). No Go toolchai
curl -fsSL https://raw.githubusercontent.com/livlign/ccbit/main/install.sh | bash
```

The installer verifies the downloaded binary's SHA-256 against the release's `checksums.txt` before installing, and refuses to install on a mismatch.

Open a new Claude Code session to see Bit. A settings file may only define **one** `statusLine`; if you already have one, the installer replaces it (your previous `settings.json` is backed up first).

On Windows, grab `ccbit_windows_amd64.zip` (or `arm64`) from the [latest release](https://github.com/livlign/ccbit/releases/latest) and point your status line at the extracted `ccbit.exe`.
Expand Down Expand Up @@ -57,25 +61,25 @@ Bit's face maps to the session state (first match wins, in priority order), and
| Priority | State | Face | When |
|---|---|---|---|
| 1 | Stopped | `(¬°-°)¬` | a quiet open turn with a dispatched tool that never answered (hang or unnoticed permission prompt), or silence past any plausible thinking stretch (~15m) |
| 2 | Failed | <code>(╯°□°)╯︵&nbsp;┻━┻</code> | the latest build/test errored |
| 2 | Failed | <code>(╯°□°)╯︵&nbsp;┻━┻</code> | the latest build/test errored — Bit appends the concrete reason when the output gives one up (a compiler location, a named failing test, a known signature) |
| 3 | Waiting on you | `(◕_◕)?` | a question or plan is awaiting your answer |
| 4 | Agents running | <code>┏(•&#95;•)┛&nbsp;⇄&nbsp;┗(•&#95;•)┓</code> | subagents are in flight |
| 5 | Working | <code>-(๏&#95;๏)-&nbsp;⇄&nbsp;৲(๏&#95;๏)৲</code> | a turn is in progress; long silent stretches with nothing pending show as `thinking (2m31s)` rather than decaying into Stopped |
| 5 | Working | <code>\(•&#95;•)/&nbsp;⇄&nbsp;/(•&#95;•)\</code> | a turn is in progress; the working face is a hand gesture picked per turn (raise-the-roof, pumping, flexing, …) that animates between two frames. Long silent stretches with nothing pending show as `thinking (2m31s)` rather than decaying into Stopped |
| 6 | Done (recovered) | `(→_←")` | idle, and a build/test passed this turn after an earlier failure |
| 7 | Done | `(つ•‿•)つ` | the turn finished having edited, committed/pushed/deployed, or passed a build/test |
| 8 | Idle | `(•_•)` | nothing else applies (e.g. a turn that only read or answered) |
| 8 | Idle | `(•_•)` `(°.°)` `(◕_◕)` … | nothing else applies (e.g. a turn that only read or answered). The idle face rotates per turn through a small set of resting moods — picked deterministically from the turn, so it's steady through a turn's repaints and just varies turn to turn |

Bit recaps in plain sentences rather than a row of glyphs:

```
(つ•‿•)つ 4 files edited, line changes: +885/-99. Build succeeded. Tests succeeded.
(→_←") 2 files edited. Build green again.
-(๏_๏)- editing ccbit (3 files) · 1/4 todos · 2m14s
(╯°□°)╯︵ ┻━┻ ccbit build failed
(╯°□°)╯︵ ┻━┻ ccbit build failed · internal/render/render.go:42:3: undefined: foo
(¬°-°)¬ stopped · last: editing render.go · 2m ago
```

Motion exists only in Working and Agents — a 2-frame swap on a ~2s wall-clock cycle, time-derived so concurrent repaints never jitter. The felt liveness during a long turn comes from the **numbers** ticking, not the face.
Motion exists only in Working and Agents — a 2-frame swap on a ~2s wall-clock cycle, time-derived so concurrent repaints never jitter. The Working and Idle faces are also assembled per turn from shared parts — a pool of eyes and a per-state pool of hands — so which gesture (or resting pose) you see is picked once per turn and held steady through that turn's repaints, varying turn to turn. The felt liveness during a long turn comes from the **numbers** ticking and the gesture animating, not from the face changing under you.

### Other sessions

Expand All @@ -91,6 +95,21 @@ When you run several sessions at once, Bit speaks up about the others on line 1
- A session that **crashed**, **needs you**, or **stalled** is named with its state.
- Several merely-busy sessions become a light count; a single idle one isn't mentioned at all.

Line 1 can only afford a fragment. For the full picture, run `ccbit sessions` in any shell — it prints every live session as a table, the actionable ones first:

```
$ ccbit sessions
STATE AGE PROJECT SESSION
failed 2m api Fix login bug
needs you 5s web Redesign nav
working 1s ccbit Refactor parser
idle 30s docs —
```

Same data, no new source — just a full read of the heartbeats line 1 hints at. Add `--json` to pipe it.

To see every face before a real session happens to hit each state, run `ccbit demo` — it prints Bit in all eight states from synthetic data (reads nothing). `ccbit version` and `ccbit help` round out the CLI.

### Line 2 — the ambient line

```
Expand All @@ -117,6 +136,16 @@ Everything stays silent until there's enough history to be trustworthy — a wro
| `NO_COLOR` | unset | set to disable all ANSI color |
| `COLUMNS` | — | width hint; below ~60 columns, risky wide glyphs fall back to ASCII-safe faces |

### Custom error signatures (optional)

When a build fails, Bit shows the first concrete reason it can extract from the output. To map a recurring failure to your own message, create `~/.config/ccbit/error-signatures` (or `$XDG_CONFIG_HOME/ccbit/error-signatures`) with one `pattern⇥message` per line — a case-insensitive regular expression, a **tab**, then the message. A matching signature wins over the built-in extraction; `#` lines are comments.

```
# pattern<TAB>message
401|codeartifact renew AWS SSO for codeartifact
no space left on device disk full — clear build cache
```

## How it works

```
Expand Down
98 changes: 92 additions & 6 deletions cmd/ccbit/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,10 @@ package main

import (
"context"
"encoding/json"
"fmt"
"hash/fnv"
"io"
"os"
"os/exec"
"path/filepath"
Expand All @@ -31,6 +33,90 @@ import (
var version = "dev"

func main() {
// Claude Code invokes ccbit with no arguments to paint the status line. Any
// argument means a human ran it directly — dispatch to a subcommand.
if len(os.Args) > 1 {
os.Exit(runCLI(os.Args[1:]))
}
statusLine()
}

// runCLI handles the explicit subcommands. Returns a process exit code.
func runCLI(args []string) int {
switch args[0] {
case "sessions":
return cmdSessions(args[1:])
case "demo":
for _, line := range render.Demo(os.Getenv("NO_COLOR") == "") {
fmt.Println(line)
}
return 0
case "version", "-v", "--version":
fmt.Println("ccbit " + version)
return 0
case "help", "-h", "--help":
printUsage(os.Stdout)
return 0
default:
fmt.Fprintf(os.Stderr, "ccbit: unknown command %q\n\n", args[0])
printUsage(os.Stderr)
return 2
}
}

// cmdSessions prints the roster of live sessions across all terminals — a full
// read of the heartbeat dir the status line otherwise only hints at on line 1.
func cmdSessions(args []string) int {
jsonOut := false
for _, a := range args {
switch a {
case "--json":
jsonOut = true
default:
fmt.Fprintf(os.Stderr, "ccbit sessions: unknown flag %q\n", a)
return 2
}
}
now := time.Now()
beats := sessions.Active("", now) // selfID "" excludes nothing: list them all
if jsonOut {
if beats == nil {
beats = []sessions.Beat{}
}
b, err := json.MarshalIndent(beats, "", " ")
if err != nil {
return 1
}
fmt.Println(string(b))
return 0
}
for _, line := range render.Roster(beats, now, os.Getenv("NO_COLOR") == "") {
fmt.Println(line)
}
return 0
}

func printUsage(w io.Writer) {
fmt.Fprint(w, `ccbit — a session-awareness status line for Claude Code.

Usage:
ccbit Render the status line (reads Claude Code's stdin JSON).
ccbit sessions List every live Claude Code session across your terminals.
ccbit demo Preview Bit in every state (synthetic; reads nothing).
ccbit version Print the version.
ccbit help Show this help.

Flags:
ccbit sessions --json Machine-readable roster, for scripting.

ccbit is normally invoked by Claude Code as the statusLine command; the
subcommands above are for running it yourself.
`)
}

// statusLine is the default invocation: read Claude Code's stdin JSON, derive
// this session's state, and print the two ambient lines.
func statusLine() {
now := time.Now()
in := input.Parse(os.Stdin)

Expand Down Expand Up @@ -106,12 +192,12 @@ func main() {
RepoRoot: root,
// RepoRoot is resolved with a disposable cache so a 1s refresh interval
// does not spawn git every render.
Cols: cols,
Narrow: cols > 0 && cols < render.NarrowCols,
Frame: int((now.Unix() / 2) % 2),
FrameFast: int(now.Unix() % 2),
ColorOn: os.Getenv("NO_COLOR") == "",
Now: now,
Cols: cols,
Narrow: cols > 0 && cols < render.NarrowCols,
Frame: int((now.Unix() / 2) % 2),
FrameFast: int(now.Unix() % 2),
ColorOn: os.Getenv("NO_COLOR") == "",
Now: now,
Trend: trend,
Siblings: sessions.Active(in.SessionID, now),
TypicalTurn: stats.TypicalTurn(),
Expand Down
9 changes: 4 additions & 5 deletions docs/ccbit-PRD-v2.md
Original file line number Diff line number Diff line change
Expand Up @@ -276,11 +276,10 @@ The only Claude Code config ccbit needs is the `statusLine` block (§11.2).

The v1 stdout `[VERIFY]` is resolved: the failure text is in the transcript `tool_result` (§6.3). All tiers read the same entry.

- **Tier 1 (build now):** `is_error` → `build ✓` / `build failed`. No text parsing.
- **Tier 2 (signature dictionary):** on failure, grep the result `content` text against a user-maintained pattern→message map (`~/.config/ccbit/error-signatures` or in-repo). Example: text containing `401`/`codeartifact` → `renew AWS SSO for codeartifact`. Unknown → fall back to tier 1.
- **Tier 3 (model-assisted, opt-in, default off):** on failure with no tier-2 match, send the result text to a cheap model call, write a one-line diagnosis. Adds an API call + latency per failure; gate behind `CCBIT_TIER3=1`.

Tiers 2–3 are post-v1 but no longer blocked by a verification.
- **Tier 1 (SHIPPED):** `is_error` → `build ✓` / `build failed`. No text parsing.
- **Tier 1.5 (SHIPPED — `internal/diag`):** on failure, extract the first concrete reason from the captured result text with no config — a compiler/linter `file:line[:col]: message`, a named `--- FAIL: Test…`, an explicit `error:` line, else the bare `Exit code N`. High-signal only; a miss surfaces nothing (plain "build failed") rather than a guess. The Failed line appends it, ellipsized.
- **Tier 2 (SHIPPED — `internal/diag`):** a user-maintained `pattern⇥message` map at `~/.config/ccbit/error-signatures` (or `$XDG_CONFIG_HOME/ccbit/...`). A matching case-insensitive regexp's message overrides tier 1.5; unparsable lines are skipped so one typo can't break the file.
- **Tier 3 (model-assisted) — NOT BUILT, by design.** A per-failure model call adds network, latency, and cost, which cuts against ccbit being a transcript-only, no-daemon tool. Left out deliberately rather than gated behind a flag.

---

Expand Down
9 changes: 8 additions & 1 deletion docs/ccbit-WISHLIST.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,15 @@
## `ccbit sessions` subcommand
## `ccbit sessions` subcommand — SHIPPED

Standalone command that prints the full roster of live sessions, not just the
width-squeezed inline hint on line 1.

**As built:** `ccbit sessions` reads the heartbeat dir and prints an aligned
table (STATE / AGE / PROJECT / SESSION), actionable states first — reusing the
status line's own ordering, state vocabulary, and per-state colors. The open
questions resolved as: human table by default with `--json` for piping;
live-only (same 3-min `activeWindow` the line uses), no dimmed-expired rows;
invoked manually. Adding the dispatcher also wired `ccbit version` / `help`.

**Why:** cross-session awareness is ccbit's differentiator vs. other statuslines.
Line 1 can only afford a terse fragment ("3 other sessions running"); the heartbeat
dir already holds every live session's state + title, so a full roster is near-free.
Expand Down
43 changes: 43 additions & 0 deletions install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,48 @@ esac

ASSET="ccbit_${OS}_${ARCH}.tar.gz"
URL="https://github.com/$REPO/releases/latest/download/$ASSET"
CHECKSUMS_URL="https://github.com/$REPO/releases/latest/download/checksums.txt"

# sha256_of prints the SHA-256 of a file, using whichever tool is present.
# Returns non-zero when neither is available (caller skips verification).
sha256_of() {
if command -v sha256sum >/dev/null 2>&1; then
sha256sum "$1" | awk '{print $1}'
elif command -v shasum >/dev/null 2>&1; then
shasum -a 256 "$1" | awk '{print $1}'
else
return 1
fi
}

# verify_checksum checks the downloaded asset against the release's
# checksums.txt. A mismatch is fatal (return 1). When verification genuinely
# can't run — checksums.txt unreachable, asset unlisted, or no sha256 tool — it
# warns and returns 0 so a release predating checksums still installs.
verify_checksum() {
local asset_path="$1" sums want got
sums="$(dirname "$asset_path")/checksums.txt"
if ! curl -fsSL "$CHECKSUMS_URL" -o "$sums" 2>/dev/null; then
echo "ccbit: WARNING — could not fetch checksums.txt; skipping integrity check" >&2
return 0
fi
want=$(awk -v f="$ASSET" '$2 == f {print $1}' "$sums")
if [ -z "$want" ]; then
echo "ccbit: WARNING — no checksum listed for $ASSET; skipping integrity check" >&2
return 0
fi
if ! got=$(sha256_of "$asset_path"); then
echo "ccbit: WARNING — no sha256 tool (sha256sum/shasum); skipping integrity check" >&2
return 0
fi
if [ "$got" != "$want" ]; then
echo "ccbit: ERROR — checksum mismatch for $ASSET (refusing to install)" >&2
echo " expected $want" >&2
echo " got $got" >&2
return 1
fi
echo "ccbit: checksum verified"
}

fetch_release() {
[ -n "$OS" ] && [ -n "$ARCH" ] || return 1
Expand All @@ -35,6 +77,7 @@ fetch_release() {
trap "rm -rf '$tmp'" RETURN 2>/dev/null || true
echo "ccbit: downloading $ASSET from the latest release"
curl -fsSL "$URL" -o "$tmp/$ASSET" || { rm -rf "$tmp"; return 1; }
verify_checksum "$tmp/$ASSET" || { rm -rf "$tmp"; return 1; }
tar -xzf "$tmp/$ASSET" -C "$tmp" ccbit || { rm -rf "$tmp"; return 1; }
mkdir -p "$DEST_DIR"
install -m 0755 "$tmp/ccbit" "$DEST"
Expand Down
Loading
Loading