SLOPSHOPPER

bridge

KOZMOS Bridge: a mission-control sidebar. Model and effort, context fill, 5-hour and weekly limits with reset countdowns, session cost and burn rate, git…

newpaneguardcommandprocesstimer
★ 4v0.1.0MITupdated 2026-10-09ersinkoc/claude-mods/mods/bridge
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · bridge
│ ┃ kz-bridge ✕ › fix the failing auth test and add an audit log call │ ┃ ◆ Opus 5.5 ○ idle │ ┃ CTX ███████████▊░░░░░░░░░░░░ 49% 97k/200k ⏺ Read(src/auth.ts) │ ┃ 5H ███████▍░░░░░░░░░░░░░░░░ 31% ⎿ Read 6 lines │ ┃ COST $0.42 · 30:01 · $0.84/h ⏺ Update(src/auth.ts) │ ┃ GIT ✓ clean ⎿ Added 2 lines, removed 1 line │ ┃ CPU — ⏺ Bash(bun test) │ ┃ AGT ◈ 0 running · 0 done ⎿ 3 pass, 1 fail │ ┃ TOOL ✓ Bash Show environment file · 40ms · # │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /bridge │ ⎿ bridge: Bridge closed. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · kz-bridge
◆ Opus 5.5 ○ idle CTX ███████████▊░░░░░░░░░░░░ 49% 97k/200k 5H ███████▍░░░░░░░░░░░░░░░░ 31% COST $0.42 · 30:01 · $0.84/h GIT ✓ clean CPU — AGT ◈ 0 running · 0 done TOOL ✓ Bash Show environment file · 40ms · #9
README

KOZMOS

48 live, visual mods for Claude Code — in the terminal and in the desktop app.

Above the chat input, in the sidebar, in the transcript, on the status line and in your speakers: what Claude is doing right now, what it costs, how close you are to the 5-hour and weekly limits, what your subagents are up to, how full the context is, the state of your git tree and the heat of your machine.

<a href="https://www.youtube.com/watch?v=Eabw9kr9nVc"><img src="docs/media/kozmos-promo-poster.jpg" alt="KOZMOS: the 2:48 promo film on YouTube. Click to watch." width="860"></a>

<img src="docs/previews/orrery-aboveprompt-terminal.png" alt="orrery: subagents orbiting Claude above the prompt" width="860"> <table><tr> <td valign="top"><img src="docs/previews/bridge-pane-terminal.png" alt="bridge sidebar in the terminal" width="560"></td> <td valign="top"><picture><source media="(prefers-color-scheme: dark)" srcset="docs/previews/bridge-pane-desktop-dark.png"><img src="docs/previews/bridge-pane-desktop-light.png" alt="bridge sidebar in the desktop app" width="270"></picture></td> </tr></table>

KOZMOS is a bundle of independent function-hooks plugins ("mods"). Install one, a preset, or all of them. Every mod draws twice: for the terminal (colored text, cell rasters, 30 fps surface animations) and for the desktop app (animated SVG that follows the light and dark themes).

Requires Claude Code 2.1.290+, where function hooks are on by default. The mod API is early access and may change between releases.

Install

Inside Claude Code

/plugin marketplace add ersinkoc/claude-mods
/plugin install kozmos@kozmos
/plugin install bridge@kozmos

or one line per mod: /plugin install orrery --marketplace ersinkoc/claude-mods. Then type /kozmos: the hub lists every mod and opens each one with a click.

With the installer: a menu, presets, uninstall

git clone https://github.com/ersinkoc/claude-mods.git
cd claude-mods
./install.ps1                    # Windows: interactive menu
./install.ps1 -Preset showtime   # essentials | showtime | all
./install.ps1 -Mods bridge,orrery,halo
./install.ps1 -Uninstall
./install.sh                     # macOS / Linux / Git Bash
./install.sh --preset all

The installer adds the folder as the kozmos marketplace and installs what you pick at the user scope, so the mods load in the CLI and in the desktop app's Code tab. A folder marketplace is read from the folder itself: after git pull, /reload-plugins picks up the new code. To try one mod for a single session: claude --plugin-dir mods/bridge.

Presets

  • essentials — kozmos, bridge, halo, questline, marquee, hivemind, hourglass, compass, stamp, warden, ballast
  • showtime — essentials + heartline, orrery, glyphfall, aurora, throttle, clawdling, skies, laurels, warpdrive, tidewater, storyboard, mantra, verdict
  • all — everything

⚡ Sponsored by WrongStack

Built on the wrong stack. Shipped anyway.

Want way more than Claude Code? WrongStack is a free, open-source AI coding agent with a Brain, a Memory, and a full toolbox. It reads your code, edits files, runs commands, and coordinates specialist agents — across six surfaces, from a plain terminal REPL to a cross-machine HQ dashboard. No subscription required, and you keep your hand on every permission.

Website &nbsp; GitHub &nbsp; Stars

curl -fsSL https://wrongstack.com/install.sh | sh   # macOS / Linux — self-contained binary
irm https://wrongstack.com/install.ps1 | iex        # Windows (PowerShell) — no Node.js needed
What you get
🌐 200+ LLM providersCatalog pulled live from models.dev — Anthropic, OpenAI, Google, plus OAuth sign-in for Claude Pro/Max, ChatGPT and Copilot, and any OpenAI-compatible endpoint (Ollama, vLLM, LM Studio)
🛠️ 67 built-in toolsEdits, lint/typecheck/test, execution, git, web, browser/E2E and a SQLite codebase index — every call gated by per-tool permissions
🧠 SAGE memoryProject-wide long-term memory in SQLite/FTS5, anchored to files, symbols and commits — it gets better at your codebase over time
🖥️ Six surfacesReadline REPL · Ink/React TUI (--tui) · WebUI · SimpleUI · Desktop · cross-machine HQ (--hq)
🤖 Fleet orchestrationA Director fans out specialist subagents over a project mailbox; eternal & parallel goal loops run until the contract verifies
🔍 Chimera & KanbanAuto-review agents that critique your diffs with severity-ranked findings, plus durable Kanban boards with atomic verification
🔐 Secure by defaultEncrypted secrets at rest, project-root containment, opt-in YOLO mode — MIT licensed, TypeScript-strict

📊 The perfect pairing: KOZMOS's hourglass, bridge and throttle tell you exactly where your Claude limits stand — and WrongStack keeps you moving when they close in. It reads plan windows for Claude, ChatGPT, Copilot, Z.AI and more right in its own statusline and quota page, and when one provider runs dry, fallback chains rotate you onto the next model automatically. Watch the gauges, dodge the wall.

🔗 wrongstack.com &nbsp;·&nbsp; github.com/WrongStack/WrongStack &nbsp;·&nbsp; ⭐ Star it on GitHub


The mods

The pictures are the mods' own drawings, played through a made-up session in claude plugin test and rendered by scripts/render-previews.mjs.

<!-- gallery:start --> <!-- Generated by scripts/build-readme.mjs. Edit the mods' plugin.json descriptions instead. -->

Hub

One command opens them all.

Kozmos · /kozmos

the launcher for the whole bundle. /kozmos opens a starfield sidebar listing every KOZMOS mod by where it draws, which ones are installed in this session, and a button that runs each mod's own command.

<table><tr><td valign="top"><img src="docs/previews/kozmos-pane-terminal.png" alt="kozmos in the terminal" width="520"></td><td valign="top"><picture><source media="(prefers-color-scheme: dark)" srcset="docs/previews/kozmos-pane-desktop-dark.png"><img src="docs/previews/kozmos-pane-desktop-light.png" alt="kozmos in the desktop app" width="300"></picture></td></tr></table>

Sidebars

Docked beside the transcript; each toggles with its own command.

Almanac · /almanac

what this session is made of. Slash commands run, skills invoked, subagent types spawned, MCP servers used, models seen, plugins whose tools were called and tool families, each with counts and first-use time, as ranked bars and tag clouds; plus the engine version and how many installed commands went unused. /almanac toggles it.

<table><tr><td valign="top"><img src="docs/previews/almanac-pane-terminal.png" alt="almanac in the terminal" width="520"></td><td valign="top"><picture><source media="(prefers-color-scheme: dark)" srcset="docs/previews/almanac-pane-desktop-dark.png"><img src="docs/previews/almanac-pane-desktop-light.png" alt="almanac in the desktop app" width="300"></picture></td></tr></table>

Breadcrumbs · /breadcrumbs

the session's web trail in a sidebar. WebSearch queries with result counts and top hits, WebFetch URLs with status and size, and browser-like MCP visits, grouped by domain with a monogram badge, clickable links and a copy-all-as-Markdown button. /breadcrumbs toggles it.

<table><tr><td valign="top"><img src="docs/previews/breadcrumbs-pane-terminal.png" alt="breadcrumbs in the terminal" width="520"></td><td valign="top"><picture><source media="(prefers-color-scheme: dark)" srcset="docs/previews/breadcrumbs-pane-desktop-dark.png"><img src="docs/previews/breadcrumbs-pane-desktop-light.png" alt="breadcrumbs in the desktop app" width="300"></picture></td></tr></table>

Bridge · /bridge

a mission-control sidebar. Model and effort, context fill, 5-hour and weekly limits with reset countdowns, session cost and burn rate, git branch and tree state, CPU/RAM/GPU, live subagents and the tool running now. /bridge toggles it.

<table><tr><td valign="top"><img src="docs/previews/bridge-pane-terminal.png" alt="bridge in the terminal" width="520"></td><td valign="top"><picture><source media="(prefers-color-scheme: dark)" srcset="docs/previews/bridge-pane-desktop-dark.png"><img src="docs/previews/bridge-pane-desktop-light.png" alt="bridge in the desktop app" width="300"></picture></td></tr></table>

Chronicle · /chronicle

the session as a timeline. Prompts, turn ends with duration, tool count and spend, subagents spawned and finished, commits and pushes, failed tools, compactions, and rate-limit and context milestones, each stamped HH:MM:SS, newest first, with filters. /chronicle toggles the sidebar.

<table><tr><td valign="top"><img src="docs/previews/chronicle-pane-terminal.png" alt="chronicle in the terminal" width="520"></td><td valign="top"><picture><source media="(prefers-color-scheme: dark)" srcset="docs/previews/chronicle-pane-desktop-dark.png"><img src="docs/previews/chronicle-pane-desktop-light.png" alt="chronicle in the desktop app" width="300"></picture></td></tr></table>

Faultline · /faultline

