Files
web-terminal/public/sw.js
Yaojia Wang d6809c65c4 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.
2026-06-30 17:42:18 +02:00

92 lines
2.8 KiB
JavaScript

/**
* public/sw.js — service worker (ES module; must be registered with {type:'module'}).
*
* 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())
self.addEventListener('activate', (e) => e.waitUntil(self.clients.claim()))
self.addEventListener('fetch', (e) => {
const req = e.request
if (req.method !== 'GET') return
const url = new URL(req.url)
// 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)
.then((res) => {
const copy = res.clone()
caches.open(CACHE).then((c) => c.put(req, copy))
return res
})
.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)
}),
)
}
})