diff --git a/gitbook-docs/SUMMARY.md b/gitbook-docs/SUMMARY.md index baa580d6..4856058a 100644 --- a/gitbook-docs/SUMMARY.md +++ b/gitbook-docs/SUMMARY.md @@ -26,6 +26,7 @@ - [News and Reading (CLI)](cli/news-and-reading.md) - [Slot Cache](cli/ad-cache.md) - [Local Ledger](cli/local-ledger.md) +- [Utilities Channel (CH 03)](cli/utilities-channel.md) - [Dashboard Current State](dashboard/current-state.md) - [Reading List (Dashboard)](dashboard/reading.md) - [Dev Workflow](engineering/dev-workflow.md) diff --git a/gitbook-docs/cli/utilities-channel.md b/gitbook-docs/cli/utilities-channel.md new file mode 100644 index 00000000..2b3cb311 --- /dev/null +++ b/gitbook-docs/cli/utilities-channel.md @@ -0,0 +1,124 @@ +# CH 03 — Utilities (dev instrument panel) + +CH 03 is a third rotating slot alongside **CH 01 NEWS** and **CH 02 MARKETS**. +Where those surface _external_ feeds, Utilities is _inward-looking_: a compact +instrument panel of local, real-time stats a developer wants to glance at while +Claude Code works — Claude usage/limits, git state, machine vitals, and service +health. It rotates in the same flow (news → markets → utils → …). + +Unlike news/ticker slots, the utility slot is **built locally by the daemon** +every time it's that slot's turn — it never round-trips `/me/content/next`, and +usage/cost data never leaves the machine. + +## The telemetry feed (and the status-line fix it rode in on) + +Claude Code pipes a rich JSON blob to its `statusLine` command on stdin every +render: `rate_limits.five_hour/.seven_day.{used_percentage,resets_at}`, +`context_window.{used_percentage,context_window_size}`, `cost.total_cost_usd`, +cache token counts, `model.display_name`, `effort.level`, and +`workspace.current_dir`. We previously discarded this. + +`distro statusline` now parses it into a local snapshot +(`~/.distro/claude-usage.json`, latest fields + a short rolling history) which +the utility slot reads. See `lib/claude-usage.ts`. + +### Status-line chaining (append, never clobber) + +Claude Code's `statusLine` is a **single command slot**. When Distro takes it +over, `setStatusLine` (`lib/claude-settings.ts`) now first stashes any +pre-existing user command in `~/.distro/wrapped-statusline.json` +(`lib/wrapped-statusline.ts`). `distro statusline` then: + +1. snapshots the stdin telemetry, +2. runs the wrapped command (feeding it the same stdin, 800 ms timeout) and + emits its output, +3. appends our current slot line **below** it. + +`removeStatusLine` (uninstall) restores the wrapped command instead of deleting +the entry. Net effect: a user's custom status line keeps working untouched, with +the Distro line added beneath it. + +## Buckets + +All buckets are optional — an unavailable source is omitted, never shown as a +fake zeroed gauge (`UtilityPayload` in `@distrotv/shared`). + +| Bucket | Source | Fields | +| ----------- | -------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- | +| **ai** | statusline snapshot + derived history | 5h/7d limit % + reset, ctx %, cost, cache %, burn $/min, time-to-limit, model/effort, lines ± | +| **git** | `git` in the snapshot's `cwd` (cached 10 s) | branch, ahead/behind, dirty files, uncommitted lines, last-commit age | +| **machine** | sampled cpu + `memory_pressure`/`MemAvailable` + `pmset` + `statfs` (15 s) | cpu % busy (sampled), mem % used (real pressure), battery %, disk % used | +| **health** | status.anthropic.com summary (cached 60 s) | Anthropic status (ok/degraded/down), online | + +Burn rate and time-to-limit are derived from the rolling cost/limit history in +`deriveUsage`. + +## Generation & rotation + +`createUtilityProvider` (`lib/utility-slot.ts`) caches probe results and +refreshes stale ones **in the background** so `build()` stays synchronous and +fast. The orchestrator (`lib/daemon/orchestrator.ts`) injects a utility slot +every `UTILITY_SLOT_EVERY_N` (=3) non-suppressed picks, gated on the +`utilitiesEnabled` pref. If the provider has no data yet (cold start, no Claude +session), `build()` returns `null` and the rotation falls through to a normal +slot rather than skipping a beat. + +The slot is tagged `cacheSource: "local"`; the orchestrator skips the +`writeNowPlaying` API mirror for it, so usage data stays on-device. + +## Layout — the instrument-panel grid + +`renderUtilityPanel` (`render-line.ts`) draws three rows on **one shared +4-column grid**, so bars line up vertically and the `│` separators run straight +down. Each column owns a hue (fill + a dark "unfilled" track tint): + +``` +▍ utils +⎇ organic-bread ↑3 ↓1 │ $0.29 · $0.19/m · ~5m │ Opus 4.8 / high │ ⚠ api degraded +ctx ███░░░░░ 42% │ cache ████████ 99% │ week █░░░ 14% · 3d │ 5h █░░░ 9% · 4h 6m +────────────────────────────────────────────────────────────────────────────────── +cpu ███░░░░░ 38 │ mem ████░░░░ 44 │ disk ███░░░░░ 43 +``` + +Gauge order is **ctx · cache · week · 5h** — the two Claude limits (weekly + +5-hour) sit **together** as columns 3 & 4. Per-metric hues: ctx amber, cache +violet, week teal, 5h periwinkle. + +- **context row** — git `⎇ branch ↑ahead ↓behind` (git-CLI style, no dirty count) · + cost · burn · `~time-to-limit` · model/effort · **api line only on an incident** +- **gauge row** — ctx · cache · weekly limit (`week`) · 5-hour limit (`5h`) +- a **faint dim rule** separates the gauge row from the machine row. A truly + blank line can't be used: Claude's statusLine collapses a zero-width line, and + a whitespace/non-breaking-space row makes it **drop every row after it** — so a + thin rule is the lightest separator that renders reliably +- **machine row** — cpu / mem / disk, gray bars that redden ≥90% (battery dropped — + not useful in a terminal) + +No header `· live` suffix and no footer line. Per-column label fields +(`COL_FIELD`) are uniform so every bar starts at the same x in its column. Limit +gauges turn red + `⚠` at/above `UTILITY_LIMIT_WARN_PCT` (90%, for week/5h) and +`UTILITY_CTX_WARN_PCT` (90%, ctx). The **API health line is hidden when Anthropic +is healthy** and only appears (red) during a `degraded`/`down` incident. + +Bar width scales with the terminal width the daemon reads off the tty, and the +panel degrades progressively on narrow terminals: full → drop machine row → drop +context detail → **gauges-only** (the four core gauges always fit). Absent +buckets (no git repo, stale snapshot) are omitted, never shown as zeroed bars. + +The `utilitiesLayout` pref (`auto`/`full`/`complement`) and the `layout` field on +`UtilityPayload` are retained but the renderer currently draws the same grid +regardless — bars are always shown, including when appended below a user's own +status line. + +## Preferences + +`distro preferences` → **utilities panel** toggles `utilitiesEnabled` and picks +the layout. Both are CLI-local (never uploaded), so they write straight to the +config file. The daemon reads `utilitiesLayout` live via the file-watch reload — +no restart needed. + +## Out of scope (v1) + +Backend sync / dashboard usage history; a persistent always-on grid; cross-tool +(Cursor/Copilot) aggregation. CI/build + deploy status are reserved for their own +future channels. diff --git a/packages/cli/src/commands/daemon.ts b/packages/cli/src/commands/daemon.ts index 436878e3..10a3d02f 100644 --- a/packages/cli/src/commands/daemon.ts +++ b/packages/cli/src/commands/daemon.ts @@ -5,6 +5,7 @@ import { platform } from "node:os" import { Command } from "commander" import { configPath, readConfig, writeConfig } from "../lib/config.js" import { openSlotCache } from "../lib/slot-cache.js" +import { createUtilityProvider } from "../lib/utility-slot.js" import { openLedger } from "../lib/ledger.js" import { showAd } from "../lib/daemon/display.js" import { cliVersion, refreshDeviceMetadata } from "../lib/device.js" @@ -357,8 +358,17 @@ export async function runDaemon(): Promise { await writeConfig({ ...current, preferences: next }) } + // CH 03: locally-built utility panel injected into the rotation. Layout pref + // is read live so file-watch reloads take effect without a daemon restart. + let livePrefs = cfg.preferences + const utilityProvider = createUtilityProvider({ + getLayoutPref: () => livePrefs.utilitiesLayout, + log: (msg, fields) => log.debug(msg, fields), + }) + orchestrator = createOrchestrator({ slotCache, + utilityProvider, ledger, display: { show: showAd }, keyCapture, @@ -382,6 +392,7 @@ export async function runDaemon(): Promise { try { const next = await readConfig() if (!next) return + livePrefs = next.preferences orchestrator.updatePreferences(next.preferences) const nextFp = fingerprintPrefs(next.preferences) if (nextFp !== lastPrefsFingerprint) { diff --git a/packages/cli/src/commands/preferences.ts b/packages/cli/src/commands/preferences.ts index 4cb95439..72fa6e71 100644 --- a/packages/cli/src/commands/preferences.ts +++ b/packages/cli/src/commands/preferences.ts @@ -16,6 +16,7 @@ type Action = | "channels" | "watchlist" | "alerts" + | "utilities" | "quiet-hours" | "tz" | "topics" @@ -51,6 +52,7 @@ async function showMenu(currentMode: ChannelMode): Promise { { value: "channels", label: "channels (tech / finance / crypto / …)" }, { value: "watchlist", label: "watchlist (add / remove tickers)" }, { value: "alerts", label: "alerts (global threshold)" }, + { value: "utilities", label: "utilities panel (CH 03 — usage, git, machine, health)" }, { value: "quiet-hours", label: "quiet hours (set start / end, blank to disable)" }, { value: "tz", label: "tz offset (auto-detect or manual)" }, { value: "topics", label: "news topics (v1.1 — coming soon)" }, @@ -118,6 +120,47 @@ async function tzAction(current: SyncedPreferences): Promise { log.success(`tz offset set to ${manual} minutes`) } +// utilitiesEnabled / utilitiesLayout are CLI-local (never uploaded), so this +// writes straight to the config file rather than through the preferences API. +async function utilitiesAction(): Promise { + const cfg = await readConfig() + if (!cfg) { + log.warn("not initialized — run `distro init` first") + return + } + const cur = cfg.preferences + const enabled = await confirm({ + message: `utilities panel is ${cur.utilitiesEnabled ? "on" : "off"} — keep it on?`, + initialValue: cur.utilitiesEnabled, + }) + if (isCancel(enabled)) return + + let layout = cur.utilitiesLayout + if (enabled) { + const pick = await select({ + message: "panel layout", + initialValue: cur.utilitiesLayout, + options: [ + { value: "auto", label: "auto (complement if you have a custom status line, else full)" }, + { value: "full", label: "full (all gauges)" }, + { value: "complement", label: "complement (skip gauges your status line already shows)" }, + ], + }) + if (isCancel(pick)) return + layout = pick as "auto" | "full" | "complement" + } + + await writeConfig({ + apiUrl: cfg.apiUrl, + auth: cfg.auth, + user: cfg.user, + device: cfg.device, + cli: cfg.cli, + preferences: { ...cur, utilitiesEnabled: enabled === true, utilitiesLayout: layout }, + }) + log.success(`utilities ${enabled ? `on · ${layout}` : "off"}`) +} + async function runPreferences(): Promise { intro("distro preferences") let prefs = await getPreferences() @@ -205,6 +248,11 @@ async function runPreferences(): Promise { continue } + if (action === "utilities") { + await utilitiesAction() + continue + } + if (action === "quiet-hours") { await quietHoursAction(prefs) prefs = await getPreferences() diff --git a/packages/cli/src/commands/statusline.ts b/packages/cli/src/commands/statusline.ts index dec2d939..0cdfb8c9 100644 --- a/packages/cli/src/commands/statusline.ts +++ b/packages/cli/src/commands/statusline.ts @@ -1,16 +1,76 @@ +import { spawnSync } from "node:child_process" +import { readFileSync } from "node:fs" import { Command } from "commander" import { readStatusLine } from "../lib/statusline-state.js" +import { parseStatuslineInput, recordUsage } from "../lib/claude-usage.js" +import { readWrappedStatusLine } from "../lib/wrapped-statusline.js" + +// Invoked by Claude Code's statusLine on a cadence. Claude pipes session JSON +// on stdin; we (1) snapshot that telemetry for CH 03, (2) run the user's +// original status line (if Distro wrapped one) and emit its output, then +// (3) append our current slot line BELOW it. Must be fast and never throw. + +// Read stdin only when it's piped — reading fd 0 on an interactive tty blocks. +function readStdin(): string { + if (process.stdin.isTTY) return "" + try { + return readFileSync(0, "utf8") + } catch { + return "" + } +} -// Invoked by Claude Code's statusLine hook on a cadence. Claude pipes session -// JSON on stdin (ignored) and renders our stdout at the bottom. Must be fast -// and must never throw — print the current slot line (or nothing) and exit. export const statuslineCmd = new Command("statusline") .description("print the current Distro TV slot as one line (used by Claude Code's statusLine)") .action(() => { + const input = readStdin() + + // snapshot telemetry for the utilities channel — best-effort. + if (input) { + try { + recordUsage(parseStatuslineInput(input, Date.now())) + } catch { + /* ignore */ + } + } + + const parts: string[] = [] + + // run the wrapped (user's original) status line first, feeding it the same + // stdin. Tight timeout so a slow custom line can't stall Claude's render. + const wrapped = readWrappedStatusLine() + if (wrapped) { + try { + const res = spawnSync("sh", ["-c", wrapped.command], { + input, + encoding: "utf8", + timeout: 800, + maxBuffer: 1024 * 1024, + }) + // res.error is set on spawn failure / timeout (and stdout may be a + // partial chunk) — skip it then so we never emit a half-rendered line. + // stderr from the wrapped command is intentionally dropped. + if (!res.error) { + const out = (res.stdout ?? "").replace(/\n+$/, "") + if (out.length > 0) parts.push(out) + } + } catch { + /* fall through — show our line alone */ + } + } + + // our slot line below the wrapped output (empty when nothing is playing). + try { + const ours = readStatusLine() + if (ours.length > 0) parts.push(ours) + } catch { + /* ignore */ + } + try { - process.stdout.write(readStatusLine()) + process.stdout.write(parts.join("\n")) } catch { - /* print nothing on any failure */ + /* ignore */ } process.exit(0) }) diff --git a/packages/cli/src/lib/claude-settings.ts b/packages/cli/src/lib/claude-settings.ts index 35701ca5..5ae3cfd0 100644 --- a/packages/cli/src/lib/claude-settings.ts +++ b/packages/cli/src/lib/claude-settings.ts @@ -1,6 +1,11 @@ import { randomBytes } from "node:crypto" import { copyFile, readFile, rename, stat, writeFile } from "node:fs/promises" import { basename, dirname, join } from "node:path" +import { + clearWrappedStatusLine, + readWrappedStatusLine, + writeWrappedStatusLine, +} from "./wrapped-statusline.js" export interface HookCommand { type: "command" @@ -212,39 +217,72 @@ function buildStatusLineCommand(binPath: string): string { // matches " statusline" (bare / single / double quoted), capturing the bin const STATUSLINE_COMMAND_RE = /^\s*(?:"((?:\\.|[^"])*)"|'([^']*)'|(\S+))\s+statusline\s*$/ +// True when `command` is our own " statusline". +function isOurStatusLineCommand(command: unknown): boolean { + if (typeof command !== "string") return false + const m = STATUSLINE_COMMAND_RE.exec(command) + if (!m) return false + const bin = m[1] !== undefined ? unescapeDoubleQuoted(m[1]) : (m[2] ?? m[3] ?? "") + return DISTRO_BIN_RE.test(basename(bin)) +} + // Point Claude Code's statusLine at `distro statusline` so it renders the // current slot at the bottom. Idempotent — no-op if already pointed at binPath. +// +// Claude's statusLine is a SINGLE command slot, so before we take it over we +// stash any pre-existing user command (see wrapped-statusline.ts); `distro +// statusline` then runs it and appends our line below, and uninstall restores +// it. Re-captures if the user later swaps in a new custom command. export function setStatusLine( settings: Settings, binPath: string ): { next: Settings; changed: boolean } { const command = buildStatusLineCommand(binPath) - const current = settings.statusLine as { type?: unknown; command?: unknown } | undefined - if ( - current && - typeof current === "object" && - current.type === "command" && - current.command === command - ) { - return { next: settings, changed: false } + const current = settings.statusLine as + | { type?: unknown; command?: unknown; padding?: unknown } + | undefined + + if (current && typeof current === "object" && typeof current.command === "string") { + if (isOurStatusLineCommand(current.command)) { + // already ours — leave any previously-captured wrapped command intact. + if (current.command === command) return { next: settings, changed: false } + } else { + // foreign command — preserve it so we can chain + later restore it. + writeWrappedStatusLine({ + type: typeof current.type === "string" ? current.type : "command", + command: current.command, + ...(typeof current.padding === "number" ? { padding: current.padding } : {}), + }) + } } + return { next: { ...settings, statusLine: { type: "command", command, padding: 0 } }, changed: true, } } -// Strip the statusLine entry only when it's ours (" statusline"), -// so we never clobber a user's custom status line. +// On uninstall, restore the user's original status line if we wrapped one; +// otherwise strip the entry — but only when it's ours, so we never clobber a +// custom status line the user added after us. export function removeStatusLine(settings: Settings): { next: Settings; changed: boolean } { const current = settings.statusLine as { command?: unknown } | undefined if (!current || typeof current !== "object" || typeof current.command !== "string") { return { next: settings, changed: false } } - const m = STATUSLINE_COMMAND_RE.exec(current.command) - if (!m) return { next: settings, changed: false } - const bin = m[1] !== undefined ? unescapeDoubleQuoted(m[1]) : (m[2] ?? m[3] ?? "") - if (!DISTRO_BIN_RE.test(basename(bin))) return { next: settings, changed: false } + if (!isOurStatusLineCommand(current.command)) return { next: settings, changed: false } + + const wrapped = readWrappedStatusLine() + if (wrapped) { + const restored: StatusLineConfig = { + type: "command", + command: wrapped.command, + ...(typeof wrapped.padding === "number" ? { padding: wrapped.padding } : {}), + } + clearWrappedStatusLine() + return { next: { ...settings, statusLine: restored }, changed: true } + } + const next = { ...settings } delete next.statusLine return { next, changed: true } diff --git a/packages/cli/src/lib/claude-usage.ts b/packages/cli/src/lib/claude-usage.ts new file mode 100644 index 00000000..760789ba --- /dev/null +++ b/packages/cli/src/lib/claude-usage.ts @@ -0,0 +1,217 @@ +import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs" +import { randomBytes } from "node:crypto" +import { join } from "node:path" +import { configDir } from "./config.js" + +// Telemetry feed for CH 03. Claude Code pipes a rich JSON blob to the status +// line command on stdin every render; `distro statusline` parses it here and +// persists a snapshot + a short rolling history. The daemon reads the snapshot +// when it builds a utility slot. Stays entirely local — never uploaded. + +const SNAPSHOT_VERSION = 1 +const HISTORY_MAX = 30 + +// Fields we lift straight from the latest stdin payload. +export interface UsageLatest { + ts: number + cwd?: string + repoName?: string + costUsd?: number + linesAdded?: number + linesRemoved?: number + model?: string + effort?: string + ctxPct?: number + ctxSize?: number + cachePct?: number + fiveHourPct?: number + fiveHourResetAt?: number // epoch ms + sevenDayPct?: number + sevenDayResetAt?: number // epoch ms + version?: string +} + +// Trimmed sample kept in the ring buffer for rate derivation. +interface UsageSample { + ts: number + costUsd?: number + fiveHourPct?: number + sevenDayPct?: number +} + +interface UsageSnapshot { + version: number + latest: UsageLatest + history: UsageSample[] +} + +export interface UsageDerived { + burnUsdPerMin?: number + timeToLimitMin?: number +} + +function snapshotPath(): string { + return join(configDir(), "claude-usage.json") +} + +function num(v: unknown): number | undefined { + return typeof v === "number" && Number.isFinite(v) ? v : undefined +} + +function str(v: unknown): string | undefined { + return typeof v === "string" && v.length > 0 ? v : undefined +} + +// resets_at comes through as epoch seconds; normalize to ms. Guard values that +// already look like ms so we don't double-scale. +function toMs(v: unknown): number | undefined { + const n = num(v) + if (n === undefined) return undefined + return n < 1e12 ? Math.round(n * 1000) : Math.round(n) +} + +function clampPct(v: number | undefined): number | undefined { + if (v === undefined) return undefined + return Math.max(0, Math.min(100, Math.round(v))) +} + +// Strip "Claude " + any trailing parenthetical, mirroring the reference script. +function cleanModel(name: string | undefined): string | undefined { + if (!name) return undefined + return name + .replace(/^Claude /, "") + .replace(/\s*\([^)]*\)\s*$/, "") + .trim() +} + +// Parse the Claude Code status-line stdin JSON into our latest-fields shape. +// Tolerant: every field is optional and bad input yields an empty-ish object. +export function parseStatuslineInput(raw: string, now: number): UsageLatest { + let j: Record = {} + try { + j = JSON.parse(raw) as Record + } catch { + return { ts: now } + } + const pick = (path: string[]): unknown => { + let cur: unknown = j + for (const k of path) { + if (cur === null || typeof cur !== "object") return undefined + cur = (cur as Record)[k] + } + return cur + } + + // cache hit rate = reads / (reads + fresh uncached input). cache_creation + // tokens are writes (being stored for the first time), not reads — counting + // them in the denominator deflated the % while a session was building its + // cache, so they're excluded. + const cacheRead = num(pick(["context_window", "current_usage", "cache_read_input_tokens"])) ?? 0 + const inTok = num(pick(["context_window", "current_usage", "input_tokens"])) ?? 0 + const cacheDenom = cacheRead + inTok + const cachePct = cacheDenom > 0 ? Math.round((cacheRead * 100) / cacheDenom) : undefined + + return { + ts: now, + cwd: str(pick(["workspace", "current_dir"])) ?? str(pick(["cwd"])), + repoName: str(pick(["workspace", "repo", "name"])), + costUsd: num(pick(["cost", "total_cost_usd"])), + linesAdded: num(pick(["cost", "total_lines_added"])), + linesRemoved: num(pick(["cost", "total_lines_removed"])), + model: cleanModel(str(pick(["model", "display_name"]))), + effort: str(pick(["effort", "level"])) ?? str(pick(["model", "reasoning_effort"])), + ctxPct: clampPct(num(pick(["context_window", "used_percentage"]))), + ctxSize: num(pick(["context_window", "context_window_size"])), + cachePct, + fiveHourPct: clampPct(num(pick(["rate_limits", "five_hour", "used_percentage"]))), + fiveHourResetAt: toMs(pick(["rate_limits", "five_hour", "resets_at"])), + sevenDayPct: clampPct(num(pick(["rate_limits", "seven_day", "used_percentage"]))), + sevenDayResetAt: toMs(pick(["rate_limits", "seven_day", "resets_at"])), + version: str(pick(["version"])), + } +} + +function readSnapshot(): UsageSnapshot | null { + try { + const parsed = JSON.parse(readFileSync(snapshotPath(), "utf8")) as UsageSnapshot + if (parsed?.version !== SNAPSHOT_VERSION) return null + return parsed + } catch { + return null + } +} + +function writeSnapshot(snap: UsageSnapshot): void { + try { + const dir = configDir() + mkdirSync(dir, { recursive: true, mode: 0o700 }) + const tmp = join(dir, `.claude-usage.${randomBytes(6).toString("hex")}.tmp`) + writeFileSync(tmp, JSON.stringify(snap), { mode: 0o600 }) + renameSync(tmp, snapshotPath()) + } catch { + /* never throw from the status-line path */ + } +} + +// Append a freshly-parsed sample, capped at HISTORY_MAX. Called once per +// status-line render — must be fast and never throw. +export function recordUsage(latest: UsageLatest): void { + const prev = readSnapshot() + const history = prev?.history ?? [] + history.push({ + ts: latest.ts, + costUsd: latest.costUsd, + fiveHourPct: latest.fiveHourPct, + sevenDayPct: latest.sevenDayPct, + }) + while (history.length > HISTORY_MAX) history.shift() + writeSnapshot({ version: SNAPSHOT_VERSION, latest, history }) +} + +// Read the snapshot once and return both the latest fields (or null when stale) +// and the derived burn-rate / time-to-limit — a single file read per call, so +// the per-rotation render path doesn't read the same file twice. +export function loadUsage( + now: number, + staleMs: number +): { latest: UsageLatest | null; derived: UsageDerived } { + const snap = readSnapshot() + if (!snap) return { latest: null, derived: {} } + const latest = now - snap.latest.ts > staleMs ? null : snap.latest + return { latest, derived: deriveFromSnapshot(snap, now) } +} + +// Rolling window for burn-rate / projection — deliberately independent of the +// snapshot staleness cutoff. +const BURN_RATE_WINDOW_MS = 10 * 60_000 + +// Burn rate ($/min) from cumulative cost over BURN_RATE_WINDOW_MS, and a +// projection of minutes until the nearest rate-limit window hits 100%. +function deriveFromSnapshot(snap: UsageSnapshot, now: number): UsageDerived { + const hist = snap.history.filter((s) => now - s.ts <= BURN_RATE_WINDOW_MS) + if (hist.length < 2) return {} + const first = hist[0] + const last = hist[hist.length - 1] + if (!first || !last) return {} + const dtMin = (last.ts - first.ts) / 60_000 + if (dtMin <= 0) return {} + + const out: UsageDerived = {} + if (first.costUsd !== undefined && last.costUsd !== undefined) { + const dCost = last.costUsd - first.costUsd + if (dCost > 0) out.burnUsdPerMin = dCost / dtMin + } + + // project minutes-to-100% for whichever window is climbing fastest. + const proj = (firstPct?: number, lastPct?: number): number | undefined => { + if (firstPct === undefined || lastPct === undefined) return undefined + const rate = (lastPct - firstPct) / dtMin // pct per min + if (rate <= 0) return undefined + return Math.max(0, (100 - lastPct) / rate) + } + const five = proj(first.fiveHourPct, last.fiveHourPct) + const seven = proj(first.sevenDayPct, last.sevenDayPct) + const candidates = [five, seven].filter((v): v is number => v !== undefined) + if (candidates.length > 0) out.timeToLimitMin = Math.round(Math.min(...candidates)) + return out +} diff --git a/packages/cli/src/lib/daemon/orchestrator.ts b/packages/cli/src/lib/daemon/orchestrator.ts index f621cd8a..60088e6b 100644 --- a/packages/cli/src/lib/daemon/orchestrator.ts +++ b/packages/cli/src/lib/daemon/orchestrator.ts @@ -9,10 +9,12 @@ import { PROGRESS_CAP, PROGRESS_SNAP_HOLD_MS, PROGRESS_TICK_MS, + UTILITY_SLOT_EVERY_N, type DevdripPreferences, type SlotKind, } from "@distrotv/shared" import type { SlotCache, CachedSlot } from "../slot-cache.js" +import type { UtilityProvider } from "../utility-slot.js" import type { Ledger, LocalNewsImpression } from "../ledger.js" import type { KeyCapture } from "./input.js" import { step, type Effect, type Event, type State } from "./state-machine.js" @@ -44,6 +46,8 @@ export type OpenUrl = (url: string) => void export interface OrchestratorDeps { slotCache: SlotCache + // CH 03: builds the locally-generated utility panel injected into rotation. + utilityProvider?: UtilityProvider ledger: Ledger display: DisplayApi keyCapture: KeyCapture @@ -175,6 +179,9 @@ export function createOrchestrator(deps: OrchestratorDeps): Orchestrator { // double the ads a user sees in an hour/day. const hourlyTimestamps: number[] = [] const dailyTimestamps: number[] = [] + // counts non-suppressed picks so we can inject a utility panel every Nth one + // (news → ticker → utils → …). Resets on daemon restart (acceptable for v1). + let pickCount = 0 function getOrCreateSession(key: string, tty: string | null): Session { let s = sessions.get(key) @@ -422,7 +429,12 @@ export function createOrchestrator(deps: OrchestratorDeps): Orchestrator { } return case "displayAd": { - const payloadId = effect.ad.kind === "news" ? effect.ad.id : effect.ad.symbol + const payloadId = + effect.ad.kind === "news" + ? effect.ad.id + : effect.ad.kind === "ticker" + ? effect.ad.symbol + : "utility" if (!effect.tty) { deps.log.warn("display skipped: no tty path", { payloadId }) queueMicrotask(() => dispatch({ kind: "dismiss", now: now(), tty: session.tty })) @@ -686,6 +698,18 @@ export function createOrchestrator(deps: OrchestratorDeps): Orchestrator { deps.log.debug("slot suppressed", { context, reason, tty: session.tty }) return null } + pickCount += 1 + // every Nth pick is the CH 03 utility panel — but only when enabled and the + // provider actually has data (cold start / no Claude session → falls through + // to a normal slot rather than skipping a rotation). + if ( + deps.utilityProvider && + preferences.utilitiesEnabled && + pickCount % UTILITY_SLOT_EVERY_N === 0 + ) { + const util = deps.utilityProvider.build() + if (util) return util + } const slot = deps.slotCache.next() if (!slot) { deps.log.debug("cache empty", { context }) diff --git a/packages/cli/src/lib/render-line.ts b/packages/cli/src/lib/render-line.ts index 514ea060..b4522822 100644 --- a/packages/cli/src/lib/render-line.ts +++ b/packages/cli/src/lib/render-line.ts @@ -1,5 +1,6 @@ +import { UTILITY_CTX_WARN_PCT, UTILITY_LIMIT_WARN_PCT, type UtilityPayload } from "@distrotv/shared" import type { CachedSlot } from "./slot-cache.js" -import { color, type ColorMode } from "./ansi.js" +import { bgRgb, color, rgb, type ColorMode } from "./ansi.js" import { renderChip, getBrandName, chipLabelFor } from "./brand-colors.js" import { renderChart, directionFor } from "./sparkline.js" @@ -95,6 +96,10 @@ export function renderSlotLine( const dot = color("muted", "·", mode) const nudge = updateLatest ? [updateNudge(updateLatest, mode)] : [] + if (slot.kind === "utility") { + return renderUtilityPanel(slot, mode, W, nudge) + } + if (slot.kind === "ticker") { const header = `${color("indigo", "▍", mode)} ${color("indigo", "markets", mode)} ${dot} ${color("muted", "live", mode)}` @@ -144,3 +149,286 @@ export function renderSlotLine( const footer = `${LEFT_PAD}${color("muted", "distro tv · news", mode)}` return [...nudge, header, metaLine, ...hlLines, footer].join("\n") } + +// ── CH 03 utility panel ───────────────────────────────────────────────────── +// "instrument panel": context / gauge / machine rows share one 4-column grid +// (col hues teal · amber · violet · periwinkle), fill+track bars that line up +// across rows, branch + ahead/behind, and an api line only on an incident. + +type Rgb = readonly [number, number, number] +// one row of the utility grid; `sepBefore` draws a faint divider above it. +interface GridRow { + cells: string[] + sepBefore?: boolean +} +interface Hue { + fill: Rgb + track: Rgb +} + +// per-column hue + its dark "unfilled" track tint (tuned for a dark terminal). +const H_TEAL: Hue = { fill: [94, 200, 188], track: [34, 65, 62] } +const H_AMBER: Hue = { fill: [255, 150, 40], track: [67, 51, 30] } +const H_VIOLET: Hue = { fill: [170, 132, 235], track: [51, 42, 71] } +const H_PERI: Hue = { fill: [132, 165, 240], track: [38, 52, 86] } +const H_GRAY: Hue = { fill: [107, 114, 128], track: [35, 39, 47] } +const H_RED: Hue = { fill: [240, 97, 109], track: [65, 34, 42] } +const FG_RGB: Rgb = [233, 235, 242] + +// per-column label field (max label + a space) so bars start at the same x in +// their column on every row. +// columns: ctx(0) · cache(1) · week(2) · 5h(3); machine cpu(0)/mem(1)/disk(2). +const COL_FIELD = [4, 6, 5, 3] as const + +// fill + track bar. truecolor → bg blocks; else █/░ fallback. +function renderBar(pct: number, width: number, h: Hue, mode: ColorMode): string { + const p = Math.max(0, Math.min(100, pct)) + const filled = Math.min(width, Math.round((p * width) / 100)) + const empty = width - filled + if (mode === "truecolor") { + return ( + bgRgb(" ".repeat(filled), h.fill[0], h.fill[1], h.fill[2], mode) + + bgRgb(" ".repeat(empty), h.track[0], h.track[1], h.track[2], mode) + ) + } + return ( + rgb("█".repeat(filled), h.fill[0], h.fill[1], h.fill[2], mode) + + rgb("░".repeat(empty), h.track[0], h.track[1], h.track[2], mode) + ) +} + +// "1h 1m" / "4d 18h" / "12m" / "-" countdown to an epoch-ms reset. +function fmtReset(at: number | undefined, now: number): string { + if (at === undefined) return "-" + const diff = Math.max(0, at - now) + const d = Math.floor(diff / 86_400_000) + const h = Math.floor((diff % 86_400_000) / 3_600_000) + const m = Math.floor((diff % 3_600_000) / 60_000) + if (d > 0) return `${d}d ${h}h` + if (h > 0) return `${h}h ${m}m` + return `${m}m` +} + +// right-pad a (possibly colored) string to `n` visible columns. +function padEndVis(s: string, n: number): string { + return s + " ".repeat(Math.max(0, n - visLen(s))) +} + +// one grid cell: label(padded to its column field) + bar + value(+suffix). +function gcell( + label: string, + col: number, + pct: number, + barW: number, + h: Hue, + value: string, + valueRgb: Rgb, + mode: ColorMode, + suffix?: string +): string { + const lab = color("muted", label.padEnd(COL_FIELD[col] ?? 4), mode) + const val = rgb(value, valueRgb[0], valueRgb[1], valueRgb[2], mode) + const suf = suffix ? color("muted", ` · ${suffix}`, mode) : "" + return `${lab}${renderBar(pct, barW, h, mode)} ${val}${suf}` +} + +function renderUtilityPanel( + slot: UtilityPayload, + mode: ColorMode, + W: number, + nudge: string[] +): string { + const now = Date.now() + const header = `${color("indigo", "▍", mode)} ${color("indigo", "utils", mode)}` + const sep = color("muted", " │ ", mode) + const { ai, git, machine, health } = slot + + // bar width scales with the terminal width the daemon read off the tty. + const barW = W >= 110 ? 8 : W >= 90 ? 7 : W >= 72 ? 6 : 5 + + // ── context row ── git | cost/burn/proj | model | api(incident only) + const ctx: string[] = ["", "", "", ""] + if (git?.branch) { + // git-CLI style: just the branch + ↑ahead/↓behind vs remote (no dirty count). + const ab: string[] = [] + if (git.ahead) ab.push(`↑${git.ahead}`) + if (git.behind) ab.push(`↓${git.behind}`) + const meta = ab.length ? color("muted", ` ${ab.join(" ")}`, mode) : "" + ctx[0] = rgb(`⎇ ${git.branch}`, H_TEAL.fill[0], H_TEAL.fill[1], H_TEAL.fill[2], mode) + meta + } + if (ai) { + const econ: string[] = [] + if (ai.costUsd !== undefined) econ.push(`$${ai.costUsd.toFixed(2)}`) + if (ai.burnUsdPerMin !== undefined) econ.push(`$${ai.burnUsdPerMin.toFixed(2)}/m`) + let e = econ.length ? color("muted", econ.join(" · "), mode) : "" + if (ai.timeToLimitMin !== undefined) { + const proj = rgb( + `~${fmtMins(ai.timeToLimitMin)}`, + H_AMBER.fill[0], + H_AMBER.fill[1], + H_AMBER.fill[2], + mode + ) + e += (e ? color("muted", " · ", mode) : "") + proj + } + ctx[1] = e + if (ai.model) { + ctx[2] = rgb( + ai.effort ? `${ai.model} / ${ai.effort}` : ai.model, + H_VIOLET.fill[0], + H_VIOLET.fill[1], + H_VIOLET.fill[2], + mode + ) + } + } + if (health?.anthropic && health.anthropic !== "ok") { + ctx[3] = color("negative", `⚠ api ${health.anthropic}`, mode) + } else if (health && health.online === false) { + ctx[3] = color("negative", "⚠ offline", mode) + } + + // ── gauge row ── ctx | cache | week | 5h (the two limits kept together) + const gau: string[] = ["", "", "", ""] + if (ai) { + if (ai.ctxPct !== undefined) { + const w = ai.ctxPct >= UTILITY_CTX_WARN_PCT + gau[0] = gcell( + "ctx", + 0, + ai.ctxPct, + barW, + w ? H_RED : H_AMBER, + `${ai.ctxPct}%`, + w ? H_RED.fill : H_AMBER.fill, + mode + ) + } + if (ai.cachePct !== undefined) { + gau[1] = gcell( + "cache", + 1, + ai.cachePct, + barW, + H_VIOLET, + `${ai.cachePct}%`, + H_VIOLET.fill, + mode + ) + } + if (ai.sevenDayPct !== undefined) { + const w = ai.sevenDayPct >= UTILITY_LIMIT_WARN_PCT + gau[2] = gcell( + "week", + 2, + ai.sevenDayPct, + barW, + w ? H_RED : H_TEAL, + `${ai.sevenDayPct}%`, + w ? H_RED.fill : H_TEAL.fill, + mode, + ai.sevenDayResetAt ? fmtReset(ai.sevenDayResetAt, now) : undefined + ) + } + if (ai.fiveHourPct !== undefined) { + const w = ai.fiveHourPct >= UTILITY_LIMIT_WARN_PCT + const pre = w ? color("negative", "⚠ ", mode) : "" + gau[3] = + pre + + gcell( + "5h", + 3, + ai.fiveHourPct, + barW, + w ? H_RED : H_PERI, + `${ai.fiveHourPct}%`, + w ? H_RED.fill : H_PERI.fill, + mode, + ai.fiveHourResetAt ? fmtReset(ai.fiveHourResetAt, now) : undefined + ) + } + } + + // ── machine row ── cpu | mem | disk (gray; redden when full ≥90%). + const mac: string[] = ["", "", "", ""] + if (machine) { + const mc = (label: string, col: number, pct: number | undefined): string => { + if (pct === undefined) return "" + const hot = pct >= 90 + return gcell( + label, + col, + pct, + barW, + hot ? H_RED : H_GRAY, + String(pct), + hot ? H_RED.fill : FG_RGB, + mode + ) + } + mac[0] = mc("cpu", 0, machine.cpuPct) + mac[1] = mc("mem", 1, machine.memPct) + mac[2] = mc("disk", 2, machine.diskUsedPct) + } + + // pad cols 0-2 to their widest cell across the rendered rows (col 3 is the + // tail — its cells already start at the same x, so they align unpadded). + const build = (rows: GridRow[]): { lines: string[]; max: number } => { + const present = rows.filter((r) => r.cells.some((c) => c.length > 0)) + const w = [0, 0, 0] + for (const r of present) + for (let i = 0; i < 3; i++) w[i] = Math.max(w[i] ?? 0, visLen(r.cells[i] ?? "")) + const lines: string[] = [] + let max = 0 + for (const r of present) { + // `sepBefore` rows get a faint divider above them. A truly-blank line is + // collapsed by Claude's statusLine, so we draw a dim rule instead. + if (r.sepBefore && lines.length > 0) { + const ruleW = Math.max(1, max - LEFT_PAD.length) + lines.push(`${LEFT_PAD}${rgb("─".repeat(ruleW), 60, 64, 74, mode)}`) + } + const c = r.cells + let s = + padEndVis(c[0] ?? "", w[0] ?? 0) + + sep + + padEndVis(c[1] ?? "", w[1] ?? 0) + + sep + + padEndVis(c[2] ?? "", w[2] ?? 0) + if ((c[3] ?? "").length > 0) s += sep + c[3] + const line = `${LEFT_PAD}${s}` + lines.push(line) + max = Math.max(max, visLen(line)) + } + return { lines, max } + } + + // progressive degradation so the core gauges always fit: full -> drop machine + // -> drop context detail (keep git + api) -> gauges only. + const ctxRow: GridRow = { cells: ctx } + const gauRow: GridRow = { cells: gau } + const macRow: GridRow = { cells: mac, sepBefore: true } + const slimCtxRow: GridRow = { cells: [ctx[0] ?? "", "", "", ctx[3] ?? ""] } + const attempts: GridRow[][] = [ + [ctxRow, gauRow, macRow], + [ctxRow, gauRow], + [slimCtxRow, gauRow], + [gauRow], + ] + // pre-seed with the narrowest layout; only replace it when one actually fits. + let chosen = build(attempts[attempts.length - 1] as GridRow[]) + for (const rows of attempts) { + const r = build(rows) + if (r.max <= W) { + chosen = r + break + } + } + + return [...nudge, header, ...chosen.lines].join("\n") +} + +// "90m" → "1h 30m", "45m" → "45m" +function fmtMins(min: number): string { + if (min >= 60) return `${Math.floor(min / 60)}h ${min % 60}m` + return `${min}m` +} diff --git a/packages/cli/src/lib/slot-cache.ts b/packages/cli/src/lib/slot-cache.ts index 66e7cd66..e1893a30 100644 --- a/packages/cli/src/lib/slot-cache.ts +++ b/packages/cli/src/lib/slot-cache.ts @@ -40,7 +40,9 @@ const BATCH_SIZE = 20 // Old "ad-cache.json" (version 2) will fail the check and be silently dropped. const CACHE_FILE_VERSION = 3 -export type CachedSlot = SlotPayload & { cacheSource: "api" | "demo" } +// "local" tags slots the daemon builds itself (CH 03 utility panel) — never +// fetched from /me/content/next and never written to the cache file. +export type CachedSlot = SlotPayload & { cacheSource: "api" | "demo" | "local" } interface CacheFile { version: 3 diff --git a/packages/cli/src/lib/utility-slot.ts b/packages/cli/src/lib/utility-slot.ts new file mode 100644 index 00000000..ff877ec2 --- /dev/null +++ b/packages/cli/src/lib/utility-slot.ts @@ -0,0 +1,295 @@ +import { execFile } from "node:child_process" +import { readFile, statfs } from "node:fs/promises" +import { cpus, platform } from "node:os" +import { promisify } from "node:util" +import { + UTILITY_GIT_CACHE_MS, + UTILITY_HEALTH_CACHE_MS, + UTILITY_MACHINE_CACHE_MS, + UTILITY_SNAPSHOT_STALE_MS, + type UtilityGit, + type UtilityHealth, + type UtilityLayout, + type UtilityMachine, + type UtilityPayload, +} from "@distrotv/shared" +import type { CachedSlot } from "./slot-cache.js" +import { loadUsage, type UsageDerived, type UsageLatest } from "./claude-usage.js" +import { hasWrappedStatusLine } from "./wrapped-statusline.js" + +const execFileAsync = promisify(execFile) + +export type LayoutPref = "auto" | "full" | "complement" + +export interface UtilityProviderDeps { + now?: () => number + // CLI-local pref: "auto" picks complement when a custom status line was + // wrapped at install, else full. + getLayoutPref?: () => LayoutPref + // optional debug logger; background probe failures are reported here (never + // thrown). Without it, failures are silent. + log?: (msg: string, fields?: Record) => void +} + +export interface UtilityProvider { + // Synchronous: assembles the panel from the latest cached probe values + the + // usage snapshot. Kicks stale probes off in the background (never awaits), so + // the render path stays fast. Returns null when there's nothing to show. + build(): CachedSlot | null +} + +// ── git probe ─────────────────────────────────────────────────────────────── + +async function git(cwd: string, args: string[]): Promise { + try { + const { stdout } = await execFileAsync("git", args, { cwd, timeout: 1500 }) + return stdout.trim() + } catch { + return null + } +} + +async function probeGit(cwd: string, now: number): Promise { + const branch = await git(cwd, ["rev-parse", "--abbrev-ref", "HEAD"]) + if (branch === null) return null // not a repo / git unavailable + + const [counts, porcelain, numstat, lastCommit] = await Promise.all([ + git(cwd, ["rev-list", "--left-right", "--count", "@{upstream}...HEAD"]), + git(cwd, ["status", "--porcelain"]), + git(cwd, ["diff", "--numstat", "HEAD"]), + git(cwd, ["log", "-1", "--format=%ct"]), + ]) + + const out: UtilityGit = { branch } + + if (counts) { + const [behind, ahead] = counts.split(/\s+/).map((n) => Number.parseInt(n, 10)) + if (Number.isFinite(behind)) out.behind = behind + if (Number.isFinite(ahead)) out.ahead = ahead + } + if (porcelain !== null) { + out.dirtyFiles = porcelain.length === 0 ? 0 : porcelain.split("\n").filter(Boolean).length + } + if (numstat) { + let lines = 0 + for (const row of numstat.split("\n")) { + const [add, del] = row.split(/\s+/) + const a = Number.parseInt(add ?? "", 10) + const d = Number.parseInt(del ?? "", 10) + if (Number.isFinite(a)) lines += a + if (Number.isFinite(d)) lines += d + } + out.uncommittedLines = lines + } + if (lastCommit) { + const ct = Number.parseInt(lastCommit, 10) + if (Number.isFinite(ct)) out.lastCommitAgeSec = Math.max(0, Math.round(now / 1000 - ct)) + } + return out +} + +// ── machine probe ───────────────────────────────────────────────────────── + +function cpuSnapshot(): { idle: number; total: number } { + let idle = 0 + let total = 0 + for (const c of cpus()) { + for (const k of Object.keys(c.times) as (keyof typeof c.times)[]) total += c.times[k] + idle += c.times.idle + } + return { idle, total } +} + +// real instantaneous CPU% busy: sample cpu times twice ~250ms apart. loadavg +// is not a % and overstates on multi-core boxes, so we never use it. +async function probeCpu(): Promise { + const a = cpuSnapshot() + await new Promise((r) => setTimeout(r, 250)) + const b = cpuSnapshot() + const dt = b.total - a.total + if (dt <= 0) return undefined + return Math.max(0, Math.min(100, Math.round((1 - (b.idle - a.idle) / dt) * 100))) +} + +// real memory pressure: os.freemem() is meaningless on macOS (reports ~3% free +// because the OS uses RAM for cache), so use memory_pressure / MemAvailable. +async function probeMem(): Promise { + if (platform() === "darwin") { + try { + const { stdout } = await execFileAsync("memory_pressure", [], { timeout: 1500 }) + const m = /System-wide memory free percentage:\s*([\d.]+)%/.exec(stdout) + if (m) return Math.max(0, Math.min(100, Math.round(100 - Number.parseFloat(m[1] as string)))) + } catch { + /* omit */ + } + return undefined + } + if (platform() === "linux") { + try { + const txt = await readFile("/proc/meminfo", "utf8") + const tot = /MemTotal:\s+(\d+)/.exec(txt) + const avail = /MemAvailable:\s+(\d+)/.exec(txt) + if (tot && avail) { + return Math.round((1 - Number(avail[1]) / Number(tot[1])) * 100) + } + } catch { + /* omit */ + } + } + return undefined // never show a misleading fallback +} + +async function probeMachine(cwd: string | undefined): Promise { + const [cpuPct, memPct] = await Promise.all([probeCpu(), probeMem()]) + const out: UtilityMachine = {} + if (cpuPct !== undefined) out.cpuPct = cpuPct + if (memPct !== undefined) out.memPct = memPct + try { + const stats = await statfs(cwd || process.env["HOME"] || "/") + const blocks = Number(stats.blocks) + if (blocks > 0) out.diskUsedPct = Math.round((1 - Number(stats.bavail) / blocks) * 100) + } catch { + /* omit */ + } + return out +} + +// ── service-health probe ──────────────────────────────────────────────────── + +async function fetchWithTimeout(url: string, ms: number): Promise { + const ctrl = new AbortController() + const t = setTimeout(() => ctrl.abort(), ms) + try { + return await fetch(url, { signal: ctrl.signal, method: "GET" }) + } catch { + return null + } finally { + clearTimeout(t) + } +} + +async function probeHealth(now: () => number): Promise { + const out: UtilityHealth = {} + const start = now() + // Atlassian Statuspage summary for status.anthropic.com. + const res = await fetchWithTimeout("https://status.anthropic.com/api/v2/status.json", 2500) + out.apiLatencyMs = now() - start + out.online = res !== null + if (res && res.ok) { + try { + const body = (await res.json()) as { status?: { indicator?: string } } + const ind = body.status?.indicator ?? "none" + out.anthropic = + ind === "none" ? "ok" : ind === "critical" || ind === "major" ? "down" : "degraded" + } catch { + /* leave anthropic unset */ + } + } + return out +} + +// ── provider: caches probe results, refreshes stale ones in the background ── + +interface Cell { + value: T | null + at: number + inFlight: boolean +} + +export function createUtilityProvider(deps: UtilityProviderDeps = {}): UtilityProvider { + const now = deps.now ?? (() => Date.now()) + const getLayoutPref = deps.getLayoutPref ?? (() => "auto" as LayoutPref) + const log = deps.log ?? (() => {}) + + const gitCell: Cell = { value: null, at: 0, inFlight: false } + const machineCell: Cell = { value: null, at: 0, inFlight: false } + const healthCell: Cell = { value: null, at: 0, inFlight: false } + + function refresh( + label: string, + cell: Cell, + ttl: number, + run: () => Promise + ): void { + if (cell.inFlight) return + if (cell.value !== null && now() - cell.at < ttl) return + cell.inFlight = true + run() + .then((v) => { + cell.value = v + cell.at = now() + }) + .catch((err: unknown) => { + // probes must never throw into the render path, but the failure + // shouldn't be invisible either — report it for diagnosis. + log(`utility probe failed: ${label}`, { error: (err as Error)?.message }) + }) + .finally(() => { + cell.inFlight = false + }) + } + + function resolveLayout(): UtilityLayout { + const pref = getLayoutPref() + if (pref === "full" || pref === "complement") return pref + return hasWrappedStatusLine() ? "complement" : "full" + } + + function aiFrom( + usage: UsageLatest | null, + derived: UsageDerived + ): UtilityPayload["ai"] | undefined { + if (!usage) return undefined + const ai: NonNullable = { + fiveHourPct: usage.fiveHourPct, + fiveHourResetAt: usage.fiveHourResetAt, + sevenDayPct: usage.sevenDayPct, + sevenDayResetAt: usage.sevenDayResetAt, + ctxPct: usage.ctxPct, + ctxSize: usage.ctxSize, + costUsd: usage.costUsd, + cachePct: usage.cachePct, + model: usage.model, + effort: usage.effort, + linesAdded: usage.linesAdded, + linesRemoved: usage.linesRemoved, + burnUsdPerMin: derived.burnUsdPerMin, + timeToLimitMin: derived.timeToLimitMin, + } + return ai + } + + return { + build() { + const t = now() + const { latest: usage, derived } = loadUsage(t, UTILITY_SNAPSHOT_STALE_MS) + + // kick stale probes off in the background; use whatever's cached now. + refresh("machine", machineCell, UTILITY_MACHINE_CACHE_MS, () => probeMachine(usage?.cwd)) + refresh("health", healthCell, UTILITY_HEALTH_CACHE_MS, () => probeHealth(now)) + if (usage?.cwd) { + const cwd = usage.cwd + refresh("git", gitCell, UTILITY_GIT_CACHE_MS, () => probeGit(cwd, t)) + } + + const ai = aiFrom(usage, derived) + const git = gitCell.value ?? undefined + const machine = machineCell.value ?? undefined + const health = healthCell.value ?? undefined + + // nothing useful yet (cold start, no Claude session) — skip this rotation. + if (!ai && !git && !machine && !health) return null + + const payload: UtilityPayload = { + kind: "utility", + generatedAt: t, + layout: resolveLayout(), + ...(ai ? { ai } : {}), + ...(git ? { git } : {}), + ...(machine ? { machine } : {}), + ...(health ? { health } : {}), + } + return { ...payload, cacheSource: "local" } + }, + } +} diff --git a/packages/cli/src/lib/wrapped-statusline.ts b/packages/cli/src/lib/wrapped-statusline.ts new file mode 100644 index 00000000..544266e8 --- /dev/null +++ b/packages/cli/src/lib/wrapped-statusline.ts @@ -0,0 +1,53 @@ +import { mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs" +import { randomBytes } from "node:crypto" +import { join } from "node:path" +import { configDir } from "./config.js" + +// When Distro takes over Claude Code's (single-slot) statusLine, we stash the +// user's pre-existing command here so `distro statusline` can run it and append +// our line BELOW its output — and so uninstall can restore it. Lives in our own +// config dir, never inside Claude's settings.json. + +export interface WrappedStatusLine { + type?: string + command: string + padding?: number +} + +function wrappedPath(): string { + return join(configDir(), "wrapped-statusline.json") +} + +export function readWrappedStatusLine(): WrappedStatusLine | null { + try { + const parsed = JSON.parse(readFileSync(wrappedPath(), "utf8")) as WrappedStatusLine + if (!parsed || typeof parsed.command !== "string" || parsed.command.length === 0) return null + return parsed + } catch { + return null + } +} + +export function writeWrappedStatusLine(entry: WrappedStatusLine): void { + try { + const dir = configDir() + mkdirSync(dir, { recursive: true, mode: 0o700 }) + const tmp = join(dir, `.wrapped-statusline.${randomBytes(6).toString("hex")}.tmp`) + writeFileSync(tmp, JSON.stringify(entry, null, 2), { mode: 0o600 }) + renameSync(tmp, wrappedPath()) + } catch { + /* non-fatal */ + } +} + +export function clearWrappedStatusLine(): void { + try { + unlinkSync(wrappedPath()) + } catch { + /* already gone */ + } +} + +export function hasWrappedStatusLine(): boolean { + return readWrappedStatusLine() !== null +} diff --git a/packages/shared/src/constants/index.ts b/packages/shared/src/constants/index.ts index 09a40cec..50bd2ae2 100644 --- a/packages/shared/src/constants/index.ts +++ b/packages/shared/src/constants/index.ts @@ -7,6 +7,21 @@ export const GRACE_PERIOD_MS = 0 // every slot (news + ticker) shows for this long before rotating. export const MAX_AD_DURATION_MS = 12_000 +// ── CH 03 Utilities ──────────────────────────────────────────────────────── +// Inject a locally-built utility panel every Nth slot pick (news → ticker → +// utils → …). 3 keeps it present without crowding out news/markets. +export const UTILITY_SLOT_EVERY_N = 3 +// AI telemetry snapshot is considered stale past this — Claude only writes it +// while it's actively rendering the status line, so an idle gap goes stale. +export const UTILITY_SNAPSHOT_STALE_MS = 5 * 60_000 +// probe caches — keep the render path cheap, refresh in the background. +export const UTILITY_GIT_CACHE_MS = 10_000 +export const UTILITY_MACHINE_CACHE_MS = 15_000 +export const UTILITY_HEALTH_CACHE_MS = 60_000 +// gauge turns red + ⚠ at/above these thresholds. +export const UTILITY_LIMIT_WARN_PCT = 90 +export const UTILITY_CTX_WARN_PCT = 90 + // A tty session only renders slots if it saw a real hook/key event within this // window. Stops idle/stalled/background terminals from rotating slots (and the // now-playing + content API traffic that drives) when the user isn't on them. @@ -97,6 +112,8 @@ export function defaultPreferences(): DevdripPreferences { // Sentinel "never synced" — first GET /me/preferences will replace it. updatedAt: new Date(0).toISOString(), muteUntil: null, + utilitiesEnabled: true, + utilitiesLayout: "auto", } } diff --git a/packages/shared/src/types/SlotPayload.ts b/packages/shared/src/types/SlotPayload.ts index 9e54beeb..c1cc9cb7 100644 --- a/packages/shared/src/types/SlotPayload.ts +++ b/packages/shared/src/types/SlotPayload.ts @@ -1,11 +1,13 @@ import type { NewsPayload } from "./NewsPayload.js" import type { TickerPayload } from "./TickerPayload.js" +import type { UtilityPayload } from "./UtilityPayload.js" -export type SlotKind = "news" | "ticker" | "sponsored" | "portfolio" +export type SlotKind = "news" | "ticker" | "utility" | "sponsored" | "portfolio" export type SlotLayout = "single" | "grid" // SlotKind includes "sponsored" and "portfolio" as reserved future kinds. // Their payload types do not exist yet, so a discriminant check on those // values will narrow SlotPayload to `never`. Add the payload types when -// these kinds become live. -export type SlotPayload = NewsPayload | TickerPayload +// these kinds become live. "utility" (CH 03) is locally generated by the +// daemon — never returned by /me/content/next. +export type SlotPayload = NewsPayload | TickerPayload | UtilityPayload diff --git a/packages/shared/src/types/UtilityPayload.ts b/packages/shared/src/types/UtilityPayload.ts new file mode 100644 index 00000000..61b75542 --- /dev/null +++ b/packages/shared/src/types/UtilityPayload.ts @@ -0,0 +1,68 @@ +// CH 03 "Utilities" — an ambient dev instrument panel rendered as one rotating +// slot alongside news + markets. Unlike news/ticker, this payload is built +// LOCALLY by the daemon (never fetched from the API) from the telemetry Claude +// Code pipes to `distro statusline` on stdin plus cheap local probes (git, +// machine, service health). Every bucket is optional so an unavailable source +// is simply omitted rather than rendered as a fake zeroed gauge. + +// Claude session telemetry, sourced from the status-line stdin snapshot. +export interface UtilityAi { + // rolling rate-limit windows (0-100), with epoch-ms reset times when known + fiveHourPct?: number + fiveHourResetAt?: number + sevenDayPct?: number + sevenDayResetAt?: number + // context window + ctxPct?: number + ctxSize?: number + // session economics + costUsd?: number + cachePct?: number + // burn rate ($/min), derived from the rolling snapshot history + burnUsdPerMin?: number + // projected minutes until the nearest limit (5h or 7d) is hit at current burn + timeToLimitMin?: number + // identity / session deltas + model?: string + effort?: string + linesAdded?: number + linesRemoved?: number +} + +export interface UtilityGit { + branch?: string + ahead?: number + behind?: number + dirtyFiles?: number + uncommittedLines?: number + lastCommitAgeSec?: number +} + +export interface UtilityMachine { + cpuPct?: number // instantaneous % busy (sampled), not load average + memPct?: number // % used — real pressure (macOS memory_pressure / linux MemAvailable) + diskUsedPct?: number // % used (100 - free), so a fuller disk reads as a fuller bar +} + +export type AnthropicHealth = "ok" | "degraded" | "down" + +export interface UtilityHealth { + anthropic?: AnthropicHealth + apiLatencyMs?: number + online?: boolean +} + +// "full" shows the complete gauge grid; "complement" drops the gauges a user's +// own custom status line likely already shows (ctx/5h/7d/cost) and leads with +// the derived/extra stats instead. +export type UtilityLayout = "full" | "complement" + +export interface UtilityPayload { + kind: "utility" + generatedAt: number + layout: UtilityLayout + ai?: UtilityAi + git?: UtilityGit + machine?: UtilityMachine + health?: UtilityHealth +} diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index 8e687adf..5a930665 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -63,6 +63,12 @@ export interface SyncedPreferences { export interface LocalPreferences { // epoch ms; null = not muted. cleared when now >= muteUntil. muteUntil: number | null + // CH 03 Utilities: gate the locally-injected utility slot. Local-first + + // session-independent — not derived from a server subscription in v1. + utilitiesEnabled: boolean + // "auto" → "complement" when a custom status line was detected at install, + // else "full". Explicit values override the auto detection. + utilitiesLayout: "auto" | "full" | "complement" } export type DevdripPreferences = SyncedPreferences & LocalPreferences @@ -82,6 +88,15 @@ export interface Device { export type { NewsPayload } from "./NewsPayload.js" export type { TickerPayload, TickerStats } from "./TickerPayload.js" export type { SlotPayload, SlotKind, SlotLayout } from "./SlotPayload.js" +export type { + UtilityPayload, + UtilityLayout, + UtilityAi, + UtilityGit, + UtilityMachine, + UtilityHealth, + AnthropicHealth, +} from "./UtilityPayload.js" export type { WatchlistDto, WatchlistTickerDto, AssetClass } from "./WatchlistDto.js" export type { AlertDto, AlertScope, PendingAlert, AlertReplacement } from "./AlertDto.js" export type { ChannelDto } from "./ChannelDto.js" diff --git a/packages/shared/src/types/news.ts b/packages/shared/src/types/news.ts index a06ea4fa..58bdf0f2 100644 --- a/packages/shared/src/types/news.ts +++ b/packages/shared/src/types/news.ts @@ -18,4 +18,11 @@ export enum NewsTopic { } // Stable user-facing channel identifier. -export type ChannelKey = "tech" | "finance" | "crypto" | "ai-papers" | "design" | "gaming" +export type ChannelKey = + | "tech" + | "finance" + | "crypto" + | "ai-papers" + | "design" + | "gaming" + | "utilities"