the error lens in a sidebar. Every failed or denied tool call, refused or errored turn and API error, grouped by a masked signature with count, first and last seen, the agents that hit it, the full first error (expandable) and a copy button, under a severity strip of the last 10 minutes. /faultline toggles it.

<table><tr><td valign="top"><img src="docs/previews/faultline-pane-terminal.png" alt="faultline in the terminal" width="520"></td><td valign="top"><picture><source media="(prefers-color-scheme: dark)" srcset="docs/previews/faultline-pane-desktop-dark.png"><img src="docs/previews/faultline-pane-desktop-light.png" alt="faultline in the desktop app" width="300"></picture></td></tr></table>

Gearbox · /gearbox

tool analytics for the session. Per tool its calls, errors and error rate, average, p95 and max duration and total time as a sorted bar chart, the five slowest calls, calls per minute, and the main loop against subagents. /gearbox toggles the sidebar.

<table><tr><td valign="top"><img src="docs/previews/gearbox-pane-terminal.png" alt="gearbox in the terminal" width="520"></td><td valign="top"><picture><source media="(prefers-color-scheme: dark)" srcset="docs/previews/gearbox-pane-desktop-dark.png"><img src="docs/previews/gearbox-pane-desktop-light.png" alt="gearbox in the desktop app" width="300"></picture></td></tr></table>

Gitscope · /gitscope

a git radar sidebar. Branch, upstream and ahead/behind, staged/changed/new/conflict counts and stashes, the last 8 commits on a graph rail, per-file +/- histograms from git diff, and a marker on every file this session edited. /gitscope toggles it.

<table><tr><td valign="top"><img src="docs/previews/gitscope-pane-terminal.png" alt="gitscope in the terminal" width="520"></td><td valign="top"><picture><source media="(prefers-color-scheme: dark)" srcset="docs/previews/gitscope-pane-desktop-dark.png"><img src="docs/previews/gitscope-pane-desktop-light.png" alt="gitscope in the desktop app" width="300"></picture></td></tr></table>

Hivemind · /hivemind

the agent swarm as a live tree. The main loop at the root and every subagent beneath its parent, each with a status glyph, type and task, model and effort, elapsed time, requests, tokens, estimated spend, context fill and the tool it runs now. /hivemind toggles the sidebar.

<table><tr><td valign="top"><img src="docs/previews/hivemind-pane-terminal.png" alt="hivemind in the terminal" width="520"></td><td valign="top"><picture><source media="(prefers-color-scheme: dark)" srcset="docs/previews/hivemind-pane-desktop-dark.png"><img src="docs/previews/hivemind-pane-desktop-light.png" alt="hivemind in the desktop app" width="300"></picture></td></tr></table>

Hourglass · /hourglass

the limit oracle. For the 5-hour, weekly and spend windows: percent used, reset countdown, a burn rate learned across sessions, the time to 100 % and a verdict on whether the reset comes first. Toasts at 80 % and 95 %, an optional status line. /hourglass toggles the sidebar.

<table><tr><td valign="top"><img src="docs/previews/hourglass-pane-terminal.png" alt="hourglass in the terminal" width="520"></td><td valign="top"><picture><source media="(prefers-color-scheme: dark)" srcset="docs/previews/hourglass-pane-desktop-dark.png"><img src="docs/previews/hourglass-pane-desktop-light.png" alt="hourglass in the desktop app" width="300"></picture></td></tr></table>

Launchpad · /launchpad

a pane of big launch buttons. It learns your most-used slash commands as you run them, keeps pinned favorites and saved prompts, and runs a command or fills the prompt box in one press. /launchpad toggles it; save <text>, rm <n>, pin <cmd>, unpin <cmd>, list.

<table><tr><td valign="top"><img src="docs/previews/launchpad-pane-terminal.png" alt="launchpad in the terminal" width="520"></td><td valign="top"><picture><source media="(prefers-color-scheme: dark)" srcset="docs/previews/launchpad-pane-desktop-dark.png"><img src="docs/previews/launchpad-pane-desktop-light.png" alt="launchpad in the desktop app" width="300"></picture></td></tr></table>

Mosaic · /mosaic

a GitHub-style activity calendar across sessions. 26 weeks of days colored by turns, tool calls, tokens or dollars, with the current and longest streak, the best day, totals and today's line. /mosaic toggles the sidebar.

<table><tr><td valign="top"><img src="docs/previews/mosaic-pane-terminal.png" alt="mosaic in the terminal" width="520"></td><td valign="top"><picture><source media="(prefers-color-scheme: dark)" srcset="docs/previews/mosaic-pane-desktop-dark.png"><img src="docs/previews/mosaic-pane-desktop-light.png" alt="mosaic in the desktop app" width="300"></picture></td></tr></table>

Relay · /relay

the API latency lab in a sidebar. Every model request of the main loop and its subagents with latency, time to first token, model, effort, input/output/cache tokens, tokens per second and stop reason; a latency histogram, tokens/sec over time, model mix, p50/p95, the slowest five and cache-read share per request. /relay toggles it.

<table><tr><td valign="top"><img src="docs/previews/relay-pane-terminal.png" alt="relay in the terminal" width="520"></td><td valign="top"><picture><source media="(prefers-color-scheme: dark)" srcset="docs/previews/relay-pane-desktop-dark.png"><img src="docs/previews/relay-pane-desktop-light.png" alt="relay in the desktop app" width="300"></picture></td></tr></table>

Spectra · /spectra

the context X-ray. The context window broken down by category as a heat grid with a ranked legend, context growth per turn, and an estimate of how many turns remain before auto-compact. /spectra toggles the sidebar.

<table><tr><td valign="top"><img src="docs/previews/spectra-pane-terminal.png" alt="spectra in the terminal" width="520"></td><td valign="top"><picture><source media="(prefers-color-scheme: dark)" srcset="docs/previews/spectra-pane-desktop-dark.png"><img src="docs/previews/spectra-pane-desktop-light.png" alt="spectra in the desktop app" width="300"></picture></td></tr></table>

Switchboard · /switchboard

an MCP observatory sidebar. Every MCP server in the session with its tools, calls, errors, average and p95 latency, last call and a status lamp (healthy, erroring, idle, busy); servers with no calls yet too, and per-tool rows on expand. On the desktop a patch bay with glowing cables to the active servers. /switchboard toggles it.

<table><tr><td valign="top"><img src="docs/previews/switchboard-pane-terminal.png" alt="switchboard in the terminal" width="520"></td><td valign="top"><picture><source media="(prefers-color-scheme: dark)" srcset="docs/previews/switchboard-pane-desktop-dark.png"><img src="docs/previews/switchboard-pane-desktop-light.png" alt="switchboard in the desktop app" width="300"></picture></td></tr></table>

Taskforge · /taskforge

a live kanban of the session's tasks. TodoWrite lists and TaskCreate/TaskUpdate tasks fall into Pending, In progress and Done columns with the active form, time in the column and the owning agent, under a progress bar. /taskforge toggles the sidebar.

<table><tr><td valign="top"><img src="docs/previews/taskforge-pane-terminal.png" alt="taskforge in the terminal" width="520"></td><td valign="top"><picture><source media="(prefers-color-scheme: dark)" srcset="docs/previews/taskforge-pane-desktop-dark.png"><img src="docs/previews/taskforge-pane-desktop-light.png" alt="taskforge in the desktop app" width="300"></picture></td></tr></table>

Terminus · /terminus

the session's shell history in a sidebar. Every Bash and PowerShell call of the main loop and its subagents with command, description, cwd, exit status, duration, output size and who ran it; filters for failed and slow runs, copy and re-run buttons. /terminus toggles it.

<table><tr><td valign="top"><img src="docs/previews/terminus-pane-terminal.png" alt="terminus in the terminal" width="520"></td><td valign="top"><picture><source media="(prefers-color-scheme: dark)" srcset="docs/previews/terminus-pane-desktop-dark.png"><img src="docs/previews/terminus-pane-desktop-light.png" alt="terminus in the desktop app" width="300"></picture></td></tr></table>

Thermal · /thermal

a heatmap of the files this session touches. Reads, edits, writes and searches per file, a heat score that cools over time, grouped as a directory tree under the session folder: an indented heat tree on the terminal, a squarified treemap on the desktop, switchable by heat, reads or edits. /thermal toggles it.

<table><tr><td valign="top"><img src="docs/previews/thermal-pane-terminal.png" alt="thermal in the terminal" width="520"></td><td valign="top"><picture><source media="(prefers-color-scheme: dark)" srcset="docs/previews/thermal-pane-desktop-dark.png"><img src="docs/previews/thermal-pane-desktop-light.png" alt="thermal in the desktop app" width="300"></picture></td></tr></table>

Tokenomics · /tokenomics

the cost lab. Session cost as the headline, an estimated split by model and by token type, cache hit ratio, burn per hour, cost per turn, a pace forecast and daily and weekly totals across sessions with a 14-day chart. /tokenomics toggles the sidebar.

<table><tr><td valign="top"><img src="docs/previews/tokenomics-pane-terminal.png" alt="tokenomics in the terminal" width="520"></td><td valign="top"><picture><source media="(prefers-color-scheme: dark)" srcset="docs/previews/tokenomics-pane-desktop-dark.png"><img src="docs/previews/tokenomics-pane-desktop-light.png" alt="tokenomics in the desktop app" width="300"></picture></td></tr></table>

Vitals · /vitals

a btop-style machine monitor sidebar. CPU with a braille history graph, RAM bar and history, disk use, NVIDIA GPU load, memory and temperature, process count, and the Claude side: session time, tool calls, running agents. Samples every 2 s only while open. /vitals toggles it.

<table><tr><td valign="top"><img src="docs/previews/vitals-pane-terminal.png" alt="vitals in the terminal" width="520"></td><td valign="top"><picture><source media="(prefers-color-scheme: dark)" srcset="docs/previews/vitals-pane-desktop-dark.png"><img src="docs/previews/vitals-pane-desktop-light.png" alt="vitals in the desktop app" width="300"></picture></td></tr></table>

Bands above the prompt — the visual show

Live drawings just above the chat input. Every band has a ✕ and its command brings it back.

Aurora · /aurora

northern lights above the prompt while the model thinks. Shifting curtains whose intensity and speed follow the effort level (low is gentle, max is wild), a calmer single wave while it responds, and a label like 'thinking · xhigh · 12s'. /aurora toggles it.

<img src="docs/previews/aurora-aboveprompt-terminal.png" alt="aurora in the terminal" width="820">

Blackbox · /blackbox

