diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c16a633..39ba2d2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -58,3 +58,20 @@ jobs: env: GOOS: ${{ matrix.goos }} GOARCH: ${{ matrix.goarch }} + + bats: + name: Container scripts (bats, macOS) + # macOS is the primary target; run the bats suite on a mac runner so the + # container shell scripts (entrypoint, hooks, migrate-settings, firewall + # dry-runs, prelaunch overview) are gated on every PR. Run from the repo + # root — several tests source back2base-container/entrypoint.sh by a + # root-relative path. + runs-on: macos-latest + steps: + - uses: actions/checkout@v4 + + - name: Install bats + jq + run: brew install bats-core jq + + - name: Run container test suite + run: bats back2base-container/test/ diff --git a/README.md b/README.md index f7f3451..5692b91 100644 --- a/README.md +++ b/README.md @@ -1,30 +1,36 @@ # oss-back2base -> The open-source release of **[back2base](https://back2base.net)** — the CLI and container runtime, without the hosted services. +> A sandboxed [Claude Code](https://docs.anthropic.com/en/docs/claude-code) container, driven by a single Go CLI. Claude runs inside Docker behind an outbound firewall; your host stays clean. -A sandboxed [Claude Code](https://docs.anthropic.com/en/docs/claude-code) container. Claude runs inside Docker; you keep the host clean. The container ships with a starter set of MCP servers, slash commands, and skills — all defined in plain JSON / Markdown files you can edit. +`oss-back2base` is a Go (Cobra) command-line tool that wraps Docker Compose to launch a containerized Claude Code session. Each session ships with a curated MCP-server registry, named tool profiles, an outbound iptables firewall, and persistent local state — all defined in plain JSON / Markdown files you can edit. **macOS is the primary supported platform** (Linux works too). ## About back2base -[**back2base**](https://back2base.net) is a hosted product that gives Claude Code a managed home: a proxy gateway with shared rate limits and observability, cross-device config sync, durable-object memory that persists across sessions and machines, and Auth0-backed sign-in. +This repository is the open-source build of **[back2base](https://back2base.net)** — the de-clouded fork of the hosted product. It is everything you need to run a sandboxed Claude Code session **locally, with no account**: -This repository is the open-source slice: the Go CLI, the Docker container payload, the outbound firewall, and the starter configs for MCP servers, skills, and slash commands. Everything you need to run a sandboxed Claude Code session locally — no account required. +- the Go CLI, +- the Docker container payload (Dockerfile, compose, entrypoint, firewall), +- the starter configs for MCP servers, skills, and slash commands. + +It deliberately leaves out the hosted pieces. There is **no Auth0 sign-in, no Cloudflare Workers / proxy gateway, and no cloud memory sync** — persistence here is local bind mounts only. | Feature | `oss-back2base` (this repo) | [back2base.net](https://back2base.net) | |---|:---:|:---:| -| Containerized Claude Code | ✓ | ✓ | -| MCP server registry + named profiles | ✓ | ✓ | -| Outbound iptables firewall | ✓ | ✓ | -| Starter skills + slash commands | ✓ | ✓ | -| `claudeproxy` API gateway | — | ✓ | -| Cross-device config sync | — | ✓ | -| Durable-object / R2 memory | — | ✓ | -| Auth0 sign-in | — | ✓ | +| Containerized Claude Code | yes | yes | +| MCP server registry + named profiles | yes | yes | +| Outbound iptables firewall | yes | yes | +| Starter skills + slash commands | yes | yes | +| Local plans/memories persistence (bind mount) | yes | yes | +| Proxy / API gateway | no | yes | +| Cross-device config sync | no | yes | +| Cloud (durable-object / R2) memory sync | no | yes | +| Auth0 sign-in | no | yes | ## Requirements -- Docker (Desktop, Colima, or any compatible daemon) -- An Anthropic credential — either a Claude Code OAuth token (uses your subscription) or an `ANTHROPIC_API_KEY` +- **macOS** (primary target) or Linux. +- **Docker** — Docker Desktop, Colima, or any compatible daemon. +- An **Anthropic credential** — either a Claude Code OAuth token (uses your Claude subscription) or an `ANTHROPIC_API_KEY`. ## Install @@ -34,7 +40,7 @@ This repository is the open-source slice: the Go CLI, the Docker container paylo brew install back2base/back2base/oss-back2base ``` -### Debian / Ubuntu +### Debian / Ubuntu (`.deb`) Download `oss-back2base__linux_.deb` from the [Releases page](https://github.com/back2base/oss-back2base/releases) and install it: @@ -44,101 +50,177 @@ sudo dpkg -i oss-back2base_*_linux_amd64.deb ### Pre-built binary -Grab the matching tarball from the [Releases page](https://github.com/back2base/oss-back2base/releases), extract `oss-back2base`, and move it onto your `$PATH`. +Grab the matching `oss-back2base__.tar.gz` from the [Releases page](https://github.com/back2base/oss-back2base/releases), extract the `oss-back2base` binary, and move it onto your `$PATH`. ### From source +Requires Go 1.23+. + ```bash -go build -o oss-back2base . # requires Go 1.23+ +go build -o oss-back2base . +# or, with version metadata stamped in: +make build ``` ## Quickstart ```bash # 1. Authenticate. Either: -claude setup-token # OAuth, uses your Claude subscription -# …or grab an Anthropic API key from https://console.anthropic.com - -# 2. Drop the credential into the env file -mkdir -p ~/.config/back2base -cat > ~/.config/back2base/env <<'EOF' -BACK2BASE_CLAUDE_CODE_OAUTH_TOKEN= -# or, if using an API key instead: -# BACK2BASE_ANTHROPIC_API_KEY=sk-ant-... -EOF - -# 3. Launch. First run extracts the container payload to ~/.local/share/back2base -# and builds the image (~500 MB pull + a thin local layer). +claude setup-token # OAuth — uses your Claude subscription +# …or grab an API key from https://console.anthropic.com + +# 2. Run first-time setup. This extracts the container payload to +# ~/.local/share/back2base, seeds ~/.config/back2base/env from the +# template, and builds the image (~500 MB base-image pull + a thin +# local layer). +oss-back2base install + +# 3. Paste your credential into the env file. +# Set BACK2BASE_CLAUDE_CODE_OAUTH_TOKEN= (or BACK2BASE_ANTHROPIC_API_KEY=). +$EDITOR ~/.config/back2base/env + +# 4. Launch Claude Code in the current directory. oss-back2base ``` -Other useful entry points: +`oss-back2base` with no subcommand launches a Claude Code session, mounting your current working directory as `/workspace` inside the container. Run it from inside the repo you want to work on. On an interactive launch it asks which [tool profile](#profiles-and-models) to load. + +Other common entry points: ```bash -./oss-back2base shell # drop into a container shell instead of Claude -./oss-back2base profile # pick which MCP servers load (full / go / frontend / infra / …) -./oss-back2base doctor # health-check Docker, the payload, and your settings -./oss-back2base status # show install paths, image, running containers +oss-back2base shell # drop into a container shell instead of Claude +oss-back2base doctor # health-check Docker, the payload, and your config +oss-back2base status # show container / volume status +oss-back2base mcp list # list the MCP servers configured for this namespace +oss-back2base resume # resume the most recent session in this workspace ``` +## How it works + +**Container.** Each launch runs `docker compose run --rm` against a payload extracted to `~/.local/share/back2base`. The image is built `FROM` a prebuilt base (`ramseymcgrath/back2base-base:latest`) that carries the toolchains and MCP servers; the local layer adds your defaults. Claude Code runs as the unprivileged `node` user, started with `--permission-mode bypassPermissions` *inside* the sandbox, so the firewall and container boundary — not interactive prompts — are what contain it. + +**Firewall.** The container is granted `NET_ADMIN` and applies an outbound iptables allowlist on startup (see [`back2base-container/init-firewall.sh`](back2base-container/init-firewall.sh)). A refresh daemon re-resolves the allowed domains periodically so long sessions survive IP rotation. Set `DISABLE_FIREWALL=1` to allow all outbound traffic (useful for debugging). + +**MCP servers + profiles.** The registry lives in `~/.config/back2base/state/.mcp.json`. A *profile* names a subset of those servers (plus an optional model pin) so you only load the tools a given task needs. Profiles are defined in [`back2base-container/defaults/profiles.json`](back2base-container/defaults/profiles.json). + +**Persistent state.** Your `~/.claude` directory inside the container is a bind mount of `~/.config/back2base/state/` on the host — MCP registry, settings, skills, slash commands, session history. It is seeded from image-baked defaults on first launch, then it is yours to edit. The in-session statusline is [claude-hud](https://github.com/jarrodwatts/claude-hud) (vendored, MIT). + +**Host credentials.** When present on the host, `~/.ssh` and `~/.gitconfig` mount read-only for git, and `~/.aws`, `~/.kube`, and `~/.config/gh` are bind-mounted (per-run, only if they exist) so those CLIs work inside the container. The Docker socket is mounted so Claude can spawn docker-based MCP servers. + ## Configuration -Two on-disk locations matter: +Two on-disk locations matter, both under `~/.config/back2base/`: | Path | What it holds | How to edit | |---|---|---| -| `~/.config/back2base/env` | Auth tokens, model overrides, profile selection, custom API headers, firewall toggle. | Edit directly. Sourced fresh on every launch. See [`back2base-container/defaults/env.example`](back2base-container/defaults/env.example) for the full catalog. | -| `~/.config/back2base/state/` | The container's `~/.claude` directory. Holds `.mcp.json` (MCP server registry), `settings.json` (Claude Code settings), `skills/`, `commands/`, session history, memory. | Edit directly. Seeded from the image-baked defaults on first launch, then user-owned forever. | +| `~/.config/back2base/env` | Auth tokens, model / profile overrides, MCP-server tokens, firewall toggle, data dir. | Edit directly. Re-sourced on every launch. See [`back2base-container/defaults/env.example`](back2base-container/defaults/env.example) for the full catalog. | +| `~/.config/back2base/state/` | The container's `~/.claude`: `.mcp.json`, `settings.json`, `skills/`, `commands/`, session history. | Edit directly. Seeded from image defaults on first launch, then user-owned. | -### Adding or removing MCP servers +### Environment variables -Edit `~/.config/back2base/state/.mcp.json`. The starter file lists the default servers (`context7`, `filesystem`, `git`, `github`, `datadog`, etc.). Add your own, remove the ones you don't want — the file is plain JSON. Drop in the corresponding token in `~/.config/back2base/env` if the server needs one. +All variables are read from `~/.config/back2base/env` (or your shell, for process-env overrides). Auth values are read **only** under the `BACK2BASE_` prefix — the CLI strips the prefix and forwards the canonical name into the container, so it never accidentally inherits a bare `ANTHROPIC_API_KEY` you set for something else. -To temporarily narrow which subset loads, use profiles: +| Variable | Purpose | +|---|---| +| `BACK2BASE_CLAUDE_CODE_OAUTH_TOKEN` | Claude Code OAuth token (uses your subscription). From `claude setup-token`. | +| `BACK2BASE_ANTHROPIC_API_KEY` | Anthropic API key (pay-as-you-go). Alternative to the OAuth token. | +| `BACK2BASE_ANTHROPIC_AUTH_TOKEN` | Custom auth bearer token for an Anthropic-compatible endpoint. | +| `BACK2BASE_ANTHROPIC_BASE_URL` | Custom Anthropic-compatible base URL (e.g. a self-hosted proxy). | +| `BACK2BASE_PROFILE` | Default MCP profile to load (`full`, `go`, `frontend`, `infra`, …). `last` resolves to the last profile used in this namespace. | +| `BACK2BASE_MODEL` | Force a specific Claude model alias. Unset = the selected profile's pinned model (default `claude-opus-4-8[1m]`). | +| `BACK2BASE_DATA_DIR` | Host directory for persistent plans + memories. See [below](#persisting-plans-and-memories). | +| `BACK2BASE_MANAGED_SETTINGS_DIR` | Override the host path probed for [enterprise managed policy](#enterprise-managed-policy). | +| `DISABLE_FIREWALL` | Set to `1` to disable the outbound firewall (allow all). | +| `BACK2BASE_HOME` | Override the payload dir (default `~/.local/share/back2base`). | +| `BACK2BASE_CONFIG` | Override the config/state parent dir (default `~/.config/back2base`). | +| `BACK2BASE_BASE_IMAGE` | Override the base image (default `ramseymcgrath/back2base-base:latest`). | +| `REPO_PATH` | Pin a fixed repo to mount as `/workspace` instead of the CWD. | +| `TZ` | Container timezone. | + +Optional per-tool MCP credentials (`GITHUB_TOKEN`, `DD_API_KEY` / `DD_APPLICATION_KEY`, `CONTEXT7_API_KEY`, `BRAVE_API_KEY`, `TFC_TOKEN`, `BUILDKITE_API_TOKEN`, `CF_AIG_TOKEN`, `CUSTOM_API_HEADER`, `AWS_PROFILE` / `AWS_REGION`) are forwarded to the matching server when set. The full annotated list lives in [`env.example`](back2base-container/defaults/env.example). + +### Profiles and models + +A profile narrows which MCP servers (and which model) a session loads. Pick one per run: ```bash -./oss-back2base --profile go # backend Go work: context7, fetch, github, godevmcp, … -./oss-back2base --profile frontend # frontend / TypeScript -./oss-back2base --profile minimal # filesystem + git only -./oss-back2base profile # interactive picker +oss-back2base --profile go # backend Go work +oss-back2base --profile frontend # web / TypeScript +oss-back2base --profile minimal # filesystem + git only +oss-back2base # no flag → interactive picker on launch ``` -Profiles are defined in [`back2base-container/defaults/profiles.json`](back2base-container/defaults/profiles.json). Each profile names a subset of the MCP servers from `.mcp.json` plus an optional model pin. +Built-in profiles: `full`, `go`, `python`, `frontend`, `infra`, `research`, `documentation`, `minimal`. Every profile includes the `core` servers (`filesystem`, `git`). The default model pinned by the profiles is `claude-opus-4-8[1m]`; override per-run with `BACK2BASE_MODEL`. Edit or add profiles in [`back2base-container/defaults/profiles.json`](back2base-container/defaults/profiles.json). + +### Adding or removing MCP servers + +Edit `~/.config/back2base/state/.mcp.json` — plain JSON. Add your own servers, remove the ones you don't want, and drop any required token into `~/.config/back2base/env`. Use `oss-back2base mcp list` to see the resolved set, and `oss-back2base mcp test` to probe HTTP-transport servers for reachability. ### Editing skills and slash commands -The container seeds `~/.config/back2base/state/skills/` and `~/.config/back2base/state/commands/` from the image defaults on first launch. After that they're yours — add, remove, or rewrite anything. The defaults are starting points, not a contract. +`~/.config/back2base/state/skills/` and `~/.config/back2base/state/commands/` are seeded from the image defaults on first launch, then they're yours — add, remove, or rewrite anything. The defaults are starting points, not a contract. -### Disabling the firewall +### Persisting plans and memories -The container applies an iptables outbound allowlist (see [`back2base-container/init-firewall.sh`](back2base-container/init-firewall.sh)). To bypass it entirely — useful for debugging or networks the allowlist doesn't cover — set `DISABLE_FIREWALL=1` in `~/.config/back2base/env`. +By default, memories live under the back2base state dir and are cleared at each session start. To persist them somewhere you control, set: + +```bash +BACK2BASE_DATA_DIR=~/back2base-data +``` + +in `~/.config/back2base/env` (or your shell). On launch, `oss-back2base` creates `plans/` and `memories/` subfolders under that directory and bind-mounts them at `~/.back2base/plans` and `~/.back2base/memories` inside the container. The memory tool reads and writes the mounted `memories/` folder directly, so memories persist across sessions and live somewhere you own. This is local-only: OSS has no cloud sync, so it's volumes-only. ### Enterprise managed policy -If your organization deploys [Claude Code managed settings](https://code.claude.com/docs/en/server-managed-settings) (admin-defined `permissions.allow` / `permissions.deny`, hooks, env vars, an `allowManagedPermissionRulesOnly` lock, or a centrally managed `CLAUDE.md`), `oss-back2base` honors that policy inside the container. +If your organization deploys [Claude Code managed settings](https://code.claude.com/docs/en/server-managed-settings) (admin `permissions.allow` / `permissions.deny`, hooks, env vars, a permission-rules lock, or a centrally managed `CLAUDE.md`), `oss-back2base` honors that policy inside the container. -On every launch, the CLI probes the platform-standard host directory and bind-mounts the artifacts read-only at the canonical Linux paths that Claude Code reads inside the container: +On every launch the CLI probes the platform-standard host directory and bind-mounts any artifacts that exist, read-only, at the canonical Linux paths Claude Code reads: -| Host path (probed) | Container path (mounted) | +| Host path (probed) | Container path (mounted, read-only) | |---|---| -| `/Library/Application Support/ClaudeCode/managed-settings.json` (macOS)
`/etc/claude-code/managed-settings.json` (Linux) | `/etc/claude-code/managed-settings.json` | +| `/Library/Application Support/ClaudeCode/managed-settings.json` (macOS)
`/etc/claude-code/managed-settings.json` (Linux) | `/etc/claude-code/managed-settings.json` | | `…/managed-settings.d/` | `/etc/claude-code/managed-settings.d/` | | `…/CLAUDE.md` | `/etc/claude-code/CLAUDE.md` | -Mounts are emitted only for artifacts that actually exist on the host. Set `BACK2BASE_MANAGED_SETTINGS_DIR=` in `~/.config/back2base/env` to probe a non-standard host directory (useful for testing or air-gapped installs). +Only artifacts that actually exist on the host are mounted. Set `BACK2BASE_MANAGED_SETTINGS_DIR=` to probe a non-standard host directory (useful for testing or air-gapped installs). Verify the wiring with `oss-back2base doctor`. -To verify policy is wired up, run: +## Commands + +| Command | What it does | +|---|---| +| `oss-back2base` *(no subcommand)* | Launch a Claude Code session in the current directory. | +| `install` | First-time setup: check prereqs, seed the env file, build the image. | +| `build` | Build the container image. | +| `rebuild` | Full rebuild with no cache. | +| `shell` | Drop into a container shell instead of Claude. | +| `resume [sessionId]` | Resume the most recent (or named) session in this workspace. | +| `mcp list` / `mcp test` | List configured MCP servers / probe HTTP servers for reachability. | +| `doctor` | Run health checks across Docker, payload, and config (`--json` for machine output). | +| `status` | Show container / volume status. | +| `clean` | Remove this project's containers and built image. | +| `prune` | Remove old base/container images that no longer match this binary (`--dry-run`, `--keep N`). | +| `wipe-images` | Remove ALL oss-back2base containers and images. | +| `update` | Update the `oss-back2base` binary to the latest release. | +| `version` | Print version information. | + +Useful root flags: `--profile ` (MCP profile), `-p/--prompt` (one-shot prompt, then exit), `-d/--dir ` (mount an extra directory at `/repos/`, repeatable), `-r/--repo` (override the repo mount), `--namespace ` (memory namespace), `--overview` / `--no-overview` (pre-launch repo overview), `-y/--yes` (skip confirmations), `--no-update-check`. The model is chosen by the profile or `BACK2BASE_MODEL` — there is no `--model` flag. Run `oss-back2base --help` for full details. + +## Development ```bash -./oss-back2base doctor -# look for: [PASS] Managed Claude Code policy: /etc/claude-code/ — mounting managed-settings.json, CLAUDE.md read-only into /etc/claude-code/ +go build ./... # build +go test ./... # run the Go unit tests +make build # build with version metadata +make lint # go vet +bats back2base-container/test/ # container-side bats tests (run from repo root) ``` -## Repository layout +### Repository layout - `*.go` — the CLI source. -- `back2base-container/` — the Docker image payload (Dockerfile, compose, entrypoint, firewall, defaults). Extracted to `~/.local/share/back2base` on first run. -- `.github/workflows/` — CI and release pipeline. +- `back2base-container/` — the Docker image payload (Dockerfile, `docker-compose.yml`, `entrypoint.sh`, `init-firewall.sh`, `defaults/`, `skills/`, `commands/`, `lib/`). Extracted to `~/.local/share/back2base` on first run. +- `back2base-container/test/` — bats tests for the container scripts. +- `.github/workflows/` — CI and the GoReleaser-driven release pipeline. ## License diff --git a/back2base-container/Dockerfile b/back2base-container/Dockerfile index 0cfe27a..ffa42c7 100644 --- a/back2base-container/Dockerfile +++ b/back2base-container/Dockerfile @@ -60,6 +60,10 @@ COPY --chown=node:node --chmod=0755 lib/firewall-refresh.sh /opt/back2base/firew # symlink lands in the wrong place; this catches it loudly. COPY --chown=node:node --chmod=0755 lib/path-sentinel.sh /opt/back2base/path-sentinel.sh +# Memory-path alignment lib — sourced by entrypoint.sh and unit-tested by +# test/memory-align.bats. Defines b2b_align_memory_dir. +COPY --chown=node:node lib/memory-align.sh /opt/back2base/memory-align.sh + # CLAUDE.md renderer (stdlib Python). Replaces the bash+jq generate_claude_md # function in entrypoint.sh. Reassembles ~/.claude/CLAUDE.md from the template # and the live mcp.json/commands dir on every container start. @@ -81,9 +85,13 @@ COPY --chown=node:node lib/prelaunch-prompt.txt /opt/back2base/prelaunch-prompt. # skills are usable without a Skill tool roundtrip. Stdlib Python only. COPY --chown=node:node --chmod=0755 lib/render-skill-preplan.py /opt/back2base/render-skill-preplan.py -# Cold-start phase helpers + in-session statusLine renderer. +# Cold-start phase helpers + in-session statusLine. COPY --chown=node:node lib/status.sh /opt/back2base/status.sh -COPY --chown=node:node --chmod=0755 lib/statusline.sh /opt/back2base/statusline.sh +# claude-hud renders the in-session HUD (vendored, precompiled ESM JS, runs on +# node, zero deps). statusline-extra.sh feeds the back2base namespace segment +# via --extra-cmd. Vendored from https://github.com/jarrodwatts/claude-hud (MIT). +COPY --chown=node:node vendor/claude-hud/ /opt/back2base/claude-hud/ +COPY --chown=node:node --chmod=0755 lib/statusline-extra.sh /opt/back2base/statusline-extra.sh # Power-steering supervisor — async drift-detection daemon. Auths the # same way Claude Code does; idles cleanly when no auth is set. @@ -91,7 +99,7 @@ COPY --chown=node:node --chmod=0755 lib/power-steering.py /opt/back2base/power-s COPY --chown=node:node lib/power-steering-prompt.md /opt/back2base/power-steering-prompt.md # Belt + suspenders for legacy docker builders that silently ignore --chmod. -RUN chmod +x /opt/back2base/hooks/_common.sh /opt/back2base/hooks/power-steering-drain.sh /opt/back2base/hooks/power-steering-tick.sh /opt/back2base/hooks/claude-md-audit-tally.sh /opt/back2base/hooks/claude-md-audit-trigger.sh /opt/back2base/session-snapshot.sh /opt/back2base/migrate-settings.py /opt/back2base/render-hooks.py /opt/back2base/firewall-refresh.sh /opt/back2base/path-sentinel.sh /opt/back2base/render-claude-md.py /opt/back2base/claude-md-audit.py /opt/back2base/prelaunch-overview.sh /opt/back2base/render-overview.py /opt/back2base/render-skill-preplan.py /opt/back2base/status.sh /opt/back2base/statusline.sh /opt/back2base/power-steering.py 2>/dev/null || true +RUN chmod +x /opt/back2base/hooks/_common.sh /opt/back2base/hooks/power-steering-drain.sh /opt/back2base/hooks/power-steering-tick.sh /opt/back2base/hooks/claude-md-audit-tally.sh /opt/back2base/hooks/claude-md-audit-trigger.sh /opt/back2base/session-snapshot.sh /opt/back2base/migrate-settings.py /opt/back2base/render-hooks.py /opt/back2base/firewall-refresh.sh /opt/back2base/path-sentinel.sh /opt/back2base/render-claude-md.py /opt/back2base/claude-md-audit.py /opt/back2base/prelaunch-overview.sh /opt/back2base/render-overview.py /opt/back2base/render-skill-preplan.py /opt/back2base/status.sh /opt/back2base/statusline-extra.sh /opt/back2base/power-steering.py 2>/dev/null || true # Copy firewall and entrypoint scripts COPY init-firewall.sh entrypoint.sh /usr/local/bin/ diff --git a/back2base-container/defaults/env.example b/back2base-container/defaults/env.example index ddb8ea3..ef02b26 100644 --- a/back2base-container/defaults/env.example +++ b/back2base-container/defaults/env.example @@ -77,6 +77,16 @@ TZ=America/New_York # Set to 1 to disable outbound network filtering (allow all traffic). # DISABLE_FIREWALL=0 +# ── Local plans + memories ──────────────────────────────────────────────────── +# Point this at a host directory to persist plans and memories outside the +# opaque back2base state dir. The CLI creates `plans/` and `memories/` +# subfolders inside it and bind-mounts them at ~/.back2base/plans and +# ~/.back2base/memories in the container. The memory tool reads and writes the +# mounted memories dir directly, so memories persist across sessions. Leave +# unset for the default (memories live under the back2base state dir, cleared +# each session start). +# BACK2BASE_DATA_DIR=~/back2base-data + # ── Optional: Managed Claude Code policy (enterprise) ──────────────────────── # Claude Code reads enterprise-deployed policy from a platform-specific # host path (macOS: /Library/Application Support/ClaudeCode/, Linux: diff --git a/back2base-container/defaults/profiles.json b/back2base-container/defaults/profiles.json index a00aa32..88f9a6c 100644 --- a/back2base-container/defaults/profiles.json +++ b/back2base-container/defaults/profiles.json @@ -3,7 +3,7 @@ "profiles": { "full": { "description": "All MCP servers (max capability, highest token cost)", - "model": "claude-opus-4-7[1m]", + "model": "claude-opus-4-8[1m]", "servers": [ "aws-knowledge", "brave-search", "buildkite", "context7", "datadog", "fetch", "filesystem", "git", "github", "godevmcp", @@ -13,32 +13,32 @@ }, "go": { "description": "Go backend development", - "model": "claude-opus-4-7[1m]", + "model": "claude-opus-4-8[1m]", "servers": ["context7", "fetch", "github", "godevmcp", "sequential-thinking", "sqlite"] }, "python": { "description": "Python development", - "model": "claude-opus-4-7[1m]", + "model": "claude-opus-4-8[1m]", "servers": ["context7", "fetch", "github", "sequential-thinking", "sqlite"] }, "frontend": { "description": "Web / TypeScript frontend development", - "model": "claude-opus-4-7[1m]", + "model": "claude-opus-4-8[1m]", "servers": ["context7", "fetch", "github", "lsmcp"] }, "infra": { "description": "Infrastructure and platform operations", - "model": "claude-opus-4-7[1m]", + "model": "claude-opus-4-8[1m]", "servers": ["aws-knowledge", "datadog", "fetch", "github", "kubernetes", "terraform"] }, "research": { "description": "Research, docs, and exploration", - "model": "claude-opus-4-7[1m]", + "model": "claude-opus-4-8[1m]", "servers": ["brave-search", "context7", "fetch", "sequential-thinking"] }, "documentation": { "description": "Technical writing and documentation authoring", - "model": "claude-opus-4-7[1m]", + "model": "claude-opus-4-8[1m]", "servers": ["brave-search", "context7", "fetch", "github", "sequential-thinking"] }, "minimal": { diff --git a/back2base-container/defaults/settings.json b/back2base-container/defaults/settings.json index db624b1..b1a167f 100644 --- a/back2base-container/defaults/settings.json +++ b/back2base-container/defaults/settings.json @@ -1,8 +1,8 @@ { - "_back2base_schema": 4, + "_back2base_schema": 5, "statusLine": { "type": "command", - "command": "/opt/back2base/statusline.sh", + "command": "bash -c 'export COLUMNS=\"${COLUMNS:-120}\"; exec node /opt/back2base/claude-hud/index.js --extra-cmd /opt/back2base/statusline-extra.sh'", "padding": 0 } } diff --git a/back2base-container/entrypoint.sh b/back2base-container/entrypoint.sh index 37e5063..34a223d 100644 --- a/back2base-container/entrypoint.sh +++ b/back2base-container/entrypoint.sh @@ -160,68 +160,10 @@ _phase2_workspace_setup() { export GIT_SSH_COMMAND="ssh -F $HOME/.ssh_local/config" fi - # Memory directories. - # - # Two paths are involved and they MUST point at the same physical files: - # - # 1. The "namespaced" path under MEMORY_NAMESPACE (human-friendly, derived - # from the git remote basename, e.g. ~/.claude/projects/myrepo/memory). - # - # 2. Claude Code's auto-memory path, which it derives from the current - # working directory by replacing every '/' with '-' - # (e.g. /workspace → ~/.claude/projects/-workspace/memory). This is - # where Claude Code reads MEMORY.md from at session start and writes - # new memories to during the session. - # - # Without alignment, memories saved under (1) land outside what Claude - # Code only reads (2) — they ships past each other and "memory doesn't - # load". We make (1) the canonical store and symlink (2) → (1). If a user - # already has real files at (2) from a previous version, migrate them into - # (1) once before replacing (2) with the symlink. - if [ -n "${MEMORY_NAMESPACE:-}" ]; then - ns_memory_dir="$HOME/.claude/projects/${MEMORY_NAMESPACE}/memory" - mkdir -p "$ns_memory_dir" - - # Always start with a blank memory folder so old session memory does not - # silently bleed into a new session via Claude Code's MEMORY.md autoload. - if [ -n "$(ls -A "$ns_memory_dir" 2>/dev/null)" ]; then - rm -rf "${ns_memory_dir:?}"/* "${ns_memory_dir:?}"/.[!.]* 2>/dev/null || true - fi - - # Claude Code's project dir name = $PWD with '/' → '-'. Compute against - # the cwd at this point in the entrypoint, which is what claude inherits - # via the final `exec "$@"` below (after any /repos cd above). - cwd_dir_name="${PWD//\//-}" - cc_memory_dir="$HOME/.claude/projects/${cwd_dir_name}/memory" - - # Export the absolute project dir so daemons (power-steering, - # session-snapshot) can find Claude Code's session JSONLs without - # re-deriving the slug. Session files live at depth 1 under this dir. - export CLAUDE_PROJECT_DIR="$HOME/.claude/projects/${cwd_dir_name}" - - if [ "$cc_memory_dir" != "$ns_memory_dir" ]; then - mkdir -p "$(dirname "$cc_memory_dir")" - if [ -L "$cc_memory_dir" ]; then - # Already a symlink — repoint only if it's stale (e.g. namespace - # changed because the user switched MEMORY_NAMESPACE override). - if [ "$(readlink "$cc_memory_dir")" != "$ns_memory_dir" ]; then - ln -sfn "$ns_memory_dir" "$cc_memory_dir" - fi - elif [ -d "$cc_memory_dir" ]; then - # Real directory left over from before the alignment was added. - # Migrate any existing files into the namespaced location, then - # replace with a symlink. cp -an preserves perms (-a) and never - # clobbers existing files in the destination (-n). - if [ -n "$(ls -A "$cc_memory_dir" 2>/dev/null)" ]; then - cp -an "$cc_memory_dir"/. "$ns_memory_dir"/ 2>/dev/null || true - fi - rm -rf "$cc_memory_dir" - ln -sfn "$ns_memory_dir" "$cc_memory_dir" - else - ln -sfn "$ns_memory_dir" "$cc_memory_dir" - fi - fi - fi + # Memory-path alignment — see lib/memory-align.sh. Sourced so the logic is + # unit-testable (test/memory-align.bats); entrypoint just invokes it here. + . /opt/back2base/memory-align.sh + b2b_align_memory_dir # Path-naming sentinel. Background daemon: 30s after launch, verifies that # Claude Code wrote its session JSONL under the directory name we predicted diff --git a/back2base-container/lib/memory-align.sh b/back2base-container/lib/memory-align.sh new file mode 100644 index 0000000..84ed34f --- /dev/null +++ b/back2base-container/lib/memory-align.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +# Memory-path alignment for the back2base container entrypoint. +# +# Sourced by entrypoint.sh and by test/memory-align.bats. Defines one function, +# b2b_align_memory_dir, which selects the canonical memory store and points +# Claude Code's auto-memory path at it. Reads HOME, PWD, MEMORY_NAMESPACE; +# exports CLAUDE_PROJECT_DIR and (when the bind mount is present) +# BACK2BASE_PLANS_DIR. + +# b2b_align_memory_dir picks the canonical memory store and symlinks Claude +# Code's auto-memory path (PWD with '/' -> '-') to it: +# - BACK2BASE_DATA_DIR mounted ~/.back2base/memories -> use it, never wiped. +# - else MEMORY_NAMESPACE set -> ~/.claude/projects//memory, wiped each +# session to prevent bleed. +# - else -> no-op. +b2b_align_memory_dir() { + local ns_memory_dir="" + if [ -d "$HOME/.back2base/memories" ]; then + ns_memory_dir="$HOME/.back2base/memories" + mkdir -p "$HOME/.back2base/plans" + export BACK2BASE_PLANS_DIR="$HOME/.back2base/plans" + elif [ -n "${MEMORY_NAMESPACE:-}" ]; then + ns_memory_dir="$HOME/.claude/projects/${MEMORY_NAMESPACE}/memory" + mkdir -p "$ns_memory_dir" + # Start blank so old session memory doesn't bleed via MEMORY.md autoload. + if [ -n "$(ls -A "$ns_memory_dir" 2>/dev/null)" ]; then + rm -rf "${ns_memory_dir:?}"/* "${ns_memory_dir:?}"/.[!.]* 2>/dev/null || true + fi + fi + + [ -n "$ns_memory_dir" ] || return 0 + + local cwd_dir_name cc_memory_dir + cwd_dir_name="${PWD//\//-}" + cc_memory_dir="$HOME/.claude/projects/${cwd_dir_name}/memory" + export CLAUDE_PROJECT_DIR="$HOME/.claude/projects/${cwd_dir_name}" + [ "$cc_memory_dir" != "$ns_memory_dir" ] || return 0 + + mkdir -p "$(dirname "$cc_memory_dir")" + if [ -L "$cc_memory_dir" ]; then + if [ "$(readlink "$cc_memory_dir")" != "$ns_memory_dir" ]; then + ln -sfn "$ns_memory_dir" "$cc_memory_dir" + fi + elif [ -d "$cc_memory_dir" ]; then + if [ -n "$(ls -A "$cc_memory_dir" 2>/dev/null)" ]; then + cp -an "$cc_memory_dir"/. "$ns_memory_dir"/ 2>/dev/null || true + fi + rm -rf "$cc_memory_dir" + ln -sfn "$ns_memory_dir" "$cc_memory_dir" + else + ln -sfn "$ns_memory_dir" "$cc_memory_dir" + fi +} diff --git a/back2base-container/lib/migrate-settings.py b/back2base-container/lib/migrate-settings.py index 5a97ec6..f76c822 100644 --- a/back2base-container/lib/migrate-settings.py +++ b/back2base-container/lib/migrate-settings.py @@ -22,6 +22,20 @@ # ── Migrations ────────────────────────────────────────────────────────────── +# The back2base statusLine command. claude-hud (vendored at +# /opt/back2base/claude-hud) renders the HUD; statusline-extra.sh feeds the +# back2base namespace segment via --extra-cmd. COLUMNS is exported because the +# container TTY may not set it and claude-hud uses it for width. +STATUSLINE_COMMAND = ( + "bash -c 'export COLUMNS=\"${COLUMNS:-120}\"; " + "exec node /opt/back2base/claude-hud/index.js " + "--extra-cmd /opt/back2base/statusline-extra.sh'" +) + +# The legacy hand-rolled renderer, replaced by claude-hud in schema 5. Used by +# migrate_5 to detect an unmodified back2base statusLine worth upgrading. +LEGACY_STATUSLINE_COMMAND = "/opt/back2base/statusline.sh" + def migrate_1_envelope_hooks(d): """Wrap bare {type:"command",...} hook entries in {matcher,hooks} envelope. @@ -98,7 +112,7 @@ def migrate_3_add_statusline(d): return False d["statusLine"] = { "type": "command", - "command": "/opt/back2base/statusline.sh", + "command": STATUSLINE_COMMAND, "padding": 0, } return True @@ -117,6 +131,22 @@ def migrate_4_strip_hooks_block(d): return True +def migrate_5_statusline_to_claude_hud(d): + """Repoint an unmodified back2base statusLine at vendored claude-hud. + + Only rewrites a statusLine still pointing at the legacy renderer + (/opt/back2base/statusline.sh). A user who customized the command to + something else is left alone — same defensive posture as migrate_3. + """ + sl = d.get("statusLine") + if not isinstance(sl, dict): + return False + if sl.get("command") != LEGACY_STATUSLINE_COMMAND: + return False + sl["command"] = STATUSLINE_COMMAND + return True + + # Registry: (number, callable). Numbers must be strictly increasing. Each # callable mutates the dict in place and returns True if it changed anything # (return value is informational; presence in the registry is what matters). @@ -125,6 +155,7 @@ def migrate_4_strip_hooks_block(d): (2, migrate_2_prune_defunct_sessionstart), (3, migrate_3_add_statusline), (4, migrate_4_strip_hooks_block), + (5, migrate_5_statusline_to_claude_hud), ] CURRENT_SCHEMA = max(n for n, _ in MIGRATIONS) @@ -137,6 +168,7 @@ def migrate_4_strip_hooks_block(d): 2: ":: settings.json: pruned defunct SessionStart prefetch hook", 3: ":: settings.json: seeded statusLine block", 4: ":: settings.json: stripped hooks block (renderer now owns it)", + 5: ":: settings.json: repointed statusLine at claude-hud", } diff --git a/back2base-container/lib/statusline-extra.sh b/back2base-container/lib/statusline-extra.sh new file mode 100644 index 0000000..828aa30 --- /dev/null +++ b/back2base-container/lib/statusline-extra.sh @@ -0,0 +1,26 @@ +#!/bin/bash +# claude-hud --extra-cmd helper for oss-back2base. +# +# claude-hud (vendored at /opt/back2base/claude-hud) renders the HUD; this adds +# the one segment it can't know — the memory namespace — as a JSON object on +# stdout: {"label":"ns:"}. Omitted when no namespace is set. (The +# hosted build's auth/pwr segments don't exist in OSS.) + +set +e + +label="" +# Strip control chars (newlines etc.) from the namespace so a pathological +# value can't emit invalid JSON or leak terminal-control sequences into the HUD. +ns=$(printf '%s' "${MEMORY_NAMESPACE:-}" | tr -d '[:cntrl:]') +if [ -n "$ns" ]; then + label="ns:${ns}" +fi + +# claude-hud truncates past 50 chars anyway; cap here to keep this self-contained. +if [ "${#label}" -gt 50 ]; then + label="${label:0:49}…" +fi + +label=${label//\\/\\\\} +label=${label//\"/\\\"} +printf '{"label":"%s"}\n' "$label" diff --git a/back2base-container/lib/statusline.sh b/back2base-container/lib/statusline.sh deleted file mode 100755 index e2333a0..0000000 --- a/back2base-container/lib/statusline.sh +++ /dev/null @@ -1,142 +0,0 @@ -#!/bin/bash -# claude code statusLine renderer. -# -# Reads a JSON object from stdin (claude code's session info) and prints -# a single-line footer for the in-session status bar. Cadence is owned by -# claude code (default ~5s). - -set +e - -input=$(cat) - -# Per-container runtime dir written by power-steering. The override -# variable is for tests; production runs always resolve to /run/... -runtime_dir="${STATUSLINE_RUNTIME_DIR:-/run/back2base/power-steering}" - -# Model id from JSON, or env, or "unknown". -model=$(printf '%s' "$input" | jq -r '.model.id // empty' 2>/dev/null) -model=${model:-${BACK2BASE_MODEL:-unknown}} - -profile=${BACK2BASE_PROFILE:-full} -ns=${MEMORY_NAMESPACE:-?} - -# Overview presence: marker the prelaunch helper wrote into CLAUDE.md. -overview="—" -if grep -q '' "$HOME/.claude/CLAUDE.md" 2>/dev/null; then - overview="✓" -fi - -# Auth: presence of any supported Anthropic auth env var. -auth="✗" -if [ -n "${ANTHROPIC_API_KEY:-}" ] || [ -n "${ANTHROPIC_AUTH_TOKEN:-}" ] || [ -n "${CLAUDE_CODE_OAUTH_TOKEN:-}" ]; then - auth="✓" -fi - -# Context tokens. Prefer claude's own accounting; fall back to transcript size. -tokens=$(printf '%s' "$input" | jq -r '.cost.total_tokens // empty' 2>/dev/null) -if [ -z "$tokens" ] || [ "$tokens" = "null" ]; then - transcript=$(printf '%s' "$input" | jq -r '.transcript_path // empty' 2>/dev/null) - if [ -n "$transcript" ] && [ -r "$transcript" ]; then - bytes=$(wc -c < "$transcript" 2>/dev/null | tr -d ' ') - tokens=$((bytes / 4)) - fi -fi - -# Limit depends on model variant. -case "$model" in - *\[1m\]) limit=1048576 ;; - *) limit=200000 ;; -esac - -ctx_str="ctx:—" -if [ -n "$tokens" ] && [ "$tokens" -gt 0 ] 2>/dev/null; then - pct=$(( tokens * 100 / limit )) - if [ "$pct" -lt 50 ]; then - color=$'\e[32m' # green - elif [ "$pct" -lt 80 ]; then - color=$'\e[33m' # yellow - else - color=$'\e[31m' # red - fi - ctx_str="ctx:${color}${pct}%"$'\e[0m' -fi - -# Power-steering reviewer status. Primary signal: pending.md non-empty -# means the supervisor flagged drift and the user has NOT yet seen it -# (the UserPromptSubmit hook truncates on inject). Secondary signal: the -# tail of the log tells us idle / ok / api-error so the user can tell at -# a glance whether the daemon is actually doing anything. -pwr_label="pwr:—" -pwr_color=$'\e[2m' # dim by default -case "${BACK2BASE_POWER_STEERING:-}" in - off|0|false|no) - pwr_label="pwr:off" - ;; - *) - pending_file="$runtime_dir/pending.md" - log_file="$runtime_dir/power-steering.log" - if [ -s "$pending_file" ]; then - pwr_label="pwr:drift!" - pwr_color=$'\e[31m' # red — drift queued for next turn - elif [ -s "$log_file" ]; then - last=$(tail -n1 "$log_file" 2>/dev/null) - case "$last" in - *"check drift"*) pwr_label="pwr:drift"; pwr_color=$'\e[31m' ;; - *"check ok"*) pwr_label="pwr:ok"; pwr_color=$'\e[32m' ;; - *"api error"*) pwr_label="pwr:err"; pwr_color=$'\e[33m' ;; - *"daemon idle"*|*"; daemon idle"*) pwr_label="pwr:idle" ;; - *"disabled via"*) pwr_label="pwr:off" ;; - *) pwr_label="pwr:…" ;; # daemon up, no checks yet - esac - fi - ;; -esac -pwr_str="${pwr_color}${pwr_label}"$'\e[0m' - -# API metrics: total tokens, cache hit ratio, total cost USD across the -# main session + power-steering's own calls + the prelaunch repo overview. -# Source: $runtime_dir/metrics.json (refreshed on each daemon -# poll, ~every 10s). Each segment renders as a dim dash when no data -# exists yet (fresh container, daemon idle, or jq missing). -metrics_file="$runtime_dir/metrics.json" -tok_str="tok:—" -cache_str="cache:—" -cost_str="cost:—" -if [ -s "$metrics_file" ] && command -v jq >/dev/null 2>&1; then - vals=$(jq -r '[ - (.input_tokens // 0), - (.output_tokens // 0), - (.cache_creation_input_tokens // 0), - (.cache_read_input_tokens // 0), - (.request_count // 0), - (.cost_usd // 0) - ] | @tsv' "$metrics_file" 2>/dev/null) - if [ -n "$vals" ]; then - IFS=$'\t' read -r m_in m_out m_cw m_cr m_req m_cost <<< "$vals" - if [ "${m_req:-0}" -gt 0 ] 2>/dev/null; then - total=$(( m_in + m_out + m_cw + m_cr )) - tok_str=$(awk -v t="$total" 'BEGIN { - if (t >= 1000000) printf "tok:%.1fM", t/1000000 - else if (t >= 1000) printf "tok:%.1fk", t/1000 - else printf "tok:%d", t - }') - uncached_in=$(( m_in + m_cw + m_cr )) - if [ "$uncached_in" -gt 0 ]; then - cache_str=$(awk -v r="$m_cr" -v u="$uncached_in" \ - 'BEGIN { printf "cache:%d%%", r/u*100 }') - fi - cost_str=$(awk -v c="$m_cost" 'BEGIN { printf "cost:$%.2f", c }') - fi - fi -fi -metrics_dim=$'\e[2m' -tok_str="${metrics_dim}${tok_str}"$'\e[0m' -cache_str="${metrics_dim}${cache_str}"$'\e[0m' -cost_str="${metrics_dim}${cost_str}"$'\e[0m' - -sep=$'\e[2m·\e[0m' - -printf '\e[1m%s\e[0m %s ns:%s %s %s %s overview:%s %s %s %s auth:%s %s %s %s %s %s %s %s %s\n' \ - "$model" "$sep" "$ns" "$sep" "$profile" "$sep" "$overview" "$sep" "$ctx_str" \ - "$sep" "$auth" "$sep" "$pwr_str" \ - "$sep" "$tok_str" "$sep" "$cache_str" "$sep" "$cost_str" diff --git a/back2base-container/test/firewall-refresh.bats b/back2base-container/test/firewall-refresh.bats index 4df6469..36e14ce 100644 --- a/back2base-container/test/firewall-refresh.bats +++ b/back2base-container/test/firewall-refresh.bats @@ -57,10 +57,3 @@ setup() { MEMORY_MCP_URL=https://memory.example.test/mcp source "$REPO_DIR/init-firewall.sh" --dry-run-refresh printf '%s\n' "${ALLOWED_DOMAINS[@]}" | grep -q '^memory\.example\.test$' } - -@test "init-firewall.sh allow-lists the back2base OTel collector" { - # Daemon ships traces here via mTLS; firewall must let outbound - # connections through. - source "$REPO_DIR/init-firewall.sh" --dry-run-refresh - printf '%s\n' "${ALLOWED_DOMAINS[@]}" | grep -q '^otel\.back2base\.net$' -} diff --git a/back2base-container/test/memory-align.bats b/back2base-container/test/memory-align.bats new file mode 100644 index 0000000..3c29167 --- /dev/null +++ b/back2base-container/test/memory-align.bats @@ -0,0 +1,63 @@ +#!/usr/bin/env bats +# Tests for lib/memory-align.sh: b2b_align_memory_dir selects the canonical +# memory store and symlinks Claude Code's auto-memory path to it. + +setup() { + REPO_DIR="$(cd "$BATS_TEST_DIRNAME/.." && pwd)" + . "$REPO_DIR/lib/memory-align.sh" + TMP="$(mktemp -d)" + export HOME="$TMP/home"; mkdir -p "$HOME" + mkdir -p "$TMP/workspace"; cd "$TMP/workspace" + unset MEMORY_NAMESPACE CLAUDE_PROJECT_DIR BACK2BASE_PLANS_DIR +} +teardown() { rm -rf "$TMP"; } + +cc_path() { echo "$HOME/.claude/projects/${PWD//\//-}/memory"; } + +@test "mounted: symlinks Claude Code memory path to ~/.back2base/memories" { + mkdir -p "$HOME/.back2base/memories" + b2b_align_memory_dir + cc="$(cc_path)" + [ -L "$cc" ] + [ "$(readlink "$cc")" = "$HOME/.back2base/memories" ] +} + +@test "mounted: does NOT wipe existing memory in the mount" { + mkdir -p "$HOME/.back2base/memories" + echo "keep me" > "$HOME/.back2base/memories/MEMORY.md" + b2b_align_memory_dir + [ -f "$HOME/.back2base/memories/MEMORY.md" ] + [ "$(cat "$HOME/.back2base/memories/MEMORY.md")" = "keep me" ] +} + +@test "mounted: exports BACK2BASE_PLANS_DIR and creates the plans dir" { + mkdir -p "$HOME/.back2base/memories" + b2b_align_memory_dir + [ "$BACK2BASE_PLANS_DIR" = "$HOME/.back2base/plans" ] + [ -d "$HOME/.back2base/plans" ] +} + +@test "mounted: migrates a pre-existing real memory dir into the mount, then symlinks" { + mkdir -p "$HOME/.back2base/memories" + cc="$(cc_path)"; mkdir -p "$cc"; echo "old" > "$cc/old.md" + b2b_align_memory_dir + [ -L "$cc" ] + [ "$(readlink "$cc")" = "$HOME/.back2base/memories" ] + [ -f "$HOME/.back2base/memories/old.md" ] +} + +@test "not mounted + namespace set: symlinks to namespaced dir and wipes stale memory" { + export MEMORY_NAMESPACE=myrepo + ns="$HOME/.claude/projects/myrepo/memory"; mkdir -p "$ns"; echo stale > "$ns/stale.md" + b2b_align_memory_dir + cc="$(cc_path)" + [ -L "$cc" ] + [ "$(readlink "$cc")" = "$ns" ] + [ ! -f "$ns/stale.md" ] +} + +@test "not mounted + no namespace: no-op (no symlink, no plans export)" { + b2b_align_memory_dir + [ ! -e "$(cc_path)" ] + [ -z "${BACK2BASE_PLANS_DIR:-}" ] +} diff --git a/back2base-container/test/migrate-settings.bats b/back2base-container/test/migrate-settings.bats index 68470cf..3dda1ca 100644 --- a/back2base-container/test/migrate-settings.bats +++ b/back2base-container/test/migrate-settings.bats @@ -11,6 +11,7 @@ # 2. prune defunct /opt/back2base/memory-sessionstart-hook.sh registration. # 3. seed statusLine block. # 4. strip hooks block (render-hooks.py owns it from schema 4 on). +# 5. repoint unmodified back2base statusLine at vendored claude-hud. # # The script must be idempotent (re-running is a no-op) and tolerant of # malformed input (silently exits 0). @@ -19,7 +20,7 @@ setup() { TEST_TMP="$(mktemp -d "${BATS_TMPDIR}/back2base-migrate-settings.XXXXXX")" SETTINGS="$TEST_TMP/settings.json" SCRIPT="$BATS_TEST_DIRNAME/../lib/migrate-settings.py" - CURRENT_SCHEMA=4 + CURRENT_SCHEMA=5 } teardown() { @@ -221,7 +222,7 @@ EOF JSON run python3 "$SCRIPT" "$SETTINGS" [ "$status" -eq 0 ] - python3 -c "import json,sys; d=json.load(open('$SETTINGS')); assert d['statusLine']['type']=='command', d; assert d['statusLine']['command']=='/opt/back2base/statusline.sh', d; assert d['_back2base_schema']>=3, d" + python3 -c "import json,sys; d=json.load(open('$SETTINGS')); assert d['statusLine']['type']=='command', d; assert 'claude-hud' in d['statusLine']['command'], d; assert d['_back2base_schema']==5, d" } @test "migration 3 preserves existing statusLine" { @@ -232,3 +233,23 @@ JSON [ "$status" -eq 0 ] python3 -c "import json; d=json.load(open('$SETTINGS')); assert d['statusLine']['command']=='/usr/local/bin/my-custom-line', d; assert d['_back2base_schema']>=3, d" } + +# ── Migration 5: repoint unmodified statusLine at claude-hud ────────── + +@test "migration 5 repoints an unmodified back2base statusLine at claude-hud" { + cat > "$SETTINGS" <<'JSON' +{"statusLine": {"type": "command", "command": "/opt/back2base/statusline.sh", "padding": 0}} +JSON + run python3 "$SCRIPT" "$SETTINGS" + [ "$status" -eq 0 ] + python3 -c "import json,sys; d=json.load(open('$SETTINGS')); assert d['_back2base_schema']==5, d; assert 'claude-hud' in d['statusLine']['command'], d; assert 'index.js' in d['statusLine']['command'], d" +} + +@test "migration 5 leaves a customized statusLine alone" { + cat > "$SETTINGS" <<'JSON' +{"_back2base_schema": 4, "statusLine": {"type": "command", "command": "/my/own.sh"}} +JSON + run python3 "$SCRIPT" "$SETTINGS" + [ "$status" -eq 0 ] + python3 -c "import json; d=json.load(open('$SETTINGS')); assert d['statusLine']['command']=='/my/own.sh', d" +} diff --git a/back2base-container/test/prelaunch-overview.bats b/back2base-container/test/prelaunch-overview.bats index 8934b5d..5334cdb 100644 --- a/back2base-container/test/prelaunch-overview.bats +++ b/back2base-container/test/prelaunch-overview.bats @@ -26,8 +26,10 @@ setup() { export OVERVIEW_RENDERER_PATH="$RENDERER" export OVERVIEW_PROMPT_PATH="$PROMPT_FILE" - # Tighten timing for tests so the suite stays fast. - export OVERVIEW_TIMEOUT_SECS=3 + # Generous timeout so heavy parallel load can't trip a spurious timeout in + # the completion tests (splice / skip-nonzero / skip-empty). The dedicated + # timeout test overrides this to a short value. + export OVERVIEW_TIMEOUT_SECS=60 export OVERVIEW_HEARTBEAT_SECS=1 } @@ -104,6 +106,7 @@ exit 0' @test "timeout kills slow claude" { export BACK2BASE_OVERVIEW=1 + export OVERVIEW_TIMEOUT_SECS=1 # short timeout; stub sleeps far longer stub_claude 'sleep 10; echo too-late' . "$HELPER" diff --git a/back2base-container/vendor/claude-hud/.upstream b/back2base-container/vendor/claude-hud/.upstream new file mode 100644 index 0000000..0316e87 --- /dev/null +++ b/back2base-container/vendor/claude-hud/.upstream @@ -0,0 +1,11 @@ +name: claude-hud +version: 0.1.0 +repo: https://github.com/jarrodwatts/claude-hud +license: MIT +vendored: dist/ (precompiled ESM JS, runtime-only — .d.ts and .map files pruned) +runtime: node >=18 (base image ships node:24); zero runtime dependencies +invocation: node /opt/back2base/claude-hud/index.js (statusLine command; reads + the Claude Code statusline JSON payload on stdin, writes the rendered HUD to + stdout). back2base feeds ns/auth/pwr via --extra-cmd statusline-extra.sh. +notes: do not edit vendored files in place. To update, re-copy dist/ from the + upstream plugin/release at the pinned version and re-prune *.d.ts / *.map. diff --git a/back2base-container/vendor/claude-hud/LICENSE b/back2base-container/vendor/claude-hud/LICENSE new file mode 100644 index 0000000..7a0b1b9 --- /dev/null +++ b/back2base-container/vendor/claude-hud/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Jarrod Watts + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/back2base-container/vendor/claude-hud/claude-config-dir.js b/back2base-container/vendor/claude-hud/claude-config-dir.js new file mode 100644 index 0000000..ab6e334 --- /dev/null +++ b/back2base-container/vendor/claude-hud/claude-config-dir.js @@ -0,0 +1,24 @@ +import * as path from 'node:path'; +function expandHomeDirPrefix(inputPath, homeDir) { + if (inputPath === '~') { + return homeDir; + } + if (inputPath.startsWith('~/') || inputPath.startsWith('~\\')) { + return path.join(homeDir, inputPath.slice(2)); + } + return inputPath; +} +export function getClaudeConfigDir(homeDir) { + const envConfigDir = process.env.CLAUDE_CONFIG_DIR?.trim(); + if (!envConfigDir) { + return path.join(homeDir, '.claude'); + } + return path.resolve(expandHomeDirPrefix(envConfigDir, homeDir)); +} +export function getClaudeConfigJsonPath(homeDir) { + return `${getClaudeConfigDir(homeDir)}.json`; +} +export function getHudPluginDir(homeDir) { + return path.join(getClaudeConfigDir(homeDir), 'plugins', 'claude-hud'); +} +//# sourceMappingURL=claude-config-dir.js.map \ No newline at end of file diff --git a/back2base-container/vendor/claude-hud/config-reader.js b/back2base-container/vendor/claude-hud/config-reader.js new file mode 100644 index 0000000..b5b6d05 --- /dev/null +++ b/back2base-container/vendor/claude-hud/config-reader.js @@ -0,0 +1,378 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { createHash } from 'node:crypto'; +import { createDebug } from './debug.js'; +import { getClaudeConfigDir, getClaudeConfigJsonPath, getHudPluginDir } from './claude-config-dir.js'; +const debug = createDebug('config'); +function getMcpServerNames(filePath) { + if (!fs.existsSync(filePath)) + return new Set(); + try { + const content = fs.readFileSync(filePath, 'utf8'); + const config = JSON.parse(content); + if (config.mcpServers && typeof config.mcpServers === 'object') { + return new Set(Object.keys(config.mcpServers)); + } + } + catch (error) { + debug(`Failed to read MCP servers from ${filePath}:`, error); + } + return new Set(); +} +function getDisabledMcpServers(filePath, key) { + if (!fs.existsSync(filePath)) + return new Set(); + try { + const content = fs.readFileSync(filePath, 'utf8'); + const config = JSON.parse(content); + if (Array.isArray(config[key])) { + const validNames = config[key].filter((s) => typeof s === 'string'); + if (validNames.length !== config[key].length) { + debug(`${key} in ${filePath} contains non-string values, ignoring them`); + } + return new Set(validNames); + } + } + catch (error) { + debug(`Failed to read ${key} from ${filePath}:`, error); + } + return new Set(); +} +function countMcpServersInFile(filePath, excludeFrom) { + const servers = getMcpServerNames(filePath); + if (excludeFrom) { + const exclude = getMcpServerNames(excludeFrom); + for (const name of exclude) { + servers.delete(name); + } + } + return servers.size; +} +function countHooksInFile(filePath) { + if (!fs.existsSync(filePath)) + return 0; + try { + const content = fs.readFileSync(filePath, 'utf8'); + const config = JSON.parse(content); + if (config.hooks && typeof config.hooks === 'object') { + return Object.keys(config.hooks).length; + } + } + catch (error) { + debug(`Failed to read hooks from ${filePath}:`, error); + } + return 0; +} +function readStringSetting(filePath, key) { + if (!fs.existsSync(filePath)) + return undefined; + try { + const content = fs.readFileSync(filePath, 'utf8'); + const config = JSON.parse(content); + if (typeof config[key] === 'string') { + const value = config[key].trim(); + return value.length > 0 ? value : undefined; + } + } + catch (error) { + debug(`Failed to read ${key} from ${filePath}:`, error); + } + return undefined; +} +function countRulesInDir(rulesDir) { + if (!fs.existsSync(rulesDir)) + return 0; + let count = 0; + try { + const entries = fs.readdirSync(rulesDir, { withFileTypes: true }); + for (const entry of entries) { + const fullPath = path.join(rulesDir, entry.name); + if (entry.isDirectory()) { + count += countRulesInDir(fullPath); + } + else if (entry.isFile() && entry.name.endsWith('.md')) { + count++; + } + } + } + catch (error) { + debug(`Failed to read rules from ${rulesDir}:`, error); + } + return count; +} +function normalizePathForComparison(inputPath) { + let normalized = path.normalize(path.resolve(inputPath)); + const root = path.parse(normalized).root; + while (normalized.length > root.length && normalized.endsWith(path.sep)) { + normalized = normalized.slice(0, -1); + } + return process.platform === 'win32' ? normalized.toLowerCase() : normalized; +} +function pathsReferToSameLocation(pathA, pathB) { + if (normalizePathForComparison(pathA) === normalizePathForComparison(pathB)) { + return true; + } + if (!fs.existsSync(pathA) || !fs.existsSync(pathB)) { + return false; + } + try { + const realPathA = fs.realpathSync.native(pathA); + const realPathB = fs.realpathSync.native(pathB); + return normalizePathForComparison(realPathA) === normalizePathForComparison(realPathB); + } + catch { + return false; + } +} +function getConfigCachePath(cwd, claudeConfigDir, homeDir) { + const identity = JSON.stringify({ cwd, claudeConfigDir }); + const hash = createHash('sha256').update(identity).digest('hex'); + return path.join(getHudPluginDir(homeDir), 'config-cache', `${hash}.json`); +} +function statSentinel(filePath) { + try { + const stat = fs.statSync(filePath); + return { mtimeMs: stat.mtimeMs, size: stat.size }; + } + catch { + return null; + } +} +function buildSentinelPaths(claudeDir, claudeConfigJsonPath, cwd) { + // Note: We sentinel CLAUDE.md directly instead of claudeDir because the + // cache itself is stored under claudeDir/plugins/, which would change + // claudeDir's mtime and immediately invalidate the cache on every write. + const paths = [ + path.join(claudeDir, 'CLAUDE.md'), + path.join(claudeDir, 'rules'), + path.join(claudeDir, 'settings.json'), + path.join(claudeDir, 'settings.local.json'), + claudeConfigJsonPath, + ]; + if (cwd) { + paths.push(cwd, path.join(cwd, '.claude'), path.join(cwd, '.claude', 'rules'), path.join(cwd, '.mcp.json'), path.join(cwd, '.claude', 'settings.json'), path.join(cwd, '.claude', 'settings.local.json')); + } + return paths; +} +function collectRuleDirectorySentinels(rulesDir) { + if (!fs.existsSync(rulesDir)) + return []; + const sentinels = [rulesDir]; + try { + const entries = fs.readdirSync(rulesDir, { withFileTypes: true }); + for (const entry of entries) { + if (!entry.isDirectory()) { + continue; + } + sentinels.push(...collectRuleDirectorySentinels(path.join(rulesDir, entry.name))); + } + } + catch (error) { + debug(`Failed to read rule sentinel paths from ${rulesDir}:`, error); + } + return sentinels; +} +function statSentinels(paths) { + const result = {}; + for (const p of paths) { + result[p] = statSentinel(p); + } + return result; +} +function sentinelsMatch(a, b) { + const keysA = Object.keys(a); + const keysB = Object.keys(b); + if (keysA.length !== keysB.length) + return false; + for (const key of keysA) { + const sa = a[key]; + const sb = b[key]; + if (sa === null && sb === null) + continue; + if (sa === null || sb === null) + return false; + if (sa.mtimeMs !== sb.mtimeMs || sa.size !== sb.size) + return false; + } + return true; +} +function isConfigCounts(value) { + if (!value || typeof value !== 'object') { + return false; + } + const counts = value; + return (typeof counts.claudeMdCount === 'number' + && Number.isFinite(counts.claudeMdCount) + && counts.claudeMdCount >= 0 + && typeof counts.rulesCount === 'number' + && Number.isFinite(counts.rulesCount) + && counts.rulesCount >= 0 + && typeof counts.mcpCount === 'number' + && Number.isFinite(counts.mcpCount) + && counts.mcpCount >= 0 + && typeof counts.hooksCount === 'number' + && Number.isFinite(counts.hooksCount) + && counts.hooksCount >= 0 + && (counts.outputStyle === undefined || typeof counts.outputStyle === 'string')); +} +function readConfigCache(cacheKey, homeDir) { + try { + const cachePath = getConfigCachePath(cacheKey.cwd, cacheKey.claudeConfigDir, homeDir); + const raw = fs.readFileSync(cachePath, 'utf8'); + const parsed = JSON.parse(raw); + if (parsed.key?.cwd !== cacheKey.cwd || parsed.key?.claudeConfigDir !== cacheKey.claudeConfigDir) { + return null; + } + if (!isConfigCounts(parsed.data)) { + return null; + } + return parsed; + } + catch { + return null; + } +} +function writeConfigCache(key, data, homeDir) { + try { + const cachePath = getConfigCachePath(key.cwd, key.claudeConfigDir, homeDir); + fs.mkdirSync(path.dirname(cachePath), { recursive: true }); + const payload = { key, data }; + fs.writeFileSync(cachePath, JSON.stringify(payload), 'utf8'); + } + catch { + // Cache write failures are non-fatal. + } +} +function computeConfigCountsFresh(cwd) { + let claudeMdCount = 0; + let rulesCount = 0; + let hooksCount = 0; + let outputStyle; + const homeDir = os.homedir(); + const claudeDir = getClaudeConfigDir(homeDir); + // Collect all MCP servers across scopes, then subtract disabled ones + const userMcpServers = new Set(); + const projectMcpServers = new Set(); + // === USER SCOPE === + // ~/.claude/CLAUDE.md + if (fs.existsSync(path.join(claudeDir, 'CLAUDE.md'))) { + claudeMdCount++; + } + // ~/.claude/rules/*.md + rulesCount += countRulesInDir(path.join(claudeDir, 'rules')); + // ~/.claude/settings.json (MCPs and hooks) + const userSettings = path.join(claudeDir, 'settings.json'); + for (const name of getMcpServerNames(userSettings)) { + userMcpServers.add(name); + } + hooksCount += countHooksInFile(userSettings); + outputStyle = readStringSetting(userSettings, 'outputStyle'); + const userLocalSettings = path.join(claudeDir, 'settings.local.json'); + outputStyle = readStringSetting(userLocalSettings, 'outputStyle') ?? outputStyle; + // {CLAUDE_CONFIG_DIR}.json (additional user-scope MCPs) + const userClaudeJson = getClaudeConfigJsonPath(homeDir); + for (const name of getMcpServerNames(userClaudeJson)) { + userMcpServers.add(name); + } + // Get disabled user-scope MCPs from ~/.claude.json + const disabledUserMcps = getDisabledMcpServers(userClaudeJson, 'disabledMcpServers'); + for (const name of disabledUserMcps) { + userMcpServers.delete(name); + } + // === PROJECT SCOPE === + // Avoid double-counting when project .claude directory is the same location as user scope. + const projectClaudeDir = cwd ? path.join(cwd, '.claude') : null; + const projectClaudeOverlapsUserScope = projectClaudeDir + ? pathsReferToSameLocation(projectClaudeDir, claudeDir) + : false; + if (cwd) { + // {cwd}/CLAUDE.md + if (fs.existsSync(path.join(cwd, 'CLAUDE.md'))) { + claudeMdCount++; + } + // {cwd}/CLAUDE.local.md + if (fs.existsSync(path.join(cwd, 'CLAUDE.local.md'))) { + claudeMdCount++; + } + // {cwd}/.claude/CLAUDE.md (alternative location, skip when it is user scope) + if (!projectClaudeOverlapsUserScope && fs.existsSync(path.join(cwd, '.claude', 'CLAUDE.md'))) { + claudeMdCount++; + } + // {cwd}/.claude/CLAUDE.local.md + if (fs.existsSync(path.join(cwd, '.claude', 'CLAUDE.local.md'))) { + claudeMdCount++; + } + // {cwd}/.claude/rules/*.md (recursive) + // Skip when it overlaps with user-scope rules. + if (!projectClaudeOverlapsUserScope) { + rulesCount += countRulesInDir(path.join(cwd, '.claude', 'rules')); + } + // {cwd}/.mcp.json (project MCP config) - tracked separately for disabled filtering + const mcpJsonServers = getMcpServerNames(path.join(cwd, '.mcp.json')); + // {cwd}/.claude/settings.json (project settings) + // Skip when it overlaps with user-scope settings. + const projectSettings = path.join(cwd, '.claude', 'settings.json'); + if (!projectClaudeOverlapsUserScope) { + for (const name of getMcpServerNames(projectSettings)) { + projectMcpServers.add(name); + } + hooksCount += countHooksInFile(projectSettings); + outputStyle = readStringSetting(projectSettings, 'outputStyle') ?? outputStyle; + } + // {cwd}/.claude/settings.local.json (local project settings) + const localSettings = path.join(cwd, '.claude', 'settings.local.json'); + for (const name of getMcpServerNames(localSettings)) { + projectMcpServers.add(name); + } + hooksCount += countHooksInFile(localSettings); + outputStyle = readStringSetting(localSettings, 'outputStyle') ?? outputStyle; + // Get disabled .mcp.json servers from settings.local.json + const disabledMcpJsonServers = getDisabledMcpServers(localSettings, 'disabledMcpjsonServers'); + for (const name of disabledMcpJsonServers) { + mcpJsonServers.delete(name); + } + // Add remaining .mcp.json servers to project set + for (const name of mcpJsonServers) { + projectMcpServers.add(name); + } + } + // Total MCP count = user servers + project servers + // Note: Deduplication only occurs within each scope, not across scopes. + // A server with the same name in both user and project scope counts as 2 (separate configs). + const mcpCount = userMcpServers.size + projectMcpServers.size; + return { claudeMdCount, rulesCount, mcpCount, hooksCount, outputStyle }; +} +export async function countConfigs(cwd) { + const homeDir = os.homedir(); + const claudeDir = getClaudeConfigDir(homeDir); + const claudeConfigJsonPath = getClaudeConfigJsonPath(homeDir); + const normalizedCwd = cwd ? path.resolve(cwd) : null; + const staticSentinelPaths = buildSentinelPaths(claudeDir, claudeConfigJsonPath, normalizedCwd); + const cached = readConfigCache({ cwd: normalizedCwd, claudeConfigDir: claudeDir }, homeDir); + const cacheValidationPaths = cached + ? Array.from(new Set([...staticSentinelPaths, ...Object.keys(cached.key.sentinels)])) + : staticSentinelPaths; + const currentSentinels = statSentinels(cacheValidationPaths); + if (cached && sentinelsMatch(cached.key.sentinels, currentSentinels)) { + return cached.data; + } + const result = computeConfigCountsFresh(cwd); + const ruleSentinelPaths = collectRuleDirectorySentinels(path.join(claudeDir, 'rules')); + const projectClaudeDir = normalizedCwd ? path.join(normalizedCwd, '.claude') : null; + const projectClaudeOverlapsUserScope = projectClaudeDir + ? pathsReferToSameLocation(projectClaudeDir, claudeDir) + : false; + if (normalizedCwd && !projectClaudeOverlapsUserScope) { + ruleSentinelPaths.push(...collectRuleDirectorySentinels(path.join(normalizedCwd, '.claude', 'rules'))); + } + const cacheSentinelPaths = Array.from(new Set([...staticSentinelPaths, ...ruleSentinelPaths])); + const cacheKey = { + cwd: normalizedCwd, + claudeConfigDir: claudeDir, + sentinels: statSentinels(cacheSentinelPaths), + }; + writeConfigCache(cacheKey, result, homeDir); + return result; +} +//# sourceMappingURL=config-reader.js.map \ No newline at end of file diff --git a/back2base-container/vendor/claude-hud/config.js b/back2base-container/vendor/claude-hud/config.js new file mode 100644 index 0000000..6c1927f --- /dev/null +++ b/back2base-container/vendor/claude-hud/config.js @@ -0,0 +1,488 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as os from 'node:os'; +import { getHudPluginDir } from './claude-config-dir.js'; +export const DEFAULT_ELEMENT_ORDER = [ + 'project', + 'addedDirs', + 'context', + 'usage', + 'promptCache', + 'memory', + 'environment', + 'tools', + 'agents', + 'todos', + 'sessionTime', +]; +export const DEFAULT_MERGE_GROUPS = [ + ['context', 'usage'], +]; +const KNOWN_ELEMENTS = new Set(DEFAULT_ELEMENT_ORDER); +export const DEFAULT_CONFIG = { + language: 'en', + lineLayout: 'expanded', + showSeparators: false, + pathLevels: 1, + maxWidth: null, + forceMaxWidth: false, + elementOrder: [...DEFAULT_ELEMENT_ORDER], + gitStatus: { + enabled: true, + showDirty: true, + showAheadBehind: false, + showFileStats: false, + branchOverflow: 'truncate', + pushWarningThreshold: 0, + pushCriticalThreshold: 0, + }, + display: { + showModel: true, + showProject: true, + showAddedDirs: true, + addedDirsLayout: 'inline', + showContextBar: true, + contextValue: 'percent', + showConfigCounts: false, + showCost: false, + showDuration: false, + showSpeed: false, + showTokenBreakdown: true, + showUsage: true, + usageValue: 'percent', + usageBarEnabled: true, + showResetLabel: true, + usageCompact: false, + showTools: false, + showAgents: false, + showTodos: false, + showSessionName: false, + showClaudeCodeVersion: false, + showEffortLevel: false, + showMemoryUsage: false, + showPromptCache: false, + promptCacheTtlSeconds: 300, + showSessionTokens: false, + showOutputStyle: false, + showSessionStartDate: false, + showLastResponseAt: false, + mergeGroups: DEFAULT_MERGE_GROUPS.map(group => [...group]), + autocompactBuffer: 'enabled', + contextWarningThreshold: 70, + contextCriticalThreshold: 85, + usageThreshold: 0, + sevenDayThreshold: 80, + environmentThreshold: 0, + externalUsagePath: '', + externalUsageFreshnessMs: 300000, + modelFormat: 'full', + modelOverride: '', + customLine: '', + timeFormat: 'relative', + }, + colors: { + context: 'green', + usage: 'brightBlue', + warning: 'yellow', + usageWarning: 'brightMagenta', + critical: 'red', + model: 'cyan', + project: 'yellow', + git: 'magenta', + gitBranch: 'cyan', + label: 'dim', + custom: 208, + barFilled: '█', + barEmpty: '░', + }, +}; +export function getConfigPath() { + const homeDir = os.homedir(); + return path.join(getHudPluginDir(homeDir), 'config.json'); +} +function validatePathLevels(value) { + return value === 1 || value === 2 || value === 3; +} +function validateLineLayout(value) { + return value === 'compact' || value === 'expanded'; +} +function validateAutocompactBuffer(value) { + return value === 'enabled' || value === 'disabled'; +} +function validateGitBranchOverflow(value) { + return value === 'truncate' || value === 'wrap'; +} +function validateContextValue(value) { + return value === 'percent' || value === 'tokens' || value === 'remaining' || value === 'both'; +} +function validateUsageValue(value) { + return value === 'percent' || value === 'remaining'; +} +function validateLanguage(value) { + return value === 'en' || value === 'zh'; +} +function validateModelFormat(value) { + return value === 'full' || value === 'compact' || value === 'short'; +} +function validateTimeFormat(value) { + return value === 'relative' || value === 'absolute' || value === 'both'; +} +function validateColorName(value) { + return value === 'dim' + || value === 'red' + || value === 'green' + || value === 'yellow' + || value === 'magenta' + || value === 'cyan' + || value === 'brightBlue' + || value === 'brightMagenta'; +} +const UNSAFE_CODEPOINT = /[\p{Cc}\p{Cf}\p{Variation_Selector}\p{Zl}\p{Zp}\p{Cn}]/u; +function validateBarChar(value) { + if (typeof value !== 'string' || value.length === 0) + return false; + const segmenter = new Intl.Segmenter(undefined, { granularity: 'grapheme' }); + if (Array.from(segmenter.segment(value)).length !== 1) + return false; + for (const ch of value) { + if (UNSAFE_CODEPOINT.test(ch)) + return false; + } + return true; +} +const HEX_COLOR_PATTERN = /^#[0-9a-fA-F]{6}$/; +function validateColorValue(value) { + if (validateColorName(value)) + return true; + if (typeof value === 'number' && Number.isInteger(value) && value >= 0 && value <= 255) + return true; + if (typeof value === 'string' && HEX_COLOR_PATTERN.test(value)) + return true; + return false; +} +function validateElementOrder(value) { + if (!Array.isArray(value) || value.length === 0) { + return [...DEFAULT_ELEMENT_ORDER]; + } + const seen = new Set(); + const elementOrder = []; + for (const item of value) { + if (typeof item !== 'string' || !KNOWN_ELEMENTS.has(item)) { + continue; + } + const element = item; + if (seen.has(element)) { + continue; + } + seen.add(element); + elementOrder.push(element); + } + return elementOrder.length > 0 ? elementOrder : [...DEFAULT_ELEMENT_ORDER]; +} +function validateMergeGroups(value) { + if (!Array.isArray(value)) { + return DEFAULT_MERGE_GROUPS.map(group => [...group]); + } + if (value.length === 0) { + return []; + } + const usedElements = new Set(); + const mergeGroups = []; + for (const group of value) { + if (!Array.isArray(group)) { + continue; + } + const seenInGroup = new Set(); + const normalizedGroup = []; + const pendingElements = []; + for (const item of group) { + if (typeof item !== 'string' || !KNOWN_ELEMENTS.has(item)) { + continue; + } + const element = item; + if (seenInGroup.has(element) || usedElements.has(element)) { + continue; + } + seenInGroup.add(element); + normalizedGroup.push(element); + pendingElements.push(element); + } + if (normalizedGroup.length >= 2) { + for (const element of pendingElements) { + usedElements.add(element); + } + mergeGroups.push(normalizedGroup); + } + } + return mergeGroups.length > 0 + ? mergeGroups + : DEFAULT_MERGE_GROUPS.map(group => [...group]); +} +function migrateConfig(userConfig) { + const migrated = { ...userConfig }; + if ('layout' in userConfig && !('lineLayout' in userConfig)) { + if (typeof userConfig.layout === 'string') { + // Legacy string migration (v0.0.x → v0.1.x) + if (userConfig.layout === 'separators') { + migrated.lineLayout = 'compact'; + migrated.showSeparators = true; + } + else { + migrated.lineLayout = 'compact'; + migrated.showSeparators = false; + } + } + else if (typeof userConfig.layout === 'object' && userConfig.layout !== null) { + // Object layout written by third-party tools — extract nested fields + const obj = userConfig.layout; + if (typeof obj.lineLayout === 'string') + migrated.lineLayout = obj.lineLayout; + if (typeof obj.showSeparators === 'boolean') + migrated.showSeparators = obj.showSeparators; + if (typeof obj.pathLevels === 'number') + migrated.pathLevels = obj.pathLevels; + } + delete migrated.layout; + } + return migrated; +} +function validateThreshold(value, max = 100) { + if (typeof value !== 'number') + return 0; + return Math.max(0, Math.min(max, value)); +} +function validateContextThreshold(value, fallback) { + if (typeof value !== 'number' || !Number.isFinite(value)) + return fallback; + return Math.max(0, Math.min(100, value)); +} +function validateCountThreshold(value) { + if (typeof value !== 'number' || !Number.isFinite(value)) { + return 0; + } + return Math.max(0, Math.floor(value)); +} +function validateDurationSeconds(value, fallback) { + if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) { + return fallback; + } + return Math.floor(value); +} +function validateOptionalPath(value) { + return typeof value === 'string' ? value.trim() : ''; +} +function validateFreshnessMs(value) { + if (typeof value !== 'number' || !Number.isFinite(value)) { + return DEFAULT_CONFIG.display.externalUsageFreshnessMs; + } + return Math.max(0, Math.floor(value)); +} +export function mergeConfig(userConfig) { + const migrated = migrateConfig(userConfig); + const language = validateLanguage(migrated.language) + ? migrated.language + : DEFAULT_CONFIG.language; + const lineLayout = validateLineLayout(migrated.lineLayout) + ? migrated.lineLayout + : DEFAULT_CONFIG.lineLayout; + const showSeparators = typeof migrated.showSeparators === 'boolean' + ? migrated.showSeparators + : DEFAULT_CONFIG.showSeparators; + const pathLevels = validatePathLevels(migrated.pathLevels) + ? migrated.pathLevels + : DEFAULT_CONFIG.pathLevels; + const rawMaxWidth = migrated.maxWidth; + const maxWidth = (typeof rawMaxWidth === 'number' && Number.isFinite(rawMaxWidth) && rawMaxWidth > 0) + ? Math.floor(rawMaxWidth) + : null; + const elementOrder = validateElementOrder(migrated.elementOrder); + const forceMaxWidth = typeof migrated.forceMaxWidth === 'boolean' + ? migrated.forceMaxWidth + : DEFAULT_CONFIG.forceMaxWidth; + const gitStatus = { + enabled: typeof migrated.gitStatus?.enabled === 'boolean' + ? migrated.gitStatus.enabled + : DEFAULT_CONFIG.gitStatus.enabled, + showDirty: typeof migrated.gitStatus?.showDirty === 'boolean' + ? migrated.gitStatus.showDirty + : DEFAULT_CONFIG.gitStatus.showDirty, + showAheadBehind: typeof migrated.gitStatus?.showAheadBehind === 'boolean' + ? migrated.gitStatus.showAheadBehind + : DEFAULT_CONFIG.gitStatus.showAheadBehind, + showFileStats: typeof migrated.gitStatus?.showFileStats === 'boolean' + ? migrated.gitStatus.showFileStats + : DEFAULT_CONFIG.gitStatus.showFileStats, + branchOverflow: validateGitBranchOverflow(migrated.gitStatus?.branchOverflow) + ? migrated.gitStatus.branchOverflow + : DEFAULT_CONFIG.gitStatus.branchOverflow, + pushWarningThreshold: validateCountThreshold(migrated.gitStatus?.pushWarningThreshold), + pushCriticalThreshold: validateCountThreshold(migrated.gitStatus?.pushCriticalThreshold), + }; + const display = { + showModel: typeof migrated.display?.showModel === 'boolean' + ? migrated.display.showModel + : DEFAULT_CONFIG.display.showModel, + showProject: typeof migrated.display?.showProject === 'boolean' + ? migrated.display.showProject + : DEFAULT_CONFIG.display.showProject, + showAddedDirs: typeof migrated.display?.showAddedDirs === 'boolean' + ? migrated.display.showAddedDirs + : DEFAULT_CONFIG.display.showAddedDirs, + addedDirsLayout: (migrated.display?.addedDirsLayout === 'inline' || migrated.display?.addedDirsLayout === 'line') + ? migrated.display.addedDirsLayout + : DEFAULT_CONFIG.display.addedDirsLayout, + showContextBar: typeof migrated.display?.showContextBar === 'boolean' + ? migrated.display.showContextBar + : DEFAULT_CONFIG.display.showContextBar, + contextValue: validateContextValue(migrated.display?.contextValue) + ? migrated.display.contextValue + : DEFAULT_CONFIG.display.contextValue, + showConfigCounts: typeof migrated.display?.showConfigCounts === 'boolean' + ? migrated.display.showConfigCounts + : DEFAULT_CONFIG.display.showConfigCounts, + showCost: typeof migrated.display?.showCost === 'boolean' + ? migrated.display.showCost + : DEFAULT_CONFIG.display.showCost, + showDuration: typeof migrated.display?.showDuration === 'boolean' + ? migrated.display.showDuration + : DEFAULT_CONFIG.display.showDuration, + showSpeed: typeof migrated.display?.showSpeed === 'boolean' + ? migrated.display.showSpeed + : DEFAULT_CONFIG.display.showSpeed, + showTokenBreakdown: typeof migrated.display?.showTokenBreakdown === 'boolean' + ? migrated.display.showTokenBreakdown + : DEFAULT_CONFIG.display.showTokenBreakdown, + showUsage: typeof migrated.display?.showUsage === 'boolean' + ? migrated.display.showUsage + : DEFAULT_CONFIG.display.showUsage, + usageValue: validateUsageValue(migrated.display?.usageValue) + ? migrated.display.usageValue + : DEFAULT_CONFIG.display.usageValue, + usageBarEnabled: typeof migrated.display?.usageBarEnabled === 'boolean' + ? migrated.display.usageBarEnabled + : DEFAULT_CONFIG.display.usageBarEnabled, + showResetLabel: typeof migrated.display?.showResetLabel === 'boolean' + ? migrated.display.showResetLabel + : DEFAULT_CONFIG.display.showResetLabel, + usageCompact: typeof migrated.display?.usageCompact === 'boolean' + ? migrated.display.usageCompact + : DEFAULT_CONFIG.display.usageCompact, + showTools: typeof migrated.display?.showTools === 'boolean' + ? migrated.display.showTools + : DEFAULT_CONFIG.display.showTools, + showAgents: typeof migrated.display?.showAgents === 'boolean' + ? migrated.display.showAgents + : DEFAULT_CONFIG.display.showAgents, + showTodos: typeof migrated.display?.showTodos === 'boolean' + ? migrated.display.showTodos + : DEFAULT_CONFIG.display.showTodos, + showSessionName: typeof migrated.display?.showSessionName === 'boolean' + ? migrated.display.showSessionName + : DEFAULT_CONFIG.display.showSessionName, + showClaudeCodeVersion: typeof migrated.display?.showClaudeCodeVersion === 'boolean' + ? migrated.display.showClaudeCodeVersion + : DEFAULT_CONFIG.display.showClaudeCodeVersion, + showEffortLevel: typeof migrated.display?.showEffortLevel === 'boolean' + ? migrated.display.showEffortLevel + : DEFAULT_CONFIG.display.showEffortLevel, + showMemoryUsage: typeof migrated.display?.showMemoryUsage === 'boolean' + ? migrated.display.showMemoryUsage + : DEFAULT_CONFIG.display.showMemoryUsage, + showPromptCache: typeof migrated.display?.showPromptCache === 'boolean' + ? migrated.display.showPromptCache + : DEFAULT_CONFIG.display.showPromptCache, + promptCacheTtlSeconds: validateDurationSeconds(migrated.display?.promptCacheTtlSeconds, DEFAULT_CONFIG.display.promptCacheTtlSeconds), + showSessionTokens: typeof migrated.display?.showSessionTokens === 'boolean' + ? migrated.display.showSessionTokens + : DEFAULT_CONFIG.display.showSessionTokens, + showOutputStyle: typeof migrated.display?.showOutputStyle === 'boolean' + ? migrated.display.showOutputStyle + : DEFAULT_CONFIG.display.showOutputStyle, + showSessionStartDate: typeof migrated.display?.showSessionStartDate === 'boolean' + ? migrated.display.showSessionStartDate + : DEFAULT_CONFIG.display.showSessionStartDate, + showLastResponseAt: typeof migrated.display?.showLastResponseAt === 'boolean' + ? migrated.display.showLastResponseAt + : DEFAULT_CONFIG.display.showLastResponseAt, + mergeGroups: validateMergeGroups(migrated.display?.mergeGroups), + autocompactBuffer: validateAutocompactBuffer(migrated.display?.autocompactBuffer) + ? migrated.display.autocompactBuffer + : DEFAULT_CONFIG.display.autocompactBuffer, + contextWarningThreshold: validateContextThreshold(migrated.display?.contextWarningThreshold, DEFAULT_CONFIG.display.contextWarningThreshold), + contextCriticalThreshold: validateContextThreshold(migrated.display?.contextCriticalThreshold, DEFAULT_CONFIG.display.contextCriticalThreshold), + usageThreshold: validateThreshold(migrated.display?.usageThreshold, 100), + sevenDayThreshold: validateThreshold(migrated.display?.sevenDayThreshold, 100), + environmentThreshold: validateThreshold(migrated.display?.environmentThreshold, 100), + externalUsagePath: validateOptionalPath(migrated.display?.externalUsagePath), + externalUsageFreshnessMs: validateFreshnessMs(migrated.display?.externalUsageFreshnessMs), + modelFormat: validateModelFormat(migrated.display?.modelFormat) + ? migrated.display.modelFormat + : DEFAULT_CONFIG.display.modelFormat, + modelOverride: typeof migrated.display?.modelOverride === 'string' + ? migrated.display.modelOverride.slice(0, 80) + : DEFAULT_CONFIG.display.modelOverride, + customLine: typeof migrated.display?.customLine === 'string' + ? migrated.display.customLine.slice(0, 80) + : DEFAULT_CONFIG.display.customLine, + timeFormat: validateTimeFormat(migrated.display?.timeFormat) + ? migrated.display.timeFormat + : DEFAULT_CONFIG.display.timeFormat, + }; + const colors = { + context: validateColorValue(migrated.colors?.context) + ? migrated.colors.context + : DEFAULT_CONFIG.colors.context, + usage: validateColorValue(migrated.colors?.usage) + ? migrated.colors.usage + : DEFAULT_CONFIG.colors.usage, + warning: validateColorValue(migrated.colors?.warning) + ? migrated.colors.warning + : DEFAULT_CONFIG.colors.warning, + usageWarning: validateColorValue(migrated.colors?.usageWarning) + ? migrated.colors.usageWarning + : DEFAULT_CONFIG.colors.usageWarning, + critical: validateColorValue(migrated.colors?.critical) + ? migrated.colors.critical + : DEFAULT_CONFIG.colors.critical, + model: validateColorValue(migrated.colors?.model) + ? migrated.colors.model + : DEFAULT_CONFIG.colors.model, + project: validateColorValue(migrated.colors?.project) + ? migrated.colors.project + : DEFAULT_CONFIG.colors.project, + git: validateColorValue(migrated.colors?.git) + ? migrated.colors.git + : DEFAULT_CONFIG.colors.git, + gitBranch: validateColorValue(migrated.colors?.gitBranch) + ? migrated.colors.gitBranch + : DEFAULT_CONFIG.colors.gitBranch, + label: validateColorValue(migrated.colors?.label) + ? migrated.colors.label + : DEFAULT_CONFIG.colors.label, + custom: validateColorValue(migrated.colors?.custom) + ? migrated.colors.custom + : DEFAULT_CONFIG.colors.custom, + barFilled: validateBarChar(migrated.colors?.barFilled) + ? migrated.colors.barFilled + : DEFAULT_CONFIG.colors.barFilled, + barEmpty: validateBarChar(migrated.colors?.barEmpty) + ? migrated.colors.barEmpty + : DEFAULT_CONFIG.colors.barEmpty, + }; + return { language, lineLayout, showSeparators, pathLevels, maxWidth, forceMaxWidth, elementOrder, gitStatus, display, colors }; +} +export async function loadConfig() { + const configPath = getConfigPath(); + try { + if (!fs.existsSync(configPath)) { + return mergeConfig({}); + } + const content = fs.readFileSync(configPath, 'utf-8'); + const userConfig = JSON.parse(content); + return mergeConfig(userConfig); + } + catch { + return mergeConfig({}); + } +} +//# sourceMappingURL=config.js.map \ No newline at end of file diff --git a/back2base-container/vendor/claude-hud/constants.js b/back2base-container/vendor/claude-hud/constants.js new file mode 100644 index 0000000..6a4179b --- /dev/null +++ b/back2base-container/vendor/claude-hud/constants.js @@ -0,0 +1,11 @@ +/** + * Autocompact buffer percentage. + * + * NOTE: This value is applied as a percentage of Claude Code's reported + * context window size. The `33k/200k` example is just the 200k-window case. + * It is empirically derived from current Claude Code `/context` output, is + * not officially documented by Anthropic, and may need adjustment if users + * report mismatches in future Claude Code versions. + */ +export const AUTOCOMPACT_BUFFER_PERCENT = 0.165; +//# sourceMappingURL=constants.js.map \ No newline at end of file diff --git a/back2base-container/vendor/claude-hud/context-cache.js b/back2base-container/vendor/claude-hud/context-cache.js new file mode 100644 index 0000000..608bef0 --- /dev/null +++ b/back2base-container/vendor/claude-hud/context-cache.js @@ -0,0 +1,263 @@ +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { createHash } from "node:crypto"; +import { getHudPluginDir } from "./claude-config-dir.js"; +const CACHE_DIRNAME = "context-cache"; +/** + * Minimum interval between cache rewrites for the same session. + * Status line runs every ~300ms so this keeps the steady-state write path cheap + * while still refreshing the fallback snapshot regularly. + */ +const WRITE_TTL_MS = 3_000; +/** + * Sweep parameters bounding long-term growth of the cache directory. + * A sweep is attempted probabilistically on cache writes to avoid paying + * directory-scan cost on every status line tick. + */ +const MAX_CACHE_AGE_MS = 7 * 24 * 60 * 60 * 1000; +const MAX_CACHE_ENTRIES = 100; +const SWEEP_SAMPLE_RATE = 0.01; +const defaultDeps = { + homeDir: () => os.homedir(), + now: () => Date.now(), + random: () => Math.random(), +}; +/** + * Resolve the session-scoped cache file used for context window fallback. + * Uses a sha256 of the transcript path so that concurrent Claude Code + * sessions never share or overwrite each other's cached snapshots. + */ +function getCachePath(homeDir, transcriptPath) { + const hash = createHash("sha256") + .update(path.resolve(transcriptPath)) + .digest("hex"); + return path.join(getHudPluginDir(homeDir), CACHE_DIRNAME, `${hash}.json`); +} +/** + * Resolve the cache directory that holds all session-scoped snapshots. + */ +function getCacheDir(homeDir) { + return path.join(getHudPluginDir(homeDir), CACHE_DIRNAME); +} +/** + * Read the last known good context snapshot from disk. + * Returns null when the cache is missing, malformed, or invalid. + */ +function readCache(homeDir, transcriptPath) { + try { + const cachePath = getCachePath(homeDir, transcriptPath); + if (!fs.existsSync(cachePath)) + return null; + const content = fs.readFileSync(cachePath, "utf8"); + const parsed = JSON.parse(content); + if (typeof parsed.used_percentage !== "number" || + !Number.isFinite(parsed.used_percentage)) { + return null; + } + return parsed; + } + catch { + return null; + } +} +/** + * Decide whether the current write can be skipped because the cached snapshot + * for this session was refreshed recently enough. + */ +function shouldSkipWrite(cachePath, now) { + try { + const stat = fs.statSync(cachePath); + return now - stat.mtimeMs < WRITE_TTL_MS; + } + catch { + return false; + } +} +/** + * Persist a known-good context snapshot for future fallback use. + * Any write failure is intentionally ignored to keep rendering non-blocking. + */ +function writeCache(homeDir, transcriptPath, contextWindow, now, sessionName) { + try { + const cachePath = getCachePath(homeDir, transcriptPath); + if (shouldSkipWrite(cachePath, now)) { + return; + } + const cacheDir = path.dirname(cachePath); + if (!fs.existsSync(cacheDir)) { + fs.mkdirSync(cacheDir, { recursive: true }); + } + const payload = { + used_percentage: contextWindow.used_percentage ?? 0, + remaining_percentage: contextWindow.remaining_percentage ?? null, + current_usage: contextWindow.current_usage ?? null, + context_window_size: contextWindow.context_window_size ?? null, + saved_at: now, + session_name: sessionName ?? null, + }; + fs.writeFileSync(cachePath, JSON.stringify(payload), "utf8"); + const timestampSeconds = now / 1000; + fs.utimesSync(cachePath, timestampSeconds, timestampSeconds); + } + catch { + // Ignore cache write failures + } +} +/** + * Remove stale cache entries and enforce a hard cap on total file count. + * Safe to run opportunistically; every per-file failure is swallowed. + */ +function sweepCacheDir(cacheDir, now) { + try { + if (!fs.existsSync(cacheDir)) + return; + const entries = fs.readdirSync(cacheDir, { withFileTypes: true }); + const survivors = []; + for (const entry of entries) { + if (!entry.isFile() || !entry.name.endsWith(".json")) + continue; + const fullPath = path.join(cacheDir, entry.name); + try { + const stat = fs.statSync(fullPath); + if (now - stat.mtimeMs > MAX_CACHE_AGE_MS) { + fs.unlinkSync(fullPath); + continue; + } + survivors.push({ fullPath, mtimeMs: stat.mtimeMs }); + } + catch { + // Ignore per-file failure + } + } + if (survivors.length > MAX_CACHE_ENTRIES) { + survivors.sort((a, b) => a.mtimeMs - b.mtimeMs); + const toDelete = survivors.length - MAX_CACHE_ENTRIES; + for (let i = 0; i < toDelete; i += 1) { + try { + fs.unlinkSync(survivors[i].fullPath); + } + catch { + // Ignore per-file failure + } + } + } + } + catch { + // Ignore top-level sweep errors + } +} +/** + * Check whether all tracked token counters in current_usage are zero. + */ +function isAllUsageZero(usage) { + if (!usage) { + return true; + } + return ((usage.input_tokens ?? 0) === 0 && + (usage.output_tokens ?? 0) === 0 && + (usage.cache_creation_input_tokens ?? 0) === 0 && + (usage.cache_read_input_tokens ?? 0) === 0); +} +/** + * Returns true when context window data looks like a Claude Code reporting + * glitch rather than a genuine zero-usage state. + * + * We only treat a zero-percent frame as suspicious when accumulated totals are + * non-zero and `current_usage` is still empty. If `current_usage` already shows + * non-zero token counters, keep the live frame instead of restoring stale cache. + */ +function isSuspiciousZero(contextWindow) { + const usedPercentage = contextWindow.used_percentage ?? 0; + if (usedPercentage !== 0) { + return false; + } + if (!isAllUsageZero(contextWindow.current_usage)) { + return false; + } + const totalInputTokens = contextWindow.total_input_tokens ?? 0; + const totalOutputTokens = contextWindow.total_output_tokens ?? 0; + return totalInputTokens > 0 || totalOutputTokens > 0; +} +/** + * Determine whether the current frame contains a usable context snapshot. + */ +function hasGoodContext(contextWindow) { + return ((contextWindow.context_window_size ?? 0) > 0 && + typeof contextWindow.used_percentage === "number" && + contextWindow.used_percentage > 0); +} +/** + * Merge cached context fields into the current frame. + * Prefer the frame's context_window_size when already present. + */ +function applyCachedContext(contextWindow, cache) { + contextWindow.used_percentage = cache.used_percentage; + contextWindow.remaining_percentage = cache.remaining_percentage ?? null; + contextWindow.current_usage = cache.current_usage ?? null; + contextWindow.context_window_size = + contextWindow.context_window_size ?? cache.context_window_size ?? undefined; +} +/** + * Apply context-window fallback in-place: + * - For suspicious zero frames, try restoring from the session-scoped cache. + * - For healthy frames, refresh the cache snapshot for this session + * (subject to TTL + value-change throttling to avoid hot-path writes). + * + * When `compactHint.lastCompactBoundaryAt` is newer than the cached snapshot's + * `saved_at`, the zero frame is treated as a legitimate post-/compact reset and + * the stale pre-compact snapshot is NOT restored. If `lastCompactPostTokens` + * is provided, it is used to synthesize an accurate transition-window percent. + * + * No-op when stdin has no transcript_path, since without a stable session key + * we cannot safely isolate cache entries across concurrent Claude Code sessions. + */ +export function applyContextWindowFallback(stdin, overrides = {}, sessionName, compactHint) { + const contextWindow = stdin.context_window; + if (!contextWindow) { + return; + } + const transcriptPath = stdin.transcript_path?.trim(); + if (!transcriptPath) { + return; + } + const deps = { ...defaultDeps, ...overrides }; + const homeDir = deps.homeDir(); + const now = deps.now(); + if (isSuspiciousZero(contextWindow)) { + const cached = readCache(homeDir, transcriptPath); + const boundaryMs = compactHint?.lastCompactBoundaryAt?.getTime(); + const isPostCompactReset = typeof boundaryMs === "number" && + Number.isFinite(boundaryMs) && + (!cached?.saved_at || boundaryMs > cached.saved_at); + if (isPostCompactReset) { + // Legitimate /compact reset: keep the zero frame instead of restoring a + // stale pre-compact snapshot. Surface the compactMetadata.postTokens + // value (when available) so the bar shows the real post-compact + // percent during the transition before the next assistant response. + const postTokens = compactHint?.lastCompactPostTokens; + const size = contextWindow.context_window_size ?? 0; + if (typeof postTokens === "number" && postTokens > 0 && size > 0) { + const pct = Math.min(100, Math.max(0, Math.round((postTokens / size) * 100))); + contextWindow.used_percentage = pct; + contextWindow.remaining_percentage = 100 - pct; + } + } + else if (cached) { + applyCachedContext(contextWindow, cached); + } + } + if (hasGoodContext(contextWindow)) { + writeCache(homeDir, transcriptPath, contextWindow, now, sessionName); + if (deps.random() < SWEEP_SAMPLE_RATE) { + sweepCacheDir(getCacheDir(homeDir), now); + } + } +} +/** + * Test-only entrypoint for deterministically exercising the sweep logic. + */ +export function _sweepCacheForTests(homeDir, now) { + sweepCacheDir(getCacheDir(homeDir), now); +} +//# sourceMappingURL=context-cache.js.map \ No newline at end of file diff --git a/back2base-container/vendor/claude-hud/cost.js b/back2base-container/vendor/claude-hud/cost.js new file mode 100644 index 0000000..06fc3c2 --- /dev/null +++ b/back2base-container/vendor/claude-hud/cost.js @@ -0,0 +1,129 @@ +import { isBedrockModelId, isVertexModelId } from './stdin.js'; +const TOKENS_PER_MILLION = 1_000_000; +const CACHE_WRITE_MULTIPLIER = 1.25; +const CACHE_READ_MULTIPLIER = 0.1; +// Patterns are tried in order; the first match wins. Families with more specific +// model lines (Haiku 4.x differs from Haiku 3.5) must come before any broader +// fallback patterns to avoid silent under-pricing. +const ANTHROPIC_MODEL_PRICING = [ + { pattern: /\bopus 4(?: \d+)?\b/i, pricing: { inputUsdPerMillion: 15, outputUsdPerMillion: 75 } }, + { pattern: /\bsonnet 4(?: \d+)?\b/i, pricing: { inputUsdPerMillion: 3, outputUsdPerMillion: 15 } }, + { pattern: /\bsonnet 3 7\b/i, pricing: { inputUsdPerMillion: 3, outputUsdPerMillion: 15 } }, + { pattern: /\bsonnet 3 5\b/i, pricing: { inputUsdPerMillion: 3, outputUsdPerMillion: 15 } }, + { pattern: /\bhaiku 4(?: \d+)?\b/i, pricing: { inputUsdPerMillion: 1, outputUsdPerMillion: 5 } }, + { pattern: /\bhaiku 3 5\b/i, pricing: { inputUsdPerMillion: 0.8, outputUsdPerMillion: 4 } }, + // Enterprise plan aliases (e.g. opusplan, sonnetplan, haikuplan) + { pattern: /\bopusplan\b/i, pricing: { inputUsdPerMillion: 15, outputUsdPerMillion: 75 } }, + { pattern: /\bsonnetplan\b/i, pricing: { inputUsdPerMillion: 3, outputUsdPerMillion: 15 } }, + { pattern: /\bhaikuplan\b/i, pricing: { inputUsdPerMillion: 0.8, outputUsdPerMillion: 4 } }, +]; +function normalizeModelName(modelName) { + return modelName + .toLowerCase() + .replace(/^claude\s+/, '') + .replace(/\([^)]*\)/g, ' ') + .replace(/[._-]+/g, ' ') + .replace(/\s+/g, ' ') + .trim(); +} +function matchAnthropicPricing(modelName) { + const normalized = normalizeModelName(modelName); + for (const entry of ANTHROPIC_MODEL_PRICING) { + if (entry.pattern.test(normalized)) { + return entry.pricing; + } + } + return null; +} +function calculateUsd(tokens, usdPerMillion) { + return (tokens * usdPerMillion) / TOKENS_PER_MILLION; +} +function getAnthropicPricing(stdin) { + const candidates = [ + stdin.model?.display_name?.trim(), + stdin.model?.id?.trim(), + ]; + for (const candidate of candidates) { + if (!candidate) { + continue; + } + const pricing = matchAnthropicPricing(candidate); + if (pricing) { + return pricing; + } + } + return null; +} +export function estimateSessionCost(stdin, sessionTokens) { + if (!sessionTokens) { + return null; + } + if (isBedrockModelId(stdin.model?.id)) { + return null; + } + if (isVertexModelId(stdin.model?.id)) { + return null; + } + const pricing = getAnthropicPricing(stdin); + if (!pricing) { + return null; + } + const totalTokens = sessionTokens.inputTokens + + sessionTokens.cacheCreationTokens + + sessionTokens.cacheReadTokens + + sessionTokens.outputTokens; + if (totalTokens === 0) { + return null; + } + const inputUsd = calculateUsd(sessionTokens.inputTokens, pricing.inputUsdPerMillion); + const cacheCreationUsd = calculateUsd(sessionTokens.cacheCreationTokens, pricing.inputUsdPerMillion * CACHE_WRITE_MULTIPLIER); + const cacheReadUsd = calculateUsd(sessionTokens.cacheReadTokens, pricing.inputUsdPerMillion * CACHE_READ_MULTIPLIER); + const outputUsd = calculateUsd(sessionTokens.outputTokens, pricing.outputUsdPerMillion); + return { + totalUsd: inputUsd + cacheCreationUsd + cacheReadUsd + outputUsd, + inputUsd, + cacheCreationUsd, + cacheReadUsd, + outputUsd, + }; +} +function getNativeCostUsd(stdin) { + const nativeCost = stdin.cost?.total_cost_usd; + if (typeof nativeCost !== 'number' || !Number.isFinite(nativeCost)) { + return null; + } + if (isBedrockModelId(stdin.model?.id)) { + return null; + } + if (isVertexModelId(stdin.model?.id)) { + return null; + } + return nativeCost; +} +export function resolveSessionCost(stdin, sessionTokens) { + const nativeCostUsd = getNativeCostUsd(stdin); + if (nativeCostUsd !== null) { + return { + totalUsd: nativeCostUsd, + source: 'native', + }; + } + const estimate = estimateSessionCost(stdin, sessionTokens); + if (!estimate) { + return null; + } + return { + totalUsd: estimate.totalUsd, + source: 'estimate', + }; +} +export function formatUsd(amount) { + if (amount >= 1) { + return `$${amount.toFixed(2)}`; + } + if (amount >= 0.1) { + return `$${amount.toFixed(3)}`; + } + return `$${amount.toFixed(4)}`; +} +//# sourceMappingURL=cost.js.map \ No newline at end of file diff --git a/back2base-container/vendor/claude-hud/debug.js b/back2base-container/vendor/claude-hud/debug.js new file mode 100644 index 0000000..962097a --- /dev/null +++ b/back2base-container/vendor/claude-hud/debug.js @@ -0,0 +1,15 @@ +// Shared debug logging utility +// Enable via: DEBUG=claude-hud or DEBUG=* +const DEBUG = process.env.DEBUG?.includes('claude-hud') || process.env.DEBUG === '*'; +/** + * Create a namespaced debug logger + * @param namespace - Tag for log messages (e.g., 'config', 'usage') + */ +export function createDebug(namespace) { + return function debug(msg, ...args) { + if (DEBUG) { + console.error(`[claude-hud:${namespace}] ${msg}`, ...args); + } + }; +} +//# sourceMappingURL=debug.js.map \ No newline at end of file diff --git a/back2base-container/vendor/claude-hud/effort.js b/back2base-container/vendor/claude-hud/effort.js new file mode 100644 index 0000000..d2f1f85 --- /dev/null +++ b/back2base-container/vendor/claude-hud/effort.js @@ -0,0 +1,71 @@ +import { execFileSync } from 'node:child_process'; +const KNOWN_SYMBOLS = { + low: '○', + medium: '◔', + high: '◑', + xhigh: '◕', + max: '●', +}; +/** + * Resolve the current session's effort level. + * + * Resolution order (matches `extractEffortString` below): + * 1. stdin.effort as non-empty string — original PR #471 future-proofed path. + * 2. stdin.effort as object with string `level` — Claude Code 2.1.115+ schema + * (e.g., `{ "level": "max" }`). + * 3. Parent process CLI args — `--effort` flag captured from ppid. + * 4. null. + * + * Non-matching inputs (numbers, booleans, arrays, objects without a string + * `level`) fall through to step 3 rather than crashing. + */ +export function resolveEffortLevel(stdinEffort) { + const fromStdin = extractEffortString(stdinEffort); + if (fromStdin) { + return formatEffort(fromStdin); + } + const cliEffort = readParentProcessEffort(); + if (cliEffort) { + return formatEffort(cliEffort); + } + return null; +} +function extractEffortString(value) { + if (typeof value === 'string') { + return value.length > 0 ? value : null; + } + if (value && typeof value === 'object' && !Array.isArray(value)) { + const level = value.level; + if (typeof level === 'string' && level.length > 0) { + return level; + } + } + return null; +} +function formatEffort(level) { + const normalized = level.toLowerCase().trim(); + const symbol = KNOWN_SYMBOLS[normalized] ?? ''; + return { level: normalized, symbol }; +} +function readParentProcessEffort() { + if (process.platform === 'win32') { + return null; + } + try { + const ppid = process.ppid; + if (!ppid || ppid <= 1) { + return null; + } + const output = execFileSync('ps', ['-o', 'args=', '-p', String(ppid)], { + encoding: 'utf8', + timeout: 500, + stdio: ['pipe', 'pipe', 'pipe'], + }).trim(); + const match = output.match(/--effort[= ]+(\w+)/); + return match?.[1] ?? null; + } + catch { + return null; + } +} +//# sourceMappingURL=effort.js.map \ No newline at end of file diff --git a/back2base-container/vendor/claude-hud/external-usage.js b/back2base-container/vendor/claude-hud/external-usage.js new file mode 100644 index 0000000..b93ee07 --- /dev/null +++ b/back2base-container/vendor/claude-hud/external-usage.js @@ -0,0 +1,92 @@ +import * as fs from 'node:fs'; +const MAX_BALANCE_LABEL_LENGTH = 50; +function parseUsagePercent(value) { + if (typeof value !== 'number' || !Number.isFinite(value)) { + return null; + } + return Math.round(Math.min(100, Math.max(0, value))); +} +function sanitizeBalanceLabel(value) { + if (typeof value !== 'string') { + return null; + } + const sanitized = value + .replace(/\x1B\[[0-?]*[ -/]*[@-~]/g, '') + .replace(/\x1B\][^\x07\x1B]*(?:\x07|\x1B\\)/g, '') + .replace(/\x1B[@-Z\\-_]/g, '') + .replace(/[\u0000-\u001F\u007F-\u009F]/g, '') + .replace(/[\u061C\u200E\u200F\u202A-\u202E\u2066-\u2069\u206A-\u206F]/g, '') + .trim(); + if (!sanitized) { + return null; + } + if (sanitized.length <= MAX_BALANCE_LABEL_LENGTH) { + return sanitized; + } + return `${sanitized.slice(0, MAX_BALANCE_LABEL_LENGTH - 3)}...`; +} +function parseDateValue(value) { + if (typeof value === 'number') { + if (!Number.isFinite(value) || value <= 0) { + return null; + } + const millis = value > 1e12 ? value : value * 1000; + const date = new Date(millis); + return Number.isNaN(date.getTime()) ? null : date; + } + if (typeof value === 'string' && value.trim()) { + const date = new Date(value); + return Number.isNaN(date.getTime()) ? null : date; + } + return null; +} +function parseUpdatedAt(value) { + const date = parseDateValue(value); + return date ? date.getTime() : null; +} +export function getUsageFromExternalSnapshot(config, now = Date.now()) { + const snapshotPath = config.display.externalUsagePath; + if (!snapshotPath) { + return null; + } + try { + const raw = fs.readFileSync(snapshotPath, 'utf8'); + const parsed = JSON.parse(raw); + const updatedAt = parseUpdatedAt(parsed.updated_at); + if (updatedAt === null) { + return null; + } + const freshnessMs = config.display.externalUsageFreshnessMs; + if (now - updatedAt > freshnessMs) { + return null; + } + const fiveHour = parseUsagePercent(parsed.five_hour?.used_percentage); + const sevenDay = parseUsagePercent(parsed.seven_day?.used_percentage); + const balanceLabel = sanitizeBalanceLabel(parsed.balance_label); + if (fiveHour === null && sevenDay === null && balanceLabel === null) { + return null; + } + const fiveHourResetAt = parseDateValue(parsed.five_hour?.resets_at); + const sevenDayResetAt = parseDateValue(parsed.seven_day?.resets_at); + if (parsed.five_hour && parsed.five_hour.resets_at != null && fiveHourResetAt === null) { + return null; + } + if (parsed.seven_day && parsed.seven_day.resets_at != null && sevenDayResetAt === null) { + return null; + } + const usage = { + fiveHour, + sevenDay, + fiveHourResetAt, + sevenDayResetAt, + }; + if (balanceLabel !== null) { + usage.balanceLabel = balanceLabel; + } + return usage; + } + catch { + return null; + } +} +//# sourceMappingURL=external-usage.js.map \ No newline at end of file diff --git a/back2base-container/vendor/claude-hud/extra-cmd.js b/back2base-container/vendor/claude-hud/extra-cmd.js new file mode 100644 index 0000000..3438f0a --- /dev/null +++ b/back2base-container/vendor/claude-hud/extra-cmd.js @@ -0,0 +1,103 @@ +import { exec } from 'node:child_process'; +import { promisify } from 'node:util'; +const execAsync = promisify(exec); +const MAX_BUFFER = 10 * 1024; // 10KB - plenty for a label +const MAX_LABEL_LENGTH = 50; +const TIMEOUT_MS = 3000; +const isDebug = process.env.DEBUG?.includes('claude-hud') ?? false; +function debug(message) { + if (isDebug) { + console.error(`[claude-hud:extra-cmd] ${message}`); + } +} +/** + * Sanitize output to prevent terminal escape injection. + * Strips ANSI escapes, OSC sequences, control characters, and bidi controls. + */ +export function sanitize(input) { + return input + .replace(/\x1B\[[0-?]*[ -/]*[@-~]/g, '') // CSI sequences + .replace(/\x1B\][^\x07\x1B]*(?:\x07|\x1B\\)/g, '') // OSC sequences + .replace(/\x1B[@-Z\\-_]/g, '') // 7-bit C1 / ESC Fe + .replace(/[\u0000-\u001F\u007F-\u009F]/g, '') // C0/C1 controls + .replace(/[\u061C\u200E\u200F\u202A-\u202E\u2066-\u2069\u206A-\u206F]/g, ''); // bidi +} +/** + * Parse --extra-cmd argument from process.argv + * Supports both: --extra-cmd "command" and --extra-cmd="command" + */ +export function parseExtraCmdArg(argv = process.argv) { + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]; + // Handle --extra-cmd=value syntax + if (arg.startsWith('--extra-cmd=')) { + const value = arg.slice('--extra-cmd='.length); + if (value === '') { + debug('Warning: --extra-cmd value is empty, ignoring'); + return null; + } + return value; + } + // Handle --extra-cmd value syntax + if (arg === '--extra-cmd') { + if (i + 1 >= argv.length) { + debug('Warning: --extra-cmd specified but no value provided'); + return null; + } + const value = argv[i + 1]; + if (value === '') { + debug('Warning: --extra-cmd value is empty, ignoring'); + return null; + } + return value; + } + } + return null; +} +/** + * Execute a command and parse JSON output expecting { label: string } + * Returns null on any error (timeout, parse failure, missing label) + * + * SECURITY NOTE: The cmd parameter is sourced exclusively from CLI arguments + * (--extra-cmd) typed by the user. Since the user controls their own shell, + * shell injection is not a concern here - it's intentional user input. + */ +export async function runExtraCmd(cmd, timeout = TIMEOUT_MS) { + try { + const { stdout } = await execAsync(cmd, { + timeout, + maxBuffer: MAX_BUFFER, + }); + const data = JSON.parse(stdout.trim()); + if (typeof data === 'object' && + data !== null && + 'label' in data && + typeof data.label === 'string') { + let label = sanitize(data.label); + if (label.length > MAX_LABEL_LENGTH) { + label = label.slice(0, MAX_LABEL_LENGTH - 1) + '…'; + } + return label; + } + debug(`Command output missing 'label' field or invalid type: ${JSON.stringify(data)}`); + return null; + } + catch (err) { + if (err instanceof Error) { + if (err.message.includes('TIMEOUT') || err.message.includes('killed')) { + debug(`Command timed out after ${timeout}ms: ${cmd}`); + } + else if (err instanceof SyntaxError) { + debug(`Failed to parse JSON output: ${err.message}`); + } + else { + debug(`Command failed: ${err.message}`); + } + } + else { + debug(`Command failed with unknown error`); + } + return null; + } +} +//# sourceMappingURL=extra-cmd.js.map \ No newline at end of file diff --git a/back2base-container/vendor/claude-hud/git.js b/back2base-container/vendor/claude-hud/git.js new file mode 100644 index 0000000..9a5a562 --- /dev/null +++ b/back2base-container/vendor/claude-hud/git.js @@ -0,0 +1,196 @@ +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; +const execFileAsync = promisify(execFile); +export async function getGitBranch(cwd) { + if (!cwd) + return null; + try { + const { stdout } = await execFileAsync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd, timeout: 1000, encoding: 'utf8' }); + return stdout.trim() || null; + } + catch { + return null; + } +} +export async function getGitStatus(cwd) { + if (!cwd) + return null; + try { + // Get branch name + const { stdout: branchOut } = await execFileAsync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd, timeout: 1000, encoding: 'utf8' }); + const branch = branchOut.trim(); + if (!branch) + return null; + // Check for dirty state and parse file stats + let isDirty = false; + let fileStats; + let lineDiff; + try { + const { stdout: statusOut } = await execFileAsync('git', ['-c', 'core.quotePath=false', '--no-optional-locks', 'status', '--porcelain'], { cwd, timeout: 1000, encoding: 'utf8' }); + const trimmed = statusOut.trim(); + isDirty = trimmed.length > 0; + if (isDirty) { + fileStats = parseFileStats(trimmed); + } + } + catch { + // Ignore errors, assume clean + } + // Get per-file and total line diffs + if (isDirty) { + try { + const { stdout: numstatOut } = await execFileAsync('git', ['-c', 'core.quotePath=false', 'diff', '--numstat', 'HEAD'], { cwd, timeout: 2000, encoding: 'utf8' }); + const trackedPaths = new Set(fileStats?.trackedFiles.map((file) => file.fullPath) ?? []); + const { totalDiff, perFileDiff } = parseNumstat(numstatOut, trackedPaths); + lineDiff = totalDiff; + if (fileStats) { + applyLineDiffsToFiles(fileStats.trackedFiles, perFileDiff); + } + } + catch { + // Ignore errors + } + } + // Get ahead/behind counts + let ahead = 0; + let behind = 0; + try { + const { stdout: revOut } = await execFileAsync('git', ['rev-list', '--left-right', '--count', '@{upstream}...HEAD'], { cwd, timeout: 1000, encoding: 'utf8' }); + const parts = revOut.trim().split(/\s+/); + if (parts.length === 2) { + behind = parseInt(parts[0], 10) || 0; + ahead = parseInt(parts[1], 10) || 0; + } + } + catch { + // No upstream or error, keep 0/0 + } + // Build GitHub branch URL from remote + let branchUrl; + try { + const { stdout: remoteOut } = await execFileAsync('git', ['remote', 'get-url', 'origin'], { cwd, timeout: 1000, encoding: 'utf8' }); + const remote = remoteOut.trim(); + const httpsBase = remote + .replace(/^git@github\.com:/, 'https://github.com/') + .replace(/^ssh:\/\/git@github\.com\//, 'https://github.com/') + .replace(/\.git$/, ''); + if (httpsBase.startsWith('https://github.com/')) { + branchUrl = `${httpsBase}/tree/${encodeURIComponent(branch)}`; + } + } + catch { + // No remote or not GitHub + } + return { branch, isDirty, ahead, behind, fileStats, lineDiff, branchUrl }; + } + catch { + return null; + } +} +/** + * Parse git status --porcelain output and count file stats (Starship-compatible format) + * Status codes: M=modified, A=added, D=deleted, ??=untracked + */ +function parseFileStats(porcelainOutput) { + const stats = { modified: 0, added: 0, deleted: 0, untracked: 0, trackedFiles: [] }; + const lines = porcelainOutput.split('\n').filter(Boolean); + for (const line of lines) { + if (line.length < 2) + continue; + const index = line[0]; // staged status + const worktree = line[1]; // unstaged status + if (line.startsWith('??')) { + stats.untracked++; + } + else if (index === 'A') { + stats.added++; + const fullPath = parsePorcelainPath(line.slice(2).trimStart()); + stats.trackedFiles.push({ basename: fullPath.split('/').pop() ?? fullPath, fullPath, type: 'added' }); + } + else if (index === 'D' || worktree === 'D') { + stats.deleted++; + const fullPath = parsePorcelainPath(line.slice(2).trimStart()); + stats.trackedFiles.push({ basename: fullPath.split('/').pop() ?? fullPath, fullPath, type: 'deleted' }); + } + else if (index === 'M' || worktree === 'M' || index === 'R' || index === 'C') { + // M=modified, R=renamed (counts as modified), C=copied (counts as modified) + stats.modified++; + // For renames, git porcelain shows "old -> new"; take the destination path + const fullPath = parsePorcelainPath(line.slice(2).trimStart().split(' -> ').pop() ?? line.slice(2).trimStart()); + stats.trackedFiles.push({ basename: fullPath.split('/').pop() ?? fullPath, fullPath, type: 'modified' }); + } + } + return stats; +} +function parsePorcelainPath(pathField) { + if (pathField.startsWith('"') && pathField.endsWith('"')) { + try { + return JSON.parse(pathField); + } + catch { + return pathField.slice(1, -1); + } + } + return pathField; +} +/** + * Extract the destination path from a numstat path field. + * + * For renames, `git diff --numstat` emits the path as `old => new` + * (sometimes with a shared directory prefix like `pkg/{old.ts => new.ts}`). + * `git status --porcelain` reports the renamed file under its destination + * only, so we key `perFileDiff` by the destination to make lookups match. + */ +function extractNumstatDestination(filePath) { + const braceMatch = filePath.match(/^(.*)\{(.*) => (.*)\}(.*)$/); + if (braceMatch) { + const [, prefix, , dest, suffix] = braceMatch; + return `${prefix}${dest}${suffix}`.replace(/\/{2,}/g, '/'); + } + const arrowIndex = filePath.indexOf(' => '); + if (arrowIndex !== -1) { + return filePath.slice(arrowIndex + 4); + } + return filePath; +} +function resolveNumstatPath(filePath, trackedPaths) { + if (trackedPaths.has(filePath)) { + return filePath; + } + const destinationPath = extractNumstatDestination(filePath); + if (destinationPath !== filePath && trackedPaths.has(destinationPath)) { + return destinationPath; + } + return filePath; +} +/** + * Parse `git diff --numstat HEAD` output. + * Returns total line diff and a map of fullPath -> LineDiff. + */ +function parseNumstat(numstatOutput, trackedPaths) { + const totalDiff = { added: 0, deleted: 0 }; + const perFileDiff = new Map(); + for (const line of numstatOutput.trim().split('\n').filter(Boolean)) { + const parts = line.split('\t'); + if (parts.length < 3) + continue; + const added = parseInt(parts[0], 10); + const deleted = parseInt(parts[1], 10); + const filePath = resolveNumstatPath(parts[2], trackedPaths); + if (Number.isNaN(added) || Number.isNaN(deleted)) + continue; // binary file + totalDiff.added += added; + totalDiff.deleted += deleted; + perFileDiff.set(filePath, { added, deleted }); + } + return { totalDiff, perFileDiff }; +} +function applyLineDiffsToFiles(files, perFileDiff) { + for (const file of files) { + const diff = perFileDiff.get(file.fullPath); + if (diff) { + file.lineDiff = diff; + } + } +} +//# sourceMappingURL=git.js.map \ No newline at end of file diff --git a/back2base-container/vendor/claude-hud/i18n/en.js b/back2base-container/vendor/claude-hud/i18n/en.js new file mode 100644 index 0000000..628618a --- /dev/null +++ b/back2base-container/vendor/claude-hud/i18n/en.js @@ -0,0 +1,34 @@ +export const en = { + // Labels + "label.context": "Context", + "label.usage": "Usage", + "label.weekly": "Weekly", + "label.approxRam": "Approx RAM", + "label.promptCache": "Cache", + "label.rules": "rules", + "label.hooks": "hooks", + "label.estimatedCost": "Est.", + "label.cost": "Cost", + "label.tokens": "Tokens", + "label.sessionStarted": "Started", + "label.lastReply": "Last reply", + // Status + "status.limitReached": "Limit reached", + "status.allTodosComplete": "All todos complete", + "status.expired": "expired", + // Format + "format.resets": "resets", + "format.resetsIn": "resets in", + "format.at": "at", + "format.in": "in", + "format.cache": "cache", + "format.out": "out", + "format.tok": "tok", + "format.tokPerSec": "tok/s", + "format.justNow": "just now", + "format.ago": "ago", + // Init + "init.initializing": "[claude-hud] Initializing...", + "init.macosNote": "[claude-hud] Note: On macOS, you may need to restart Claude Code for the HUD to appear.", +}; +//# sourceMappingURL=en.js.map \ No newline at end of file diff --git a/back2base-container/vendor/claude-hud/i18n/index.js b/back2base-container/vendor/claude-hud/i18n/index.js new file mode 100644 index 0000000..7c8ec04 --- /dev/null +++ b/back2base-container/vendor/claude-hud/i18n/index.js @@ -0,0 +1,14 @@ +import { en } from "./en.js"; +import { zh } from "./zh.js"; +const locales = { en, zh }; +let currentLanguage = "en"; +export function setLanguage(lang) { + currentLanguage = lang; +} +export function getLanguage() { + return currentLanguage; +} +export function t(key) { + return locales[currentLanguage][key] ?? locales.en[key] ?? key; +} +//# sourceMappingURL=index.js.map \ No newline at end of file diff --git a/back2base-container/vendor/claude-hud/i18n/types.js b/back2base-container/vendor/claude-hud/i18n/types.js new file mode 100644 index 0000000..718fd38 --- /dev/null +++ b/back2base-container/vendor/claude-hud/i18n/types.js @@ -0,0 +1,2 @@ +export {}; +//# sourceMappingURL=types.js.map \ No newline at end of file diff --git a/back2base-container/vendor/claude-hud/i18n/zh.js b/back2base-container/vendor/claude-hud/i18n/zh.js new file mode 100644 index 0000000..bfcf1ab --- /dev/null +++ b/back2base-container/vendor/claude-hud/i18n/zh.js @@ -0,0 +1,34 @@ +export const zh = { + // Labels + "label.context": "上下文", + "label.usage": "用量", + "label.weekly": "本周", + "label.approxRam": "内存", + "label.promptCache": "缓存", + "label.rules": "规则", + "label.hooks": "钩子", + "label.estimatedCost": "估算", + "label.cost": "费用", + "label.tokens": "令牌", + "label.sessionStarted": "开始", + "label.lastReply": "上次回复", + // Status + "status.limitReached": "已达上限", + "status.allTodosComplete": "全部完成", + "status.expired": "已过期", + // Format + "format.resets": "重置于", + "format.resetsIn": "重置剩余", + "format.at": "", + "format.in": "输入", + "format.cache": "缓存", + "format.out": "输出", + "format.tok": "令牌", + "format.tokPerSec": "tok/s", + "format.justNow": "刚刚", + "format.ago": "前", + // Init + "init.initializing": "[claude-hud] 正在初始化...", + "init.macosNote": "[claude-hud] 注意:在 macOS 上,您可能需要重启 Claude Code 才能显示 HUD。", +}; +//# sourceMappingURL=zh.js.map \ No newline at end of file diff --git a/back2base-container/vendor/claude-hud/index.js b/back2base-container/vendor/claude-hud/index.js new file mode 100644 index 0000000..1ebce46 --- /dev/null +++ b/back2base-container/vendor/claude-hud/index.js @@ -0,0 +1,131 @@ +import { readStdin, getUsageFromStdin } from "./stdin.js"; +import { parseTranscript } from "./transcript.js"; +import { render } from "./render/index.js"; +import { countConfigs } from "./config-reader.js"; +import { getGitStatus } from "./git.js"; +import { loadConfig } from "./config.js"; +import { parseExtraCmdArg, runExtraCmd } from "./extra-cmd.js"; +import { getClaudeCodeVersion } from "./version.js"; +import { getMemoryUsage } from "./memory.js"; +import { resolveEffortLevel } from "./effort.js"; +import { applyContextWindowFallback } from "./context-cache.js"; +import { getUsageFromExternalSnapshot } from "./external-usage.js"; +import { setLanguage, t } from "./i18n/index.js"; +export { getUsageFromExternalSnapshot } from "./external-usage.js"; +import { fileURLToPath } from "node:url"; +import { realpathSync } from "node:fs"; +export async function main(overrides = {}) { + const deps = { + readStdin, + getUsageFromStdin, + getUsageFromExternalSnapshot, + parseTranscript, + countConfigs, + getGitStatus, + loadConfig, + parseExtraCmdArg, + runExtraCmd, + getClaudeCodeVersion, + getMemoryUsage, + applyContextWindowFallback, + render, + now: () => Date.now(), + log: console.log, + ...overrides, + }; + try { + const stdin = await deps.readStdin(); + if (!stdin) { + // Running without stdin - this happens during setup verification + const config = await deps.loadConfig(); + setLanguage(config.language); + const isMacOS = process.platform === "darwin"; + deps.log(t("init.initializing")); + if (isMacOS) { + deps.log(t("init.macosNote")); + } + return; + } + const transcriptPath = stdin.transcript_path ?? ""; + const transcript = await deps.parseTranscript(transcriptPath); + deps.applyContextWindowFallback(stdin, {}, transcript.sessionName, { + lastCompactBoundaryAt: transcript.lastCompactBoundaryAt, + lastCompactPostTokens: transcript.lastCompactPostTokens, + }); + const { claudeMdCount, rulesCount, mcpCount, hooksCount, outputStyle } = await deps.countConfigs(stdin.cwd); + const config = await deps.loadConfig(); + setLanguage(config.language); + const gitStatus = config.gitStatus.enabled + ? await deps.getGitStatus(stdin.cwd) + : null; + let usageData = null; + if (config.display.showUsage !== false) { + usageData = deps.getUsageFromStdin(stdin); + if (!usageData) { + usageData = deps.getUsageFromExternalSnapshot(config, deps.now()); + } + } + const extraCmd = deps.parseExtraCmdArg(); + const extraLabel = extraCmd ? await deps.runExtraCmd(extraCmd) : null; + const sessionDuration = formatSessionDuration(transcript.sessionStart, deps.now); + const claudeCodeVersion = config.display.showClaudeCodeVersion + ? await deps.getClaudeCodeVersion() + : undefined; + const effortInfo = config.display.showEffortLevel + ? resolveEffortLevel(stdin.effort) + : null; + const memoryUsage = config.display.showMemoryUsage && config.lineLayout === "expanded" + ? await deps.getMemoryUsage() + : null; + const ctx = { + stdin, + transcript, + claudeMdCount, + rulesCount, + mcpCount, + hooksCount, + sessionDuration, + gitStatus, + usageData, + memoryUsage, + config, + extraLabel, + outputStyle, + claudeCodeVersion, + effortLevel: effortInfo?.level, + effortSymbol: effortInfo?.symbol, + }; + deps.render(ctx); + } + catch (error) { + deps.log("[claude-hud] Error:", error instanceof Error ? error.message : "Unknown error"); + } +} +export function formatSessionDuration(sessionStart, now = () => Date.now()) { + if (!sessionStart) { + return ""; + } + const ms = now() - sessionStart.getTime(); + const mins = Math.floor(ms / 60000); + if (mins < 1) + return "<1m"; + if (mins < 60) + return `${mins}m`; + const hours = Math.floor(mins / 60); + const remainingMins = mins % 60; + return `${hours}h ${remainingMins}m`; +} +const scriptPath = fileURLToPath(import.meta.url); +const argvPath = process.argv[1]; +const isSamePath = (a, b) => { + try { + return realpathSync(a) === realpathSync(b); + } + catch { + return a === b; + } +}; +if (argvPath && isSamePath(argvPath, scriptPath)) { + void main(); +} +//# sourceMappingURL=index.js.map \ No newline at end of file diff --git a/back2base-container/vendor/claude-hud/memory.js b/back2base-container/vendor/claude-hud/memory.js new file mode 100644 index 0000000..e30ad53 --- /dev/null +++ b/back2base-container/vendor/claude-hud/memory.js @@ -0,0 +1,104 @@ +import os from 'node:os'; +import { execFileSync } from 'node:child_process'; +import { readFileSync } from 'node:fs'; +export function parseVmStat(output) { + const pageSizeMatch = output.match(/page size of (\d+) bytes/); + if (!pageSizeMatch) + return null; + const activeMatch = output.match(/Pages active:\s+(\d+)/); + const wiredMatch = output.match(/Pages wired down:\s+(\d+)/); + if (!activeMatch || !wiredMatch) + return null; + return { + pageSize: Number(pageSizeMatch[1]), + active: Number(activeMatch[1]), + wired: Number(wiredMatch[1]), + }; +} +export function parseLinuxMeminfo(output) { + const totalMatch = output.match(/^MemTotal:\s+(\d+)\s+kB/m); + const availMatch = output.match(/^MemAvailable:\s+(\d+)\s+kB/m); + if (!totalMatch || !availMatch) + return null; + const totalBytes = Number(totalMatch[1]) * 1024; + const freeBytes = Number(availMatch[1]) * 1024; + if (!Number.isFinite(totalBytes) || !Number.isFinite(freeBytes)) { + return null; + } + return { totalBytes, freeBytes }; +} +const readDefaultMemory = () => ({ + totalBytes: os.totalmem(), + freeBytes: os.freemem(), +}); +const readLinuxMemory = () => { + try { + const content = readFileSync('/proc/meminfo', 'utf8'); + return parseLinuxMeminfo(content) ?? readDefaultMemory(); + } + catch { + return readDefaultMemory(); + } +}; +const readMacOSMemory = () => { + try { + const output = execFileSync('/usr/bin/vm_stat', { + encoding: 'utf8', + timeout: 5000, + }); + const parsed = parseVmStat(output); + if (!parsed) + return readDefaultMemory(); + const totalBytes = os.totalmem(); + const usedBytes = (parsed.active + parsed.wired) * parsed.pageSize; + return { totalBytes, freeBytes: totalBytes - usedBytes }; + } + catch { + return readDefaultMemory(); + } +}; +let readMemory = process.platform === 'darwin' ? readMacOSMemory : + process.platform === 'linux' ? readLinuxMemory : + readDefaultMemory; +export async function getMemoryUsage() { + try { + const { totalBytes, freeBytes } = readMemory(); + if (!Number.isFinite(totalBytes) || totalBytes <= 0) { + return null; + } + const safeFreeBytes = Number.isFinite(freeBytes) + ? Math.min(Math.max(freeBytes, 0), totalBytes) + : 0; + const usedBytes = totalBytes - safeFreeBytes; + const usedPercent = Math.round((usedBytes / totalBytes) * 100); + return { + totalBytes, + usedBytes, + freeBytes: safeFreeBytes, + usedPercent: Math.min(Math.max(usedPercent, 0), 100), + }; + } + catch { + return null; + } +} +export function formatBytes(bytes) { + if (!Number.isFinite(bytes) || bytes <= 0) { + return '0 B'; + } + const units = ['B', 'KB', 'MB', 'GB', 'TB']; + let value = bytes; + let unitIndex = 0; + while (value >= 1024 && unitIndex < units.length - 1) { + value /= 1024; + unitIndex += 1; + } + const fractionDigits = value >= 10 || unitIndex === 0 ? 0 : 1; + return `${value.toFixed(fractionDigits)} ${units[unitIndex]}`; +} +export function _setMemoryReaderForTests(reader) { + readMemory = reader ?? (process.platform === 'darwin' ? readMacOSMemory : + process.platform === 'linux' ? readLinuxMemory : + readDefaultMemory); +} +//# sourceMappingURL=memory.js.map \ No newline at end of file diff --git a/back2base-container/vendor/claude-hud/package.json b/back2base-container/vendor/claude-hud/package.json new file mode 100644 index 0000000..a7e2c57 --- /dev/null +++ b/back2base-container/vendor/claude-hud/package.json @@ -0,0 +1,7 @@ +{ + "name": "claude-hud-vendored", + "version": "0.1.0", + "private": true, + "type": "module", + "main": "index.js" +} diff --git a/back2base-container/vendor/claude-hud/render/agents-line.js b/back2base-container/vendor/claude-hud/render/agents-line.js new file mode 100644 index 0000000..fecdd6f --- /dev/null +++ b/back2base-container/vendor/claude-hud/render/agents-line.js @@ -0,0 +1,71 @@ +import { yellow, green, magenta, label } from './colors.js'; +const MAX_RECENT_COMPLETED = 2; +const MAX_AGENTS_SHOWN = 3; +export function renderAgentsLine(ctx) { + const { agents } = ctx.transcript; + const colors = ctx.config?.colors; + const runningAgents = agents.filter((a) => a.status === 'running'); + const recentCompleted = agents + .filter((a) => a.status === 'completed') + .slice(-MAX_RECENT_COMPLETED); + const seen = new Set(); + const toShow = [...runningAgents, ...recentCompleted] + .filter((a) => { + if (seen.has(a.id)) + return false; + seen.add(a.id); + return true; + }) + .slice(-MAX_AGENTS_SHOWN); + if (toShow.length === 0) { + return null; + } + const lines = []; + for (const agent of toShow) { + lines.push(formatAgent(agent, colors)); + } + return lines.join('\n'); +} +function getStatusIcon(status) { + switch (status) { + case 'running': + return yellow('◐'); + case 'completed': + default: + return green('✓'); + } +} +function formatAgent(agent, colors) { + const statusIcon = getStatusIcon(agent.status); + const type = magenta(agent.type); + const model = agent.model ? label(`[${agent.model}]`, colors) : ''; + const desc = agent.description + ? label(`: ${truncateDesc(agent.description)}`, colors) + : ''; + const elapsed = formatElapsed(agent); + return `${statusIcon} ${type}${model ? ` ${model}` : ''}${desc} ${label(`(${elapsed})`, colors)}`; +} +function truncateDesc(desc, maxLen = 40) { + if (desc.length <= maxLen) + return desc; + return desc.slice(0, maxLen - 3) + '...'; +} +function formatElapsed(agent) { + const now = Date.now(); + const start = agent.startTime.getTime(); + const end = agent.endTime?.getTime() ?? now; + const ms = Math.max(0, end - start); + if (ms < 1000) + return '<1s'; + if (ms < 60_000) + return `${Math.round(ms / 1000)}s`; + const totalSecs = Math.floor(ms / 1000); + const mins = Math.floor(totalSecs / 60); + const secs = totalSecs % 60; + if (mins < 60) + return `${mins}m ${secs}s`; + const hours = Math.floor(mins / 60); + const remainingMins = mins % 60; + return `${hours}h ${remainingMins}m`; +} +//# sourceMappingURL=agents-line.js.map \ No newline at end of file diff --git a/back2base-container/vendor/claude-hud/render/colors.js b/back2base-container/vendor/claude-hud/render/colors.js new file mode 100644 index 0000000..6a6e367 --- /dev/null +++ b/back2base-container/vendor/claude-hud/render/colors.js @@ -0,0 +1,131 @@ +export const RESET = '\x1b[0m'; +const DIM = '\x1b[2m'; +const RED = '\x1b[31m'; +const GREEN = '\x1b[32m'; +const YELLOW = '\x1b[33m'; +const MAGENTA = '\x1b[35m'; +const CYAN = '\x1b[36m'; +const BRIGHT_BLUE = '\x1b[94m'; +const BRIGHT_MAGENTA = '\x1b[95m'; +const CLAUDE_ORANGE = '\x1b[38;5;208m'; +const ANSI_BY_NAME = { + dim: DIM, + red: RED, + green: GREEN, + yellow: YELLOW, + magenta: MAGENTA, + cyan: CYAN, + brightBlue: BRIGHT_BLUE, + brightMagenta: BRIGHT_MAGENTA, +}; +/** Convert a hex color string (#rrggbb) to a truecolor ANSI escape sequence. */ +function hexToAnsi(hex) { + const r = parseInt(hex.slice(1, 3), 16); + const g = parseInt(hex.slice(3, 5), 16); + const b = parseInt(hex.slice(5, 7), 16); + return `\x1b[38;2;${r};${g};${b}m`; +} +/** + * Resolve a color value to an ANSI escape sequence. + * Accepts named presets, 256-color indices (0-255), or hex strings (#rrggbb). + */ +function resolveAnsi(value, fallback) { + if (value === undefined || value === null) { + return fallback; + } + if (typeof value === 'number') { + return `\x1b[38;5;${value}m`; + } + if (typeof value === 'string' && value.startsWith('#') && value.length === 7) { + return hexToAnsi(value); + } + return ANSI_BY_NAME[value] ?? fallback; +} +function colorize(text, color) { + return `${color}${text}${RESET}`; +} +function withOverride(text, value, fallback) { + return colorize(text, resolveAnsi(value, fallback)); +} +export function green(text) { + return colorize(text, GREEN); +} +export function yellow(text) { + return colorize(text, YELLOW); +} +export function red(text) { + return colorize(text, RED); +} +export function cyan(text) { + return colorize(text, CYAN); +} +export function magenta(text) { + return colorize(text, MAGENTA); +} +export function dim(text) { + return colorize(text, DIM); +} +export function claudeOrange(text) { + return colorize(text, CLAUDE_ORANGE); +} +export function model(text, colors) { + return withOverride(text, colors?.model, CYAN); +} +export function project(text, colors) { + return withOverride(text, colors?.project, YELLOW); +} +export function git(text, colors) { + return withOverride(text, colors?.git, MAGENTA); +} +export function gitBranch(text, colors) { + return withOverride(text, colors?.gitBranch, CYAN); +} +export function label(text, colors) { + return withOverride(text, colors?.label, DIM); +} +export function custom(text, colors) { + return withOverride(text, colors?.custom, CLAUDE_ORANGE); +} +export function warning(text, colors) { + return colorize(text, resolveAnsi(colors?.warning, YELLOW)); +} +export function critical(text, colors) { + return colorize(text, resolveAnsi(colors?.critical, RED)); +} +export function getContextColor(percent, colors, thresholds) { + const critical = thresholds?.critical ?? 85; + const warning = thresholds?.warning ?? 70; + if (percent >= critical) + return resolveAnsi(colors?.critical, RED); + if (percent >= warning) + return resolveAnsi(colors?.warning, YELLOW); + return resolveAnsi(colors?.context, GREEN); +} +export function getQuotaColor(percent, colors) { + if (percent >= 90) + return resolveAnsi(colors?.critical, RED); + if (percent >= 75) + return resolveAnsi(colors?.usageWarning, BRIGHT_MAGENTA); + return resolveAnsi(colors?.usage, BRIGHT_BLUE); +} +export function quotaBar(percent, width = 10, colors) { + const safeWidth = Number.isFinite(width) ? Math.max(0, Math.round(width)) : 0; + const safePercent = Number.isFinite(percent) ? Math.min(100, Math.max(0, percent)) : 0; + const filled = Math.round((safePercent / 100) * safeWidth); + const empty = safeWidth - filled; + const color = getQuotaColor(safePercent, colors); + const filledChar = colors?.barFilled ?? '█'; + const emptyChar = colors?.barEmpty ?? '░'; + return `${color}${filledChar.repeat(filled)}${DIM}${emptyChar.repeat(empty)}${RESET}`; +} +export function coloredBar(percent, width = 10, colors, thresholds) { + const safeWidth = Number.isFinite(width) ? Math.max(0, Math.round(width)) : 0; + const safePercent = Number.isFinite(percent) ? Math.min(100, Math.max(0, percent)) : 0; + const filled = Math.round((safePercent / 100) * safeWidth); + const empty = safeWidth - filled; + const color = getContextColor(safePercent, colors, thresholds); + const filledChar = colors?.barFilled ?? '█'; + const emptyChar = colors?.barEmpty ?? '░'; + return `${color}${filledChar.repeat(filled)}${DIM}${emptyChar.repeat(empty)}${RESET}`; +} +//# sourceMappingURL=colors.js.map \ No newline at end of file diff --git a/back2base-container/vendor/claude-hud/render/format-reset-time.js b/back2base-container/vendor/claude-hud/render/format-reset-time.js new file mode 100644 index 0000000..ec94549 --- /dev/null +++ b/back2base-container/vendor/claude-hud/render/format-reset-time.js @@ -0,0 +1,59 @@ +import { t } from '../i18n/index.js'; +/** + * Formats a usage-window reset timestamp for display in the HUD. + * + * @param resetAt - The reset timestamp, or null if unknown. + * @param mode - How to express the time: + * - `'relative'` (default) — duration until reset, e.g. `2h 30m` + * - `'absolute'` — wall-clock time, e.g. `at 14:30` (locale-aware) + * - `'both'` — both combined, e.g. `2h 30m, at 14:30` (locale-aware) + * @returns A formatted string, or an empty string when the reset is in the past + * or the date is unknown. + */ +export function formatResetTime(resetAt, mode = 'relative') { + if (!resetAt) + return ''; + const now = new Date(); + const diffMs = resetAt.getTime() - now.getTime(); + if (diffMs <= 0) + return ''; + if (mode === 'relative') { + return formatRelative(diffMs); + } + const absolute = formatAbsolute(resetAt, now); + if (mode === 'absolute') { + return absolute; + } + // 'both' — comma separator avoids nested parentheses when the caller + // wraps the result in its own (...) parenthetical + return `${formatRelative(diffMs)}, ${absolute}`; +} +function formatRelative(diffMs) { + const diffMins = Math.ceil(diffMs / 60000); + if (diffMins < 60) { + return `${diffMins}m`; + } + const hours = Math.floor(diffMins / 60); + const mins = diffMins % 60; + if (hours >= 24) { + const days = Math.floor(hours / 24); + const remHours = hours % 24; + return remHours > 0 ? `${days}d ${remHours}h` : `${days}d`; + } + return mins > 0 ? `${hours}h ${mins}m` : `${hours}h`; +} +function formatAbsolute(resetAt, now) { + // The "at" prefix is i18n-aware. Locales that bake the preposition into + // "format.resets" (e.g. zh: "重置于") set "format.at" to "" so the time + // is returned bare ("14:30") and the preposition is supplied by the caller. + const at = t('format.at'); + const prefix = at ? `${at} ` : ''; + const timeStr = resetAt.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); + // Show the date only when the reset falls on a different calendar day + if (resetAt.toDateString() === now.toDateString()) { + return `${prefix}${timeStr}`; + } + const dateStr = resetAt.toLocaleDateString([], { month: 'short', day: 'numeric' }); + return `${prefix}${dateStr} ${timeStr}`; +} +//# sourceMappingURL=format-reset-time.js.map \ No newline at end of file diff --git a/back2base-container/vendor/claude-hud/render/index.js b/back2base-container/vendor/claude-hud/render/index.js new file mode 100644 index 0000000..87fe99c --- /dev/null +++ b/back2base-container/vendor/claude-hud/render/index.js @@ -0,0 +1,445 @@ +import { DEFAULT_ELEMENT_ORDER, DEFAULT_MERGE_GROUPS } from '../config.js'; +import { renderSessionLine } from './session-line.js'; +import { renderToolsLine } from './tools-line.js'; +import { renderAgentsLine } from './agents-line.js'; +import { renderTodosLine } from './todos-line.js'; +import { renderIdentityLine, renderProjectLine, renderAddedDirsLine, renderGitFilesLine, renderEnvironmentLine, renderPromptCacheLine, renderUsageLine, renderMemoryLine, renderSessionTokensLine, renderSessionTimeLine, } from './lines/index.js'; +import { dim, RESET } from './colors.js'; +import { getTerminalWidth, UNKNOWN_TERMINAL_WIDTH } from '../utils/terminal.js'; +import { codePointCellWidth, isCjkAmbiguousWide } from './width.js'; +// eslint-disable-next-line no-control-regex +const ANSI_ESCAPE_PATTERN = /^(?:\x1b\[[0-9;]*m|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\))/; +// eslint-disable-next-line no-control-regex +const ANSI_ESCAPE_GLOBAL = /(?:\x1b\[[0-9;]*m|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\))/g; +const GRAPHEME_SEGMENTER = typeof Intl.Segmenter === 'function' + ? new Intl.Segmenter(undefined, { granularity: 'grapheme' }) + : null; +function stripAnsi(str) { + return str.replace(ANSI_ESCAPE_GLOBAL, ''); +} +function splitAnsiTokens(str) { + const tokens = []; + let i = 0; + while (i < str.length) { + const ansiMatch = ANSI_ESCAPE_PATTERN.exec(str.slice(i)); + if (ansiMatch) { + tokens.push({ type: 'ansi', value: ansiMatch[0] }); + i += ansiMatch[0].length; + continue; + } + let j = i; + while (j < str.length) { + const nextAnsi = ANSI_ESCAPE_PATTERN.exec(str.slice(j)); + if (nextAnsi) { + break; + } + j += 1; + } + tokens.push({ type: 'text', value: str.slice(i, j) }); + i = j; + } + return tokens; +} +function segmentGraphemes(text) { + if (!text) { + return []; + } + if (!GRAPHEME_SEGMENTER) { + return Array.from(text); + } + return Array.from(GRAPHEME_SEGMENTER.segment(text), segment => segment.segment); +} +function graphemeWidth(grapheme, ambiguousWide) { + if (!grapheme || /^\p{Control}$/u.test(grapheme)) { + return 0; + } + // Emoji glyphs and ZWJ sequences generally render as double-width. + if (/\p{Extended_Pictographic}/u.test(grapheme)) { + return 2; + } + let hasVisibleBase = false; + let width = 0; + for (const char of Array.from(grapheme)) { + if (/^\p{Mark}$/u.test(char) || char === '\u200D' || char === '\uFE0F') { + continue; + } + hasVisibleBase = true; + const codePoint = char.codePointAt(0); + if (codePoint !== undefined) { + width = Math.max(width, codePointCellWidth(codePoint, ambiguousWide)); + } + else { + width = Math.max(width, 1); + } + } + return hasVisibleBase ? width : 0; +} +function visualLength(str) { + const ambiguousWide = isCjkAmbiguousWide(); + let width = 0; + for (const token of splitAnsiTokens(str)) { + if (token.type === 'ansi') { + continue; + } + for (const grapheme of segmentGraphemes(token.value)) { + width += graphemeWidth(grapheme, ambiguousWide); + } + } + return width; +} +function sliceVisible(str, maxVisible) { + if (maxVisible <= 0) { + return ''; + } + const ambiguousWide = isCjkAmbiguousWide(); + let result = ''; + let visibleWidth = 0; + let done = false; + let i = 0; + while (i < str.length && !done) { + const ansiMatch = ANSI_ESCAPE_PATTERN.exec(str.slice(i)); + if (ansiMatch) { + result += ansiMatch[0]; + i += ansiMatch[0].length; + continue; + } + let j = i; + while (j < str.length) { + const nextAnsi = ANSI_ESCAPE_PATTERN.exec(str.slice(j)); + if (nextAnsi) { + break; + } + j += 1; + } + const plainChunk = str.slice(i, j); + for (const grapheme of segmentGraphemes(plainChunk)) { + const graphemeCellWidth = graphemeWidth(grapheme, ambiguousWide); + if (visibleWidth + graphemeCellWidth > maxVisible) { + done = true; + break; + } + result += grapheme; + visibleWidth += graphemeCellWidth; + } + i = j; + } + return result; +} +function truncateToWidth(str, maxWidth) { + if (maxWidth <= 0 || visualLength(str) <= maxWidth) { + return str; + } + const suffix = maxWidth >= 3 ? '...' : '.'.repeat(maxWidth); + const keep = Math.max(0, maxWidth - suffix.length); + return `${sliceVisible(str, keep)}${suffix}${RESET}`; +} +function splitLineBySeparators(line) { + const segments = []; + const separators = []; + let currentStart = 0; + let i = 0; + while (i < line.length) { + const ansiMatch = ANSI_ESCAPE_PATTERN.exec(line.slice(i)); + if (ansiMatch) { + i += ansiMatch[0].length; + continue; + } + const separator = line.startsWith(' | ', i) + ? ' | ' + : (line.startsWith(' │ ', i) ? ' │ ' : null); + if (separator) { + segments.push(line.slice(currentStart, i)); + separators.push(separator); + i += separator.length; + currentStart = i; + continue; + } + i += 1; + } + segments.push(line.slice(currentStart)); + return { segments, separators }; +} +function splitWrapParts(line) { + const { segments, separators } = splitLineBySeparators(line); + if (segments.length === 0) { + return []; + } + let parts = [{ + separator: '', + segment: segments[0], + }]; + for (let segmentIndex = 1; segmentIndex < segments.length; segmentIndex += 1) { + parts.push({ + separator: separators[segmentIndex - 1] ?? ' | ', + segment: segments[segmentIndex], + }); + } + // Keep the leading [model | provider] block together. + // This avoids splitting inside the model badge while still splitting + // separators elsewhere in the line. + const firstVisible = stripAnsi(parts[0].segment).trimStart(); + const firstHasOpeningBracket = firstVisible.startsWith('['); + const firstHasClosingBracket = stripAnsi(parts[0].segment).includes(']'); + if (firstHasOpeningBracket && !firstHasClosingBracket && parts.length > 1) { + let mergedSegment = parts[0].segment; + let consumeIndex = 1; + while (consumeIndex < parts.length) { + const nextPart = parts[consumeIndex]; + mergedSegment += `${nextPart.separator}${nextPart.segment}`; + consumeIndex += 1; + if (stripAnsi(nextPart.segment).includes(']')) { + break; + } + } + parts = [ + { separator: '', segment: mergedSegment }, + ...parts.slice(consumeIndex), + ]; + } + return parts; +} +function wrapLineToWidth(line, maxWidth) { + if (maxWidth <= 0 || visualLength(line) <= maxWidth) { + return [line]; + } + const parts = splitWrapParts(line); + if (parts.length <= 1) { + return [truncateToWidth(line, maxWidth)]; + } + const wrapped = []; + let current = parts[0].segment; + for (const part of parts.slice(1)) { + const candidate = `${current}${part.separator}${part.segment}`; + if (visualLength(candidate) <= maxWidth) { + current = candidate; + continue; + } + wrapped.push(truncateToWidth(current, maxWidth)); + current = part.segment; + } + if (current) { + wrapped.push(truncateToWidth(current, maxWidth)); + } + return wrapped; +} +// `length` is a target visual width in cells. +// `─` (U+2500) is East Asian Ambiguous-width: rendered as 2 cells in CJK +// terminals and 1 cell elsewhere. Repeating it `length` times in CJK mode +// would double the visual width and force the terminal to wrap. +function makeSeparator(length) { + const cellsPerDash = isCjkAmbiguousWide() ? 2 : 1; + const repeats = Math.max(1, Math.floor(length / cellsPerDash)); + return dim('─'.repeat(repeats)); +} +const ACTIVITY_ELEMENTS = new Set(['tools', 'agents', 'todos']); +function buildMergeGroupLookup(mergeGroups) { + const lookup = new Map(); + for (const group of mergeGroups) { + const groupSet = new Set(group); + for (const element of group) { + if (!lookup.has(element)) { + lookup.set(element, groupSet); + } + } + } + return lookup; +} +function collectMergeSequence(elementOrder, startIndex, seen, group) { + const sequence = []; + for (let index = startIndex; index < elementOrder.length; index += 1) { + const element = elementOrder[index]; + if (seen.has(element) || !group.has(element)) { + break; + } + sequence.push(element); + } + return sequence; +} +function collectActivityLines(ctx) { + const activityLines = []; + const display = ctx.config?.display; + if (display?.showTools !== false) { + const toolsLine = renderToolsLine(ctx); + if (toolsLine) { + activityLines.push(toolsLine); + } + } + if (display?.showAgents !== false) { + const agentsLine = renderAgentsLine(ctx); + if (agentsLine) { + activityLines.push(agentsLine); + } + } + if (display?.showTodos !== false) { + const todosLine = renderTodosLine(ctx); + if (todosLine) { + activityLines.push(todosLine); + } + } + return activityLines; +} +function renderElementLine(ctx, element, options) { + const display = ctx.config?.display; + const alignProgressLabels = options?.alignProgressLabels ?? false; + switch (element) { + case 'project': + return renderProjectLine(ctx); + case 'addedDirs': + return renderAddedDirsLine(ctx); + case 'context': + return renderIdentityLine(ctx, alignProgressLabels); + case 'usage': + return renderUsageLine(ctx, alignProgressLabels); + case 'promptCache': + return renderPromptCacheLine(ctx); + case 'memory': + return renderMemoryLine(ctx); + case 'environment': + return renderEnvironmentLine(ctx); + case 'tools': + return display?.showTools === false ? null : renderToolsLine(ctx); + case 'agents': + return display?.showAgents === false ? null : renderAgentsLine(ctx); + case 'todos': + return display?.showTodos === false ? null : renderTodosLine(ctx); + case 'sessionTime': + return renderSessionTimeLine(ctx); + } +} +function renderCompact(ctx) { + const lines = []; + const sessionLine = renderSessionLine(ctx); + if (sessionLine) { + lines.push(sessionLine); + } + return lines; +} +function renderExpanded(ctx, terminalWidth = null) { + const elementOrder = ctx.config?.elementOrder ?? DEFAULT_ELEMENT_ORDER; + const mergeGroups = ctx.config?.display?.mergeGroups ?? DEFAULT_MERGE_GROUPS; + const mergeGroupLookup = buildMergeGroupLookup(mergeGroups); + const seen = new Set(); + const lines = []; + for (let index = 0; index < elementOrder.length; index += 1) { + const element = elementOrder[index]; + if (seen.has(element)) { + continue; + } + const mergeGroup = mergeGroupLookup.get(element); + if (mergeGroup) { + const mergeSequence = collectMergeSequence(elementOrder, index, seen, mergeGroup); + if (mergeSequence.length > 1) { + index += mergeSequence.length - 1; + for (const groupedElement of mergeSequence) { + seen.add(groupedElement); + } + const renderedGroupLines = mergeSequence + .map(groupedElement => ({ + element: groupedElement, + line: renderElementLine(ctx, groupedElement), + })) + .filter((entry) => typeof entry.line === 'string' && entry.line.length > 0); + if (renderedGroupLines.length > 1) { + const combinedLine = renderedGroupLines.map(({ line }) => line).join(' │ '); + const widthIsReal = terminalWidth !== UNKNOWN_TERMINAL_WIDTH; + const canCombine = !widthIsReal || visualLength(combinedLine) <= terminalWidth; + if (canCombine) { + lines.push({ + line: combinedLine, + isActivity: renderedGroupLines.some(({ element: groupedElement }) => ACTIVITY_ELEMENTS.has(groupedElement)), + }); + } + else { + for (const { element: groupedElement, line } of renderedGroupLines) { + const stackedLine = renderElementLine(ctx, groupedElement, { + alignProgressLabels: true, + }) ?? line; + lines.push({ + line: stackedLine, + isActivity: ACTIVITY_ELEMENTS.has(groupedElement), + }); + } + } + } + else if (renderedGroupLines.length === 1) { + const [{ element: groupedElement, line }] = renderedGroupLines; + lines.push({ + line, + isActivity: ACTIVITY_ELEMENTS.has(groupedElement), + }); + } + continue; + } + } + seen.add(element); + const line = renderElementLine(ctx, element); + if (!line) { + continue; + } + lines.push({ + line, + isActivity: ACTIVITY_ELEMENTS.has(element), + }); + } + // Git files line always goes last (pass width so it can hide itself if too narrow) + const gitFilesLine = renderGitFilesLine(ctx, terminalWidth); + if (gitFilesLine) { + lines.push({ line: gitFilesLine, isActivity: false }); + } + return lines; +} +export function render(ctx) { + const lineLayout = ctx.config?.lineLayout ?? 'expanded'; + const showSeparators = ctx.config?.showSeparators ?? false; + const detectedWidth = getTerminalWidth({ preferEnv: true, fallback: UNKNOWN_TERMINAL_WIDTH }); + const configuredMaxWidth = ctx.config?.maxWidth ?? UNKNOWN_TERMINAL_WIDTH; + const terminalWidth = ctx.config?.forceMaxWidth && configuredMaxWidth !== UNKNOWN_TERMINAL_WIDTH + ? configuredMaxWidth + : (detectedWidth ?? configuredMaxWidth ?? UNKNOWN_TERMINAL_WIDTH); + let lines; + if (lineLayout === 'expanded') { + const renderedLines = renderExpanded(ctx, terminalWidth); + lines = renderedLines.map(({ line }) => line); + // Session token usage (cumulative) + if (ctx.config?.display?.showSessionTokens) { + const sessionTokensLine = renderSessionTokensLine(ctx); + if (sessionTokensLine) { + lines.push(sessionTokensLine); + } + } + if (showSeparators) { + const firstActivityIndex = renderedLines.findIndex(({ isActivity }) => isActivity); + if (firstActivityIndex > 0) { + const separatorBaseWidth = Math.max(...renderedLines + .slice(0, firstActivityIndex) + .map(({ line }) => visualLength(line)), 20); + const separatorWidth = terminalWidth + ? Math.min(separatorBaseWidth, terminalWidth) + : separatorBaseWidth; + lines.splice(firstActivityIndex, 0, makeSeparator(separatorWidth)); + } + } + } + else { + const headerLines = renderCompact(ctx); + const activityLines = collectActivityLines(ctx); + lines = [...headerLines]; + if (showSeparators && activityLines.length > 0) { + const maxWidth = Math.max(...headerLines.map(visualLength), 20); + const separatorWidth = terminalWidth ? Math.min(maxWidth, terminalWidth) : maxWidth; + lines.push(makeSeparator(separatorWidth)); + } + lines.push(...activityLines); + } + const physicalLines = lines.flatMap(line => line.split('\n')); + // Only wrap when terminal width is real (known). When width is the + // UNKNOWN_TERMINAL_WIDTH fallback, wrapping would use an arbitrary value + // and produce incorrect line breaks. + const wrapWidth = terminalWidth !== UNKNOWN_TERMINAL_WIDTH ? (terminalWidth ?? 0) : 0; + const visibleLines = physicalLines.flatMap(line => wrapLineToWidth(line, wrapWidth)); + for (const line of visibleLines) { + const outputLine = `${RESET}${line}`; + console.log(outputLine); + } +} +//# sourceMappingURL=index.js.map \ No newline at end of file diff --git a/back2base-container/vendor/claude-hud/render/lines/added-dirs.js b/back2base-container/vendor/claude-hud/render/lines/added-dirs.js new file mode 100644 index 0000000..b23d36b --- /dev/null +++ b/back2base-container/vendor/claude-hud/render/lines/added-dirs.js @@ -0,0 +1,80 @@ +import * as path from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { dim, label } from '../colors.js'; +const CONTROL_AND_BIDI_PATTERN = new RegExp('[' + + '\\u0000-\\u001F\\u007F-\\u009F' + + '\\u061C\\u200E\\u200F' + + '\\u202A-\\u202E\\u2066-\\u2069\\u206A-\\u206F' + + ']', 'g'); +export function sanitize(value) { + return value.replace(CONTROL_AND_BIDI_PATTERN, ''); +} +export function basenameOf(dir) { + const segments = dir.split(/[/\\]/).filter(Boolean); + return segments[segments.length - 1] ?? dir; +} +export const MAX_RENDERED_ADDED_DIRS = 5; +export const MAX_ADDED_DIR_NAME_LEN = 24; +// Length is measured in UTF-16 code units, not grapheme clusters; a name +// of mostly 4-byte codepoints (emoji, rare CJK) may render slightly wider +// than MAX_ADDED_DIR_NAME_LEN. Acceptable simplification for a statusline. +export function truncateBasename(name) { + if (name.length <= MAX_ADDED_DIR_NAME_LEN) + return name; + return name.slice(0, MAX_ADDED_DIR_NAME_LEN - 1) + '…'; +} +export function normalizeAddedDirs(value) { + if (!Array.isArray(value)) + return []; + return value.filter((v) => typeof v === 'string' && + v.length > 0 && + sanitize(basenameOf(v)).length > 0); +} +function getFileHref(filePath) { + try { + return pathToFileURL(path.resolve(filePath)).toString(); + } + catch { + return null; + } +} +function hyperlink(uri, text) { + const esc = '\x1b'; + const st = '\\'; + return `${esc}]8;;${uri}${esc}${st}${text}${esc}]8;;${esc}${st}`; +} +function safeHyperlink(uri, text) { + if (!uri) + return text; + try { + const parsed = new URL(uri); + if (parsed.protocol !== 'file:') + return text; + return hyperlink(parsed.toString(), text); + } + catch { + return text; + } +} +export function renderAddedDirsLine(ctx) { + const display = ctx.config?.display; + if (display?.showAddedDirs === false) + return null; + if ((display?.addedDirsLayout ?? 'inline') !== 'line') + return null; + const dirs = normalizeAddedDirs(ctx.stdin.workspace?.added_dirs); + if (dirs.length === 0) + return null; + const colors = ctx.config?.colors; + const visible = dirs.slice(0, MAX_RENDERED_ADDED_DIRS); + const overflow = dirs.length - visible.length; + const rendered = visible.map((dir) => { + const name = truncateBasename(sanitize(basenameOf(dir))); + return safeHyperlink(getFileHref(dir), dim(name)); + }); + if (overflow > 0) { + rendered.push(dim(`+${overflow} more`)); + } + return `${label('Added dirs:', colors)} ${rendered.join(dim(', '))}`; +} +//# sourceMappingURL=added-dirs.js.map \ No newline at end of file diff --git a/back2base-container/vendor/claude-hud/render/lines/cost.js b/back2base-container/vendor/claude-hud/render/lines/cost.js new file mode 100644 index 0000000..ada1685 --- /dev/null +++ b/back2base-container/vendor/claude-hud/render/lines/cost.js @@ -0,0 +1,15 @@ +import { resolveSessionCost, formatUsd } from '../../cost.js'; +import { t } from '../../i18n/index.js'; +import { label } from '../colors.js'; +export function renderCostEstimate(ctx) { + if (ctx.config?.display?.showCost !== true) { + return null; + } + const cost = resolveSessionCost(ctx.stdin, ctx.transcript.sessionTokens); + if (!cost) { + return null; + } + const labelKey = cost.source === 'native' ? 'label.cost' : 'label.estimatedCost'; + return label(`${t(labelKey)} ${formatUsd(cost.totalUsd)}`, ctx.config?.colors); +} +//# sourceMappingURL=cost.js.map \ No newline at end of file diff --git a/back2base-container/vendor/claude-hud/render/lines/environment.js b/back2base-container/vendor/claude-hud/render/lines/environment.js new file mode 100644 index 0000000..8727a17 --- /dev/null +++ b/back2base-container/vendor/claude-hud/render/lines/environment.js @@ -0,0 +1,32 @@ +import { label } from "../colors.js"; +import { t } from "../../i18n/index.js"; +export function renderEnvironmentLine(ctx) { + const display = ctx.config?.display; + const totalCounts = ctx.claudeMdCount + ctx.rulesCount + ctx.mcpCount + ctx.hooksCount; + const threshold = display?.environmentThreshold ?? 0; + const showCounts = display?.showConfigCounts !== false; + const showOutputStyle = display?.showOutputStyle === true; + const parts = []; + if (showCounts && totalCounts >= threshold && totalCounts > 0) { + if (ctx.claudeMdCount > 0) { + parts.push(`${ctx.claudeMdCount} CLAUDE.md`); + } + if (ctx.rulesCount > 0) { + parts.push(`${ctx.rulesCount} ${t("label.rules")}`); + } + if (ctx.mcpCount > 0) { + parts.push(`${ctx.mcpCount} MCPs`); + } + if (ctx.hooksCount > 0) { + parts.push(`${ctx.hooksCount} ${t("label.hooks")}`); + } + } + if (showOutputStyle && ctx.outputStyle) { + parts.push(`style: ${ctx.outputStyle}`); + } + if (parts.length === 0) { + return null; + } + return label(parts.join(" | "), ctx.config?.colors); +} +//# sourceMappingURL=environment.js.map \ No newline at end of file diff --git a/back2base-container/vendor/claude-hud/render/lines/identity.js b/back2base-container/vendor/claude-hud/render/lines/identity.js new file mode 100644 index 0000000..7231a6b --- /dev/null +++ b/back2base-container/vendor/claude-hud/render/lines/identity.js @@ -0,0 +1,67 @@ +import { getContextPercent, getBufferedPercent, getTotalTokens, } from "../../stdin.js"; +import { coloredBar, label, getContextColor, RESET } from "../colors.js"; +import { getAdaptiveBarWidth } from "../../utils/terminal.js"; +import { t } from "../../i18n/index.js"; +import { progressLabel } from "./label-align.js"; +const DEBUG = process.env.DEBUG?.includes("claude-hud") || process.env.DEBUG === "*"; +export function renderIdentityLine(ctx, alignLabels = false) { + const rawPercent = getContextPercent(ctx.stdin); + const bufferedPercent = getBufferedPercent(ctx.stdin); + const autocompactMode = ctx.config?.display?.autocompactBuffer ?? "enabled"; + const percent = autocompactMode === "disabled" ? rawPercent : bufferedPercent; + const colors = ctx.config?.colors; + if (DEBUG && autocompactMode === "disabled") { + console.error(`[claude-hud:context] autocompactBuffer=disabled, showing raw ${rawPercent}% (buffered would be ${bufferedPercent}%)`); + } + const display = ctx.config?.display; + const contextThresholds = { + warning: display?.contextWarningThreshold, + critical: display?.contextCriticalThreshold, + }; + const contextValueMode = display?.contextValue ?? "percent"; + const contextValue = formatContextValue(ctx, percent, contextValueMode); + const contextValueDisplay = `${getContextColor(percent, colors, contextThresholds)}${contextValue}${RESET}`; + let line = display?.showContextBar !== false + ? `${progressLabel("label.context", colors, alignLabels)} ${coloredBar(percent, getAdaptiveBarWidth(), colors, contextThresholds)} ${contextValueDisplay}` + : `${progressLabel("label.context", colors, alignLabels)} ${contextValueDisplay}`; + if (display?.showTokenBreakdown !== false && percent >= (display?.contextCriticalThreshold ?? 85)) { + const usage = ctx.stdin.context_window?.current_usage; + if (usage) { + const input = formatTokens(usage.input_tokens ?? 0); + const cache = formatTokens((usage.cache_creation_input_tokens ?? 0) + + (usage.cache_read_input_tokens ?? 0)); + line += label(` (${t("format.in")}: ${input}, ${t("format.cache")}: ${cache})`, colors); + } + } + return line; +} +function formatTokens(n) { + if (n >= 1000000) { + return `${(n / 1000000).toFixed(1)}M`; + } + if (n >= 1000) { + return `${(n / 1000).toFixed(0)}k`; + } + return n.toString(); +} +function formatContextValue(ctx, percent, mode) { + const totalTokens = getTotalTokens(ctx.stdin); + const size = ctx.stdin.context_window?.context_window_size ?? 0; + if (mode === "tokens") { + if (size > 0) { + return `${formatTokens(totalTokens)}/${formatTokens(size)}`; + } + return formatTokens(totalTokens); + } + if (mode === "both") { + if (size > 0) { + return `${percent}% (${formatTokens(totalTokens)}/${formatTokens(size)})`; + } + return `${percent}%`; + } + if (mode === "remaining") { + return `${Math.max(0, 100 - percent)}%`; + } + return `${percent}%`; +} +//# sourceMappingURL=identity.js.map \ No newline at end of file diff --git a/back2base-container/vendor/claude-hud/render/lines/index.js b/back2base-container/vendor/claude-hud/render/lines/index.js new file mode 100644 index 0000000..5c1f879 --- /dev/null +++ b/back2base-container/vendor/claude-hud/render/lines/index.js @@ -0,0 +1,10 @@ +export { renderIdentityLine } from './identity.js'; +export { renderProjectLine, renderGitFilesLine } from './project.js'; +export { renderAddedDirsLine } from './added-dirs.js'; +export { renderEnvironmentLine } from './environment.js'; +export { renderPromptCacheLine, formatPromptCacheCountdown } from './prompt-cache.js'; +export { renderUsageLine } from './usage.js'; +export { renderMemoryLine } from './memory.js'; +export { renderSessionTokensLine } from './session-tokens.js'; +export { renderSessionTimeLine } from './session-time.js'; +//# sourceMappingURL=index.js.map \ No newline at end of file diff --git a/back2base-container/vendor/claude-hud/render/lines/label-align.js b/back2base-container/vendor/claude-hud/render/lines/label-align.js new file mode 100644 index 0000000..fdc9344 --- /dev/null +++ b/back2base-container/vendor/claude-hud/render/lines/label-align.js @@ -0,0 +1,55 @@ +import { label } from "../colors.js"; +import { t } from "../../i18n/index.js"; +import { codePointCellWidth, isCjkAmbiguousWide } from "../width.js"; +/** Label keys that should be aligned when rendered on separate lines. */ +const PROGRESS_LABEL_KEYS = [ + "label.context", + "label.usage", + "label.weekly", +]; +/** + * Compute the visual width of a plain-text string (no ANSI). + * CJK ideographs count as 2 cells; ASCII characters count as 1. + * In CJK locales, East Asian Ambiguous-width chars also count as 2. + */ +function plainTextWidth(str) { + const ambiguousWide = isCjkAmbiguousWide(); + let width = 0; + for (const char of str) { + const cp = char.codePointAt(0); + if (cp !== undefined) { + width += codePointCellWidth(cp, ambiguousWide); + } + else { + width += 1; + } + } + return width; +} +/** Compute the max visual width across the three progress-bar labels. */ +function maxLabelWidth() { + let max = 0; + for (const key of PROGRESS_LABEL_KEYS) { + const w = plainTextWidth(t(key)); + if (w > max) + max = w; + } + return max; +} +/** + * Return a label whose visible text is right-padded to align with the widest + * progress-bar label in the current locale, then wrapped with the `label()` + * ANSI helper. + */ +export function paddedLabel(key, colors) { + const text = t(key); + const pad = maxLabelWidth() - plainTextWidth(text); + const padded = pad > 0 ? text + " ".repeat(pad) : text; + return label(padded, colors); +} +export function progressLabel(key, colors, align = false) { + return align ? paddedLabel(key, colors) : label(t(key), colors); +} +// Exported for testing only. +export { plainTextWidth as _plainTextWidth, maxLabelWidth as _maxLabelWidth }; +//# sourceMappingURL=label-align.js.map \ No newline at end of file diff --git a/back2base-container/vendor/claude-hud/render/lines/memory.js b/back2base-container/vendor/claude-hud/render/lines/memory.js new file mode 100644 index 0000000..7ad73f2 --- /dev/null +++ b/back2base-container/vendor/claude-hud/render/lines/memory.js @@ -0,0 +1,23 @@ +import { formatBytes } from "../../memory.js"; +import { label, getQuotaColor, quotaBar, RESET } from "../colors.js"; +import { getAdaptiveBarWidth } from "../../utils/terminal.js"; +import { t } from "../../i18n/index.js"; +export function renderMemoryLine(ctx) { + const display = ctx.config?.display; + const colors = ctx.config?.colors; + if (ctx.config?.lineLayout !== "expanded") { + return null; + } + if (display?.showMemoryUsage !== true) { + return null; + } + if (!ctx.memoryUsage) { + return null; + } + const memoryLabel = label(t("label.approxRam"), colors); + const percentColor = getQuotaColor(ctx.memoryUsage.usedPercent, colors); + const percent = `${percentColor}${ctx.memoryUsage.usedPercent}%${RESET}`; + const bar = quotaBar(ctx.memoryUsage.usedPercent, getAdaptiveBarWidth(), colors); + return `${memoryLabel} ${bar} ${formatBytes(ctx.memoryUsage.usedBytes)} / ${formatBytes(ctx.memoryUsage.totalBytes)} (${percent})`; +} +//# sourceMappingURL=memory.js.map \ No newline at end of file diff --git a/back2base-container/vendor/claude-hud/render/lines/project.js b/back2base-container/vendor/claude-hud/render/lines/project.js new file mode 100644 index 0000000..aec6862 --- /dev/null +++ b/back2base-container/vendor/claude-hud/render/lines/project.js @@ -0,0 +1,235 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { getModelName, formatModelName, getProviderLabel } from '../../stdin.js'; +import { getOutputSpeed } from '../../speed-tracker.js'; +import { git as gitColor, gitBranch as gitBranchColor, warning as warningColor, critical as criticalColor, label, model as modelColor, project as projectColor, red, green, yellow, dim, custom as customColor } from '../colors.js'; +import { t } from '../../i18n/index.js'; +import { renderCostEstimate } from './cost.js'; +import { normalizeAddedDirs, sanitize as sanitizeDisplayText, basenameOf, truncateBasename, MAX_RENDERED_ADDED_DIRS } from './added-dirs.js'; +function hyperlink(uri, text) { + const esc = '\x1b'; + const st = '\\'; + return `${esc}]8;;${uri}${esc}${st}${text}${esc}]8;;${esc}${st}`; +} +function getFileHref(filePath) { + try { + return pathToFileURL(path.resolve(filePath)).toString(); + } + catch { + return null; + } +} +function resolvePathWithinCwd(cwd, candidatePath) { + const resolvedCwd = path.resolve(cwd); + const resolvedPath = path.resolve(cwd, candidatePath); + const relative = path.relative(resolvedCwd, resolvedPath); + if (relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative))) { + return resolvedPath; + } + return null; +} +function safeHyperlink(uri, text) { + if (!uri) { + return text; + } + const sanitizedUri = sanitizeDisplayText(uri); + try { + const parsed = new URL(sanitizedUri); + if (parsed.protocol !== 'https:' && parsed.protocol !== 'file:') { + return text; + } + return hyperlink(parsed.toString(), text); + } + catch { + return text; + } +} +export function renderProjectLine(ctx) { + const display = ctx.config?.display; + const colors = ctx.config?.colors; + const parts = []; + if (display?.showModel !== false) { + const model = formatModelName(getModelName(ctx.stdin), ctx.config?.display?.modelFormat, ctx.config?.display?.modelOverride); + const providerLabel = getProviderLabel(ctx.stdin); + const modelQualifier = providerLabel ?? undefined; + let modelDisplay = modelQualifier ? `${model} | ${modelQualifier}` : model; + if (ctx.effortLevel && ctx.effortSymbol) { + modelDisplay += ` ${ctx.effortSymbol} ${ctx.effortLevel}`; + } + else if (ctx.effortLevel) { + modelDisplay += ` ${ctx.effortLevel}`; + } + parts.push(modelColor(`[${modelDisplay}]`, colors)); + } + let projectPart = null; + if (display?.showProject !== false && ctx.stdin.cwd) { + const segments = ctx.stdin.cwd.split(/[/\\]/).filter(Boolean); + const pathLevels = ctx.config?.pathLevels ?? 1; + const projectPath = sanitizeDisplayText(segments.length > 0 ? segments.slice(-pathLevels).join('/') : '/'); + const coloredProject = projectColor(projectPath, colors); + projectPart = safeHyperlink(getFileHref(ctx.stdin.cwd), coloredProject); + } + let addedDirsPart = null; + const addedDirs = normalizeAddedDirs(ctx.stdin.workspace?.added_dirs); + const addedDirsLayout = display?.addedDirsLayout ?? 'inline'; + if (display?.showAddedDirs !== false && addedDirsLayout === 'inline' && addedDirs.length > 0) { + const visible = addedDirs.slice(0, MAX_RENDERED_ADDED_DIRS); + const overflow = addedDirs.length - visible.length; + const rendered = visible.map((dir) => { + const name = truncateBasename(sanitizeDisplayText(basenameOf(dir))); + const text = dim(`+${name}`); + return safeHyperlink(getFileHref(dir), text); + }); + if (overflow > 0) { + rendered.push(dim(`+${overflow} more`)); + } + addedDirsPart = rendered.join(' '); + } + let gitPart = ''; + const gitConfig = ctx.config?.gitStatus; + const showGit = gitConfig?.enabled ?? true; + const branchOverflow = gitConfig?.branchOverflow ?? 'truncate'; + if (showGit && ctx.gitStatus) { + const branchText = sanitizeDisplayText(ctx.gitStatus.branch + ((gitConfig?.showDirty ?? true) && ctx.gitStatus.isDirty ? '*' : '')); + const coloredBranch = gitBranchColor(branchText, colors); + const linkedBranch = safeHyperlink(ctx.gitStatus.branchUrl, coloredBranch); + const gitInner = [linkedBranch]; + if (gitConfig?.showAheadBehind) { + if (ctx.gitStatus.ahead > 0) { + gitInner.push(formatAheadCount(ctx.gitStatus.ahead, gitConfig, colors)); + } + if (ctx.gitStatus.behind > 0) + gitInner.push(gitBranchColor(`↓${ctx.gitStatus.behind}`, colors)); + } + if (gitConfig?.showFileStats && ctx.gitStatus.lineDiff) { + const { added, deleted } = ctx.gitStatus.lineDiff; + const diffParts = []; + if (added > 0) + diffParts.push(green(`+${added}`)); + if (deleted > 0) + diffParts.push(red(`-${deleted}`)); + if (diffParts.length > 0) { + gitInner.push(`[${diffParts.join(' ')}]`); + } + } + gitPart = `${gitColor('git:(', colors)}${gitInner.join(' ')}${gitColor(')', colors)}`; + } + const projectWithDirs = projectPart && addedDirsPart + ? `${projectPart} ${addedDirsPart}` + : projectPart ?? addedDirsPart; + if (projectWithDirs && gitPart) { + if (branchOverflow === 'wrap') { + parts.push(projectWithDirs); + parts.push(gitPart); + } + else { + parts.push(`${projectWithDirs} ${gitPart}`); + } + } + else if (projectWithDirs) { + parts.push(projectWithDirs); + } + else if (gitPart) { + parts.push(gitPart); + } + if (display?.showSessionName && ctx.transcript.sessionName) { + parts.push(label(ctx.transcript.sessionName, colors)); + } + if (display?.showClaudeCodeVersion && ctx.claudeCodeVersion) { + parts.push(label(`CC v${ctx.claudeCodeVersion}`, colors)); + } + if (ctx.extraLabel) { + parts.push(label(ctx.extraLabel, colors)); + } + if (display?.showDuration !== false && ctx.sessionDuration) { + parts.push(label(`⏱️ ${ctx.sessionDuration}`, colors)); + } + const costEstimate = renderCostEstimate(ctx); + if (costEstimate) { + parts.push(costEstimate); + } + if (display?.showSpeed) { + const speed = getOutputSpeed(ctx.stdin); + if (speed !== null) { + parts.push(label(`${t('format.out')}: ${speed.toFixed(1)} ${t('format.tokPerSec')}`, colors)); + } + } + const customLine = display?.customLine; + if (customLine) { + parts.push(customColor(customLine, colors)); + } + if (parts.length === 0) { + return null; + } + return parts.join(' \u2502 '); +} +function formatAheadCount(ahead, gitConfig, colors) { + const value = `↑${ahead}`; + const criticalThreshold = gitConfig?.pushCriticalThreshold ?? 0; + const warningThreshold = gitConfig?.pushWarningThreshold ?? 0; + if (criticalThreshold > 0 && ahead >= criticalThreshold) { + return criticalColor(value, colors); + } + if (warningThreshold > 0 && ahead >= warningThreshold) { + return warningColor(value, colors); + } + return gitBranchColor(value, colors); +} +export function renderGitFilesLine(ctx, terminalWidth = null) { + const gitConfig = ctx.config?.gitStatus; + if (!(gitConfig?.showFileStats ?? false)) + return null; + if (!ctx.gitStatus?.fileStats) + return null; + const { trackedFiles, untracked } = ctx.gitStatus.fileStats; + if (trackedFiles.length === 0 && untracked === 0) + return null; + if (terminalWidth !== null && terminalWidth < 60) + return null; + const cwd = ctx.stdin.cwd; + const sorted = [...trackedFiles].sort((a, b) => { + try { + const aPath = cwd ? resolvePathWithinCwd(cwd, a.fullPath) : null; + const bPath = cwd ? resolvePathWithinCwd(cwd, b.fullPath) : null; + const aMtime = aPath ? fs.statSync(aPath).mtimeMs : 0; + const bMtime = bPath ? fs.statSync(bPath).mtimeMs : 0; + return bMtime - aMtime; + } + catch { + return 0; + } + }); + const shown = sorted.slice(0, 6); + const overflow = sorted.length - shown.length; + const statParts = []; + for (const trackedFile of shown) { + const prefix = trackedFile.type === 'added' ? green('+') : trackedFile.type === 'deleted' ? red('-') : yellow('~'); + const safeBasename = sanitizeDisplayText(trackedFile.basename); + const coloredName = trackedFile.type === 'added' + ? green(safeBasename) + : trackedFile.type === 'deleted' + ? red(safeBasename) + : yellow(safeBasename); + const resolvedPath = cwd ? resolvePathWithinCwd(cwd, trackedFile.fullPath) : null; + const linkedName = resolvedPath ? safeHyperlink(getFileHref(resolvedPath), coloredName) : coloredName; + let entry = `${prefix}${linkedName}`; + if (trackedFile.lineDiff) { + const diffParts = []; + if (trackedFile.lineDiff.added > 0) + diffParts.push(green(`+${trackedFile.lineDiff.added}`)); + if (trackedFile.lineDiff.deleted > 0) + diffParts.push(red(`-${trackedFile.lineDiff.deleted}`)); + if (diffParts.length > 0) { + entry += dim(`(${diffParts.join(' ')})`); + } + } + statParts.push(entry); + } + if (overflow > 0) + statParts.push(dim(`+${overflow} more`)); + if (untracked > 0) + statParts.push(dim(`?${untracked}`)); + return statParts.join(' '); +} +//# sourceMappingURL=project.js.map \ No newline at end of file diff --git a/back2base-container/vendor/claude-hud/render/lines/prompt-cache.js b/back2base-container/vendor/claude-hud/render/lines/prompt-cache.js new file mode 100644 index 0000000..ed178ff --- /dev/null +++ b/back2base-container/vendor/claude-hud/render/lines/prompt-cache.js @@ -0,0 +1,50 @@ +import { getContextColor, RESET, label, warning as warningColor } from '../colors.js'; +import { t } from '../../i18n/index.js'; +function getPromptCacheWarningSeconds(ttlSeconds) { + return Math.min(ttlSeconds, Math.max(60, Math.floor(ttlSeconds / 5))); +} +function colorPromptCacheValue(value, state, ctx) { + if (state === 'expired') { + return label(value, ctx.config?.colors); + } + if (state === 'warning') { + return warningColor(value, ctx.config?.colors); + } + return `${getContextColor(0, ctx.config?.colors)}${value}${RESET}`; +} +export function formatPromptCacheCountdown(remainingMs) { + if (remainingMs <= 0) { + return t('status.expired'); + } + const totalSeconds = Math.ceil(remainingMs / 1000); + const hours = Math.floor(totalSeconds / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const seconds = totalSeconds % 60; + if (hours > 0) { + return `${hours}h ${minutes}m ${seconds}s`; + } + return `${minutes}m ${seconds}s`; +} +export function renderPromptCacheLine(ctx, now = Date.now()) { + const display = ctx.config?.display; + if (!display?.showPromptCache) { + return null; + } + const lastAssistantResponseAt = ctx.transcript.lastAssistantResponseAt; + if (!lastAssistantResponseAt || Number.isNaN(lastAssistantResponseAt.getTime())) { + return null; + } + const ttlSeconds = (typeof display.promptCacheTtlSeconds === 'number' + && Number.isFinite(display.promptCacheTtlSeconds) + && display.promptCacheTtlSeconds > 0) + ? Math.floor(display.promptCacheTtlSeconds) + : 300; + const remainingMs = (lastAssistantResponseAt.getTime() + ttlSeconds * 1000) - now; + const state = remainingMs <= 0 + ? 'expired' + : remainingMs <= getPromptCacheWarningSeconds(ttlSeconds) * 1000 + ? 'warning' + : 'active'; + return `${label(t('label.promptCache'), ctx.config?.colors)} ${colorPromptCacheValue(`⏱ ${formatPromptCacheCountdown(remainingMs)}`, state, ctx)}`; +} +//# sourceMappingURL=prompt-cache.js.map \ No newline at end of file diff --git a/back2base-container/vendor/claude-hud/render/lines/session-time.js b/back2base-container/vendor/claude-hud/render/lines/session-time.js new file mode 100644 index 0000000..58a7ec4 --- /dev/null +++ b/back2base-container/vendor/claude-hud/render/lines/session-time.js @@ -0,0 +1,62 @@ +import { label } from '../colors.js'; +import { getLanguage, t } from '../../i18n/index.js'; +function pad(n) { + return n < 10 ? `0${n}` : `${n}`; +} +function formatStartDate(date) { + const y = date.getFullYear(); + const m = pad(date.getMonth() + 1); + const d = pad(date.getDate()); + const h = pad(date.getHours()); + const min = pad(date.getMinutes()); + return `${y}-${m}-${d} ${h}:${min}`; +} +function formatRelativeTime(ms) { + if (ms < 0) { + return t('format.justNow'); + } + const withAgo = (value) => { + const ago = t('format.ago'); + return getLanguage() === 'zh' ? `${value}${ago}` : `${value} ${ago}`; + }; + const seconds = Math.floor(ms / 1000); + if (seconds < 60) { + return withAgo(`${seconds}s`); + } + const minutes = Math.floor(seconds / 60); + if (minutes < 60) { + return withAgo(`${minutes}m`); + } + const hours = Math.floor(minutes / 60); + const remainingMinutes = minutes % 60; + if (hours < 24) { + return withAgo(remainingMinutes > 0 ? `${hours}h ${remainingMinutes}m` : `${hours}h`); + } + const days = Math.floor(hours / 24); + const remainingHours = hours % 24; + return withAgo(remainingHours > 0 ? `${days}d ${remainingHours}h` : `${days}d`); +} +export function renderSessionTimeLine(ctx, nowFn) { + const display = ctx.config?.display; + const showStart = display?.showSessionStartDate === true; + const showLastReply = display?.showLastResponseAt === true; + const colors = ctx.config?.colors; + if (!showStart && !showLastReply) { + return null; + } + const parts = []; + if (showStart && ctx.transcript.sessionStart) { + const startStr = formatStartDate(ctx.transcript.sessionStart); + parts.push(`${label(`${t('label.sessionStarted')}:`, colors)} ${startStr}`); + } + if (showLastReply && ctx.transcript.lastAssistantResponseAt) { + const now = nowFn ? nowFn() : Date.now(); + const elapsed = now - ctx.transcript.lastAssistantResponseAt.getTime(); + parts.push(`${label(`${t('label.lastReply')}:`, colors)} ${formatRelativeTime(elapsed)}`); + } + if (parts.length === 0) { + return null; + } + return parts.join(` ${label('│', colors)} `); +} +//# sourceMappingURL=session-time.js.map \ No newline at end of file diff --git a/back2base-container/vendor/claude-hud/render/lines/session-tokens.js b/back2base-container/vendor/claude-hud/render/lines/session-tokens.js new file mode 100644 index 0000000..7ba8371 --- /dev/null +++ b/back2base-container/vendor/claude-hud/render/lines/session-tokens.js @@ -0,0 +1,35 @@ +import { label } from '../colors.js'; +import { t } from '../../i18n/index.js'; +function formatTokens(n) { + if (n >= 1000000) { + return `${(n / 1000000).toFixed(1)}M`; + } + if (n >= 1000) { + return `${(n / 1000).toFixed(0)}k`; + } + return n.toString(); +} +export function renderSessionTokensLine(ctx) { + const display = ctx.config?.display; + if (display?.showSessionTokens === false) { + return null; + } + const tokens = ctx.transcript.sessionTokens; + if (!tokens) { + return null; + } + const total = tokens.inputTokens + tokens.outputTokens + tokens.cacheCreationTokens + tokens.cacheReadTokens; + if (total === 0) { + return null; + } + const colors = ctx.config?.colors; + const parts = [ + `${t('format.in')}: ${formatTokens(tokens.inputTokens)}`, + `${t('format.out')}: ${formatTokens(tokens.outputTokens)}`, + ]; + if (tokens.cacheCreationTokens > 0 || tokens.cacheReadTokens > 0) { + parts.push(`${t('format.cache')}: ${formatTokens(tokens.cacheCreationTokens + tokens.cacheReadTokens)}`); + } + return label(`${t('label.tokens')} ${formatTokens(total)} (${parts.join(', ')})`, colors); +} +//# sourceMappingURL=session-tokens.js.map \ No newline at end of file diff --git a/back2base-container/vendor/claude-hud/render/lines/usage.js b/back2base-container/vendor/claude-hud/render/lines/usage.js new file mode 100644 index 0000000..37e7b76 --- /dev/null +++ b/back2base-container/vendor/claude-hud/render/lines/usage.js @@ -0,0 +1,150 @@ +import { isLimitReached } from "../../types.js"; +import { shouldHideUsage } from "../../stdin.js"; +import { critical, label, getQuotaColor, quotaBar, RESET } from "../colors.js"; +import { getAdaptiveBarWidth } from "../../utils/terminal.js"; +import { t } from "../../i18n/index.js"; +import { progressLabel } from "./label-align.js"; +import { formatResetTime } from "../format-reset-time.js"; +export function renderUsageLine(ctx, alignLabels = false) { + const display = ctx.config?.display; + const colors = ctx.config?.colors; + if (display?.showUsage === false) { + return null; + } + if (!ctx.usageData) { + return null; + } + if (shouldHideUsage(ctx.stdin)) { + return null; + } + const usageLabel = progressLabel("label.usage", colors, alignLabels); + if (ctx.usageData.balanceLabel) { + return `${usageLabel} ${ctx.usageData.balanceLabel}`; + } + const timeFormat = display?.timeFormat ?? 'relative'; + const showResetLabel = display?.showResetLabel ?? true; + const resetsKey = timeFormat === 'absolute' ? "format.resets" : "format.resetsIn"; + const usageCompact = display?.usageCompact ?? false; + const usageValueMode = display?.usageValue ?? 'percent'; + if (isLimitReached(ctx.usageData)) { + const resetTime = ctx.usageData.fiveHour === 100 + ? formatResetTime(ctx.usageData.fiveHourResetAt, timeFormat) + : formatResetTime(ctx.usageData.sevenDayResetAt, timeFormat); + if (usageCompact) { + return critical(`⚠ Limit${resetTime ? ` (${resetTime})` : ""}`, colors); + } + const resetSuffix = resetTime + ? showResetLabel + ? ` (${t(resetsKey)} ${resetTime})` + : ` (${resetTime})` + : ""; + return `${usageLabel} ${critical(`⚠ ${t("status.limitReached")}${resetSuffix}`, colors)}`; + } + const threshold = display?.usageThreshold ?? 0; + const fiveHour = ctx.usageData.fiveHour; + const sevenDay = ctx.usageData.sevenDay; + const effectiveUsage = Math.max(fiveHour ?? 0, sevenDay ?? 0); + if (effectiveUsage < threshold) { + return null; + } + const sevenDayThreshold = display?.sevenDayThreshold ?? 80; + if (usageCompact) { + const fiveHourPart = fiveHour !== null + ? formatCompactWindowPart("5h", fiveHour, ctx.usageData.fiveHourResetAt, timeFormat, colors, usageValueMode) + : null; + const sevenDayPart = (sevenDay !== null && (fiveHour === null || sevenDay >= sevenDayThreshold)) + ? formatCompactWindowPart("7d", sevenDay, ctx.usageData.sevenDayResetAt, timeFormat, colors, usageValueMode) + : null; + if (fiveHourPart && sevenDayPart) { + return `${fiveHourPart} | ${sevenDayPart}`; + } + return fiveHourPart ?? sevenDayPart ?? null; + } + const usageBarEnabled = display?.usageBarEnabled ?? true; + const barWidth = getAdaptiveBarWidth(); + if (fiveHour === null && sevenDay !== null) { + const weeklyOnlyPart = formatUsageWindowPart({ + label: t("label.weekly"), + labelKey: "label.weekly", + percent: sevenDay, + resetAt: ctx.usageData.sevenDayResetAt, + colors, + usageBarEnabled, + barWidth, + timeFormat, + showResetLabel, + forceLabel: true, + alignLabels, + usageValueMode, + }); + return `${usageLabel} ${weeklyOnlyPart}`; + } + const fiveHourPart = formatUsageWindowPart({ + label: "5h", + percent: fiveHour, + resetAt: ctx.usageData.fiveHourResetAt, + colors, + usageBarEnabled, + barWidth, + timeFormat, + showResetLabel, + usageValueMode, + }); + if (sevenDay !== null && sevenDay >= sevenDayThreshold) { + const sevenDayPart = formatUsageWindowPart({ + label: t("label.weekly"), + labelKey: "label.weekly", + percent: sevenDay, + resetAt: ctx.usageData.sevenDayResetAt, + colors, + usageBarEnabled, + barWidth, + timeFormat, + showResetLabel, + forceLabel: true, + alignLabels, + usageValueMode, + }); + return `${usageLabel} ${fiveHourPart} | ${sevenDayPart}`; + } + return `${usageLabel} ${fiveHourPart}`; +} +function formatCompactWindowPart(windowLabel, percent, resetAt, timeFormat, colors, usageValueMode = 'percent') { + const usageDisplay = formatUsagePercent(percent, colors, usageValueMode); + const reset = formatResetTime(resetAt, timeFormat); + const styledLabel = label(`${windowLabel}:`, colors); + return reset + ? `${styledLabel} ${usageDisplay} ${label(`(${reset})`, colors)}` + : `${styledLabel} ${usageDisplay}`; +} +function formatUsagePercent(percent, colors, mode = 'percent') { + if (percent === null) { + return label("--", colors); + } + const color = getQuotaColor(percent, colors); + const displayPercent = mode === 'remaining' ? Math.max(0, 100 - percent) : percent; + return `${color}${displayPercent}%${RESET}`; +} +function formatUsageWindowPart({ label: windowLabel, labelKey, percent, resetAt, colors, usageBarEnabled, barWidth, timeFormat = 'relative', showResetLabel, forceLabel = false, alignLabels = false, usageValueMode = 'percent', }) { + const usageDisplay = formatUsagePercent(percent, colors, usageValueMode); + const reset = formatResetTime(resetAt, timeFormat); + const styledLabel = labelKey + ? progressLabel(labelKey, colors, alignLabels) + : label(windowLabel, colors); + const resetsKey = timeFormat === 'absolute' ? "format.resets" : "format.resetsIn"; + const resetSuffix = reset + ? showResetLabel + ? `(${t(resetsKey)} ${reset})` + : `(${reset})` + : ""; + if (usageBarEnabled) { + const body = resetSuffix + ? `${quotaBar(percent ?? 0, barWidth, colors)} ${usageDisplay} ${resetSuffix}` + : `${quotaBar(percent ?? 0, barWidth, colors)} ${usageDisplay}`; + return forceLabel ? `${styledLabel} ${body}` : body; + } + return resetSuffix + ? `${styledLabel} ${usageDisplay} ${resetSuffix}` + : `${styledLabel} ${usageDisplay}`; +} +//# sourceMappingURL=usage.js.map \ No newline at end of file diff --git a/back2base-container/vendor/claude-hud/render/session-line.js b/back2base-container/vendor/claude-hud/render/session-line.js new file mode 100644 index 0000000..5b0c436 --- /dev/null +++ b/back2base-container/vendor/claude-hud/render/session-line.js @@ -0,0 +1,371 @@ +import { isLimitReached } from '../types.js'; +import { getContextPercent, getBufferedPercent, getModelName, formatModelName, getProviderLabel, getTotalTokens, shouldHideUsage } from '../stdin.js'; +import { getOutputSpeed } from '../speed-tracker.js'; +import { coloredBar, critical, git as gitColor, gitBranch as gitBranchColor, label, model as modelColor, project as projectColor, getContextColor, getQuotaColor, quotaBar, custom as customColor, RESET } from './colors.js'; +import { getAdaptiveBarWidth } from '../utils/terminal.js'; +import { renderCostEstimate } from './lines/cost.js'; +import { renderPromptCacheLine } from './lines/prompt-cache.js'; +import { renderSessionTimeLine } from './lines/session-time.js'; +import { t } from '../i18n/index.js'; +import { formatResetTime } from './format-reset-time.js'; +const DEBUG = process.env.DEBUG?.includes('claude-hud') || process.env.DEBUG === '*'; +/** + * Renders the full session line (model + context bar + project + git + counts + usage + duration). + * Used for compact layout mode. + */ +export function renderSessionLine(ctx) { + const model = formatModelName(getModelName(ctx.stdin), ctx.config?.display?.modelFormat, ctx.config?.display?.modelOverride); + const rawPercent = getContextPercent(ctx.stdin); + const bufferedPercent = getBufferedPercent(ctx.stdin); + const autocompactMode = ctx.config?.display?.autocompactBuffer ?? 'enabled'; + const percent = autocompactMode === 'disabled' ? rawPercent : bufferedPercent; + if (DEBUG && autocompactMode === 'disabled') { + console.error(`[claude-hud:context] autocompactBuffer=disabled, showing raw ${rawPercent}% (buffered would be ${bufferedPercent}%)`); + } + const colors = ctx.config?.colors; + const display = ctx.config?.display; + const contextThresholds = { + warning: display?.contextWarningThreshold, + critical: display?.contextCriticalThreshold, + }; + const barWidth = getAdaptiveBarWidth(); + const bar = coloredBar(percent, barWidth, colors, contextThresholds); + const parts = []; + const timeFormat = display?.timeFormat ?? 'relative'; + const resetsKey = timeFormat === 'absolute' ? 'format.resets' : 'format.resetsIn'; + const contextValueMode = display?.contextValue ?? 'percent'; + const contextValue = formatContextValue(ctx, percent, contextValueMode); + const contextValueDisplay = `${getContextColor(percent, colors, contextThresholds)}${contextValue}${RESET}`; + // Model and context bar (FIRST) + const providerLabel = getProviderLabel(ctx.stdin); + const modelQualifier = providerLabel ?? undefined; + let modelDisplay = modelQualifier ? `${model} | ${modelQualifier}` : model; + if (ctx.effortLevel && ctx.effortSymbol) { + modelDisplay += ` ${ctx.effortSymbol} ${ctx.effortLevel}`; + } + else if (ctx.effortLevel) { + modelDisplay += ` ${ctx.effortLevel}`; + } + if (display?.showModel !== false && display?.showContextBar !== false) { + parts.push(`${modelColor(`[${modelDisplay}]`, colors)} ${bar} ${contextValueDisplay}`); + } + else if (display?.showModel !== false) { + parts.push(`${modelColor(`[${modelDisplay}]`, colors)} ${contextValueDisplay}`); + } + else if (display?.showContextBar !== false) { + parts.push(`${bar} ${contextValueDisplay}`); + } + else { + parts.push(contextValueDisplay); + } + // Project path + git status (SECOND) + let projectPart = null; + if (display?.showProject !== false && ctx.stdin.cwd) { + // Split by both Unix (/) and Windows (\) separators for cross-platform support + const segments = ctx.stdin.cwd.split(/[/\\]/).filter(Boolean); + const pathLevels = ctx.config?.pathLevels ?? 1; + // Always join with forward slash for consistent display + // Handle root path (/) which results in empty segments + const projectPath = segments.length > 0 ? segments.slice(-pathLevels).join('/') : '/'; + projectPart = projectColor(projectPath, colors); + } + let gitPart = ''; + const gitConfig = ctx.config?.gitStatus; + const showGit = gitConfig?.enabled ?? true; + const branchOverflow = gitConfig?.branchOverflow ?? 'truncate'; + if (showGit && ctx.gitStatus) { + const gitParts = [ctx.gitStatus.branch]; + // Show dirty indicator + if ((gitConfig?.showDirty ?? true) && ctx.gitStatus.isDirty) { + gitParts.push('*'); + } + // Show ahead/behind (with space separator for readability) + if (gitConfig?.showAheadBehind) { + if (ctx.gitStatus.ahead > 0) { + gitParts.push(` ↑${ctx.gitStatus.ahead}`); + } + if (ctx.gitStatus.behind > 0) { + gitParts.push(` ↓${ctx.gitStatus.behind}`); + } + } + // Show file stats in Starship-compatible format (!modified +added ✘deleted ?untracked) + if (gitConfig?.showFileStats && ctx.gitStatus.fileStats) { + const { modified, added, deleted, untracked } = ctx.gitStatus.fileStats; + const statParts = []; + if (modified > 0) + statParts.push(`!${modified}`); + if (added > 0) + statParts.push(`+${added}`); + if (deleted > 0) + statParts.push(`✘${deleted}`); + if (untracked > 0) + statParts.push(`?${untracked}`); + if (statParts.length > 0) { + gitParts.push(` ${statParts.join(' ')}`); + } + } + gitPart = `${gitColor('git:(', colors)}${gitBranchColor(gitParts.join(''), colors)}${gitColor(')', colors)}`; + } + if (projectPart && gitPart) { + if (branchOverflow === 'wrap') { + parts.push(projectPart); + parts.push(gitPart); + } + else { + parts.push(`${projectPart} ${gitPart}`); + } + } + else if (projectPart) { + parts.push(projectPart); + } + else if (gitPart) { + parts.push(gitPart); + } + // Session name (custom title from /rename, or auto-generated slug) + if (display?.showSessionName && ctx.transcript.sessionName) { + parts.push(label(ctx.transcript.sessionName, colors)); + } + if (display?.showClaudeCodeVersion && ctx.claudeCodeVersion) { + parts.push(label(`CC v${ctx.claudeCodeVersion}`, colors)); + } + // Config counts (respects environmentThreshold) + if (display?.showConfigCounts !== false) { + const totalCounts = ctx.claudeMdCount + ctx.rulesCount + ctx.mcpCount + ctx.hooksCount; + const envThreshold = display?.environmentThreshold ?? 0; + if (totalCounts > 0 && totalCounts >= envThreshold) { + if (ctx.claudeMdCount > 0) { + parts.push(label(`${ctx.claudeMdCount} CLAUDE.md`, colors)); + } + if (ctx.rulesCount > 0) { + parts.push(label(`${ctx.rulesCount} ${t('label.rules')}`, colors)); + } + if (ctx.mcpCount > 0) { + parts.push(label(`${ctx.mcpCount} MCPs`, colors)); + } + if (ctx.hooksCount > 0) { + parts.push(label(`${ctx.hooksCount} ${t('label.hooks')}`, colors)); + } + } + } + // Usage limits display (shown when enabled in config, respects usageThreshold) + if (display?.showUsage !== false && ctx.usageData && !shouldHideUsage(ctx.stdin)) { + const usageCompact = display?.usageCompact ?? false; + const showResetLabel = display?.showResetLabel ?? true; + const usageValueMode = display?.usageValue ?? 'percent'; + if (ctx.usageData.balanceLabel) { + parts.push(`${label(t('label.usage'), colors)} ${ctx.usageData.balanceLabel}`); + } + else if (isLimitReached(ctx.usageData)) { + const resetTime = ctx.usageData.fiveHour === 100 + ? formatResetTime(ctx.usageData.fiveHourResetAt, timeFormat) + : formatResetTime(ctx.usageData.sevenDayResetAt, timeFormat); + if (usageCompact) { + parts.push(critical(`⚠ Limit${resetTime ? ` (${resetTime})` : ''}`, colors)); + } + else { + const resetSuffix = resetTime + ? showResetLabel + ? ` (${t(resetsKey)} ${resetTime})` + : ` (${resetTime})` + : ''; + parts.push(critical(`⚠ ${t('status.limitReached')}${resetSuffix}`, colors)); + } + } + else { + const usageThreshold = display?.usageThreshold ?? 0; + const fiveHour = ctx.usageData.fiveHour; + const sevenDay = ctx.usageData.sevenDay; + const effectiveUsage = Math.max(fiveHour ?? 0, sevenDay ?? 0); + if (effectiveUsage >= usageThreshold) { + const usageBarEnabled = display?.usageBarEnabled ?? true; + if (usageCompact) { + const fiveHourPart = fiveHour !== null + ? formatCompactWindowPart('5h', fiveHour, ctx.usageData.fiveHourResetAt, timeFormat, colors, usageValueMode) + : null; + const sevenDayThreshold = display?.sevenDayThreshold ?? 80; + const sevenDayPart = (sevenDay !== null && (fiveHour === null || sevenDay >= sevenDayThreshold)) + ? formatCompactWindowPart('7d', sevenDay, ctx.usageData.sevenDayResetAt, timeFormat, colors, usageValueMode) + : null; + if (fiveHourPart && sevenDayPart) { + parts.push(fiveHourPart); + parts.push(sevenDayPart); + } + else if (fiveHourPart) { + parts.push(fiveHourPart); + } + else if (sevenDayPart) { + parts.push(sevenDayPart); + } + } + else if (fiveHour === null && sevenDay !== null) { + const weeklyOnlyPart = formatUsageWindowPart({ + label: t('label.weekly'), + percent: sevenDay, + resetAt: ctx.usageData.sevenDayResetAt, + colors, + usageBarEnabled, + barWidth, + timeFormat, + showResetLabel, + forceLabel: true, + usageValueMode, + }); + parts.push(weeklyOnlyPart); + } + else { + const fiveHourPart = formatUsageWindowPart({ + label: '5h', + percent: fiveHour, + resetAt: ctx.usageData.fiveHourResetAt, + colors, + usageBarEnabled, + barWidth, + timeFormat, + showResetLabel, + usageValueMode, + }); + const sevenDayThreshold = display?.sevenDayThreshold ?? 80; + if (sevenDay !== null && sevenDay >= sevenDayThreshold) { + const sevenDayPart = formatUsageWindowPart({ + label: t('label.weekly'), + percent: sevenDay, + resetAt: ctx.usageData.sevenDayResetAt, + colors, + usageBarEnabled, + barWidth, + timeFormat, + showResetLabel, + forceLabel: true, + usageValueMode, + }); + parts.push(`${label(t('label.usage'), colors)} ${fiveHourPart}`); + parts.push(sevenDayPart); + } + else { + parts.push(`${label(t('label.usage'), colors)} ${fiveHourPart}`); + } + } + } + } + } + // Session token usage (cumulative) + if (display?.showSessionTokens && ctx.transcript.sessionTokens) { + const st = ctx.transcript.sessionTokens; + const total = st.inputTokens + st.outputTokens + st.cacheCreationTokens + st.cacheReadTokens; + if (total > 0) { + parts.push(label(`${t('format.tok')}: ${formatTokens(total)} (${t('format.in')}: ${formatTokens(st.inputTokens)}, ${t('format.out')}: ${formatTokens(st.outputTokens)})`, colors)); + } + } + if (display?.showDuration !== false && ctx.sessionDuration) { + parts.push(label(`⏱️ ${ctx.sessionDuration}`, colors)); + } + const sessionTimeLine = renderSessionTimeLine(ctx); + if (sessionTimeLine) { + parts.push(sessionTimeLine); + } + const promptCacheLine = renderPromptCacheLine(ctx); + if (promptCacheLine) { + parts.push(promptCacheLine); + } + const costEstimate = renderCostEstimate(ctx); + if (costEstimate) { + parts.push(costEstimate); + } + if (display?.showSpeed) { + const speed = getOutputSpeed(ctx.stdin); + if (speed !== null) { + parts.push(label(`${t('format.out')}: ${speed.toFixed(1)} ${t('format.tokPerSec')}`, colors)); + } + } + if (ctx.extraLabel) { + parts.push(label(ctx.extraLabel, colors)); + } + // Custom line (static user-defined text) + const customLine = display?.customLine; + if (customLine) { + parts.push(customColor(customLine, colors)); + } + let line = parts.join(' | '); + // Token breakdown at high context + if (display?.showTokenBreakdown !== false && percent >= (display?.contextCriticalThreshold ?? 85)) { + const usage = ctx.stdin.context_window?.current_usage; + if (usage) { + const input = formatTokens(usage.input_tokens ?? 0); + const cache = formatTokens((usage.cache_creation_input_tokens ?? 0) + (usage.cache_read_input_tokens ?? 0)); + line += label(` (${t('format.in')}: ${input}, ${t('format.cache')}: ${cache})`, colors); + } + } + return line; +} +function formatTokens(n) { + if (n >= 1000000) { + return `${(n / 1000000).toFixed(1)}M`; + } + if (n >= 1000) { + return `${(n / 1000).toFixed(0)}k`; + } + return n.toString(); +} +function formatContextValue(ctx, percent, mode) { + const totalTokens = getTotalTokens(ctx.stdin); + const size = ctx.stdin.context_window?.context_window_size ?? 0; + if (mode === 'tokens') { + if (size > 0) { + return `${formatTokens(totalTokens)}/${formatTokens(size)}`; + } + return formatTokens(totalTokens); + } + if (mode === 'both') { + if (size > 0) { + return `${percent}% (${formatTokens(totalTokens)}/${formatTokens(size)})`; + } + return `${percent}%`; + } + if (mode === 'remaining') { + return `${Math.max(0, 100 - percent)}%`; + } + return `${percent}%`; +} +function formatCompactWindowPart(windowLabel, percent, resetAt, timeFormat, colors, usageValueMode = 'percent') { + const usageDisplay = formatUsagePercent(percent, colors, usageValueMode); + const reset = formatResetTime(resetAt, timeFormat); + const styledLabel = label(`${windowLabel}:`, colors); + return reset + ? `${styledLabel} ${usageDisplay} ${label(`(${reset})`, colors)}` + : `${styledLabel} ${usageDisplay}`; +} +function formatUsagePercent(percent, colors, mode = 'percent') { + if (percent === null) { + return label('--', colors); + } + const color = getQuotaColor(percent, colors); + const displayPercent = mode === 'remaining' ? Math.max(0, 100 - percent) : percent; + return `${color}${displayPercent}%${RESET}`; +} +function formatUsageWindowPart({ label: windowLabel, percent, resetAt, colors, usageBarEnabled, barWidth, timeFormat = 'relative', showResetLabel, forceLabel = false, usageValueMode = 'percent', }) { + const usageDisplay = formatUsagePercent(percent, colors, usageValueMode); + const reset = formatResetTime(resetAt, timeFormat); + const styledLabel = label(windowLabel, colors); + // "resets in X" for relative/both; "resets X" for absolute (avoids "resets in at 14:30") + const resetsKey = timeFormat === 'absolute' ? 'format.resets' : 'format.resetsIn'; + if (usageBarEnabled) { + // Relative mode keeps the upstream "(duration / windowLabel)" pattern (e.g. "2h 30m / 5h"). + // Absolute/both modes use the preposition form instead — "(at 14:30 / 5h)" is incoherent. + const barReset = timeFormat === 'relative' + ? (reset ? `${reset} / ${windowLabel}` : null) + : (reset ? (showResetLabel ? `${t(resetsKey)} ${reset}` : reset) : null); + const body = barReset + ? `${quotaBar(percent ?? 0, barWidth, colors)} ${usageDisplay} (${barReset})` + : `${quotaBar(percent ?? 0, barWidth, colors)} ${usageDisplay}`; + return forceLabel ? `${styledLabel} ${body}` : body; + } + const resetSuffix = reset + ? showResetLabel + ? `(${t(resetsKey)} ${reset})` + : `(${reset})` + : ''; + return resetSuffix + ? `${styledLabel} ${usageDisplay} ${resetSuffix}` + : `${styledLabel} ${usageDisplay}`; +} +//# sourceMappingURL=session-line.js.map \ No newline at end of file diff --git a/back2base-container/vendor/claude-hud/render/todos-line.js b/back2base-container/vendor/claude-hud/render/todos-line.js new file mode 100644 index 0000000..d98b9ca --- /dev/null +++ b/back2base-container/vendor/claude-hud/render/todos-line.js @@ -0,0 +1,27 @@ +import { yellow, green, label } from "./colors.js"; +import { t } from "../i18n/index.js"; +export function renderTodosLine(ctx) { + const { todos } = ctx.transcript; + const colors = ctx.config?.colors; + if (!todos || todos.length === 0) { + return null; + } + const inProgress = todos.find((todo) => todo.status === "in_progress"); + const completed = todos.filter((todo) => todo.status === "completed").length; + const total = todos.length; + if (!inProgress) { + if (completed === total && total > 0) { + return `${green("✓")} ${t("status.allTodosComplete")} ${label(`(${completed}/${total})`, colors)}`; + } + return null; + } + const content = truncateContent(inProgress.content); + const progress = label(`(${completed}/${total})`, colors); + return `${yellow("▸")} ${content} ${progress}`; +} +function truncateContent(content, maxLen = 50) { + if (content.length <= maxLen) + return content; + return content.slice(0, maxLen - 3) + "..."; +} +//# sourceMappingURL=todos-line.js.map \ No newline at end of file diff --git a/back2base-container/vendor/claude-hud/render/tools-line.js b/back2base-container/vendor/claude-hud/render/tools-line.js new file mode 100644 index 0000000..ac4d0f9 --- /dev/null +++ b/back2base-container/vendor/claude-hud/render/tools-line.js @@ -0,0 +1,44 @@ +import { yellow, green, cyan, label } from './colors.js'; +export function renderToolsLine(ctx) { + const { tools } = ctx.transcript; + const colors = ctx.config?.colors; + if (tools.length === 0) { + return null; + } + const parts = []; + const runningTools = tools.filter((t) => t.status === 'running'); + const completedTools = tools.filter((t) => t.status === 'completed' || t.status === 'error'); + for (const tool of runningTools.slice(-2)) { + const target = tool.target ? truncatePath(tool.target) : ''; + parts.push(`${yellow('◐')} ${cyan(tool.name)}${target ? label(`: ${target}`, colors) : ''}`); + } + const toolCounts = new Map(); + for (const tool of completedTools) { + const count = toolCounts.get(tool.name) ?? 0; + toolCounts.set(tool.name, count + 1); + } + const sortedTools = Array.from(toolCounts.entries()) + .sort((a, b) => b[1] - a[1]) + .slice(0, 4); + for (const [name, count] of sortedTools) { + parts.push(`${green('✓')} ${name} ${label(`×${count}`, colors)}`); + } + if (parts.length === 0) { + return null; + } + return parts.join(' | '); +} +function truncatePath(path, maxLen = 20) { + // Normalize Windows backslashes to forward slashes for consistent display + const normalizedPath = path.replace(/\\/g, '/'); + if (normalizedPath.length <= maxLen) + return normalizedPath; + // Split by forward slash (already normalized) + const parts = normalizedPath.split('/'); + const filename = parts.pop() || normalizedPath; + if (filename.length >= maxLen) { + return filename.slice(0, maxLen - 3) + '...'; + } + return '.../' + filename; +} +//# sourceMappingURL=tools-line.js.map \ No newline at end of file diff --git a/back2base-container/vendor/claude-hud/render/width.js b/back2base-container/vendor/claude-hud/render/width.js new file mode 100644 index 0000000..366ff0f --- /dev/null +++ b/back2base-container/vendor/claude-hud/render/width.js @@ -0,0 +1,49 @@ +import { getLanguage } from '../i18n/index.js'; +// CJK terminals render East Asian Ambiguous-width chars (box drawing, +// block elements, arrows, etc.) as 2 cells. The HUD bar/separator/icon +// glyphs fall in those ranges, so width math must follow suit when the +// user's language is CJK — otherwise wrap calculations under-report +// visual width and the terminal itself wraps. +export function isCjkAmbiguousWide() { + return getLanguage() === 'zh'; +} +export function isWideCodePoint(codePoint) { + return codePoint >= 0x1100 && (codePoint <= 0x115F || // Hangul Jamo + codePoint === 0x2329 || + codePoint === 0x232A || + (codePoint >= 0x2E80 && codePoint <= 0xA4CF && codePoint !== 0x303F) || + (codePoint >= 0xAC00 && codePoint <= 0xD7A3) || + (codePoint >= 0xF900 && codePoint <= 0xFAFF) || + (codePoint >= 0xFE10 && codePoint <= 0xFE19) || + (codePoint >= 0xFE30 && codePoint <= 0xFE6F) || + (codePoint >= 0xFF00 && codePoint <= 0xFF60) || + (codePoint >= 0xFFE0 && codePoint <= 0xFFE6) || + (codePoint >= 0x1F300 && codePoint <= 0x1FAFF) || + (codePoint >= 0x20000 && codePoint <= 0x3FFFD)); +} +// East Asian Ambiguous-width ranges actually emitted by the HUD: +// box drawing (│ ─), block elements (█ ░), geometric shapes (◐ ● ▸), +// arrows (↑ ↓ →), math operators (≤ ≥), misc symbols (⚠), dingbats +// (✓ ✘), general punctuation (— …), misc technical (⏱). +export function isAmbiguousWideCodePoint(codePoint) { + if (codePoint < 0x2010) + return false; + return ((codePoint >= 0x2010 && codePoint <= 0x2027) || + (codePoint >= 0x2030 && codePoint <= 0x205E) || + (codePoint >= 0x2190 && codePoint <= 0x21FF) || + (codePoint >= 0x2200 && codePoint <= 0x22FF) || + (codePoint >= 0x2300 && codePoint <= 0x23FF) || + (codePoint >= 0x2460 && codePoint <= 0x24FF) || + (codePoint >= 0x2500 && codePoint <= 0x259F) || + (codePoint >= 0x25A0 && codePoint <= 0x25FF) || + (codePoint >= 0x2600 && codePoint <= 0x26FF) || + (codePoint >= 0x2700 && codePoint <= 0x27BF)); +} +export function codePointCellWidth(codePoint, ambiguousWide) { + if (isWideCodePoint(codePoint)) + return 2; + if (ambiguousWide && isAmbiguousWideCodePoint(codePoint)) + return 2; + return 1; +} +//# sourceMappingURL=width.js.map \ No newline at end of file diff --git a/back2base-container/vendor/claude-hud/speed-tracker.js b/back2base-container/vendor/claude-hud/speed-tracker.js new file mode 100644 index 0000000..558bf01 --- /dev/null +++ b/back2base-container/vendor/claude-hud/speed-tracker.js @@ -0,0 +1,111 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as os from 'node:os'; +import { createHash } from 'node:crypto'; +import { getHudPluginDir } from './claude-config-dir.js'; +const SPEED_WINDOW_MS = 2000; +// Status lines can re-render many times per second while tokens stream. +// Computing a rate from sub-500ms windows amplifies noise and produces +// spurious multi-thousand tok/s readings (see #481). Require at least +// half a second of elapsed time before reporting a speed. +const MIN_DELTA_MS = 500; +const CACHE_DIRNAME = 'speed-cache'; +const LEGACY_CACHE_FILENAME = '.speed-cache.json'; +const defaultDeps = { + homeDir: () => os.homedir(), + now: () => Date.now(), +}; +// Scope the cache by a sha256 of the resolved transcript path so that +// concurrent Claude Code sessions never share or overwrite each other's +// cached output-token counters. Sharing the cache across sessions +// produced bogus speed readings on idle terminals whenever another +// terminal was actively streaming (see #495). +function getCachePath(homeDir, transcriptPath) { + const hash = createHash('sha256').update(path.resolve(transcriptPath)).digest('hex'); + return path.join(getHudPluginDir(homeDir), CACHE_DIRNAME, `${hash}.json`); +} +function readCache(homeDir, transcriptPath) { + try { + const cachePath = getCachePath(homeDir, transcriptPath); + if (!fs.existsSync(cachePath)) + return null; + const content = fs.readFileSync(cachePath, 'utf8'); + const parsed = JSON.parse(content); + if (typeof parsed.outputTokens !== 'number' || typeof parsed.timestamp !== 'number') { + return null; + } + return parsed; + } + catch { + return null; + } +} +function writeCache(homeDir, transcriptPath, cache) { + try { + const cachePath = getCachePath(homeDir, transcriptPath); + const cacheDir = path.dirname(cachePath); + if (!fs.existsSync(cacheDir)) { + fs.mkdirSync(cacheDir, { recursive: true }); + } + fs.writeFileSync(cachePath, JSON.stringify(cache), 'utf8'); + } + catch { + // Ignore cache write failures + } +} +// Remove the pre-0.x global cache file once, if present. It has no owner +// session so leaving it around only wastes disk. +function removeLegacyCache(homeDir) { + try { + const legacyPath = path.join(getHudPluginDir(homeDir), LEGACY_CACHE_FILENAME); + if (fs.existsSync(legacyPath)) { + fs.unlinkSync(legacyPath); + } + } + catch { + // Ignore cleanup failures + } +} +export function getOutputSpeed(stdin, overrides = {}) { + const outputTokens = stdin.context_window?.current_usage?.output_tokens; + if (typeof outputTokens !== 'number' || !Number.isFinite(outputTokens)) { + return null; + } + const transcriptPath = stdin.transcript_path?.trim(); + if (!transcriptPath) { + // Without a stable session key we cannot safely isolate cache entries + // across concurrent Claude Code sessions, so skip speed tracking. + return null; + } + const deps = { ...defaultDeps, ...overrides }; + const now = deps.now(); + const homeDir = deps.homeDir(); + removeLegacyCache(homeDir); + const previous = readCache(homeDir, transcriptPath); + if (!previous) { + writeCache(homeDir, transcriptPath, { outputTokens, timestamp: now }); + return null; + } + if (outputTokens < previous.outputTokens) { + writeCache(homeDir, transcriptPath, { outputTokens, timestamp: now }); + return null; + } + let speed = null; + const deltaTokens = outputTokens - previous.outputTokens; + const deltaMs = now - previous.timestamp; + if (deltaMs > SPEED_WINDOW_MS) { + writeCache(homeDir, transcriptPath, { outputTokens, timestamp: now }); + return null; + } + if (deltaTokens <= 0) { + writeCache(homeDir, transcriptPath, { outputTokens, timestamp: now }); + return null; + } + if (deltaMs < MIN_DELTA_MS) { + return null; + } + speed = deltaTokens / (deltaMs / 1000); + writeCache(homeDir, transcriptPath, { outputTokens, timestamp: now }); + return speed; +} +//# sourceMappingURL=speed-tracker.js.map \ No newline at end of file diff --git a/back2base-container/vendor/claude-hud/stdin.js b/back2base-container/vendor/claude-hud/stdin.js new file mode 100644 index 0000000..9bdfd8c --- /dev/null +++ b/back2base-container/vendor/claude-hud/stdin.js @@ -0,0 +1,324 @@ +import { AUTOCOMPACT_BUFFER_PERCENT } from './constants.js'; +const DEFAULT_FIRST_BYTE_TIMEOUT_MS = 250; +const DEFAULT_IDLE_TIMEOUT_MS = 30; +const DEFAULT_MAX_STDIN_BYTES = 256 * 1024; +export async function readStdin(stream = process.stdin, options = {}) { + if (stream.isTTY) { + return null; + } + const firstByteTimeoutMs = options.firstByteTimeoutMs ?? DEFAULT_FIRST_BYTE_TIMEOUT_MS; + const idleTimeoutMs = options.idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS; + const maxBytes = options.maxBytes ?? DEFAULT_MAX_STDIN_BYTES; + try { + stream.setEncoding('utf8'); + } + catch { + return null; + } + return await new Promise((resolve) => { + let raw = ''; + let settled = false; + let sawData = false; + let firstByteTimer; + let idleTimer; + const cleanup = () => { + if (firstByteTimer) { + clearTimeout(firstByteTimer); + firstByteTimer = undefined; + } + if (idleTimer) { + clearTimeout(idleTimer); + idleTimer = undefined; + } + stream.off('data', onData); + stream.off('end', onEnd); + stream.off('error', onError); + stream.pause(); + }; + const finish = (value) => { + if (settled) { + return; + } + settled = true; + cleanup(); + resolve(value); + }; + const tryParse = () => { + const trimmed = raw.trim(); + if (!trimmed) { + return null; + } + try { + return JSON.parse(trimmed); + } + catch { + return undefined; + } + }; + const scheduleIdleParse = () => { + if (idleTimer) { + clearTimeout(idleTimer); + } + idleTimer = setTimeout(() => { + const parsed = tryParse(); + finish(parsed ?? null); + }, idleTimeoutMs); + }; + const onData = (chunk) => { + sawData = true; + if (firstByteTimer) { + clearTimeout(firstByteTimer); + firstByteTimer = undefined; + } + raw += String(chunk); + if (Buffer.byteLength(raw, 'utf8') > maxBytes) { + finish(null); + return; + } + const parsed = tryParse(); + if (parsed !== undefined) { + finish(parsed); + return; + } + scheduleIdleParse(); + }; + const onEnd = () => { + const parsed = tryParse(); + finish(parsed ?? null); + }; + const onError = () => { + finish(null); + }; + firstByteTimer = setTimeout(() => { + if (!sawData) { + finish(null); + } + }, firstByteTimeoutMs); + stream.on('data', onData); + stream.on('end', onEnd); + stream.on('error', onError); + }); +} +export function getTotalTokens(stdin) { + const usage = stdin.context_window?.current_usage; + return ((usage?.input_tokens ?? 0) + + (usage?.cache_creation_input_tokens ?? 0) + + (usage?.cache_read_input_tokens ?? 0)); +} +/** + * Get native percentage from Claude Code v2.1.6+ if available. + * Returns null if not available or invalid, triggering fallback to manual calculation. + * + * A value of 0 is treated as "not yet populated": on a fresh session Claude Code + * may emit used_percentage=0 before the first API response arrives, while + * current_usage already contains the real initial-context tokens (system prompt, + * tools, memory files, etc.). Falling through to the token-based calculation + * ensures those tokens are reflected in the context bar from the very first tick. + */ +function getNativePercent(stdin) { + const nativePercent = stdin.context_window?.used_percentage; + if (typeof nativePercent === 'number' && !Number.isNaN(nativePercent) && nativePercent > 0) { + return Math.min(100, Math.max(0, Math.round(nativePercent))); + } + return null; +} +export function getContextPercent(stdin) { + // Prefer native percentage (v2.1.6+) - accurate and matches /context + const native = getNativePercent(stdin); + if (native !== null) { + return native; + } + // Fallback: manual calculation without buffer + const size = stdin.context_window?.context_window_size; + if (!size || size <= 0) { + return 0; + } + const totalTokens = getTotalTokens(stdin); + return Math.min(100, Math.round((totalTokens / size) * 100)); +} +export function getBufferedPercent(stdin) { + // Prefer native percentage (v2.1.6+) so the HUD matches Claude Code's + // own context output. The buffered fallback only approximates older versions. + const native = getNativePercent(stdin); + if (native !== null) { + return native; + } + // Fallback: manual calculation with buffer for older Claude Code versions + const size = stdin.context_window?.context_window_size; + if (!size || size <= 0) { + return 0; + } + const totalTokens = getTotalTokens(stdin); + // Scale buffer by raw usage: no buffer at ≤5% (e.g. after /clear), + // full buffer at ≥50%. Autocompact doesn't kick in at very low usage. + const rawRatio = totalTokens / size; + const LOW = 0.05; + const HIGH = 0.50; + const scale = Math.min(1, Math.max(0, (rawRatio - LOW) / (HIGH - LOW))); + const buffer = size * AUTOCOMPACT_BUFFER_PERCENT * scale; + return Math.min(100, Math.round(((totalTokens + buffer) / size) * 100)); +} +// Enterprise plan alias → human-readable display name +const ENTERPRISE_ALIAS_LABELS = { + opusplan: 'Claude Opus', + sonnetplan: 'Claude Sonnet', + haikuplan: 'Claude Haiku', +}; +export function getModelName(stdin) { + const displayName = stdin.model?.display_name?.trim(); + if (displayName) { + return displayName; + } + const modelId = stdin.model?.id?.trim(); + if (!modelId) { + return 'Unknown'; + } + // Resolve enterprise plan aliases to readable labels + const enterpriseLabel = ENTERPRISE_ALIAS_LABELS[modelId.toLowerCase()]; + if (enterpriseLabel) { + return enterpriseLabel; + } + const normalizedBedrockLabel = normalizeBedrockModelLabel(modelId); + return normalizedBedrockLabel ?? modelId; +} +export function isBedrockModelId(modelId) { + if (!modelId) { + return false; + } + const normalized = modelId.toLowerCase(); + return normalized.includes('anthropic.claude-'); +} +// Vertex AI model IDs use '@' as version separator (e.g. claude-3-5-sonnet@20241022) +export function isVertexModelId(modelId) { + if (!modelId) { + return false; + } + return modelId.includes('@'); +} +const ENTERPRISE_MODEL_IDS = new Set(['opusplan', 'sonnetplan', 'haikuplan']); +export function isEnterpriseModelId(modelId) { + if (!modelId) { + return false; + } + return ENTERPRISE_MODEL_IDS.has(modelId.toLowerCase()); +} +export function getProviderLabel(stdin) { + if (process.env.CLAUDE_CODE_USE_BEDROCK === '1') { + return 'Bedrock'; + } + if (process.env.CLAUDE_CODE_USE_VERTEX === '1') { + return 'Vertex'; + } + if (isEnterpriseModelId(stdin.model?.id)) { + return 'Enterprise'; + } + return null; +} +export function shouldHideUsage(stdin) { + return getProviderLabel(stdin) === 'Bedrock' || isBedrockModelId(stdin.model?.id); +} +function parseRateLimitPercent(value) { + if (typeof value !== 'number' || !Number.isFinite(value)) { + return null; + } + return Math.round(Math.min(100, Math.max(0, value))); +} +function parseRateLimitResetAt(value) { + if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) { + return null; + } + return new Date(value * 1000); +} +export function getUsageFromStdin(stdin) { + const rateLimits = stdin.rate_limits; + if (!rateLimits) { + return null; + } + const fiveHour = parseRateLimitPercent(rateLimits.five_hour?.used_percentage); + const sevenDay = parseRateLimitPercent(rateLimits.seven_day?.used_percentage); + if (fiveHour === null && sevenDay === null) { + return null; + } + return { + fiveHour, + sevenDay, + fiveHourResetAt: parseRateLimitResetAt(rateLimits.five_hour?.resets_at), + sevenDayResetAt: parseRateLimitResetAt(rateLimits.seven_day?.resets_at), + }; +} +/** + * Strips redundant context-window size suffixes from model display names. + * + * Claude Code may include the context window size in the display name + * (e.g. "Opus 4.6 (1M context)"), but the HUD already shows context + * usage via the context bar — so the parenthetical is redundant. + */ +export function stripContextSuffix(name) { + return name.replace(/\s*\([^)]*\bcontext\b[^)]*\)/i, '').trim(); +} +/** + * Formats a model name according to the user's chosen display settings. + * + * When `override` is set, it replaces the model name entirely. + * Otherwise, `format` controls how the raw name is abbreviated: + * + * full: Return raw name unchanged (e.g. "Opus 4.6 (1M context)") + * compact: Strip context-window suffix (e.g. "Opus 4.6") + * short: Strip context suffix AND leading "Claude " prefix (e.g. "Opus 4.6") + */ +export function formatModelName(name, format, override) { + if (override) { + return override; + } + if (!format || format === 'full') { + return name; + } + let result = stripContextSuffix(name); + if (format === 'short') { + result = result.replace(/^Claude\s+/i, ''); + } + return result; +} +function normalizeBedrockModelLabel(modelId) { + if (!isBedrockModelId(modelId)) { + return null; + } + const lowercaseId = modelId.toLowerCase(); + const claudePrefix = 'anthropic.claude-'; + const claudeIndex = lowercaseId.indexOf(claudePrefix); + if (claudeIndex === -1) { + return null; + } + let suffix = lowercaseId.slice(claudeIndex + claudePrefix.length); + suffix = suffix.replace(/-v\d+:\d+$/, ''); + suffix = suffix.replace(/-\d{8}$/, ''); + const tokens = suffix.split('-').filter(Boolean); + if (tokens.length === 0) { + return null; + } + const familyIndex = tokens.findIndex((token) => token === 'haiku' || token === 'sonnet' || token === 'opus'); + if (familyIndex === -1) { + return null; + } + const family = tokens[familyIndex]; + const beforeVersion = readNumericVersion(tokens, familyIndex - 1, -1).reverse(); + const afterVersion = readNumericVersion(tokens, familyIndex + 1, 1); + const versionParts = beforeVersion.length >= afterVersion.length ? beforeVersion : afterVersion; + const version = versionParts.length ? versionParts.join('.') : null; + const familyLabel = family[0].toUpperCase() + family.slice(1); + return version ? `Claude ${familyLabel} ${version}` : `Claude ${familyLabel}`; +} +function readNumericVersion(tokens, startIndex, step) { + const parts = []; + for (let i = startIndex; i >= 0 && i < tokens.length; i += step) { + if (!/^\d+$/.test(tokens[i])) { + break; + } + parts.push(tokens[i]); + if (parts.length === 2) { + break; + } + } + return parts; +} +//# sourceMappingURL=stdin.js.map \ No newline at end of file diff --git a/back2base-container/vendor/claude-hud/transcript.js b/back2base-container/vendor/claude-hud/transcript.js new file mode 100644 index 0000000..3698e27 --- /dev/null +++ b/back2base-container/vendor/claude-hud/transcript.js @@ -0,0 +1,439 @@ +import * as fs from 'fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import * as readline from 'readline'; +import { createHash } from 'node:crypto'; +import { getHudPluginDir } from './claude-config-dir.js'; +const TRANSCRIPT_CACHE_VERSION = 4; +let createReadStreamImpl = fs.createReadStream; +function normalizeTokenCount(value) { + if (typeof value !== 'number' || !Number.isFinite(value)) { + return 0; + } + return Math.max(0, Math.trunc(value)); +} +function normalizeSessionTokens(tokens) { + if (!tokens || typeof tokens !== 'object') { + return undefined; + } + const raw = tokens; + return { + inputTokens: normalizeTokenCount(raw.inputTokens), + outputTokens: normalizeTokenCount(raw.outputTokens), + cacheCreationTokens: normalizeTokenCount(raw.cacheCreationTokens), + cacheReadTokens: normalizeTokenCount(raw.cacheReadTokens), + }; +} +function getTranscriptCachePath(transcriptPath, homeDir) { + const hash = createHash('sha256').update(path.resolve(transcriptPath)).digest('hex'); + return path.join(getHudPluginDir(homeDir), 'transcript-cache', `${hash}.json`); +} +function canonicalizeTranscriptPath(transcriptPath) { + try { + return fs.realpathSync(transcriptPath); + } + catch { + return null; + } +} +function readTranscriptFileState(transcriptPath) { + try { + const stat = fs.statSync(transcriptPath); + if (!stat.isFile()) { + return null; + } + return { + mtimeMs: stat.mtimeMs, + size: stat.size, + }; + } + catch { + return null; + } +} +function serializeTranscriptData(data) { + return { + tools: data.tools.map((tool) => ({ + ...tool, + startTime: tool.startTime.toISOString(), + endTime: tool.endTime?.toISOString(), + })), + agents: data.agents.map((agent) => ({ + ...agent, + startTime: agent.startTime.toISOString(), + endTime: agent.endTime?.toISOString(), + })), + todos: data.todos.map((todo) => ({ ...todo })), + sessionStart: data.sessionStart?.toISOString(), + sessionName: data.sessionName, + lastAssistantResponseAt: data.lastAssistantResponseAt?.toISOString(), + sessionTokens: data.sessionTokens, + lastCompactBoundaryAt: data.lastCompactBoundaryAt?.toISOString(), + lastCompactPostTokens: data.lastCompactPostTokens, + }; +} +function deserializeTranscriptData(data) { + return { + tools: data.tools.map((tool) => ({ + ...tool, + startTime: new Date(tool.startTime), + endTime: tool.endTime ? new Date(tool.endTime) : undefined, + })), + agents: data.agents.map((agent) => ({ + ...agent, + startTime: new Date(agent.startTime), + endTime: agent.endTime ? new Date(agent.endTime) : undefined, + })), + todos: data.todos.map((todo) => ({ ...todo })), + sessionStart: data.sessionStart ? new Date(data.sessionStart) : undefined, + sessionName: data.sessionName, + lastAssistantResponseAt: data.lastAssistantResponseAt ? new Date(data.lastAssistantResponseAt) : undefined, + sessionTokens: normalizeSessionTokens(data.sessionTokens), + lastCompactBoundaryAt: data.lastCompactBoundaryAt ? new Date(data.lastCompactBoundaryAt) : undefined, + lastCompactPostTokens: typeof data.lastCompactPostTokens === 'number' ? data.lastCompactPostTokens : undefined, + }; +} +function readTranscriptCache(transcriptPath, state) { + try { + const cachePath = getTranscriptCachePath(transcriptPath, os.homedir()); + const raw = fs.readFileSync(cachePath, 'utf8'); + const parsed = JSON.parse(raw); + if (parsed.version !== TRANSCRIPT_CACHE_VERSION + || !parsed.data + || !parsed.transcriptPath + || parsed.transcriptPath !== path.resolve(transcriptPath) + || parsed.transcriptState?.mtimeMs !== state.mtimeMs + || parsed.transcriptState?.size !== state.size) { + return null; + } + return deserializeTranscriptData(parsed.data); + } + catch { + return null; + } +} +function writeTranscriptCache(transcriptPath, state, data) { + try { + const cachePath = getTranscriptCachePath(transcriptPath, os.homedir()); + fs.mkdirSync(path.dirname(cachePath), { recursive: true }); + const payload = { + version: TRANSCRIPT_CACHE_VERSION, + transcriptPath: path.resolve(transcriptPath), + transcriptState: state, + data: serializeTranscriptData(data), + }; + fs.writeFileSync(cachePath, JSON.stringify(payload), { encoding: 'utf8', mode: 0o600 }); + } + catch { + // Cache failures are non-fatal; fall back to fresh parsing next time. + } +} +export async function parseTranscript(transcriptPath) { + const result = { + tools: [], + agents: [], + todos: [], + }; + if (!transcriptPath || !fs.existsSync(transcriptPath)) { + return result; + } + const canonicalTranscriptPath = canonicalizeTranscriptPath(transcriptPath); + if (!canonicalTranscriptPath) { + return result; + } + const transcriptState = readTranscriptFileState(canonicalTranscriptPath); + if (!transcriptState) { + return result; + } + const cached = readTranscriptCache(canonicalTranscriptPath, transcriptState); + if (cached) { + return cached; + } + const toolMap = new Map(); + const agentMap = new Map(); + let latestTodos = []; + const taskIdToIndex = new Map(); + const queueCompletionMap = new Map(); + let latestSlug; + let customTitle; + let lastCompactBoundaryAt; + let lastCompactPostTokens; + const sessionTokens = { + inputTokens: 0, + outputTokens: 0, + cacheCreationTokens: 0, + cacheReadTokens: 0, + }; + let parsedCleanly = false; + try { + const fileStream = createReadStreamImpl(canonicalTranscriptPath); + const rl = readline.createInterface({ + input: fileStream, + crlfDelay: Infinity, + }); + for await (const line of rl) { + if (!line.trim()) + continue; + try { + const entry = JSON.parse(line); + if (entry.type === 'custom-title' && typeof entry.customTitle === 'string') { + customTitle = entry.customTitle; + } + else if (typeof entry.slug === 'string') { + latestSlug = entry.slug; + } + // Accumulate token usage from assistant messages + if (entry.type === 'assistant' && entry.message?.usage) { + const usage = entry.message.usage; + sessionTokens.inputTokens += normalizeTokenCount(usage.input_tokens); + sessionTokens.outputTokens += normalizeTokenCount(usage.output_tokens); + sessionTokens.cacheCreationTokens += normalizeTokenCount(usage.cache_creation_input_tokens); + sessionTokens.cacheReadTokens += normalizeTokenCount(usage.cache_read_input_tokens); + } + // Track Claude Code's compact_boundary marker. Both manual (/compact) + // and auto compaction emit this system entry with compactMetadata; we + // take the most recent one's timestamp so callers can distinguish a + // legitimate post-compact zero frame from a transient stdin glitch. + if (entry.type === 'system' && entry.subtype === 'compact_boundary') { + const ts = entry.timestamp ? new Date(entry.timestamp) : null; + if (ts && !Number.isNaN(ts.getTime())) { + if (!lastCompactBoundaryAt || ts.getTime() > lastCompactBoundaryAt.getTime()) { + lastCompactBoundaryAt = ts; + const post = entry.compactMetadata?.postTokens; + lastCompactPostTokens = typeof post === 'number' && Number.isFinite(post) && post >= 0 + ? Math.trunc(post) + : undefined; + } + } + } + // Capture accurate background-agent completion timestamps from queue-operation entries. + // The tool_result timestamp in the parent transcript is written at launch time, not + // when the agent actually finishes, so we override with the enqueue timestamp. + if (entry.type === 'queue-operation' && entry.operation === 'enqueue' && entry.content) { + const taskIdMatch = entry.content.match(/([^<]+)<\/task-id>/); + const toolUseIdMatch = entry.content.match(/([^<]+)<\/tool-use-id>/); + if (taskIdMatch && toolUseIdMatch && entry.timestamp) { + const ts = new Date(entry.timestamp); + if (!Number.isNaN(ts.getTime())) { + queueCompletionMap.set(toolUseIdMatch[1], ts); + } + } + } + processEntry(entry, toolMap, agentMap, taskIdToIndex, latestTodos, result); + } + catch { + // Skip malformed lines + } + } + parsedCleanly = true; + } + catch { + // Return partial results on error + } + // Resolve agent completion: prefer queue-operation timestamps (accurate for + // background agents), fall back to tool_result timestamps (inline agents). + // Status is deferred so background agents show ◐ until they truly finish. + for (const [toolUseId, endTime] of queueCompletionMap) { + const agent = agentMap.get(toolUseId); + if (agent?.background) { + agent.endTime = endTime; + agent.status = 'completed'; + } + } + for (const agent of agentMap.values()) { + if (agent.status === 'running' && agent.endTime) { + agent.status = 'completed'; + } + } + result.tools = Array.from(toolMap.values()).slice(-20); + result.agents = Array.from(agentMap.values()).slice(-10); + result.todos = latestTodos; + result.sessionName = customTitle ?? latestSlug; + result.sessionTokens = sessionTokens; + result.lastCompactBoundaryAt = lastCompactBoundaryAt; + result.lastCompactPostTokens = lastCompactPostTokens; + if (parsedCleanly) { + writeTranscriptCache(canonicalTranscriptPath, transcriptState, result); + } + return result; +} +export function _setCreateReadStreamForTests(impl) { + createReadStreamImpl = impl ?? fs.createReadStream; +} +function processEntry(entry, toolMap, agentMap, taskIdToIndex, latestTodos, result) { + const timestamp = entry.timestamp ? new Date(entry.timestamp) : new Date(); + const hasValidTimestamp = !Number.isNaN(timestamp.getTime()); + if (!result.sessionStart && entry.timestamp && hasValidTimestamp) { + result.sessionStart = timestamp; + } + if (entry.type === 'assistant' && entry.timestamp && hasValidTimestamp) { + result.lastAssistantResponseAt = timestamp; + } + const content = entry.message?.content; + if (!content || !Array.isArray(content)) + return; + for (const block of content) { + if (block.type === 'tool_use' && block.id && block.name) { + const toolEntry = { + id: block.id, + name: block.name, + target: extractTarget(block.name, block.input), + status: 'running', + startTime: timestamp, + }; + if (block.name === 'Task' || block.name === 'Agent') { + const input = block.input; + const agentEntry = { + id: block.id, + type: input?.subagent_type ?? 'agent', + model: input?.model ?? undefined, + description: input?.description ?? undefined, + status: 'running', + startTime: timestamp, + background: input?.run_in_background === true, + }; + agentMap.set(block.id, agentEntry); + } + else if (block.name === 'TodoWrite') { + const input = block.input; + if (input?.todos && Array.isArray(input.todos)) { + // Build a FIFO queue of taskIds per content string, ordered by the + // old array position. Two todos that share the same content must + // each get their own taskId back after the rebuild, so we cannot + // collapse duplicates to one index. + const contentToTaskIds = new Map(); + const taskIdsByOldIndex = []; + for (const [taskId, idx] of taskIdToIndex) { + if (idx < latestTodos.length) { + taskIdsByOldIndex.push([idx, taskId]); + } + } + taskIdsByOldIndex.sort((a, b) => a[0] - b[0]); + for (const [idx, taskId] of taskIdsByOldIndex) { + const content = latestTodos[idx].content; + const ids = contentToTaskIds.get(content) ?? []; + ids.push(taskId); + contentToTaskIds.set(content, ids); + } + latestTodos.length = 0; + taskIdToIndex.clear(); + latestTodos.push(...input.todos); + // Consume one queued taskId per new todo that matches by content, + // so duplicate-content items still each get their own taskId. + for (let i = 0; i < latestTodos.length; i++) { + const ids = contentToTaskIds.get(latestTodos[i].content); + if (ids && ids.length > 0) { + const taskId = ids.shift(); + taskIdToIndex.set(taskId, i); + if (ids.length === 0) { + contentToTaskIds.delete(latestTodos[i].content); + } + } + } + } + } + else if (block.name === 'TaskCreate') { + const input = block.input; + const subject = typeof input?.subject === 'string' ? input.subject : ''; + const description = typeof input?.description === 'string' ? input.description : ''; + const content = subject || description || 'Untitled task'; + const status = normalizeTaskStatus(input?.status) ?? 'pending'; + latestTodos.push({ content, status }); + const rawTaskId = input?.taskId; + const taskId = typeof rawTaskId === 'string' || typeof rawTaskId === 'number' + ? String(rawTaskId) + : block.id; + if (taskId) { + taskIdToIndex.set(taskId, latestTodos.length - 1); + } + } + else if (block.name === 'TaskUpdate') { + const input = block.input; + const index = resolveTaskIndex(input?.taskId, taskIdToIndex, latestTodos); + if (index !== null) { + const status = normalizeTaskStatus(input?.status); + if (status) { + latestTodos[index].status = status; + } + const subject = typeof input?.subject === 'string' ? input.subject : ''; + const description = typeof input?.description === 'string' ? input.description : ''; + const content = subject || description; + if (content) { + latestTodos[index].content = content; + } + } + } + else { + toolMap.set(block.id, toolEntry); + } + } + if (block.type === 'tool_result' && block.tool_use_id) { + const tool = toolMap.get(block.tool_use_id); + if (tool) { + tool.status = block.is_error ? 'error' : 'completed'; + tool.endTime = timestamp; + } + const agent = agentMap.get(block.tool_use_id); + if (agent && !agent.background) { + agent.endTime = timestamp; + } + } + } +} +function extractTarget(toolName, input) { + if (!input) + return undefined; + switch (toolName) { + case 'Read': + case 'Write': + case 'Edit': + return input.file_path ?? input.path; + case 'Glob': + return input.pattern; + case 'Grep': + return input.pattern; + case 'Skill': + return typeof input.skill === 'string' && input.skill.trim().length > 0 + ? input.skill + : undefined; + case 'Bash': + const cmd = input.command; + return cmd?.slice(0, 30) + (cmd?.length > 30 ? '...' : ''); + } + return undefined; +} +function resolveTaskIndex(taskId, taskIdToIndex, latestTodos) { + if (typeof taskId === 'string' || typeof taskId === 'number') { + const key = String(taskId); + const mapped = taskIdToIndex.get(key); + if (typeof mapped === 'number') { + return mapped; + } + if (/^\d+$/.test(key)) { + const numericIndex = Number.parseInt(key, 10) - 1; + if (numericIndex >= 0 && numericIndex < latestTodos.length) { + return numericIndex; + } + } + } + return null; +} +function normalizeTaskStatus(status) { + if (typeof status !== 'string') + return null; + switch (status) { + case 'pending': + case 'not_started': + return 'pending'; + case 'in_progress': + case 'running': + return 'in_progress'; + case 'completed': + case 'complete': + case 'done': + return 'completed'; + default: + return null; + } +} +//# sourceMappingURL=transcript.js.map \ No newline at end of file diff --git a/back2base-container/vendor/claude-hud/types.js b/back2base-container/vendor/claude-hud/types.js new file mode 100644 index 0000000..6f773f9 --- /dev/null +++ b/back2base-container/vendor/claude-hud/types.js @@ -0,0 +1,5 @@ +/** Check if usage limit is reached (either window at 100%) */ +export function isLimitReached(data) { + return data.fiveHour === 100 || data.sevenDay === 100; +} +//# sourceMappingURL=types.js.map \ No newline at end of file diff --git a/back2base-container/vendor/claude-hud/usage-api.js b/back2base-container/vendor/claude-hud/usage-api.js new file mode 100644 index 0000000..fd0d281 --- /dev/null +++ b/back2base-container/vendor/claude-hud/usage-api.js @@ -0,0 +1,945 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import * as net from 'net'; +import * as tls from 'tls'; +import * as https from 'https'; +import { execFileSync } from 'child_process'; +import { createHash } from 'crypto'; +import { createDebug } from './debug.js'; +import { getClaudeConfigDir, getHudPluginDir } from './claude-config-dir.js'; +const debug = createDebug('usage'); +const LEGACY_KEYCHAIN_SERVICE_NAME = 'Claude Code-credentials'; +// File-based cache (HUD runs as new process each render, so in-memory cache won't persist) +const CACHE_TTL_MS = 5 * 60_000; // 5 minutes — matches Anthropic usage API rate limit window +const CACHE_FAILURE_TTL_MS = 15_000; // 15 seconds for failed requests +const CACHE_RATE_LIMITED_BASE_MS = 60_000; // 60s base for 429 backoff +const CACHE_RATE_LIMITED_MAX_MS = 5 * 60_000; // 5 min max backoff +const CACHE_LOCK_STALE_MS = 30_000; +const CACHE_LOCK_WAIT_MS = 2_000; +const CACHE_LOCK_POLL_MS = 50; +const KEYCHAIN_TIMEOUT_MS = 3000; +const KEYCHAIN_BACKOFF_MS = 60_000; // Backoff on keychain failures to avoid re-prompting +const USAGE_API_TIMEOUT_MS_DEFAULT = 15_000; +export const USAGE_API_USER_AGENT = 'claude-code/2.1'; +/** + * Check if user is using a custom API endpoint instead of the default Anthropic API. + * When using custom providers (e.g., via cc-switch), the OAuth usage API is not applicable. + */ +function isUsingCustomApiEndpoint(env = process.env) { + const baseUrl = env.ANTHROPIC_BASE_URL?.trim() || env.ANTHROPIC_API_BASE_URL?.trim(); + // No custom endpoint configured - using default Anthropic API + if (!baseUrl) { + return false; + } + try { + return new URL(baseUrl).origin !== 'https://api.anthropic.com'; + } + catch { + return true; + } +} +function getCachePath(homeDir) { + return path.join(getHudPluginDir(homeDir), '.usage-cache.json'); +} +function getCacheLockPath(homeDir) { + return path.join(getHudPluginDir(homeDir), '.usage-cache.lock'); +} +function hydrateCacheData(data) { + // JSON.stringify converts Date to ISO string, so we need to reconvert on read. + // new Date() handles both Date objects and ISO strings safely. + if (data.fiveHourResetAt) { + data.fiveHourResetAt = new Date(data.fiveHourResetAt); + } + if (data.sevenDayResetAt) { + data.sevenDayResetAt = new Date(data.sevenDayResetAt); + } + return data; +} +function getRateLimitedTtlMs(count) { + // Exponential backoff: 60s, 120s, 240s, capped at 5 min + return Math.min(CACHE_RATE_LIMITED_BASE_MS * Math.pow(2, Math.max(0, count - 1)), CACHE_RATE_LIMITED_MAX_MS); +} +function getRateLimitedRetryUntil(cache) { + if (cache.data.apiError !== 'rate-limited') { + return null; + } + if (cache.retryAfterUntil && cache.retryAfterUntil > cache.timestamp) { + return cache.retryAfterUntil; + } + if (cache.rateLimitedCount && cache.rateLimitedCount > 0) { + return cache.timestamp + getRateLimitedTtlMs(cache.rateLimitedCount); + } + return null; +} +function withRateLimitedSyncing(data) { + return { + ...data, + apiError: 'rate-limited', + }; +} +function readCacheState(homeDir, now, ttls) { + try { + const cachePath = getCachePath(homeDir); + if (!fs.existsSync(cachePath)) + return null; + const content = fs.readFileSync(cachePath, 'utf8'); + const cache = JSON.parse(content); + // Only serve lastGoodData during rate-limit backoff. Other failures should remain visible. + const displayData = (cache.data.apiError === 'rate-limited' && cache.lastGoodData) + ? withRateLimitedSyncing(cache.lastGoodData) + : cache.data; + const rateLimitedRetryUntil = getRateLimitedRetryUntil(cache); + if (rateLimitedRetryUntil && now < rateLimitedRetryUntil) { + return { data: hydrateCacheData(displayData), timestamp: cache.timestamp, isFresh: true }; + } + const ttl = cache.data.apiUnavailable ? ttls.failureCacheTtlMs : ttls.cacheTtlMs; + return { + data: hydrateCacheData(displayData), + timestamp: cache.timestamp, + isFresh: now - cache.timestamp < ttl, + }; + } + catch { + return null; + } +} +function readRateLimitedCount(homeDir) { + try { + const cachePath = getCachePath(homeDir); + if (!fs.existsSync(cachePath)) + return 0; + const content = fs.readFileSync(cachePath, 'utf8'); + const cache = JSON.parse(content); + return cache.rateLimitedCount ?? 0; + } + catch { + return 0; + } +} +function readLastGoodData(homeDir) { + try { + const cachePath = getCachePath(homeDir); + if (!fs.existsSync(cachePath)) + return null; + const content = fs.readFileSync(cachePath, 'utf8'); + const cache = JSON.parse(content); + return cache.lastGoodData ? hydrateCacheData(cache.lastGoodData) : null; + } + catch { + return null; + } +} +function readCachedPlanName(homeDir) { + try { + const cachePath = getCachePath(homeDir); + if (!fs.existsSync(cachePath)) + return null; + const content = fs.readFileSync(cachePath, 'utf8'); + const cache = JSON.parse(content); + return cache.data.planName ?? cache.lastGoodData?.planName ?? null; + } + catch { + return null; + } +} +function readCache(homeDir, now, ttls) { + const cache = readCacheState(homeDir, now, ttls); + return cache?.isFresh ? cache.data : null; +} +function writeCache(homeDir, data, timestamp, opts) { + try { + const cachePath = getCachePath(homeDir); + const cacheDir = path.dirname(cachePath); + if (!fs.existsSync(cacheDir)) { + fs.mkdirSync(cacheDir, { recursive: true }); + } + const cache = { data, timestamp }; + if (opts?.rateLimitedCount && opts.rateLimitedCount > 0) { + cache.rateLimitedCount = opts.rateLimitedCount; + } + if (opts?.retryAfterUntil) { + cache.retryAfterUntil = opts.retryAfterUntil; + } + if (opts?.lastGoodData) { + cache.lastGoodData = opts.lastGoodData; + } + fs.writeFileSync(cachePath, JSON.stringify(cache), 'utf8'); + } + catch { + // Ignore cache write failures + } +} +function readLockTimestamp(lockPath) { + try { + if (!fs.existsSync(lockPath)) + return null; + const raw = fs.readFileSync(lockPath, 'utf8').trim(); + const parsed = Number.parseInt(raw, 10); + return Number.isFinite(parsed) ? parsed : null; + } + catch { + return null; + } +} +function tryAcquireCacheLock(homeDir) { + const lockPath = getCacheLockPath(homeDir); + const cacheDir = path.dirname(lockPath); + try { + if (!fs.existsSync(cacheDir)) { + fs.mkdirSync(cacheDir, { recursive: true }); + } + const fd = fs.openSync(lockPath, 'wx'); + try { + fs.writeFileSync(fd, String(Date.now()), 'utf8'); + } + finally { + fs.closeSync(fd); + } + return 'acquired'; + } + catch (error) { + const maybeError = error; + if (maybeError.code !== 'EEXIST') { + debug('Usage cache lock unavailable, continuing without coordination:', maybeError.message); + return 'unsupported'; + } + } + const lockTimestamp = readLockTimestamp(lockPath); + // Unparseable timestamp — use mtime to distinguish a crash leftover from an active writer. + if (lockTimestamp === null) { + try { + const lockStat = fs.statSync(lockPath); + if (Date.now() - lockStat.mtimeMs < CACHE_LOCK_STALE_MS) { + return 'busy'; + } + } + catch { + return tryAcquireCacheLock(homeDir); + } + try { + fs.unlinkSync(lockPath); + } + catch { + return 'busy'; + } + return tryAcquireCacheLock(homeDir); + } + if (lockTimestamp != null && Date.now() - lockTimestamp > CACHE_LOCK_STALE_MS) { + try { + fs.unlinkSync(lockPath); + } + catch { + return 'busy'; + } + return tryAcquireCacheLock(homeDir); + } + return 'busy'; +} +function releaseCacheLock(homeDir) { + try { + const lockPath = getCacheLockPath(homeDir); + if (fs.existsSync(lockPath)) { + fs.unlinkSync(lockPath); + } + } + catch { + // Ignore lock cleanup failures + } +} +async function waitForFreshCache(homeDir, now, ttls, timeoutMs = CACHE_LOCK_WAIT_MS) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, CACHE_LOCK_POLL_MS)); + const cached = readCache(homeDir, now(), ttls); + if (cached) { + return cached; + } + if (!fs.existsSync(getCacheLockPath(homeDir))) { + break; + } + } + return readCache(homeDir, now(), ttls); +} +const defaultDeps = { + homeDir: () => os.homedir(), + fetchApi: fetchUsageApi, + now: () => Date.now(), + readKeychain: readKeychainCredentials, + ttls: { cacheTtlMs: CACHE_TTL_MS, failureCacheTtlMs: CACHE_FAILURE_TTL_MS }, +}; +/** + * Get OAuth usage data from Anthropic API. + * Returns null if user is an API user (no OAuth credentials) or credentials are expired. + * Returns { apiUnavailable: true, ... } if API call fails (to show warning in HUD). + * + * Uses file-based cache since HUD runs as a new process each render (~300ms). + * Cache TTL is configurable via usage.cacheTtlSeconds / usage.failureCacheTtlSeconds in config.json + * (defaults: 60s for success, 15s for failures). + */ +export async function getUsage(overrides = {}) { + const deps = { ...defaultDeps, ...overrides }; + const now = deps.now(); + const homeDir = deps.homeDir(); + // Skip usage API if user is using a custom provider + if (isUsingCustomApiEndpoint()) { + debug('Skipping usage API: custom API endpoint configured'); + return null; + } + // Check file-based cache first + const cacheState = readCacheState(homeDir, now, deps.ttls); + if (cacheState?.isFresh) { + return cacheState.data; + } + let holdsCacheLock = false; + const lockStatus = tryAcquireCacheLock(homeDir); + if (lockStatus === 'busy') { + if (cacheState) { + return cacheState.data; + } + return await waitForFreshCache(homeDir, deps.now, deps.ttls); + } + holdsCacheLock = lockStatus === 'acquired'; + try { + const refreshedCache = readCache(homeDir, deps.now(), deps.ttls); + if (refreshedCache) { + return refreshedCache; + } + const credentials = readCredentials(homeDir, now, deps.readKeychain); + if (!credentials) { + return null; + } + const { accessToken, subscriptionType } = credentials; + // Determine plan name from subscriptionType + let planName = getPlanName(subscriptionType); + if (!planName) { + // Only fall back to cache when subscriptionType is genuinely missing or + // empty after an OAuth refresh. Explicit values like "api" should remain + // API users with no usage display. + if (!subscriptionType.trim()) { + const cached = readCacheState(homeDir, now, deps.ttls); + if (cached?.data?.planName) { + planName = cached.data.planName; + } + } + if (!planName) { + return null; + } + } + // Fetch usage from API + const apiResult = await deps.fetchApi(accessToken); + if (!apiResult.data) { + const isRateLimited = apiResult.error === 'rate-limited'; + const prevCount = readRateLimitedCount(homeDir); + const rateLimitedCount = isRateLimited ? prevCount + 1 : 0; + const retryAfterUntil = isRateLimited && apiResult.retryAfterSec + ? now + apiResult.retryAfterSec * 1000 + : undefined; + const backoffOpts = { + rateLimitedCount: isRateLimited ? rateLimitedCount : undefined, + retryAfterUntil, + }; + const failureResult = { + planName, + fiveHour: null, + sevenDay: null, + fiveHourResetAt: null, + sevenDayResetAt: null, + apiUnavailable: true, + apiError: apiResult.error, + }; + if (isRateLimited) { + const staleCache = readCacheState(homeDir, now, deps.ttls); + const lastGood = readLastGoodData(homeDir); + const goodData = (staleCache && !staleCache.data.apiUnavailable) + ? staleCache.data + : lastGood; + if (goodData) { + // Preserve the backoff state in cache, but keep rendering the last successful values + // with a syncing hint so stale data is visible to the user. + writeCache(homeDir, failureResult, now, { ...backoffOpts, lastGoodData: goodData }); + return withRateLimitedSyncing(goodData); + } + } + writeCache(homeDir, failureResult, now, backoffOpts); + return failureResult; + } + // Parse response - API returns 0-100 percentage directly + // Clamp to 0-100 and handle NaN/Infinity + const fiveHour = parseUtilization(apiResult.data.five_hour?.utilization); + const sevenDay = parseUtilization(apiResult.data.seven_day?.utilization); + const fiveHourResetAt = parseDate(apiResult.data.five_hour?.resets_at); + const sevenDayResetAt = parseDate(apiResult.data.seven_day?.resets_at); + const result = { + planName, + fiveHour, + sevenDay, + fiveHourResetAt, + sevenDayResetAt, + }; + // Write to file cache — also store as lastGoodData for rate-limit resilience + writeCache(homeDir, result, now, { lastGoodData: result }); + return result; + } + catch (error) { + debug('getUsage failed:', error); + return null; + } + finally { + if (holdsCacheLock) { + releaseCacheLock(homeDir); + } + } +} +/** + * Get path for keychain failure backoff cache. + * Separate from usage cache to track keychain-specific failures. + */ +function getKeychainBackoffPath(homeDir) { + return path.join(getHudPluginDir(homeDir), '.keychain-backoff'); +} +/** + * Check if we're in keychain backoff period (recent failure/timeout). + * Prevents re-prompting user on every render cycle. + */ +function isKeychainBackoff(homeDir, now) { + try { + const backoffPath = getKeychainBackoffPath(homeDir); + if (!fs.existsSync(backoffPath)) + return false; + const timestamp = parseInt(fs.readFileSync(backoffPath, 'utf8'), 10); + return now - timestamp < KEYCHAIN_BACKOFF_MS; + } + catch { + return false; + } +} +/** + * Record keychain failure for backoff. + */ +function recordKeychainFailure(homeDir, now) { + try { + const backoffPath = getKeychainBackoffPath(homeDir); + const dir = path.dirname(backoffPath); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + fs.writeFileSync(backoffPath, String(now), 'utf8'); + } + catch { + // Ignore write failures + } +} +/** + * Determine the macOS Keychain service name for Claude Code credentials. + * Claude Code uses the default service for ~/.claude and a hashed suffix for custom config directories. + */ +export function getKeychainServiceName(configDir, homeDir) { + const normalizedConfigDir = path.normalize(path.resolve(configDir)); + const normalizedDefaultDir = path.normalize(path.resolve(path.join(homeDir, '.claude'))); + if (normalizedConfigDir === normalizedDefaultDir) { + return LEGACY_KEYCHAIN_SERVICE_NAME; + } + const hash = createHash('sha256').update(normalizedConfigDir).digest('hex').slice(0, 8); + return `${LEGACY_KEYCHAIN_SERVICE_NAME}-${hash}`; +} +export function getKeychainServiceNames(configDir, homeDir, env = process.env) { + const serviceNames = [getKeychainServiceName(configDir, homeDir)]; + const envConfigDir = env.CLAUDE_CONFIG_DIR?.trim(); + if (envConfigDir) { + const normalizedDefaultDir = path.normalize(path.resolve(path.join(homeDir, '.claude'))); + const normalizedEnvDir = path.normalize(path.resolve(envConfigDir)); + if (normalizedEnvDir === normalizedDefaultDir) { + serviceNames.push(LEGACY_KEYCHAIN_SERVICE_NAME); + } + else { + const envHash = createHash('sha256').update(envConfigDir).digest('hex').slice(0, 8); + serviceNames.push(`${LEGACY_KEYCHAIN_SERVICE_NAME}-${envHash}`); + } + } + serviceNames.push(LEGACY_KEYCHAIN_SERVICE_NAME); + return [...new Set(serviceNames)]; +} +function isMissingKeychainItemError(error) { + if (!error || typeof error !== 'object') + return false; + const maybeError = error; + if (maybeError.status === 44) + return true; + const message = typeof maybeError.message === 'string' ? maybeError.message.toLowerCase() : ''; + if (message.includes('could not be found in the keychain')) + return true; + const stderr = typeof maybeError.stderr === 'string' + ? maybeError.stderr.toLowerCase() + : Buffer.isBuffer(maybeError.stderr) + ? maybeError.stderr.toString('utf8').toLowerCase() + : ''; + return stderr.includes('could not be found in the keychain'); +} +export function resolveKeychainCredentials(serviceNames, now, loadService, accountName) { + let shouldBackoff = false; + let allowGenericFallback = Boolean(accountName); + for (const serviceName of serviceNames) { + try { + const keychainData = accountName + ? loadService(serviceName, accountName) + : loadService(serviceName); + if (accountName) + allowGenericFallback = false; + const trimmedKeychainData = keychainData.trim(); + if (!trimmedKeychainData) + continue; + const data = JSON.parse(trimmedKeychainData); + const credentials = parseCredentialsData(data, now); + if (credentials) { + return { credentials, shouldBackoff: false }; + } + } + catch (error) { + if (!isMissingKeychainItemError(error)) { + if (accountName) + allowGenericFallback = false; + shouldBackoff = true; + } + } + } + if (!accountName || !allowGenericFallback) { + return { credentials: null, shouldBackoff }; + } + for (const serviceName of serviceNames) { + try { + const keychainData = loadService(serviceName).trim(); + if (!keychainData) + continue; + const data = JSON.parse(keychainData); + const credentials = parseCredentialsData(data, now); + if (credentials) { + return { credentials, shouldBackoff: false }; + } + } + catch (error) { + if (!isMissingKeychainItemError(error)) { + shouldBackoff = true; + } + } + } + return { credentials: null, shouldBackoff }; +} +function getKeychainAccountName() { + try { + const username = os.userInfo().username.trim(); + return username || null; + } + catch { + return null; + } +} +/** + * Read credentials from macOS Keychain. + * Claude Code stores OAuth credentials in the macOS Keychain with profile-specific service names. + * Returns null if not on macOS or credentials not found. + * + * Security: Uses execFileSync with absolute path to avoid shell injection and PATH hijacking. + */ +function readKeychainCredentials(now, homeDir) { + // Only available on macOS + if (process.platform !== 'darwin') { + return null; + } + // Check backoff to avoid re-prompting on every render after a failure + if (isKeychainBackoff(homeDir, now)) { + debug('Keychain in backoff period, skipping'); + return null; + } + try { + const configDir = getClaudeConfigDir(homeDir); + const serviceNames = getKeychainServiceNames(configDir, homeDir); + const accountName = getKeychainAccountName(); + debug('Trying keychain service names:', serviceNames); + if (accountName) { + debug('Trying keychain account name:', accountName); + } + const resolved = resolveKeychainCredentials(serviceNames, now, (serviceName, lookupAccountName) => execFileSync('/usr/bin/security', lookupAccountName + ? ['find-generic-password', '-s', serviceName, '-a', lookupAccountName, '-w'] + : ['find-generic-password', '-s', serviceName, '-w'], { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'], timeout: KEYCHAIN_TIMEOUT_MS }), accountName); + if (resolved.credentials) { + return resolved.credentials; + } + if (resolved.shouldBackoff) { + recordKeychainFailure(homeDir, now); + } + return null; + } + catch (error) { + // Security: Only log error message, not full error object (may contain stdout/stderr with tokens) + const message = error instanceof Error ? error.message : 'unknown error'; + debug('Failed to read from macOS Keychain:', message); + // Record failure for backoff to avoid re-prompting + recordKeychainFailure(homeDir, now); + return null; + } +} +/** + * Read credentials from file (legacy method). + * Older versions of Claude Code stored credentials in {CLAUDE_CONFIG_DIR}/.credentials.json. + */ +function readFileCredentials(homeDir, now) { + const credentialsPath = path.join(getClaudeConfigDir(homeDir), '.credentials.json'); + if (!fs.existsSync(credentialsPath)) { + return null; + } + try { + const content = fs.readFileSync(credentialsPath, 'utf8'); + const data = JSON.parse(content); + return parseCredentialsData(data, now); + } + catch (error) { + debug('Failed to read credentials file:', error); + return null; + } +} +function readFileSubscriptionType(homeDir) { + const credentialsPath = path.join(getClaudeConfigDir(homeDir), '.credentials.json'); + if (!fs.existsSync(credentialsPath)) { + return null; + } + try { + const content = fs.readFileSync(credentialsPath, 'utf8'); + const data = JSON.parse(content); + const subscriptionType = data.claudeAiOauth?.subscriptionType; + const normalizedSubscriptionType = typeof subscriptionType === 'string' + ? subscriptionType.trim() + : ''; + if (!normalizedSubscriptionType) { + return null; + } + return normalizedSubscriptionType; + } + catch (error) { + debug('Failed to read file subscriptionType:', error); + return null; + } +} +/** + * Parse and validate credentials data from either Keychain or file. + */ +function parseCredentialsData(data, now) { + const accessToken = data.claudeAiOauth?.accessToken; + const subscriptionType = data.claudeAiOauth?.subscriptionType ?? ''; + if (!accessToken) { + return null; + } + // Check if token is expired (expiresAt is Unix ms timestamp) + // Use != null to handle expiresAt=0 correctly (would be expired) + const expiresAt = data.claudeAiOauth?.expiresAt; + if (expiresAt != null && expiresAt <= now) { + debug('OAuth token expired'); + return null; + } + return { accessToken, subscriptionType }; +} +/** + * Read OAuth credentials, trying macOS Keychain first (Claude Code 2.x), + * then falling back to file-based credentials (older versions). + * + * Token priority: Keychain token is authoritative (Claude Code 2.x stores current token there). + * SubscriptionType: Can be supplemented from file if keychain lacks it (display-only field). + */ +function readCredentials(homeDir, now, readKeychain) { + // Try macOS Keychain first (Claude Code 2.x) + const keychainCreds = readKeychain(now, homeDir); + if (keychainCreds) { + if (keychainCreds.subscriptionType) { + debug('Using credentials from macOS Keychain'); + return keychainCreds; + } + // Keychain has token but no subscriptionType - try to supplement from file + const fileSubscriptionType = readFileSubscriptionType(homeDir); + if (fileSubscriptionType) { + debug('Using keychain token with file subscriptionType'); + return { + accessToken: keychainCreds.accessToken, + subscriptionType: fileSubscriptionType, + }; + } + // No subscriptionType available - use keychain token anyway + debug('Using keychain token without subscriptionType'); + return keychainCreds; + } + // Fall back to file-based credentials (older versions or non-macOS) + const fileCreds = readFileCredentials(homeDir, now); + if (fileCreds) { + debug('Using credentials from file'); + return fileCreds; + } + return null; +} +function getPlanName(subscriptionType) { + const lower = subscriptionType.toLowerCase(); + if (lower.includes('max')) + return 'Max'; + if (lower.includes('pro')) + return 'Pro'; + if (lower.includes('team')) + return 'Team'; + // API users don't have subscriptionType or have 'api' + if (!subscriptionType || lower.includes('api')) + return null; + // Unknown subscription type - show it capitalized + return subscriptionType.charAt(0).toUpperCase() + subscriptionType.slice(1); +} +export function getUsagePlanNameFallback(homeDir = os.homedir()) { + const subscriptionType = readFileSubscriptionType(homeDir); + if (subscriptionType) { + return getPlanName(subscriptionType); + } + const cachedPlanName = readCachedPlanName(homeDir); + return cachedPlanName ?? null; +} +/** Parse utilization value, clamping to 0-100 and handling NaN/Infinity */ +function parseUtilization(value) { + if (value == null) + return null; + if (!Number.isFinite(value)) + return null; // Handles NaN and Infinity + return Math.round(Math.max(0, Math.min(100, value))); +} +/** Parse ISO date string safely, returning null for invalid dates */ +function parseDate(dateStr) { + if (!dateStr) + return null; + const date = new Date(dateStr); + // Check for Invalid Date + if (isNaN(date.getTime())) { + debug('Invalid date string:', dateStr); + return null; + } + return date; +} +export function getUsageApiTimeoutMs(env = process.env) { + const raw = env.CLAUDE_HUD_USAGE_TIMEOUT_MS?.trim(); + if (!raw) + return USAGE_API_TIMEOUT_MS_DEFAULT; + const parsed = Number(raw); + if (!Number.isInteger(parsed) || parsed <= 0) { + debug('Invalid CLAUDE_HUD_USAGE_TIMEOUT_MS value:', raw); + return USAGE_API_TIMEOUT_MS_DEFAULT; + } + return parsed; +} +export function isNoProxy(hostname, env = process.env) { + const noProxy = env.NO_PROXY ?? env.no_proxy; + if (!noProxy) + return false; + const host = hostname.toLowerCase(); + return noProxy.split(',').some((entry) => { + const pattern = entry.trim().toLowerCase(); + if (!pattern) + return false; + if (pattern === '*') + return true; + if (host === pattern) + return true; + const suffix = pattern.startsWith('.') ? pattern : `.${pattern}`; + return host.endsWith(suffix); + }); +} +export function getProxyUrl(hostname, env = process.env) { + if (isNoProxy(hostname, env)) { + debug('Proxy bypassed by NO_PROXY for host:', hostname); + return null; + } + const proxyEnv = env.HTTPS_PROXY + ?? env.https_proxy + ?? env.ALL_PROXY + ?? env.all_proxy + ?? env.HTTP_PROXY + ?? env.http_proxy; + if (!proxyEnv) + return null; + try { + const proxyUrl = new URL(proxyEnv); + if (proxyUrl.protocol !== 'http:' && proxyUrl.protocol !== 'https:') { + debug('Unsupported proxy protocol:', proxyUrl.protocol); + return null; + } + return proxyUrl; + } + catch { + debug('Invalid proxy URL:', proxyEnv); + return null; + } +} +function createProxyTunnelAgent(proxyUrl) { + const proxyHost = proxyUrl.hostname; + const proxyPort = Number.parseInt(proxyUrl.port || (proxyUrl.protocol === 'https:' ? '443' : '80'), 10); + const proxyAuth = proxyUrl.username + ? `Basic ${Buffer.from(`${decodeURIComponent(proxyUrl.username)}:${decodeURIComponent(proxyUrl.password || '')}`).toString('base64')}` + : null; + return new class extends https.Agent { + createConnection(options, callback) { + const targetHost = String(options.host ?? options.hostname ?? 'localhost'); + const targetPort = Number(options.port) || 443; + let settled = false; + const settle = (err, socket) => { + if (settled) + return; + settled = true; + callback?.(err, socket); + }; + const proxySocket = proxyUrl.protocol === 'https:' + ? tls.connect({ host: proxyHost, port: proxyPort, servername: proxyHost }) + : net.connect(proxyPort, proxyHost); + proxySocket.once('error', (error) => { + settle(error, proxySocket); + }); + proxySocket.once('connect', () => { + const connectHeaders = [ + `CONNECT ${targetHost}:${targetPort} HTTP/1.1`, + `Host: ${targetHost}:${targetPort}`, + ]; + if (proxyAuth) { + connectHeaders.push(`Proxy-Authorization: ${proxyAuth}`); + } + connectHeaders.push('', ''); + proxySocket.write(connectHeaders.join('\r\n')); + let responseBuffer = Buffer.alloc(0); + const onData = (chunk) => { + responseBuffer = Buffer.concat([responseBuffer, chunk]); + const headerEndIndex = responseBuffer.indexOf('\r\n\r\n'); + if (headerEndIndex === -1) + return; + proxySocket.removeListener('data', onData); + const headerText = responseBuffer.subarray(0, headerEndIndex).toString('utf8'); + const statusLine = headerText.split('\r\n')[0] ?? ''; + if (!/^HTTP\/1\.[01] 200 /.test(statusLine)) { + const error = new Error(`Proxy CONNECT rejected: ${statusLine || 'unknown status'}`); + proxySocket.destroy(error); + settle(error, proxySocket); + return; + } + const tlsSocket = tls.connect({ + socket: proxySocket, + servername: String(options.servername ?? targetHost), + rejectUnauthorized: getProxyTunnelRejectUnauthorized(options.rejectUnauthorized), + }, () => { + settle(null, tlsSocket); + }); + tlsSocket.once('error', (error) => { + settle(error, tlsSocket); + }); + }; + proxySocket.on('data', onData); + }); + // Must not return the socket here. In Node.js _http_agent.js, createSocket() + // calls: `if (newSocket) oncreate(null, newSocket)` — returning a truthy value + // causes the HTTP request to be written to the raw proxy socket immediately, + // before the CONNECT tunnel is established. Only deliver the final TLS socket + // asynchronously via the callback after the CONNECT handshake succeeds. + return undefined; + } + }(); +} +export function getProxyTunnelRejectUnauthorized(rejectUnauthorized, env = process.env) { + if (rejectUnauthorized === false) { + return false; + } + return env.NODE_TLS_REJECT_UNAUTHORIZED !== '0'; +} +function fetchUsageApi(accessToken) { + return new Promise((resolve) => { + const host = 'api.anthropic.com'; + const timeoutMs = getUsageApiTimeoutMs(); + const proxyUrl = getProxyUrl(host); + const options = { + hostname: host, + path: '/api/oauth/usage', + method: 'GET', + headers: { + 'Authorization': `Bearer ${accessToken}`, + 'anthropic-beta': 'oauth-2025-04-20', + 'User-Agent': USAGE_API_USER_AGENT, + }, + timeout: timeoutMs, + agent: proxyUrl ? createProxyTunnelAgent(proxyUrl) : undefined, + }; + if (proxyUrl) { + debug('Using proxy for usage API:', proxyUrl.origin); + } + const req = https.request(options, (res) => { + let data = ''; + res.on('data', (chunk) => { + data += chunk.toString(); + }); + res.on('end', () => { + if (res.statusCode !== 200) { + debug('API returned non-200 status:', res.statusCode); + // Use a distinct error key for 429 so cache/render can handle it specially + const error = res.statusCode === 429 + ? 'rate-limited' + : res.statusCode ? `http-${res.statusCode}` : 'http-error'; + const retryAfterSec = res.statusCode === 429 + ? parseRetryAfterSeconds(res.headers['retry-after']) + : undefined; + if (retryAfterSec) { + debug('Retry-After:', retryAfterSec, 'seconds'); + } + resolve({ data: null, error, retryAfterSec }); + return; + } + try { + const parsed = JSON.parse(data); + resolve({ data: parsed }); + } + catch (error) { + debug('Failed to parse API response:', error); + resolve({ data: null, error: 'parse' }); + } + }); + }); + req.on('error', (error) => { + debug('API request error:', error); + resolve({ data: null, error: 'network' }); + }); + req.on('timeout', () => { + debug('API request timeout'); + req.destroy(); + resolve({ data: null, error: 'timeout' }); + }); + req.end(); + }); +} +export function parseRetryAfterSeconds(raw, nowMs = Date.now()) { + const value = Array.isArray(raw) ? raw[0] : raw; + if (!value) + return undefined; + const parsedSeconds = Number.parseInt(value, 10); + if (Number.isFinite(parsedSeconds) && parsedSeconds > 0) { + return parsedSeconds; + } + const retryAtMs = Date.parse(value); + if (!Number.isFinite(retryAtMs)) { + return undefined; + } + const retryAfterSeconds = Math.ceil((retryAtMs - nowMs) / 1000); + return retryAfterSeconds > 0 ? retryAfterSeconds : undefined; +} +// Export for testing +export function clearCache(homeDir) { + if (homeDir) { + try { + const cachePath = getCachePath(homeDir); + if (fs.existsSync(cachePath)) { + fs.unlinkSync(cachePath); + } + const lockPath = getCacheLockPath(homeDir); + if (fs.existsSync(lockPath)) { + fs.unlinkSync(lockPath); + } + } + catch { + // Ignore + } + } +} +//# sourceMappingURL=usage-api.js.map \ No newline at end of file diff --git a/back2base-container/vendor/claude-hud/utils/terminal.js b/back2base-container/vendor/claude-hud/utils/terminal.js new file mode 100644 index 0000000..851c978 --- /dev/null +++ b/back2base-container/vendor/claude-hud/utils/terminal.js @@ -0,0 +1,37 @@ +export const UNKNOWN_TERMINAL_WIDTH = null; +function parseEnvColumns() { + const envColumns = Number.parseInt(process.env.COLUMNS ?? '', 10); + return Number.isFinite(envColumns) && envColumns > 0 ? envColumns : null; +} +function parseStreamColumns(columns) { + return typeof columns === 'number' && Number.isFinite(columns) && columns > 0 + ? Math.floor(columns) + : null; +} +export function getTerminalWidth(options = {}) { + const { preferEnv = false, fallback = null } = options; + if (preferEnv) { + return parseEnvColumns() + ?? parseStreamColumns(process.stdout?.columns) + ?? parseStreamColumns(process.stderr?.columns) + ?? fallback; + } + return parseStreamColumns(process.stdout?.columns) + ?? parseStreamColumns(process.stderr?.columns) + ?? parseEnvColumns() + ?? fallback; +} +// Returns a progress bar width scaled to the current terminal width. +// Wide (>=100): 10, Medium (60-99): 6, Narrow (<60): 4. +export function getAdaptiveBarWidth() { + const cols = getTerminalWidth({ preferEnv: true }); + if (cols !== null) { + if (cols >= 100) + return 10; + if (cols >= 60) + return 6; + return 4; + } + return 10; +} +//# sourceMappingURL=terminal.js.map \ No newline at end of file diff --git a/back2base-container/vendor/claude-hud/version.js b/back2base-container/vendor/claude-hud/version.js new file mode 100644 index 0000000..285b2c1 --- /dev/null +++ b/back2base-container/vendor/claude-hud/version.js @@ -0,0 +1,227 @@ +import { execFile } from 'node:child_process'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { promisify } from 'node:util'; +import { getHudPluginDir } from './claude-config-dir.js'; +const CACHE_FILENAME = '.claude-code-version-cache.json'; +const defaultExecFile = promisify(execFile); +let execFileImpl = defaultExecFile; +let resolveClaudeBinaryImpl = resolveClaudeBinaryFromPath; +let platformImpl = () => process.platform; +let windowsCmdImpl = () => 'C:\\Windows\\System32\\cmd.exe'; +let cachedBinaryKey; +let cachedVersion; +let hasResolved = false; +function getVersionCachePath(homeDir) { + return path.join(getHudPluginDir(homeDir), CACHE_FILENAME); +} +function getBinaryCacheKey(binaryInfo) { + return `${binaryInfo.path}:${binaryInfo.mtimeMs}`; +} +function quoteForCmd(arg) { + if (!arg) { + return '""'; + } + if (!/[\s"&|<>^()]/.test(arg)) { + return arg; + } + return `"${arg.replace(/"/g, '""')}"`; +} +function statResolvedBinary(binaryPath) { + try { + const realPath = fs.realpathSync(binaryPath); + const stat = fs.statSync(realPath); + if (!stat.isFile()) { + return null; + } + return { + path: realPath, + mtimeMs: stat.mtimeMs, + }; + } + catch { + return null; + } +} +function readVersionCache(homeDir) { + try { + const cachePath = getVersionCachePath(homeDir); + if (!fs.existsSync(cachePath)) { + return null; + } + const parsed = JSON.parse(fs.readFileSync(cachePath, 'utf8')); + if ((parsed.resolvedFromPath !== undefined && typeof parsed.resolvedFromPath !== 'string') + || + typeof parsed.binaryPath !== 'string' + || typeof parsed.binaryMtimeMs !== 'number' + || (typeof parsed.version !== 'string' && parsed.version !== null)) { + return null; + } + return parsed; + } + catch { + return null; + } +} +function writeVersionCache(homeDir, cache) { + try { + const cachePath = getVersionCachePath(homeDir); + const cacheDir = path.dirname(cachePath); + if (!fs.existsSync(cacheDir)) { + fs.mkdirSync(cacheDir, { recursive: true }); + } + fs.writeFileSync(cachePath, JSON.stringify(cache), 'utf8'); + } + catch { + // Ignore cache write failures. + } +} +function isExecutableFile(candidatePath) { + try { + const stat = fs.statSync(candidatePath); + if (!stat.isFile()) { + return false; + } + if (process.platform === 'win32') { + return true; + } + fs.accessSync(candidatePath, fs.constants.X_OK); + return true; + } + catch { + return false; + } +} +function getPathCandidates(command) { + if (process.platform !== 'win32') { + return [command]; + } + const ext = path.extname(command); + if (ext) { + return [command]; + } + const pathExt = (process.env.PATHEXT || '.COM;.EXE;.BAT;.CMD') + .split(';') + .map((value) => value.trim()) + .filter(Boolean); + return [command, ...pathExt.map((suffix) => `${command}${suffix.toLowerCase()}`), ...pathExt.map((suffix) => `${command}${suffix.toUpperCase()}`)]; +} +function resolveClaudeBinaryFromPath() { + const pathValue = process.env.PATH; + if (!pathValue) { + return null; + } + const candidates = getPathCandidates('claude'); + for (const entry of pathValue.split(path.delimiter)) { + if (!entry) { + continue; + } + const dir = entry.replace(/^"(.*)"$/, '$1'); + for (const candidate of candidates) { + const candidatePath = path.join(dir, candidate); + if (!isExecutableFile(candidatePath)) { + continue; + } + const binaryInfo = statResolvedBinary(candidatePath); + if (binaryInfo) { + return binaryInfo; + } + } + } + return null; +} +export function _parseClaudeCodeVersion(output) { + const trimmed = output.trim(); + if (!trimmed) { + return undefined; + } + const match = trimmed.match(/\d+(?:\.\d+)+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?/); + return match?.[0]; +} +export function _getClaudeVersionInvocation(binaryPath, platform = platformImpl(), windowsCmd = windowsCmdImpl()) { + const ext = path.extname(binaryPath).toLowerCase(); + if (platform === 'win32' && (ext === '.cmd' || ext === '.bat')) { + const command = [quoteForCmd(binaryPath), '--version'].join(' '); + return { + file: windowsCmd, + args: ['/d', '/s', '/c', `"${command}"`], + }; + } + return { + file: binaryPath, + args: ['--version'], + }; +} +export async function getClaudeCodeVersion() { + const homeDir = os.homedir(); + const diskCache = readVersionCache(homeDir); + if (diskCache) { + const cachedBinaryInfo = statResolvedBinary(diskCache.binaryPath); + const resolvedBinaryCandidate = resolveClaudeBinaryImpl(); + const currentResolvedBinary = resolvedBinaryCandidate + ? (statResolvedBinary(resolvedBinaryCandidate.path) ?? resolvedBinaryCandidate) + : null; + if (cachedBinaryInfo + && cachedBinaryInfo.path === diskCache.binaryPath + && cachedBinaryInfo.mtimeMs === diskCache.binaryMtimeMs + && currentResolvedBinary + && currentResolvedBinary.path === diskCache.binaryPath) { + const cachedKey = getBinaryCacheKey(cachedBinaryInfo); + if (hasResolved && cachedBinaryKey === cachedKey) { + return cachedVersion; + } + cachedBinaryKey = cachedKey; + cachedVersion = diskCache.version ?? undefined; + hasResolved = true; + return cachedVersion; + } + } + const resolvedBinaryInfo = resolveClaudeBinaryImpl(); + if (!resolvedBinaryInfo) { + return undefined; + } + // Normalize resolver output to the actual on-disk binary so cache keys and + // persisted mtimes stay stable across process boundaries. + const binaryInfo = statResolvedBinary(resolvedBinaryInfo.path) ?? resolvedBinaryInfo; + const binaryKey = getBinaryCacheKey(binaryInfo); + if (hasResolved && cachedBinaryKey === binaryKey) { + return cachedVersion; + } + try { + const invocation = _getClaudeVersionInvocation(binaryInfo.path); + const { stdout } = await execFileImpl(invocation.file, invocation.args, { + timeout: 2000, + encoding: 'utf8', + }); + cachedVersion = _parseClaudeCodeVersion(stdout); + } + catch { + cachedVersion = undefined; + } + writeVersionCache(homeDir, { + resolvedFromPath: resolvedBinaryInfo.path, + binaryPath: binaryInfo.path, + binaryMtimeMs: binaryInfo.mtimeMs, + version: cachedVersion ?? null, + }); + cachedBinaryKey = binaryKey; + hasResolved = true; + return cachedVersion; +} +export function _resetVersionCache() { + cachedBinaryKey = undefined; + cachedVersion = undefined; + hasResolved = false; +} +export function _setExecFileImplForTests(impl) { + execFileImpl = impl ?? defaultExecFile; +} +export function _setResolveClaudeBinaryForTests(impl) { + resolveClaudeBinaryImpl = impl ?? resolveClaudeBinaryFromPath; +} +export function _setVersionInvocationEnvForTests(platformGetter, windowsCmdGetter) { + platformImpl = platformGetter ?? (() => process.platform); + windowsCmdImpl = windowsCmdGetter ?? (() => 'C:\\Windows\\System32\\cmd.exe'); +} +//# sourceMappingURL=version.js.map \ No newline at end of file diff --git a/compose.go b/compose.go index fbc8f0c..35fbd06 100644 --- a/compose.go +++ b/compose.go @@ -145,7 +145,7 @@ func writeManagedSettingsOverride(cfg cbConfig) string { if _, err := os.Stat(m.src); err != nil { continue } - lines = append(lines, fmt.Sprintf(" - %q:%q:ro", m.src, m.dst)) + lines = append(lines, fmt.Sprintf(" - %q", m.src+":"+m.dst+":ro")) } if len(lines) == 0 { return "" @@ -163,6 +163,78 @@ func writeManagedSettingsOverride(cfg cbConfig) string { return path } +// dataDirOverridePath is where writeDataDirOverride stages its generated +// compose override fragment. +func dataDirOverridePath(cfg cbConfig) string { + return filepath.Join(cfg.StateDir, "run", "data-dir-override.yml") +} + +// resolveDataDir returns the host dir for the plans + memories mount, or "" +// when unset. Process env wins over the BACK2BASE_DATA_DIR key in the env file; +// a leading "~/" is expanded to $HOME. +func resolveDataDir(cfg cbConfig) string { + v := strings.TrimSpace(os.Getenv("BACK2BASE_DATA_DIR")) + if v == "" { + v = strings.TrimSpace(readEnvFile(cfg.EnvFile)["BACK2BASE_DATA_DIR"]) + } + if v == "" { + return "" + } + if strings.HasPrefix(v, "~/") { + if home := os.Getenv("HOME"); home != "" { + v = filepath.Join(home, v[2:]) + } + } + return v +} + +// writeDataDirOverride generates a compose override that bind-mounts +// /plans and /memories (created if absent) at +// ~/.back2base/plans and ~/.back2base/memories. Returns "" when the var is +// unset or the dir is missing (warned to stderr). OSS has no cloud sync, so +// it's volumes-only. +func writeDataDirOverride(cfg cbConfig) string { + dir := resolveDataDir(cfg) + if dir == "" { + return "" + } + // A newline in the path would break the generated override YAML (or let a + // crafted value inject extra keys), so reject it rather than emit corrupt + // YAML. Unix dir names can legally contain newlines, so the IsDir check + // below isn't enough on its own. + if strings.ContainsAny(dir, "\r\n") { + fmt.Fprintf(os.Stderr, ":: warn: BACK2BASE_DATA_DIR contains a newline — skipping plans/memories mount\n") + return "" + } + fi, err := os.Stat(dir) + if err != nil || !fi.IsDir() { + fmt.Fprintf(os.Stderr, ":: warn: BACK2BASE_DATA_DIR %q is not an existing directory — skipping plans/memories mount\n", dir) + return "" + } + plansHost := filepath.Join(dir, "plans") + memHost := filepath.Join(dir, "memories") + for _, d := range []string{plansHost, memHost} { + if err := os.MkdirAll(d, 0o700); err != nil { + fmt.Fprintf(os.Stderr, ":: warn: could not create %q (%v) — skipping plans/memories mount\n", d, err) + return "" + } + } + body := "services:\n claude:\n volumes:\n" + + fmt.Sprintf(" - %s:%s\n", memHost, "/home/node/.back2base/memories") + + fmt.Sprintf(" - %s:%s\n", plansHost, "/home/node/.back2base/plans") + path := dataDirOverridePath(cfg) + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + fmt.Fprintf(os.Stderr, ":: warn: could not stage data-dir override dir (%v)\n", err) + return "" + } + if err := os.WriteFile(path, []byte(body), 0o600); err != nil { + fmt.Fprintf(os.Stderr, ":: warn: could not write data-dir override (%v)\n", err) + return "" + } + fmt.Fprintf(os.Stderr, ":: Mounting data dir: %s → ~/.back2base/{plans,memories}\n", dir) + return path +} + type runOpts struct { extraDirs []string prompt string @@ -178,6 +250,9 @@ func buildRunArgs(cfg cbConfig, opts runOpts) []string { if p := writeManagedSettingsOverride(cfg); p != "" { overrides = append(overrides, p) } + if p := writeDataDirOverride(cfg); p != "" { + overrides = append(overrides, p) + } args := baseComposeArgs(cfg, overrides...) args = append(args, "run", "--rm") diff --git a/compose_test.go b/compose_test.go index ed05917..3edd46d 100644 --- a/compose_test.go +++ b/compose_test.go @@ -338,3 +338,125 @@ func TestBuildRunArgs_AppendsManagedSettingsOverrideWhenPresent(t *testing.T) { t.Fatalf("managed-settings override -f must follow base compose -f: %s", joined) } } + +func TestWriteDataDirOverride(t *testing.T) { + state := t.TempDir() + data := t.TempDir() + cfg := cbConfig{StateDir: state, EnvFile: filepath.Join(state, "noenv")} + + // writeDataDirOverride logs to stderr on both the success and skip paths; + // silence it so `go test` output stays clean. + origStderr := os.Stderr + devnull, _ := os.OpenFile(os.DevNull, os.O_WRONLY, 0) + os.Stderr = devnull + defer func() { os.Stderr = origStderr; devnull.Close() }() + + // Unset → no override, current behavior preserved. + t.Setenv("BACK2BASE_DATA_DIR", "") + if p := writeDataDirOverride(cfg); p != "" { + t.Fatalf("expected no override when unset, got %q", p) + } + + // Set to an existing dir → override with both mounts; subdirs created. + t.Setenv("BACK2BASE_DATA_DIR", data) + p := writeDataDirOverride(cfg) + if p == "" { + t.Fatal("expected an override path, got empty") + } + body, err := os.ReadFile(p) + if err != nil { + t.Fatalf("read override: %v", err) + } + s := string(body) + if !strings.Contains(s, filepath.Join(data, "memories")) || + !strings.Contains(s, "/home/node/.back2base/memories") { + t.Errorf("missing memories mount:\n%s", s) + } + if !strings.Contains(s, filepath.Join(data, "plans")) || + !strings.Contains(s, "/home/node/.back2base/plans") { + t.Errorf("missing plans mount:\n%s", s) + } + if _, err := os.Stat(filepath.Join(data, "plans")); err != nil { + t.Errorf("plans subdir not created: %v", err) + } + if _, err := os.Stat(filepath.Join(data, "memories")); err != nil { + t.Errorf("memories subdir not created: %v", err) + } + if strings.Contains(s, ":ro") { + t.Errorf("data-dir override must be read-write (got :ro):\n%s", s) + } + if strings.Contains(s, "\"") { + t.Errorf("override YAML must not contain double-quoted paths (breaks compose parsing):\n%s", s) + } + // OSS has no cloud sync — the override must NOT carry an environment block. + if strings.Contains(s, "environment:") { + t.Errorf("OSS override should not set environment:\n%s", s) + } + + // Set to a non-existent path → no override (warning path). + t.Setenv("BACK2BASE_DATA_DIR", filepath.Join(data, "nope")) + if p := writeDataDirOverride(cfg); p != "" { + t.Errorf("expected no override for missing dir, got %q", p) + } + + // Existing dir whose name contains a newline → rejected (a raw newline + // would corrupt the generated override YAML). + if nlDir := filepath.Join(data, "bad\nname"); os.MkdirAll(nlDir, 0o700) == nil { + t.Setenv("BACK2BASE_DATA_DIR", nlDir) + if p := writeDataDirOverride(cfg); p != "" { + t.Errorf("expected no override for newline path, got %q", p) + } + } +} + +// TestManagedSettingsOverrideYAMLValid: the generated YAML must NOT contain the +// per-half-quoted form ("src":"dst":ro) which breaks docker compose config, and +// the host path — including any spaces — must survive intact in the override. +func TestManagedSettingsOverrideYAMLValid(t *testing.T) { + // Create a host dir with a space in its path (mirrors macOS production path). + base := t.TempDir() + hostDir := filepath.Join(base, "App Support") + if err := os.MkdirAll(hostDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(hostDir, "managed-settings.json"), []byte(`{}`), 0o644); err != nil { + t.Fatal(err) + } + t.Setenv("BACK2BASE_MANAGED_SETTINGS_DIR", hostDir) + + cfg := cbConfig{StateDir: t.TempDir()} + path := writeManagedSettingsOverride(cfg) + if path == "" { + t.Fatal("expected override path, got empty") + } + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read override: %v", err) + } + body := string(data) + + // The broken per-half-quoted pattern is a quote immediately followed by a + // colon immediately followed by another quote: ":" + if strings.Contains(body, `":"`) { + t.Errorf("override contains per-half-quoted volume pattern (bug: %%q:%%q:ro form):\n%s", body) + } + + // The space-containing source path must appear verbatim in the YAML body. + src := filepath.Join(hostDir, "managed-settings.json") + if !strings.Contains(body, src) { + t.Errorf("override does not contain source path %q:\n%s", src, body) + } +} + +func TestResolveDataDirEnvFileFallback(t *testing.T) { + dir := t.TempDir() + envFile := filepath.Join(dir, "env") + if err := os.WriteFile(envFile, []byte("BACK2BASE_DATA_DIR="+dir+"\n"), 0o600); err != nil { + t.Fatalf("write env file: %v", err) + } + cfg := cbConfig{StateDir: dir, EnvFile: envFile} + t.Setenv("BACK2BASE_DATA_DIR", "") // process env empty → must fall back to the file + if got := resolveDataDir(cfg); got != dir { + t.Errorf("resolveDataDir: want %q from env file, got %q", dir, got) + } +}