Four small, high-delight features that turn passive capture into glanceable signals.
- Sync chip on project cards: ahead/behind vs upstream + last-commit time, folded
into the existing concurrent per-repo metadata pass (git rev-list --count
--left-right @{u}...HEAD + git log -1 --format=%ct; no upstream → undefined, no route).
- Cost budget guard: COST_BUDGET_USD env (0 = off); a per-session one-shot latch
(Session.budgetNotified, cost is monotonic so never re-armed) fires a single push
on threshold crossing in manager.handleStatusLine; the already-broadcast telemetry
frame carries the warn (tg-cost-warn styling derived from costUsd>=budget via
/config/ui — no new ServerMessage). web-push title added to sw-push.js.
- "While you were away" digest: GET /digest?since= → {finished, needsInput, stuck,
totalCostUsd, sessions[]} aggregate over manager.list(); FE banner on reconnect.
- Recent commits per project: src/http/git-log.ts (NUL-delimited git log → CommitInfo[]),
GET /projects/log?path= (isValidGitDir), textContent-inert render in project detail.
All git via execFile (no shell) + validated cwd; new routes read-only; commit
messages rendered via textContent. Verified: typecheck + build:web clean, 1904 pass
at --test-timeout=30000 (two default-5s failures are slow-sandbox real-subprocess
timeout flakes — the known ring-buffer test + a new real-git-clone sync test — not
logic regressions).
84 lines
3.0 KiB
JavaScript
84 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',
|
|
budget: 'Cost Budget Reached',
|
|
}
|
|
|
|
/**
|
|
* 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)}`,
|
|
}
|
|
}
|