#!/usr/bin/env node /** * scripts/statusline.mjs — Claude Code statusLine callback for web-terminal (B2). * * Claude Code pipes statusLine JSON to stdin when this script is configured * as a statusLine handler. The script: * 1. POSTs the raw JSON to $WEBTERM_STATUSLINE_URL with X-Webterm-Session header * (server parses + broadcasts to attached clients via parseStatusLine) * 2. Prints a one-line summary (ctx% · $cost · model) to stdout for Claude's status bar * * Outside web-terminal ($WEBTERM_STATUSLINE_URL not set) → prints summary only, no POST. * Never throws; all network errors are silently absorbed. * * Field mapping from raw statusLine JSON (R0-confirmed, see src/types.ts StatusTelemetry): * context_window.used_percentage → contextUsedPct * cost.total_cost_usd → costUsd * model.display_name (or model) → model * * Installed via scripts/setup-hooks.mjs (T-hooks-installer). * Idempotent marker: WEBTERM_STATUSLINE_URL env var set by spawn env (T-spawn-env). */ import { fileURLToPath } from 'node:url' /** Safety cap on stdin reads (64 KB). */ const MAX_STDIN_BYTES = 64 * 1024 /** Max length for model display name (prevents runaway strings). */ const MAX_MODEL_LEN = 100 /** * Read all stdin into a string (up to maxBytes). Never throws. * * @param {number} [maxBytes] * @returns {Promise} */ export async function readStdin(maxBytes = MAX_STDIN_BYTES) { return new Promise((resolve) => { /** @type {Buffer[]} */ const chunks = [] let total = 0 process.stdin.on('data', (/** @type {Buffer} */ chunk) => { total += chunk.length if (total <= maxBytes) chunks.push(chunk) }) process.stdin.on('end', () => resolve(Buffer.concat(chunks).toString('utf8'))) process.stdin.on('error', () => resolve('')) }) } /** * Parse the raw Claude Code statusLine JSON into display fields. * Tolerant: missing/invalid fields → undefined, never throws. * * @param {string} raw - raw stdin JSON string * @returns {{ contextUsedPct?: number; costUsd?: number; model?: string } | null} * Returns null for unparseable or non-object JSON; returns object (with possible * undefined fields) for valid JSON objects. */ export function parseRaw(raw) { /** @type {unknown} */ let json try { json = JSON.parse(raw) } catch { return null } if (typeof json !== 'object' || json === null || Array.isArray(json)) return null /** @type {Record} */ const obj = /** @type {Record} */ (json) /** * Extract a finite number from an unknown value. * @param {unknown} v * @returns {number | undefined} */ const safeNum = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : undefined) // context_window.used_percentage const ctxWin = obj['context_window'] != null && typeof obj['context_window'] === 'object' && !Array.isArray(obj['context_window']) ? /** @type {Record} */ (obj['context_window']) : null const contextUsedPct = ctxWin ? safeNum(ctxWin['used_percentage']) : undefined // cost.total_cost_usd const costObj = obj['cost'] != null && typeof obj['cost'] === 'object' && !Array.isArray(obj['cost']) ? /** @type {Record} */ (obj['cost']) : null const costUsd = costObj ? safeNum(costObj['total_cost_usd']) : undefined // model.display_name OR model (string fallback) const rawModel = obj['model'] != null && typeof obj['model'] === 'object' && !Array.isArray(obj['model']) ? /** @type {Record} */ (obj['model'])['display_name'] : obj['model'] const model = typeof rawModel === 'string' && rawModel.length > 0 ? rawModel.slice(0, MAX_MODEL_LEN) : undefined return { contextUsedPct, costUsd, model } } /** * Build the one-line summary for Claude's status bar. * Format: "ctx:45% · $0.53 · claude-opus-4-5" * Returns empty string if no fields are available. * * @param {{ contextUsedPct?: number; costUsd?: number; model?: string } | null} parsed * @returns {string} */ export function buildSummary(parsed) { if (!parsed) return '' /** @type {string[]} */ const parts = [] if (typeof parsed.contextUsedPct === 'number') { parts.push(`ctx:${Math.round(parsed.contextUsedPct)}%`) } if (typeof parsed.costUsd === 'number') { parts.push(`$${parsed.costUsd.toFixed(2)}`) } if (typeof parsed.model === 'string' && parsed.model.length > 0) { parts.push(parsed.model) } return parts.join(' · ') } /** * POST the raw JSON body to the statusLine URL with the session header. * Resolves when complete or on any error. Never throws. * Uses AbortController to enforce a deadline. * * @param {string} url - $WEBTERM_STATUSLINE_URL (loopback http://127.0.0.1:/hook/status) * @param {string} sessionId - $WEBTERM_SESSION (identifies the Claude session) * @param {string} body - raw stdin JSON to forward verbatim * @param {number} [timeoutMs] - abort after this many ms (default 5000) * @returns {Promise} */ export async function postToServer(url, sessionId, body, timeoutMs = 5000) { const controller = new AbortController() const timer = setTimeout(() => controller.abort(), timeoutMs) try { await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Webterm-Session': sessionId, }, body, signal: controller.signal, }) } catch { // silent: server down, wrong URL, timeout, outside web-terminal — all absorbed } finally { clearTimeout(timer) } } // ── Main execution (only when run directly, not when imported for testing) ── if (process.argv[1] === fileURLToPath(import.meta.url)) { void (async () => { const raw = await readStdin() const parsed = parseRaw(raw) const summary = buildSummary(parsed) // Print summary to stdout — Claude uses this as the status line text. // Empty summary (no data or unparseable) → print nothing (Claude keeps its default). if (summary) process.stdout.write(summary + '\n') // POST to server only when inside web-terminal (env var injected by spawn env). const url = process.env['WEBTERM_STATUSLINE_URL'] const sessionId = process.env['WEBTERM_SESSION'] ?? '' if (url) { await postToServer(url, sessionId, raw) } })() }