@calloop/agent-sdk 0.2.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/Monitor.js CHANGED
@@ -1,173 +1,173 @@
1
- // A supervisor listening to a call (docs/agent-protocol.md, Monitoring).
2
- // Created by CalloopAgent.monitor() — don't construct directly.
3
- //
4
- // One audio line to Calloop: it sends the supervisor's microphone and
5
- // receives the call — the customer and the agent, mixed by Calloop. A DIRECT
6
- // call (no room to mix in) is listen-only: Calloop offers one line per side and
7
- // the stream carries both. Calloop decides who hears the supervisor, so a mode
8
- // change needs no renegotiation:
9
- // listen the supervisor hears the call; nobody hears the supervisor
10
- // whisper the agent hears the supervisor; the customer doesn't
11
- // barge both hear the supervisor
12
- //
13
- // state: connecting → active → ended
14
- // Events: 'state' (state, previous), 'stream' (MediaStream: the call),
15
- // 'mode' (mode), 'modeRefused' ({ mode, code, message }), 'agentPrivate' ({ active }),
16
- // 'agentReconnected', 'ended' ({ reason, code?, message? }).
17
- import { Emitter } from './emitter.js';
18
-
19
- const CONNECT_TIMEOUT_MS = 15000;
20
- const MODES = new Set(['listen', 'whisper', 'barge']);
21
-
22
- export class Monitor extends Emitter {
23
- constructor(agent, callId) {
24
- super();
25
- this.agent = agent;
26
- this.callId = callId;
27
- this.state = 'connecting';
28
- this.mode = 'listen';
29
- this.muted = false;
30
- this.stream = null;
31
- this.localStream = null;
32
- this._ownsLocalStream = false;
33
- this.pc = null;
34
- this._pendingRemote = [];
35
- this._timer = null;
36
- this._started = false; // Calloop confirmed the monitor (call:monitor:started)
37
- this._requestedMode = null; // the last setMode() not yet confirmed
38
- }
39
-
40
- /** 'listen' | 'whisper' | 'barge'. */
41
- setMode(mode) {
42
- if (!MODES.has(mode)) throw new Error(`Unknown monitor mode "${mode}" (listen, whisper, barge)`);
43
- if (this.state === 'ended') return;
44
- this._requestedMode = mode;
45
- this.agent._send('call:monitor:mode', { callId: this.callId, mode });
46
- }
47
-
48
- /** Mute the supervisor's microphone (whisper / barge). */
49
- mute(muted = true) {
50
- this.muted = Boolean(muted);
51
- for (const track of this.localStream?.getAudioTracks() ?? []) track.enabled = !this.muted;
52
- }
53
-
54
- stop() {
55
- if (this.state === 'ended') return;
56
- this.agent._send('call:monitor:stop', { callId: this.callId });
57
- this._end('stopped');
58
- }
59
-
60
- // ── Internals ─────────────────────────────────────────────────────────────
61
-
62
- async _start(stream) {
63
- const { RTCPeerConnection, MediaStream } = this.agent.webrtc;
64
- const pc = new RTCPeerConnection({ iceServers: this.agent.iceServers });
65
- this.pc = pc;
66
- // A direct call is listened to by answering Calloop's offer, and never
67
- // needs the microphone.
68
- this._direct = this.agent.board?.get?.(String(this.callId))?.mediaTopology === 'DIRECT';
69
-
70
- if (!this._direct) {
71
- this._ownsLocalStream = !stream;
72
- this.localStream = stream ?? await this.agent._getMicrophone();
73
- const mic = this.localStream.getAudioTracks()[0];
74
- pc.addTransceiver(mic ?? 'audio', { direction: 'sendrecv', streams: [this.localStream] });
75
- }
76
-
77
- pc.onicecandidate = (e) => {
78
- if (!e.candidate || pc !== this.pc) return;
79
- this.agent._send('connection:ice-candidate', {
80
- callId: this.callId, connectionType: 'MONITOR',
81
- candidate: { candidate: e.candidate.candidate, sdpMid: e.candidate.sdpMid, sdpMLineIndex: e.candidate.sdpMLineIndex },
82
- });
83
- };
84
- pc.ontrack = (e) => {
85
- if (pc !== this.pc) return;
86
- // Every line Calloop sends goes into the one stream (a direct call: both sides).
87
- if (!this.stream) this.stream = new MediaStream([e.track]);
88
- else this.stream.addTrack(e.track);
89
- this.emit('stream', this.stream, e.track);
90
- };
91
- pc.onconnectionstatechange = () => {
92
- if (pc !== this.pc) return;
93
- if (pc.connectionState === 'connected') { clearTimeout(this._timer); this._setState('active'); }
94
- else if (pc.connectionState === 'failed') this.stop();
95
- };
96
- this._timer = setTimeout(() => { if (this.state === 'connecting') this.stop(); }, CONNECT_TIMEOUT_MS);
97
-
98
- if (this._direct) {
99
- this.agent._send('call:monitor', { callId: this.callId });
100
- return;
101
- }
102
- await pc.setLocalDescription(await pc.createOffer());
103
- this.agent._send('call:monitor', { callId: this.callId, sdpOffer: pc.localDescription.sdp });
104
- }
105
-
106
- // Calloop's offer, for a monitor started without one.
107
- async _onOffer({ sdpOffer }) {
108
- if (!this.pc || this.pc.remoteDescription) return;
109
- await this.pc.setRemoteDescription({ type: 'offer', sdp: sdpOffer });
110
- for (const c of this._pendingRemote.splice(0)) await this.pc.addIceCandidate(c).catch(() => { });
111
- await this.pc.setLocalDescription(await this.pc.createAnswer());
112
- this.agent._send('call:monitor:answer', { callId: this.callId, sdpAnswer: this.pc.localDescription.sdp });
113
- }
114
-
115
- async _onStarted({ sdpAnswer }) {
116
- this._started = true;
117
- if (!sdpAnswer || !this.pc || this.pc.remoteDescription) return;
118
- await this.pc.setRemoteDescription({ type: 'answer', sdp: sdpAnswer });
119
- for (const c of this._pendingRemote.splice(0)) await this.pc.addIceCandidate(c).catch(() => { });
120
- }
121
-
122
- async _onServerCandidate(candidate) {
123
- if (!candidate) return;
124
- if (this.pc?.remoteDescription) await this.pc.addIceCandidate(candidate).catch(() => { });
125
- else this._pendingRemote.push(candidate);
126
- }
127
-
128
- // MONITOR_FAILED / MONITOR_MODE_NOT_ALLOWED for this call. Before Calloop
129
- // confirmed the monitor it was refused and Calloop has already let it go
130
- // (no leg, socket detached). Once started, only a mode change can fail:
131
- // Calloop keeps the monitor in its current mode, and so do we.
132
- _onError({ code, message }) {
133
- if (this.state === 'ended') return;
134
- if (this._started) {
135
- const mode = this._requestedMode;
136
- this._requestedMode = null;
137
- this.emit('modeRefused', { mode, code, message });
138
- return;
139
- }
140
- // A mode asked for while connecting may be what failed, with the
141
- // monitor still up on Calloop's side: let it go there too.
142
- if (this._requestedMode) this.agent._send('call:monitor:stop', { callId: this.callId });
143
- this._end(code === 'MONITOR_MODE_NOT_ALLOWED' ? 'not_allowed' : 'failed', { code, message });
144
- }
145
-
146
- _onMode(mode) {
147
- if (mode === this._requestedMode) this._requestedMode = null;
148
- if (!MODES.has(mode) || mode === this.mode) return;
149
- this.mode = mode;
150
- this.emit('mode', mode);
151
- }
152
-
153
- _setState(next) {
154
- if (this.state === next || this.state === 'ended') return;
155
- const previous = this.state;
156
- this.state = next;
157
- this.emit('state', next, previous);
158
- }
159
-
160
- _end(reason, details = {}) {
161
- if (this.state === 'ended') return;
162
- clearTimeout(this._timer);
163
- const pc = this.pc;
164
- this.pc = null;
165
- try { pc?.close(); } catch { /* already closed */ }
166
- if (this._ownsLocalStream) for (const t of this.localStream?.getTracks() ?? []) t.stop();
167
- const previous = this.state;
168
- this.state = 'ended';
169
- this.emit('state', 'ended', previous);
170
- this.emit('ended', { reason, ...details });
171
- this.agent._forgetMonitor(this, reason);
172
- }
173
- }
1
+ // A supervisor listening to a call (docs/agent-protocol.md, Monitoring).
2
+ // Created by CalloopAgent.monitor() — don't construct directly.
3
+ //
4
+ // One audio line to Calloop: it sends the supervisor's microphone and
5
+ // receives the call — the customer and the agent, mixed by Calloop. A DIRECT
6
+ // call (no room to mix in) is listen-only: Calloop offers one line per side and
7
+ // the stream carries both. Calloop decides who hears the supervisor, so a mode
8
+ // change needs no renegotiation:
9
+ // listen the supervisor hears the call; nobody hears the supervisor
10
+ // whisper the agent hears the supervisor; the customer doesn't
11
+ // barge both hear the supervisor
12
+ //
13
+ // state: connecting → active → ended
14
+ // Events: 'state' (state, previous), 'stream' (MediaStream: the call),
15
+ // 'mode' (mode), 'modeRefused' ({ mode, code, message }), 'agentPrivate' ({ active }),
16
+ // 'agentReconnected', 'ended' ({ reason, code?, message? }).
17
+ import { Emitter } from './emitter.js';
18
+
19
+ const CONNECT_TIMEOUT_MS = 15000;
20
+ const MODES = new Set(['listen', 'whisper', 'barge']);
21
+
22
+ export class Monitor extends Emitter {
23
+ constructor(agent, callId) {
24
+ super();
25
+ this.agent = agent;
26
+ this.callId = callId;
27
+ this.state = 'connecting';
28
+ this.mode = 'listen';
29
+ this.muted = false;
30
+ this.stream = null;
31
+ this.localStream = null;
32
+ this._ownsLocalStream = false;
33
+ this.pc = null;
34
+ this._pendingRemote = [];
35
+ this._timer = null;
36
+ this._started = false; // Calloop confirmed the monitor (call:monitor:started)
37
+ this._requestedMode = null; // the last setMode() not yet confirmed
38
+ }
39
+
40
+ /** 'listen' | 'whisper' | 'barge'. */
41
+ setMode(mode) {
42
+ if (!MODES.has(mode)) throw new Error(`Unknown monitor mode "${mode}" (listen, whisper, barge)`);
43
+ if (this.state === 'ended') return;
44
+ this._requestedMode = mode;
45
+ this.agent._send('call:monitor:mode', { callId: this.callId, mode });
46
+ }
47
+
48
+ /** Mute the supervisor's microphone (whisper / barge). */
49
+ mute(muted = true) {
50
+ this.muted = Boolean(muted);
51
+ for (const track of this.localStream?.getAudioTracks() ?? []) track.enabled = !this.muted;
52
+ }
53
+
54
+ stop() {
55
+ if (this.state === 'ended') return;
56
+ this.agent._send('call:monitor:stop', { callId: this.callId });
57
+ this._end('stopped');
58
+ }
59
+
60
+ // ── Internals ─────────────────────────────────────────────────────────────
61
+
62
+ async _start(stream) {
63
+ const { RTCPeerConnection, MediaStream } = this.agent.webrtc;
64
+ const pc = new RTCPeerConnection({ iceServers: this.agent.iceServers });
65
+ this.pc = pc;
66
+ // A direct call is listened to by answering Calloop's offer, and never
67
+ // needs the microphone.
68
+ this._direct = this.agent.board?.get?.(String(this.callId))?.mediaTopology === 'DIRECT';
69
+
70
+ if (!this._direct) {
71
+ this._ownsLocalStream = !stream;
72
+ this.localStream = stream ?? await this.agent._getMicrophone();
73
+ const mic = this.localStream.getAudioTracks()[0];
74
+ pc.addTransceiver(mic ?? 'audio', { direction: 'sendrecv', streams: [this.localStream] });
75
+ }
76
+
77
+ pc.onicecandidate = (e) => {
78
+ if (!e.candidate || pc !== this.pc) return;
79
+ this.agent._send('connection:ice-candidate', {
80
+ callId: this.callId, connectionType: 'MONITOR',
81
+ candidate: { candidate: e.candidate.candidate, sdpMid: e.candidate.sdpMid, sdpMLineIndex: e.candidate.sdpMLineIndex },
82
+ });
83
+ };
84
+ pc.ontrack = (e) => {
85
+ if (pc !== this.pc) return;
86
+ // Every line Calloop sends goes into the one stream (a direct call: both sides).
87
+ if (!this.stream) this.stream = new MediaStream([e.track]);
88
+ else this.stream.addTrack(e.track);
89
+ this.emit('stream', this.stream, e.track);
90
+ };
91
+ pc.onconnectionstatechange = () => {
92
+ if (pc !== this.pc) return;
93
+ if (pc.connectionState === 'connected') { clearTimeout(this._timer); this._setState('active'); }
94
+ else if (pc.connectionState === 'failed') this.stop();
95
+ };
96
+ this._timer = setTimeout(() => { if (this.state === 'connecting') this.stop(); }, CONNECT_TIMEOUT_MS);
97
+
98
+ if (this._direct) {
99
+ this.agent._send('call:monitor', { callId: this.callId });
100
+ return;
101
+ }
102
+ await pc.setLocalDescription(await pc.createOffer());
103
+ this.agent._send('call:monitor', { callId: this.callId, sdpOffer: pc.localDescription.sdp });
104
+ }
105
+
106
+ // Calloop's offer, for a monitor started without one.
107
+ async _onOffer({ sdpOffer }) {
108
+ if (!this.pc || this.pc.remoteDescription) return;
109
+ await this.pc.setRemoteDescription({ type: 'offer', sdp: sdpOffer });
110
+ for (const c of this._pendingRemote.splice(0)) await this.pc.addIceCandidate(c).catch(() => { });
111
+ await this.pc.setLocalDescription(await this.pc.createAnswer());
112
+ this.agent._send('call:monitor:answer', { callId: this.callId, sdpAnswer: this.pc.localDescription.sdp });
113
+ }
114
+
115
+ async _onStarted({ sdpAnswer }) {
116
+ this._started = true;
117
+ if (!sdpAnswer || !this.pc || this.pc.remoteDescription) return;
118
+ await this.pc.setRemoteDescription({ type: 'answer', sdp: sdpAnswer });
119
+ for (const c of this._pendingRemote.splice(0)) await this.pc.addIceCandidate(c).catch(() => { });
120
+ }
121
+
122
+ async _onServerCandidate(candidate) {
123
+ if (!candidate) return;
124
+ if (this.pc?.remoteDescription) await this.pc.addIceCandidate(candidate).catch(() => { });
125
+ else this._pendingRemote.push(candidate);
126
+ }
127
+
128
+ // MONITOR_FAILED / MONITOR_MODE_NOT_ALLOWED for this call. Before Calloop
129
+ // confirmed the monitor it was refused and Calloop has already let it go
130
+ // (no leg, socket detached). Once started, only a mode change can fail:
131
+ // Calloop keeps the monitor in its current mode, and so do we.
132
+ _onError({ code, message }) {
133
+ if (this.state === 'ended') return;
134
+ if (this._started) {
135
+ const mode = this._requestedMode;
136
+ this._requestedMode = null;
137
+ this.emit('modeRefused', { mode, code, message });
138
+ return;
139
+ }
140
+ // A mode asked for while connecting may be what failed, with the
141
+ // monitor still up on Calloop's side: let it go there too.
142
+ if (this._requestedMode) this.agent._send('call:monitor:stop', { callId: this.callId });
143
+ this._end(code === 'MONITOR_MODE_NOT_ALLOWED' ? 'not_allowed' : 'failed', { code, message });
144
+ }
145
+
146
+ _onMode(mode) {
147
+ if (mode === this._requestedMode) this._requestedMode = null;
148
+ if (!MODES.has(mode) || mode === this.mode) return;
149
+ this.mode = mode;
150
+ this.emit('mode', mode);
151
+ }
152
+
153
+ _setState(next) {
154
+ if (this.state === next || this.state === 'ended') return;
155
+ const previous = this.state;
156
+ this.state = next;
157
+ this.emit('state', next, previous);
158
+ }
159
+
160
+ _end(reason, details = {}) {
161
+ if (this.state === 'ended') return;
162
+ clearTimeout(this._timer);
163
+ const pc = this.pc;
164
+ this.pc = null;
165
+ try { pc?.close(); } catch { /* already closed */ }
166
+ if (this._ownsLocalStream) for (const t of this.localStream?.getTracks() ?? []) t.stop();
167
+ const previous = this.state;
168
+ this.state = 'ended';
169
+ this.emit('state', 'ended', previous);
170
+ this.emit('ended', { reason, ...details });
171
+ this.agent._forgetMonitor(this, reason);
172
+ }
173
+ }
package/src/emitter.js CHANGED
@@ -1,25 +1,25 @@
1
- // A minimal event emitter (browser and Node, no dependencies).
2
- export class Emitter {
3
- #handlers = new Map();
4
-
5
- on(event, fn) {
6
- if (!this.#handlers.has(event)) this.#handlers.set(event, new Set());
7
- this.#handlers.get(event).add(fn);
8
- return () => this.off(event, fn);
9
- }
10
-
11
- once(event, fn) {
12
- const off = this.on(event, (...args) => { off(); fn(...args); });
13
- return off;
14
- }
15
-
16
- off(event, fn) {
17
- this.#handlers.get(event)?.delete(fn);
18
- }
19
-
20
- emit(event, ...args) {
21
- for (const fn of [...(this.#handlers.get(event) ?? [])]) {
22
- try { fn(...args); } catch (err) { console.error(`[calloop] "${event}" handler threw:`, err); }
23
- }
24
- }
25
- }
1
+ // A minimal event emitter (browser and Node, no dependencies).
2
+ export class Emitter {
3
+ #handlers = new Map();
4
+
5
+ on(event, fn) {
6
+ if (!this.#handlers.has(event)) this.#handlers.set(event, new Set());
7
+ this.#handlers.get(event).add(fn);
8
+ return () => this.off(event, fn);
9
+ }
10
+
11
+ once(event, fn) {
12
+ const off = this.on(event, (...args) => { off(); fn(...args); });
13
+ return off;
14
+ }
15
+
16
+ off(event, fn) {
17
+ this.#handlers.get(event)?.delete(fn);
18
+ }
19
+
20
+ emit(event, ...args) {
21
+ for (const fn of [...(this.#handlers.get(event) ?? [])]) {
22
+ try { fn(...args); } catch (err) { console.error(`[calloop] "${event}" handler threw:`, err); }
23
+ }
24
+ }
25
+ }
package/src/index.js CHANGED
@@ -1,5 +1,5 @@
1
- // @calloop/agent-sdk — the agent side of Calloop's agent protocol
2
- // (docs/agent-protocol.md) for browsers and Node.
3
- export { connect, CalloopAgent } from './CalloopAgent.js';
4
- export { Call } from './Call.js';
5
- export { Monitor } from './Monitor.js';
1
+ // @calloop/agent-sdk — the agent side of Calloop's agent protocol
2
+ // (docs/agent-protocol.md) for browsers and Node.
3
+ export { connect, CalloopAgent } from './CalloopAgent.js';
4
+ export { Call } from './Call.js';
5
+ export { Monitor } from './Monitor.js';