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" +```