chore: bump version to 0.1656

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
arkon
2026-02-27 08:28:30 +01:00
co-authored by Claude Opus 4.6
parent 8fc0dc8c2e
commit c668f9bae1
12 changed files with 792 additions and 22 deletions
+179
View File
@@ -0,0 +1,179 @@
/**
* @fileoverview VAPID key auto-generation and push subscription CRUD.
*
* Persists VAPID keys to ~/.codeman/push-keys.json and subscriptions
* to ~/.codeman/push-subscriptions.json. Debounced saves prevent
* excessive disk I/O during rapid subscription updates.
*/
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
import { join } from 'node:path';
import { homedir } from 'node:os';
import webpush from 'web-push';
import type { VapidKeys, PushSubscriptionRecord } from './types.js';
const DATA_DIR = join(homedir(), '.codeman');
const KEYS_FILE = join(DATA_DIR, 'push-keys.json');
const SUBS_FILE = join(DATA_DIR, 'push-subscriptions.json');
const SAVE_DEBOUNCE_MS = 500;
export class PushSubscriptionStore {
private vapidKeys: VapidKeys | null = null;
private subscriptions: Map<string, PushSubscriptionRecord> = new Map();
private saveTimer: NodeJS.Timeout | null = null;
private _disposed = false;
constructor() {
this.loadSubscriptions();
}
get isDisposed(): boolean {
return this._disposed;
}
/** Get or generate VAPID keys */
getVapidKeys(): VapidKeys {
if (this.vapidKeys) return this.vapidKeys;
// Try loading from disk
if (existsSync(KEYS_FILE)) {
try {
const raw = readFileSync(KEYS_FILE, 'utf-8');
this.vapidKeys = JSON.parse(raw) as VapidKeys;
return this.vapidKeys;
} catch {
// Regenerate on parse error
}
}
// Generate new keys
const keys = webpush.generateVAPIDKeys();
this.vapidKeys = {
publicKey: keys.publicKey,
privateKey: keys.privateKey,
generatedAt: Date.now(),
};
// Persist
mkdirSync(DATA_DIR, { recursive: true });
writeFileSync(KEYS_FILE, JSON.stringify(this.vapidKeys, null, 2));
return this.vapidKeys;
}
/** Get the public VAPID key for client subscription */
getPublicKey(): string {
return this.getVapidKeys().publicKey;
}
/** Register or update a push subscription (deduplicates by endpoint) */
addSubscription(sub: Omit<PushSubscriptionRecord, 'lastUsedAt'>): PushSubscriptionRecord {
// Check for existing subscription with same endpoint
for (const [existingId, existing] of this.subscriptions) {
if (existing.endpoint === sub.endpoint) {
// Update existing
const updated: PushSubscriptionRecord = {
...existing,
keys: sub.keys,
userAgent: sub.userAgent,
lastUsedAt: Date.now(),
pushPreferences: sub.pushPreferences,
};
this.subscriptions.set(existingId, updated);
this.scheduleSave();
return updated;
}
}
// New subscription
const record: PushSubscriptionRecord = {
...sub,
lastUsedAt: Date.now(),
};
this.subscriptions.set(record.id, record);
this.scheduleSave();
return record;
}
/** Update push preferences for a subscription */
updatePreferences(id: string, preferences: Record<string, boolean>): PushSubscriptionRecord | null {
const sub = this.subscriptions.get(id);
if (!sub) return null;
sub.pushPreferences = preferences;
sub.lastUsedAt = Date.now();
this.scheduleSave();
return sub;
}
/** Remove a subscription */
removeSubscription(id: string): boolean {
const deleted = this.subscriptions.delete(id);
if (deleted) this.scheduleSave();
return deleted;
}
/** Remove subscription by endpoint (used for auto-cleanup of expired subs) */
removeByEndpoint(endpoint: string): void {
for (const [id, sub] of this.subscriptions) {
if (sub.endpoint === endpoint) {
this.subscriptions.delete(id);
this.scheduleSave();
return;
}
}
}
/** Get all subscriptions */
getAll(): PushSubscriptionRecord[] {
return Array.from(this.subscriptions.values());
}
/** Get a single subscription by ID */
get(id: string): PushSubscriptionRecord | null {
return this.subscriptions.get(id) ?? null;
}
/** Load subscriptions from disk */
private loadSubscriptions(): void {
if (!existsSync(SUBS_FILE)) return;
try {
const raw = readFileSync(SUBS_FILE, 'utf-8');
const arr = JSON.parse(raw) as PushSubscriptionRecord[];
for (const sub of arr) {
this.subscriptions.set(sub.id, sub);
}
} catch {
// Start fresh on parse error
}
}
/** Schedule a debounced save */
private scheduleSave(): void {
if (this._disposed) return;
if (this.saveTimer) clearTimeout(this.saveTimer);
this.saveTimer = setTimeout(() => {
this.flushSave();
}, SAVE_DEBOUNCE_MS);
}
/** Immediately persist subscriptions to disk */
private flushSave(): void {
try {
mkdirSync(DATA_DIR, { recursive: true });
writeFileSync(SUBS_FILE, JSON.stringify(Array.from(this.subscriptions.values()), null, 2));
} catch {
// Ignore write errors
}
}
/** Clean shutdown */
dispose(): void {
if (this._disposed) return;
this._disposed = true;
if (this.saveTimer) {
clearTimeout(this.saveTimer);
this.saveTimer = null;
}
// Final flush
this.flushSave();
}
}
+20
View File
@@ -1406,6 +1406,26 @@ export interface PaneInfo {
export type { PlanItem } from './plan-orchestrator.js';
// ========== Web Push Types ==========
/** A registered push subscription */
export interface PushSubscriptionRecord {
id: string;
endpoint: string;
keys: { p256dh: string; auth: string };
userAgent: string;
createdAt: number;
lastUsedAt: number;
pushPreferences: Record<string, boolean>;
}
/** VAPID key pair for Web Push */
export interface VapidKeys {
publicKey: string;
privateKey: string;
generatedAt: number;
}
// ========== Agent Teams Types ==========
/** Team configuration from ~/.claude/teams/{name}/config.json */
+208 -12
View File
@@ -1,5 +1,21 @@
// Codeman App - Tab-based Terminal UI
// ============================================================================
// Web Push Utilities
// ============================================================================
/** Convert a base64-encoded VAPID key to Uint8Array for pushManager.subscribe() */
function urlBase64ToUint8Array(base64String) {
const padding = '='.repeat((4 - base64String.length % 4) % 4);
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
const rawData = atob(base64);
const outputArray = new Uint8Array(rawData.length);
for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i);
}
return outputArray;
}
// ============================================================================
// Constants
// ============================================================================
@@ -2237,16 +2253,16 @@ class NotificationManager {
loadPreferences() {
const defaultEventTypes = {
permission_prompt: { enabled: true, browser: true, audio: true },
elicitation_dialog: { enabled: true, browser: true, audio: true },
idle_prompt: { enabled: true, browser: true, audio: false },
stop: { enabled: true, browser: false, audio: false },
session_error: { enabled: true, browser: true, audio: false },
respawn_cycle: { enabled: true, browser: false, audio: false },
token_milestone: { enabled: true, browser: false, audio: false },
ralph_complete: { enabled: true, browser: true, audio: true },
subagent_spawn: { enabled: false, browser: false, audio: false },
subagent_complete: { enabled: false, browser: false, audio: false },
permission_prompt: { enabled: true, browser: true, audio: true, push: false },
elicitation_dialog: { enabled: true, browser: true, audio: true, push: false },
idle_prompt: { enabled: true, browser: true, audio: false, push: false },
stop: { enabled: true, browser: false, audio: false, push: false },
session_error: { enabled: true, browser: true, audio: false, push: false },
respawn_cycle: { enabled: true, browser: false, audio: false, push: false },
token_milestone: { enabled: true, browser: false, audio: false, push: false },
ralph_complete: { enabled: true, browser: true, audio: true, push: false },
subagent_spawn: { enabled: false, browser: false, audio: false, push: false },
subagent_complete: { enabled: false, browser: false, audio: false, push: false },
};
// Device-specific defaults: mobile has notifications disabled by default
@@ -2262,7 +2278,7 @@ class NotificationManager {
muteInfo: false,
// Per-event-type preferences
eventTypes: defaultEventTypes,
_version: 3,
_version: 4,
};
try {
const storageKey = this.getStorageKey();
@@ -2280,6 +2296,18 @@ class NotificationManager {
prefs._version = 3;
localStorage.setItem(storageKey, JSON.stringify(prefs));
}
// Migrate: v3 -> v4 adds push field to all eventTypes
if (prefs._version < 4) {
if (prefs.eventTypes) {
for (const key of Object.keys(prefs.eventTypes)) {
if (prefs.eventTypes[key] && prefs.eventTypes[key].push === undefined) {
prefs.eventTypes[key].push = false;
}
}
}
prefs._version = 4;
localStorage.setItem(storageKey, JSON.stringify(prefs));
}
// Merge with defaults to ensure all eventTypes exist
return {
...defaults,
@@ -2875,6 +2903,8 @@ class CodemanApp {
if (this._initGeneration === 0) this.loadState();
}, 3000);
});
// Register service worker for push notifications
this.registerServiceWorker();
// Share a single settings fetch between both consumers
const settingsPromise = fetch('/api/settings').then(r => r.ok ? r.json() : null).catch(() => null);
this.loadQuickStartCases(null, settingsPromise);
@@ -11424,6 +11454,149 @@ class CodemanApp {
});
}
// ========== Web Push ==========
registerServiceWorker() {
if (!('serviceWorker' in navigator)) return;
navigator.serviceWorker.register('/sw.js').then((reg) => {
this._swRegistration = reg;
// Listen for messages from service worker (notification clicks)
navigator.serviceWorker.addEventListener('message', (event) => {
if (event.data?.type === 'notification-click') {
const { sessionId } = event.data;
if (sessionId && this.sessions.has(sessionId)) {
this.selectSession(sessionId);
}
window.focus();
}
});
// Check if already subscribed
reg.pushManager.getSubscription().then((sub) => {
if (sub) {
this._pushSubscription = sub;
this._updatePushUI(true);
}
});
}).catch(() => {
// Service worker registration failed (likely not HTTPS)
});
}
async subscribeToPush() {
if (!this._swRegistration) {
this.showToast('Service worker not available. HTTPS or localhost required.', 'error');
return;
}
try {
// Get VAPID public key from server
const keyRes = await fetch('/api/push/vapid-key');
const keyData = await keyRes.json();
if (!keyData.success) throw new Error('Failed to get VAPID key');
const applicationServerKey = urlBase64ToUint8Array(keyData.data.publicKey);
const subscription = await this._swRegistration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey,
});
// Send subscription to server
const subJson = subscription.toJSON();
const res = await fetch('/api/push/subscribe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
endpoint: subJson.endpoint,
keys: subJson.keys,
userAgent: navigator.userAgent,
pushPreferences: this._buildPushPreferences(),
}),
});
const data = await res.json();
if (!data.success) throw new Error('Failed to register subscription');
this._pushSubscription = subscription;
this._pushSubscriptionId = data.data.id;
localStorage.setItem('codeman-push-subscription-id', data.data.id);
this._updatePushUI(true);
this.showToast('Push notifications enabled', 'success');
} catch (err) {
this.showToast('Push subscription failed: ' + (err.message || err), 'error');
}
}
async unsubscribeFromPush() {
try {
if (this._pushSubscription) {
await this._pushSubscription.unsubscribe();
}
const subId = this._pushSubscriptionId || localStorage.getItem('codeman-push-subscription-id');
if (subId) {
await fetch(`/api/push/subscribe/${subId}`, { method: 'DELETE' }).catch(() => {});
}
this._pushSubscription = null;
this._pushSubscriptionId = null;
localStorage.removeItem('codeman-push-subscription-id');
this._updatePushUI(false);
this.showToast('Push notifications disabled', 'success');
} catch (err) {
this.showToast('Failed to unsubscribe: ' + (err.message || err), 'error');
}
}
async togglePushSubscription() {
if (this._pushSubscription) {
await this.unsubscribeFromPush();
} else {
await this.subscribeToPush();
}
}
/** Sync push preferences to server */
async _syncPushPreferences() {
const subId = this._pushSubscriptionId || localStorage.getItem('codeman-push-subscription-id');
if (!subId) return;
try {
await fetch(`/api/push/subscribe/${subId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ pushPreferences: this._buildPushPreferences() }),
});
} catch {
// Silently fail — prefs saved locally, will sync on next subscribe
}
}
/** Build push preferences object from current event type checkboxes */
_buildPushPreferences() {
const prefs = {};
const eventMap = {
'hook:permission_prompt': 'eventPermissionPush',
'hook:elicitation_dialog': 'eventQuestionPush',
'hook:idle_prompt': 'eventIdlePush',
'hook:stop': 'eventStopPush',
'respawn:blocked': 'eventRespawnPush',
'session:ralphCompletionDetected': 'eventRalphPush',
};
for (const [event, checkboxId] of Object.entries(eventMap)) {
const el = document.getElementById(checkboxId);
prefs[event] = el ? el.checked : true;
}
// session:error always receives push (no per-event toggle, always critical)
prefs['session:error'] = true;
return prefs;
}
_updatePushUI(subscribed) {
const btn = document.getElementById('pushSubscribeBtn');
const status = document.getElementById('pushSubscriptionStatus');
if (btn) btn.textContent = subscribed ? 'Unsubscribe' : 'Subscribe';
if (status) {
status.textContent = subscribed ? 'active' : 'off';
status.classList.remove('granted', 'denied');
if (subscribed) status.classList.add('granted');
}
}
// ========== App Settings Modal ==========
openAppSettings() {
@@ -11478,42 +11651,52 @@ class CodemanApp {
document.getElementById('appSettingsNotifCritical').checked = !notifPrefs.muteCritical;
document.getElementById('appSettingsNotifWarning').checked = !notifPrefs.muteWarning;
document.getElementById('appSettingsNotifInfo').checked = !notifPrefs.muteInfo;
// Push notification settings
document.getElementById('appSettingsPushEnabled').checked = !!this._pushSubscription;
this._updatePushUI(!!this._pushSubscription);
// Per-event-type preferences
const eventTypes = notifPrefs.eventTypes || {};
// Permission prompts
const permPref = eventTypes.permission_prompt || {};
document.getElementById('eventPermissionEnabled').checked = permPref.enabled ?? true;
document.getElementById('eventPermissionBrowser').checked = permPref.browser ?? true;
document.getElementById('eventPermissionPush').checked = permPref.push ?? false;
document.getElementById('eventPermissionAudio').checked = permPref.audio ?? true;
// Questions (elicitation_dialog)
const questionPref = eventTypes.elicitation_dialog || {};
document.getElementById('eventQuestionEnabled').checked = questionPref.enabled ?? true;
document.getElementById('eventQuestionBrowser').checked = questionPref.browser ?? true;
document.getElementById('eventQuestionPush').checked = questionPref.push ?? false;
document.getElementById('eventQuestionAudio').checked = questionPref.audio ?? true;
// Session idle (idle_prompt)
const idlePref = eventTypes.idle_prompt || {};
document.getElementById('eventIdleEnabled').checked = idlePref.enabled ?? true;
document.getElementById('eventIdleBrowser').checked = idlePref.browser ?? true;
document.getElementById('eventIdlePush').checked = idlePref.push ?? false;
document.getElementById('eventIdleAudio').checked = idlePref.audio ?? false;
// Response complete (stop)
const stopPref = eventTypes.stop || {};
document.getElementById('eventStopEnabled').checked = stopPref.enabled ?? true;
document.getElementById('eventStopBrowser').checked = stopPref.browser ?? false;
document.getElementById('eventStopPush').checked = stopPref.push ?? false;
document.getElementById('eventStopAudio').checked = stopPref.audio ?? false;
// Respawn cycles
const respawnPref = eventTypes.respawn_cycle || {};
document.getElementById('eventRespawnEnabled').checked = respawnPref.enabled ?? true;
document.getElementById('eventRespawnBrowser').checked = respawnPref.browser ?? false;
document.getElementById('eventRespawnPush').checked = respawnPref.push ?? false;
document.getElementById('eventRespawnAudio').checked = respawnPref.audio ?? false;
// Task complete (ralph_complete)
const ralphPref = eventTypes.ralph_complete || {};
document.getElementById('eventRalphEnabled').checked = ralphPref.enabled ?? true;
document.getElementById('eventRalphBrowser').checked = ralphPref.browser ?? true;
document.getElementById('eventRalphPush').checked = ralphPref.push ?? false;
document.getElementById('eventRalphAudio').checked = ralphPref.audio ?? true;
// Subagent activity (subagent_spawn and subagent_complete)
const subagentPref = eventTypes.subagent_spawn || {};
document.getElementById('eventSubagentEnabled').checked = subagentPref.enabled ?? false;
document.getElementById('eventSubagentBrowser').checked = subagentPref.browser ?? false;
document.getElementById('eventSubagentPush').checked = subagentPref.push ?? false;
document.getElementById('eventSubagentAudio').checked = subagentPref.audio ?? false;
// Update permission status display (compact format for new grid layout)
const permStatus = document.getElementById('notifPermissionStatus');
@@ -11971,61 +12154,74 @@ class CodemanApp {
permission_prompt: {
enabled: document.getElementById('eventPermissionEnabled').checked,
browser: document.getElementById('eventPermissionBrowser').checked,
push: document.getElementById('eventPermissionPush').checked,
audio: document.getElementById('eventPermissionAudio').checked,
},
elicitation_dialog: {
enabled: document.getElementById('eventQuestionEnabled').checked,
browser: document.getElementById('eventQuestionBrowser').checked,
push: document.getElementById('eventQuestionPush').checked,
audio: document.getElementById('eventQuestionAudio').checked,
},
idle_prompt: {
enabled: document.getElementById('eventIdleEnabled').checked,
browser: document.getElementById('eventIdleBrowser').checked,
push: document.getElementById('eventIdlePush').checked,
audio: document.getElementById('eventIdleAudio').checked,
},
stop: {
enabled: document.getElementById('eventStopEnabled').checked,
browser: document.getElementById('eventStopBrowser').checked,
push: document.getElementById('eventStopPush').checked,
audio: document.getElementById('eventStopAudio').checked,
},
session_error: {
enabled: true,
browser: this.notificationManager?.preferences?.eventTypes?.session_error?.browser ?? true,
push: this.notificationManager?.preferences?.eventTypes?.session_error?.push ?? false,
audio: false,
},
respawn_cycle: {
enabled: document.getElementById('eventRespawnEnabled').checked,
browser: document.getElementById('eventRespawnBrowser').checked,
push: document.getElementById('eventRespawnPush').checked,
audio: document.getElementById('eventRespawnAudio').checked,
},
token_milestone: {
enabled: true,
browser: false,
push: false,
audio: false,
},
ralph_complete: {
enabled: document.getElementById('eventRalphEnabled').checked,
browser: document.getElementById('eventRalphBrowser').checked,
push: document.getElementById('eventRalphPush').checked,
audio: document.getElementById('eventRalphAudio').checked,
},
subagent_spawn: {
enabled: document.getElementById('eventSubagentEnabled').checked,
browser: document.getElementById('eventSubagentBrowser').checked,
push: document.getElementById('eventSubagentPush').checked,
audio: document.getElementById('eventSubagentAudio').checked,
},
subagent_complete: {
enabled: document.getElementById('eventSubagentEnabled').checked,
browser: document.getElementById('eventSubagentBrowser').checked,
push: document.getElementById('eventSubagentPush').checked,
audio: document.getElementById('eventSubagentAudio').checked,
},
},
_version: 3,
_version: 4,
};
if (this.notificationManager) {
this.notificationManager.preferences = notifPrefsToSave;
this.notificationManager.savePreferences();
}
// Sync push preferences to server
this._syncPushPreferences();
// Apply header visibility immediately
this.applyHeaderVisibilitySettings();
this.applyTabWrapSettings();
+22
View File
@@ -6,6 +6,7 @@
<meta name="description" content="Claude Code session manager with web interface">
<meta name="theme-color" content="#0a0a0a">
<meta name="google" content="notranslate">
<link rel="manifest" href="manifest.json">
<title>Codeman</title>
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Cdefs%3E%3ClinearGradient id='g' x1='0%25' y1='0%25' x2='100%25' y2='100%25'%3E%3Cstop offset='0%25' stop-color='%2360a5fa'/%3E%3Cstop offset='100%25' stop-color='%233b82f6'/%3E%3C/linearGradient%3E%3C/defs%3E%3Crect width='32' height='32' rx='6' fill='%230a0a0a'/%3E%3Cpath d='M18 4L8 18h6l-2 10 10-14h-6z' fill='url(%23g)'/%3E%3C/svg%3E">
<link rel="stylesheet" href="styles.css?v=0.1631">
@@ -1112,6 +1113,19 @@
</div>
<span class="form-hint" style="grid-column: 1 / -1; margin-top: -8px;">For remote access, HTTPS is required. Start with: <code>codeman web --https</code></span>
<div class="settings-item" title="OS-level push notifications — works even when tab is closed">
<span class="settings-item-label">Push</span>
<div class="settings-item-actions">
<label class="switch switch-sm">
<input type="checkbox" id="appSettingsPushEnabled">
<span class="slider"></span>
</label>
<button class="btn-toolbar btn-sm" id="pushSubscribeBtn" onclick="app.togglePushSubscription()" title="Subscribe/unsubscribe to push notifications">Subscribe</button>
<span class="settings-status" id="pushSubscriptionStatus">off</span>
</div>
</div>
<span class="form-hint" style="grid-column: 1 / -1; margin-top: -8px;">Works through Cloudflare tunnel. Requires HTTPS or localhost. iOS requires PWA (Add to Home Screen).</span>
<!-- Alerts Section -->
<div class="settings-section-header">Alerts</div>
<div class="settings-item" title="Play a short beep for critical events">
@@ -1164,41 +1178,49 @@
<div class="event-header">Event</div>
<div class="event-header">On</div>
<div class="event-header">Browser</div>
<div class="event-header">Push</div>
<div class="event-header">Sound</div>
<div class="event-label">Permission prompts</div>
<input type="checkbox" id="eventPermissionEnabled" checked>
<input type="checkbox" id="eventPermissionBrowser" checked>
<input type="checkbox" id="eventPermissionPush" checked>
<input type="checkbox" id="eventPermissionAudio" checked>
<div class="event-label">Questions from Claude</div>
<input type="checkbox" id="eventQuestionEnabled" checked>
<input type="checkbox" id="eventQuestionBrowser" checked>
<input type="checkbox" id="eventQuestionPush" checked>
<input type="checkbox" id="eventQuestionAudio" checked>
<div class="event-label">Session idle</div>
<input type="checkbox" id="eventIdleEnabled" checked>
<input type="checkbox" id="eventIdleBrowser" checked>
<input type="checkbox" id="eventIdlePush">
<input type="checkbox" id="eventIdleAudio">
<div class="event-label">Response complete</div>
<input type="checkbox" id="eventStopEnabled" checked>
<input type="checkbox" id="eventStopBrowser">
<input type="checkbox" id="eventStopPush">
<input type="checkbox" id="eventStopAudio">
<div class="event-label">Respawn cycles</div>
<input type="checkbox" id="eventRespawnEnabled" checked>
<input type="checkbox" id="eventRespawnBrowser">
<input type="checkbox" id="eventRespawnPush">
<input type="checkbox" id="eventRespawnAudio">
<div class="event-label">Task complete</div>
<input type="checkbox" id="eventRalphEnabled" checked>
<input type="checkbox" id="eventRalphBrowser" checked>
<input type="checkbox" id="eventRalphPush">
<input type="checkbox" id="eventRalphAudio" checked>
<div class="event-label">Subagent activity</div>
<input type="checkbox" id="eventSubagentEnabled">
<input type="checkbox" id="eventSubagentBrowser">
<input type="checkbox" id="eventSubagentPush">
<input type="checkbox" id="eventSubagentAudio">
</div>
</div>
+8
View File
@@ -0,0 +1,8 @@
{
"name": "Codeman",
"short_name": "Codeman",
"start_url": "/",
"display": "standalone",
"background_color": "#0a0a0a",
"theme_color": "#0a0a0a"
}
+1 -1
View File
@@ -2997,7 +2997,7 @@ body {
/* Event Type Notification Grid */
.event-type-grid {
display: grid;
grid-template-columns: 1fr 50px 50px 50px;
grid-template-columns: 1fr 50px 50px 50px 50px;
gap: 6px 8px;
align-items: center;
margin-top: 8px;
+67
View File
@@ -0,0 +1,67 @@
// Codeman Service Worker — Web Push notifications
// This service worker receives push events from the server and displays OS-level notifications.
// It also handles notification clicks to focus or open the Codeman tab.
self.addEventListener('install', () => {
self.skipWaiting();
});
self.addEventListener('activate', (event) => {
event.waitUntil(self.clients.claim());
});
self.addEventListener('push', (event) => {
if (!event.data) return;
let payload;
try {
payload = event.data.json();
} catch {
return;
}
const { title, body, tag, sessionId, urgency, actions } = payload;
const options = {
body: body || '',
tag: tag || 'codeman-default',
icon: '/favicon.ico',
badge: '/favicon.ico',
data: { sessionId, url: sessionId ? `/?session=${sessionId}` : '/' },
renotify: true,
requireInteraction: urgency === 'critical',
};
if (actions && actions.length > 0) {
options.actions = actions;
}
event.waitUntil(
self.registration.showNotification(title || 'Codeman', options)
);
});
self.addEventListener('notificationclick', (event) => {
event.notification.close();
const { sessionId, url } = event.notification.data || {};
const targetUrl = url || '/';
event.waitUntil(
self.clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clients) => {
// Try to find an existing Codeman tab
for (const client of clients) {
if (client.url.includes(self.location.origin)) {
client.postMessage({
type: 'notification-click',
sessionId,
action: event.action || null,
});
return client.focus();
}
}
// No existing tab — open a new one
return self.clients.openWindow(targetUrl);
})
);
});
+19
View File
@@ -190,6 +190,7 @@ const NotificationEventSchema = z.object({
enabled: z.boolean().optional(),
browser: z.boolean().optional(),
audio: z.boolean().optional(),
push: z.boolean().optional(),
}).optional();
export const SettingsUpdateSchema = z.object({
@@ -399,3 +400,21 @@ export const RespawnEnableSchema = z.object({
config: RespawnConfigSchema.optional(),
durationMinutes: z.number().int().min(1).max(14400).optional(),
});
// ========== Web Push ==========
/** POST /api/push/subscribe */
export const PushSubscribeSchema = z.object({
endpoint: z.string().url().max(2000),
keys: z.object({
p256dh: z.string().min(1).max(500),
auth: z.string().min(1).max(500),
}),
userAgent: z.string().max(500).optional(),
pushPreferences: z.record(z.string(), z.boolean()).optional(),
});
/** PUT /api/push/subscribe/:id */
export const PushPreferencesUpdateSchema = z.object({
pushPreferences: z.record(z.string(), z.boolean()),
});
+145
View File
@@ -42,6 +42,8 @@ import { createRequire } from 'node:module';
import { RunSummaryTracker } from '../run-summary.js';
import { PlanOrchestrator, type DetailedPlanResult } from '../plan-orchestrator.js';
import { getLifecycleLog } from '../session-lifecycle-log.js';
import { PushSubscriptionStore } from '../push-store.js';
import webpush from 'web-push';
// Load version from package.json
const require = createRequire(import.meta.url);
@@ -92,6 +94,8 @@ import {
SubagentParentMapSchema,
InteractiveRespawnSchema,
RespawnEnableSchema,
PushSubscribeSchema,
PushPreferencesUpdateSchema,
isValidWorkingDir,
} from './schemas.js';
import { StaleExpirationMap } from '../utils/index.js';
@@ -428,6 +432,7 @@ export class WebServer extends EventEmitter {
private tunnelManager: TunnelManager = new TunnelManager();
private authSessions: StaleExpirationMap<string, string> | null = null;
private authFailures: StaleExpirationMap<string, number> | null = null;
private pushStore: PushSubscriptionStore = new PushSubscriptionStore();
private teamWatcher: TeamWatcher = new TeamWatcher();
private teamWatcherHandlers: {
teamCreated: (config: unknown) => void;
@@ -740,6 +745,15 @@ export class WebServer extends EventEmitter {
done();
});
// Service worker must never be cached — browsers check for SW updates on navigation
this.app.get('/sw.js', async (_req, reply) => {
return reply
.header('Cache-Control', 'no-cache, no-store')
.header('Service-Worker-Allowed', '/')
.type('application/javascript')
.sendFile('sw.js', join(__dirname, 'public'));
});
// Serve static files — versioned assets (?v=X) are immutable, cache aggressively
// preCompressed: serve pre-built .br/.gz files (from build step) to avoid per-request CPU compression
await this.app.register(fastifyStatic, {
@@ -3909,6 +3923,11 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
const safeData = sanitizeHookData(data);
this.broadcast(`hook:${event}`, { sessionId, timestamp: Date.now(), ...safeData });
// Send push notifications for hook events
const session = this.sessions.get(sessionId);
const sessionName = session?.name ?? sessionId.slice(0, 8);
this.sendPushNotifications(`hook:${event}`, { sessionId, sessionName, ...safeData });
// Track in run summary
const summaryTracker = this.runSummaryTrackers.get(sessionId);
if (summaryTracker) {
@@ -3918,6 +3937,51 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
return { success: true };
});
// ========== Web Push ==========
this.app.get('/api/push/vapid-key', async () => {
return { success: true, data: { publicKey: this.pushStore.getPublicKey() } };
});
this.app.post('/api/push/subscribe', async (req) => {
const result = PushSubscribeSchema.safeParse(req.body);
if (!result.success) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, result.error.issues[0]?.message ?? 'Validation failed');
}
const { endpoint, keys, userAgent, pushPreferences } = result.data;
const record = this.pushStore.addSubscription({
id: uuidv4(),
endpoint,
keys,
userAgent: userAgent ?? req.headers['user-agent'] ?? '',
createdAt: Date.now(),
pushPreferences: pushPreferences ?? {},
});
return { success: true, data: { id: record.id } };
});
this.app.put('/api/push/subscribe/:id', async (req) => {
const { id } = req.params as { id: string };
const result = PushPreferencesUpdateSchema.safeParse(req.body);
if (!result.success) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, result.error.issues[0]?.message ?? 'Validation failed');
}
const updated = this.pushStore.updatePreferences(id, result.data.pushPreferences);
if (!updated) {
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Subscription not found');
}
return { success: true };
});
this.app.delete('/api/push/subscribe/:id', async (req) => {
const { id } = req.params as { id: string };
const removed = this.pushStore.removeSubscription(id);
if (!removed) {
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Subscription not found');
}
return { success: true };
});
// Screenshot upload endpoint (accepts multipart/form-data)
// Upload form served as static file: /upload.html (src/web/public/upload.html)
this.app.post('/api/screenshots', async (req, reply) => {
@@ -4427,6 +4491,7 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
error: (error) => {
this.broadcast('session:error', { id: session.id, error });
this.sendPushNotifications('session:error', { sessionId: session.id, sessionName: session.name, error: String(error) });
// Track in run summary
const tracker = this.runSummaryTrackers.get(session.id);
if (tracker) tracker.recordError('Session error', String(error));
@@ -4551,6 +4616,7 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
ralphCompletionDetected: (phrase: string) => {
this.broadcast('session:ralphCompletionDetected', { sessionId: session.id, phrase });
this.sendPushNotifications('session:ralphCompletionDetected', { sessionId: session.id, sessionName: session.name, phrase });
// Track in run summary
const tracker = this.runSummaryTrackers.get(session.id);
if (tracker) tracker.recordRalphCompletion(phrase);
@@ -4668,6 +4734,8 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
controller.on('respawnBlocked', (data: { reason: string; details: string }) => {
this.broadcast('respawn:blocked', { sessionId, reason: data.reason, details: data.details });
const sessionForPush = this.sessions.get(sessionId);
this.sendPushNotifications('respawn:blocked', { sessionId, sessionName: sessionForPush?.name ?? sessionId.slice(0, 8), reason: data.reason });
// Track in run summary (lazy lookup)
const tracker = getTracker();
if (tracker) tracker.recordWarning(`Respawn blocked: ${data.reason}`, data.details);
@@ -5372,6 +5440,80 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
this.stateUpdatePending.clear();
}
// ========== Web Push ==========
/** Map SSE event names to push notification payloads */
private static readonly PUSH_EVENT_MAP: Record<string, { title: string; urgency: string; actions?: Array<{ action: string; title: string }> }> = {
'hook:permission_prompt': { title: 'Permission Required', urgency: 'critical', actions: [{ action: 'approve', title: 'Approve' }, { action: 'deny', title: 'Deny' }] },
'hook:elicitation_dialog': { title: 'Question Asked', urgency: 'critical' },
'hook:idle_prompt': { title: 'Waiting for Input', urgency: 'warning' },
'hook:stop': { title: 'Response Complete', urgency: 'info' },
'session:error': { title: 'Session Error', urgency: 'critical' },
'respawn:blocked': { title: 'Respawn Blocked', urgency: 'critical' },
'session:ralphCompletionDetected': { title: 'Task Complete', urgency: 'warning' },
};
/**
* Send push notifications for a given event to all subscribed devices.
* Only events in PUSH_EVENT_MAP trigger push. Per-subscription preferences are checked.
* Expired subscriptions (410/404) are auto-removed.
*/
private sendPushNotifications(event: string, data: Record<string, unknown>): void {
const template = WebServer.PUSH_EVENT_MAP[event];
if (!template) return;
const subscriptions = this.pushStore.getAll();
if (subscriptions.length === 0) return;
const vapidKeys = this.pushStore.getVapidKeys();
webpush.setVapidDetails('mailto:codeman@localhost', vapidKeys.publicKey, vapidKeys.privateKey);
const sessionName = (data.sessionName as string) || '';
const sessionId = (data.sessionId as string) || '';
// Build body text from event data
let body = sessionName ? `[${sessionName}]` : '';
if (event === 'session:error' && data.error) {
body += body ? ' ' : '';
body += String(data.error).slice(0, 200);
} else if (event === 'respawn:blocked' && data.reason) {
body += body ? ' ' : '';
body += String(data.reason);
} else if (event === 'session:ralphCompletionDetected' && data.phrase) {
body += body ? ' ' : '';
body += String(data.phrase);
} else if (event === 'hook:permission_prompt' && data.tool_name) {
body += body ? ' ' : '';
body += `Tool: ${String(data.tool_name)}`;
}
const payload = JSON.stringify({
title: template.title,
body,
tag: `codeman-${event}-${sessionId}`,
sessionId,
urgency: template.urgency,
actions: template.actions,
});
for (const sub of subscriptions) {
// Check per-subscription preferences
if (sub.pushPreferences[event] === false) continue;
const pushSub = {
endpoint: sub.endpoint,
keys: sub.keys,
};
webpush.sendNotification(pushSub, payload).catch((err: { statusCode?: number }) => {
// Auto-remove expired/invalid subscriptions
if (err.statusCode === 410 || err.statusCode === 404) {
this.pushStore.removeByEndpoint(sub.endpoint);
}
});
}
}
/**
* Clean up dead SSE clients and send keep-alive comments.
* Keep-alive prevents proxy/load-balancer timeouts on idle connections.
@@ -5915,6 +6057,9 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
this.activePlanOrchestrators.clear();
this.cleaningUp.clear();
// Dispose push store (flush pending saves)
this.pushStore.dispose();
await this.app.close();
}
}