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:
71
public/sw.js
71
public/sw.js
@@ -1,10 +1,16 @@
|
||||
/**
|
||||
* public/sw.js — minimal service worker (M4: PWA installability + offline shell).
|
||||
* public/sw.js — service worker (ES module; must be registered with {type:'module'}).
|
||||
*
|
||||
* Network-first so we never serve a stale build while online; falls back to the
|
||||
* cache when offline. Never intercepts the WebSocket (/term) or hook endpoints.
|
||||
* Network-first caching + A1 push notifications + notificationclick routing.
|
||||
* Pure push/notification helpers live in sw-push.js (no SW globals → testable).
|
||||
*
|
||||
* IMPORTANT: This file uses ES module `import` syntax. The SW registration in
|
||||
* public/main.ts must pass { type: 'module' }:
|
||||
* navigator.serviceWorker.register('./sw.js', { type: 'module' })
|
||||
*/
|
||||
|
||||
import { buildPushNotification, resolveNotificationClick } from './sw-push.js'
|
||||
|
||||
const CACHE = 'webterm-v1'
|
||||
|
||||
self.addEventListener('install', () => self.skipWaiting())
|
||||
@@ -14,7 +20,13 @@ self.addEventListener('fetch', (e) => {
|
||||
const req = e.request
|
||||
if (req.method !== 'GET') return
|
||||
const url = new URL(req.url)
|
||||
if (url.pathname === '/term' || url.pathname.startsWith('/hook')) return // WS / hooks
|
||||
// Bypass: WS upgrade, hook endpoints, and push subscription routes (A1)
|
||||
if (
|
||||
url.pathname === '/term' ||
|
||||
url.pathname.startsWith('/hook') ||
|
||||
url.pathname.startsWith('/push')
|
||||
)
|
||||
return
|
||||
|
||||
e.respondWith(
|
||||
fetch(req)
|
||||
@@ -26,3 +38,54 @@ self.addEventListener('fetch', (e) => {
|
||||
.catch(() => caches.match(req)),
|
||||
)
|
||||
})
|
||||
|
||||
/** A1: show a notification when a push arrives. */
|
||||
self.addEventListener('push', (e) => {
|
||||
let payload
|
||||
try {
|
||||
payload = e.data?.json()
|
||||
} catch {
|
||||
return // malformed push — ignore
|
||||
}
|
||||
if (!payload || typeof payload !== 'object') return
|
||||
|
||||
const { title, options } = buildPushNotification(payload)
|
||||
e.waitUntil(self.registration.showNotification(title, options))
|
||||
})
|
||||
|
||||
/**
|
||||
* A1: handle notification action clicks.
|
||||
* Allow/Deny → POST /hook/decision with capability token (SEC-C1).
|
||||
* Body click → focus / open the terminal session tab.
|
||||
*/
|
||||
self.addEventListener('notificationclick', (e) => {
|
||||
e.notification.close()
|
||||
const result = resolveNotificationClick(e.notification, e.action)
|
||||
|
||||
if (result.kind === 'decision') {
|
||||
// credentials:'same-origin' ensures Origin header is sent so the server
|
||||
// can validate it alongside the capability token (SEC-C1).
|
||||
e.waitUntil(
|
||||
fetch('/hook/decision', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'same-origin',
|
||||
body: result.body,
|
||||
}).catch((err) => {
|
||||
console.error('[sw] /hook/decision fetch failed', err)
|
||||
}),
|
||||
)
|
||||
} else {
|
||||
// Focus an existing window if available, otherwise open a new one.
|
||||
e.waitUntil(
|
||||
self.clients
|
||||
.matchAll({ type: 'window', includeUncontrolled: true })
|
||||
.then((clientList) => {
|
||||
for (const client of clientList) {
|
||||
if ('focus' in client) return client.focus()
|
||||
}
|
||||
return self.clients.openWindow(result.url)
|
||||
}),
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user