a flight recorder above the prompt. A live Gantt of the current turn: the main loop and each subagent on its own lane, every tool call a bar in its family color, model requests as ticks, a moving now cursor on an auto-scaling time axis; the last turn stays dimmed until the next. /blackbox toggles it.

<img src="docs/previews/blackbox-aboveprompt-terminal.png" alt="blackbox in the terminal" width="820">

<img src="docs/previews/blackbox-aboveprompt-desktop.svg" alt="blackbox in the desktop app, animated" width="820">

Glyphfall · /glyphfall

matrix rain of tool activity above the prompt while Claude works. Every tool call drops its name and detail as a wave of letters, colored by tool family, with bright heads and fading tails, over a sparse rain of katakana; the last five tools sit below as colored chips. /glyphfall toggles it.

<img src="docs/previews/glyphfall-aboveprompt-terminal.png" alt="glyphfall in the terminal" width="820">

<img src="docs/previews/glyphfall-aboveprompt-desktop.svg" alt="glyphfall in the desktop app, animated" width="820">

Halo · /halo

a one-row mood line above the prompt whose color and motion tell what Claude is doing: calm blue breathing when idle, a violet flow while it requests and writes, a magenta shimmer while it thinks, the running tool's color racing across, a red flash when a tool fails, an amber blink when it waits for you. /halo toggles it.

<img src="docs/previews/halo-aboveprompt-terminal.png" alt="halo in the terminal" width="820">

<img src="docs/previews/halo-aboveprompt-desktop.svg" alt="halo in the desktop app, animated" width="820">

Heartline · /heartline

a neon EKG of the session drawn above the prompt. Every tool call is a spike colored by its family, every model request a blip, a failed tool a red inverted spike; the trace drifts green to red with context fill, with a live bpm (tool calls per minute) and ctx readout. /heartline toggles it.

<img src="docs/previews/heartline-aboveprompt-terminal.png" alt="heartline in the terminal" width="820">

<img src="docs/previews/heartline-aboveprompt-desktop.svg" alt="heartline in the desktop app, animated" width="820">

Orrery · /orrery

subagents as pla

