diff --git a/.env.production b/.env.production index 1dee9e7..8789b4b 100644 --- a/.env.production +++ b/.env.production @@ -1,5 +1,9 @@ -VITE_API_URL=https://engage-api.srv1477139.hstgr.cloud -VITE_SIDECAR_URL=https://engage-api.srv1477139.hstgr.cloud +# EC2 deployment — Caddy reverse-proxies /auth/* and /api/* to the sidecar +# on the same domain, so VITE_API_URL is empty (same-origin). +VITE_API_URL= + +# SIP defaults — used as fallback if login response doesn't include agent config. +# Per-agent SIP config from the Agent entity (returned at login) takes precedence. VITE_SIP_URI=sip:523590@blr-pub-rtc4.ozonetel.com VITE_SIP_PASSWORD=523590 VITE_SIP_WS_SERVER=wss://blr-pub-rtc4.ozonetel.com:444 diff --git a/docs/developer-operations-runbook.md b/docs/developer-operations-runbook.md index 25b0287..d054508 100644 --- a/docs/developer-operations-runbook.md +++ b/docs/developer-operations-runbook.md @@ -12,6 +12,9 @@ Caddy (reverse proxy, TLS, host-routed) ├── global.engage.healix360.net → sidecar-global:4100 ├── telephony.engage.healix360.net → telephony:4200 ├── *.app.healix360.net → server:4000 (platform) + ├── monitoring.healix360.net → grafana:3000 + ├── operations.healix360.net → woodpecker-server:8000 + ├── git.healix360.net → gitea:3000 └── engage.healix360.net → 404 (no catchall) Docker Compose stack (EC2 — 13.234.31.194): @@ -28,7 +31,9 @@ Docker Compose stack (EC2 — 13.234.31.194): ├── db — PostgreSQL 16 (workspace-per-schema) ├── clickhouse — Analytics ├── minio — S3-compatible object storage - └── redpanda — Event bus (Kafka-compatible) + ├── redpanda — Event bus (Kafka-compatible) + ├── loki — Log aggregation (receives from Docker logging driver) + └── grafana — Monitoring dashboards (Loki + ClickHouse data sources) ``` --- @@ -36,38 +41,35 @@ Docker Compose stack (EC2 — 13.234.31.194): ## EC2 Access ```bash -# SSH into EC2 -ssh -i /tmp/ramaiah-ec2-key -o StrictHostKeyChecking=no ubuntu@13.234.31.194 +# SSH into EC2 (key passphrase handled by sshpass) +SSHPASS='SasiSuman@2007' sshpass -P "Enter passphrase" -e \ + ssh -i ~/Downloads/fortytwoai_hostinger -o StrictHostKeyChecking=no ubuntu@13.234.31.194 ``` | Detail | Value | |---|---| | Host | `13.234.31.194` | | User | `ubuntu` | -| SSH key | `/tmp/ramaiah-ec2-key` (decrypted from `~/Downloads/fortytwoai_hostinger`) | +| SSH key | `~/Downloads/fortytwoai_hostinger` (passphrase-protected) | +| Passphrase | `SasiSuman@2007` | | Docker compose dir | `/opt/fortytwo` | | Frontend static files | `/opt/fortytwo/helix-engage-frontend` | | Caddyfile | `/opt/fortytwo/Caddyfile` | -### SSH Key Setup +### SSH Helper -The key at `~/Downloads/fortytwoai_hostinger` is passphrase-protected (`SasiSuman@2007`). -Create a decrypted copy for non-interactive use: +The key is passphrase-protected. Use `sshpass` to supply the passphrase non-interactively. +No need to decrypt or copy the key — use the original file directly. ```bash -# One-time setup -openssl pkey -in ~/Downloads/fortytwoai_hostinger -out /tmp/ramaiah-ec2-key -chmod 600 /tmp/ramaiah-ec2-key +# SSH shorthand +EC2_SSH="SSHPASS='SasiSuman@2007' sshpass -P 'Enter passphrase' -e ssh -i ~/Downloads/fortytwoai_hostinger -o StrictHostKeyChecking=no ubuntu@13.234.31.194" # Verify -ssh -i /tmp/ramaiah-ec2-key ubuntu@13.234.31.194 hostname +eval $EC2_SSH hostname ``` -### Handy alias - -```bash -alias ec2="ssh -i /tmp/ramaiah-ec2-key -o StrictHostKeyChecking=no ubuntu@13.234.31.194" -``` +> **Note:** VPN may block port 22 to AWS. Disconnect VPN before SSH. --- @@ -80,6 +82,9 @@ alias ec2="ssh -i /tmp/ramaiah-ec2-key -o StrictHostKeyChecking=no ubuntu@13.234 | Ramaiah Platform | `https://ramaiah.app.healix360.net` | | Global Platform | `https://global.app.healix360.net` | | Telephony Dispatcher | `https://telephony.engage.healix360.net` | +| Monitoring (Grafana) | `https://monitoring.healix360.net` | +| CI/CD (Woodpecker) | `https://operations.healix360.net` | +| Git (Gitea) | `https://git.healix360.net` | --- @@ -155,29 +160,34 @@ REDIS_URL=redis://localhost:6379 ### Frontend ```bash +# Helper — reuse in all commands below +EC2="SSHPASS='SasiSuman@2007' sshpass -P 'Enter passphrase' -e ssh -i ~/Downloads/fortytwoai_hostinger -o StrictHostKeyChecking=no ubuntu@13.234.31.194" +EC2_RSYNC="SSHPASS='SasiSuman@2007' sshpass -P 'Enter passphrase' -e ssh -i ~/Downloads/fortytwoai_hostinger -o StrictHostKeyChecking=no" + cd helix-engage && npm run build -rsync -avz -e "ssh -i /tmp/ramaiah-ec2-key -o StrictHostKeyChecking=no" \ +rsync -avz -e "$EC2_RSYNC" \ dist/ ubuntu@13.234.31.194:/opt/fortytwo/helix-engage-frontend/ -ssh -i /tmp/ramaiah-ec2-key ubuntu@13.234.31.194 \ - "cd /opt/fortytwo && sudo docker compose restart caddy" +eval $EC2 "cd /opt/fortytwo && sudo docker compose restart caddy" ``` -### Sidecar (quick — code only, no new dependencies) +### Sidecar ```bash cd helix-engage-server +# 1. Login to ECR aws ecr get-login-password --region ap-south-1 | \ docker login --username AWS --password-stdin 043728036361.dkr.ecr.ap-south-1.amazonaws.com +# 2. Build and push Docker image docker buildx build --platform linux/amd64 \ -t 043728036361.dkr.ecr.ap-south-1.amazonaws.com/fortytwo-eap/helix-engage-sidecar:alpha \ --push . -ssh -i /tmp/ramaiah-ec2-key ubuntu@13.234.31.194 \ - "cd /opt/fortytwo && sudo docker compose pull sidecar-ramaiah sidecar-global && sudo docker compose up -d sidecar-ramaiah sidecar-global" +# 3. Pull and restart on EC2 +eval $EC2 "cd /opt/fortytwo && sudo docker compose pull sidecar-ramaiah sidecar-global && sudo docker compose up -d sidecar-ramaiah sidecar-global" ``` ### How to decide diff --git a/docs/generate-pptx-apr06-11.cjs b/docs/generate-pptx-apr06-11.cjs new file mode 100644 index 0000000..cdf8e72 --- /dev/null +++ b/docs/generate-pptx-apr06-11.cjs @@ -0,0 +1,612 @@ +/** + * Helix Engage — Weekly Update (Apr 6–11, 2026) + * "Clinical Precision" design — dark/light alternating, geometric, executive healthcare + */ +const PptxGenJS = require("pptxgenjs"); + +// ── Design System ─────────────────────────────────────────────── +const P = { + // Dark palette (hero slides) + navyDeep: "0F172A", // slate-900 + navyMid: "1E293B", // slate-800 + navyLight: "334155", // slate-700 + + // Light palette (content slides) + white: "FFFFFF", + snow: "F8FAFC", // slate-50 + mist: "F1F5F9", // slate-100 + silver: "E2E8F0", // slate-200 + + // Text + inkDark: "0F172A", + inkMid: "475569", // slate-600 + inkLight: "94A3B8", // slate-400 + inkOnDark: "F1F5F9", + inkMuted: "64748B", // slate-500 + + // Accents — healthcare-inspired + teal: "0D9488", // primary brand + tealLight: "14B8A6", + tealPale: "CCFBF1", // teal-100 + blue: "0284C7", // sky-600 + blueLight: "38BDF8", + indigo: "4F46E5", + amber: "D97706", + rose: "E11D48", + emerald: "059669", + violet: "7C3AED", +}; + +const F = "Calibri"; // Clean, universally available +const FB = "Calibri Light"; + +// ── Helpers ───────────────────────────────────────────────────── +function sn(s, n) { + s.addText(`${n}`, { + x: 9.3, y: 5.15, w: 0.5, h: 0.3, + fontSize: 8, color: P.inkLight, fontFace: FB, align: "right", + }); +} + +function darkSlide(pptx) { + const s = pptx.addSlide(); + s.background = { color: P.navyDeep }; + return s; +} + +function lightSlide(pptx) { + const s = pptx.addSlide(); + s.background = { color: P.white }; + return s; +} + +// Thin teal accent line at top +function topLine(s, color) { + s.addShape("rect", { x: 0, y: 0, w: 10, h: 0.04, fill: { color: color || P.teal } }); +} + +// Section label pill +function pill(s, text, color, x, y) { + const w = text.length * 0.075 + 0.5; + s.addShape("roundRect", { + x, y, w, h: 0.26, + fill: { color, transparency: 85 }, + rectRadius: 0.13, + }); + s.addText(text.toUpperCase(), { + x, y, w, h: 0.26, + fontSize: 7, fontFace: F, bold: true, color, + align: "center", valign: "middle", + }); +} + +// Metric block (for dark slides) +function metric(s, { x, y, value, label, color, w = 2.0 }) { + // Subtle card + s.addShape("roundRect", { + x, y, w, h: 1.4, + fill: { color: P.navyMid }, + line: { color: P.navyLight, width: 0.5 }, + rectRadius: 0.08, + }); + // Accent top bar + s.addShape("rect", { x: x + 0.15, y: y + 0.06, w: w - 0.3, h: 0.025, fill: { color } }); + // Value + s.addText(value, { + x, y: y + 0.15, w, h: 0.75, + fontSize: 38, fontFace: F, bold: true, color, + align: "center", valign: "middle", + }); + // Label + s.addText(label, { + x, y: y + 0.9, w, h: 0.35, + fontSize: 9, fontFace: FB, color: P.inkLight, + align: "center", valign: "top", + }); +} + +// Content card (for light slides) +function card(s, { x, y, w, h, title, accent, items }) { + // Card with left accent border + s.addShape("roundRect", { + x, y, w, h, + fill: { color: P.snow }, + line: { color: P.silver, width: 0.5 }, + rectRadius: 0.06, + }); + // Left accent bar + s.addShape("rect", { x, y: y + 0.1, w: 0.035, h: h - 0.2, fill: { color: accent } }); + // Title + s.addText(title, { + x: x + 0.25, y: y + 0.08, w: w - 0.4, h: 0.32, + fontSize: 10.5, fontFace: F, bold: true, color: accent, + }); + // Items + if (items?.length) { + s.addText( + items.map(t => ({ + text: t, + options: { + fontSize: 8.5, fontFace: FB, color: P.inkMid, + bullet: { code: "2022" }, // bullet dot + paraSpaceAfter: 3, breakLine: true, + }, + })), + { x: x + 0.25, y: y + 0.4, w: w - 0.5, h: h - 0.5, valign: "top", lineSpacingMultiple: 1.15 } + ); + } +} + +// Section heading for light slides +function sectionHead(s, title, subtitle) { + s.addText(title, { + x: 0.6, y: 0.35, w: 8, h: 0.45, + fontSize: 22, fontFace: F, bold: true, color: P.inkDark, + }); + if (subtitle) { + s.addText(subtitle, { + x: 0.6, y: 0.78, w: 8, h: 0.3, + fontSize: 10, fontFace: FB, color: P.inkMuted, + }); + } +} + +// ═════════════════════════════════════════════════════════════════ +async function build() { + const pptx = new PptxGenJS(); + pptx.layout = "LAYOUT_16x9"; + pptx.author = "Satya Suman Sari"; + pptx.company = "FortyTwo Platform"; + pptx.title = "Helix Engage — Weekly Update (Apr 6–11, 2026)"; + + // ─── SLIDE 1: Title (Dark) ──────────────────────────────────── + { + const s = darkSlide(pptx); + topLine(s, P.teal); + + // Geometric accent — vertical teal line + s.addShape("rect", { x: 0.6, y: 1.2, w: 0.035, h: 2.8, fill: { color: P.teal } }); + + pill(s, "Weekly Status", P.tealLight, 0.85, 1.3); + + s.addText("Helix Engage", { + x: 0.85, y: 1.7, w: 7, h: 0.9, + fontSize: 42, fontFace: F, bold: true, color: P.white, + }); + + s.addText("Engineering Progress Report", { + x: 0.85, y: 2.5, w: 7, h: 0.4, + fontSize: 16, fontFace: FB, color: P.inkLight, + }); + + // Date block + s.addShape("rect", { x: 0.85, y: 3.2, w: 2.2, h: 0.04, fill: { color: P.teal, transparency: 50 } }); + s.addText("April 6 – 11, 2026", { + x: 0.85, y: 3.35, w: 3, h: 0.3, + fontSize: 11, fontFace: F, bold: true, color: P.tealLight, + }); + + s.addText("Satya Suman Sari | FortyTwo Platform", { + x: 0.85, y: 4.8, w: 5, h: 0.25, + fontSize: 8, fontFace: FB, color: P.inkLight, + }); + sn(s, 1); + } + + // ─── SLIDE 2: At a Glance (Dark) ───────────────────────────── + { + const s = darkSlide(pptx); + topLine(s, P.teal); + + pill(s, "Overview", P.tealLight, 0.5, 0.3); + s.addText("Week at a Glance", { + x: 0.5, y: 0.6, w: 5, h: 0.45, + fontSize: 22, fontFace: F, bold: true, color: P.white, + }); + + metric(s, { x: 0.5, y: 1.25, value: "57", label: "Commits Shipped", color: P.blueLight, w: 2.05 }); + metric(s, { x: 2.7, y: 1.25, value: "9", label: "Defects Resolved", color: P.rose, w: 2.05 }); + metric(s, { x: 4.9, y: 1.25, value: "40", label: "E2E Tests Passing", color: P.emerald, w: 2.05 }); + metric(s, { x: 7.1, y: 1.25, value: "17", label: "Docker Containers", color: P.violet, w: 2.05 }); + + // Key highlights + const highlights = [ + "Multi-tenant EC2 architecture deployed — Ramaiah + Global on single instance", + "Woodpecker CI/CD pipeline operational with Teams notifications", + "Cross-tenant security vulnerability identified and patched", + "Complete documentation: architecture, runbook, CI/CD guide", + ]; + s.addText( + highlights.map(h => ({ + text: h, + options: { + fontSize: 10, fontFace: FB, color: P.inkOnDark, + bullet: { code: "25B8" }, paraSpaceAfter: 6, breakLine: true, + }, + })), + { x: 0.6, y: 2.9, w: 8.5, h: 2.0, valign: "top", lineSpacingMultiple: 1.2 } + ); + + sn(s, 2); + } + + // ─── SLIDE 3: Defect Fixes (Light) ──────────────────────────── + { + const s = lightSlide(pptx); + topLine(s, P.rose); + sectionHead(s, "Defect Resolution", "9 of 17 triaged bugs fixed and deployed this week"); + + const bugs = [ + ["#527", "Appointment creation overwrites patient details"], + ["#529", "Break/Training status doesn't block outbound calls"], + ["#531", "Agent can log out during an active call"], + ["#533", "Redundant Call History page header"], + ["#534", "Redundant Patients page header"], + ["#536", "My Performance displays wrong agent data"], + ["#538", "Supervisor dashboard metrics incorrect"], + ["#540", "Ghost calls visible for logged-out agents"], + ["#547", "SLA priority rules not reflected in worklist"], + ]; + + const rows = [ + [ + { text: "ID", options: { bold: true, color: P.white, fill: { color: P.navyMid }, fontSize: 8.5, fontFace: F } }, + { text: "Description", options: { bold: true, color: P.white, fill: { color: P.navyMid }, fontSize: 8.5, fontFace: F } }, + { text: "Status", options: { bold: true, color: P.white, fill: { color: P.navyMid }, fontSize: 8.5, fontFace: F } }, + ], + ...bugs.map(([id, desc], i) => [ + { text: id, options: { fontSize: 8.5, fontFace: F, color: P.inkMid, fill: { color: i % 2 === 0 ? P.snow : P.white } } }, + { text: desc, options: { fontSize: 8.5, fontFace: FB, color: P.inkMid, fill: { color: i % 2 === 0 ? P.snow : P.white } } }, + { text: "Resolved", options: { fontSize: 8.5, fontFace: F, bold: true, color: P.emerald, fill: { color: i % 2 === 0 ? P.snow : P.white } } }, + ]), + ]; + + s.addTable(rows, { + x: 0.5, y: 1.2, w: 9.0, + border: { type: "solid", pt: 0.3, color: P.silver }, + colW: [0.7, 6.6, 1.7], rowH: 0.36, + }); + + s.addText("Deferred by product: #516 recordings | #517 AI transcription | #519 supervisor calling | #539 real-time missed calls | #541 whisper/barge", { + x: 0.5, y: 4.9, w: 9, h: 0.3, + fontSize: 7.5, fontFace: FB, color: P.inkLight, italic: true, + }); + sn(s, 3); + } + + // ─── SLIDE 4: Security Fix (Dark) ──────────────────────────── + { + const s = darkSlide(pptx); + topLine(s, P.rose); + + pill(s, "Security", P.rose, 0.5, 0.3); + s.addText("Cross-Tenant Isolation Vulnerability", { + x: 0.5, y: 0.6, w: 9, h: 0.45, + fontSize: 22, fontFace: F, bold: true, color: P.white, + }); + s.addText("Discovered and patched within the same sprint", { + x: 0.5, y: 1.0, w: 9, h: 0.3, + fontSize: 10, fontFace: FB, color: P.inkLight, + }); + + // Problem + s.addShape("roundRect", { + x: 0.4, y: 1.5, w: 4.4, h: 2.6, + fill: { color: P.navyMid }, line: { color: P.navyLight, width: 0.5 }, rectRadius: 0.06, + }); + s.addShape("rect", { x: 0.4, y: 1.5, w: 0.035, h: 2.6, fill: { color: P.rose } }); + s.addText("Impact", { + x: 0.65, y: 1.55, w: 3, h: 0.3, + fontSize: 11, fontFace: F, bold: true, color: P.rose, + }); + s.addText( + [ + "Shared OZONETEL_AGENT_ID env var across sidecars", + "6 endpoints used silent fallback to wrong agent", + "Ramaiah operations could modify Global's session", + "Agent state, disposition, dial, metrics all affected", + "No error or warning — completely silent", + ].map(t => ({ + text: t, options: { fontSize: 8.5, fontFace: FB, color: P.inkOnDark, bullet: { code: "25B8" }, paraSpaceAfter: 4, breakLine: true }, + })), + { x: 0.65, y: 1.9, w: 3.9, h: 2.0, valign: "top" } + ); + + // Resolution + s.addShape("roundRect", { + x: 5.1, y: 1.5, w: 4.5, h: 2.6, + fill: { color: P.navyMid }, line: { color: P.navyLight, width: 0.5 }, rectRadius: 0.06, + }); + s.addShape("rect", { x: 5.1, y: 1.5, w: 0.035, h: 2.6, fill: { color: P.emerald } }); + s.addText("Resolution", { + x: 5.35, y: 1.55, w: 3, h: 0.3, + fontSize: 11, fontFace: F, bold: true, color: P.emerald, + }); + s.addText( + [ + "Removed all defaultAgentId fallbacks", + "All 6 endpoints now require agentId (400 if absent)", + "Frontend sends agentId from localStorage", + "OZONETEL_AGENT_ID removed from config entirely", + "Verified with 40 E2E tests — zero regressions", + ].map(t => ({ + text: t, options: { fontSize: 8.5, fontFace: FB, color: P.inkOnDark, bullet: { code: "25B8" }, paraSpaceAfter: 4, breakLine: true }, + })), + { x: 5.35, y: 1.9, w: 4.0, h: 2.0, valign: "top" } + ); + + // Clean layers footer + s.addText("Unaffected layers: Login (DB lookup) | Telephony dispatcher (event payload) | Sidecar registration (GraphQL) | Supervisor (webhook events)", { + x: 0.5, y: 4.4, w: 9, h: 0.3, + fontSize: 7.5, fontFace: FB, color: P.inkLight, + }); + sn(s, 4); + } + + // ─── SLIDE 5: EC2 Architecture (Light) ──────────────────────── + { + const s = lightSlide(pptx); + topLine(s, P.blue); + sectionHead(s, "AWS EC2 Multi-Tenant Architecture", "Single instance, strict tenant isolation, host-routed Caddy"); + + card(s, { + x: 0.4, y: 1.2, w: 4.4, h: 2.0, + title: "Shared Platform Layer", accent: P.blue, + items: [ + "NestJS server — multi-tenant by Origin header", + "PostgreSQL 16 with workspace-per-schema", + "BullMQ worker, ClickHouse analytics, Redpanda events", + "MinIO S3-compatible object storage", + ], + }); + + card(s, { + x: 5.1, y: 1.2, w: 4.5, h: 2.0, + title: "Isolated Sidecar Layer", accent: P.amber, + items: [ + "Per-hospital: sidecar + Redis + data volume", + "Caddy host-routes — no catchall, no cross-tenant", + "ramaiah.engage.healix360.net \u2192 sidecar-ramaiah", + "global.engage.healix360.net \u2192 sidecar-global", + ], + }); + + card(s, { + x: 0.4, y: 3.4, w: 4.4, h: 1.7, + title: "Telephony Dispatcher", accent: P.teal, + items: [ + "Routes Ozonetel events by agentId via Redis lookup", + "Sidecars self-register on boot with heartbeat", + "Zero config when onboarding new hospitals", + ], + }); + + card(s, { + x: 5.1, y: 3.4, w: 4.5, h: 1.7, + title: "Live Endpoints", accent: P.indigo, + items: [ + "ramaiah.engage / global.engage — Hospital UIs", + "telephony.engage — Event dispatcher", + "operations — CI/CD dashboard", + "git — Gitea forge (mirrors Azure DevOps)", + ], + }); + sn(s, 5); + } + + // ─── SLIDE 6: E2E Tests (Dark) ──────────────────────────────── + { + const s = darkSlide(pptx); + topLine(s, P.emerald); + + pill(s, "Quality Assurance", P.emerald, 0.5, 0.3); + s.addText("40 Automated E2E Tests", { + x: 0.5, y: 0.6, w: 9, h: 0.45, + fontSize: 22, fontFace: F, bold: true, color: P.white, + }); + s.addText("Playwright smoke tests covering every page across both hospitals", { + x: 0.5, y: 1.0, w: 9, h: 0.3, + fontSize: 10, fontFace: FB, color: P.inkLight, + }); + + // Ramaiah + s.addShape("roundRect", { + x: 0.4, y: 1.5, w: 4.4, h: 2.4, + fill: { color: P.navyMid }, line: { color: P.navyLight, width: 0.5 }, rectRadius: 0.06, + }); + s.addShape("rect", { x: 0.4, y: 1.5, w: 0.035, h: 2.4, fill: { color: P.amber } }); + s.addText("Ramaiah Hospitals — 27 tests", { + x: 0.65, y: 1.55, w: 4, h: 0.3, + fontSize: 10.5, fontFace: F, bold: true, color: P.amber, + }); + s.addText( + [ + "Login flow: branding, credentials, auth guard (4)", + "CC Agent: call desk, history, patients, appointments, performance, sidebar, sign-out (10)", + "Supervisor: dashboard, team perf, live monitor, all data pages, settings (12)", + "Auth setup with auto session unlock (1)", + ].map(t => ({ text: t, options: { fontSize: 8.5, fontFace: FB, color: P.inkOnDark, bullet: { code: "25B8" }, paraSpaceAfter: 5, breakLine: true } })), + { x: 0.65, y: 1.9, w: 3.9, h: 1.8, valign: "top" } + ); + + // Global + s.addShape("roundRect", { + x: 5.1, y: 1.5, w: 4.5, h: 2.4, + fill: { color: P.navyMid }, line: { color: P.navyLight, width: 0.5 }, rectRadius: 0.06, + }); + s.addShape("rect", { x: 5.1, y: 1.5, w: 0.035, h: 2.4, fill: { color: P.blueLight } }); + s.addText("Global Hospital — 13 tests", { + x: 5.35, y: 1.55, w: 4, h: 0.3, + fontSize: 10.5, fontFace: F, bold: true, color: P.blueLight, + }); + s.addText( + [ + "CC Agent: landing, history, patients, appointments, performance, sidebar, sign-out (7)", + "Supervisor: landing, patients, appointments, campaigns, settings (5)", + "Auth setup with auto session unlock (1)", + ].map(t => ({ text: t, options: { fontSize: 8.5, fontFace: FB, color: P.inkOnDark, bullet: { code: "25B8" }, paraSpaceAfter: 5, breakLine: true } })), + { x: 5.35, y: 1.9, w: 4.0, h: 1.8, valign: "top" } + ); + + // Self-healing footer + s.addShape("roundRect", { + x: 0.4, y: 4.15, w: 9.2, h: 0.85, + fill: { color: P.navyMid }, line: { color: P.navyLight, width: 0.5 }, rectRadius: 0.06, + }); + s.addText("Self-Healing", { + x: 0.65, y: 4.2, w: 2, h: 0.25, + fontSize: 9, fontFace: F, bold: true, color: P.emerald, + }); + s.addText("Auto-clears session locks before login | Completes sign-out after tests | Runs against live EC2, not mocked | ~6 min on Woodpecker CI", { + x: 0.65, y: 4.5, w: 8.5, h: 0.3, + fontSize: 8, fontFace: FB, color: P.inkLight, + }); + sn(s, 6); + } + + // ─── SLIDE 7: CI/CD (Light) ─────────────────────────────────── + { + const s = lightSlide(pptx); + topLine(s, P.indigo); + sectionHead(s, "CI/CD Pipeline", "Automated testing, report publishing, and team notifications"); + + // Flow bar + s.addShape("roundRect", { + x: 0.5, y: 1.15, w: 9.0, h: 0.4, + fill: { color: P.mist }, line: { color: P.silver, width: 0.5 }, rectRadius: 0.06, + }); + s.addText("Azure DevOps \u2192 Gitea Mirror \u2192 Woodpecker Pipeline \u2192 MinIO Reports \u2192 Teams Alert", { + x: 0.5, y: 1.15, w: 9.0, h: 0.4, + fontSize: 9.5, fontFace: F, bold: true, color: P.indigo, align: "center", valign: "middle", + }); + + card(s, { + x: 0.4, y: 1.75, w: 4.4, h: 1.7, + title: "Frontend Pipeline", accent: P.blue, + items: [ + "TypeScript typecheck (yarn tsc --noEmit)", + "40 Playwright E2E tests against live EC2", + "HTML report uploaded to MinIO (S3 plugin)", + "Teams Adaptive Card with report link", + ], + }); + + card(s, { + x: 5.1, y: 1.75, w: 4.5, h: 1.7, + title: "Sidecar Pipeline", accent: P.violet, + items: [ + "Jest unit tests (npm ci + jest --ci)", + "Teams notification on pass or fail", + "Triggered on push or manual run", + ], + }); + + card(s, { + x: 0.4, y: 3.65, w: 9.2, h: 1.4, + title: "Operations Dashboard", accent: P.teal, + items: [ + "operations.healix360.net — Woodpecker CI with full build history and logs", + "operations.healix360.net/reports/{run}/ — Playwright HTML reports with screenshots (basic auth protected)", + "git.healix360.net — Gitea forge mirroring Azure DevOps every 15 minutes", + "Teams 'Deployment updates' channel receives Adaptive Cards with pass/fail count and report link", + ], + }); + sn(s, 7); + } + + // ─── SLIDE 8: Timeline (Light) ──────────────────────────────── + { + const s = lightSlide(pptx); + topLine(s, P.teal); + sectionHead(s, "Development Timeline"); + + const timeline = [ + { date: "Apr 6 Sun", title: "Onboarding Wizard", desc: "6-phase setup wizard, widget config, telephony/AI CRUD, team invite, clinic/doctor management", color: P.blue }, + { date: "Apr 7 Mon", title: "SIP & ACW Fixes", desc: "3-layer ACW protection, SIP disconnect guard, dispose agentId, setup wizard polish", color: P.teal }, + { date: "Apr 8 Tue", title: "Master Data", desc: "Dynamic clinic/doctor fetching, appointment form overhaul, Ramaiah 195 doctor seed", color: P.amber }, + { date: "Apr 9 Wed", title: "EC2 Deployment", desc: "Multi-tenant architecture, telephony dispatcher, Caddy host routing, 14 containers", color: P.indigo }, + { date: "Apr 10 Thu", title: "Defect Sprint", desc: "9 bugs fixed, 40 E2E tests, architecture docs, runbook, cross-tenant discovery", color: P.rose }, + { date: "Apr 11 Fri", title: "CI/CD Pipeline", desc: "Woodpecker + Gitea + MinIO, Teams notifications, defaultAgentId security patch", color: P.emerald }, + ]; + + // Vertical line + s.addShape("rect", { x: 1.25, y: 1.2, w: 0.02, h: 3.9, fill: { color: P.silver } }); + + timeline.forEach((e, i) => { + const y = 1.2 + i * 0.65; + // Dot + s.addShape("ellipse", { + x: 1.18, y: y + 0.06, w: 0.16, h: 0.16, + fill: { color: e.color }, line: { color: P.white, width: 2 }, + }); + // Date + s.addText(e.date, { + x: 1.55, y, w: 1.2, h: 0.22, + fontSize: 7.5, fontFace: F, bold: true, color: e.color, + }); + // Title + s.addText(e.title, { + x: 2.8, y, w: 1.8, h: 0.22, + fontSize: 9.5, fontFace: F, bold: true, color: P.inkDark, + }); + // Desc + s.addText(e.desc, { + x: 4.7, y, w: 4.8, h: 0.55, + fontSize: 8, fontFace: FB, color: P.inkMid, valign: "top", + }); + }); + sn(s, 8); + } + + // ─── SLIDE 9: Closing (Dark) ────────────────────────────────── + { + const s = darkSlide(pptx); + topLine(s, P.teal); + + s.addShape("rect", { x: 0.6, y: 1.6, w: 0.035, h: 1.8, fill: { color: P.teal } }); + + s.addText("57 commits across 3 repositories", { + x: 0.85, y: 1.6, w: 8, h: 0.6, + fontSize: 28, fontFace: F, bold: true, color: P.white, + }); + + s.addText("From single-tenant VPS to multi-tenant EC2 with automated CI/CD,\n40 end-to-end tests, and a fully integrated operations dashboard.", { + x: 0.85, y: 2.3, w: 7, h: 0.7, + fontSize: 12, fontFace: FB, color: P.inkLight, lineSpacingMultiple: 1.4, + }); + + // Achievement pills + const items = [ + { text: "Multi-Tenant EC2", color: P.blue }, + { text: "40 E2E Tests", color: P.emerald }, + { text: "CI/CD Pipeline", color: P.indigo }, + { text: "9 Bugs Fixed", color: P.rose }, + { text: "Teams Alerts", color: P.violet }, + ]; + items.forEach((a, i) => { + const x = 0.85 + i * 1.7; + s.addShape("roundRect", { + x, y: 3.4, w: 1.5, h: 0.32, + fill: { color: P.navyMid }, + line: { color: a.color, width: 1 }, + rectRadius: 0.16, + }); + s.addText(a.text, { + x, y: 3.4, w: 1.5, h: 0.32, + fontSize: 8, fontFace: F, bold: true, color: a.color, + align: "center", valign: "middle", + }); + }); + + s.addText("Satya Suman Sari | FortyTwo Platform", { + x: 0.85, y: 4.8, w: 5, h: 0.25, + fontSize: 8, fontFace: FB, color: P.inkLight, + }); + sn(s, 9); + } + + await pptx.writeFile({ fileName: "docs/weekly-update-apr06-11.pptx" }); + console.log("Generated: docs/weekly-update-apr06-11.pptx"); +} + +build().catch(err => { console.error(err); process.exit(1); }); diff --git a/docs/superpowers/plans/2026-04-12-barge-whisper-listen.md b/docs/superpowers/plans/2026-04-12-barge-whisper-listen.md new file mode 100644 index 0000000..bed8b60 --- /dev/null +++ b/docs/superpowers/plans/2026-04-12-barge-whisper-listen.md @@ -0,0 +1,1140 @@ +# Barge / Whisper / Listen Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Enable supervisors to listen, whisper, and barge into live agent calls from the Helix Engage live monitor, using SIP WebRTC and Ozonetel's admin API. + +**Architecture:** Sidecar authenticates to Ozonetel admin API (JWT), proxies barge requests. Frontend uses a separate supervisor SIP client (JsSIP) for audio. DTMF tones (4/5/6) switch between listen/whisper/barge modes. Agent sees a badge on whisper/barge only. + +**Tech Stack:** NestJS (sidecar), JsSIP (SIP WebRTC), Ozonetel dashboardApi, React + Recoil atoms, SSE for agent notifications. + +**Spec:** `docs/superpowers/specs/2026-04-12-barge-whisper-listen-design.md` + +**Prereq:** QA validates barge flow in Ozonetel's own admin UI with the 3 SIP IDs before starting Task 4. + +--- + +## File Map + +### New Files (sidecar) +| File | Responsibility | +|------|---------------| +| `helix-engage-server/src/ozonetel/ozonetel-admin-auth.service.ts` | Ozonetel admin JWT lifecycle (login, cache, refresh) | +| `helix-engage-server/src/supervisor/supervisor-barge.controller.ts` | Barge proxy endpoints (initiate, mode, end, SIP credentials) | +| `helix-engage-server/src/supervisor/supervisor-barge.controller.spec.ts` | Unit tests for barge endpoints | + +### New Files (frontend) +| File | Responsibility | +|------|---------------| +| `helix-engage/src/lib/supervisor-sip-client.ts` | JsSIP wrapper for supervisor barge sessions | +| `helix-engage/src/components/call-desk/barge-controls.tsx` | Barge connection UI (connect, mode tabs, hangup) | + +### Modified Files (sidecar) +| File | Change | +|------|--------| +| `helix-engage-server/src/config/telephony.defaults.ts` | Add `adminUsername`, `adminPassword` to ozonetel config | +| `helix-engage-server/src/supervisor/supervisor.service.ts` | Add barge session tracking + SSE emission for supervisor events | +| `helix-engage-server/src/supervisor/supervisor.module.ts` | Register new controller + auth service | + +### Modified Files (frontend) +| File | Change | +|------|--------| +| `helix-engage/src/pages/live-monitor.tsx` | Split layout, context panel, integrate barge controls | +| `helix-engage/src/hooks/use-agent-state.ts` | Handle `supervisor-whisper`, `supervisor-barge`, `supervisor-left` SSE events | +| `helix-engage/src/components/call-desk/active-call-card.tsx` | Supervisor indicator badge | +| `helix-engage/src/components/setup/wizard-step-telephony.tsx` | Admin credential input fields | + +--- + +## Task 1: Extend TelephonyConfig with Admin Credentials + +**Files:** +- Modify: `helix-engage-server/src/config/telephony.defaults.ts` +- Modify: `helix-engage-server/src/components/setup/wizard-step-telephony.tsx` (frontend) + +- [ ] **Step 1: Add admin fields to TelephonyConfig type** + +In `helix-engage-server/src/config/telephony.defaults.ts`, add `adminUsername` and `adminPassword` to the ozonetel section: + +```typescript +// In TelephonyConfig type, inside ozonetel: +ozonetel: { + agentId: string; + agentPassword: string; + did: string; + sipId: string; + campaignName: string; + adminUsername: string; // Ozonetel portal admin login + adminPassword: string; // Ozonetel portal admin password +}; +``` + +In `DEFAULT_TELEPHONY_CONFIG`, add empty defaults: + +```typescript +ozonetel: { + agentId: '', + agentPassword: '', + did: '', + sipId: '', + campaignName: '', + adminUsername: '', + adminPassword: '', +}, +``` + +Add to `TELEPHONY_ENV_SEEDS`: + +```typescript +{ env: 'OZONETEL_ADMIN_USERNAME', path: ['ozonetel', 'adminUsername'] }, +{ env: 'OZONETEL_ADMIN_PASSWORD', path: ['ozonetel', 'adminPassword'] }, +``` + +- [ ] **Step 2: Add admin credential fields to telephony setup wizard** + +In `helix-engage/src/components/setup/wizard-step-telephony.tsx`, add two input fields in the Ozonetel section for `adminUsername` and `adminPassword`. Follow the same pattern as the existing `agentId` and `agentPassword` fields. The password field should use `type="password"`. + +- [ ] **Step 3: Add admin credentials to masked config** + +In `helix-engage-server/src/config/telephony-config.service.ts`, ensure `getMaskedConfig()` masks `ozonetel.adminPassword` the same way it masks `ozonetel.agentPassword`: + +```typescript +masked.ozonetel.adminPassword = config.ozonetel.adminPassword ? '***masked***' : ''; +``` + +- [ ] **Step 4: Commit** + +```bash +git add helix-engage-server/src/config/telephony.defaults.ts \ + helix-engage-server/src/config/telephony-config.service.ts \ + helix-engage/src/components/setup/wizard-step-telephony.tsx +git commit -m "feat(config): add Ozonetel admin credentials to telephony config" +``` + +--- + +## Task 2: Ozonetel Admin Auth Service + +**Files:** +- Create: `helix-engage-server/src/ozonetel/ozonetel-admin-auth.service.ts` +- Modify: `helix-engage-server/src/supervisor/supervisor.module.ts` + +- [ ] **Step 1: Create the admin auth service** + +Create `helix-engage-server/src/ozonetel/ozonetel-admin-auth.service.ts`: + +```typescript +import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; +import axios from 'axios'; +import { TelephonyConfigService } from '../config/telephony-config.service'; + +// Ozonetel admin API auth — login with RSA-encrypted credentials, cache JWT. +// Used by supervisor barge endpoints to call dashboardApi. + +@Injectable() +export class OzonetelAdminAuthService implements OnModuleInit { + private readonly logger = new Logger(OzonetelAdminAuthService.name); + private cachedToken: string | null = null; + private cachedUserId: string | null = null; + private cachedUserName: string | null = null; + private tokenExpiresAt = 0; + + constructor(private readonly telephony: TelephonyConfigService) {} + + async onModuleInit() { + const config = this.telephony.getConfig(); + if (config.ozonetel.adminUsername && config.ozonetel.adminPassword) { + this.logger.log('Ozonetel admin credentials configured — will authenticate on first use'); + } else { + this.logger.warn('Ozonetel admin credentials not configured — supervisor barge will be unavailable'); + } + } + + private get apiBase(): string { + return 'https://api.cloudagent.ozonetel.com'; + } + + async getAuthHeaders(): Promise> { + const token = await this.getToken(); + return { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}`, + 'userId': this.cachedUserId ?? '', + 'userName': this.cachedUserName ?? '', + 'isSuperAdmin': 'true', + 'dAccessType': 'false', + }; + } + + async getToken(): Promise { + if (this.cachedToken && Date.now() < this.tokenExpiresAt) { + return this.cachedToken; + } + return this.login(); + } + + private async login(): Promise { + const config = this.telephony.getConfig(); + const { adminUsername, adminPassword } = config.ozonetel; + + if (!adminUsername || !adminPassword) { + throw new Error('Ozonetel admin credentials not configured'); + } + + // Step 1: Get RSA public key + const preLoginRes = await axios.get(`${this.apiBase}/api/auth/public-key`); + const { publicKey, keyId } = preLoginRes.data; + + if (!publicKey || !keyId) { + throw new Error('Failed to get Ozonetel public key'); + } + + // Step 2: RSA-encrypt credentials + const JSEncrypt = (await import('jsencrypt')).default; + const encrypt = new JSEncrypt(); + encrypt.setPublicKey(publicKey); + + const encryptedUsername = encrypt.encrypt(adminUsername); + const encryptedPassword = encrypt.encrypt(adminPassword); + + if (!encryptedUsername || !encryptedPassword) { + throw new Error('RSA encryption failed'); + } + + // Step 3: Login + const loginRes = await axios.post(`${this.apiBase}/auth/login`, { + username: encryptedUsername, + password: encryptedPassword, + keyId, + ltype: 'PORTAL', + }, { + headers: { 'Content-Type': 'application/json' }, + }); + + const data = loginRes.data; + if (!data.token) { + throw new Error(`Ozonetel admin login failed: ${JSON.stringify(data)}`); + } + + this.cachedToken = data.token; + this.cachedUserId = data.userId?.toString() ?? data.UserId?.toString() ?? ''; + this.cachedUserName = data.name ?? adminUsername; + + // Decode token expiry — fallback to 6 hours + try { + const payload = JSON.parse(Buffer.from(data.token.split('.')[1], 'base64').toString()); + this.tokenExpiresAt = (payload.exp ?? 0) * 1000 - 60_000; // refresh 1 min early + } catch { + this.tokenExpiresAt = Date.now() + 6 * 60 * 60 * 1000; + } + + this.logger.log(`Ozonetel admin login successful (userId=${this.cachedUserId}, expires in ${Math.round((this.tokenExpiresAt - Date.now()) / 60000)}min)`); + return this.cachedToken; + } + + isConfigured(): boolean { + const config = this.telephony.getConfig(); + return !!(config.ozonetel.adminUsername && config.ozonetel.adminPassword); + } +} +``` + +- [ ] **Step 2: Register in supervisor module** + +In `helix-engage-server/src/supervisor/supervisor.module.ts`, add `OzonetelAdminAuthService` to the providers: + +```typescript +import { OzonetelAdminAuthService } from '../ozonetel/ozonetel-admin-auth.service'; + +@Module({ + // ...existing + providers: [SupervisorService, OzonetelAdminAuthService], + exports: [SupervisorService, OzonetelAdminAuthService], +}) +``` + +- [ ] **Step 3: Install jsencrypt** + +```bash +cd /Users/satyasumansaridae/Downloads/fortytwo-eap/helix-engage-server +npm install jsencrypt +``` + +- [ ] **Step 4: Commit** + +```bash +git add helix-engage-server/src/ozonetel/ozonetel-admin-auth.service.ts \ + helix-engage-server/src/supervisor/supervisor.module.ts \ + helix-engage-server/package.json helix-engage-server/package-lock.json +git commit -m "feat(sidecar): Ozonetel admin auth service — RSA login, JWT cache, auto-refresh" +``` + +--- + +## Task 3: Sidecar Barge Endpoints + +**Files:** +- Create: `helix-engage-server/src/supervisor/supervisor-barge.controller.ts` +- Modify: `helix-engage-server/src/supervisor/supervisor.service.ts` +- Modify: `helix-engage-server/src/supervisor/supervisor.module.ts` + +- [ ] **Step 1: Add barge session tracking to supervisor service** + +In `helix-engage-server/src/supervisor/supervisor.service.ts`, add after the existing properties: + +```typescript +// Barge session tracking +type BargeSession = { + supervisorId: string; + agentId: string; + sipNumber: string; + mode: 'listen' | 'whisper' | 'barge'; + startedAt: string; +}; + +// Add to class properties: +private readonly bargeSessions = new Map(); // key = agentId + +getBargeSession(agentId: string): BargeSession | null { + return this.bargeSessions.get(agentId) ?? null; +} + +startBargeSession(session: BargeSession): void { + this.bargeSessions.set(session.agentId, session); + this.logger.log(`[BARGE] ${session.supervisorId} → ${session.agentId} (${session.mode})`); +} + +updateBargeMode(agentId: string, mode: 'listen' | 'whisper' | 'barge'): void { + const session = this.bargeSessions.get(agentId); + if (!session) return; + + const previousMode = session.mode; + session.mode = mode; + + // Emit SSE event to agent — only for whisper/barge (listen is silent) + if (mode === 'whisper' || mode === 'barge') { + this.agentStateSubject.next({ + agentId, + state: `supervisor-${mode}` as any, + timestamp: new Date().toISOString(), + }); + } else if (previousMode !== 'listen') { + // Switching back to listen from whisper/barge — tell agent supervisor left + this.agentStateSubject.next({ + agentId, + state: 'supervisor-left' as any, + timestamp: new Date().toISOString(), + }); + } + + this.logger.log(`[BARGE] Mode: ${agentId} → ${mode}`); +} + +endBargeSession(agentId: string): void { + const session = this.bargeSessions.get(agentId); + if (!session) return; + + this.bargeSessions.delete(agentId); + + // Notify agent that supervisor left + this.agentStateSubject.next({ + agentId, + state: 'supervisor-left' as any, + timestamp: new Date().toISOString(), + }); + + this.logger.log(`[BARGE] Ended: ${agentId}`); +} +``` + +- [ ] **Step 2: Create barge controller** + +Create `helix-engage-server/src/supervisor/supervisor-barge.controller.ts`: + +```typescript +import { Controller, Post, Get, Body, HttpException, Logger } from '@nestjs/common'; +import axios from 'axios'; +import { OzonetelAdminAuthService } from '../ozonetel/ozonetel-admin-auth.service'; +import { SupervisorService } from './supervisor.service'; +import { TelephonyConfigService } from '../config/telephony-config.service'; + +@Controller('api/supervisor/barge') +export class SupervisorBargeController { + private readonly logger = new Logger(SupervisorBargeController.name); + private readonly dashboardApiUrl = 'https://api.cloudagent.ozonetel.com/dashboardApi/monitor/api'; + private readonly adminApiUrl = 'https://api.cloudagent.ozonetel.com/ca-admin-Api/CloudAgentAPI'; + + constructor( + private readonly adminAuth: OzonetelAdminAuthService, + private readonly supervisor: SupervisorService, + private readonly telephony: TelephonyConfigService, + ) {} + + @Get('sip-credentials') + async getSipCredentials() { + if (!this.adminAuth.isConfigured()) { + throw new HttpException('Ozonetel admin not configured', 503); + } + + const config = this.telephony.getConfig(); + const sipGateway = `${config.sip.domain}:${config.sip.wsPort}`; + const headers = await this.adminAuth.getAuthHeaders(); + + try { + const res = await axios.post(`${this.adminApiUrl}/endpoint/sipnumber/sipSubscribe`, { + apiId: 139, + sipURL: sipGateway, + }, { headers }); + + const data = res.data; + if (!data?.sip_number) { + throw new HttpException('No SIP numbers available in pool', 503); + } + + return { + sipNumber: data.sip_number, + sipPassword: data.password, + sipDomain: data.pop_location ?? config.sip.domain, + sipPort: config.sip.wsPort, + }; + } catch (err: any) { + this.logger.error(`SIP credentials failed: ${err.message}`); + if (err instanceof HttpException) throw err; + throw new HttpException('Failed to fetch SIP credentials', 502); + } + } + + @Post() + async initiateBarge(@Body() body: { ucid: string; agentId: string; agentNumber: string; supervisorId?: string }) { + if (!body.ucid || !body.agentNumber) { + throw new HttpException('ucid and agentNumber required', 400); + } + if (!this.adminAuth.isConfigured()) { + throw new HttpException('Ozonetel admin not configured', 503); + } + + // Check if already barged into this agent + const existing = this.supervisor.getBargeSession(body.agentId); + if (existing) { + throw new HttpException(`Agent ${body.agentId} already being monitored`, 409); + } + + // Get SIP credentials first + const sipCreds = await this.getSipCredentials(); + const headers = await this.adminAuth.getAuthHeaders(); + + try { + const res = await axios.post(this.dashboardApiUrl, { + apiId: 63, + ucid: body.ucid, + action: 'CALL_BARGEIN', + isSip: true, + phoneno: sipCreds.sipNumber, + agentNumber: body.agentNumber, + cbURL: 'helix-engage', + }, { headers }); + + this.logger.log(`[BARGE] Initiated: ucid=${body.ucid} agent=${body.agentId} sip=${sipCreds.sipNumber} response=${JSON.stringify(res.data)}`); + + // Track the session + this.supervisor.startBargeSession({ + supervisorId: body.supervisorId ?? 'admin', + agentId: body.agentId, + sipNumber: sipCreds.sipNumber, + mode: 'listen', + startedAt: new Date().toISOString(), + }); + + return { + status: 'ok', + ...sipCreds, + ozonetelResponse: res.data, + }; + } catch (err: any) { + this.logger.error(`[BARGE] Initiation failed: ${err.message}`); + throw new HttpException(`Barge failed: ${err.response?.data?.Message ?? err.message}`, 502); + } + } + + @Post('mode') + async updateMode(@Body() body: { agentId: string; mode: 'listen' | 'whisper' | 'barge' }) { + if (!body.agentId || !body.mode) { + throw new HttpException('agentId and mode required', 400); + } + + const session = this.supervisor.getBargeSession(body.agentId); + if (!session) { + throw new HttpException('No active barge session for this agent', 404); + } + + this.supervisor.updateBargeMode(body.agentId, body.mode); + + return { status: 'ok', mode: body.mode }; + } + + @Post('end') + async endBarge(@Body() body: { agentId: string }) { + if (!body.agentId) { + throw new HttpException('agentId required', 400); + } + + const session = this.supervisor.getBargeSession(body.agentId); + if (!session) { + return { status: 'ok', message: 'No active session' }; + } + + // Clear Redis tracking on Ozonetel side + if (this.adminAuth.isConfigured()) { + try { + const headers = await this.adminAuth.getAuthHeaders(); + await axios.post(this.dashboardApiUrl, { + apiId: 158, + Action: 'delete', + AgentId: body.agentId, + Sip: session.sipNumber, + }, { headers }); + } catch (err: any) { + this.logger.warn(`[BARGE] Redis cleanup failed: ${err.message}`); + } + } + + this.supervisor.endBargeSession(body.agentId); + + return { status: 'ok' }; + } +} +``` + +- [ ] **Step 3: Register barge controller in module** + +In `helix-engage-server/src/supervisor/supervisor.module.ts`: + +```typescript +import { SupervisorBargeController } from './supervisor-barge.controller'; + +@Module({ + controllers: [SupervisorController, SupervisorBargeController], + // ...rest stays the same +}) +``` + +- [ ] **Step 4: Commit** + +```bash +git add helix-engage-server/src/supervisor/supervisor-barge.controller.ts \ + helix-engage-server/src/supervisor/supervisor.service.ts \ + helix-engage-server/src/supervisor/supervisor.module.ts +git commit -m "feat(sidecar): supervisor barge endpoints — initiate, mode switch, end" +``` + +--- + +## Task 4: Supervisor SIP Client (Frontend) + +**Files:** +- Create: `helix-engage/src/lib/supervisor-sip-client.ts` + +**Prereq:** QA has validated barge works in Ozonetel's own admin UI. + +- [ ] **Step 1: Create supervisor SIP client** + +Create `helix-engage/src/lib/supervisor-sip-client.ts`: + +```typescript +import JsSIP from 'jssip'; + +type EventCallback = (...args: any[]) => void; +type SupervisorSipEvent = + | 'registered' + | 'registrationFailed' + | 'callReceived' + | 'callConnected' + | 'callEnded' + | 'callFailed'; + +type SupervisorSipConfig = { + domain: string; + port: string; + number: string; + password: string; +}; + +// Lightweight SIP client for supervisor barge sessions. +// Separate from the agent's sip-client.ts — supervisor has different lifecycle. +// Modeled on Ozonetel's kSip utility (CA-Admin/cloudagent.ozonetel.com/static/js/utils/ksip.tsx). + +class SupervisorSipClient { + private ua: JsSIP.UA | null = null; + private session: JsSIP.RTCSession | null = null; + private config: SupervisorSipConfig | null = null; + private listeners = new Map>(); + private audioElement: HTMLAudioElement | null = null; + + init(config: SupervisorSipConfig): void { + this.config = config; + this.cleanup(); + + // Create hidden audio element for remote audio + this.audioElement = document.createElement('audio'); + this.audioElement.id = 'supervisor-remote-audio'; + this.audioElement.autoplay = true; + this.audioElement.setAttribute('playsinline', ''); + document.body.appendChild(this.audioElement); + + const socketUrl = `wss://${config.domain}:${config.port}`; + const socket = new JsSIP.WebSocketInterface(socketUrl); + + this.ua = new JsSIP.UA({ + sockets: [socket], + uri: `sip:${config.number}@${config.domain}`, + password: config.password, + registrar_server: `sip:${config.domain}`, + authorization_user: config.number, + session_timers: false, + register: false, + }); + + this.ua.on('registered', () => { + this.emit('registered'); + }); + + this.ua.on('registrationFailed', (e: any) => { + this.emit('registrationFailed', e?.cause); + }); + + this.ua.on('newRTCSession', (data: any) => { + const rtcSession = data.session as JsSIP.RTCSession; + if (rtcSession.direction !== 'incoming') return; + + this.session = rtcSession; + this.emit('callReceived'); + + // Auto-answer incoming call from Ozonetel + rtcSession.on('accepted', () => { + this.emit('callConnected'); + }); + + rtcSession.on('confirmed', () => { + // Attach remote audio + const connection = rtcSession.connection; + if (connection && this.audioElement) { + const remoteStreams = connection.getRemoteStreams?.(); + if (remoteStreams?.[0]) { + this.audioElement.srcObject = remoteStreams[0]; + } + // Modern browsers: use track event + connection.addEventListener('track', (event: RTCTrackEvent) => { + if (event.streams[0] && this.audioElement) { + this.audioElement.srcObject = event.streams[0]; + } + }); + } + }); + + rtcSession.on('ended', () => { + this.session = null; + this.emit('callEnded'); + }); + + rtcSession.on('failed', (e: any) => { + this.session = null; + this.emit('callFailed', e?.cause); + }); + + // Auto-answer with audio + rtcSession.answer({ + mediaConstraints: { audio: true, video: false }, + }); + }); + + this.ua.start(); + } + + register(): void { + this.ua?.register(); + } + + isRegistered(): boolean { + return this.ua?.isRegistered() ?? false; + } + + isCallActive(): boolean { + return this.session?.isEstablished() ?? false; + } + + sendDTMF(digit: string): void { + if (!this.session?.isEstablished()) return; + this.session.sendDTMF(digit, { + duration: 160, + interToneGap: 1200, + }); + } + + hangup(): void { + if (this.session) { + try { + this.session.terminate(); + } catch { + // Session may already be ended + } + this.session = null; + } + } + + close(): void { + this.hangup(); + if (this.ua) { + this.ua.unregister({ all: true }); + this.ua.stop(); + this.ua = null; + } + this.cleanup(); + } + + on(event: SupervisorSipEvent, callback: EventCallback): void { + if (!this.listeners.has(event)) { + this.listeners.set(event, new Set()); + } + this.listeners.get(event)!.add(callback); + } + + off(event: SupervisorSipEvent, callback: EventCallback): void { + this.listeners.get(event)?.delete(callback); + } + + private emit(event: string, ...args: any[]): void { + this.listeners.get(event)?.forEach(cb => { + try { cb(...args); } catch (e) { console.error(`SupervisorSIP event error [${event}]:`, e); } + }); + } + + private cleanup(): void { + if (this.audioElement) { + this.audioElement.srcObject = null; + this.audioElement.remove(); + this.audioElement = null; + } + } +} + +export const supervisorSip = new SupervisorSipClient(); +``` + +- [ ] **Step 2: Commit** + +```bash +git add helix-engage/src/lib/supervisor-sip-client.ts +git commit -m "feat(frontend): supervisor SIP client — JsSIP wrapper for barge sessions" +``` + +--- + +## Task 5: Barge Controls Component + +**Files:** +- Create: `helix-engage/src/components/call-desk/barge-controls.tsx` + +- [ ] **Step 1: Create barge controls component** + +Create `helix-engage/src/components/call-desk/barge-controls.tsx`: + +```typescript +import { useState, useEffect, useRef } from 'react'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faPhone, faPhoneHangup, faHeadset, faCommentDots, faUsers } from '@fortawesome/pro-duotone-svg-icons'; +import { faIcon } from '@/lib/icon-wrapper'; +import { Button } from '@/components/base/buttons/button'; +import { Badge } from '@/components/base/badges/badges'; +import { Tabs, TabList, Tab } from '@/components/application/tabs/tabs'; +import { supervisorSip } from '@/lib/supervisor-sip-client'; +import { apiClient } from '@/lib/api-client'; +import { notify } from '@/lib/toast'; +import { cx } from '@/utils/cx'; + +const PhoneIcon = faIcon(faPhone); +const HangupIcon = faIcon(faPhoneHangup); + +type BargeStatus = 'idle' | 'connecting' | 'connected' | 'ended'; +type BargeMode = 'listen' | 'whisper' | 'barge'; + +const MODE_DTMF: Record = { listen: '4', whisper: '5', barge: '6' }; +const MODE_LABELS: Record = { + listen: { label: 'Listen', description: 'Silent monitoring — nobody knows you are here', color: 'gray' }, + whisper: { label: 'Whisper', description: 'Only the agent can hear you', color: 'brand' }, + barge: { label: 'Barge', description: 'Both agent and patient can hear you', color: 'error' }, +}; + +type BargeControlsProps = { + ucid: string; + agentId: string; + agentNumber: string; + agentName: string; + callerNumber: string; + onDisconnected?: () => void; +}; + +export const BargeControls = ({ ucid, agentId, agentNumber, agentName, callerNumber, onDisconnected }: BargeControlsProps) => { + const [status, setStatus] = useState('idle'); + const [mode, setMode] = useState('listen'); + const [duration, setDuration] = useState(0); + const connectedAtRef = useRef(null); + + // Duration counter + useEffect(() => { + if (status !== 'connected') return; + connectedAtRef.current = Date.now(); + const interval = setInterval(() => { + setDuration(Math.floor((Date.now() - (connectedAtRef.current ?? Date.now())) / 1000)); + }, 1000); + return () => clearInterval(interval); + }, [status]); + + // Cleanup on unmount + useEffect(() => { + return () => { + if (supervisorSip.isCallActive()) { + supervisorSip.close(); + apiClient.post('/api/supervisor/barge/end', { agentId }, { silent: true }).catch(() => {}); + } + }; + }, [agentId]); + + const handleConnect = async () => { + setStatus('connecting'); + + try { + // Call sidecar barge endpoint — gets SIP creds + initiates barge on Ozonetel + const result = await apiClient.post<{ + sipNumber: string; + sipPassword: string; + sipDomain: string; + sipPort: string; + }>('/api/supervisor/barge', { ucid, agentId, agentNumber }); + + // Initialize supervisor SIP client + supervisorSip.on('registered', () => { + // SIP registered — Ozonetel will send incoming call + }); + + supervisorSip.on('callConnected', () => { + setStatus('connected'); + // Default mode is listen — send DTMF 4 + supervisorSip.sendDTMF('4'); + notify.success('Connected', `Monitoring ${agentName}'s call`); + }); + + supervisorSip.on('callEnded', () => { + setStatus('ended'); + apiClient.post('/api/supervisor/barge/end', { agentId }, { silent: true }).catch(() => {}); + onDisconnected?.(); + }); + + supervisorSip.on('callFailed', (cause: string) => { + setStatus('ended'); + notify.error('Connection Failed', cause ?? 'Could not connect to call'); + apiClient.post('/api/supervisor/barge/end', { agentId }, { silent: true }).catch(() => {}); + }); + + supervisorSip.on('registrationFailed', (cause: string) => { + setStatus('ended'); + notify.error('SIP Registration Failed', cause ?? 'Could not register SIP'); + }); + + supervisorSip.init({ + domain: result.sipDomain, + port: result.sipPort, + number: result.sipNumber, + password: result.sipPassword, + }); + supervisorSip.register(); + } catch (err: any) { + setStatus('idle'); + notify.error('Barge Failed', err.message ?? 'Could not initiate barge'); + } + }; + + const handleModeChange = (newMode: BargeMode) => { + if (newMode === mode) return; + supervisorSip.sendDTMF(MODE_DTMF[newMode]); + setMode(newMode); + apiClient.post('/api/supervisor/barge/mode', { agentId, mode: newMode }, { silent: true }).catch(() => {}); + }; + + const handleHangup = () => { + supervisorSip.close(); + setStatus('ended'); + apiClient.post('/api/supervisor/barge/end', { agentId }, { silent: true }).catch(() => {}); + onDisconnected?.(); + }; + + const formatDuration = (sec: number) => { + const m = Math.floor(sec / 60); + const s = sec % 60; + return `${m}:${s.toString().padStart(2, '0')}`; + }; + + if (status === 'idle' || status === 'ended') { + return ( +
+ +

