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.
83 lines
3.0 KiB
JavaScript
83 lines
3.0 KiB
JavaScript
/**
|
|
* public/sw-push.js — Pure push notification helpers (no SW globals).
|
|
*
|
|
* This file is imported by public/sw.js (module service worker) and tested
|
|
* directly by test/sw-push.test.ts. It must never reference `self`, `clients`,
|
|
* or any other SW-specific global — pure functions only.
|
|
*
|
|
* Implements the §3.3 PushPayload contract (one shape; FE reads `cls`).
|
|
* SEC-M6: tag = sessionId so the browser deduplicates (no notification stacking).
|
|
* SEC-C1: capability token is forwarded in the decision body; Origin + token
|
|
* validation happens server-side at POST /hook/decision.
|
|
*/
|
|
|
|
/** @type {Record<string, string>} */
|
|
const TITLES = {
|
|
'needs-input': 'Approval Needed',
|
|
done: 'Task Complete',
|
|
stuck: 'Task Stuck',
|
|
}
|
|
|
|
/**
|
|
* Build a Notification title + options from an incoming push payload.
|
|
*
|
|
* @param {{ sessionId: string, toolName?: string, detail?: string, token?: string, cls: string }} payload
|
|
* @returns {{ title: string, options: Record<string, unknown> }}
|
|
*/
|
|
export function buildPushNotification(payload) {
|
|
const { sessionId, toolName, detail, token, cls } = payload
|
|
|
|
const title = TITLES[cls] ?? 'Web Terminal'
|
|
|
|
// Body: prefer explicit detail, fall back to toolName hint, else omit.
|
|
const body = detail ?? (toolName ? `Tool: ${toolName}` : undefined)
|
|
|
|
/** @type {Record<string, unknown>} */
|
|
const options = {
|
|
tag: sessionId, // SEC-M6: replaces any previous notification for this session
|
|
body,
|
|
data: { sessionId, token, cls },
|
|
requireInteraction: cls === 'needs-input',
|
|
// Allow / Deny actions only for needs-input (lock-screen approval, AC-A1.2/A1.3)
|
|
actions:
|
|
cls === 'needs-input'
|
|
? [
|
|
{ action: 'allow', title: 'Allow' },
|
|
{ action: 'deny', title: 'Deny' },
|
|
]
|
|
: [],
|
|
}
|
|
|
|
return { title, options }
|
|
}
|
|
|
|
/**
|
|
* Resolve a notificationclick event into an opaque result.
|
|
* The caller (sw.js) executes the result:
|
|
* kind='decision' → POST /hook/decision with result.body (SEC-C1)
|
|
* kind='focus' → openWindow / focus existing tab at result.url
|
|
*
|
|
* @param {{ data: { sessionId?: string, token?: string, cls?: string } | null }} notification
|
|
* @param {string | null | undefined} action — event.action from notificationclick
|
|
* @returns {{ kind: 'decision', body: string } | { kind: 'focus', url: string }}
|
|
*/
|
|
export function resolveNotificationClick(notification, action) {
|
|
const data = notification?.data ?? {}
|
|
const { sessionId = '', token } = /** @type {{ sessionId?: string, token?: string }} */ (data)
|
|
|
|
if (action === 'allow' || action === 'deny') {
|
|
// SEC-C1: include capability token so server can authenticate this decision.
|
|
// Origin header is added automatically by fetch() with credentials:'same-origin'.
|
|
return {
|
|
kind: 'decision',
|
|
body: JSON.stringify({ sessionId, decision: action, token }),
|
|
}
|
|
}
|
|
|
|
// Default (body click, empty string, undefined, null) → focus/open terminal session
|
|
return {
|
|
kind: 'focus',
|
|
url: `/?session=${encodeURIComponent(sessionId)}`,
|
|
}
|
|
}
|