mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-03 05:59:43 +02:00
chore: bump version to 0.1656
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"name": "Codeman",
|
||||
"short_name": "Codeman",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#0a0a0a",
|
||||
"theme_color": "#0a0a0a"
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
})
|
||||
);
|
||||
});
|
||||
@@ -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()),
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user