{status === 'ended' ? 'Session ended' : 'Ready to monitor'}

+ +
+ ); + } + + if (status === 'connecting') { + return ( +
+
+ + Connecting... +
+

Registering SIP and joining call

+
+ ); + } + + // Connected + return ( +
+ {/* Status bar */} +
+
+ + Connected +
+ {formatDuration(duration)} +
+ + {/* Mode tabs */} +
+ {(['listen', 'whisper', 'barge'] as BargeMode[]).map((m) => { + const config = MODE_LABELS[m]; + const isActive = mode === m; + return ( + + ); + })} +
+ + {/* Mode description */} +

{MODE_LABELS[mode].description}

+ + {/* Hang up */} + +
+ ); +}; +``` + +- [ ] **Step 2: Commit** + +```bash +git add helix-engage/src/components/call-desk/barge-controls.tsx +git commit -m "feat(frontend): barge controls component — connect, mode tabs, hangup" +``` + +--- + +## Task 6: Live Monitor Redesign + +**Files:** +- Modify: `helix-engage/src/pages/live-monitor.tsx` + +- [ ] **Step 1: Redesign live monitor with split layout** + +Rewrite `helix-engage/src/pages/live-monitor.tsx` to split into left (call list) and right (context + barge controls) panels. The left panel keeps the existing KPI cards + table. The right panel shows caller context when a row is selected, plus the BargeControls component. + +Key changes: +1. Add `selectedCall` state (`ActiveCall | null`) +2. Make table rows clickable — set `selectedCall` on click +3. Right panel: when `selectedCall` is set, fetch caller context from leads by phone match, show patient summary (name, AI summary, source, appointments), render `` below +4. Replace disabled Listen/Whisper/Barge buttons with a single "Monitor" button that selects the row +5. Keep KPI cards above the split layout + +The right panel follows the same pattern as the existing `context-panel.tsx` but simplified — no appointment form, no enquiry form, just read-only context + barge controls. + +This is a larger rewrite. Preserve the existing polling logic, KPI cards, caller name resolution, and table structure. Add the split layout wrapper and the right panel. + +- [ ] **Step 2: Test manually** + +1. Run `npm run dev` in helix-engage +2. Navigate to `/live-monitor` as admin +3. Verify: split layout renders, KPI cards visible, table shows active calls +4. Verify: clicking a row highlights it and right panel shows caller context +5. Verify: BargeControls shows "Ready to monitor" with Connect button + +- [ ] **Step 3: Commit** + +```bash +git add helix-engage/src/pages/live-monitor.tsx +git commit -m "feat(frontend): live monitor split layout with context panel and barge controls" +``` + +--- + +## Task 7: Agent-Side Supervisor Indicator + +**Files:** +- Modify: `helix-engage/src/hooks/use-agent-state.ts` +- Modify: `helix-engage/src/components/call-desk/active-call-card.tsx` + +- [ ] **Step 1: Handle supervisor SSE events in use-agent-state hook** + +In `helix-engage/src/hooks/use-agent-state.ts`, the SSE message handler (around line 37-63) processes `data.state`. Add handling for supervisor states. These don't replace the agent's Ozonetel state — they're a parallel signal. + +Add a new exported atom and update the hook: + +```typescript +import { atom, useSetRecoilState } from 'recoil'; + +export const supervisorPresenceAtom = atom<'none' | 'whisper' | 'barge'>({ + key: 'supervisorPresence', + default: 'none', +}); +``` + +In the SSE message handler, after the existing state handling: + +```typescript +// Handle supervisor presence events +if (data.state === 'supervisor-whisper') { + setSupervisorPresence('whisper'); + return; // Don't update agent state +} +if (data.state === 'supervisor-barge') { + setSupervisorPresence('barge'); + return; +} +if (data.state === 'supervisor-left') { + setSupervisorPresence('none'); + return; +} +``` + +The hook needs `useSetRecoilState(supervisorPresenceAtom)` added. + +- [ ] **Step 2: Add supervisor badge to active call card** + +In `helix-engage/src/components/call-desk/active-call-card.tsx`, import the atom and render a badge: + +```typescript +import { useRecoilValue } from 'recoil'; +import { supervisorPresenceAtom } from '@/hooks/use-agent-state'; + +// Inside the component: +const supervisorPresence = useRecoilValue(supervisorPresenceAtom); +``` + +Add the badge in the caller info header section (around line 227, after the caller name): + +```typescript +{supervisorPresence === 'whisper' && ( + Supervisor coaching +)} +{supervisorPresence === 'barge' && ( + Supervisor on call +)} +``` + +- [ ] **Step 3: Test manually** + +1. Open two browser tabs — one as admin (supervisor), one as cc-agent +2. Agent is on a call +3. From admin tab: navigate to live monitor, select the call, click Connect +4. Verify: agent sees "Supervisor coaching" badge when supervisor switches to Whisper +5. Verify: agent sees "Supervisor on call" badge when supervisor switches to Barge +6. Verify: badge disappears when supervisor switches back to Listen or hangs up + +- [ ] **Step 4: Commit** + +```bash +git add helix-engage/src/hooks/use-agent-state.ts \ + helix-engage/src/components/call-desk/active-call-card.tsx +git commit -m "feat(frontend): supervisor presence indicator on agent call card" +``` + +--- + +## Task 8: Integration Testing + +**Files:** No new files — manual test checklist. + +- [ ] **Step 1: Prereq check** + +Verify QA has confirmed barge works in Ozonetel's own admin UI with the hospital's account. + +- [ ] **Step 2: Configure admin credentials** + +Add Ozonetel admin username/password to the telephony config via the setup wizard or directly in `data/telephony.json`: + +```json +{ + "ozonetel": { + "adminUsername": "", + "adminPassword": "", + ... + } +} +``` + +- [ ] **Step 3: End-to-end barge test** + +1. Login as cc-agent → receive or make a call +2. Login as admin in separate browser → go to Live Monitor +3. Click the active call row → verify context panel shows caller info +4. Click "Connect" → verify status changes to "Connecting" then "Connected" +5. Verify: you can hear the call audio (Listen mode) +6. Click "Whisper" tab → verify agent hears you, patient doesn't +7. Click "Barge" tab → verify both agent and patient hear you +8. Click "Listen" tab → verify you go silent again +9. Click "Hang Up" → verify session ends cleanly +10. Verify: agent's badge disappears + +- [ ] **Step 4: Auto-disconnect test** + +1. While supervisor is barged in, have the agent end the call +2. Verify: supervisor's SIP session auto-disconnects +3. Verify: barge controls show "Session ended" +4. Verify: agent's supervisor badge disappears + +- [ ] **Step 5: Edge case tests** + +- Supervisor navigates away from live monitor mid-barge → verify cleanup on unmount +- Supervisor tries to barge two agents simultaneously → verify 409 error +- Agent goes to ACW while supervisor is barged → verify auto-disconnect +- Network drop during barge → verify SIP reconnection or clean failure + +- [ ] **Step 6: Final commit** + +```bash +git add -A +git commit -m "test: verify barge/whisper/listen integration" +``` diff --git a/docs/superpowers/specs/2026-04-12-barge-whisper-listen-design.md b/docs/superpowers/specs/2026-04-12-barge-whisper-listen-design.md new file mode 100644 index 0000000..2cea34e --- /dev/null +++ b/docs/superpowers/specs/2026-04-12-barge-whisper-listen-design.md @@ -0,0 +1,385 @@ +# Supervisor Barge / Whisper / Listen — Design Spec + +**Date:** 2026-04-12 +**Branch:** `feature/barge-whisper` +**Prereq:** QA validates barge flow in Ozonetel's own admin UI first + +--- + +## Overview + +Enable supervisors to monitor and intervene in live agent calls directly from Helix Engage's live monitor. Three modes: **Listen** (silent), **Whisper** (agent hears supervisor, patient doesn't), **Barge** (both hear supervisor). Supervisor connects via SIP WebRTC in the browser. Mode switching via DTMF tones. + +## Design Decisions + +| Decision | Choice | Rationale | +|----------|--------|-----------| +| Connection method | SIP only (PSTN later) | Supervisors are already on browser with headset | +| Agent indicator | Whisper/barge only (listen is silent) | Spec says show indicator; listen should be undetectable | +| SIP number | Dynamic from Ozonetel pool (apiId 139) | No need to pre-assign per supervisor. 3 SIP IDs available. | +| Barge UI location | Live monitor + context panel + barge controls | Supervisor needs call context to intervene effectively | +| Access control | Any admin can barge any agent | Flat RBAC, no team hierarchy | +| Call end behavior | Auto-disconnect supervisor | No orphaned sessions | + +## Architecture + +``` +┌─────────────────────────────────────────────────────────┐ +│ Supervisor Browser │ +│ │ +│ ┌──────────────┐ ┌────────────────────────────────┐ │ +│ │ Live Monitor │ │ Context Panel + Barge Controls│ │ +│ │ │ │ │ │ +│ │ Agent list │ │ Patient summary / AI insight │ │ +│ │ Active calls │──│ Appointments / Recent calls │ │ +│ │ Click → │ │ ─────────────────────────────│ │ +│ │ │ │ [Connect] │ │ +│ │ │ │ [Listen] [Whisper] [Barge] │ │ +│ │ │ │ [Hang up] │ │ +│ └──────────────┘ └────────────────────────────────┘ │ +│ │ │ │ +│ │ poll /active-calls │ SIP WebRTC (kSip) │ +│ │ every 5s │ DTMF 4/5/6 │ +└─────────┼───────────────────────┼────────────────────────┘ + │ │ + ▼ ▼ +┌─────────────────────┐ ┌──────────────────────────┐ +│ Sidecar │ │ Ozonetel SIP Gateway │ +│ │ │ (blr-pub-rtc4.ozonetel) │ +│ POST /api/supervisor│ │ │ +│ /barge │ │ SIP INVITE → supervisor │ +│ /barge-mode │ │ audio mixing │ +│ │ │ DTMF routing │ +│ → Ozonetel admin API│ └──────────────────────────┘ +│ dashboardApi │ +│ apiId 63, 139 │ +└─────────────────────┘ + │ + ▼ +┌─────────────────────┐ +│ Ozonetel Cloud │ +│ api.cloudagent. │ +│ ozonetel.com │ +│ │ +│ /dashboardApi/ │ +│ monitor/api │ +│ apiId 63 → barge │ +│ apiId 139 → SIP# │ +│ /auth/login → JWT │ +└─────────────────────┘ +``` + +## Components + +### 1. Sidecar — Ozonetel Admin Auth Service + +**New file:** `src/ozonetel/ozonetel-admin-auth.service.ts` + +Manages a persistent Ozonetel admin session for supervisor APIs. Credentials from TelephonyConfig. + +**Config extension** (`telephony.defaults.ts`): +```typescript +ozonetel: { + // ...existing fields + adminUsername: string; // NEW + adminPassword: string; // NEW +}; +``` + +**Flow:** +1. On startup, read `adminUsername` + `adminPassword` from TelephonyConfig +2. `GET /api/auth/public-key` → `{ publicKey, keyId }` +3. RSA-encrypt credentials using `jsencrypt` +4. `POST /auth/login` → JWT token +5. Cache token in memory, decode expiry via `jwt-decode` +6. Auto-refresh before expiry +7. Expose `getAuthHeaders()` for other services + +**Auth headers for all admin API calls:** +```typescript +{ + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${jwt}`, + 'userId': userId, + 'userName': userName, + 'isSuperAdmin': 'true', + 'dAccessType': 'false' +} +``` + +### 2. Sidecar — Supervisor Barge Endpoints + +**New file:** `src/supervisor/supervisor-barge.controller.ts` + +Three endpoints proxying to Ozonetel admin API: + +#### `POST /api/supervisor/barge` + +Initiates barge-in on an active call. + +```typescript +// Request +{ ucid: string, agentNumber: string } + +// Sidecar calls: +POST https://api.cloudagent.ozonetel.com/dashboardApi/monitor/api +{ + apiId: 63, + ucid: "", + action: "CALL_BARGEIN", + isSip: true, + phoneno: "", + agentNumber: "", + cbURL: "" +} + +// Response +{ status: "success", sipNumber: "19810", sipPassword: "19810", sipDomain: "blr-sbc1.ozonetel.com", sipPort: "442" } +``` + +Before calling barge, fetches an available SIP number: + +#### `GET /api/supervisor/barge/sip-credentials` + +```typescript +// Sidecar calls: +POST https://api.cloudagent.ozonetel.com/ca-admin-Api/CloudAgentAPI/endpoint/sipnumber/sipSubscribe +{ apiId: 139, sipURL: "" } + +// Response +{ sip_number: "19810", password: "19810", pop_location: "blr-sbc1.ozonetel.com" } +``` + +#### `POST /api/supervisor/barge/end` + +Cleanup: disconnect SIP, clear Redis tracking. + +```typescript +// Request +{ agentId: string, sipId: string } + +// Sidecar calls: +POST https://api.cloudagent.ozonetel.com/dashboardApi/monitor/api +{ apiId: 158, Action: "delete", AgentId: "", Sip: "" } +``` + +### 3. Frontend — Supervisor SIP Client + +**New file:** `src/lib/supervisor-sip-client.ts` + +Lightweight SIP client for supervisor barge sessions. Modeled on Ozonetel's `kSip.tsx` — separate from the agent's `sip-client.ts`. + +```typescript +type SupervisorSipClient = { + init(domain: string, port: string, number: string, password: string): void; + register(): void; + isRegistered(): boolean; + isCallActive(): boolean; + sendDTMF(digit: string): void; // "4"=listen, "5"=whisper, "6"=barge + hangup(): void; + close(): void; + on(event: string, callback: Function): void; + off(event: string, callback: Function): void; +}; +``` + +**Events emitted:** +- `registered` — SIP registration successful +- `registrationFailed` — SIP registration error +- `callReceived` — incoming call from Ozonetel (auto-answer) +- `callConnected` — barge session active +- `callEnded` — call terminated (agent hung up or supervisor hung up) + +**Audio:** Remote audio plays through a hidden `