@coventia/softphone 0.1.0 → 0.2.0

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-DUVnDxsz.d.cts} +152 -5
  2. package/dist/{call-BTYlT2SU.d.ts → call-DUVnDxsz.d.ts} +152 -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-3V2L6UTK.js → chunk-LMJXX4YM.js} +17 -3
  6. package/dist/chunk-LMJXX4YM.js.map +1 -0
  7. package/dist/{chunk-5BGEHMPP.js → chunk-YWI6EQYS.js} +40 -7
  8. package/dist/chunk-YWI6EQYS.js.map +1 -0
  9. package/dist/element/index.d.ts +154 -5
  10. package/dist/element/index.js +512 -152
  11. package/dist/element/index.js.map +1 -1
  12. package/dist/{engine-D9nbeY08.d.cts → engine-CDjkrXHY.d.cts} +1 -1
  13. package/dist/{engine-Doa6Brkm.d.ts → engine-EPCKf-Bl.d.ts} +1 -1
  14. package/dist/index.cjs +396 -14
  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 +256 -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-BtdgZWkm.d.ts} +17 -2
  26. package/dist/{softphone-DHmhotbf.d.ts → softphone-DHG9MTNg.d.cts} +17 -2
  27. package/dist/softphone.css +395 -1
  28. package/dist/{sources-N4BOEDRL.js → sources-5L2R4HTV.js} +104 -5
  29. package/dist/sources-5L2R4HTV.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 +490 -139
  37. package/dist/ui/index.cjs.map +1 -1
  38. package/dist/ui/index.d.cts +2 -2
  39. package/dist/ui/index.d.ts +2 -2
  40. package/dist/ui/index.js +478 -141
  41. package/dist/ui/index.js.map +1 -1
  42. package/dist/zadarma/index.cjs +38 -5
  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-4VOPIBXG.js +5 -0
  48. package/dist/{zadarma-engine-DN2SZSQ3.js.map → zadarma-engine-4VOPIBXG.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
@@ -1,4 +1,4 @@
1
- import { e as CallOptions, b as CallDirection, H as HangupReason, i as HoldOriginator, o as SoftphoneState, k as SoftphoneError } from './call-BTYlT2SU.cjs';
1
+ import { e as CallOptions, b as CallDirection, H as HangupReason, i as HoldOriginator, o as SoftphoneState, k as SoftphoneError } from './call-DUVnDxsz.cjs';
2
2
 
3
3
  /**
4
4
  * The `CallEngine` seam — INTERNAL, not exported from src/index.ts.
@@ -1,4 +1,4 @@
1
- import { e as CallOptions, b as CallDirection, H as HangupReason, i as HoldOriginator, o as SoftphoneState, k as SoftphoneError } from './call-BTYlT2SU.js';
1
+ import { e as CallOptions, b as CallDirection, H as HangupReason, i as HoldOriginator, o as SoftphoneState, k as SoftphoneError } from './call-DUVnDxsz.js';
2
2
 
3
3
  /**
4
4
  * The `CallEngine` seam — INTERNAL, not exported from src/index.ts.
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,89 @@ 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
+ close: () => {
297
+ ws.onclose = null;
298
+ ws.onerror = null;
299
+ ws.close();
300
+ }
301
+ });
302
+ };
303
+ ws.onmessage = (event) => {
304
+ let data;
305
+ try {
306
+ data = JSON.parse(String(event.data));
307
+ } catch {
308
+ return;
309
+ }
310
+ const msg = data;
311
+ if (msg.type === "snapshot") {
312
+ callbacks.onSnapshot((msg.agents ?? []).map(toPresenceEntry));
313
+ } else if (msg.type === "delta" && msg.agent) {
314
+ callbacks.onDelta(toPresenceEntry(msg.agent));
315
+ }
316
+ };
317
+ ws.onclose = () => {
318
+ if (!settled) {
319
+ settled = true;
320
+ reject(new Error("presence socket closed before opening"));
321
+ return;
322
+ }
323
+ callbacks.onClose();
324
+ };
325
+ ws.onerror = () => {
326
+ };
327
+ })();
328
+ });
329
+ };
254
330
  const contacts = config.contacts ?? coventiaContacts;
255
331
  const resolve = config.resolveContact ?? (config.contacts ? resolverFromContacts(config.contacts) : coventiaResolve);
256
332
  const history = withNameResolution(rawHistory, resolve);
257
- return { contacts, history, resolveContact: resolve, listAgents: coventiaListAgents };
333
+ const transferOutcomes = config.transferOutcomes ?? coventiaTransferOutcomes;
334
+ const presence = config.presence ?? coventiaPresence;
335
+ return {
336
+ contacts,
337
+ history,
338
+ resolveContact: resolve,
339
+ listAgents: coventiaListAgents,
340
+ transferOutcomes,
341
+ presence
342
+ };
258
343
  }
259
- var toContact, toDirection, otherParty, isMissed, toOutcome, toDate;
344
+ var toContact, toDirection, otherParty, isMissed, toOutcome, toDate, toTransferOutcome, toPresenceState, toPresenceEntry, toWebSocketUrl;
260
345
  var init_sources2 = __esm({
261
346
  "src/coventia/sources.ts"() {
262
347
  init_sources();
@@ -291,6 +376,20 @@ var init_sources2 = __esm({
291
376
  const d = new Date(raw);
292
377
  return Number.isNaN(d.getTime()) ? /* @__PURE__ */ new Date(0) : d;
293
378
  };
