From c668f9bae12950cf5f476330e3ffbc1403f6aa04 Mon Sep 17 00:00:00 2001 From: arkon Date: Fri, 27 Feb 2026 08:28:30 +0100 Subject: [PATCH] chore: bump version to 0.1656 Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 16 ++- package-lock.json | 112 +++++++++++++++++- package.json | 4 +- src/push-store.ts | 179 ++++++++++++++++++++++++++++ src/types.ts | 20 ++++ src/web/public/app.js | 220 +++++++++++++++++++++++++++++++++-- src/web/public/index.html | 22 ++++ src/web/public/manifest.json | 8 ++ src/web/public/styles.css | 2 +- src/web/public/sw.js | 67 +++++++++++ src/web/schemas.ts | 19 +++ src/web/server.ts | 145 +++++++++++++++++++++++ 12 files changed, 792 insertions(+), 22 deletions(-) create mode 100644 src/push-store.ts create mode 100644 src/web/public/manifest.json create mode 100644 src/web/public/sw.js diff --git a/CLAUDE.md b/CLAUDE.md index 47b50c10..1834b317 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -35,7 +35,7 @@ When user says "COM": 1. Increment version in BOTH `package.json` AND `CLAUDE.md` (verify they match with `grep version package.json && grep Version CLAUDE.md`) 2. Run: `git add -A && git commit -m "chore: bump version to X.XXXX" && git push && npm run build && systemctl --user restart codeman-web` -**Version**: 0.1655 (must match `package.json`) +**Version**: 0.1656 (must match `package.json`) ## Project Overview @@ -120,6 +120,7 @@ journalctl --user -u codeman-web -f | `src/bash-tool-parser.ts` | Parses Claude's bash tool invocations from output | | `src/transcript-watcher.ts` | Watches Claude's transcript files for changes | | `src/hooks-config.ts` | Manages `.claude/settings.local.json` hook configuration | +| `src/push-store.ts` | VAPID key auto-gen + push subscription CRUD for Web Push | | `src/session-lifecycle-log.ts` | Append-only JSONL audit log at `~/.codeman/session-lifecycle.jsonl` | | `src/image-watcher.ts` | Watches for image file creation (screenshots, etc.) | | `src/file-stream-manager.ts` | Manages `tail -f` processes for live log viewing | @@ -128,7 +129,7 @@ journalctl --user -u codeman-web -f | `src/prompts/*.ts` | Agent prompts (research-agent, planner) | | `src/templates/claude-md.ts` | CLAUDE.md generation for new cases | | `src/cli.ts` | Command-line interface handlers | -| `src/web/server.ts` | Fastify REST API + SSE at `/api/events` (~101 routes) | +| `src/web/server.ts` | Fastify REST API + SSE at `/api/events` (~105 routes) | | `src/web/schemas.ts` | Zod v4 validation schemas with path/env security allowlists | | `src/web/public/app.js` | Frontend: xterm.js, tab management, subagent windows, mobile support (~17K lines) | | `src/types.ts` | All TypeScript interfaces (~70 type/interface/enum defs, ~1450 lines) | @@ -184,6 +185,8 @@ journalctl --user -u codeman-web -f **Hook events**: Claude Code hooks trigger notifications via `/api/hook-event`. Key events: `permission_prompt` (tool approval needed), `elicitation_dialog` (Claude asking question), `idle_prompt` (waiting for input), `stop` (response complete), `teammate_idle` (Agent Teams), `task_completed` (Agent Teams). See `src/hooks-config.ts`. +**Web Push**: Layer 5 of the notification system. Service worker (`sw.js`) receives push events and shows OS-level notifications even when the browser tab is closed. VAPID keys auto-generated on first use and persisted to `~/.codeman/push-keys.json`. Per-subscription per-event preferences stored in `~/.codeman/push-subscriptions.json`. Expired subscriptions (410/404) auto-cleaned. Requires HTTPS or localhost. iOS requires PWA installed to home screen. See `src/push-store.ts`. + **Agent Teams (experimental)**: `TeamWatcher` polls `~/.claude/teams/` for team configs and matches teams to sessions via `leadSessionId`. Teammates are in-process threads (not separate OS processes) and appear as standard subagents. RespawnController checks `TeamWatcher.hasActiveTeammates()` before triggering respawn. Enable via `CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1` env var in `settings.local.json`. See `agent-teams/` for full docs. **Circuit breaker**: Prevents respawn thrashing when Claude is stuck. States: `CLOSED` (normal) → `HALF_OPEN` (testing) → `OPEN` (blocked). Tracks consecutive no-progress, same-error-repeated, and tests-failing-too-long. Reset via API at `/api/sessions/:id/ralph-circuit-breaker/reset`. @@ -201,6 +204,8 @@ journalctl --user -u codeman-web -f | `src/web/public/styles.css` | Main styling (dark theme, layout, components) | | `src/web/public/mobile.css` | Responsive overrides for screens <1024px (loaded conditionally via `media` attribute) | | `src/web/public/upload.html` | Screenshot upload page served at `/upload.html` | +| `src/web/public/sw.js` | Service worker for Web Push notifications | +| `src/web/public/manifest.json` | Minimal PWA manifest (required for push on Android) | | `src/web/public/vendor/` | Self-hosted xterm.js + addons (eliminates CDN latency) | ### Frontend Architecture (`app.js`) @@ -213,7 +218,7 @@ The frontend is a single ~17.5K-line vanilla JS file with these key systems: | **Local echo overlay** | `LocalEchoOverlay` class | DOM overlay for instant mobile keystroke feedback | | **Mobile support** | `MobileDetection`, `KeyboardHandler`, `SwipeHandler`, `KeyboardAccessoryBar` | Touch input, viewport adaptation, swipe navigation | | **Subagent windows** | `openSubagentWindow()`, `closeSubagentWindow()`, `updateConnectionLines()` | Floating terminal windows with parent connection lines | -| **Notifications** | `NotificationManager` class | 4-layer: in-app drawer, tab flash, browser API, audio beep | +| **Notifications** | `NotificationManager` class | 5-layer: in-app drawer, tab flash, browser API, web push, audio beep | | **SSE connection** | `connectSSE()`, `addListener()` | EventSource with exponential backoff (1-30s), offline queue (64KB) | | **Settings** | `openAppSettings()`, `apply*Visibility()` | Server-backed + localStorage persistence | | **Focus management** | `FocusTrap` class | Modal keyboard navigation with focus restore | @@ -254,7 +259,7 @@ The frontend is a single ~17.5K-line vanilla JS file with these key systems: ### API Route Categories -~101 routes in `server.ts:buildServer()`. Key groups: +~105 routes in `server.ts:buildServer()`. Key groups: | Group | Prefix | Count | Key endpoints | |-------|--------|-------|---------------| @@ -265,6 +270,7 @@ The frontend is a single ~17.5K-line vanilla JS file with these key systems: | Subagents | `/api/subagents` | 7 | list, transcript, kill, cleanup | | Cases | `/api/cases` | 5 | CRUD, link, fix-plan | | Scheduled | `/api/scheduled` | 4 | CRUD for scheduled runs | +| Push | `/api/push` | 4 | VAPID key, subscribe, update prefs, unsubscribe | | System | `/api/status`, `/api/stats`, `/api/config`, `/api/settings` | 8 | App state, config | | Files | `/api/sessions/:id/file*`, `tail-file` | 5 | Browser, preview, raw, tail stream | | Mux | `/api/mux-sessions` | 4 | tmux management, stats | @@ -287,6 +293,8 @@ The frontend is a single ~17.5K-line vanilla JS file with these key systems: | `~/.codeman/state.json` | Sessions, settings, tokens, respawn config | | `~/.codeman/mux-sessions.json` | Tmux session metadata for recovery | | `~/.codeman/settings.json` | User preferences | +| `~/.codeman/push-keys.json` | VAPID key pair for Web Push (auto-generated) | +| `~/.codeman/push-subscriptions.json` | Registered push notification subscriptions | ## Default Settings diff --git a/package-lock.json b/package-lock.json index 97ca97d9..ccbe53ce 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "codeman", - "version": "0.1651", + "version": "0.1655", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "codeman", - "version": "0.1651", + "version": "0.1655", "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -23,6 +23,7 @@ "qrcode": "^1.5.4", "remotion": "4.0.429", "uuid": "^10.0.0", + "web-push": "^3.6.7", "xterm": "^5.3.0", "xterm-addon-fit": "^0.8.0", "xterm-addon-unicode11": "^0.6.0", @@ -37,6 +38,7 @@ "@types/pngjs": "^6.0.5", "@types/react": "^19.2.14", "@types/uuid": "^10.0.0", + "@types/web-push": "^3.6.4", "@vitest/coverage-v8": "^4.0.18", "agent-browser": "^0.6.0", "esbuild": "^0.27.3", @@ -2563,6 +2565,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/web-push": { + "version": "3.6.4", + "resolved": "https://registry.npmjs.org/@types/web-push/-/web-push-3.6.4.tgz", + "integrity": "sha512-GnJmSr40H3RAnj0s34FNTcJi1hmWFV5KXugE0mYWnYhgTAHLJ/dJKAwDmvPJYMke0RplY2XE9LnM4hqSqKIjhQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/yauzl": { "version": "2.10.3", "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", @@ -2919,7 +2931,6 @@ "version": "7.1.4", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", - "dev": true, "license": "MIT", "engines": { "node": ">= 14" @@ -3040,6 +3051,18 @@ "dev": true, "license": "Python-2.0" }, + "node_modules/asn1.js": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz", + "integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==", + "license": "MIT", + "dependencies": { + "bn.js": "^4.0.0", + "inherits": "^2.0.1", + "minimalistic-assert": "^1.0.0", + "safer-buffer": "^2.1.0" + } + }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -3289,6 +3312,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/bn.js": { + "version": "4.12.3", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz", + "integrity": "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==", + "license": "MIT" + }, "node_modules/brace-expansion": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.2.tgz", @@ -3379,6 +3408,12 @@ "node": "*" } }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -3809,6 +3844,15 @@ "safe-buffer": "~5.1.0" } }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, "node_modules/electron-to-chromium": { "version": "1.5.302", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.302.tgz", @@ -4490,6 +4534,15 @@ "dev": true, "license": "MIT" }, + "node_modules/http_ece": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/http_ece/-/http_ece-1.2.0.tgz", + "integrity": "sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA==", + "license": "MIT", + "engines": { + "node": ">=16" + } + }, "node_modules/http-errors": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", @@ -4528,7 +4581,6 @@ "version": "7.0.6", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", - "dev": true, "license": "MIT", "dependencies": { "agent-base": "^7.1.2", @@ -4876,6 +4928,27 @@ "node": ">=6" } }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, "node_modules/kleur": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", @@ -5107,6 +5180,12 @@ "node": ">=6" } }, + "node_modules/minimalistic-assert": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", + "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", + "license": "ISC" + }, "node_modules/minimatch": { "version": "10.2.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.2.tgz", @@ -6265,6 +6344,12 @@ "node": ">=10" } }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", @@ -7277,6 +7362,25 @@ "node": ">=10.13.0" } }, + "node_modules/web-push": { + "version": "3.6.7", + "resolved": "https://registry.npmjs.org/web-push/-/web-push-3.6.7.tgz", + "integrity": "sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A==", + "license": "MPL-2.0", + "dependencies": { + "asn1.js": "^5.3.0", + "http_ece": "1.2.0", + "https-proxy-agent": "^7.0.0", + "jws": "^4.0.0", + "minimist": "^1.2.5" + }, + "bin": { + "web-push": "src/cli.js" + }, + "engines": { + "node": ">= 16" + } + }, "node_modules/webdriver-bidi-protocol": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/webdriver-bidi-protocol/-/webdriver-bidi-protocol-0.4.1.tgz", diff --git a/package.json b/package.json index 7c58f559..12b80ca0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "codeman", - "version": "0.1655", + "version": "0.1656", "description": "The missing control plane for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence", "type": "module", "main": "dist/index.js", @@ -50,6 +50,7 @@ "qrcode": "^1.5.4", "remotion": "4.0.429", "uuid": "^10.0.0", + "web-push": "^3.6.7", "xterm": "^5.3.0", "xterm-addon-fit": "^0.8.0", "xterm-addon-unicode11": "^0.6.0", @@ -61,6 +62,7 @@ "@types/pngjs": "^6.0.5", "@types/react": "^19.2.14", "@types/uuid": "^10.0.0", + "@types/web-push": "^3.6.4", "@vitest/coverage-v8": "^4.0.18", "agent-browser": "^0.6.0", "esbuild": "^0.27.3", diff --git a/src/push-store.ts b/src/push-store.ts new file mode 100644 index 00000000..9a27ae76 --- /dev/null +++ b/src/push-store.ts @@ -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 = 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 { + // 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): 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(); + } +} diff --git a/src/types.ts b/src/types.ts index 342ac9a9..d0841727 100644 --- a/src/types.ts +++ b/src/types.ts @@ -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; +} + +/** 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 */ diff --git a/src/web/public/app.js b/src/web/public/app.js index 0cbe0e23..84bcef1a 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -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(); diff --git a/src/web/public/index.html b/src/web/public/index.html index 43112586..32a29852 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -6,6 +6,7 @@ + Codeman @@ -1112,6 +1113,19 @@ For remote access, HTTPS is required. Start with: codeman web --https +
+ Push +
+ + + off +
+
+ Works through Cloudflare tunnel. Requires HTTPS or localhost. iOS requires PWA (Add to Home Screen). +
Alerts
@@ -1164,41 +1178,49 @@
Event
On
Browser
+
Push
Sound
Permission prompts
+
Questions from Claude
+
Session idle
+
Response complete
+
Respawn cycles
+
Task complete
+
Subagent activity
+
diff --git a/src/web/public/manifest.json b/src/web/public/manifest.json new file mode 100644 index 00000000..410d4912 --- /dev/null +++ b/src/web/public/manifest.json @@ -0,0 +1,8 @@ +{ + "name": "Codeman", + "short_name": "Codeman", + "start_url": "/", + "display": "standalone", + "background_color": "#0a0a0a", + "theme_color": "#0a0a0a" +} diff --git a/src/web/public/styles.css b/src/web/public/styles.css index 0c5b1fa0..70de57d4 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -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; diff --git a/src/web/public/sw.js b/src/web/public/sw.js new file mode 100644 index 00000000..27db293d --- /dev/null +++ b/src/web/public/sw.js @@ -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); + }) + ); +}); diff --git a/src/web/schemas.ts b/src/web/schemas.ts index 91308eb9..0e014460 100644 --- a/src/web/schemas.ts +++ b/src/web/schemas.ts @@ -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()), +}); diff --git a/src/web/server.ts b/src/web/server.ts index 06f7c748..06341d52 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -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 | null = null; private authFailures: StaleExpirationMap | 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 }> = { + '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): 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(); } }