feat(v0.7): Walk-away Workbench (Band A + B) — multi-agent parallel build
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.
This commit is contained in:
131
src/http/statusline.ts
Normal file
131
src/http/statusline.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* 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 }),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user