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:
Yaojia Wang
2026-06-30 17:42:18 +02:00
parent 4f1d3ebc6b
commit d6809c65c4
54 changed files with 13171 additions and 200 deletions

131
src/http/statusline.ts Normal file
View 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 }),
};
}