379
+ toTransferOutcome = (row) => ({
380
+ id: row.id ?? "",
381
+ target: row.target ?? "",
382
+ status: row.status === "connected" ? "connected" : "not-connected",
383
+ at: toDate(row.at)
384
+ });
385
+ toPresenceState = (raw) => raw === "disponible" || raw === "en_llamada" ? raw : "desconectado";
386
+ toPresenceEntry = (row) => ({
387
+ userId: row.userId ?? "",
388
+ extension: row.extension ?? "",
389
+ name: row.name ?? null,
390
+ state: toPresenceState(row.state)
391
+ });
392
+ toWebSocketUrl = (httpUrl) => httpUrl.replace(/^http/, "ws").replace(/\/+$/, "");
294
393
  }
295
394
  });
296
395
 
@@ -746,6 +845,14 @@ var init_ua = __esm({
746
845
  #uaInFlight = null;
747
846
  #domain = "";
748
847
  #activeCall = null;
848
+ /**
849
+ * The consult leg from `startConsult` (SPEC §5.6), if one is live on this UA right now.
850
+ *
851
+ * Tracked separately from `#activeCall` so `#maybeTeardownUA` can tell "both legs are actually gone"
852
+ * apart from "the main leg ended, but a consult is still up" — see that method's own comment for the
853
+ * bug this exists to prevent.
854
+ */
855
+ #consultCall = null;
749
856
  /** The chosen microphone, or `null` for the browser default. Read fresh by every new leg's
750
857
  * `mediaConstraints`, and pushed live to `#activeCall` by `setInputDevice`. */
751
858
  #inputDeviceId = null;
@@ -864,9 +971,7 @@ var init_ua = __esm({
864
971
  const ms = this.provider.quirks?.noInviteTeardownMs;
865
972
  if (!ms) return;
866
973
  this.#clearNoInviteTimer();
867
- this.#noInviteTimer = setTimeout(() => {
868
- if (!this.#activeCall) this.#teardownUA();
869
- }, ms);
974
+ this.#noInviteTimer = setTimeout(() => this.#maybeTeardownUA(), ms);
870
975
  }
871
976
  /**
872
977
  * Rebuild the incoming-signal channel with fresh credentials, after a registration failure.
@@ -988,6 +1093,7 @@ var init_ua = __esm({
988
1093
  }
989
1094
  this.#ua = null;
990
1095
  this.#activeCall = null;
1096
+ this.#consultCall = null;
991
1097
  }
992
1098
  /** Place a call once the UA is registered (its 200 OK for REGISTER has arrived). */
993
1099
  /**
@@ -1030,7 +1136,7 @@ var init_ua = __esm({
1030
1136
  const code = missed ? "call/missed" : CALL_END_CODES[event.reason];
1031
1137
  if (code) this.#reportError(code, event.cause);
1032
1138
  if (this.#activeCall === call) this.#activeCall = null;
1033
- if (this.provider.registrationMode === "on-demand") this.#teardownUA();
1139
+ this.#maybeTeardownUA();
1034
1140
  }
1035
1141
  });
1036
1142
  return call;
@@ -1041,12 +1147,38 @@ var init_ua = __esm({
1041
1147
  mediaConstraints: buildAudioConstraints(this.#inputDeviceId),
1042
1148
  pcConfig: PC_CONFIG
1043
1149
  });
1044
- return new SipEngineCall(consultSession, {
1150
+ const consultCall = new SipEngineCall(consultSession, {
1045
1151
  id: nextCallId(),
1046
1152
  buildTarget: (x) => this.#buildTarget(x),
1047
1153
  placeConsult: () => Promise.reject(new Error("cannot consult from a consult leg")),
1048
1154
  iceGatheringCapMs: this.provider.quirks?.iceGatheringCapMs ?? DEFAULT_ICE_CAP_MS
1049
1155
  });
1156
+ this.#consultCall = consultCall;
1157
+ consultCall.onEvent((event) => {
1158
+ if (event.type === "ended") {
1159
+ if (this.#consultCall === consultCall) this.#consultCall = null;
1160
+ this.#maybeTeardownUA();
1161
+ }
1162
+ });
1163
+ return consultCall;
1164
+ }
1165
+ /**
1166
+ * On-demand registration (SPEC §3.3): the UA is disposable once NOTHING is left on it — but "the
1167
+ * main leg ended" (or "no INVITE followed this push") is not the same fact as "nothing is left."
1168
+ * `completeTransfer()` (SPEC §5.6) ends the main leg via a REFER…Replaces BYE *while the consult leg
1169
+ * is still what the PBX needs to bridge the caller onto* — tearing the UA down (and with it, EVERY
1170
+ * session on it, via `terminateSessions`) the instant the main leg's `ended` fires killed that
1171
+ * consult leg out from under the transfer before the bridge could use it, and the caller was simply
1172
+ * gone. Two real call sites hit this independently (found live, 2026-08-04): the main leg's own
1173
+ * `ended` handler, and `#armNoInviteTeardown`'s 5s timer — the latter re-arms on EVERY push,
1174
+ * including unrelated "answered" pushes Zadarma sends around other account activity, so it can fire
1175
+ * minutes after a transfer with no `#activeCall` and no idea a consult leg is mid-handoff. Both now
1176
+ * route through here rather than checking `#activeCall` alone.
1177
+ */
1178
+ #maybeTeardownUA() {
1179
+ if (this.provider.registrationMode !== "on-demand") return;
1180
+ if (this.#activeCall || this.#consultCall) return;
1181
+ this.#teardownUA();
1050
1182
  }
1051
1183
  #startRejectGuard() {
1052
1184
  const ms = this.provider.quirks?.rejectGuardMs;
@@ -1517,6 +1649,8 @@ var SoftphoneEvent = /* @__PURE__ */ ((SoftphoneEvent2) => {
1517
1649
  SoftphoneEvent2["DeviceChange"] = "deviceChange";
1518
1650
  SoftphoneEvent2["Error"] = "error";
1519
1651
  SoftphoneEvent2["LeaderChanged"] = "leaderChanged";
1652
+ SoftphoneEvent2["TransferOutcome"] = "transferOutcome";
1653
+ SoftphoneEvent2["PresenceChanged"] = "presenceChanged";
1520
1654
  return SoftphoneEvent2;
1521
1655
  })(SoftphoneEvent || {});
1522
1656
  var CallEvent = /* @__PURE__ */ ((CallEvent2) => {
@@ -1558,10 +1692,11 @@ function isTerminal(state) {
1558
1692
 
1559
1693
  // src/core/call-impl.ts
1560
1694
  var CallImpl = class _CallImpl extends Emitter {
1561
- constructor(engineCall, onListenerError, onInternalError) {
1695
+ constructor(engineCall, onListenerError, onInternalError, onTransfer) {
1562
1696
  super(onListenerError);
1563
1697
  this.engineCall = engineCall;
1564
1698
  this.onInternalError = onInternalError;
1699
+ this.onTransfer = onTransfer;
1565
1700
  this.#onListenerError = onListenerError;
1566
1701
  this.#state = INITIAL_STATES[engineCall.direction];
1567
1702
  this.#remote = {
@@ -1573,6 +1708,7 @@ var CallImpl = class _CallImpl extends Emitter {
1573
1708
  }
1574
1709
  engineCall;
1575
1710
  onInternalError;
1711
+ onTransfer;
1576
1712
  #state;
1577
1713
  #remote;
1578
1714
  #answeredAt = null;
@@ -1658,7 +1794,10 @@ var CallImpl = class _CallImpl extends Emitter {
1658
1794
  }
1659
1795
  async hangup() {
1660
1796
  if (isTerminal(this.#state)) return;
1661
- await this.engineCall.hangup();
1797
+ const consult = this.#consultCall;
1798
+ this.#consultCall = null;
1799
+ this.#consultEngineCall = null;
1800
+ await Promise.all([consult?.hangup() ?? Promise.resolve(), this.engineCall.hangup()]);
1662
1801
  }
1663
1802
  mute(muted) {
1664
1803
  if (this.#muted === muted) return;
@@ -1680,6 +1819,7 @@ var CallImpl = class _CallImpl extends Emitter {
1680
1819
  }
1681
1820
  async transfer(target) {
1682
1821
  await this.engineCall.transfer(target);
1822
+ this.onTransfer?.(target, this.#remote.number.trim() || null);
1683
1823
  }
1684
1824
  async startConsult(target) {
1685
1825
  await this.hold();
@@ -1715,6 +1855,12 @@ var CallImpl = class _CallImpl extends Emitter {
1715
1855
  }
1716
1856
  #end(reason) {
1717
1857
  if (isTerminal(this.#state)) return;
1858
+ if (this.#consultCall) {
1859
+ const consult = this.#consultCall;
1860
+ this.#consultCall = null;
1861
+ this.#consultEngineCall = null;
1862
+ void consult.hangup();
1863
+ }
1718
1864
  const from = this.#state;
1719
1865
  this.#state = "ended";
1720
1866
  this.emit("stateChanged" /* StateChanged */, { state: "ended", previous: from });
@@ -1758,7 +1904,9 @@ function coventiaBackend(config, tokenSource, fallbackBaseUrl) {
1758
1904
  contacts: async (params) => (await load()).contacts(params),
1759
1905
  history: async (params) => (await load()).history(params),
1760
1906
  resolveContact: async (number) => (await load()).resolveContact(number),
1761
- listAgents: async () => (await load()).listAgents()
1907
+ listAgents: async () => (await load()).listAgents(),
1908
+ transferOutcomes: async (params) => (await load()).transferOutcomes(params),
1909
+ presence: async (callbacks) => (await load()).presence(callbacks)
1762
1910
  };
1763
1911
  }
1764
1912
 
@@ -1806,6 +1954,166 @@ function webLocksTabLock(lockName = LOCK_NAME) {
1806
1954
  };
1807
1955
  }
1808
1956
 
1957
+ // src/core/transfer-watch.ts
1958
+ var FAST_INTERVAL_MS = 2e3;
1959
+ var FAST_WINDOW_MS = 1e4;
1960
+ var SLOW_INTERVAL_MS = 5e3;
1961
+ var DEADLINE_MS = 9e4;
1962
+ var EXTENSION_LIKE = /^\d{1,6}$/;
1963
+ function startTransferWatch(params) {
1964
+ const {
1965
+ target,
1966
+ callerNumber,
1967
+ source,
1968
+ getIsLeader,
1969
+ emit,
1970
+ setTimeout: scheduleTimeout = globalThis.setTimeout,
1971
+ clearTimeout: cancelTimeout = globalThis.clearTimeout,
1972
+ now = () => Date.now()
1973
+ } = params;
1974
+ if (!EXTENSION_LIKE.test(target)) return () => {
1975
+ };
1976
+ if (!getIsLeader()) return () => {
1977
+ };
1978
+ let stopped = false;
1979
+ let timer = null;
1980
+ let cursor = null;
1981
+ const startedAt = now();
1982
+ const stop = () => {
1983
+ stopped = true;
1984
+ if (timer !== null) {
1985
+ cancelTimeout(timer);
1986
+ timer = null;
1987
+ }
1988
+ };
1989
+ const finish = (status) => {
1990
+ stop();
1991
+ emit({ status, target, caller: callerNumber, at: new Date(now()) });
1992
+ };
1993
+ const scheduleNext = () => {
1994
+ if (stopped) return;
1995
+ const elapsed = now() - startedAt;
1996
+ if (elapsed >= DEADLINE_MS) {
1997
+ stop();
1998
+ return;
1999
+ }
2000
+ timer = scheduleTimeout(() => void poll(), elapsed < FAST_WINDOW_MS ? FAST_INTERVAL_MS : SLOW_INTERVAL_MS);
2001
+ };
2002
+ const poll = async () => {
2003
+ if (stopped) return;
2004
+ if (!getIsLeader()) {
2005
+ stop();
2006
+ return;
2007
+ }
2008
+ let page;
2009
+ try {
2010
+ page = await source(cursor === null ? {} : { since: cursor });
2011
+ } catch {
2012
+ if (!stopped) scheduleNext();
2013
+ return;
2014
+ }
2015
+ if (stopped) return;
2016
+ if (cursor === null) {
2017
+ cursor = page.cursor;
2018
+ } else {
2019
+ cursor = page.cursor;
2020
+ const match = page.outcomes.find((o) => o.target === target);
2021
+ if (match) {
2022
+ finish(match.status);
2023
+ return;
2024
+ }
2025
+ }
2026
+ scheduleNext();
2027
+ };
2028
+ void poll();
2029
+ return stop;
2030
+ }
2031
+
2032
+ // src/core/presence-socket.ts
2033
+ var RECONNECT_BASE_MS = 1e3;
2034
+ var RECONNECT_MAX_MS = 3e4;
2035
+ var NOOP_WATCH = { reportCallState: () => {
2036
+ }, stop: () => {
2037
+ } };
2038
+ function startPresenceSocket(params) {
2039
+ const {
2040
+ source,
2041
+ getIsLeader,
2042
+ onAgentsChanged,
2043
+ setTimeout: scheduleTimeout = globalThis.setTimeout,
2044
+ clearTimeout: cancelTimeout = globalThis.clearTimeout,
2045
+ random = Math.random
2046
+ } = params;
2047
+ if (!getIsLeader()) return NOOP_WATCH;
2048
+ let stopped = false;
2049
+ let attempt = 0;
2050
+ let reconnectTimer = null;
2051
+ let agents = /* @__PURE__ */ new Map();
2052
+ let lastReportedOnCall = null;
2053
+ let connection = null;
2054
+ const stop = () => {
2055
+ if (stopped) return;
2056
+ stopped = true;
2057
+ if (reconnectTimer !== null) {
2058
+ cancelTimeout(reconnectTimer);
2059
+ reconnectTimer = null;
2060
+ }
2061
+ connection?.close();
2062
+ connection = null;
2063
+ };
2064
+ const scheduleReconnect = () => {
2065
+ if (stopped) return;
2066
+ attempt += 1;
2067
+ const capped = Math.min(RECONNECT_BASE_MS * 2 ** (attempt - 1), RECONNECT_MAX_MS);
2068
+ const delay = capped * random();
2069
+ reconnectTimer = scheduleTimeout(() => void connect(), delay);
2070
+ };
2071
+ const connect = async () => {
2072
+ if (stopped) return;
2073
+ if (!getIsLeader()) {
2074
+ stop();
2075
+ return;
2076
+ }
2077
+ let opened;
2078
+ try {
2079
+ opened = await source({
2080
+ onSnapshot: (snapshot) => {
2081
+ agents = new Map(snapshot.map((agent) => [agent.userId, agent]));
2082
+ onAgentsChanged([...agents.values()]);
2083
+ },
2084
+ onDelta: (agent) => {
2085
+ agents.set(agent.userId, agent);
2086
+ onAgentsChanged([...agents.values()]);
2087
+ },
2088
+ onClose: () => {
2089
+ if (connection === null) return;
2090
+ connection = null;
2091
+ scheduleReconnect();
2092
+ }
2093
+ });
2094
+ } catch {
2095
+ if (!stopped) scheduleReconnect();
2096
+ return;
2097
+ }
2098
+ if (stopped) {
2099
+ opened.close();
2100
+ return;
2101
+ }
2102
+ connection = opened;
2103
+ attempt = 0;
2104
+ if (lastReportedOnCall !== null) connection.reportCallState(lastReportedOnCall);
2105
+ };
2106
+ void connect();
2107
+ return {
2108
+ reportCallState: (onCall) => {
2109
+ if (onCall === lastReportedOnCall) return;
2110
+ lastReportedOnCall = onCall;
2111
+ connection?.reportCallState(onCall);
2112
+ },
2113
+ stop
2114
+ };
2115
+ }
2116
+
1809
2117
  // src/core/softphone-impl.ts
1810
2118
  init_errors();
1811
2119
  var liveInstance = false;
@@ -1869,6 +2177,21 @@ var SoftphoneImpl = class extends Emitter {
1869
2177
  /** Contacts + history, whenever there is a Coventia backend to reach. The widget reads these. */
1870
2178
  sources;
1871
2179
  audio;
2180
+ /**
2181
+ * Every in-flight transfer-outcome watch (Phase A), so `destroy()` can stop all of them. Never
2182
+ * pruned as individual watches finish on their own — `stop()` is idempotent and cheap to call again,
2183
+ * and a session accumulating a handful of closures for however many transfers it makes is not worth
2184
+ * the bookkeeping a self-removing set would add.
2185
+ */
2186
+ #activeWatches = /* @__PURE__ */ new Set();
2187
+ /**
2188
+ * Phase B (presence-gated transfer picker). ONE long-lived watch, not a member of `#activeWatches`
2189
+ * — that set is per-transfer instances a live call spawns and forgets; this is a single background
2190
+ * connection tied to the softphone's own lifecycle, started and stopped by `#syncPresenceWatch`
2191
+ * rather than by any one call.
2192
+ */
2193
+ #presenceWatch = null;
2194
+ #presenceAgents = [];
1872
2195
  /**
1873
2196
  * `tabLock` defaults to the real Web Locks implementation — not part of `SoftphoneConfig` (an
1874
2197
  * internal seam, not something an integrator tunes). Tests construct this class directly with a
@@ -1901,6 +2224,9 @@ var SoftphoneImpl = class extends Emitter {
1901
2224
  get activeCall() {
1902
2225
  return this.#activeCall;
1903
2226
  }
2227
+ get presence() {
2228
+ return this.#presenceAgents;
2229
+ }
1904
2230
  get canPlayAudio() {
1905
2231
  return true;
1906
2232
  }
@@ -1937,6 +2263,10 @@ var SoftphoneImpl = class extends Emitter {
1937
2263
  async destroy() {
1938
2264
  if (this.#destroyed) return;
1939
2265
  this.#destroyed = true;
2266
+ for (const stopWatch of this.#activeWatches) stopWatch();
2267
+ this.#activeWatches.clear();
2268
+ this.#presenceWatch?.stop();
2269
+ this.#presenceWatch = null;
1940
2270
  await this.stop().catch(() => {
1941
2271
  });
1942
2272
  this.#setState("destroyed");
@@ -1967,7 +2297,8 @@ var SoftphoneImpl = class extends Emitter {
1967
2297
  const call = new CallImpl(
1968
2298
  engineCall,
1969
2299
  (event, error) => this.#reportListenerError("call", event, error),
1970
- (message) => this.#reportInternalError(message)
2300
+ (message) => this.#reportInternalError(message),
2301
+ (target, callerNumber) => this.#onTransfer(target, callerNumber)
1971
2302
  );
1972
2303
  this.#activeCall = call;
1973
2304
  const off = call.on("ended" /* Ended */, () => {
@@ -1992,11 +2323,62 @@ var SoftphoneImpl = class extends Emitter {
1992
2323
  this.#state = state;
1993
2324
  if (state === "online") this.#lastError = null;
1994
2325
  this.emit("stateChanged" /* StateChanged */, { state, previous });
2326
+ this.#syncPresenceWatch();
1995
2327
  }
1996
2328
  #setLeader(isLeader) {
1997
2329
  if (isLeader === this.#isLeader) return;
1998
2330
  this.#isLeader = isLeader;
1999
2331
  this.emit("leaderChanged" /* LeaderChanged */, { isLeader });
2332
+ this.#syncPresenceWatch();
2333
+ }
2334
+ /**
2335
+ * Phase B (presence-gated transfer picker, `docs/specs/transfer-presence-picker.md` plan T6/T7).
2336
+ * The ONE place that decides whether the presence connection should be running right now — called
2337
+ * after every `#setState`/`#setLeader` transition rather than left to either alone, since either
2338
+ * one flipping can change the answer. No-ops entirely when no Coventia backend is configured
2339
+ * (`sources` is null) — same guard `#onTransfer` uses, and for the same reason: nothing to open.
2340
+ *
2341
+ * `presence-socket.ts`'s own `getIsLeader` is checked at start and before each reconnect attempt,
2342
+ * not on a poll tick the way `startTransferWatch`'s is — see that module's own doc comment for why.
2343
+ * This still passes `getIsLeader: () => this.#isLeader` for that narrower, defensive recheck; the
2344
+ * BROAD online/leader gate — whether to be connected AT ALL — is owned entirely here, the same
2345
+ * split `#onTransfer` already has with `startTransferWatch`.
2346
+ */
2347
+ #syncPresenceWatch() {
2348
+ const shouldRun = Boolean(this.sources) && this.#state === "online" && this.#isLeader;
2349
+ if (shouldRun === (this.#presenceWatch !== null)) return;
2350
+ if (shouldRun) {
2351
+ this.#presenceWatch = startPresenceSocket({
2352
+ source: this.sources.presence,
2353
+ getIsLeader: () => this.#isLeader,
2354
+ onAgentsChanged: (agents) => {
2355
+ this.#presenceAgents = agents;
2356
+ this.emit("presenceChanged" /* PresenceChanged */, { agents });
2357
+ }
2358
+ });
2359
+ } else {
2360
+ this.#presenceWatch?.stop();
2361
+ this.#presenceWatch = null;
2362
+ this.#presenceAgents = [];
2363
+ this.emit("presenceChanged" /* PresenceChanged */, { agents: [] });
2364
+ }
2365
+ }
2366
+ /**
2367
+ * Phase A (transfer outcome relay, `docs/specs/transfer-outcome-relay.md`) — `CallImpl.transfer()`'s
2368
+ * internal seam calls this. No-ops entirely when no Coventia backend is configured (`sources` is
2369
+ * null): there is then nothing to poll, and behaviour is exactly what it was before this feature
2370
+ * existed (spec §5.4).
2371
+ */
2372
+ #onTransfer(target, callerNumber) {
2373
+ if (!this.sources) return;
2374
+ const stopWatch = startTransferWatch({
2375
+ target,
2376
+ callerNumber,
2377
+ source: this.sources.transferOutcomes,
2378
+ getIsLeader: () => this.#isLeader,
2379
+ emit: (payload) => this.emit("transferOutcome" /* TransferOutcome */, payload)
2380
+ });
2381
+ this.#activeWatches.add(stopWatch);
2000
2382
  }
2001
2383
  /**
2002
2384
  * Re-emit an engine failure to the host, and remember it if it is fatal.
@@ -2116,7 +2498,7 @@ function isCallEngine(value) {
2116
2498
 
2117
2499
  // src/index.ts
2118
2500
  init_errors();
2119
- var VERSION = "0.0.0";
2501
+ var VERSION = "0.2.0";
2120
2502
 
2121
2503
  exports.CALL_TRANSITIONS = CALL_TRANSITIONS;
2122
2504
  exports.CallEvent = CallEvent;