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

@@ -13,7 +13,14 @@ import type { ITheme } from '@xterm/xterm'
import { FitAddon } from '@xterm/addon-fit'
import { SearchAddon } from '@xterm/addon-search'
import { WebLinksAddon } from '@xterm/addon-web-links'
import type { ClaudeStatus, ClientMessage, ServerMessage } from '../src/types.js'
import type {
ClaudeStatus,
ClientMessage,
PermissionGate,
PermissionMode,
ServerMessage,
StatusTelemetry,
} from '../src/types.js'
import { folderFromTitle, cwdFromOsc7 } from './title-util.js'
// Delay after the shell is ready before typing a session's initial command
@@ -56,6 +63,9 @@ export interface TerminalSessionOpts {
onStatus?: (status: SessionStatus) => void
/** Optional: fired when Claude Code activity changes (from a hook, H2). */
onClaudeStatus?: (status: ClaudeStatus, detail?: string) => void
/** Optional: fired when new statusLine telemetry arrives (B2). Single source of
* truth — T-tabs reads session.telemetry via the getter, not its own copy. */
onTelemetry?: (telemetry: StatusTelemetry) => void
/** Optional: spawn a NEW session in this directory ("new tab here", M6). */
cwd?: string
/** Optional: type this once the new session's shell is ready (O2 resume). */
@@ -74,6 +84,7 @@ export class TerminalSession {
private readonly onTitle: ((title: string) => void) | undefined
private readonly onStatus: ((status: SessionStatus) => void) | undefined
private readonly onClaudeStatus: ((status: ClaudeStatus, detail?: string) => void) | undefined
private readonly onTelemetry: ((telemetry: StatusTelemetry) => void) | undefined
private readonly spawnCwd: string | undefined
private readonly initialInput: string | undefined
private initialSent = false
@@ -82,6 +93,8 @@ export class TerminalSession {
private cwdValue: string | null = null
private pendingApprovalValue = false
private pendingToolValue: string | undefined = undefined
private telemetryValue: StatusTelemetry | null = null
private pendingGateValue: PermissionGate | null = null
private ws: WebSocket | null = null
private sessionId: string | null
@@ -102,6 +115,7 @@ export class TerminalSession {
this.onTitle = opts.onTitle
this.onStatus = opts.onStatus
this.onClaudeStatus = opts.onClaudeStatus
this.onTelemetry = opts.onTelemetry
this.spawnCwd = opts.cwd
this.initialInput = opts.initialInput
@@ -163,6 +177,18 @@ export class TerminalSession {
return this.pendingToolValue
}
/** Latest statusLine telemetry (B2). Single source of truth (review #15):
* T-tabs reads this getter; TabEntry does NOT cache its own copy. */
get telemetry(): StatusTelemetry | null {
return this.telemetryValue
}
/** The gate kind from the last pending status frame (B4): 'plan' or 'tool'.
* Null when no approval is held or the last status had no gate. */
get pendingGate(): PermissionGate | null {
return this.pendingGateValue
}
private setStatus(s: SessionStatus): void {
this.statusValue = s
this.onStatus?.(s)
@@ -270,9 +296,15 @@ export class TerminalSession {
this.claudeStatusValue = msg.status
this.pendingApprovalValue = msg.pending === true
this.pendingToolValue = msg.pending === true ? msg.detail : undefined
this.pendingGateValue = msg.gate ?? null
this.onClaudeStatus?.(msg.status, msg.detail)
break
}
case 'telemetry': {
this.telemetryValue = msg.telemetry
this.onTelemetry?.(msg.telemetry)
break
}
case 'exit': {
this.setStatus('exited')
const reason = msg.reason ? ` (${msg.reason})` : ''
@@ -294,6 +326,14 @@ export class TerminalSession {
})
break
}
default: {
// Compile-time exhaustiveness: TypeScript narrows msg to 'never' here
// once all ServerMessage variants are handled. Silently ignored at runtime
// (future server additions won't crash older clients).
const _exhaustive: never = msg
void _exhaustive
break
}
}
}
@@ -318,10 +358,12 @@ export class TerminalSession {
this.sendMsg({ type: 'input', data })
}
/** Resolve a held PermissionRequest (H3). */
approve(): void {
/** Resolve a held PermissionRequest (H3). Optionally relay a permission-mode
* change (B4): mode is included only when explicitly provided so the server
* can distinguish "approve with default" from "approve, keep existing mode". */
approve(mode?: PermissionMode): void {
this.pendingApprovalValue = false
this.sendMsg({ type: 'approve' })
this.sendMsg({ type: 'approve', ...(mode !== undefined ? { mode } : {}) })
}
reject(): void {
this.pendingApprovalValue = false