@coventia/softphone 0.1.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.
Files changed (54) hide show
  1. package/dist/{call-BTYlT2SU.d.cts → call-VKq1I8yP.d.cts} +158 -5
  2. package/dist/{call-BTYlT2SU.d.ts → call-VKq1I8yP.d.ts} +158 -5
  3. package/dist/{chunk-N2N2EQQJ.js → chunk-7R6LOOAI.js} +4 -2
  4. package/dist/chunk-7R6LOOAI.js.map +1 -0
  5. package/dist/{chunk-5BGEHMPP.js → chunk-EYEDPD7Y.js} +146 -20
  6. package/dist/chunk-EYEDPD7Y.js.map +1 -0
  7. package/dist/{chunk-3V2L6UTK.js → chunk-LMJXX4YM.js} +17 -3
  8. package/dist/chunk-LMJXX4YM.js.map +1 -0
  9. package/dist/element/index.d.ts +162 -7
  10. package/dist/element/index.js +866 -421
  11. package/dist/element/index.js.map +1 -1
  12. package/dist/{engine-D9nbeY08.d.cts → engine-BlQBo2Ys.d.cts} +1 -1
  13. package/dist/{engine-Doa6Brkm.d.ts → engine-C0BOkIcW.d.ts} +1 -1
  14. package/dist/index.cjs +586 -28
  15. package/dist/index.cjs.map +1 -1
  16. package/dist/index.d.cts +5 -5
  17. package/dist/index.d.ts +5 -5
  18. package/dist/index.js +335 -8
  19. package/dist/index.js.map +1 -1
  20. package/dist/react/index.cjs +15 -0
  21. package/dist/react/index.cjs.map +1 -1
  22. package/dist/react/index.d.cts +14 -3
  23. package/dist/react/index.d.ts +14 -3
  24. package/dist/react/index.js +2 -2
  25. package/dist/{softphone-DhuhcKCy.d.cts → softphone-Cyq3hiIM.d.ts} +17 -2
  26. package/dist/{softphone-DHmhotbf.d.ts → softphone-DlGV12Xp.d.cts} +17 -2
  27. package/dist/softphone.css +477 -35
  28. package/dist/{sources-N4BOEDRL.js → sources-IRKNQPWW.js} +108 -5
  29. package/dist/sources-IRKNQPWW.js.map +1 -0
  30. package/dist/testing/index.cjs +41 -5
  31. package/dist/testing/index.cjs.map +1 -1
  32. package/dist/testing/index.d.cts +8 -2
  33. package/dist/testing/index.d.ts +8 -2
  34. package/dist/testing/index.js +41 -5
  35. package/dist/testing/index.js.map +1 -1
  36. package/dist/ui/index.cjs +846 -410
  37. package/dist/ui/index.cjs.map +1 -1
  38. package/dist/ui/index.d.cts +17 -6
  39. package/dist/ui/index.d.ts +17 -6
  40. package/dist/ui/index.js +878 -456
  41. package/dist/ui/index.js.map +1 -1
  42. package/dist/zadarma/index.cjs +144 -18
  43. package/dist/zadarma/index.cjs.map +1 -1
  44. package/dist/zadarma/index.d.cts +2 -2
  45. package/dist/zadarma/index.d.ts +2 -2
  46. package/dist/zadarma/index.js +1 -1
  47. package/dist/zadarma-engine-L5DEFXUS.js +5 -0
  48. package/dist/{zadarma-engine-DN2SZSQ3.js.map → zadarma-engine-L5DEFXUS.js.map} +1 -1
  49. package/package.json +1 -1
  50. package/dist/chunk-3V2L6UTK.js.map +0 -1
  51. package/dist/chunk-5BGEHMPP.js.map +0 -1
  52. package/dist/chunk-N2N2EQQJ.js.map +0 -1
  53. package/dist/sources-N4BOEDRL.js.map +0 -1
  54. package/dist/zadarma-engine-DN2SZSQ3.js +0 -5
package/dist/index.cjs CHANGED
@@ -92,7 +92,7 @@ var init_token_cache = __esm({
92
92
  });
93
93
 
94
94
  // src/coventia/api.ts
