Implements docs/PLAN_WALKAWAY_WORKBENCH.md (27 tasks, waves R0→W0→W1×14→W2→W3→W4)
via module-builder agents. 23 tasks built, 0 blocked.
Band A (finish the walk-away loop): A1 Web Push + lock-screen approve/deny
(web-push dep), A2 voice dictation, A3 quick-reply chips + saved-prompt palette,
A4 activity timeline, A5 stuck/idle alert.
Band B (workbench above the terminal): B1 read-only git diff viewer, B2 statusLine
telemetry → per-tab cost/context/PR gauges, B3 create git worktrees from the UI,
B4 plan-mode / permission-mode relay.
New: src/push/* (subscription store + VAPID push), src/http/{diff,statusline}.ts,
src/session/timeline.ts, public/{diff,timeline,quickreply,push-ui,...}.ts, sw-push,
statusLine script; extends hook intake, manager, server routes (Origin/CSRF guards
+ per-IP rate limits on state-changing ones; loopback-only ingest), terminal-session,
tabs, projects detail, service worker, setup-hooks (statusLine + ntfy bridge).
Orchestrator reconciled a W0 contract gap: added the 21 v0.7 Config fields to the
Config interface in types.ts (T-types had left them only in config.ts's return).
Verified: both tsc clean, full vitest + coverage 91.4/84.1/92.2/93.4 (≥80×4),
build:web OK. W4 review: no CRITICAL/HIGH; all security checks pass. Follow-ups
(non-blocking): move approve.mode validation into parseClientMessage, drop CSP
ws:/wss: wildcard, validate worktree base ref, +2 targeted tests.
187 lines
6.3 KiB
JavaScript
Executable File
187 lines
6.3 KiB
JavaScript
Executable File
#!/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<string>}
|
|
*/
|
|
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<string, unknown>} */
|
|
const obj = /** @type {Record<string, unknown>} */ (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<string, unknown>} */ (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<string, unknown>} */ (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<string, unknown>} */ (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:<port>/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<void>}
|
|
*/
|
|
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)
|
|
}
|
|
})()
|
|
}
|