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.
92 lines
2.8 KiB
JavaScript
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)
|
|
}),
|
|
)
|
|
}
|
|
})
|