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.
132 lines
5.1 KiB
TypeScript
132 lines
5.1 KiB
TypeScript
/**
|
|
* src/http/statusline.ts — parse a Claude Code statusLine JSON body (B2).
|
|
*
|
|
* Pure function, never throws. Maps the statusLine stdin JSON (R0-confirmed
|
|
* field names, see StatusTelemetry comment in types.ts) into a StatusTelemetry.
|
|
*
|
|
* Security: SEC-M7 — unknown input narrowed field-by-field; string lengths capped.
|
|
*
|
|
* Field mapping (JSON → StatusTelemetry):
|
|
* context_window.used_percentage → contextUsedPct
|
|
* cost.total_cost_usd → costUsd
|
|
* cost.total_lines_added → linesAdded
|
|
* cost.total_lines_removed → linesRemoved
|
|
* model.display_name → model
|
|
* effort.level → effort
|
|
* pr → pr (same shape, required: number+url)
|
|
* rate_limits → rate (fiveHourPct, sevenDayPct)
|
|
*/
|
|
|
|
import type { StatusTelemetry } from '../types.js';
|
|
|
|
// String length limits (SEC-M7 — prevent DoS through overly long strings)
|
|
const MODEL_MAX_LEN = 200;
|
|
const EFFORT_MAX_LEN = 100;
|
|
const URL_MAX_LEN = 2000;
|
|
const REVIEW_STATE_MAX_LEN = 100;
|
|
|
|
/**
|
|
* Parse the body of a POST /hook/status request into a StatusTelemetry.
|
|
*
|
|
* @param body - raw untrusted JSON body (unknown type)
|
|
* @returns StatusTelemetry with at always set to Date.now(), or null when
|
|
* body is not a plain object. Missing/dirty fields → undefined.
|
|
* Never throws.
|
|
*/
|
|
export function parseStatusLine(body: unknown): StatusTelemetry | null {
|
|
// Non-objects (including null and arrays) → null
|
|
if (body === null || typeof body !== 'object' || Array.isArray(body)) return null;
|
|
|
|
const b = body as Record<string, unknown>;
|
|
|
|
const contextUsedPct = safeFiniteNumber(nestedValue(b, 'context_window', 'used_percentage'));
|
|
|
|
const costObj = asObjectOrNull(b['cost']);
|
|
const costUsd = safeFiniteNumber(costObj?.['total_cost_usd']);
|
|
const linesAdded = safeFiniteNumber(costObj?.['total_lines_added']);
|
|
const linesRemoved = safeFiniteNumber(costObj?.['total_lines_removed']);
|
|
|
|
const modelObj = asObjectOrNull(b['model']);
|
|
const model = safeBoundedString(modelObj?.['display_name'], MODEL_MAX_LEN);
|
|
|
|
const effortObj = asObjectOrNull(b['effort']);
|
|
const effort = safeBoundedString(effortObj?.['level'], EFFORT_MAX_LEN);
|
|
|
|
const pr = parsePr(b['pr']);
|
|
const rate = parseRate(b['rate_limits']);
|
|
|
|
return {
|
|
at: Date.now(),
|
|
...(contextUsedPct !== undefined && { contextUsedPct }),
|
|
...(costUsd !== undefined && { costUsd }),
|
|
...(linesAdded !== undefined && { linesAdded }),
|
|
...(linesRemoved !== undefined && { linesRemoved }),
|
|
...(model !== undefined && { model }),
|
|
...(effort !== undefined && { effort }),
|
|
...(pr !== undefined && { pr }),
|
|
...(rate !== undefined && { rate }),
|
|
};
|
|
}
|
|
|
|
// ─── Private helpers ─────────────────────────────────────────────────────────
|
|
|
|
/** Get obj[key1][key2], or undefined if either level is not a plain object. */
|
|
function nestedValue(obj: Record<string, unknown>, key1: string, key2: string): unknown {
|
|
const inner = asObjectOrNull(obj[key1]);
|
|
return inner !== null ? inner[key2] : undefined;
|
|
}
|
|
|
|
/** Return v if it is a finite number; otherwise undefined. */
|
|
function safeFiniteNumber(v: unknown): number | undefined {
|
|
return typeof v === 'number' && Number.isFinite(v) ? v : undefined;
|
|
}
|
|
|
|
/** Return v if it is a string within maxLen; otherwise undefined. */
|
|
function safeBoundedString(v: unknown, maxLen: number): string | undefined {
|
|
if (typeof v !== 'string') return undefined;
|
|
return v.length <= maxLen ? v : undefined;
|
|
}
|
|
|
|
/** Cast v to a Record if it is a non-null, non-array object; otherwise null. */
|
|
function asObjectOrNull(v: unknown): Record<string, unknown> | null {
|
|
if (v === null || typeof v !== 'object' || Array.isArray(v)) return null;
|
|
return v as Record<string, unknown>;
|
|
}
|
|
|
|
/** Parse the pr field. Returns undefined unless both number (finite) and url (bounded) are valid. */
|
|
function parsePr(
|
|
v: unknown,
|
|
): { number: number; url: string; reviewState?: string } | undefined {
|
|
const obj = asObjectOrNull(v);
|
|
if (obj === null) return undefined;
|
|
|
|
const num = safeFiniteNumber(obj['number']);
|
|
if (num === undefined) return undefined;
|
|
|
|
const url = safeBoundedString(obj['url'], URL_MAX_LEN);
|
|
if (url === undefined) return undefined;
|
|
|
|
const reviewState = safeBoundedString(obj['reviewState'], REVIEW_STATE_MAX_LEN);
|
|
return {
|
|
number: num,
|
|
url,
|
|
...(reviewState !== undefined && { reviewState }),
|
|
};
|
|
}
|
|
|
|
/** Parse rate_limits → rate. Returns undefined if rate_limits is not an object.
|
|
* Returns a (possibly empty) object with undefined sub-fields when the object
|
|
* exists but sub-field values fail Number.isFinite validation. */
|
|
function parseRate(v: unknown): { fiveHourPct?: number; sevenDayPct?: number } | undefined {
|
|
const obj = asObjectOrNull(v);
|
|
if (obj === null) return undefined;
|
|
|
|
const fiveHourPct = safeFiniteNumber(obj['fiveHourPct']);
|
|
const sevenDayPct = safeFiniteNumber(obj['sevenDayPct']);
|
|
|
|
return {
|
|
...(fiveHourPct !== undefined && { fiveHourPct }),
|
|
...(sevenDayPct !== undefined && { sevenDayPct }),
|
|
};
|
|
}
|