Source 4 files
hooks/register.tsx 430 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderChildren } from 'claude-code'
3
4import type { BridgeSnap } from '../types'
5import {
6  KZ, bar, clamp01, fitText, fmtClock, fmtPct, fmtSpan, fmtTokens, fmtUsd, heat, limitLabel, modelName,
7  pxOf, sparkline, svg, svgBar, svgText, toolColor, toolDetail, toolGlyph, toolName, untilReset,
8} from './lib/kz.ts'
9import {
10  GIT_HEAD, GIT_STATUS, MAC_MEMSIZE, MAC_TOP, NO_GIT, NVIDIA_SMI, WIN_SYS, fmtBytes, parseGitStatus, parseMacTop,
11  parseMeminfo, parseNvidia, parseProcStat, parseWinSys, platformFrom,
12} from './lib/probe.ts'
13import type { CpuTicks, GitSnap, Platform, SysSnap } from './lib/probe.ts'
14
15const PANE = 'kz-bridge'
16const TITLE = 'KOZMOS · Bridge'
17const snapAtom = atom({ plugin: 'bridge', key: 'snap' } as const, null)
18
19const blank = (now: number): BridgeSnap => ({
20  model: '',
21  isWorking: false,
22  ctxWindow: 1_000_000,
23  limits: [],
24  startedAt: now,
25  branch: '',
26  isRepo: false,
27  ahead: 0,
28  behind: 0,
29  staged: 0,
30  unstaged: 0,
31  untracked: 0,
32  conflicts: 0,
33  cpuHistory: [],
34  agentsRunning: 0,
35  agentsDone: 0,
36  agentsFailed: 0,
37  toolCount: 0,
38  now,
39})
40
41async function isOpen($: EngineInterface): Promise<boolean> {
42  return (await $.ui.panes()).some(p => p.id === PANE)
43}
44
45async function toggle($: EngineInterface): Promise<boolean> {
46  if (await isOpen($)) {
47    await $.ui.close({ id: PANE })
48    return false
49  }
50  await $.ui.open({ id: PANE, title: TITLE })
51  return true
52}
53
54// ---------------------------------------------------------------------------
55// Probes: $ may only flow into functions of this file, so the shell calls
56// live here and lib/probe.ts parses what they print.
57
58let platform: Platform | undefined
59let cpuTicks: CpuTicks | undefined
60let hasGpu: boolean | undefined
61
62async function sh($: EngineInterface, argv: readonly string[], timeoutMs: number, cwd?: string): Promise<string | undefined> {
63  try {
64    const r = await $.process.run(argv, cwd ? { timeoutMs, cwd } : { timeoutMs })
65    return r.exitCode === 0 ? r.stdout : undefined
66  } catch {
67    return undefined
68  }
69}
70
71async function readGit($: EngineInterface, cwd: string): Promise<GitSnap> {
72  const status = await sh($, GIT_STATUS, 5000, cwd)
73  if (status === undefined) return NO_GIT
74  return parseGitStatus(status, (await sh($, GIT_HEAD, 3000, cwd)) ?? '')
75}
76
77async function readSys($: EngineInterface): Promise<SysSnap> {
78  platform ??= platformFrom(await $.env.get('OS'), (await $.env.get('OS')) ? '' : await sh($, ['uname', '-s'], 2000))
79  let snap: SysSnap = {}
80  if (platform === 'win') snap = parseWinSys((await sh($, WIN_SYS, 8000)) ?? '')
81  else if (platform === 'mac') snap = parseMacTop((await sh($, MAC_TOP, 6000)) ?? '', (await sh($, MAC_MEMSIZE, 2000)) ?? '')
82  else {
83    try {
84      const { cpu, ticks } = parseProcStat(await $.fs.read('/proc/stat'), cpuTicks)
85      cpuTicks = ticks
86      snap = { cpu, ...parseMeminfo(await $.fs.read('/proc/meminfo')) }
87    } catch {
88      snap = {}
89    }
90  }
91  if (hasGpu !== false) {
92    const out = await sh($, NVIDIA_SMI, 4000)
93    hasGpu = out !== undefined
94    snap.gpu = out === undefined ? undefined : parseNvidia(out)
95  }
96  return snap
97}
98
99// Live collector; the pane draws from the published snapshot in $.state.
100// Module state starts over on a reload, and session.start fills it again.
101let live: BridgeSnap = blank(0)
102let tick = 0
103let lastPublished = ''
104
105async function publish($: EngineInterface): Promise<void> {
106  live.now = await $.clock.now()
107  // Publish only what changed, so an idle pane does not redraw for nothing
108  // (the clock only matters while something runs).
109  const key = JSON.stringify({ ...live, now: live.tool || live.isWorking ? live.now : 0 })
110  if (key === lastPublished) return
111  lastPublished = key
112  await update($, snapAtom, () => ({ ...live, cpuHistory: [...live.cpuHistory] }))
113}
114
115async function sample($: EngineInterface): Promise<void> {
116  tick++
117  try {
118    const usage = await $.session.usage()
119    live.ctxTokens = usage.context.tokens
120    live.ctxWindow = usage.context.window
121    live.ctxPercent = usage.context.percent
122    live.limits = usage.rateLimits.map(l => ({ kind: l.kind, percentUsed: l.percentUsed, resetsAt: l.resetsAt }))
123    live.costUsd = usage.cost?.usd
124    live.startedAt = usage.startedAt
125    live.model = await $.session.model()
126    const agents = await $.agent.list()
127    live.agentsRunning = agents.filter(a => a.status === 'running' || a.status === 'pending' || a.status === 'waiting').length
128    live.agentsDone = agents.filter(a => a.status === 'completed' || a.status === 'idle').length
129    live.agentsFailed = agents.filter(a => a.status === 'failed' || a.status === 'killed').length
130  } catch {
131    // A figure the engine cannot give now is left as it was.
132  }
133  // Git every 5 s, the machine every 4 s: both shell out.
134  if (tick % 5 === 1) {
135    const g = await readGit($, await $.session.cwd())
136    Object.assign(live, {
137      isRepo: g.isRepo, branch: g.branch, ahead: g.ahead, behind: g.behind,
138      staged: g.staged, unstaged: g.unstaged, untracked: g.untracked, conflicts: g.conflicts,
139    })
140  }
141  if (tick % 4 === 1) {
142    const s = await readSys($)
143    live.cpu = s.cpu
144    if (s.cpu !== undefined) live.cpuHistory = [...live.cpuHistory, s.cpu].slice(-40)
145    live.memUsed = s.memUsed
146    live.memTotal = s.memTotal
147    live.gpuUtil = s.gpu?.util
148    live.gpuName = s.gpu?.name
149  }
150  await publish($)
151}
152
153export const register: Register = (on, options) => {
154  on('session.start', async ($, e, next) => {
155    const started = await next(e)
156    live = blank(await $.clock.now())
157    tick = 0
158    lastPublished = ''
159    await $.command.register({ name: 'bridge', description: 'KOZMOS: toggle the Bridge mission-control sidebar', immediate: true })
160    await sample($).catch(() => undefined)
161    $.clock.every(1000, () => void sample($).catch(() => undefined))
162    if (options.autoOpen !== false) void $.ui.open({ id: PANE, title: TITLE })
163    return started
164  })
165
166  on('command.run', { command: 'bridge' }, async $ => ({
167    text: (await toggle($)) ? 'Bridge open.' : 'Bridge closed.',
168  }))
169
170  on('turn.start', async ($, e, next) => {
171    live.isWorking = true
172    void publish($)
173    return next(e)
174  })
175
176  on('turn.step', async function* ($, e, next) {
177    if (e.agentId === undefined) {
178      live.effort = e.effort === undefined ? live.effort : String(e.effort)
179      live.model = e.model
180    }
181    return yield* next(e)
182  })
183
184  on('turn.complete', async ($, e, next) => {
185    if (e.agentId === undefined) {
186      live.isWorking = false
187      live.tool = undefined
188      void publish($)
189    }
190    return next(e)
191  })
192
193  on('tool.call', async ($, e, next) => {
194    const at = await $.clock.now()
195    const name = String(e.tool)
196    const detail = toolDetail(name, e)
197    if (e.agentId === undefined) live.tool = { name, detail, startedAt: at }
198    live.toolCount++
199    void publish($)
200    const ran = await next(e)
201    const ms = (await $.clock.now()) - at
202    if (e.agentId === undefined) {
203      live.lastTool = { name, detail, ms, isError: ran.isError === true || ran.deny !== undefined }
204      if (live.tool?.startedAt === at) live.tool = undefined
205    }
206    void publish($)
207    return ran
208  }).catch(($, e, next) => next(e))
209
210  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
211    const snap = (await read($, snapAtom)) ?? blank(await $.clock.now())
212    const ui = $.ui.resolve(e)
213    const { Box, Text } = ui
214
215    if ('Svg' in ui && e.surface !== 'terminal') {
216      const { Svg } = ui
217      const W = pxOf(e.props.bodyColumns, 44)
218      const { source, height } = desktopCard(snap, W)
219      return (
220        <Box flexDirection="column">
221          <Svg source={source} alt={altText(snap)} width={W} height={height} />
222        </Box>
223      )
224    }
225
226    const cols = Math.max(28, e.props.bodyColumns || 40)
227    const barW = Math.max(6, Math.min(24, cols - 22))
228    const row = (key: string, label: string, body: RenderChildren) => (
229      <Box key={key} flexDirection="row">
230        <Box width={5} flexShrink={0}>
231          <Text dimColor>{label}</Text>
232        </Box>
233        <Text wrap="truncate-end">{body}</Text>
234      </Box>
235    )
236    const ctxRatio = (snap.ctxPercent ?? 0) / 100
237    const elapsed = snap.now - snap.startedAt
238    const perHour = snap.costUsd !== undefined && elapsed > 60_000 ? snap.costUsd / (elapsed / 3600_000) : undefined
239    const gitBits = snap.isRepo
240      ? [
241          snap.ahead ? `↑${snap.ahead}` : '',
242          snap.behind ? `↓${snap.behind}` : '',
243          snap.staged ? `●${snap.staged}` : '',
244          snap.unstaged ? `✚${snap.unstaged}` : '',
245          snap.untracked ? `?${snap.untracked}` : '',
246          snap.conflicts ? `✖${snap.conflicts}` : '',
247        ].filter(Boolean).join(' ') || '✓ clean'
248      : ''
249
250    return (
251      <Box flexDirection="column">
252        <Box flexDirection="row" justifyContent="space-between">
253          <Text bold color={KZ.violet}>◆ {modelName(snap.model)}{snap.effort ? <Text color={KZ.mist}> · {snap.effort}</Text> : ''}</Text>
254          <Text color={snap.isWorking ? KZ.green : KZ.mist}>{snap.isWorking ? '● working' : '○ idle'}</Text>
255        </Box>
256        {row('ctx', 'CTX', [
257          <Text key="b" color={heat(ctxRatio * 1.15)}>{bar(ctxRatio, barW)}</Text>,
258          <Text key="t"> {fmtPct(snap.ctxPercent)} <Text dimColor>{fmtTokens(snap.ctxTokens ?? 0)}/{fmtTokens(snap.ctxWindow)}</Text></Text>,
259        ])}
260        {snap.limits.map(l => {
261          const left = untilReset(l, snap.now)
262          return row(`lim-${l.kind}`, limitLabel(l.kind).toUpperCase(), [
263            <Text key="b" color={heat(l.percentUsed / 100)}>{bar(l.percentUsed / 100, barW)}</Text>,
264            <Text key="t"> {fmtPct(l.percentUsed)}{left !== undefined ? <Text dimColor> ↻{fmtSpan(left)}</Text> : ''}</Text>,
265          ])
266        })}
267        {row('cost', 'COST', [
268          <Text key="c" color={KZ.yellow}>{snap.costUsd !== undefined ? fmtUsd(snap.costUsd) : '—'}</Text>,
269          <Text key="d" dimColor> · {fmtClock(elapsed)}{perHour !== undefined ? ` · ${fmtUsd(perHour)}/h` : ''}</Text>,
270        ])}
271        {snap.isRepo && row('git', 'GIT', [
272          <Text key="b" color={KZ.cyan}> {snap.branch}</Text>,
273          <Text key="s" color={snap.conflicts ? KZ.red : snap.staged + snap.unstaged + snap.untracked ? KZ.amber : KZ.green}> {gitBits}</Text>,
274        ])}
275        {row('cpu', 'CPU', [
276          <Text key="s" color={heat((snap.cpu ?? 0) / 100)}>{sparkline(snap.cpuHistory, Math.max(4, barW - 6), 100)}</Text>,
277          <Text key="p"> {fmtPct(snap.cpu)}</Text>,
278        ])}
279        {snap.memTotal !== undefined && row('ram', 'RAM', [
280          <Text key="b" color={heat((snap.memUsed ?? 0) / snap.memTotal)}>{bar((snap.memUsed ?? 0) / snap.memTotal, barW)}</Text>,
281          <Text key="t" dimColor> {fmtBytes(snap.memUsed)}/{fmtBytes(snap.memTotal)}</Text>,
282        ])}
283        {snap.gpuUtil !== undefined && row('gpu', 'GPU', [
284          <Text key="b" color={heat(snap.gpuUtil / 100)}>{bar(snap.gpuUtil / 100, barW)}</Text>,
285          <Text key="t"> {fmtPct(snap.gpuUtil)}</Text>,
286        ])}
287        {row('agt', 'AGT', [
288          <Text key="r" color={snap.agentsRunning ? KZ.violet : KZ.mist}>◈ {snap.agentsRunning} running</Text>,
289          <Text key="d" dimColor> · {snap.agentsDone} done{snap.agentsFailed ? ` · ${snap.agentsFailed} failed` : ''}</Text>,
290        ])}
291        {row('tool', 'TOOL', snap.tool
292          ? [
293              <Text key="g" color={toolColor(snap.tool.name)}>{toolGlyph(snap.tool.name)} {toolName(snap.tool.name)}</Text>,
294              <Text key="d" dimColor> {snap.tool.detail} · {fmtClock(snap.now - snap.tool.startedAt)}</Text>,
295            ]
296          : snap.lastTool
297            ? [
298                <Text key="g" color={snap.lastTool.isError ? KZ.red : KZ.mist}>{snap.lastTool.isError ? '✖' : '✓'} {toolName(snap.lastTool.name)}</Text>,
299                <Text key="d" dimColor> {snap.lastTool.detail} · {snap.lastTool.ms}ms · #{snap.toolCount}</Text>,
300              ]
301            : <Text dimColor>—</Text>)}
302      </Box>
303    )
304  })
305}
306
307function altText(s: BridgeSnap): string {
308  const lim = s.limits.map(l => `${limitLabel(l.kind)} ${fmtPct(l.percentUsed)}`).join(', ')
309  return `${modelName(s.model)}; context ${fmtPct(s.ctxPercent)}; ${lim}; cost ${s.costUsd !== undefined ? fmtUsd(s.costUsd) : 'unknown'}; branch ${s.branch || 'none'}; ${s.agentsRunning} agents running`
310}
311
312/** The whole sidebar as one SVG card stack. */
313function desktopCard(s: BridgeSnap, W: number): { source: string; height: number } {
314  const parts: string[] = []
315  let y = 0
316  const pad = 12
317  const inner = W - pad * 2
318
319  // Header: model, effort, working orb.
320  parts.push(`<rect class="p" x="0" y="${y}" width="${W}" height="54" rx="12"/>`)
321  parts.push(`<circle cx="${pad + 8}" cy="${y + 27}" r="7" fill="${s.isWorking ? KZ.green : KZ.mist}" class="${s.isWorking ? 'pulse' : ''}"/>`)
322  parts.push(svgText(pad + 24, y + 24, fitText(modelName(s.model), 15, inner - 120), { size: 15, weight: 650 }))
323  parts.push(svgText(pad + 24, y + 42, s.effort ? `effort · ${s.effort}` : 'effort · —', { cls: 's', size: 11.5 }))
324  parts.push(svgText(W - pad, y + 24, s.isWorking ? 'WORKING' : 'IDLE', { size: 10.5, weight: 700, anchor: 'end', fill: s.isWorking ? KZ.green : KZ.mist }))
325  parts.push(svgText(W - pad, y + 42, s.tool ? `${toolName(s.tool.name)} · ${fmtClock(s.now - s.tool.startedAt)}` : `${s.toolCount} tool calls`, { cls: 'm', size: 11, anchor: 'end' }))
326  y += 62
327
328  // Gauges: context and every rate-limit window.
329  const gauge = (label: string, ratio: number, right: string, sub: string) => {
330    parts.push(`<rect class="p" x="0" y="${y}" width="${W}" height="48" rx="12"/>`)
331    parts.push(svgText(pad, y + 18, label, { cls: 's', size: 11, weight: 600 }))
332    parts.push(svgText(W - pad, y + 18, right, { size: 12.5, weight: 650, anchor: 'end' }))
333    parts.push(svgBar(pad, y + 27, inner, 8, ratio, heat(ratio * 1.1)))
334    if (sub) parts.push(svgText(pad, y + 45, sub, { cls: 'm', size: 9.5 }))
335    y += 56
336  }
337  gauge('CONTEXT', clamp01((s.ctxPercent ?? 0) / 100), fmtPct(s.ctxPercent), `${fmtTokens(s.ctxTokens ?? 0)} of ${fmtTokens(s.ctxWindow)}`)
338  for (const l of s.limits) {
339    const left = untilReset(l, s.now)
340    gauge(`${limitLabel(l.kind).toUpperCase()} LIMIT`, clamp01(l.percentUsed / 100), fmtPct(l.percentUsed), left !== undefined ? `resets in ${fmtSpan(left)}` : '')
341  }
342
343  // Tiles: cost, time, agents.
344  const elapsed = s.now - s.startedAt
345  const perHour = s.costUsd !== undefined && elapsed > 60_000 ? s.costUsd / (elapsed / 3600_000) : undefined
346  const tiles: [string, string, string, string][] = [
347    ['COST', s.costUsd !== undefined ? fmtUsd(s.costUsd) : '—', perHour !== undefined ? `${fmtUsd(perHour)}/h` : '', KZ.yellow],
348    ['TIME', fmtClock(elapsed), '', KZ.cyan],
349    ['AGENTS', String(s.agentsRunning), `${s.agentsDone} done`, KZ.violet],
350  ]
351  const tw = (W - 12) / 3
352  tiles.forEach(([k, v, sub, c], i) => {
353    const x = i * (tw + 6)
354    parts.push(`<rect class="p" x="${x}" y="${y}" width="${tw}" height="58" rx="12"/>`)
355    parts.push(`<rect x="${x + 10}" y="${y + 10}" width="3" height="38" rx="1.5" fill="${c}"/>`)
356    parts.push(svgText(x + 20, y + 22, k, { cls: 's', size: 10, weight: 600 }))
357    parts.push(svgText(x + 20, y + 40, v, { size: 15, weight: 700 }))
358    if (sub) parts.push(svgText(x + 20, y + 52, sub, { cls: 'm', size: 9.5 }))
359  })
360  y += 66
361
362  // Git.
363  if (s.isRepo) {
364    parts.push(`<rect class="p" x="0" y="${y}" width="${W}" height="46" rx="12"/>`)
365    parts.push(`<path d="M${pad + 4} ${y + 14}v18M${pad + 4} ${y + 23}c8 0 10-6 10-9" stroke="${KZ.cyan}" stroke-width="2" fill="none" stroke-linecap="round"/><circle cx="${pad + 14}" cy="${y + 13}" r="3" fill="${KZ.cyan}"/>`)
366    parts.push(svgText(pad + 26, y + 20, fitText(s.branch, 13, inner - 140), { size: 13, weight: 650 }))
367    const ab = `${s.ahead ? `↑${s.ahead} ` : ''}${s.behind ? `↓${s.behind}` : ''}`.trim()
368    parts.push(svgText(pad + 26, y + 37, ab || 'in sync', { cls: 'm', size: 10.5 }))
369    const chips: [string, number, string][] = [['staged', s.staged, KZ.green], ['changed', s.unstaged, KZ.amber], ['new', s.untracked, KZ.blue], ['conflict', s.conflicts, KZ.red]]
370    let cx = W - pad
371    for (const [label, n, c] of chips.reverse()) {
372      if (!n) continue
373      const txt = `${n} ${label}`
374      const cw = txt.length * 6.2 + 14
375      cx -= cw
376      parts.push(`<rect x="${cx}" y="${y + 14}" width="${cw}" height="18" rx="9" fill="${c}" opacity=".18"/>`)
377      parts.push(svgText(cx + cw / 2, y + 27, txt, { size: 10, weight: 600, anchor: 'middle', fill: c }))
378      cx -= 6
379    }
380    if (s.staged + s.unstaged + s.untracked + s.conflicts === 0) parts.push(svgText(W - pad, y + 27, '✓ clean', { size: 11, weight: 600, anchor: 'end', fill: KZ.green }))
381    y += 54
382  }
383
384  // Machine: CPU sparkline area, RAM and GPU bars.
385  parts.push(`<rect class="p" x="0" y="${y}" width="${W}" height="${s.gpuUtil !== undefined ? 96 : 78}" rx="12"/>`)
386  parts.push(svgText(pad, y + 18, 'CPU', { cls: 's', size: 10.5, weight: 600 }))
387  parts.push(svgText(W - pad, y + 18, fmtPct(s.cpu), { size: 12, weight: 650, anchor: 'end' }))
388  const hist = s.cpuHistory.length ? s.cpuHistory : [0]
389  const gw = inner
390  const gh = 22
391  const step = gw / Math.max(1, hist.length - 1)
392  const pts = hist.map((v, i) => `${(pad + i * step).toFixed(1)},${(y + 24 + gh - (v / 100) * gh).toFixed(1)}`)
393  parts.push(`<defs><linearGradient id="cpuG" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="${KZ.violet}" stop-opacity=".55"/><stop offset="1" stop-color="${KZ.violet}" stop-opacity="0"/></linearGradient></defs>`)
394  if (hist.length > 1) {
395    parts.push(`<path d="M${pts.join('L')}L${pad + gw},${y + 24 + gh}L${pad},${y + 24 + gh}Z" fill="url(#cpuG)"/>`)
396    parts.push(`<path d="M${pts.join('L')}" stroke="${KZ.violet}" stroke-width="1.6" fill="none"/>`)
397  }
398  let my = y + 58
399  if (s.memTotal !== undefined) {
400    const r = (s.memUsed ?? 0) / s.memTotal
401    parts.push(svgText(pad, my + 4, 'RAM', { cls: 's', size: 10.5, weight: 600 }))
402    parts.push(svgBar(pad + 36, my - 3, inner - 120, 7, r, heat(r)))
403    parts.push(svgText(W - pad, my + 4, `${fmtBytes(s.memUsed)} / ${fmtBytes(s.memTotal)}`, { cls: 'm', size: 10, anchor: 'end' }))
404    my += 18
405  }
406  if (s.gpuUtil !== undefined) {
407    const r = s.gpuUtil / 100
408    parts.push(svgText(pad, my + 4, 'GPU', { cls: 's', size: 10.5, weight: 600 }))
409    parts.push(svgBar(pad + 36, my - 3, inner - 120, 7, r, heat(r)))
410    parts.push(svgText(W - pad, my + 4, `${fmtPct(s.gpuUtil)}`, { cls: 'm', size: 10, anchor: 'end' }))
411  }
412  y += (s.gpuUtil !== undefined ? 96 : 78) + 8
413
414  // The tool running now, or the last one.
415  const t = s.tool ?? s.lastTool
416  if (t) {
417    // Only the last tool carries its duration.
418    const isRunning = !('ms' in t)
419    const c = 'ms' in t ? (t.isError ? KZ.red : KZ.mist) : toolColor(t.name)
420    parts.push(`<rect class="p" x="0" y="${y}" width="${W}" height="40" rx="12"/>`)
421    parts.push(`<rect x="0" y="${y}" width="4" height="40" rx="2" fill="${c}" class="${isRunning ? 'pulse' : ''}"/>`)
422    parts.push(svgText(pad + 4, y + 17, `${isRunning ? 'RUNNING' : 'LAST'} · ${toolName(t.name)}`, { size: 10.5, weight: 700, fill: c }))
423    parts.push(svgText(pad + 4, y + 32, fitText(t.detail || '—', 11, inner - 70), { cls: 's', size: 11, mono: true }))
424    parts.push(svgText(W - pad, y + 24, 'ms' in t ? `${t.ms}ms` : fmtClock(s.now - t.startedAt), { cls: 'm', size: 11, anchor: 'end' }))
425    y += 48
426  }
427
428  return { source: svg(W, y, parts.join('')), height: y }
429}
430
hooks/lib/kz.ts 510 lines
1// GENERATED by scripts/sync-shared.mjs from shared/kz.ts. Do not edit here.
2// KOZMOS shared kit. Source of truth: shared/kz.ts at the bundle root.
3// `node scripts/sync-shared.mjs` copies it into every mod as hooks/lib/kz.ts,
4// because a hooks module may only import files inside its own plugin.
5// Edit the root copy, never a mod's copy.
6
7// ---------------------------------------------------------------------------
8// Palette: one neon family across every KOZMOS mod.
9
10export const KZ = {
11  violet: '#a78bfa',
12  magenta: '#f472b6',
13  cyan: '#22d3ee',
14  teal: '#2dd4bf',
15  green: '#4ade80',
16  lime: '#a3e635',
17  yellow: '#facc15',
18  amber: '#fb923c',
19  red: '#f87171',
20  blue: '#60a5fa',
21  clay: '#d97757',
22  ink: '#1f1e1d',
23  mist: '#9ca3af',
24} as const
25
26/** Tool families, colored the same in every mod. */
27export function toolColor(tool: string): string {
28  const t = String(tool)
29  if (t === 'Bash' || t === 'PowerShell') return KZ.green
30  if (t === 'Edit' || t === 'Write' || t === 'NotebookEdit' || t === 'MultiEdit') return KZ.yellow
31  if (t === 'Read' || t === 'Glob' || t === 'Grep' || t === 'LSP') return KZ.blue
32  if (t === 'Agent' || t === 'Task' || t === 'Workflow') return KZ.violet
33  if (t.startsWith('Web')) return KZ.cyan
34  if (t.startsWith('Todo') || t.startsWith('Task')) return KZ.teal
35  if (t.startsWith('mcp__')) return KZ.magenta
36  return KZ.mist
37}
38
39/** A short glyph per tool family. */
40export function toolGlyph(tool: string): string {
41  const t = String(tool)
42  if (t === 'Bash' || t === 'PowerShell') return '$'
43  if (t === 'Edit' || t === 'Write' || t === 'NotebookEdit') return '✎'
44  if (t === 'Read') return '◉'
45  if (t === 'Glob' || t === 'Grep') return '⌕'
46  if (t === 'Agent') return '◈'
47  if (t.startsWith('Web')) return '◍'
48  if (t.startsWith('Todo') || t.startsWith('Task')) return '☑'
49  if (t.startsWith('mcp__')) return '⬡'
50  return '•'
51}
52
53// ---------------------------------------------------------------------------
54// Colors.
55
56export function hexToRgb(hex: string): [number, number, number] {
57  const h = hex.replace('#', '')
58  const n = parseInt(h.length === 3 ? h.split('').map(c => c + c).join('') : h, 16)
59  return [(n >> 16) & 255, (n >> 8) & 255, n & 255]
60}
61
62export function rgbToHex(r: number, g: number, b: number): string {
63  const c = (v: number) => Math.max(0, Math.min(255, Math.round(v))).toString(16).padStart(2, '0')
64  return `#${c(r)}${c(g)}${c(b)}`
65}
66
67export function mix(a: string, b: string, t: number): string {
68  const [r1, g1, b1] = hexToRgb(a)
69  const [r2, g2, b2] = hexToRgb(b)
70  const k = clamp01(t)
71  return rgbToHex(r1 + (r2 - r1) * k, g1 + (g2 - g1) * k, b1 + (b2 - b1) * k)
72}
73
74/** Green at 0, yellow at 0.6, red at 1: the heat of a gauge. */
75export function heat(t: number): string {
76  const k = clamp01(t)
77  return k < 0.6 ? mix(KZ.green, KZ.yellow, k / 0.6) : mix(KZ.yellow, KZ.red, (k - 0.6) / 0.4)
78}
79
80/** A smooth rainbow for hues 0..1 (for auroras, plasma and the like). */
81export function hue(h: number, s = 0.75, l = 0.6): string {
82  const k = ((h % 1) + 1) % 1
83  const a = s * Math.min(l, 1 - l)
84  const f = (n: number) => {
85    const x = (n + k * 12) % 12
86    return l - a * Math.max(-1, Math.min(x - 3, 9 - x, 1))
87  }
88  return rgbToHex(f(0) * 255, f(8) * 255, f(4) * 255)
89}
90
91export function hexToInt(hex: string): number {
92  const [r, g, b] = hexToRgb(hex)
93  return (r << 16) | (g << 8) | b
94}
95
96// ---------------------------------------------------------------------------
97// Numbers and text.
98
99export const clamp01 = (v: number): number => (Number.isFinite(v) ? Math.max(0, Math.min(1, v)) : 0)
100
101export function fmtTokens(n: number): string {
102  if (!Number.isFinite(n)) return '—'
103  // Each unit starts where the one below would round up to 1000 of itself.
104  if (n >= 999.5e6) return `${(n / 1e9).toFixed(1)}B`
105  if (n >= 999_500) return `${(n / 1e6).toFixed(n >= 1e7 ? 0 : 1)}M`
106  if (n >= 999.5) return `${(n / 1e3).toFixed(n >= 1e4 ? 0 : 1)}k`
107  return `${Math.round(n)}`
108}
109
110export function fmtUsd(usd: number): string {
111  if (!Number.isFinite(usd)) return '$—'
112  if (usd < 0.01 && usd > 0) return '<$0.01'
113  return usd < 100 ? `$${usd.toFixed(2)}` : `$${Math.round(usd)}`
114}
115
116/** 0:42, 3:07, 1:02:33. */
117export function fmtClock(ms: number): string {
118  const s = Math.max(0, Math.round(ms / 1000))
119  const h = Math.floor(s / 3600)
120  const m = Math.floor((s % 3600) / 60)
121  const ss = String(s % 60).padStart(2, '0')
122  return h ? `${h}:${String(m).padStart(2, '0')}:${ss}` : `${m}:${ss}`
123}
124
125/** 42s, 7m, 2h41m, 3d4h: compact durations for countdowns. */
126export function fmtSpan(ms: number): string {
127  const s = Math.max(0, Math.round(ms / 1000))
128  if (s < 60) return `${s}s`
129  const m = Math.floor(s / 60)
130  if (m < 60) return `${m}m`
131  const h = Math.floor(m / 60)
132  if (h < 48) return `${h}h${String(m % 60).padStart(2, '0')}m`
133  return `${Math.floor(h / 24)}d${h % 24}h`
134}
135
136export function fmtPct(p: number | undefined): string {
137  return p === undefined || !Number.isFinite(p) ? '—' : `${Math.round(p)}%`
138}
139
140export function clip(s: string, max: number): string {
141  const one = s.replace(/\s+/g, ' ').trim()
142  return one.length > max ? one.slice(0, Math.max(1, max - 1)) + '…' : one
143}
144
145export function padEnd(s: string, n: number): string {
146  return s.length >= n ? s.slice(0, n) : s + ' '.repeat(n - s.length)
147}
148
149export function padStart(s: string, n: number): string {
150  return s.length >= n ? s.slice(s.length - n) : ' '.repeat(n - s.length) + s
151}
152
153/** `claude-opus-5-5[1m]` → `Opus 5.5`. */
154export function modelName(id: string | undefined): string {
155  if (!id) return '—'
156  const m = /(fable|mythos|opus|sonnet|haiku)-(\d+)(?:-(\d{1,2})(?!\d))?/i.exec(id)
157  if (!m) return id.replace(/^claude-/, '').replace(/\[.*\]$/, '')
158  const fam = m[1]!
159  return `${fam.charAt(0).toUpperCase()}${fam.slice(1).toLowerCase()} ${m[2]}${m[3] ? '.' + m[3] : ''}`
160}
161
162/** Last path segment, either slash. */
163export function baseName(p: string): string {
164  const parts = p.split(/[\\/]/).filter(Boolean)
165  return parts[parts.length - 1] ?? p
166}
167
168/** A stable small hash for seeding. */
169export function hash(s: string): number {
170  let h = 2166136261
171  for (let i = 0; i < s.length; i++) {
172    h ^= s.charCodeAt(i)
173    h = Math.imul(h, 16777619)
174  }
175  return h >>> 0
176}
177
178/** Deterministic 0..1 noise from two ints. */
179export function noise(x: number, y: number): number {
180  const s = Math.sin(x * 12.9898 + y * 78.233) * 43758.5453
181  return s - Math.floor(s)
182}
183
184/** A seeded PRNG (mulberry32). */
185export function rng(seed: number): () => number {
186  let a = seed >>> 0
187  return () => {
188    a = (a + 0x6d2b79f5) >>> 0
189    let t = a
190    t = Math.imul(t ^ (t >>> 15), t | 1)
191    t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
192    return ((t ^ (t >>> 14)) >>> 0) / 4294967296
193  }
194}
195
196// ---------------------------------------------------------------------------
197// Text gauges for the terminal.
198
199const EIGHTHS = ['', '▏', '▎', '▍', '▌', '▋', '▊', '▉']
200
201/** A smooth bar with eighth blocks: `█████▍    `. */
202export function bar(ratio: number, width: number, empty = '░'): string {
203  const w = Math.max(1, Math.floor(width))
204  const exact = clamp01(ratio) * w
205  const full = Math.floor(exact)
206  const part = EIGHTHS[Math.floor((exact - full) * 8)] ?? ''
207  const used = full + (part ? 1 : 0)
208  return '█'.repeat(full) + part + empty.repeat(Math.max(0, w - used))
209}
210
211/** A segmented gauge: `▰▰▰▱▱▱`. */
212export function pips(ratio: number, width: number, on = '▰', off = '▱'): string {
213  const w = Math.max(1, Math.floor(width))
214  const n = Math.round(clamp01(ratio) * w)
215  return on.repeat(n) + off.repeat(w - n)
216}
217
218const SPARK = '▁▂▃▄▅▆▇█'
219
220/** A sparkline of the last `width` values, scaled to their own max (or `max`). */
221export function sparkline(values: readonly number[], width: number, max?: number): string {
222  const tail = values.slice(-Math.max(1, width))
223  const top = max ?? Math.max(1e-9, ...tail)
224  const line = tail.map(v => SPARK[Math.min(7, Math.max(0, Math.round((v / top) * 7)))] ?? '▁').join('')
225  return line.padStart(width, ' ')
226}
227
228/** Braille line graph, 2 samples per cell, `rows` cells tall. */
229export function brailleGraph(values: readonly number[], width: number, rows: number, max?: number): string[] {
230  const samples = values.slice(-(width * 2))
231  const top = max ?? Math.max(1e-9, ...samples)
232  const dotsTall = rows * 4
233  const grid: number[][] = Array.from({ length: rows }, () => Array.from({ length: width }, () => 0))
234  const offset = width * 2 - samples.length
235  // Dot bits per column, from the bottom row of a cell up.
236  const LEFT = [0x40, 0x04, 0x02, 0x01]
237  const RIGHT = [0x80, 0x20, 0x10, 0x08]
238  samples.forEach((v, i) => {
239    const x = offset + i
240    const col = Math.floor(x / 2)
241    const isRight = x % 2 === 1
242    const h = Math.round(clamp01(v / top) * (dotsTall - 1))
243    for (let d = 0; d <= h; d++) {
244      const row = rows - 1 - Math.floor(d / 4)
245      const bits = isRight ? RIGHT : LEFT
246      const cellRow = grid[row]
247      if (cellRow && col >= 0) cellRow[col] = (cellRow[col] ?? 0) | bits[d % 4]!
248    }
249  })
250  return grid.map(r => r.map(b => String.fromCharCode(0x2800 + b)).join(''))
251}
252
253// ---------------------------------------------------------------------------
254// Raster: a grid of colored cells, packed as the engine wants it.
255
256const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
257
258export function toBase64(bytes: Uint8Array): string {
259  let out = ''
260  let i = 0
261  for (; i + 2 < bytes.length; i += 3) {
262    const n = (bytes[i]! << 16) | (bytes[i + 1]! << 8) | bytes[i + 2]!
263    out += B64[(n >> 18) & 63]! + B64[(n >> 12) & 63]! + B64[(n >> 6) & 63]! + B64[n & 63]!
264  }
265  const rest = bytes.length - i
266  if (rest === 1) {
267    const n = bytes[i]! << 16
268    out += B64[(n >> 18) & 63]! + B64[(n >> 12) & 63]! + '=='
269  } else if (rest === 2) {
270    const n = (bytes[i]! << 16) | (bytes[i + 1]! << 8)
271    out += B64[(n >> 18) & 63]! + B64[(n >> 12) & 63]! + B64[(n >> 6) & 63]! + '='
272  }
273  return out
274}
275
276/** The terminal's own default color, for a cell's foreground or background. */
277export const DEFAULT_COLOR = 0x01000000
278
279export class Canvas {
280  readonly cols: number
281  readonly rows: number
282  private readonly words: Uint32Array
283
284  constructor(cols: number, rows: number) {
285    this.cols = Math.max(1, Math.min(512, Math.floor(cols)))
286    this.rows = Math.max(1, Math.min(256, Math.floor(rows)))
287    this.words = new Uint32Array(this.cols * this.rows * 3)
288    this.clear()
289  }
290
291  clear(bg: number = DEFAULT_COLOR): void {
292    for (let i = 0; i < this.cols * this.rows; i++) {
293      this.words[i * 3] = 0x20
294      this.words[i * 3 + 1] = DEFAULT_COLOR
295      this.words[i * 3 + 2] = bg
296    }
297  }
298
299  /** Puts one width-1 character; colors are '#rrggbb' or a packed int. */
300  set(x: number, y: number, ch: string, fg?: string | number, bg?: string | number): void {
301    const cx = Math.floor(x)
302    const cy = Math.floor(y)
303    if (cx < 0 || cy < 0 || cx >= this.cols || cy >= this.rows) return
304    const i = (cy * this.cols + cx) * 3
305    const code = ch.codePointAt(0) ?? 0x20
306    this.words[i] = code > 0xffff || code < 0x20 ? 0x20 : code
307    if (fg !== undefined) this.words[i + 1] = typeof fg === 'number' ? fg : hexToInt(fg)
308    if (bg !== undefined) this.words[i + 2] = typeof bg === 'number' ? bg : hexToInt(bg)
309  }
310
311  /** Writes a string left to right, clipped to the canvas. */
312  text(x: number, y: number, s: string, fg?: string | number, bg?: string | number): void {
313    let cx = x
314    for (const ch of s) {
315      this.set(cx, y, ch, fg, bg)
316      cx++
317    }
318  }
319
320  /** Paints a cell's background only, keeping its character. */
321  paint(x: number, y: number, bg: string | number): void {
322    const cx = Math.floor(x)
323    const cy = Math.floor(y)
324    if (cx < 0 || cy < 0 || cx >= this.cols || cy >= this.rows) return
325    this.words[(cy * this.cols + cx) * 3 + 2] = typeof bg === 'number' ? bg : hexToInt(bg)
326  }
327
328  /** Two vertical pixels per cell with the upper-half block: `py` is in half-cells. */
329  pixel(x: number, py: number, color: string): void {
330    const cx = Math.floor(x)
331    const cy = Math.floor(py / 2)
332    if (cx < 0 || cy < 0 || cx >= this.cols || cy >= this.rows) return
333    const i = (cy * this.cols + cx) * 3
334    const c = hexToInt(color)
335    const isTop = Math.floor(py) % 2 === 0
336    if (this.words[i] !== 0x2580) {
337      this.words[i] = 0x2580
338      this.words[i + 1] = DEFAULT_COLOR
339      this.words[i + 2] = DEFAULT_COLOR
340    }
341    if (isTop) this.words[i + 1] = c
342    else this.words[i + 2] = c
343  }
344
345  /** The `cells` prop of a Raster. */
346  encode(): string {
347    return toBase64(new Uint8Array(this.words.buffer))
348  }
349}
350
351// ---------------------------------------------------------------------------
352// SVG for the desktop: one drawing per row (the desktop wraps siblings).
353
354export const FONT = "-apple-system,BlinkMacSystemFont,'Segoe UI',Inter,sans-serif"
355export const MONO = "ui-monospace,'Cascadia Code','SF Mono',Consolas,monospace"
356
357const XML_ESC: Record<string, string> = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }
358
359export function xml(s: string): string {
360  return s.replace(/[&<>"']/g, c => XML_ESC[c]!)
361}
362
363/** Theme-aware classes every KOZMOS drawing shares: t text, s secondary, m muted, k track, p panel. */
364export const SVG_BASE_CSS = `
365.t{fill:#1f1f1f}.s{fill:#5f5f5c}.m{fill:#8e8e8a}.k{fill:#e7e5e0}.p{fill:#f5f4f1}.ln{stroke:#e1dfda}
366@media (prefers-color-scheme: dark){.t{fill:#ededed}.s{fill:#b4b4b0}.m{fill:#7c7c78}.k{fill:#2d2d2b}.p{fill:#232322}.ln{stroke:#363634}}
367.pulse{animation:kzp 1.6s ease-in-out infinite}@keyframes kzp{50%{opacity:.35}}
368.spin{transform-box:fill-box;transform-origin:center;animation:kzs 2s linear infinite}@keyframes kzs{to{transform:rotate(360deg)}}
369@media (prefers-reduced-motion: reduce){*{animation:none!important}}
370`
371
372export function svg(width: number, height: number, body: string, css = ''): string {
373  return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}"><style>${SVG_BASE_CSS}${css}</style>${body}</svg>`
374}
375
376/** Rough width of UI text in px, for fitting labels. */
377export function textWidth(s: string, size: number): number {
378  let w = 0
379  for (const ch of s) w += (/[\s.,:;'|!il1()[\]]/.test(ch) ? 0.3 : /[A-Z@%MWmw]/.test(ch) ? 0.72 : 0.56) * size
380  return w
381}
382
383export function fitText(s: string, size: number, maxW: number): string {
384  if (textWidth(s, size) <= maxW) return s
385  let out = ''
386  for (const ch of s) {
387    if (textWidth(out + ch + '…', size) > maxW) break
388    out += ch
389  }
390  return out + '…'
391}
392
393/** A rounded progress bar as SVG markup. */
394export function svgBar(x: number, y: number, w: number, h: number, ratio: number, color: string): string {
395  const fw = Math.max(0, Math.min(w, w * clamp01(ratio)))
396  return `<rect class="k" x="${x}" y="${y}" width="${w}" height="${h}" rx="${h / 2}"/>` +
397    (fw > 0 ? `<rect x="${x}" y="${y}" width="${Math.max(h, fw)}" height="${h}" rx="${h / 2}" fill="${color}"/>` : '')
398}
399
400/** SVG text helper. */
401export function svgText(x: number, y: number, s: string, opts: { cls?: string; size?: number; weight?: number; anchor?: 'start' | 'middle' | 'end'; fill?: string; mono?: boolean } = {}): string {
402  const { cls = 't', size = 12, weight = 400, anchor = 'start', fill, mono = false } = opts
403  return `<text ${fill ? `fill="${fill}"` : `class="${cls}"`} x="${x}" y="${y}" font-family="${mono ? MONO : FONT}" font-size="${size}" font-weight="${weight}" text-anchor="${anchor}" font-variant-numeric="tabular-nums">${xml(s)}</text>`
404}
405
406/** Pixel width a desktop pane or band gives a drawing for `columns` reported columns. */
407export function pxOf(columns: number | undefined, fallback = 60, slack = 8): number {
408  return Math.max(200, Math.min(1600, (columns || fallback) * 8 - slack))
409}
410
411// ---------------------------------------------------------------------------
412// Prices, USD per million tokens: input, output, cache read, cache write (5 min).
413// Anthropic first-party rates as of 2026-10. Used only where the engine reports
414// tokens and not money (a subagent's own spend); the session total comes from
415// $.session.usage().cost, which is the engine's.
416
417const PRICES: [RegExp, [number, number, number, number]][] = [
418  [/fable|mythos/i, [10, 50, 0.25, 12.5]],
419  [/opus-5-5/i, [4, 20, 0.2, 5]],
420  [/opus/i, [5, 25, 0.5, 6.25]],
421  [/sonnet-5/i, [2, 10, 0.2, 2.5]],
422  [/sonnet/i, [3, 15, 0.3, 3.75]],
423  [/haiku-5/i, [0.1, 0.5, 0.01, 0.125]],
424  [/haiku/i, [1, 5, 0.1, 1.25]],
425]
426
427export type Usage = {
428  input_tokens?: number
429  output_tokens?: number
430  cache_read_input_tokens?: number
431  cache_creation_input_tokens?: number
432}
433
434export function priceOf(model: string): [number, number, number, number] {
435  return PRICES.find(([re]) => re.test(model))?.[1] ?? [4, 20, 0.2, 5]
436}
437
438export function costOf(model: string, u: Usage | null | undefined): number {
439  if (!u) return 0
440  const [i, o, r, w] = priceOf(model)
441  return ((u.input_tokens ?? 0) * i + (u.output_tokens ?? 0) * o + (u.cache_read_input_tokens ?? 0) * r + (u.cache_creation_input_tokens ?? 0) * w) / 1e6
442}
443
444export function tokensOf(u: Usage | null | undefined): number {
445  if (!u) return 0
446  return (u.input_tokens ?? 0) + (u.output_tokens ?? 0) + (u.cache_read_input_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0)
447}
448
449/** The context a request filled: everything it read plus what it wrote. */
450export function contextOf(u: Usage | null | undefined): number {
451  return tokensOf(u)
452}
453
454export function windowOf(model: string): number {
455  return /haiku-4/i.test(model) ? 200_000 : 1_000_000
456}
457
458// ---------------------------------------------------------------------------
459// Rate limits.
460
461export type Limit = { kind: string; percentUsed: number; resetsAt?: string }
462
463export function limitLabel(kind: string): string {
464  if (kind === 'five_hour') return '5h'
465  if (kind === 'seven_day') return '7d'
466  if (kind === 'spend_limit') return '$'
467  return kind.replace(/_/g, ' ')
468}
469
470/** Milliseconds until a window resets, or undefined. */
471export function untilReset(l: Limit, now: number): number | undefined {
472  if (!l.resetsAt) return undefined
473  const t = Date.parse(l.resetsAt)
474  return Number.isFinite(t) ? Math.max(0, t - now) : undefined
475}
476
477/** The length of a window in ms, for burn-rate math. */
478export function windowMs(kind: string): number | undefined {
479  if (kind === 'five_hour') return 5 * 3600_000
480  if (kind === 'seven_day') return 7 * 24 * 3600_000
481  return undefined
482}
483
484// ---------------------------------------------------------------------------
485// Tool calls, described in a few words.
486
487/** What a tool call is about: the command, the file, the pattern, the agent's task. */
488export function toolDetail(tool: string, input: unknown): string {
489  const e = (input ?? {}) as Record<string, unknown>
490  const s = (k: string) => (typeof e[k] === 'string' ? (e[k] as string) : '')
491  const t = String(tool)
492  if (t === 'Bash' || t === 'PowerShell') return clip(s('description') || s('command'), 60)
493  if (s('file_path')) return baseName(s('file_path'))
494  if (s('notebook_path')) return baseName(s('notebook_path'))
495  if (t === 'Agent') return clip(s('description') || s('subagent_type'), 60)
496  if (s('pattern')) return clip(s('pattern'), 60)
497  if (s('url')) return clip(s('url').replace(/^https?:\/\//, ''), 60)
498  if (s('query')) return clip(s('query'), 60)
499  if (s('subject')) return clip(s('subject'), 60)
500  if (Array.isArray(e.todos)) return `${(e.todos as unknown[]).length} todos`
501  if (t.startsWith('mcp__')) return t.split('__').slice(1).join(' · ')
502  return ''
503}
504
505/** `mcp__server__tool` → `server·tool`; built-ins unchanged. */
506export function toolName(tool: string): string {
507  const t = String(tool)
508  return t.startsWith('mcp__') ? t.split('__').slice(1).join('·') : t
509}
510
hooks/lib/probe.ts 203 lines
1// GENERATED by scripts/sync-shared.mjs from shared/probe.ts. Do not edit here.
2// KOZMOS shared probes: git and the machine, as argv lists and pure parsers.
3// Source of truth: shared/probe.ts; synced into each mod as hooks/lib/probe.ts.
4//
5// The engine only lets `$` flow into functions declared in the hooks file
6// itself, never across an import. So this file never touches `$`: a mod runs
7// the argv with `$.process.run` (or reads the path with `$.fs.read`) in a
8// top-level function of its own and hands the text to a parser here:
9//
10//   async function readGit($: EngineInterface, cwd: string): Promise<GitSnap> {
11//     const st = await $.process.run(GIT_STATUS, { cwd, timeoutMs: 5000 }).catch(() => undefined)
12//     if (!st || st.exitCode !== 0) return NO_GIT
13//     const head = await $.process.run(GIT_HEAD, { cwd, timeoutMs: 3000 }).catch(() => undefined)
14//     return parseGitStatus(st.stdout, head?.exitCode === 0 ? head.stdout : '')
15//   }
16
17export type Platform = 'win' | 'mac' | 'linux'
18
19/** From `$.env.get('OS')` and, off Windows, `uname -s`. */
20export function platformFrom(osEnv: string | undefined, uname: string | undefined): Platform {
21  if (/windows/i.test(osEnv ?? '')) return 'win'
22  return /darwin/i.test(uname ?? '') ? 'mac' : 'linux'
23}
24
25// ---------------------------------------------------------------------------
26// Git.
27
28export const GIT_STATUS = ['git', 'status', '--porcelain=v2', '--branch', '--show-stash'] as const
29export const GIT_HEAD = ['git', 'log', '-1', '--format=%h%x09%s%x09%cr'] as const
30export const gitLogArgv = (n: number): string[] => ['git', 'log', `-${n}`, '--format=%h%x09%s%x09%cr%x09%an%x09%ct']
31export const GIT_NUMSTAT = ['git', 'diff', '--numstat', 'HEAD'] as const
32
33export type GitSnap = {
34  isRepo: boolean
35  branch: string
36  isDetached: boolean
37  upstream?: string
38  ahead: number
39  behind: number
40  staged: number
41  unstaged: number
42  untracked: number
43  conflicts: number
44  head?: { sha: string; subject: string; when: string }
45  stash: number
46}
47
48export const NO_GIT: GitSnap = { isRepo: false, branch: '', isDetached: false, ahead: 0, behind: 0, staged: 0, unstaged: 0, untracked: 0, conflicts: 0, stash: 0 }
49
50/** Parses `GIT_STATUS` output, and `GIT_HEAD` output when given. */
51export function parseGitStatus(status: string, headLine = ''): GitSnap {
52  const snap: GitSnap = { ...NO_GIT, isRepo: true }
53  for (const line of status.split('\n')) {
54    if (line.startsWith('# branch.head ')) {
55      const b = line.slice(14).trim()
56      snap.isDetached = b === '(detached)'
57      snap.branch = b
58    } else if (line.startsWith('# branch.upstream ')) snap.upstream = line.slice(18).trim()
59    else if (line.startsWith('# branch.ab ')) {
60      const m = /\+(\d+) -(\d+)/.exec(line)
61      snap.ahead = Number(m?.[1] ?? 0)
62      snap.behind = Number(m?.[2] ?? 0)
63    } else if (line.startsWith('# stash ')) snap.stash = Number(line.slice(8)) || 0
64    else if (line.startsWith('1 ') || line.startsWith('2 ')) {
65      const xy = line.slice(2, 4)
66      if (xy[0] !== '.') snap.staged++
67      if (xy[1] !== '.') snap.unstaged++
68    } else if (line.startsWith('u ')) snap.conflicts++
69    else if (line.startsWith('? ')) snap.untracked++
70  }
71  const h = headLine.trim()
72  if (h) {
73    const [sha, subject = '', when = ''] = h.split('\t') as [string, ...string[]]
74    snap.head = { sha, subject, when }
75  }
76  if (snap.isDetached && snap.head) snap.branch = snap.head.sha
77  return snap
78}
79
80export type GitCommit = { sha: string; subject: string; when: string; author: string; at: number }
81
82/** Parses `gitLogArgv(n)` output. `at` is the commit time in ms. */
83export function parseGitLog(out: string): GitCommit[] {
84  return out
85    .split('\n')
86    .filter(Boolean)
87    .map(l => {
88      const [sha, subject = '', when = '', author = '', ct = '0'] = l.split('\t') as [string, ...string[]]
89      return { sha, subject, when, author, at: Number(ct) * 1000 }
90    })
91}
92
93export type GitFileStat = { path: string; added: number; removed: number }
94
95/** Parses `GIT_NUMSTAT` output (binary files count as 0/0). */
96export function parseNumstat(out: string): GitFileStat[] {
97  return out
98    .split('\n')
99    .filter(Boolean)
100    .map(l => {
101      const [a, d = '0', ...rest] = l.split('\t') as [string, ...string[]]
102      return { path: rest.join('\t'), added: Number(a) || 0, removed: Number(d) || 0 }
103    })
104}
105
106// ---------------------------------------------------------------------------
107// The machine.
108
109export type SysSnap = {
110  cpu?: number // percent, all cores
111  memUsed?: number // bytes
112  memTotal?: number // bytes
113  diskUsed?: number
114  diskTotal?: number
115  procs?: number
116  gpu?: GpuSnap
117}
118
119export type GpuSnap = { util: number; memUsed: number; memTotal: number; temp: number; name: string }
120
121/** Windows: one PowerShell call answering CPU, memory, C: and process count as JSON. */
122export const WIN_SYS = [
123  'powershell', '-NoProfile', '-NonInteractive', '-Command',
124  '$o=Get-CimInstance Win32_OperatingSystem;' +
125    '$c=(Get-CimInstance Win32_Processor | Measure-Object -Property LoadPercentage -Average).Average;' +
126    "$d=Get-CimInstance Win32_LogicalDisk -Filter \"DeviceID='C:'\";" +
127    '$p=(Get-Process).Count;' +
128    '[pscustomobject]@{cpu=$c;free=$o.FreePhysicalMemory;total=$o.TotalVisibleMemorySize;dfree=$d.FreeSpace;dsize=$d.Size;procs=$p} | ConvertTo-Json -Compress',
129] as const
130
131export function parseWinSys(out: string): SysSnap {
132  const snap: SysSnap = {}
133  try {
134    const j = JSON.parse(out || '{}') as Record<string, number | null>
135    if (typeof j.cpu === 'number') snap.cpu = j.cpu
136    if (typeof j.total === 'number' && typeof j.free === 'number') {
137      snap.memTotal = j.total * 1024
138      snap.memUsed = (j.total - j.free) * 1024
139    }
140    if (typeof j.dsize === 'number' && typeof j.dfree === 'number') {
141      snap.diskTotal = j.dsize
142      snap.diskUsed = j.dsize - j.dfree
143    }
144    if (typeof j.procs === 'number') snap.procs = j.procs
145  } catch {
146    // Leave the fields out.
147  }
148  return snap
149}
150
151export type CpuTicks = { idle: number; total: number }
152
153/** Linux: `/proc/stat`'s first line, against the previous reading. */
154export function parseProcStat(stat: string, prev: CpuTicks | undefined): { cpu?: number; ticks: CpuTicks } {
155  const nums = stat.split('\n')[0]!.trim().split(/\s+/).slice(1).map(Number)
156  const idle = (nums[3] ?? 0) + (nums[4] ?? 0)
157  const total = nums.reduce((a, b) => a + (Number.isFinite(b) ? b : 0), 0)
158  const ticks = { idle, total }
159  if (!prev || total <= prev.total) return { ticks }
160  return { cpu: 100 * (1 - (idle - prev.idle) / (total - prev.total)), ticks }
161}
162
163/** Linux: `/proc/meminfo`. */
164export function parseMeminfo(mem: string): Pick<SysSnap, 'memUsed' | 'memTotal'> {
165  const kb = (k: string) => Number(new RegExp(`${k}:\\s+(\\d+)`).exec(mem)?.[1] ?? 0) * 1024
166  const memTotal = kb('MemTotal')
167  return memTotal ? { memTotal, memUsed: memTotal - kb('MemAvailable') } : {}
168}
169
170/** macOS: `top -l 1 -n 0` and `sysctl -n hw.memsize`. */
171export const MAC_TOP = ['top', '-l', '1', '-n', '0'] as const
172export const MAC_MEMSIZE = ['sysctl', '-n', 'hw.memsize'] as const
173
174export function parseMacTop(top: string, memsize: string): SysSnap {
175  const snap: SysSnap = {}
176  const m = /CPU usage: ([\d.]+)% user, ([\d.]+)% sys/.exec(top)
177  if (m) snap.cpu = Number(m[1]) + Number(m[2])
178  const pm = /PhysMem: ([\d.]+)([GM]) used/.exec(top)
179  if (pm) snap.memUsed = Number(pm[1]) * (pm[2] === 'G' ? 1024 ** 3 : 1024 ** 2)
180  const total = Number(memsize.trim())
181  if (total) snap.memTotal = total
182  return snap
183}
184
185/** NVIDIA GPUs, any platform. */
186export const NVIDIA_SMI = ['nvidia-smi', '--query-gpu=utilization.gpu,memory.used,memory.total,temperature.gpu,name', '--format=csv,noheader,nounits'] as const
187
188export function parseNvidia(out: string): GpuSnap | undefined {
189  const line = out.split('\n')[0]!
190  if (!line.trim()) return undefined
191  const [util, used = '0', total = '0', temp = '0', name = 'GPU'] = line.split(',').map(s => s.trim()) as [string, ...string[]]
192  return { util: Number(util), memUsed: Number(used) * 1024 ** 2, memTotal: Number(total) * 1024 ** 2, temp: Number(temp), name }
193}
194
195export function fmtBytes(n: number | undefined): string {
196  if (n === undefined || !Number.isFinite(n)) return '—'
197  // Each unit starts where the one below would round up to 1024 of itself.
198  if (n >= 1023.95 * 1024 ** 3) return `${(n / 1024 ** 4).toFixed(1)}T`
199  if (n >= 1023.5 * 1024 ** 2) return `${(n / 1024 ** 3).toFixed(1)}G`
200  if (n >= 1023.5 * 1024) return `${Math.round(n / 1024 ** 2)}M`
201  return `${Math.round(n / 1024)}K`
202}
203
types/index.d.ts 41 lines
1export type BridgeLimit = { kind: string; percentUsed: number; resetsAt?: string }
2
3export type BridgeSnap = {
4  model: string
5  effort?: string
6  isWorking: boolean
7  ctxTokens?: number
8  ctxWindow: number
9  ctxPercent?: number
10  limits: BridgeLimit[]
11  costUsd?: number
12  startedAt: number
13  branch: string
14  isRepo: boolean
15  ahead: number
16  behind: number
17  staged: number
18  unstaged: number
19  untracked: number
20  conflicts: number
21  cpu?: number
22  cpuHistory: number[]
23  memUsed?: number
24  memTotal?: number
25  gpuUtil?: number
26  gpuName?: string
27  agentsRunning: number
28  agentsDone: number
29  agentsFailed: number
30  tool?: { name: string; detail: string; startedAt: number }
31  lastTool?: { name: string; detail: string; ms: number; isError: boolean }
32  toolCount: number
33  now: number
34}
35
36declare module 'claude-code' {
37  interface PluginState {
38    bridge: { snap: BridgeSnap | null }
39  }
40}
41