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

View File

@@ -9,7 +9,7 @@
*/
import { Terminal } from '@xterm/xterm'
import type { LiveSessionInfo } from '../src/types.js'
import type { LiveSessionInfo, StatusTelemetry, ClaudeStatus } from '../src/types.js'
/** Shape of GET /live-sessions/:id/preview. */
export interface SessionPreview {
@@ -44,9 +44,13 @@ export function relTime(ms: number): string {
return `${Math.floor(s / 86400)}d`
}
/** Human label for a Claude activity status. */
/** Human label for a Claude activity status. Supports all ClaudeStatus values including 'stuck'. */
export function statusText(s: LiveSessionInfo['status']): string {
return s === 'working' ? '⚙ working' : s === 'waiting' ? '⏳ waiting' : s === 'idle' ? '✓ idle' : '·'
if (s === 'working') return '⚙ working'
if (s === 'waiting') return '⏳ waiting'
if (s === 'idle') return '✓ idle'
if (s === 'stuck') return '⚠ stuck'
return '·'
}
/** Display name for a session: last cwd segment, else the short id. */
@@ -178,3 +182,86 @@ export async function fetchLiveSessions(): Promise<LiveSessionInfo[]> {
return []
}
}
/**
* Render a telemetry gauge into `container` (clears first).
*
* Shows: context-usage bar (>80% = warning colour), $cost chip, model chip,
* and a PR badge. When `telemetry.at` is older than `staleTtlMs` the container
* receives the class `tg-stale` so CSS can grey it out.
*
* Security: all telemetry strings are set via `textContent` (SEC-H5); the PR
* link href is only set when `url.protocol === 'https:'` (SEC-L5).
* Zero `innerHTML` anywhere.
*/
export function renderTelemetryGauge(
container: HTMLElement,
telemetry: StatusTelemetry | null,
staleTtlMs: number,
): void {
// Clear existing children
while (container.firstChild) container.removeChild(container.firstChild)
if (!telemetry) {
container.classList.remove('tg-stale')
return
}
const isStale = Date.now() - telemetry.at > staleTtlMs
if (isStale) container.classList.add('tg-stale')
else container.classList.remove('tg-stale')
// Context-usage bar
if (telemetry.contextUsedPct !== undefined) {
const bar = el('div', 'tg-ctx-bar')
const fill = el('div', 'tg-ctx-fill')
fill.style.width = `${Math.min(100, telemetry.contextUsedPct)}%`
if (telemetry.contextUsedPct > 80) fill.classList.add('tg-ctx-warn')
bar.append(fill, el('span', 'tg-ctx-label', `ctx ${Math.round(telemetry.contextUsedPct)}%`))
container.append(bar)
}
// Cost chip
if (telemetry.costUsd !== undefined) {
container.append(el('span', 'tg-cost', `$${telemetry.costUsd.toFixed(4)}`))
}
// Model chip
if (telemetry.model !== undefined) {
container.append(el('span', 'tg-model', telemetry.model))
}
// PR badge
if (telemetry.pr !== undefined) {
const badge = el('span', 'tg-pr')
const link = el('a', 'tg-pr-link')
link.textContent = `PR #${telemetry.pr.number}`
// SEC-L5: only set href for https URLs
try {
const parsed = new URL(telemetry.pr.url)
if (parsed.protocol === 'https:') {
link.href = telemetry.pr.url
link.target = '_blank'
link.rel = 'noopener noreferrer'
}
} catch {
// Invalid URL — leave href unset
}
badge.append(link)
if (telemetry.pr.reviewState !== undefined) {
badge.append(el('span', 'tg-pr-state', telemetry.pr.reviewState))
}
container.append(badge)
}
}
/**
* Render a Claude status badge into `container` (clears first).
* 'stuck' shows the ⚠ warning symbol (A5). Uses only textContent (SEC-H5).
*/
export function renderStatusBadge(container: HTMLElement, status: ClaudeStatus): void {
while (container.firstChild) container.removeChild(container.firstChild)
container.append(el('span', `sb-badge sb-${status}`, statusText(status)))
}