SLOPSHOPPER

ccxp-skills

Task lifecycle, PR automation, CI/RCA and retro skills for the ccxp-family workflow suite, plus general-purpose dev-tool skills (Cloudflare, docs, email…

newpanebandrowscommandstatus
★ 1v2.3.0Apache-2.0updated 2026-10-26Synx-Data-Labs/ccxp-skills
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · ccxp-skills
│ ┃ ccxp-hud ✕ ┌─────────────────────────────────────────────────────────────────┐ │ ┃ enabled: true band: true userMessage: │ ⟨engine⟩ │ │ ┃ true └─────────────────────────────────────────────────────────────────┘ │ ┃ autopilot: not running │ ┃ jobs: no ledger ⏺ Read(src/auth.ts) │ ┃ agents: 0 running, 0 waiting, 0 failed ⎿ Read 6 lines │ ┃ context left: 51% ⏺ Update(src/auth.ts) │ ┃ data is stale (the helper failed or timed ⎿ Added 2 lines, removed 1 line │ ┃ out) ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /hud │ ⎿ ccxp-skills: ccxp-hud is on. Config: /Users/dev/.claude/ccxp-hud │ │ ctx 51% ▓▓▓▓▓░░░░░ │ jobs — │ stale ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
ctx 51% ▓▓▓▓▓░░░░░ │ jobs — │ stale
Pane · ccxp-hud
enabled: true band: true userMessage: true autopilot: not running jobs: no ledger agents: 0 running, 0 waiting, 0 failed context left: 51% data is stale (the helper failed or timed out)
Your message
┌──────────────────────────────────────────────────────────────────────────────────────────────────┐ │ ⟨engine⟩ │ └──────────────────────────────────────────────────────────────────────────────────────────────────┘
README

ccxp-skills

Shared Claude Code skills for the ccxp-family workflow suite (task lifecycle, PR automation, CI/RCA, retros) plus general-purpose dev-tool skills — maintained in one place and used across every repo on a given machine. Split out of a private company repo so any team can adopt it with no company-specific dependency: every skill here is either fully generic or config-driven via environment variables with no baked-in default.

Install

This repo is a Claude Code plugin and its own single-plugin marketplace, so /plugin can install all 51 skills in one step:

/plugin marketplace add Synx-Data-Labs/ccxp-skills
/plugin install ccxp-skills@ccxp-skills

Update later with /plugin update ccxp-skills, and remove with /plugin uninstall ccxp-skills. /plugin manages the clone for you.

The skills live at the repo root rather than under skills/, which the manifest declares via "skills": ".". That keeps each skill a sibling of the shared libs (_gh/, _session/, _taskid/, _ipm/, _docs/, _journal/), so the ../_gh/gh.sh-style references inside the skills resolve correctly under a plugin install.

_gh/git.sh (see Shared helpers below) wires its own repo-local git credentials on every invocation — nothing to do. statusLine.command (statusline-setup/SKILL.md) is the one exception: Claude Code has no per-plugin statusline mechanism, so it still needs a one-time, manual settings.json entry pointing at wherever /plugin install put this plugin — and if that install used a marketplace (not the directory source this repo's own dev checkout uses), the cache path is version-pinned (e.g. .../ccxp-skills/1.0.1/...) and needs re-pointing after /plugin update bumps it.

Prerequisites

A one-time, machine-global setup that every consumer repo on the machine shares:

  • Skills installed — this repo cloned (see Install).
  • ~/.claude/.env — shared config, loaded by skill scripts before any repo-local .env. chmod 600 it and keep it out of every repo (it's machine-global). Every variable below is optional with a documented graceful-degrade — nothing here is required just to use the suite:
  • SLACK_WEBHOOK_URL / SLACK_WEBHOOK_URL_<NAME> — incoming webhooks for the slack skill's channels; <NAME> maps to --channel <name> (uppercased). See slack/SKILL.md. SLACK_WEBHOOK_URL_DEV is also what /ccxp, /retro and /ipm post through when the Slack MCP tool is unavailable (headless cron); a failed post is logged, never silent.
  • CCXP_SLACK_CHANNEL — the one channel every daily-ccxp Slack use targets (e.g. #claude-notification): slack-check-reply standup polling, the ccxp standup, and retro Phase 6. Unset degrades to a one-line skip note (or an interactive prompt for retro). Optional per-use overrides, which win when set: SLACK_STANDUP_CHANNEL (standup / slack-check-reply) and RETRO_SLACK_CHANNEL (retro). The webhook fallback (slack-fallback.sh) still routes through SLACK_WEBHOOK_URL_DEV, which is bound to its own channel.
  • SLACK_BOT_TOKEN / SLACK_AUTOMATION_ALERTS_CHANNEL_ID — bot-token Slack Web API access (_slack/webapi.sh, no MCP), currently used by ccxp's 1.2.2a RCA-in-thread reply (labrun-rca/slack-check-reply still call MCP Slack tools as of this writing — same underlying need, rewrite pending as a follow-up on T20261001-319589); _CHANNEL_ID is the raw channel ID (e.g. C0ALGAPRCA3), not a name — a bot token can't resolve names the way the retired MCP search could. Unset degrades to a one-line skip note (see ccxp/SKILL.md 1.2.2a, _slack/README.md). See T20261001-319589.
  • PROJECT_OWNER / PROJECT_NUMBER — the org login + Project V2 number that _session/_lib.sh mirrors task status onto. Unset means the Project-board mirror is a silent no-op — the task file's own status: frontmatter stays the source of truth regardless.
  • SESSION_TOKEN / PROJECT_PAT / GH_TOKEN — one of these must carry a token with org-level Projects: Read and write for the Project-board mirror above to work.
  • ATTRIBUTION_CRONTAB_CCXP_PATHS — colon-separated clone path(s) treated as the automation/cron (ccxp) clones (vs. interactive) for the daily standup's attribution table. Unset means every location classifies as interactive — cosmetic only, never a functional failure. Deprecated: the old name ATTRIBUTION_CCXP_PATHS is still read as a fallback (the new name wins).
  • METRICS_OWNER / METRICS_REPOS — the org + space-separated repo list retro Phase 4d reads dev/quality/metrics.jsonl scoreboards from. Unset/empty means Phase 4d reports "no scoreboard reachable" and skips.
  • KNOWN_SIBLING_REPOS — comma-separated sibling repo names repo-conventions/scripts/lint_refs.py treats as "foreign" for bare reference qualification. Unset/empty is a no-op.
  • gh CLI authenticated — gh auth login. Skills invoke gh via _gh/gh.sh, which auto-selects the account with access to the current repo (so multi-account machines work).
  • Python 3 + PyYAML — pip install pyyaml. Used by repo-conventions' task-frontmatter lint and the sync-tasks / lint-tasks actions.

Per-repo CI secret (set in each repo's GitHub settings, not locally):

  • A PAT with repo access + org Projects: Read and Write, for the sync-tasks action's project-pat input (name it whatever your org's secret-naming convention prefers — see actions/sync-tasks/README.md).

Skills included

Task lifecycle & PR automation (ccxp-family core)

SkillPurpose
ccxpDay-or-week orchestrator — daily standup, Monday IPM, focused work, Friday retro
autopilotKeep calling bare /drive back-to-back for a set duration (e.g. "for the next 6 hours")
drivePick ONE task, drive it to done — recurse into blockers, never switch laterally
todoList open tasks, pick the next actionable, reorder/clean up the backlog
claimClaim/check/release a task outside the full /drive loop
epicstatus/refresh/drive a standing epic in dev/EPICS/ — drive runs its member tasks one at a time via /drive (user-invoked; merges PRs)
stageEnsure a task is present in dev/TODO/queue.md
migrate-taskMove a task file (and its queue.md entry) from one repo's dev/TODO/ to another's
top / bottomMove a task (or several) to the front/back of the priority queue
etaProjected finish time for the current (or an explicit) task — elapsed vs. remaining estimation
new-taskFile a new task to the dev/TODO backlog
supportTrack a customer-reported GitHub issue as a staged support task (intake only, posts nothing)
inceptAdversarial frontier-round interview of a task/plan before implementation; the /ipm step 3 pre-IPM design pass
gcpr (alias land)Commit & push uncommitted changes, open a PR, hand off to address-pr
address-prAddress PR review comments, verify CI + hard gate, merge or notify
cleanup-branchPost-merge branch cleanup — a specific branch, a PR-merge sweep, or a bulk prune
retroWeekly retrospective — metrics, analysis, action items
memory-to-skillReview the current clone's auto-memory, prune stale entries, codify recurring ones into a skill/doc/hook
rcaDiagnose/classify/track root cause for a failed GitHub Actions run
labrun-rcaRCA a failed run announced via Slack permalink, reply in the thread
design-scoreDeterministic 0–100 structural score gating a design doc's Phase 2 → Phase 3
quality-probeMeasure code quality on a task's touched files, record to dev/quality/metrics.jsonl
journal-compactCompact a month of dev/JOURNAL/ into a digest
repo-conventionsCLAUDE.md/guidelines.md structure, dev/ TODO lifecycle, conventions lint, branch-policy switch, and setup — bring a repo fully online in one pass
skill-conventionsConventions for authoring a SKILL.md in this suite

Notification & integration

SkillPurpose
slackSend a Slack notification to a configured channel
slack-check-replyCheck replies on escalation/standup Slack threads
email-triageTriage the Gmail inbox — summarize, surface the top action item
1password-env-setupBootstrap a repo's .env via op inject, plus a skip-if-populated .envrc

General-purpose utility

SkillPurpose
verify-siteLocal preview + automated test-plan checks for a site PR
help-netTriage traveler network issues on macOS (GFW, throttle, MTU, DNS, VPN)
statusline-setupDeploy/edit/test the Claude Code statusline script (also feeds the ccxp-hud mod, hud/README.md)
md-to-pdfConvert markdown to PDF with scaled images, CJK support
proof-readSentence-level consistency check for markdown (term drift, undefined jargon, CN↔EN)
translate-patentTranslate a patent DOCX from Chinese to English

Cloudflare developer platform

SkillPurpose
cloudflareWorkers, Pages, storage, AI, networking, security, IaC — general Cloudflare development
cloudflare-email-serviceTransactional email sending + routing on Cloudflare
cloudflare-oneZero Trust / SASE — Access, Gateway, WARP, Tunnel, DLP, CASB
cloudflare-one-migrationsMigration assessments from other VPN/SASE stacks to Cloudflare One
agents-sdkBuild stateful AI agents on Workers (Agents SDK)
durable-objectsStateful coordination, RPC, SQLite storage, alarms on Durable Objects
sandbox-sdkSandboxed code execution (interpreters, CI/CD, untrusted code)
turnstile-spinEnd-to-end Turnstile (CAPTCHA) setup in a project
workers-best-practicesReview/author Workers code against production best practices
wranglerCloudflare Workers CLI reference for deploy/dev/manage across all bindings
web-perfCore Web Vitals / performance audit via Chrome DevTools MCP

Ask-first skills (model-invoked, user-approved)

autopilot, bottom, memory-to-skill, agent-pairing, cleanup-branch, epic and to-task are model-visible, but the plugin's hooks/skill-ask.sh PreToolUse hook (matcher Skill) makes Claude Code prompt you before the model may run them. Typed /skill is unaffected. Edit hooks/ask-skills.txt to change the list. Headless -p, dontAsk and dispatched sub-agents cannot answer the prompt, so the call is denied there. Every other skill is model-invocable with no prompt.

Reference docs

Canonical cross-repo docs at this repo's root (peers, not skills) — reference these from each consumer repo's dev/guidelines.md rather than copying them per-repo:

  • lifecycle.md — task lifecycle: status flow, ID format, blocking, creating/working/completing procedures.
  • gotchas.md — hard-won operational gotchas.
  • glossary.md — canonical definitions of coined terms & acronyms (looked up by /proof-read's undefined-jargon check).
  • engineering-standards.md — the code/design quality bar (TDD, test-driven refactoring, shared-lib design, quality metrics); gated by design-score, measured by quality-probe.
  • secrets-management.md — the generic half of a secrets-management policy (automation tiers, adding a new secret). Author your own account-structure and vault-map sections to match your org — this doc deliberately omits that org-specific half.

Developing

Edits to <skill>/SKILL.md take effect immediately in every consumer repo — it's a real checkout, not a read-only cache. When ready, commit + push here; other machines pick up changes with git pull, or with /plugin update ccxp-skills where the repo is installed as a plugin.

.claude-plugin/plugin.json declares "skills": ".", so the plugin's skill root is the repo root rather than a skills/ subdirectory. That is deliberate: each skill sits beside the shared libs (_gh/, _session/, _taskid/, _ipm/, _docs/, _journal/, _queue/) and reaches them through ../_gh/gh.sh-style relative references. Moving the skills under skills/ would break every one of those and the symlink installer with them. .claude-plugin/marketplace.json makes the repo its own single-plugin marketplace, which is why /plugin marketplace add takes the repo slug directly.

Adding a new skill

  1. Create <name>/SKILL.md at the repo root with proper frontmatter (name, description, argument-hint)
  2. Commit + push
  3. Other machines: git pull, or /plugin update ccxp-skills

No manifest edit is needed — "skills": "." means any root directory containing a SKILL.md is picked up automatically, by both install paths. The frontmatter name is what the skill is invoked as, so keep it equal to the directory name. Verify with claude plugin validate ., and claude plugin details ccxp-skills once installed to see the component inventory and its token cost.

Skill scripts

If a skill needs helper scripts (bash, python, etc.), put them under <skill>/scripts/ and reference them from SKILL.md by a path relative to this skill's own directory (the harness prints that directory as "Base directory for this skill" when the skill loads — resolve against it, not against whatever the consumer repo's cwd happens to be):

bash ../<name>/scripts/<script>.sh <args>

There is no absolute path that works under every install method — a plugin-cache install's path is version-pinned and moves on every /plugin update, and the script runs against an arbitrary consumer repo's cwd, not this repo's. Scripts must:

  • Discover the consumer repo from cwd via bash ../_gh/gh.sh repo view --json nameWithOwner --jq .nameWithOwner — never hardcode owner/repo.
  • Resolve sibling shared-lib scripts from within a script itself via dirname "${BASH_SOURCE[0]}", never a hardcoded path — this is what makes the script's own invocations robust regardless of caller cwd (see _taskid/in-this-repo.sh for the pattern).
  • Accept a REPO env var override for explicit targeting.
  • Read shared config (e.g. SLACK_WEBHOOK_URL) from ~/.claude/.env first, then $(pwd)/.env.

dev/EPICS/E*.md / dev/ROADMAP.md format (local to each repo)

Every repo owns its own dev/ROADMAP.md and dev/EPICS.md — there is no shared hub repo and no ROADMAP_TARGET_REPO config (T20261005-639913 retired that global variable; each file lives at the repo's own toplevel, read and edited as plain local files). /ipm step 5b updates dev/ROADMAP.md in place (creating it from ipm/templates/ROADMAP.md on first use); dev/EPICS.md is human-authored, proposed/edited by /retro Phase 2b.

ccxp/scripts/epic-status.sh (T20260911-347027) reads the local dev/EPICS.md to roll up epic-level progress into the daily /ccxp standup. Per epic:

### E1 — <title>
Goal: <one line — the standing goal this epic exists for>
Done when: <one line — the concrete completion condition>
Deadline: YYYY-MM-DD          # optional
- [T20260101-000001](../TODO/T20260101-000001-slug.md) — <title>   # link form
- T20260101-000002            # legacy plain form, still accepted
  • One file per epic under dev/EPICS/; the legacy single dev/EPICS.md is still read when the directory is absent.
  • Link paths are relative to dev/EPICS/ and must follow the task file when it moves (close → ../JOURNAL/<date>-…, park → ../PARKING/…): _taskid/epic-links.sh sync does it, lint checks it.
  • /epic (epic/scripts/epic.sh) is the operator surface over these files: status (read-only), refresh (sync + lint + propose missing members), drive <E<n>> (member tasks in epic-file order, one /drive each, failures summarized and skipped, final report by priority).
  • No status rows in the file — status is always derived at standup time (from each task's own frontmatter + PR state), so the file can never drift out of sync with reality.
  • Deadline: is optional; omit the line entirely when an epic has none.
  • The bullet list is order-significant only for the "Leading" task tie-break (first-listed wins a rank tie) — otherwise unordered.
  • This is the exact shape tests/fixtures/epics/EPICS.md exercises and epic-status.sh's parser/render tests assert against — keep the two in sync rather than letting this doc drift from what the script actually parses.

Shared helpers (_<name>/)

Helpers shared across skills live in leading-underscore directories at the repo root (Claude Code only loads dirs containing SKILL.md, so these stay invisible as skills). Current set:

  • _taskid/ — mint/resolve task IDs, blob-link URLs, orphaned-ref checks.
  • _gh/gh.sh — gh wrapper that picks the authenticated account with access to the current repo. Use this everywhere instead of bare gh. On machines with multiple gh accounts, bare gh always uses the active account, which may not have access to the repo. The wrapper probes gh auth status accounts, prefers one with write access over a merely-readable one, and runs gh under it, scoped per-process via GH_TOKEN (no global mutation, no gh auth switch). Self-heals: if a cached pick still fails a write op with a permission-shaped error (e.g. "must be a collaborator"), it drops the cache entry and retries once with a different account (T20260911-140914).
  • Opt-in Go build (T20261020-231370, experimental): bash _gh/go/build.sh builds _gh/bin/ccxp-gh; with CCXP_GH_GO=1 both wrappers exec it (account list from gh auth status --json hosts, no text parsing). Off by default; if the binary is missing the bash path runs.
  • _gh/git.sh — same account-picking logic as gh.sh, for git commands that need GitHub auth over HTTPS (push, mainly). Use this instead of a bare git push on a multi-account machine — same rationale as gh.sh: don't hardcode GH_TOKEN="$(gh auth token --user <name>)" by hand, let the wrapper auto-detect the write-capable account (T20260911-140914). It also wires the repo's own local git config (.git/config only — never ~/.gitconfig) on every invocation: credential.helper is pointed at gh auth git-credential and url."https://github.com/".insteadOf is set for both git@github.com: and ssh://git@github.com/, so a plain git push/pull/fetch also authenticates over HTTPS as the right account instead of depending on the SSH agent's currently-loaded key. This is self-contained and idempotent — no hook, no prior session required — so it works correctly even on a fresh clone that has never run anything else in this repo (T20260925-219021).
  • _session/ — on-main task claim lock, Project V2 board mirror, PR-ownership resolution, reclaim sweep for dead claims. See _session/README.md.
  • _ipm/ — current-iteration lookup and scheduled: stamping.
  • _journal/ — journal-move helpers for closing a task.
  • _docs/ — the shared markdown-lint wrapper (lint-docs.sh) and doc-impact/freshness checks.

Notes

  • Skills live at the repo root; README.md and .git/ are ignored by Claude Code (only directories containing SKILL.md are loaded).
  • There is intentionally no per-repo customization mechanism — this pool is a union and every machine gets all of it. Revisit if the pool grows past ~50 skills or you need per-project pinning.
Source 5 files
hud/register.tsx 290 lines
1// ccxp-hud: a Claude Code mod shipped inside the ccxp-skills plugin (T20261022-345549).
2//
3//  - styles the user's own prompt rows (UserMessage render site)
4//  - draws a status band above the prompt (AbovePrompt) fed by the bash helper
5//    statusline-setup/scripts/hud-data.sh --json (claimant secret stays in bash)
6//  - /hud on|off|status|pane
7//
8// ON by default; `enabled: false` in ~/.claude/ccxp-hud.json (or a project
9// override) makes every hook a pass-through. The file is re-read on every poll
10// and by /hud, so toggling is live; no reload needed.
11// Every hook is wrapped so a throw draws the engine's own row. No network, no
12// model and no MCP calls: only fs, process (one fixed argv), clock, state.
13
14import { atom, read, update } from 'claude-code'
15import type { Register } from 'claude-code'
16
17import type { HudConfig, Snapshot } from '../types'
18import { DEFAULTS, configPaths, heartbeatPath, mergeConfig, parseConfigText, withEnabled } from './config'
19import { EMPTY, compactLine, countJobs, parseHelper } from './model'
20import { bandTree, paneTree, userMessageTree } from './render'
21
22const PANE = 'ccxp-hud'
23const snapshotAtom = atom({ plugin: 'ccxp-skills', key: 'snapshot' } as const, EMPTY)
24const configAtom = atom({ plugin: 'ccxp-skills', key: 'config' } as const, DEFAULTS)
25
26// Module-level state: a hot reload starts it over, which is fine (the config and
27// snapshot live in $.state; these are only throttles and flags).
28const mem = {
29  surface: null as string | null,
30  bandSurface: null as string | null,
31  lastHelperAt: 0,
32  heartbeatIsOn: null as boolean | null,
33  started: false,
34}
35
36async function envPaths($: any) {
37  let configDir: string | undefined
38  let home: string | undefined
39  try {
40    configDir = await $.env.get('CLAUDE_CONFIG_DIR')
41    home = await $.env.get('HOME')
42  } catch {
43    /* no env: defaults below stay undefined, config then means defaults */
44  }
45  return { configDir, home }
46}
47
48async function readText($: any, path: string | undefined): Promise<string | undefined> {
49  if (!path) return undefined
50  try {
51    return await $.fs.read(path)
52  } catch {
53    return undefined
54  }
55}
56
57/** Loads project over user over defaults into the atom; never throws. */
58async function loadConfig($: any): Promise<HudConfig> {
59  let cfg = DEFAULTS
60  try {
61    const env = await envPaths($)
62    let root: string | undefined
63    try {
64      root = await $.session.root()
65    } catch {
66      root = undefined
67    }
68    const paths = configPaths(env, root)
69    cfg = mergeConfig(parseConfigText(await readText($, paths.user)), parseConfigText(await readText($, paths.project)))
70  } catch {
71    cfg = DEFAULTS
72  }
73  await update($, configAtom, () => cfg)
74  return cfg
75}
76
77async function writeHeartbeat($: any, value: 'on' | 'off') {
78  try {
79    const id = await $.session.id()
80    const path = heartbeatPath(await envPaths($), id)
81    if (path) await $.fs.write(path, `${value}\n`)
82  } catch {
83    /* best effort: the classic statusline then just prints in full */
84  }
85}
86
87async function syncHeartbeat($: any, cfg: HudConfig) {
88  // "on" only while a band is actually drawn (bandSurface is cleared when the
89  // band hook had nothing to show), so the statusline never shrinks for nothing.
90  const wantOn = cfg.enabled && cfg.band && mem.bandSurface !== null
91  // First call after a (re)load always writes, so a heartbeat left "on" by a
92  // previous module instance cannot outlive the mod being switched off.
93  // "on" is rewritten every call: the statusline treats an old file as off.
94  if (!wantOn && mem.heartbeatIsOn === false) return
95  await writeHeartbeat($, wantOn ? 'on' : 'off')
96  mem.heartbeatIsOn = wantOn
97}
98
99async function refresh($: any, cfg: HudConfig) {
100  if (!cfg.enabled) return
101  mem.lastHelperAt = await $.clock.now()
102  let helper = null
103  let isOk = false
104  try {
105    const cwd: string = await $.session.cwd()
106    const sessionId: string = await $.session.id()
107    const argv = ['bash', `${$.plugin.root}/statusline-setup/scripts/hud-data.sh`, '--json']
108    const res = await $.process.run(argv, {
109      cwd,
110      stdin: JSON.stringify({ cwd, session_id: sessionId }),
111      timeoutMs: 5000,
112    })
113    helper = res.exitCode === 0 ? parseHelper(res.stdout) : null
114    isOk = helper !== null
115  } catch {
116    isOk = false
117  }
118  let agents: ReadonlyArray<{ status: string }> = []
119  try {
120    agents = await $.agent.list()
121  } catch {
122    agents = []
123  }
124  const usage = await readUsage($)
125  await update($, snapshotAtom, (prev: Snapshot) => ({
126    ...prev,
127    ...usage,
128    helper: isOk ? helper : prev.helper,
129    stale: !isOk,
130    jobs: countJobs(agents),
131  }))
132}
133
134async function readUsage($: any): Promise<Partial<Snapshot>> {
135  try {
136    const u = await $.session.usage()
137    return usagePart(u)
138  } catch {
139    return {}
140  }
141}
142
143function usagePart(u: any): Partial<Snapshot> {
144  // Only the units present in `u` are returned, so a partial measurement
145  // (say context alone) never wipes the others.
146  const out: Partial<Snapshot> = {}
147  if (u?.context) {
148    const percent = u.context.percent
149    out.ctxLeft = typeof percent === 'number' ? Math.max(0, 100 - percent) : null
150  }
151  if (Array.isArray(u?.rateLimits)) {
152    const five = u.rateLimits.find((r: any) => r?.kind === 'five_hour')
153    out.rateUsed = typeof five?.percentUsed === 'number' ? five.percentUsed : null
154  }
155  if (u?.cost) out.costUsd = typeof u.cost.usd === 'number' ? u.cost.usd : null
156  return out
157}
158
159async function mirrorStatus($: any, cfg: HudConfig) {
160  // VS Code draws no band: mirror a compact line there. Elsewhere clear it.
161  try {
162    if (cfg.enabled && cfg.band && mem.surface === 'vscode') {
163      $.ui.status(compactLine(await read($, snapshotAtom), cfg))
164    } else {
165      $.ui.status(undefined)
166    }
167  } catch {
168    /* ignore */
169  }
170}
171
172async function tick($: any) {
173  try {
174    await tickOnce($)
175  } catch {
176    /* never throws: a failed tick leaves the previous state */
177  }
178}
179
180async function tickOnce($: any) {
181  const cfg = await loadConfig($)
182  if (!cfg.enabled) {
183    await syncHeartbeat($, cfg)
184    await mirrorStatus($, cfg)
185    return
186  }
187  const now = await $.clock.now()
188  if (now - mem.lastHelperAt >= cfg.pollSeconds * 1000) await refresh($, cfg)
189  await syncHeartbeat($, cfg)
190  await mirrorStatus($, cfg)
191}
192
193
194export const register: Register = on => {
195  on('session.start', async ($, e, next) => {
196    try {
197      mem.surface = e.surface ?? null
198      if (!mem.started) {
199        mem.started = true
200        try {
201          await $.command.register({ name: 'hud', description: 'ccxp-hud: on | off | status | pane' })
202        } catch {
203          /* the command is optional */
204        }
205        // The timer ticks at the minimum poll; each tick gates on the configured poll.
206        $.clock.every(5000, () => {
207          void tick($)
208        })
209      }
210      await tick($)
211    } catch {
212      /* the module stays inert; the session is never broken by the hud */
213    }
214    return next(e)
215  })
216
217  on('session.measure', async ($, e, next) => {
218    try {
219      const cfg = await read($, configAtom)
220      if (cfg.enabled) await update($, snapshotAtom, (prev: Snapshot) => ({ ...prev, ...usagePart(e) }))
221    } catch {
222      /* ignore */
223    }
224    return next(e)
225  })
226
227  on('turn.complete', async ($, e, next) => {
228    try {
229      const cfg = await read($, configAtom)
230      if (cfg.enabled) await refresh($, cfg)
231    } catch {
232      /* ignore */
233    }
234    return next(e)
235  })
236
237  on('command.run', { command: 'hud' }, async ($, e) => {
238    const arg = String((e as any).args ?? '').trim().toLowerCase()
239    try {
240      const env = await envPaths($)
241      const file = configPaths(env).user
242      if (arg === 'on' || arg === 'off') {
243        if (!file) return { text: 'ccxp-hud: cannot find the config dir. Edit ccxp-hud.json by hand: {"enabled": ' + (arg === 'on') + '}' }
244        try {
245          await $.fs.write(file, withEnabled(await readText($, file), arg === 'on'))
246        } catch {
247          return { text: `ccxp-hud: could not write ${file}. Set "enabled": ${arg === 'on'} in it by hand.` }
248        }
249        const cfg = await loadConfig($)
250        if (cfg.enabled) await refresh($, cfg)
251        await syncHeartbeat($, cfg)
252        await mirrorStatus($, cfg)
253        return { text: `ccxp-hud ${cfg.enabled ? 'on' : 'off'} (${file}). Takes effect now.` }
254      }
255      if (arg === 'pane') {
256        await $.ui.open({ id: PANE, title: 'ccxp-hud' })
257        return { text: 'ccxp-hud pane opened.' }
258      }
259      const cfg = await read($, configAtom)
260      return { text: `ccxp-hud is ${cfg.enabled ? 'on' : 'off'}. Config: ${file ?? '(unknown)'}. Usage: /hud on | off | status | pane` }
261    } catch {
262      return { text: 'ccxp-hud: something went wrong; the session is unaffected.' }
263    }
264  })
265
266  on('ui.render', { component: 'UserMessage' }, async ($, e, next) => {
267    const cfg = await read($, configAtom)
268    if (!cfg.enabled || !cfg.userMessage) return next(e)
269    // Only the person's own composer rows; notifications and peers keep the engine's drawing.
270    if (e.props?.origin?.kind !== 'composer') return next(e)
271    // Desktop and VS Code draw their own frames: leave those rows alone.
272    if (e.surface !== 'terminal') return next(e)
273    const inner = await next(e)
274    return userMessageTree($.ui.resolve(e), cfg, inner)
275  }).catch(($, e, next) => next(e))
276
277  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
278    const cfg = await read($, configAtom)
279    if (!cfg.enabled || !cfg.band || e.props.hasSurvey) return next(e)
280    const tree = bandTree($.ui.resolve(e), await read($, snapshotAtom), cfg, e.props.bodyColumns)
281    mem.bandSurface = tree ? e.surface : null
282    return tree ?? next(e)
283  }).catch(($, e, next) => next(e))
284
285  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e, next) => {
286    const cfg = await read($, configAtom)
287    return paneTree($.ui.resolve(e), await read($, snapshotAtom), cfg)
288  }).catch(($, e, next) => next(e))
289}
290
hud/config.ts 107 lines
1// ccxp-hud config: pure functions only (no `$`), so the unit tests can pin them.
2// Missing, empty or malformed input always means the defaults, which are ON.
3
4import type { Field, HudConfig } from '../types'
5
6export type { Field, HudConfig }
7
8export const ALL_FIELDS: readonly Field[] = ['ap', 'ctx', 'branch', 'task', 'jobs', 'rate', 'cost', 'lastat']
9/** Text colour of the user's own prompt rows; the one place to change it. NO_COLOR is honoured by the renderer. */
10export const USER_TEXT_COLOR = 'yellow'
11export const BORDERS: readonly string[] = ['single', 'double', 'round', 'bold', 'singleDouble', 'doubleSingle', 'classic', 'quote']
12
13export const DEFAULTS: HudConfig = {
14  enabled: true,
15  userMessage: true,
16  band: true,
17  userBorder: 'quote',
18  userColor: 'suggestion',
19  fields: ['ap', 'ctx', 'branch', 'task', 'jobs', 'lastat'],
20  pollSeconds: 15,
21  ctxWarn: 40,
22  ctxCrit: 15,
23}
24
25type Raw = Record<string, unknown>
26
27function isRecord(x: unknown): x is Raw {
28  return typeof x === 'object' && x !== null && !Array.isArray(x)
29}
30
31function parseFields(x: unknown): Field[] | undefined {
32  const items = typeof x === 'string' ? x.split(',') : Array.isArray(x) ? x : undefined
33  if (!items) return undefined
34  const out: Field[] = []
35  for (const item of items) {
36    const name = String(item).trim() as Field
37    if (ALL_FIELDS.includes(name) && !out.includes(name)) out.push(name)
38  }
39  return out
40}
41
42/** Parses one config file's text into the keys that are valid; never throws. */
43export function parseConfigText(text: string | undefined | null): Partial<HudConfig> {
44  if (!text) return {}
45  let raw: unknown
46  try {
47    raw = JSON.parse(text)
48  } catch {
49    return {}
50  }
51  if (!isRecord(raw)) return {}
52  const out: Partial<HudConfig> = {}
53  for (const key of ['enabled', 'userMessage', 'band'] as const) {
54    if (typeof raw[key] === 'boolean') out[key] = raw[key] as boolean
55  }
56  if (typeof raw.userBorder === 'string') out.userBorder = raw.userBorder
57  if (typeof raw.userColor === 'string' && raw.userColor.trim() !== '') out.userColor = raw.userColor.trim()
58  const fields = parseFields(raw.fields)
59  if (fields) out.fields = fields
60  for (const key of ['pollSeconds', 'ctxWarn', 'ctxCrit'] as const) {
61    if (typeof raw[key] === 'number' && Number.isFinite(raw[key])) out[key] = raw[key] as number
62  }
63  return out
64}
65
66/** Project over user over defaults, with the numeric keys clamped. */
67export function mergeConfig(user: Partial<HudConfig>, project: Partial<HudConfig>): HudConfig {
68  const cfg: HudConfig = { ...DEFAULTS, ...user, ...project }
69  cfg.pollSeconds = Math.max(5, Math.round(cfg.pollSeconds))
70  cfg.ctxWarn = Math.min(100, Math.max(0, cfg.ctxWarn))
71  cfg.ctxCrit = Math.min(cfg.ctxWarn, Math.max(0, cfg.ctxCrit))
72  return cfg
73}
74
75/** The border to draw, or undefined for none (an unknown name draws no border). */
76export function borderOf(cfg: HudConfig): string | undefined {
77  return BORDERS.includes(cfg.userBorder) ? cfg.userBorder : undefined
78}
79
80/** The user-level file path and the optional per-project override. */
81export function configPaths(env: { configDir?: string; home?: string }, projectRoot?: string) {
82  const base = env.configDir || (env.home ? `${env.home}/.claude` : undefined)
83  return {
84    user: base ? `${base}/ccxp-hud.json` : undefined,
85    project: projectRoot ? `${projectRoot}/.claude/ccxp-hud.json` : undefined,
86  }
87}
88
89/** Text of the user file after `/hud on|off`: other keys kept, `enabled` set. */
90export function withEnabled(text: string | undefined | null, enabled: boolean): string {
91  let raw: Raw = {}
92  try {
93    const parsed: unknown = text ? JSON.parse(text) : {}
94    if (isRecord(parsed)) raw = parsed
95  } catch {
96    raw = {}
97  }
98  return JSON.stringify({ ...raw, enabled }, null, 2) + '\n'
99}
100
101/** Where the heartbeat for a session lives (the statusline reads the same path). */
102export function heartbeatPath(env: { configDir?: string; home?: string }, sessionId: string): string | undefined {
103  const base = env.configDir || (env.home ? `${env.home}/.claude` : undefined)
104  if (!base || !sessionId || /[\\/]/.test(sessionId) || sessionId === '.' || sessionId === '..') return undefined
105  return `${base}/state/hud-active/${sessionId}`
106}
107
hud/model.ts 222 lines
1// ccxp-hud model: pure snapshot + band layout. No `$`, so it is unit-testable.
2
3import type { Autopilot, Field, HelperData, HudConfig, Jobs, Ledger, Snapshot } from '../types'
4
5export type { Autopilot, HelperData, Jobs, Snapshot }
6
7export const EMPTY: Snapshot = {
8  helper: null,
9  ctxLeft: null,
10  rateUsed: null,
11  costUsd: null,
12  jobs: { running: 0, waiting: 0, failed: 0 },
13  stale: false,
14}
15
16function str(x: unknown): string {
17  return typeof x === 'string' ? x : ''
18}
19
20function num(x: unknown): number {
21  return typeof x === 'number' && Number.isFinite(x) ? x : 0
22}
23
24/** Validates the helper's stdout; null on anything unusable (the mod then goes stale). */
25export function parseHelper(stdout: string): HelperData | null {
26  let raw: unknown
27  try {
28    raw = JSON.parse(stdout)
29  } catch {
30    return null
31  }
32  if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) return null
33  const r = raw as Record<string, unknown>
34  // "{}" is the helper's own failure answer.
35  if (!('branch' in r) && !('task_id' in r)) return null
36  const ap = r.autopilot
37  let autopilot: Autopilot | null = null
38  if (typeof ap === 'object' && ap !== null && !Array.isArray(ap)) {
39    const a = ap as Record<string, unknown>
40    autopilot = {
41      status: str(a.status),
42      prefix: str(a.prefix),
43      elapsed_hr: num(a.elapsed_hr),
44      requested_hr: num(a.requested_hr),
45      stuck: num(a.stuck),
46      cycle: num(a.cycle),
47      last_outcome: str(a.last_outcome),
48      last_task: str(a.last_task),
49      stuck_reason: str(a.stuck_reason),
50      stop_reason: str(a.stop_reason),
51      merged: num(a.merged),
52    }
53  }
54  const led = r.ledger
55  let ledger: Ledger | null = null
56  if (typeof led === 'object' && led !== null && !Array.isArray(led)) {
57    const l = led as Record<string, unknown>
58    ledger = {
59      running: (Array.isArray(l.running) ? l.running : [])
60        .filter((x): x is Record<string, unknown> => typeof x === 'object' && x !== null)
61        .map(x => ({ job: num(x.job), title: str(x.title) })),
62      lifetime_jobs: num(l.lifetime_jobs),
63      parked: num(l.parked),
64      parked_ids: (Array.isArray(l.parked_ids) ? l.parked_ids : []).filter((x): x is string => typeof x === 'string'),
65      next_job: num(l.next_job),
66      updated_at: str(l.updated_at),
67    }
68  }
69  return {
70    task_id: str(r.task_id),
71    task_title: str(r.task_title),
72    branch: str(r.branch),
73    worktree: str(r.worktree),
74    autopilot,
75    ledger,
76    last_input_at: num(r.last_input_at),
77  }
78}
79
80/** Counts the session's agents by what the band shows. */
81export function countJobs(agents: ReadonlyArray<{ status: string }>): Jobs {
82  const jobs = { running: 0, waiting: 0, failed: 0 }
83  for (const a of agents) {
84    if (a.status === 'running' || a.status === 'pending') jobs.running += 1
85    else if (a.status === 'waiting' || a.status === 'idle') jobs.waiting += 1
86    else if (a.status === 'failed' || a.status === 'killed') jobs.failed += 1
87  }
88  return jobs
89}
90
91export type Tone = 'success' | 'warning' | 'error'
92
93/** Green above `ctxWarn`, yellow down to `ctxCrit`, red below. */
94export function ctxTone(left: number, cfg: Pick<HudConfig, 'ctxWarn' | 'ctxCrit'>): Tone {
95  if (left > cfg.ctxWarn) return 'success'
96  if (left >= cfg.ctxCrit) return 'warning'
97  return 'error'
98}
99
100export function meter(left: number, width = 10): string {
101  const filled = Math.max(0, Math.min(width, Math.round((left / 100) * width)))
102  return '▓'.repeat(filled) + '░'.repeat(width - filled)
103}
104
105export type Seg = { text: string; color?: string; dim?: boolean; bold?: boolean }
106
107const AP_LOOK: Record<string, { glyph: string; word: string; color: string }> = {
108  '[r]': { glyph: '●', word: 'run', color: 'success' },
109  '[b]': { glyph: '●', word: 'run', color: 'success' }, // backing off is still running; ⚠ carries the stuck count
110  '[s]': { glyph: '○', word: 'stopped', color: 'inactive' },
111  '[p]': { glyph: '⏸', word: 'paused', color: 'warning' },
112}
113
114/** A stop reason that means something went wrong (not elapsed / queue-empty / user-requested). */
115export function isFailureStop(reason: string): boolean {
116  return /fail|error|crash|abort/i.test(reason)
117}
118
119/** Timestamp format of the last-input field, in one place: local HH:MM, plus "Mon D" when not today. */
120export function formatStamp(epochSec: number, nowMs: number = Date.now()): string {
121  const d = new Date(epochSec * 1000)
122  const now = new Date(nowMs)
123  const hm = `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
124  if (d.toDateString() === now.toDateString()) return hm
125  return `${d.toLocaleString('en-US', { month: 'short' })} ${d.getDate()} ${hm}`
126}
127
128export function truncate(text: string, max: number): string {
129  if (max <= 0) return ''
130  return text.length <= max ? text : text.slice(0, Math.max(1, max - 1)) + '…'
131}
132
133function segFor(field: Field, snap: Snapshot, cfg: HudConfig, room: number): Seg[] | null {
134  const h = snap.helper
135  switch (field) {
136    case 'ap': {
137      const ap = h?.autopilot
138      if (!ap) return null
139      const look = { ...(AP_LOOK[ap.prefix] ?? { glyph: '·', word: ap.status, color: 'inactive' }) }
140      if (ap.prefix === '[s]' && isFailureStop(ap.stop_reason)) look.color = 'error'
141      const segs: Seg[] = [
142        { text: 'AP ', bold: true },
143        { text: `${look.glyph} ${look.word} `, color: look.color },
144        { text: `${ap.elapsed_hr}/${ap.requested_hr}h ` },
145      ]
146      if (ap.stuck > 0) segs.push({ text: `⚠${ap.stuck} `, color: 'error' })
147      segs.push({ text: `loops ${ap.cycle} ` }, { text: `✓${ap.merged}`, color: ap.merged > 0 ? 'success' : undefined, dim: ap.merged === 0 })
148      return segs
149    }
150    case 'ctx': {
151      if (snap.ctxLeft === null) return null
152      const left = Math.round(snap.ctxLeft)
153      return [{ text: `ctx ${left}% ${meter(left)}`, color: ctxTone(left, cfg) }]
154    }
155    case 'branch': {
156      const b = h?.branch
157      if (!b) return null
158      return [{ text: ` ${b}${h?.worktree ? ` (wt ${h.worktree})` : ''}` }]
159    }
160    case 'task': {
161      if (!h) return null
162      if (!h.task_id) return [{ text: 'no claimed task', dim: true }]
163      return [{ text: truncate(`${h.task_id} ${h.task_title}`.trim(), room) }]
164    }
165    case 'jobs': {
166      const l = h?.ledger
167      if (!l) return [{ text: 'jobs —', dim: true }]
168      const run = l.running.length
169      const segs: Seg[] = [
170        { text: 'jobs run ' },
171        { text: `${run}`, color: run > 0 ? 'success' : undefined },
172        { text: ` · total ${l.lifetime_jobs} · parked ` },
173        { text: `${l.parked}`, color: l.parked > 0 ? 'warning' : undefined },
174      ]
175      // Wide layout only (room is columns - 60): show what is running.
176      if (run > 0 && room >= 40) {
177        const names = l.running.map(j => `r${j.job}${j.title ? ' ' + j.title : ''}`).join(', ')
178        segs.push({ text: ` (${truncate(names, room - 10)})`, dim: true })
179      }
180      return segs
181    }
182    case 'rate':
183      return snap.rateUsed === null ? null : [{ text: `5h ${Math.round(snap.rateUsed)}%` }]
184    case 'cost':
185      return snap.costUsd === null ? null : [{ text: `$${snap.costUsd.toFixed(2)}` }]
186    case 'lastat': {
187      const at = h?.last_input_at
188      return at ? [{ text: `you ${formatStamp(at)}`, dim: true }] : null
189    }
190  }
191}
192
193/**
194 * The band as rows of segments. One row from ~100 columns up, two below.
195 * Empty when there is nothing to show (the caller then yields to the engine).
196 */
197export function bandRows(snap: Snapshot, cfg: HudConfig, columns: number): Seg[][] {
198  const blocks: Seg[][] = []
199  for (const f of cfg.fields) {
200    const block = segFor(f, snap, cfg, Math.max(12, columns - 60))
201    if (block) blocks.push(block)
202  }
203  if (blocks.length === 0) return []
204  if (snap.stale) blocks.push([{ text: 'stale', dim: true }])
205
206  const narrow = columns < 100
207  const rows: Seg[][] = narrow ? [[], []] : [[]]
208  const half = Math.ceil(blocks.length / 2)
209  blocks.forEach((block, i) => {
210    const row = narrow ? rows[i < half ? 0 : 1] : rows[0]
211    if (row.length > 0) row.push({ text: ' │ ', dim: true })
212    row.push(...block)
213  })
214  return rows.filter(r => r.length > 0)
215}
216
217/** One plain line for surfaces with no band (VS Code `$.ui.status`), 2,000 chars at most. */
218export function compactLine(snap: Snapshot, cfg: HudConfig): string {
219  const rows = bandRows(snap, cfg, 200)
220  return rows.map(r => r.map(s => s.text).join('')).join(' ').slice(0, 2000)
221}
222
hud/render.tsx 74 lines
1// ccxp-hud trees. The element constructors come from `$.ui.resolve(e)` and are
2// passed in, so these stay free of `$` and the tests can drive them.
3
4import type { HudConfig, Snapshot } from '../types'
5import { USER_TEXT_COLOR, borderOf } from './config'
6import { bandRows } from './model'
7
8/** The prompt row wrapped in the configured frame; `inner` is the engine's own drawing. */
9export function userMessageTree(
10  ui: { Box: any; Text: any },
11  cfg: HudConfig,
12  inner: unknown,
13) {
14  const { Box, Text } = ui
15  const border = borderOf(cfg)
16  return (
17    <Box borderStyle={border} borderColor={cfg.userColor} paddingX={border ? 1 : 0}>
18      <Text color={USER_TEXT_COLOR}>{inner}</Text>
19    </Box>
20  )
21}
22
23/** The status band, or null when there is nothing to draw. */
24export function bandTree(
25  ui: { Box: any; Text: any },
26  snap: Snapshot,
27  cfg: HudConfig,
28  columns: number,
29) {
30  const { Box, Text } = ui
31  const rows = bandRows(snap, cfg, columns)
32  if (rows.length === 0) return null
33  return (
34    <Box flexDirection="column" paddingX={1}>
35      {rows.map(row => (
36        <Box flexDirection="row">
37          {row.map(seg => (
38            <Text color={seg.color} dimColor={seg.dim} bold={seg.bold}>
39              {seg.text}
40            </Text>
41          ))}
42        </Box>
43      ))}
44    </Box>
45  )
46}
47
48/** The `/hud pane` detail view. */
49export function paneTree(ui: { Box: any; Text: any }, snap: Snapshot, cfg: HudConfig) {
50  const { Box, Text } = ui
51  const ap = snap.helper?.autopilot
52  const lines: string[] = [
53    `enabled: ${cfg.enabled}   band: ${cfg.band}   userMessage: ${cfg.userMessage}`,
54    ap
55      ? `autopilot ${ap.status} ${ap.prefix}  ${ap.elapsed_hr}/${ap.requested_hr}h  ${ap.stuck > 0 ? `⚠${ap.stuck} ` : ''}loops ${ap.cycle} ✓${ap.merged}`
56      : 'autopilot: not running',
57  ]
58  if (ap?.last_task) lines.push(`last task: ${ap.last_task}`)
59  if (ap?.stuck_reason) lines.push(`last stuck reason: ${ap.stuck_reason}`)
60  if (ap?.stop_reason) lines.push(`stop reason: ${ap.stop_reason}`)
61  const led = snap.helper?.ledger
62  lines.push(led ? `jobs: run ${led.running.length} · total ${led.lifetime_jobs} · parked ${led.parked}${led.parked_ids.length ? ` (${led.parked_ids.join(' ')})` : ''}` : 'jobs: no ledger')
63  lines.push(`agents: ${snap.jobs.running} running, ${snap.jobs.waiting} waiting, ${snap.jobs.failed} failed`)
64  if (snap.ctxLeft !== null) lines.push(`context left: ${Math.round(snap.ctxLeft)}%`)
65  if (snap.stale) lines.push('data is stale (the helper failed or timed out)')
66  return (
67    <Box flexDirection="column">
68      {lines.map(l => (
69        <Text>{l}</Text>
70      ))}
71    </Box>
72  )
73}
74
types/index.d.ts 73 lines
1// Types and $.state contract of the ccxp-hud mod (hud/register.tsx). The
2// contract must be self-contained, so the shared shapes are defined here and the
3// module's files import them from '../types'.
4
5export type Field = 'ap' | 'ctx' | 'branch' | 'task' | 'jobs' | 'rate' | 'cost' | 'lastat'
6
7export type HudConfig = {
8  enabled: boolean
9  userMessage: boolean
10  band: boolean
11  userBorder: string
12  userColor: string
13  fields: Field[]
14  pollSeconds: number
15  ctxWarn: number
16  ctxCrit: number
17}
18
19export type Autopilot = {
20  status: string
21  prefix: string
22  elapsed_hr: number
23  requested_hr: number
24  stuck: number
25  cycle: number
26  last_outcome: string
27  last_task: string
28  stuck_reason: string
29  stop_reason: string
30  /** Tasks merged this run; 0 for state files written before the field existed. */
31  merged: number
32}
33
34export type Ledger = {
35  running: { job: number; title: string }[]
36  lifetime_jobs: number
37  parked: number
38  parked_ids: string[]
39  next_job: number
40  updated_at: string
41}
42
43export type HelperData = {
44  task_id: string
45  task_title: string
46  branch: string
47  worktree: string
48  autopilot: Autopilot | null
49  /** Job ledger (statusline-setup/scripts/ledger.sh), null when there is none. */
50  ledger: Ledger | null
51  /** Epoch seconds of the session's last submitted prompt, 0 when unknown. */
52  last_input_at: number
53}
54
55export type Jobs = { running: number; waiting: number; failed: number }
56
57export type Snapshot = {
58  helper: HelperData | null
59  /** Context left, 0-100, or null before the first reading. */
60  ctxLeft: number | null
61  rateUsed: number | null
62  costUsd: number | null
63  jobs: Jobs
64  /** True when the last helper run failed: the helper part is the previous one. */
65  stale: boolean
66}
67
68declare module 'claude-code' {
69  interface PluginState {
70    'ccxp-skills': { snapshot: Snapshot; config: HudConfig }
71  }
72}
73