95
- function createGet(config) {
95
+ function createRequest(config, method) {
96
96
  const doFetch = config.fetch ? config.fetch : (...args) => globalThis.fetch(...args);
97
97
  const base = config.baseUrl.replace(/\/+$/, "");
98
98
  async function attempt(path, params, signal) {
@@ -103,6 +103,7 @@ function createGet(config) {
103
103
  }
104
104
  const query = qs.toString();
105
105
  const res = await doFetch(`${base}${path}${query ? `?${query}` : ""}`, {
106
+ method,
106
107
  // The RAW token, no `Bearer ` prefix — see `VoiceToken.token` (SPEC §6.1).
107
108
  headers: { Authorization: token },
108
109
  ...signal ? { signal } : {}
@@ -115,7 +116,7 @@ function createGet(config) {
115
116
  }
116
117
  return body;
117
118
  }
118
- return async function get(path, params = {}, signal) {
119
+ return async function request(path, params = {}, signal) {
119
120
  try {
120
121
  return await attempt(path, params, signal);
121
122
  } catch (error) {
@@ -125,6 +126,12 @@ function createGet(config) {
125
126
  }
126
127
  };
127
128
  }
129
+ function createGet(config) {
130
+ return createRequest(config, "GET");
131
+ }
132
+ function createPost(config) {
133
+ return createRequest(config, "POST");
134
+ }
128
135
  var ApiError;
129
136
  var init_api = __esm({
130
137
  "src/coventia/api.ts"() {
@@ -167,6 +174,7 @@ function resolverFromContacts(contacts) {
167
174
  }
168
175
  function coventiaSources(config) {
169
176
  const get = createGet(config);
177
+ const post = createPost(config);
170
178
  const base = config.basePath ?? "/phone";
171
179
  const org = config.organizationId ? { organization_id: config.organizationId } : {};
172
180
  const coventiaContacts = async ({ search, skip, limit }) => {
@@ -251,12 +259,93 @@ function coventiaSources(config) {
251
259
  people: (body.people ?? []).map(toAgent)
252
260
  };
253
261
  };
262
+ const coventiaTransferOutcomes = async ({ since }) => {
263
+ const body = await get(
264
+ `${base}/transfers/outcomes`,
265
+ { ...org, since }
266
+ );
267
+ return {
268
+ cursor: body.cursor ?? "0",
269
+ outcomes: (body.outcomes ?? []).map(toTransferOutcome)
270
+ };
271
+ };
272
+ const coventiaPresence = (callbacks) => {
273
+ const WS = config.WebSocket ?? globalThis.WebSocket;
274
+ return new Promise((resolve2, reject) => {
275
+ void (async () => {
276
+ let ticket;
277
+ try {
278
+ const body = await post(`${base}/presence/ticket`, org);
279
+ if (!body.ticket) throw new Error("presence ticket mint returned no ticket");
280
+ ticket = body.ticket;
281
+ } catch (error) {
282
+ reject(error instanceof Error ? error : new Error(String(error)));
283
+ return;
284
+ }
285
+ const wsUrl = `${toWebSocketUrl(config.baseUrl)}${base}/presence?ticket=${encodeURIComponent(ticket)}`;
286
+ const ws = new WS(wsUrl);
287
+ let settled = false;
288
+ ws.onopen = () => {
289
+ if (settled) return;
290
+ settled = true;
291
+ resolve2({
292
+ reportCallState: (onCall) => {
293
+ if (ws.readyState !== WS.OPEN) return;
294
+ ws.send(JSON.stringify({ type: "call-state", onCall }));
295
+ },
296
+ bye: () => {
297
+ if (ws.readyState !== WS.OPEN) return;
298
+ ws.send(JSON.stringify({ type: "bye" }));
299
+ },
300
+ close: () => {
301
+ ws.onclose = null;
302
+ ws.onerror = null;
303
+ ws.close();
304
+ }
305
+ });
306
+ };
307
+ ws.onmessage = (event) => {
308
+ let data;
309
+ try {
310
+ data = JSON.parse(String(event.data));
311
+ } catch {
312
+ return;
313
+ }
314
+ const msg = data;
315
+ if (msg.type === "snapshot") {
316
+ callbacks.onSnapshot((msg.agents ?? []).map(toPresenceEntry));
317
+ } else if (msg.type === "delta" && msg.agent) {
318
+ callbacks.onDelta(toPresenceEntry(msg.agent));
319
+ }
320
+ };
321
+ ws.onclose = () => {
322
+ if (!settled) {
323
+ settled = true;
324
+ reject(new Error("presence socket closed before opening"));
325
+ return;
326
+ }
327
+ callbacks.onClose();
328
+ };
329
+ ws.onerror = () => {
330
+ };
331
+ })();
332
+ });
333
+ };
254
334
  const contacts = config.contacts ?? coventiaContacts;
255
335
  const resolve = config.resolveContact ?? (config.contacts ? resolverFromContacts(config.contacts) : coventiaResolve);
256
336
  const history = withNameResolution(rawHistory, resolve);
257
- return { contacts, history, resolveContact: resolve, listAgents: coventiaListAgents };
337
+ const transferOutcomes = config.transferOutcomes ?? coventiaTransferOutcomes;
338
+ const presence = config.presence ?? coventiaPresence;
339
+ return {
340
+ contacts,
341
+ history,
342
+ resolveContact: resolve,
343
+ listAgents: coventiaListAgents,
344
+ transferOutcomes,
345
+ presence
346
+ };
258
347
  }
259
- var toContact, toDirection, otherParty, isMissed, toOutcome, toDate;
348
+ var toContact, toDirection, otherParty, isMissed, toOutcome, toDate, toTransferOutcome, toPresenceState, toPresenceEntry, toWebSocketUrl;
260
349
  var init_sources2 = __esm({
261
350
  "src/coventia/sources.ts"() {
262
351
  init_sources();
@@ -291,6 +380,20 @@ var init_sources2 = __esm({
291
380
  const d = new Date(raw);
292
381
  return Number.isNaN(d.getTime()) ? /* @__PURE__ */ new Date(0) : d;
293
382
  };
383
+ toTransferOutcome = (row) => ({
384
+ id: row.id ?? "",
385
+ target: row.target ?? "",
386
+ status: row.status === "connected" ? "connected" : "not-connected",
387
+ at: toDate(row.at)
388
+ });
389
+ toPresenceState = (raw) => raw === "disponible" || raw === "en_llamada" ? raw : "desconectado";
390
+ toPresenceEntry = (row) => ({
391
+ userId: row.userId ?? "",
392
+ extension: row.extension ?? "",
393
+ name: row.name ?? null,
394
+ state: toPresenceState(row.state)
395
+ });
396
+ toWebSocketUrl = (httpUrl) => httpUrl.replace(/^http/, "ws").replace(/\/+$/, "");
294
397
  }
295
398
  });
296
399
 
@@ -746,6 +849,14 @@ var init_ua = __esm({
746
849
  #uaInFlight = null;
747
850
  #domain = "";
748
851
  #activeCall = null;
852
+ /**
853
+ * The consult leg from `startConsult` (SPEC §5.6), if one is live on this UA right now.
854
+ *
855
+ * Tracked separately from `#activeCall` so `#maybeTeardownUA` can tell "both legs are actually gone"
856
+ * apart from "the main leg ended, but a consult is still up" — see that method's own comment for the
857
+ * bug this exists to prevent.
858
+ */
859
+ #consultCall = null;
749
860
  /** The chosen microphone, or `null` for the browser default. Read fresh by every new leg's
750
861
  * `mediaConstraints`, and pushed live to `#activeCall` by `setInputDevice`. */
751
862
  #inputDeviceId = null;
@@ -864,9 +975,7 @@ var init_ua = __esm({
864
975
  const ms = this.provider.quirks?.noInviteTeardownMs;
865
976
  if (!ms) return;
866
977
  this.#clearNoInviteTimer();
867
- this.#noInviteTimer = setTimeout(() => {
868
- if (!this.#activeCall) this.#teardownUA();
869
- }, ms);
978
+ this.#noInviteTimer = setTimeout(() => this.#maybeTeardownUA(), ms);
870
979
  }
871
980
  /**
872
981
  * Rebuild the incoming-signal channel with fresh credentials, after a registration failure.
@@ -988,6 +1097,7 @@ var init_ua = __esm({
988
1097
  }
989
1098
  this.#ua = null;
990
1099
  this.#activeCall = null;
1100
+ this.#consultCall = null;
991
1101
  }
992
1102
  /** Place a call once the UA is registered (its 200 OK for REGISTER has arrived). */
993
1103
  /**
@@ -1030,7 +1140,7 @@ var init_ua = __esm({
1030
1140
  const code = missed ? "call/missed" : CALL_END_CODES[event.reason];
1031
1141
  if (code) this.#reportError(code, event.cause);
1032
1142
  if (this.#activeCall === call) this.#activeCall = null;
1033
- if (this.provider.registrationMode === "on-demand") this.#teardownUA();
1143
+ this.#maybeTeardownUA();
1034
1144
  }
1035
1145
  });
1036
1146
  return call;
@@ -1041,12 +1151,38 @@ var init_ua = __esm({
1041
1151
  mediaConstraints: buildAudioConstraints(this.#inputDeviceId),
1042
1152
  pcConfig: PC_CONFIG
1043
1153
  });
1044
- return new SipEngineCall(consultSession, {
1154
+ const consultCall = new SipEngineCall(consultSession, {
1045
1155
  id: nextCallId(),
1046
1156
  buildTarget: (x) => this.#buildTarget(x),
1047
1157
  placeConsult: () => Promise.reject(new Error("cannot consult from a consult leg")),
1048
1158
  iceGatheringCapMs: this.provider.quirks?.iceGatheringCapMs ?? DEFAULT_ICE_CAP_MS
1049
1159
  });
1160
+ this.#consultCall = consultCall;
1161
+ consultCall.onEvent((event) => {
1162
+ if (event.type === "ended") {
1163
+ if (this.#consultCall === consultCall) this.#consultCall = null;
1164
+ this.#maybeTeardownUA();
1165
+ }
1166
+ });
1167
+ return consultCall;
1168
+ }
1169
+ /**
1170
+ * On-demand registration (SPEC §3.3): the UA is disposable once NOTHING is left on it — but "the
1171
+ * main leg ended" (or "no INVITE followed this push") is not the same fact as "nothing is left."
1172
+ * `completeTransfer()` (SPEC §5.6) ends the main leg via a REFER…Replaces BYE *while the consult leg
1173
+ * is still what the PBX needs to bridge the caller onto* — tearing the UA down (and with it, EVERY
1174
+ * session on it, via `terminateSessions`) the instant the main leg's `ended` fires killed that
1175
+ * consult leg out from under the transfer before the bridge could use it, and the caller was simply
1176
+ * gone. Two real call sites hit this independently (found live, 2026-08-04): the main leg's own
1177
+ * `ended` handler, and `#armNoInviteTeardown`'s 5s timer — the latter re-arms on EVERY push,
1178
+ * including unrelated "answered" pushes Zadarma sends around other account activity, so it can fire
1179
+ * minutes after a transfer with no `#activeCall` and no idea a consult leg is mid-handoff. Both now
1180
+ * route through here rather than checking `#activeCall` alone.
1181
+ */
1182
+ #maybeTeardownUA() {
1183
+ if (this.provider.registrationMode !== "on-demand") return;
1184
+ if (this.#activeCall || this.#consultCall) return;
1185
+ this.#teardownUA();
1050
1186
  }
1051
1187
  #startRejectGuard() {
1052
1188
  const ms = this.provider.quirks?.rejectGuardMs;
@@ -1090,11 +1226,13 @@ function buildPushUrl(key, sip) {
1090
1226
  const params = new URLSearchParams({ key, integrationType: "site", sipId: sip });
1091
1227
  return `wss://ws.zadarma.com/webrtc_pushes?${params.toString()}`;
1092
1228
  }
1093
- var PING, KEY_REFRESH_BUFFER_MS, ZADARMA_QUIRKS, ZadarmaPushSignal, ZadarmaProvider;
1229
+ var PING, KEY_REFRESH_BUFFER_MS, REBUILD_CONNECT_TIMEOUT_MS, REBUILD_RETRY_MS, ZADARMA_QUIRKS, ZadarmaPushSignal, ZadarmaProvider;
1094
1230
  var init_zadarma = __esm({
1095
1231
  "src/transports/providers/zadarma.ts"() {
1096
1232
  PING = { localMs: 3e4, globalMs: 6e4 };
1097
1233
  KEY_REFRESH_BUFFER_MS = 2 * 60 * 60 * 1e3;
1234
+ REBUILD_CONNECT_TIMEOUT_MS = 1e4;
1235
+ REBUILD_RETRY_MS = 3e4;
1098
1236
  ZADARMA_QUIRKS = {
1099
1237
  // Zadarma's registrar REFUSES to register a WebRTC client with any other User-Agent — a clean 401
1100
1238
  // after a valid digest challenge, indistinguishable from a wrong password (§3.1). The old CRM
@@ -1118,6 +1256,12 @@ var init_zadarma = __esm({
1118
1256
  #connected = false;
1119
1257
  #pingTimers = [];
1120
1258
  #refreshTimer;
1259
+ /** The replacement socket of a rebuild in flight, not yet current. */
1260
+ #candidate = null;
1261
+ /** Abandons the rebuild in flight (closing its candidate). */
1262
+ #abortRebuild = null;
1263
+ #rebuilding = false;
1264
+ #retryTimer;
1121
1265
  #unwatchBackground;
1122
1266
  #closed = false;
1123
1267
  #listeners = /* @__PURE__ */ new Set();
@@ -1133,20 +1277,35 @@ var init_zadarma = __esm({
1133
1277
  if (this.#closed) return;
1134
1278
  const socket = this.deps.createPushSocket(buildPushUrl(key, sip));
1135
1279
  this.#socket = socket;
1280
+ this.#attach(socket);
1281
+ this.#armRefresh(keyExpiresAt);
1282
+ }
1283
+ /**
1284
+ * Wires one socket's events. `this.#socket` is read when an event FIRES, not when it is wired: a socket that
1285
+ * has been replaced must stop driving connectivity (our own `close()` of it emits `disconnect`, which would
1286
+ * otherwise flip the state to offline right after a perfectly healthy swap). `update` is the exception — it is
1287
+ * forwarded from any socket not yet closed, because a push seen twice is harmless (the engine ignores a
1288
+ * repeated signal mid-call and reuses a UA that already exists) while one seen by neither is a missed call.
1289
+ *
1290
+ * With `hooks`, the socket is a rebuild's candidate: its first `connect` or failure goes to the rebuild instead
1291
+ * of changing the connection state (it is not current yet).
1292
+ */
1293
+ #attach(socket, hooks) {
1136
1294
  socket.on("connect", () => {
1295
+ if (hooks && socket === this.#candidate) return hooks.onConnect();
1296
+ if (socket !== this.#socket) return;
1137
1297
  this.#setConnected(true);
1138
1298
  this.#startPings(socket);
1139
1299
  });
1140
1300
  socket.on("update", () => this.#emit());
1141
- socket.on("disconnect", () => {
1142
- this.#stopPings();
1143
- this.#setConnected(false);
1144
- });
1145
- socket.on("connect_error", () => {
1301
+ const lost = () => {
1302
+ if (hooks && socket === this.#candidate) return hooks.onFailure();
1303
+ if (socket !== this.#socket) return;
1146
1304
  this.#stopPings();
1147
1305
  this.#setConnected(false);
1148
- });
1149
- this.#armRefresh(keyExpiresAt);
1306
+ };
1307
+ socket.on("disconnect", lost);
1308
+ socket.on("connect_error", lost);
1150
1309
  }
1151
1310
  /**
1152
1311
  * Presence pings — **the reason an idle tab keeps receiving calls**.
@@ -1185,22 +1344,92 @@ var init_zadarma = __esm({
1185
1344
  if (!Number.isFinite(ms) || ms <= 0) return;
1186
1345
  this.#refreshTimer = setTimeout(() => void this.#reopen(), ms);
1187
1346
  }
1347
+ /**
1348
+ * Replaces the push socket with a fresh one — MAKE-BEFORE-BREAK (presence-client-reporting.md §4.1, T8).
1349
+ *
1350
+ * Why it must happen at all: Zadarma's gateway decides the extension is alive from the raw Engine.IO ping, which
1351
+ * a hidden tab's throttled timers silently stop sending, so while the tab is hidden the socket is rebuilt every
1352
+ * 20 s (`push-socket-reliability.md` §5.3), and once before the key expires. Why it must not leave a gap: the
1353
+ * old close-then-reopen reported `connected = false` for the ~0.5 s in between, which took the softphone
1354
+ * `offline`, tore down the presence socket and left Zadarma with no socket to push an incoming call to.
1355
+ *
1356
+ * Now the OLD socket stays up and stays current until the NEW one has actually connected; only then do we swap,
1357
+ * move the pings and close the old one. A healthy rebuild never changes `connected`. A failed one — the key fetch
1358
+ * throws, the socket errors, or it has not connected within `REBUILD_CONNECT_TIMEOUT_MS` — keeps the old socket
1359
+ * and retries, instead of leaving the tab disconnected. Zadarma does not evict the older of two sockets for one
1360
+ * sip (multi-tab-leader-election.md §1), so the overlap is safe.
1361
+ */
1188
1362
  async #reopen() {
1189
- if (this.#closed) return;
1190
- this.#stopPings();
1191
- this.#socket?.close();
1192
- this.#socket = null;
1193
- this.#setConnected(false);
1363
+ if (this.#closed || this.#rebuilding) return;
1364
+ if (!this.#socket) {
1365
+ try {
1366
+ await this.#open();
1367
+ } catch {
1368
+ }
1369
+ return;
1370
+ }
1371
+ this.#rebuilding = true;
1194
1372
  try {
1195
- await this.#open();
1373
+ const { key, sip, keyExpiresAt } = await this.deps.fetchKey();
1374
+ if (this.#closed) return;
1375
+ const swapped = await new Promise((resolve) => {
1376
+ const socket = this.deps.createPushSocket(buildPushUrl(key, sip));
1377
+ this.#candidate = socket;
1378
+ let settled = false;
1379
+ const finish = (ok) => {
1380
+ if (settled) return;
1381
+ settled = true;
1382
+ clearTimeout(timer);
1383
+ this.#abortRebuild = null;
1384
+ if (ok && socket.connected) {
1385
+ this.#swapIn(socket, keyExpiresAt);
1386
+ } else {
1387
+ this.#candidate = null;
1388
+ socket.close();
1389
+ }
1390
+ resolve(ok && socket.connected);
1391
+ };
1392
+ const timer = setTimeout(() => finish(false), REBUILD_CONNECT_TIMEOUT_MS);
1393
+ this.#abortRebuild = () => finish(false);
1394
+ this.#attach(socket, { onConnect: () => finish(true), onFailure: () => finish(false) });
1395
+ });
1396
+ if (!swapped) this.#armRetry();
1196
1397
  } catch {
1398
+ this.#armRetry();
1399
+ } finally {
1400
+ this.#rebuilding = false;
1197
1401
  }
1198
1402
  }
1403
+ /**
1404
+ * The moment of the swap — synchronous, called from the candidate's own `connect` event so nothing can run
1405
+ * between "the new socket is up" and "it is the current one". Order matters: `#socket` moves first, so the old
1406
+ * socket's `disconnect` (our own `close()` of it, just below) is ignored by `#attach`.
1407
+ */
1408
+ #swapIn(socket, keyExpiresAt) {
1409
+ const previous = this.#socket;
1410
+ this.#socket = socket;
1411
+ this.#candidate = null;
1412
+ clearTimeout(this.#retryTimer);
1413
+ this.#retryTimer = void 0;
1414
+ this.#stopPings();
1415
+ this.#startPings(socket);
1416
+ this.#setConnected(true);
1417
+ previous?.close();
1418
+ this.#armRefresh(keyExpiresAt);
1419
+ }
1420
+ #armRetry() {
1421
+ if (this.#closed) return;
1422
+ clearTimeout(this.#retryTimer);
1423
+ this.#retryTimer = setTimeout(() => void this.#reopen(), REBUILD_RETRY_MS);
1424
+ }
1199
1425
  disconnect() {
1200
1426
  this.#closed = true;
1201
1427
  this.#stopPings();
1202
1428
  clearTimeout(this.#refreshTimer);
1203
1429
  this.#refreshTimer = void 0;
1430
+ clearTimeout(this.#retryTimer);
1431
+ this.#retryTimer = void 0;
1432
+ this.#abortRebuild?.();
1204
1433
  this.#unwatchBackground?.();
1205
1434
  this.#unwatchBackground = void 0;
1206
1435
  this.#socket?.close();
@@ -1517,6 +1746,8 @@ var SoftphoneEvent = /* @__PURE__ */ ((SoftphoneEvent2) => {
1517
1746
  SoftphoneEvent2["DeviceChange"] = "deviceChange";
1518
1747
  SoftphoneEvent2["Error"] = "error";
1519
1748
  SoftphoneEvent2["LeaderChanged"] = "leaderChanged";
1749
+ SoftphoneEvent2["TransferOutcome"] = "transferOutcome";
1750
+ SoftphoneEvent2["PresenceChanged"] = "presenceChanged";
1520
1751
  return SoftphoneEvent2;
1521
1752
  })(SoftphoneEvent || {});
1522
1753
  var CallEvent = /* @__PURE__ */ ((CallEvent2) => {
@@ -1555,13 +1786,17 @@ function canTransition(from, to) {
1555
1786
  function isTerminal(state) {
1556
1787
  return CALL_TRANSITIONS[state].length === 0;
1557
1788
  }
1789
+ function isOnCallState(state) {
1790
+ return state !== "ringing" && !isTerminal(state);
1791
+ }
1558
1792
 
1559
1793
  // src/core/call-impl.ts
1560
1794
  var CallImpl = class _CallImpl extends Emitter {
1561
- constructor(engineCall, onListenerError, onInternalError) {
1795
+ constructor(engineCall, onListenerError, onInternalError, onTransfer) {
1562
1796
  super(onListenerError);
1563
1797
  this.engineCall = engineCall;
1564
1798
  this.onInternalError = onInternalError;
1799
+ this.onTransfer = onTransfer;
1565
1800
  this.#onListenerError = onListenerError;
1566
1801
  this.#state = INITIAL_STATES[engineCall.direction];
1567
1802
  this.#remote = {
@@ -1573,6 +1808,7 @@ var CallImpl = class _CallImpl extends Emitter {
1573
1808
  }
1574
1809
  engineCall;
1575
1810
  onInternalError;
1811
+ onTransfer;
1576
1812
  #state;
1577
1813
  #remote;
1578
1814
  #answeredAt = null;
@@ -1658,7 +1894,10 @@ var CallImpl = class _CallImpl extends Emitter {
1658
1894
  }
1659
1895
  async hangup() {
1660
1896
  if (isTerminal(this.#state)) return;
1661
- await this.engineCall.hangup();
1897
+ const consult = this.#consultCall;
1898
+ this.#consultCall = null;
1899
+ this.#consultEngineCall = null;
1900
+ await Promise.all([consult?.hangup() ?? Promise.resolve(), this.engineCall.hangup()]);
1662
1901
  }
1663
1902
  mute(muted) {
1664
1903
  if (this.#muted === muted) return;
@@ -1680,6 +1919,7 @@ var CallImpl = class _CallImpl extends Emitter {
1680
1919
  }
1681
1920
  async transfer(target) {
1682
1921
  await this.engineCall.transfer(target);
1922
+ this.onTransfer?.(target, this.#remote.number.trim() || null);
1683
1923
  }
1684
1924
  async startConsult(target) {
1685
1925
  await this.hold();
@@ -1715,6 +1955,12 @@ var CallImpl = class _CallImpl extends Emitter {
1715
1955
  }
1716
1956
  #end(reason) {
1717
1957
  if (isTerminal(this.#state)) return;
1958
+ if (this.#consultCall) {
1959
+ const consult = this.#consultCall;
1960
+ this.#consultCall = null;
1961
+ this.#consultEngineCall = null;
1962
+ void consult.hangup();
1963
+ }
1718
1964
  const from = this.#state;
1719
1965
  this.#state = "ended";
1720
1966
  this.emit("stateChanged" /* StateChanged */, { state: "ended", previous: from });
@@ -1758,7 +2004,9 @@ function coventiaBackend(config, tokenSource, fallbackBaseUrl) {
1758
2004
  contacts: async (params) => (await load()).contacts(params),
1759
2005
  history: async (params) => (await load()).history(params),
1760
2006
  resolveContact: async (number) => (await load()).resolveContact(number),
1761
- listAgents: async () => (await load()).listAgents()
2007
+ listAgents: async () => (await load()).listAgents(),
2008
+ transferOutcomes: async (params) => (await load()).transferOutcomes(params),
2009
+ presence: async (callbacks) => (await load()).presence(callbacks)
1762
2010
  };
1763
2011
  }
1764
2012
 
@@ -1806,9 +2054,176 @@ function webLocksTabLock(lockName = LOCK_NAME) {
1806
2054
  };
1807
2055
  }
1808
2056
 
2057
+ // src/core/transfer-watch.ts
2058
+ var FAST_INTERVAL_MS = 2e3;
2059
+ var FAST_WINDOW_MS = 1e4;
2060
+ var SLOW_INTERVAL_MS = 5e3;
2061
+ var DEADLINE_MS = 9e4;
2062
+ var EXTENSION_LIKE = /^\d{1,6}$/;
2063
+ function startTransferWatch(params) {
2064
+ const {
2065
+ target,
2066
+ callerNumber,
2067
+ source,
2068
+ getIsLeader,
2069
+ emit,
2070
+ setTimeout: scheduleTimeout = globalThis.setTimeout,
2071
+ clearTimeout: cancelTimeout = globalThis.clearTimeout,
2072
+ now = () => Date.now()
2073
+ } = params;
2074
+ if (!EXTENSION_LIKE.test(target)) return () => {
2075
+ };
2076
+ if (!getIsLeader()) return () => {
2077
+ };
2078
+ let stopped = false;
2079
+ let timer = null;
2080
+ let cursor = null;
2081
+ const startedAt = now();
2082
+ const stop = () => {
2083
+ stopped = true;
2084
+ if (timer !== null) {
2085
+ cancelTimeout(timer);
2086
+ timer = null;
2087
+ }
2088
+ };
2089
+ const finish = (status) => {
2090
+ stop();
2091
+ emit({ status, target, caller: callerNumber, at: new Date(now()) });
2092
+ };
2093
+ const scheduleNext = () => {
2094
+ if (stopped) return;
2095
+ const elapsed = now() - startedAt;
2096
+ if (elapsed >= DEADLINE_MS) {
2097
+ stop();
2098
+ return;
2099
+ }
2100
+ timer = scheduleTimeout(() => void poll(), elapsed < FAST_WINDOW_MS ? FAST_INTERVAL_MS : SLOW_INTERVAL_MS);
2101
+ };
2102
+ const poll = async () => {
2103
+ if (stopped) return;
2104
+ if (!getIsLeader()) {
2105
+ stop();
2106
+ return;
2107
+ }
2108
+ let page;
2109
+ try {
2110
+ page = await source(cursor === null ? {} : { since: cursor });
2111
+ } catch {
2112
+ if (!stopped) scheduleNext();
2113
+ return;
2114
+ }
2115
+ if (stopped) return;
2116
+ if (cursor === null) {
2117
+ cursor = page.cursor;
2118
+ } else {
2119
+ cursor = page.cursor;
2120
+ const match = page.outcomes.find((o) => o.target === target);
2121
+ if (match) {
2122
+ finish(match.status);
2123
+ return;
2124
+ }
2125
+ }
2126
+ scheduleNext();
2127
+ };
2128
+ void poll();
2129
+ return stop;
2130
+ }
2131
+
2132
+ // src/core/presence-socket.ts
2133
+ var RECONNECT_BASE_MS = 1e3;
2134
+ var RECONNECT_MAX_MS = 3e4;
2135
+ var NOOP_WATCH = { reportCallState: () => {
2136
+ }, stop: () => {
2137
+ } };
2138
+ function startPresenceSocket(params) {
2139
+ const {
2140
+ source,
2141
+ getIsLeader,
2142
+ onAgentsChanged,
2143
+ setTimeout: scheduleTimeout = globalThis.setTimeout,
2144
+ clearTimeout: cancelTimeout = globalThis.clearTimeout,
2145
+ random = Math.random
2146
+ } = params;
2147
+ if (!getIsLeader()) return NOOP_WATCH;
2148
+ let stopped = false;
2149
+ let attempt = 0;
2150
+ let reconnectTimer = null;
2151
+ let agents = /* @__PURE__ */ new Map();
2152
+ let lastReportedOnCall = null;
2153
+ let connection = null;
2154
+ let byeOnArrival = false;
2155
+ const stop = (options) => {
2156
+ if (stopped) return;
2157
+ stopped = true;
2158
+ if (reconnectTimer !== null) {
2159
+ cancelTimeout(reconnectTimer);
2160
+ reconnectTimer = null;
2161
+ }
2162
+ if (options?.bye) {
2163
+ byeOnArrival = true;
2164
+ connection?.bye?.();
2165
+ }
2166
+ connection?.close();
2167
+ connection = null;
2168
+ };
2169
+ const scheduleReconnect = () => {
2170
+ if (stopped) return;
2171
+ attempt += 1;
2172
+ const capped = Math.min(RECONNECT_BASE_MS * 2 ** (attempt - 1), RECONNECT_MAX_MS);
2173
+ const delay = capped * random();
2174
+ reconnectTimer = scheduleTimeout(() => void connect(), delay);
2175
+ };
2176
+ const connect = async () => {
2177
+ if (stopped) return;
2178
+ if (!getIsLeader()) {
2179
+ stop();
2180
+ return;
2181
+ }
2182
+ let opened;
2183
+ try {
2184
+ opened = await source({
2185
+ onSnapshot: (snapshot) => {
2186
+ agents = new Map(snapshot.map((agent) => [agent.userId, agent]));
2187
+ onAgentsChanged([...agents.values()]);
2188
+ },
2189
+ onDelta: (agent) => {
2190
+ agents.set(agent.userId, agent);
2191
+ onAgentsChanged([...agents.values()]);
2192
+ },
2193
+ onClose: () => {
2194
+ if (connection === null) return;
2195
+ connection = null;
2196
+ scheduleReconnect();
2197
+ }
2198
+ });
2199
+ } catch {
2200
+ if (!stopped) scheduleReconnect();
2201
+ return;
2202
+ }
2203
+ if (stopped) {
2204
+ if (byeOnArrival) opened.bye?.();
2205
+ opened.close();
2206
+ return;
2207
+ }
2208
+ connection = opened;
2209
+ attempt = 0;
2210
+ if (lastReportedOnCall !== null) connection.reportCallState(lastReportedOnCall);
2211
+ };
2212
+ void connect();
2213
+ return {
2214
+ reportCallState: (onCall) => {
2215
+ if (onCall === lastReportedOnCall) return;
2216
+ lastReportedOnCall = onCall;
2217
+ connection?.reportCallState(onCall);
2218
+ },
2219
+ stop
2220
+ };
2221
+ }
2222
+
1809
2223
  // src/core/softphone-impl.ts
1810
2224
  init_errors();
1811
2225
  var liveInstance = false;
2226
+ var PRESENCE_TEARDOWN_GRACE_MS = 1e4;
1812
2227
  var BrowserAudioDevices = class {
1813
2228
  inputs = [];
1814
2229
  outputs = [];
@@ -1869,6 +2284,23 @@ var SoftphoneImpl = class extends Emitter {
1869
2284
  /** Contacts + history, whenever there is a Coventia backend to reach. The widget reads these. */
1870
2285
  sources;
1871
2286
  audio;
2287
+ /**
2288
+ * Every in-flight transfer-outcome watch (Phase A), so `destroy()` can stop all of them. Never
2289
+ * pruned as individual watches finish on their own — `stop()` is idempotent and cheap to call again,
2290
+ * and a session accumulating a handful of closures for however many transfers it makes is not worth
2291
+ * the bookkeeping a self-removing set would add.
2292
+ */
2293
+ #activeWatches = /* @__PURE__ */ new Set();
2294
+ /**
2295
+ * Phase B (presence-gated transfer picker). ONE long-lived watch, not a member of `#activeWatches`
2296
+ * — that set is per-transfer instances a live call spawns and forgets; this is a single background
2297
+ * connection tied to the softphone's own lifecycle, started and stopped by `#syncPresenceWatch`
2298
+ * rather than by any one call.
2299
+ */
2300
+ #presenceWatch = null;
2301
+ /** A pending state-driven teardown of the watch — see `PRESENCE_TEARDOWN_GRACE_MS`. */
2302
+ #presenceTeardownTimer = null;
2303
+ #presenceAgents = [];
1872
2304
  /**
1873
2305
  * `tabLock` defaults to the real Web Locks implementation — not part of `SoftphoneConfig` (an
1874
2306
  * internal seam, not something an integrator tunes). Tests construct this class directly with a
@@ -1901,6 +2333,9 @@ var SoftphoneImpl = class extends Emitter {
1901
2333
  get activeCall() {
1902
2334
  return this.#activeCall;
1903
2335
  }
2336
+ get presence() {
2337
+ return this.#presenceAgents;
2338
+ }
1904
2339
  get canPlayAudio() {
1905
2340
  return true;
1906
2341
  }
@@ -1924,6 +2359,7 @@ var SoftphoneImpl = class extends Emitter {
1924
2359
  }
1925
2360
  async stop() {
1926
2361
  if (!this.#startedHere) return;
2362
+ this.#sayGoodbye();
1927
2363
  this.#tabLock.release();
1928
2364
  if (this.#isLeader) {
1929
2365
  await this.#engine.stop();
@@ -1937,6 +2373,9 @@ var SoftphoneImpl = class extends Emitter {
1937
2373
  async destroy() {
1938
2374
  if (this.#destroyed) return;
1939
2375
  this.#destroyed = true;
2376
+ for (const stopWatch of this.#activeWatches) stopWatch();
2377
+ this.#activeWatches.clear();
2378
+ this.#sayGoodbye();
1940
2379
  await this.stop().catch(() => {
1941
2380
  });
1942
2381
  this.#setState("destroyed");
@@ -1967,14 +2406,19 @@ var SoftphoneImpl = class extends Emitter {
1967
2406
  const call = new CallImpl(
1968
2407
  engineCall,
1969
2408
  (event, error) => this.#reportListenerError("call", event, error),
1970
- (message) => this.#reportInternalError(message)
2409
+ (message) => this.#reportInternalError(message),
2410
+ (target, callerNumber) => this.#onTransfer(target, callerNumber)
1971
2411
  );
1972
2412
  this.#activeCall = call;
2413
+ const offState = call.on("stateChanged" /* StateChanged */, () => this.#syncCallState());
2414
+ this.#syncCallState();
1973
2415
  const off = call.on("ended" /* Ended */, () => {
1974
2416
  off();
2417
+ offState();
1975
2418
  if (this.#activeCall === call) {
1976
2419
  this.#activeCall = null;
1977
2420
  this.emit("activeCallChanged" /* ActiveCallChanged */, { call: null });
2421
+ this.#syncCallState();
1978
2422
  }
1979
2423
  });
1980
2424
  if (this.#resolveContact) {
@@ -1992,11 +2436,125 @@ var SoftphoneImpl = class extends Emitter {
1992
2436
  this.#state = state;
1993
2437
  if (state === "online") this.#lastError = null;
1994
2438
  this.emit("stateChanged" /* StateChanged */, { state, previous });
2439
+ this.#syncPresenceWatch();
2440
+ }
2441
+ /**
2442
+ * Whether this softphone is on a call right now, as presence means it (`isOnCallState`). There is a single
2443
+ * active-call slot, and a consult leg is a child of that call, so the parent staying alive covers a consult.
2444
+ */
2445
+ #isOnCall() {
2446
+ return this.#activeCall !== null && isOnCallState(this.#activeCall.state);
2447
+ }
2448
+ /**
2449
+ * Reports the current on-a-call value to the backend. The watch de-duplicates (it only sends on change) and
2450
+ * remembers the latest value to replay after a reconnect, so this can be called on every transition without
2451
+ * ceremony. A no-op while there is no presence watch (not online, not the leader, no backend configured).
2452
+ */
2453
+ #syncCallState() {
2454
+ this.#presenceWatch?.reportCallState(this.#isOnCall());
1995
2455
  }
1996
2456
  #setLeader(isLeader) {
1997
2457
  if (isLeader === this.#isLeader) return;
1998
2458
  this.#isLeader = isLeader;
1999
2459
  this.emit("leaderChanged" /* LeaderChanged */, { isLeader });
2460
+ this.#syncPresenceWatch();
2461
+ }
2462
+ /**
2463
+ * Phase B (presence-gated transfer picker, `docs/specs/transfer-presence-picker.md` plan T6/T7).
2464
+ * The ONE place that decides whether the presence connection should be running right now — called
2465
+ * after every `#setState`/`#setLeader` transition rather than left to either alone, since either
2466
+ * one flipping can change the answer. No-ops entirely when no Coventia backend is configured
2467
+ * (`sources` is null) — same guard `#onTransfer` uses, and for the same reason: nothing to open.
2468
+ *
2469
+ * `presence-socket.ts`'s own `getIsLeader` is checked at start and before each reconnect attempt,
2470
+ * not on a poll tick the way `startTransferWatch`'s is — see that module's own doc comment for why.
2471
+ * This still passes `getIsLeader: () => this.#isLeader` for that narrower, defensive recheck; the
2472
+ * BROAD online/leader gate — whether to be connected AT ALL — is owned entirely here, the same
2473
+ * split `#onTransfer` already has with `startTransferWatch`.
2474
+ *
2475
+ * One exception to "tear down the moment `online` is lost": a leader that is merely `offline` or
2476
+ * `reconnecting` keeps its watch for `PRESENCE_TEARDOWN_GRACE_MS` — a blip shorter than that never touches
2477
+ * the presence socket at all. Anything else (stopped, starting, destroyed, or no longer the leader) still
2478
+ * tears it down at once: those are not blips, and a follower must never hold a connection.
2479
+ */
2480
+ #syncPresenceWatch() {
2481
+ const eligible = Boolean(this.sources) && this.#isLeader;
2482
+ const shouldRun = eligible && this.#state === "online";
2483
+ const blip = eligible && (this.#state === "offline" || this.#state === "reconnecting");
2484
+ if (!blip) this.#cancelPresenceTeardown();
2485
+ if (blip && this.#presenceWatch !== null) {
2486
+ this.#schedulePresenceTeardown();
2487
+ return;
2488
+ }
2489
+ if (shouldRun === (this.#presenceWatch !== null)) return;
2490
+ if (shouldRun) {
2491
+ this.#presenceWatch = startPresenceSocket({
2492
+ source: this.sources.presence,
2493
+ getIsLeader: () => this.#isLeader,
2494
+ onAgentsChanged: (agents) => {
2495
+ this.#presenceAgents = agents;
2496
+ this.emit("presenceChanged" /* PresenceChanged */, { agents });
2497
+ }
2498
+ });
2499
+ this.#syncCallState();
2500
+ } else {
2501
+ this.#stopPresence();
2502
+ }
2503
+ }
2504
+ /**
2505
+ * Starts the grace period before an `offline` / `reconnecting` leader loses its presence socket. Idempotent
2506
+ * on purpose: a second notification inside the same blip must NOT push the deadline back.
2507
+ */
2508
+ #schedulePresenceTeardown() {
2509
+ if (this.#presenceTeardownTimer !== null) return;
2510
+ this.#presenceTeardownTimer = setTimeout(() => {
2511
+ this.#presenceTeardownTimer = null;
2512
+ if (this.#state === "online" && this.#isLeader) return;
2513
+ this.#stopPresence();
2514
+ }, PRESENCE_TEARDOWN_GRACE_MS);
2515
+ }
2516
+ #cancelPresenceTeardown() {
2517
+ if (this.#presenceTeardownTimer === null) return;
2518
+ clearTimeout(this.#presenceTeardownTimer);
2519
+ this.#presenceTeardownTimer = null;
2520
+ }
2521
+ /**
2522
+ * Stops the presence watch and forgets its roster. A STATE-DRIVEN teardown (the softphone left `online`
2523
+ * for good, or lost leadership) passes nothing and says no goodbye; only a deliberate stop passes `bye`.
2524
+ * Any pending grace-period teardown is cancelled — there is nothing left for it to do.
2525
+ */
2526
+ #stopPresence(options) {
2527
+ this.#cancelPresenceTeardown();
2528
+ if (!this.#presenceWatch) return;
2529
+ this.#presenceWatch.stop(options);
2530
+ this.#presenceWatch = null;
2531
+ this.#presenceAgents = [];
2532
+ this.emit("presenceChanged" /* PresenceChanged */, { agents: [] });
2533
+ }
2534
+ /**
2535
+ * A DELIBERATE stop (`stop()` / `destroy()`): say any call is over, then say goodbye, so the backend drops
2536
+ * this phone at once instead of waiting out the close grace. Idempotent — a second call finds no watch.
2537
+ */
2538
+ #sayGoodbye() {
2539
+ this.#presenceWatch?.reportCallState(false);
2540
+ this.#stopPresence({ bye: true });
2541
+ }
2542
+ /**
2543
+ * Phase A (transfer outcome relay, `docs/specs/transfer-outcome-relay.md`) — `CallImpl.transfer()`'s
2544
+ * internal seam calls this. No-ops entirely when no Coventia backend is configured (`sources` is
2545
+ * null): there is then nothing to poll, and behaviour is exactly what it was before this feature
2546
+ * existed (spec §5.4).
2547
+ */
2548
+ #onTransfer(target, callerNumber) {
2549
+ if (!this.sources) return;
2550
+ const stopWatch = startTransferWatch({
2551
+ target,
2552
+ callerNumber,
2553
+ source: this.sources.transferOutcomes,
2554
+ getIsLeader: () => this.#isLeader,
2555
+ emit: (payload) => this.emit("transferOutcome" /* TransferOutcome */, payload)
2556
+ });
2557
+ this.#activeWatches.add(stopWatch);
2000
2558
  }
2001
2559
  /**
2002
2560
  * Re-emit an engine failure to the host, and remember it if it is fatal.
@@ -2116,7 +2674,7 @@ function isCallEngine(value) {
2116
2674
 
2117
2675
  // src/index.ts
2118
2676
  init_errors();
2119
- var VERSION = "0.0.0";
2677
+ var VERSION = "0.2.1";
2120
2678
 
2121
2679
  exports.CALL_TRANSITIONS = CALL_TRANSITIONS;
2122
2680
  exports.CallEvent = CallEvent;