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:
@@ -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
|
||||
|
||||
|
||||
Generated
+108
-4
@@ -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",
|
||||
|
||||
+3
-1
@@ -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",
|
||||
|
||||
@@ -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