@q9labsai/chalk-client 4.1.16 → 4.1.17

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 (64) hide show
  1. package/README.md +44 -3
  2. package/dist/access/grant.d.ts.map +1 -1
  3. package/dist/{chunk-O7WTAXAY.js → chunk-2DZUHV6I.js} +187 -64
  4. package/dist/{chunk-E4OK5K6N.js → chunk-A7BVZEFM.js} +1 -1
  5. package/dist/{chunk-BD7FRQ67.js → chunk-AUQTQOLR.js} +635 -196
  6. package/dist/{chunk-OKIL2ELF.js → chunk-CZUJNAUI.js} +13 -2
  7. package/dist/{chunk-M3S7HNQF.js → chunk-ZRMGKMX4.js} +76 -4
  8. package/dist/client.d.ts +41 -3
  9. package/dist/client.d.ts.map +1 -1
  10. package/dist/connection/dependencies.d.ts +1 -0
  11. package/dist/connection/dependencies.d.ts.map +1 -1
  12. package/dist/connection/diagnostics.d.ts +1 -1
  13. package/dist/connection/diagnostics.d.ts.map +1 -1
  14. package/dist/connection/index.d.ts +1 -0
  15. package/dist/connection/index.d.ts.map +1 -1
  16. package/dist/connection/lifecycle.d.ts +4 -1
  17. package/dist/connection/lifecycle.d.ts.map +1 -1
  18. package/dist/connection/media-devices.d.ts.map +1 -1
  19. package/dist/effect.js +17 -5
  20. package/dist/foreground-deadline.d.ts +9 -0
  21. package/dist/foreground-deadline.d.ts.map +1 -0
  22. package/dist/generated/http-api.d.ts +41 -3
  23. package/dist/generated/http-api.d.ts.map +1 -1
  24. package/dist/generated/schemas.d.ts +88 -6
  25. package/dist/generated/schemas.d.ts.map +1 -1
  26. package/dist/index.js +37 -37
  27. package/dist/media/client.d.ts.map +1 -1
  28. package/dist/media/index.js +2 -2
  29. package/dist/media/rtk.d.ts.map +1 -1
  30. package/dist/public-invites/index.js +2 -2
  31. package/dist/server/access-refresh.d.cts +5 -0
  32. package/dist/server/access-refresh.d.ts +5 -0
  33. package/dist/server/access-refresh.d.ts.map +1 -1
  34. package/dist/server/client.d.ts.map +1 -1
  35. package/dist/server/index.cjs +10 -5
  36. package/dist/server/index.d.cts +1 -1
  37. package/dist/server/index.d.ts +1 -1
  38. package/dist/server/index.d.ts.map +1 -1
  39. package/dist/server/index.js +10 -5
  40. package/dist/server/types.d.cts +2 -1
  41. package/dist/server/types.d.ts +2 -1
  42. package/dist/server/types.d.ts.map +1 -1
  43. package/dist/space-client/core.d.ts.map +1 -1
  44. package/dist/space-client/core.test.helpers.d.ts +1 -1
  45. package/dist/space-client/core.test.helpers.d.ts.map +1 -1
  46. package/dist/space-client/media-controller.d.ts.map +1 -1
  47. package/dist/space-client/terminal.test.helpers.d.ts +115 -0
  48. package/dist/space-client/terminal.test.helpers.d.ts.map +1 -0
  49. package/dist/space-client/types.d.ts +2 -0
  50. package/dist/space-client/types.d.ts.map +1 -1
  51. package/dist/sync/index.js +1 -1
  52. package/dist/sync/v1-client.d.ts +1 -0
  53. package/dist/sync/v1-client.d.ts.map +1 -1
  54. package/dist/webhooks/generated/event-v1.d.cts +10 -2
  55. package/dist/webhooks/generated/event-v1.d.ts +10 -2
  56. package/dist/webhooks/generated/event-v1.d.ts.map +1 -1
  57. package/dist/webhooks/generated/fixtures-v1.d.cts +2 -2
  58. package/dist/webhooks/generated/fixtures-v1.d.ts +2 -2
  59. package/dist/webhooks/index.cjs +10 -2
  60. package/dist/webhooks/index.js +10 -2
  61. package/dist/webhooks/test.cjs +2 -2
  62. package/dist/webhooks/test.js +2 -2
  63. package/dist/whiteboard/v1-client.d.ts.map +1 -1
  64. package/package.json +1 -1
@@ -1,33 +1,33 @@
1
- import {
2
- CloudflareRTKClient,
3
- CloudflareSFUClient,
4
- createCloudflareSFUHTTPTransport,
5
- describeMediaCaptureError
6
- } from "./chunk-O7WTAXAY.js";
7
1
  import {
8
2
  AccessGrantError,
9
3
  requireParsedAccessGrant,
10
4
  validEpisodeDiagnosticCredential
11
- } from "./chunk-M3S7HNQF.js";
12
- import {
13
- normalizeTelemetryAttributes
14
- } from "./chunk-EWTITNJ3.js";
5
+ } from "./chunk-ZRMGKMX4.js";
15
6
  import {
16
- createTraceContext,
17
- journeyHeaders,
18
- parseTraceparent
19
- } from "./chunk-HW5TTZL2.js";
7
+ CloudflareRTKClient,
8
+ CloudflareSFUClient,
9
+ createCloudflareSFUHTTPTransport,
10
+ describeMediaCaptureError
11
+ } from "./chunk-2DZUHV6I.js";
20
12
  import {
21
13
  ChatAttachmentMimeTypeSchema,
22
14
  V1SyncError,
23
15
  createBrowserSyncLifecycle,
24
16
  createBrowserWebSocketFactory,
25
17
  createV1SyncClient
26
- } from "./chunk-OKIL2ELF.js";
18
+ } from "./chunk-CZUJNAUI.js";
27
19
  import {
28
20
  recordInitialConnection,
29
21
  recordReconnect
30
22
  } from "./chunk-RCDXIDCN.js";
23
+ import {
24
+ normalizeTelemetryAttributes
25
+ } from "./chunk-EWTITNJ3.js";
26
+ import {
27
+ createTraceContext,
28
+ journeyHeaders,
29
+ parseTraceparent
30
+ } from "./chunk-HW5TTZL2.js";
31
31
 
32
32
  // src/access/manager.ts
33
33
  import { Clock, Context, Data, Effect, Layer, SynchronizedRef } from "effect";
@@ -154,6 +154,20 @@ var ConnectionPlatformService = class extends Context2.Service()("@chalk/client/
154
154
  var makeConnectionPlatformLayer = (dependencies) => Layer2.succeed(ConnectionPlatformService, dependencies);
155
155
  var makeFakeConnectionPlatformLayer = makeConnectionPlatformLayer;
156
156
 
157
+ // src/whiteboard/types.ts
158
+ var ChalkWhiteboardV1Error = class extends Error {
159
+ operation;
160
+ code;
161
+ recoverable;
162
+ constructor(failure3, options) {
163
+ super(failure3.message, options);
164
+ this.name = "ChalkWhiteboardV1Error";
165
+ this.operation = failure3.operation;
166
+ this.code = failure3.code;
167
+ this.recoverable = failure3.recoverable;
168
+ }
169
+ };
170
+
157
171
  // src/collaboration/types.ts
158
172
  var CHALK_CHAT_ATTACHMENT_LIMITS = {
159
173
  maximumPerMessage: 5,
@@ -301,20 +315,6 @@ function isAllowedMimeType(value) {
301
315
  return typeof value === "string" && CHALK_CHAT_ATTACHMENT_MIME_TYPES.includes(value);
302
316
  }
303
317
 
304
- // src/whiteboard/types.ts
305
- var ChalkWhiteboardV1Error = class extends Error {
306
- operation;
307
- code;
308
- recoverable;
309
- constructor(failure3, options) {
310
- super(failure3.message, options);
311
- this.name = "ChalkWhiteboardV1Error";
312
- this.operation = failure3.operation;
313
- this.code = failure3.code;
314
- this.recoverable = failure3.recoverable;
315
- }
316
- };
317
-
318
318
  // src/whiteboard/file-http-transport.ts
319
319
  function createChalkWhiteboardV1FileHttpTransport(options) {
320
320
  const fetchImplementation = options.fetch ?? globalThis.fetch;
@@ -1304,6 +1304,8 @@ var ChalkWhiteboardV1Client = class {
1304
1304
  void this.#requestSnapshot().catch(() => void 0);
1305
1305
  }
1306
1306
  #requestSnapshot() {
1307
+ const active2 = this.#snapshots.values().next().value;
1308
+ if (active2) return active2.promise;
1307
1309
  const requestId = this.#nextId();
1308
1310
  const frame = { type: "request_snapshot", request_id: requestId };
1309
1311
  encodeWhiteboardV1ClientFrame(frame);
@@ -1311,7 +1313,7 @@ var ChalkWhiteboardV1Client = class {
1311
1313
  const promise = new Promise((resolve, reject) => {
1312
1314
  deferred = { resolve, reject, settled: false };
1313
1315
  });
1314
- this.#snapshots.set(requestId, { requestId, deferred, pages: /* @__PURE__ */ new Map() });
1316
+ this.#snapshots.set(requestId, { requestId, deferred, promise, pages: /* @__PURE__ */ new Map() });
1315
1317
  this.#send(frame);
1316
1318
  return promise;
1317
1319
  }
@@ -1725,11 +1727,25 @@ function createChalkWhiteboardV1Client(options) {
1725
1727
  });
1726
1728
  }
1727
1729
 
1730
+ // src/connection/types.ts
1731
+ var ConnectionError = class extends Error {
1732
+ code;
1733
+ action;
1734
+ recoverable;
1735
+ constructor(failure3, options) {
1736
+ super(failure3.message, options);
1737
+ this.name = "ConnectionError";
1738
+ this.code = failure3.code;
1739
+ this.action = failure3.action;
1740
+ this.recoverable = failure3.recoverable;
1741
+ }
1742
+ };
1743
+
1728
1744
  // src/connection/media-devices.ts
1729
1745
  function createBrowserMediaDevices(mediaDevices = globalThis.navigator?.mediaDevices) {
1730
1746
  return {
1731
1747
  async getUserMedia(constraints) {
1732
- if (!mediaDevices?.getUserMedia) throw new DOMException("Browser media capture is unavailable", "NotSupportedError");
1748
+ if (!mediaDevices?.getUserMedia || globalThis.isSecureContext === false) throw new ConnectionError({ code: "unsupported_environment", action: null, recoverable: false, message: "Browser media capture is unavailable. Use a supported browser over HTTPS." });
1733
1749
  return mediaDevices.getUserMedia(constraints);
1734
1750
  },
1735
1751
  async getDisplayMedia(constraints) {
@@ -1911,6 +1927,26 @@ function whiteboardURL(syncURL) {
1911
1927
  return url.toString();
1912
1928
  }
1913
1929
 
1930
+ // src/foreground-deadline.ts
1931
+ function scheduleForegroundDeadline(clock, milliseconds, expired) {
1932
+ let timer;
1933
+ let cancelled = false;
1934
+ const arm = () => {
1935
+ const started = clock.now();
1936
+ timer = clock.setTimeout(() => {
1937
+ if (cancelled) return;
1938
+ const elapsed = clock.now() - started;
1939
+ if (typeof document !== "undefined" && document.visibilityState === "hidden" || elapsed > milliseconds + 2e3 || elapsed < 0) arm();
1940
+ else expired();
1941
+ }, milliseconds);
1942
+ };
1943
+ arm();
1944
+ return () => {
1945
+ cancelled = true;
1946
+ clock.clearTimeout(timer);
1947
+ };
1948
+ }
1949
+
1914
1950
  // src/connection/lifecycle.ts
1915
1951
  import { Clock as Clock2, Context as Context3, Data as Data2, Deferred, Duration, Effect as Effect2, Exit, Fiber, Layer as Layer3, Queue, Scope, SubscriptionRef } from "effect";
1916
1952
 
@@ -1967,26 +2003,15 @@ var ConnectionDiagnostics = class {
1967
2003
  }
1968
2004
  };
1969
2005
 
1970
- // src/connection/types.ts
1971
- var ConnectionError = class extends Error {
1972
- code;
1973
- action;
1974
- recoverable;
1975
- constructor(failure3, options) {
1976
- super(failure3.message, options);
1977
- this.name = "ConnectionError";
1978
- this.code = failure3.code;
1979
- this.action = failure3.action;
1980
- this.recoverable = failure3.recoverable;
1981
- }
1982
- };
1983
-
1984
2006
  // src/connection/lifecycle.ts
2007
+ var MEDIA_RECOVERY_TIMEOUT_MS = 6e4;
1985
2008
  var START_TIMEOUT_MS = 1e4;
1986
2009
  var LEAVE_TIMEOUT_MS = 5e3;
1987
2010
  var RECOVERY_BUDGET_MS = 1e4;
1988
2011
  var MAX_RECOVERY_ATTEMPTS = 3;
1989
2012
  var REFRESH_RETRY_MS = 5e3;
2013
+ var RECONNECT_GRACE_MS = 250;
2014
+ var BACKGROUND_RETRY_MAX_MS = 6e4;
1990
2015
  var ConnectionLifecycleFailure = class extends Data2.TaggedError("ConnectionLifecycleFailure") {
1991
2016
  };
1992
2017
  var ConnectionLifecycleService = class extends Context3.Service()("@chalk/client/ConnectionLifecycle") {
@@ -1997,7 +2022,7 @@ var makeConnectionLifecycleLayer = (options) => {
1997
2022
  const access = makeConnectionAccessLayer(
1998
2023
  (request) => Effect2.tryPromise({
1999
2024
  try: (signal) => Promise.resolve(options.access(request ? { ...request, signal } : void 0)),
2000
- catch: (cause) => new ConnectionAccessFailure({ code: "access.unavailable", cause })
2025
+ catch: (cause) => cause instanceof ConnectionAccessFailure ? cause : new ConnectionAccessFailure({ code: accessRejected(cause) ? "access.invalid" : "access.unavailable", cause })
2001
2026
  }),
2002
2027
  options.accessRefreshWindowMs
2003
2028
  );
@@ -2032,6 +2057,8 @@ var makeConnectionLifecycleLayerFromServices = (options) => Layer3.effect(
2032
2057
  let joinDeferred = null;
2033
2058
  let activeJoin = null;
2034
2059
  let fallbackIdentifier = 0;
2060
+ let emittedPorts = null;
2061
+ let foregroundRecoveryDeadline = 0;
2035
2062
  const model = {
2036
2063
  state: "idle",
2037
2064
  sync: null,
@@ -2044,21 +2071,51 @@ var makeConnectionLifecycleLayerFromServices = (options) => Layer3.effect(
2044
2071
  try: () => intent.microphone || intent.camera ? platform.mediaDevices.getUserMedia({ audio: intent.microphone, video: intent.camera }) : Promise.resolve(streamFromTracks([])),
2045
2072
  catch: (cause) => cause
2046
2073
  }),
2074
+ initialMediaIntent: null,
2047
2075
  intent: { microphone: options.initialMicrophoneEnabled ?? true, camera: options.initialCameraEnabled ?? true },
2048
2076
  episodeEndConfirmed: false,
2049
2077
  activeScope: null,
2050
2078
  syncBindingCleanup: null,
2051
2079
  mediaBindingCleanup: null,
2052
2080
  refreshFiber: null,
2081
+ refreshFailures: 0,
2082
+ refreshInFlight: false,
2053
2083
  recoveryQueued: null,
2054
2084
  joinCancelled: false,
2055
2085
  closed: false,
2056
2086
  epoch: 0
2057
2087
  };
2088
+ const mediaAttempt = { expiresAt: null };
2058
2089
  const foreign7 = (operation, code = "internal_error", message = "A browser transport operation failed") => Effect2.tryPromise({ try: operation, catch: (cause) => lifecycleFailure(code, true, message, cause) });
2090
+ const withinForegroundBudget = (effect, milliseconds, failure3) => Effect2.raceFirst(
2091
+ effect,
2092
+ Effect2.callback((resume) => {
2093
+ const cancel = scheduleForegroundDeadline(platform.clock, milliseconds, () => resume(Effect2.fail(failure3)));
2094
+ return Effect2.sync(cancel);
2095
+ })
2096
+ );
2059
2097
  const toPromise = (effect) => Effect2.runPromiseWith(context)(effect);
2060
- const publish = () => Effect2.sync(() => snapshotFor(model, access.currentUnsafe())).pipe(
2061
- Effect2.tap((snapshot) => SubscriptionRef.set(snapshotRef, snapshot)),
2098
+ const publish = () => Effect2.sync(() => ({ previous: SubscriptionRef.getUnsafe(snapshotRef), snapshot: snapshotFor(model, access.currentUnsafe()) })).pipe(
2099
+ Effect2.tap(({ snapshot }) => SubscriptionRef.set(snapshotRef, snapshot)),
2100
+ Effect2.tap(
2101
+ ({ previous, snapshot }) => Effect2.sync(() => {
2102
+ const ports = portsFor(model);
2103
+ if (![
2104
+ ports?.sync === emittedPorts?.sync,
2105
+ ports?.media === emittedPorts?.media,
2106
+ ["healthy", "unresponsive"].includes(previous.connection.sync) === ["healthy", "unresponsive"].includes(snapshot.connection.sync),
2107
+ previous.connection.media === "healthy" === (snapshot.connection.media === "healthy")
2108
+ ].every(Boolean)) {
2109
+ emittedPorts = ports;
2110
+ for (const listener of portListeners) {
2111
+ try {
2112
+ listener(ports);
2113
+ } catch {
2114
+ }
2115
+ }
2116
+ }
2117
+ })
2118
+ ),
2062
2119
  Effect2.asVoid,
2063
2120
  Effect2.tap(
2064
2121
  () => Effect2.sync(() => {
@@ -2071,15 +2128,6 @@ var makeConnectionLifecycleLayerFromServices = (options) => Layer3.effect(
2071
2128
  })
2072
2129
  )
2073
2130
  );
2074
- const emitPorts = () => Effect2.sync(() => {
2075
- const ports = portsFor(model);
2076
- for (const listener of portListeners) {
2077
- try {
2078
- listener(ports);
2079
- } catch {
2080
- }
2081
- }
2082
- });
2083
2131
  const transition = (state) => Effect2.gen(function* () {
2084
2132
  if (model.state === state) return;
2085
2133
  model.state = state;
@@ -2109,28 +2157,51 @@ var makeConnectionLifecycleLayerFromServices = (options) => Layer3.effect(
2109
2157
  model.refreshFiber = yield* Effect2.forkIn(Effect2.sleep(Math.max(0, milliseconds)).pipe(Effect2.andThen(enqueue(refreshAccess())), Effect2.ignore), scope);
2110
2158
  });
2111
2159
  const refreshAccess = () => Effect2.gen(function* () {
2112
- if (!active(model)) return;
2160
+ const scope = model.activeScope;
2161
+ if (!active(model) || !scope || model.refreshInFlight) return;
2113
2162
  const epoch = model.epoch;
2114
- const result = yield* Effect2.exit(access.ensureFresh("scheduled_refresh"));
2115
- if (result._tag === "Failure") {
2116
- diagnostics.record({ event: "access_refresh_failed", state: model.state, epoch, code: "access_unavailable" });
2117
- yield* scheduleRefresh(REFRESH_RETRY_MS);
2118
- return;
2119
- }
2120
- diagnostics.record({ event: "access_refreshed", state: model.state, epoch });
2121
- yield* publish();
2122
- yield* scheduleRefresh();
2163
+ model.refreshInFlight = true;
2164
+ yield* Effect2.forkIn(
2165
+ access.ensureFresh("scheduled_refresh").pipe(
2166
+ Effect2.match({ onSuccess: () => null, onFailure: (cause) => cause }),
2167
+ Effect2.flatMap(
2168
+ (failure3) => enqueue(
2169
+ Effect2.gen(function* () {
2170
+ if (model.epoch !== epoch || !active(model)) return;
2171
+ model.refreshInFlight = false;
2172
+ if (failure3) {
2173
+ diagnostics.record({ event: "access_refresh_failed", state: model.state, epoch, code: failure3.code === "access.invalid" ? "invalid_access" : "access_unavailable" });
2174
+ if (failure3.code === "access.invalid") return yield* failForSnapshot(accessFailure(failure3));
2175
+ model.refreshFailures = Math.min(model.refreshFailures + 1, 5);
2176
+ yield* scheduleRefresh(Math.min(BACKGROUND_RETRY_MAX_MS, REFRESH_RETRY_MS * 2 ** (model.refreshFailures - 1)));
2177
+ return;
2178
+ }
2179
+ model.refreshFailures = 0;
2180
+ diagnostics.record({ event: "access_refreshed", state: model.state, epoch });
2181
+ yield* publish();
2182
+ yield* scheduleRefresh();
2183
+ })
2184
+ )
2185
+ ),
2186
+ Effect2.ignore
2187
+ ),
2188
+ scope
2189
+ );
2123
2190
  });
2124
2191
  const stopPorts = () => Effect2.gen(function* () {
2125
2192
  yield* stopRefresh();
2126
2193
  const scope = model.activeScope;
2127
2194
  model.activeScope = null;
2195
+ model.refreshFailures = 0;
2196
+ model.refreshInFlight = false;
2197
+ mediaAttempt.expiresAt = null;
2128
2198
  model.sync = null;
2129
2199
  model.media = null;
2130
2200
  model.syncSnapshot = null;
2131
2201
  model.syncNoticeUnresponsive = false;
2132
2202
  model.mediaSnapshot = null;
2133
- yield* emitPorts();
2203
+ model.recoveryQueued = null;
2204
+ yield* publish();
2134
2205
  if (scope) yield* Scope.close(scope, Exit.void);
2135
2206
  yield* access.clear;
2136
2207
  yield* publish();
@@ -2193,19 +2264,16 @@ var makeConnectionLifecycleLayerFromServices = (options) => Layer3.effect(
2193
2264
  yield* handleMediaSnapshot(media, media.getSnapshot());
2194
2265
  });
2195
2266
  const waitForSyncLive = (sync, timeoutMs) => Effect2.callback((resume) => {
2196
- if (sync.getSnapshot().connection.phase === "live") {
2197
- resume(Effect2.void);
2198
- return;
2199
- }
2200
- const unsubscribe = sync.subscribe((snapshot) => {
2201
- if (snapshot.connection.phase === "live") resume(Effect2.void);
2202
- else if (snapshot.connection.phase === "terminal" || snapshot.connection.phase === "stopped") resume(Effect2.fail(lifecycleFailure("sync_start_failed", true, "Sync stopped before becoming live")));
2203
- });
2267
+ const observe = (snapshot) => {
2268
+ if (episodeEnded(snapshot)) resume(Effect2.fail(lifecycleFailure("episode_ended", false, "The Episode has ended")));
2269
+ else if (snapshot.connection.phase === "live") resume(Effect2.void);
2270
+ else if (snapshot.connection.phase === "terminal") resume(Effect2.fail(syncTerminalFailure(snapshot)));
2271
+ else if (snapshot.connection.phase === "stopped") resume(Effect2.fail(lifecycleFailure("sync_start_failed", true, "Sync stopped before becoming live")));
2272
+ };
2273
+ const unsubscribe = sync.subscribe(observe);
2274
+ observe(sync.getSnapshot());
2204
2275
  return Effect2.sync(unsubscribe);
2205
- }).pipe(
2206
- Effect2.timeout(timeoutMs),
2207
- Effect2.catchTag("TimeoutError", () => Effect2.fail(lifecycleFailure("sync_start_failed", true, "Sync did not become live before the startup deadline")))
2208
- );
2276
+ }).pipe((effect) => withinForegroundBudget(effect, timeoutMs, lifecycleFailure("sync_start_failed", true, "Sync did not become live before the startup deadline")));
2209
2277
  const trace = (step, effect) => {
2210
2278
  const span = diagnostics.startSpan({ step, state: model.state, epoch: model.epoch });
2211
2279
  return traceRecovery(step, effect, recordInitialConnection).pipe(
@@ -2224,9 +2292,12 @@ var makeConnectionLifecycleLayerFromServices = (options) => Layer3.effect(
2224
2292
  const scope = yield* Scope.make("sequential");
2225
2293
  model.activeScope = scope;
2226
2294
  let stream = null;
2295
+ let captureWarning = null;
2227
2296
  yield* Effect2.gen(function* () {
2228
2297
  if (model.joinCancelled) return yield* Effect2.fail(lifecycleFailure("invalid_state", false, "Join was cancelled by Leave"));
2229
- stream = yield* trace("acquire_initial_media", model.initialMedia(model.intent).pipe(Effect2.mapError((cause) => captureFailure(cause))));
2298
+ const captured = yield* trace("acquire_initial_media", acquireInitialMedia(model.initialMedia, model.initialMediaIntent?.() ?? model.intent));
2299
+ stream = captured.stream;
2300
+ captureWarning = captured.failure;
2230
2301
  if (model.joinCancelled) return yield* Effect2.fail(lifecycleFailure("invalid_state", false, "Join was cancelled by Leave"));
2231
2302
  const grant = yield* trace("access_initialize", access.initialize(reason));
2232
2303
  const media = yield* trace(
@@ -2235,7 +2306,10 @@ var makeConnectionLifecycleLayerFromServices = (options) => Layer3.effect(
2235
2306
  try: () => platform.createMediaClient({
2236
2307
  access: grant,
2237
2308
  credential: () => toPromise(access.getMediaToken()),
2238
- replaceMediaConnection: () => toPromise(access.refresh("media_recovery", true)).then((replacement) => replacement.media),
2309
+ replaceMediaConnection: () => toPromise(access.refresh("media_recovery", true)).then((replacement) => {
2310
+ if (media === model.media) mediaAttempt.expiresAt = platform.clock.now() + MEDIA_RECOVERY_TIMEOUT_MS;
2311
+ return replacement.media;
2312
+ }),
2239
2313
  recordReconnect: options.recordReconnect,
2240
2314
  telemetry: options.telemetry,
2241
2315
  ...options.recordRtcSummary ? { recordRtcSummary: options.recordRtcSummary } : {},
@@ -2275,17 +2349,20 @@ var makeConnectionLifecycleLayerFromServices = (options) => Layer3.effect(
2275
2349
  span.end({ state: model.state, epoch: model.epoch, outcome: "cancelled", code: "invalid_state" });
2276
2350
  return yield* Effect2.fail(lifecycleFailure("invalid_state", false, "Join was cancelled by Leave"));
2277
2351
  }
2352
+ span.end({ state: model.state, epoch: model.epoch, outcome: "failed", code: failure3.code });
2353
+ const retry = !retried && failure3.code === "invalid_access";
2354
+ if (!retry) yield* recordFailure(failure3);
2278
2355
  yield* stopPorts();
2279
- if (!retried && failure3.code === "invalid_access") return yield* performJoin("access_retry", true);
2356
+ if (retry) return yield* performJoin("access_retry", true);
2280
2357
  yield* transition("failed");
2281
- span.end({ state: model.state, epoch: model.epoch, outcome: "failed", code: failure3.code });
2282
2358
  return yield* Effect2.fail(failure3);
2283
2359
  }),
2284
2360
  onSuccess: () => Effect2.gen(function* () {
2285
2361
  model.syncSnapshot = model.sync?.getSnapshot() ?? null;
2286
2362
  model.mediaSnapshot = model.media?.getSnapshot() ?? null;
2363
+ model.failure = captureWarning ? toFailure(captureWarning) : null;
2287
2364
  yield* transition("live");
2288
- yield* emitPorts();
2365
+ if (model.mediaSnapshot?.connection.phase !== "live") yield* recover("media");
2289
2366
  yield* scheduleRefresh();
2290
2367
  span.end({ state: model.state, epoch: model.epoch, outcome: "succeeded" });
2291
2368
  })
@@ -2308,26 +2385,36 @@ var makeConnectionLifecycleLayerFromServices = (options) => Layer3.effect(
2308
2385
  const leave = foreign7(() => sync.leave()).pipe(Effect2.timeout(LEAVE_TIMEOUT_MS));
2309
2386
  return (yield* Effect2.exit(leave))._tag === "Success";
2310
2387
  });
2388
+ const publishFreshAccess = () => enqueue(
2389
+ Effect2.gen(function* () {
2390
+ if (!active(model)) return;
2391
+ diagnostics.record({ event: "access_refreshed", state: model.state, epoch: model.epoch });
2392
+ yield* publish();
2393
+ yield* scheduleRefresh();
2394
+ })
2395
+ );
2396
+ const requireOpen = Effect2.suspend(() => model.closed ? Effect2.fail(lifecycleFailure("invalid_state", false, "The Connection scope is closed")) : Effect2.void);
2311
2397
  const withFreshAccess = (operation) => Effect2.gen(function* () {
2398
+ yield* requireOpen;
2312
2399
  if (yield* access.requiresRefresh) {
2400
+ yield* requireOpen;
2313
2401
  yield* access.ensureFresh("scheduled_refresh").pipe(Effect2.mapError(accessFailure));
2314
- diagnostics.record({ event: "access_refreshed", state: model.state, epoch: model.epoch });
2315
- yield* scheduleRefresh();
2402
+ yield* publishFreshAccess();
2316
2403
  }
2317
- return yield* operation().pipe(
2404
+ const run = requireOpen.pipe(
2405
+ Effect2.andThen(Effect2.suspend(operation)),
2406
+ Effect2.flatMap((value) => requireOpen.pipe(Effect2.as(value)))
2407
+ );
2408
+ return yield* run.pipe(
2318
2409
  Effect2.matchEffect({
2319
2410
  onSuccess: Effect2.succeed,
2320
2411
  onFailure: (cause) => {
2321
2412
  if (!accessRejected(cause)) return Effect2.fail(cause);
2322
- return access.refreshAfterRejection().pipe(
2323
- Effect2.mapError(accessFailure),
2324
- Effect2.tap(
2325
- () => Effect2.sync(() => {
2326
- diagnostics.record({ event: "access_refreshed", state: model.state, epoch: model.epoch });
2327
- })
2328
- ),
2329
- Effect2.andThen(scheduleRefresh()),
2330
- Effect2.andThen(operation())
2413
+ return requireOpen.pipe(
2414
+ Effect2.andThen(access.refreshAfterRejection()),
2415
+ Effect2.mapError((failure3) => failure3 instanceof ConnectionAccessFailure ? accessFailure(failure3) : failure3),
2416
+ Effect2.andThen(publishFreshAccess()),
2417
+ Effect2.andThen(run)
2331
2418
  );
2332
2419
  }
2333
2420
  })
@@ -2346,15 +2433,67 @@ var makeConnectionLifecycleLayerFromServices = (options) => Layer3.effect(
2346
2433
  )
2347
2434
  );
2348
2435
  });
2349
- const recover = (kind) => Effect2.gen(function* recoverEffect() {
2350
- if (!active(model)) return;
2351
- if (model.recoveryQueued === kind) return;
2436
+ const recover = (kind) => Effect2.gen(function* () {
2437
+ const scope = model.activeScope;
2438
+ if (!active(model) || !scope) return;
2439
+ if (model.recoveryQueued !== null) {
2440
+ model.recoveryQueued = kind;
2441
+ yield* publish();
2442
+ return;
2443
+ }
2352
2444
  model.recoveryQueued = kind;
2353
- yield* transition("reconnecting");
2354
- const plan = yield* recoveryPlan(kind);
2355
- const attempt = yield* attemptRecovery(plan);
2356
- if (attempt !== null) return yield* finishRecovery(attempt);
2357
- yield* failRecovery(plan);
2445
+ const phase = kind === "sync" ? model.sync?.getSnapshot().connection.phase : model.media?.getSnapshot().connection.phase;
2446
+ if (phase === "terminal" || phase === "failed") yield* transition("reconnecting");
2447
+ const epoch = model.epoch;
2448
+ yield* Effect2.forkIn(
2449
+ Effect2.gen(function* () {
2450
+ yield* Effect2.sleep(RECONNECT_GRACE_MS);
2451
+ const phase2 = kind === "sync" ? model.sync?.getSnapshot().connection.phase : model.media?.getSnapshot().connection.phase;
2452
+ if (phase2 === "live") {
2453
+ enqueueBackground(finishRecovery(0, epoch));
2454
+ return;
2455
+ }
2456
+ yield* enqueue(
2457
+ Effect2.gen(function* () {
2458
+ if (model.epoch === epoch && active(model)) yield* transition("reconnecting");
2459
+ })
2460
+ );
2461
+ let cycle = 0;
2462
+ const syncAttempt = { client: null, expiresAt: null, started: true };
2463
+ while (model.epoch === epoch && active(model)) {
2464
+ const plan = yield* recoveryPlan(model.recoveryQueued ?? kind);
2465
+ const outcome = yield* attemptRecovery(plan, mediaAttempt, syncAttempt).pipe(
2466
+ Effect2.match({
2467
+ onSuccess: (attempt) => ({ attempt, failure: null }),
2468
+ onFailure: (failure3) => ({ attempt: null, failure: failure3 })
2469
+ })
2470
+ );
2471
+ if (outcome.failure) {
2472
+ const failure3 = outcome.failure;
2473
+ enqueueBackground(Effect2.suspend(() => model.epoch === epoch && active(model) ? failForSnapshot(failure3) : Effect2.void));
2474
+ return;
2475
+ }
2476
+ if (outcome.attempt !== null) {
2477
+ enqueueBackground(finishRecovery(outcome.attempt, epoch));
2478
+ return;
2479
+ }
2480
+ yield* enqueue(
2481
+ Effect2.gen(function* () {
2482
+ if (model.epoch !== epoch || !active(model)) return;
2483
+ const code = plan.kind === "media" ? "media_recovery_exhausted" : "sync_recovery_exhausted";
2484
+ model.failure = toFailure(lifecycleFailure(code, true, "Connection interrupted. Reconnecting in the background."));
2485
+ diagnostics.record({ event: "recovery_exhausted", state: model.state, epoch, code });
2486
+ yield* publish();
2487
+ })
2488
+ );
2489
+ if (model.recoveryQueued !== plan.kind) continue;
2490
+ const delay = Math.min(BACKGROUND_RETRY_MAX_MS, 250 * 2 ** cycle);
2491
+ cycle = Math.min(cycle + 1, 8);
2492
+ yield* Effect2.race(Effect2.sleep(delay * (0.5 + Math.random() * 0.5)), waitForTransportChange(plan.kind));
2493
+ }
2494
+ }),
2495
+ scope
2496
+ );
2358
2497
  });
2359
2498
  const recoveryPlan = (kind) => Clock2.currentTimeMillis.pipe(
2360
2499
  Effect2.map((now) => ({
@@ -2364,80 +2503,216 @@ var makeConnectionLifecycleLayerFromServices = (options) => Layer3.effect(
2364
2503
  delays: options.recovery?.backoffMs?.length ? options.recovery.backoffMs : [100, 250, 500]
2365
2504
  }))
2366
2505
  );
2367
- const attemptRecovery = (plan) => Effect2.gen(function* attemptRecoveryEffect() {
2506
+ const attemptRecovery = (plan, mediaAttempt2, syncAttempt) => Effect2.gen(function* () {
2368
2507
  for (let attempt = 1; attempt <= plan.attempts; attempt += 1) {
2369
2508
  const remaining = plan.deadline - (yield* Clock2.currentTimeMillis);
2370
2509
  if (remaining <= 0) return null;
2371
2510
  recordReconnect(options.recordReconnect, "attempt", { recovery_kind: plan.kind, attempt });
2372
2511
  diagnostics.record({ event: "recovery_attempt", state: model.state, epoch: model.epoch, attempt });
2373
- const outcome = yield* Effect2.exit(recoveryOperation(plan.kind).pipe(Effect2.timeout(remaining)));
2374
- if (outcome._tag === "Success") return attempt;
2375
- if (attempt < plan.attempts) yield* traceRecovery("lifecycle_backoff", Effect2.sleep(recoveryDelay(plan, attempt)));
2512
+ const failure3 = yield* recoveryOperation(mediaAttempt2, syncAttempt).pipe(
2513
+ Effect2.timeout(remaining),
2514
+ Effect2.catchTag("TimeoutError", () => Effect2.fail(lifecycleFailure("sync_start_failed", true, "Connection recovery is still waiting for the transport"))),
2515
+ Effect2.match({ onSuccess: () => null, onFailure: (cause) => cause })
2516
+ );
2517
+ if (failure3 === null) return attempt;
2518
+ if (!failure3.recoverable) return yield* Effect2.fail(failure3);
2519
+ if (attempt < plan.attempts) {
2520
+ const remaining2 = Math.max(0, plan.deadline - (yield* Clock2.currentTimeMillis));
2521
+ yield* traceRecovery("lifecycle_backoff", Effect2.sleep(Math.min(recoveryDelay(plan, attempt), remaining2)));
2522
+ }
2376
2523
  }
2377
2524
  return null;
2378
2525
  });
2379
- const recoveryOperation = (kind) => kind === "media" ? recoverMedia() : recoverSync();
2380
- const recoveryDelay = (plan, attempt) => Math.max(0, plan.delays[Math.min(attempt - 1, plan.delays.length - 1)] ?? 0);
2381
- const finishRecovery = (attempt) => Effect2.gen(function* finishRecoveryEffect() {
2526
+ const recoveryOperation = (mediaAttempt2, syncAttempt) => Effect2.gen(function* () {
2527
+ const results = yield* Effect2.all(
2528
+ [recoverSync(syncAttempt), recoverMedia(mediaAttempt2)].map((effect) => effect.pipe(Effect2.matchEffect({ onSuccess: () => Effect2.succeed(null), onFailure: (failure4) => failure4.recoverable ? Effect2.succeed(failure4) : Effect2.fail(failure4) }))),
2529
+ { concurrency: "unbounded" }
2530
+ );
2531
+ const failure3 = results.find((failure4) => failure4 !== null);
2532
+ if (failure3) return yield* Effect2.fail(failure3);
2533
+ });
2534
+ const recoveryDelay = (plan, attempt) => {
2535
+ const delay = plan.delays[Math.min(attempt - 1, plan.delays.length - 1)] ?? 0;
2536
+ return Number.isFinite(delay) ? Math.max(0, delay) : 0;
2537
+ };
2538
+ const finishRecovery = (attempt, epoch) => Effect2.gen(function* () {
2539
+ if (model.epoch !== epoch || !active(model)) return;
2382
2540
  model.recoveryQueued = null;
2383
- diagnostics.record({ event: "recovery_succeeded", state: model.state, epoch: model.epoch, attempt });
2541
+ model.syncSnapshot = model.sync?.getSnapshot() ?? null;
2542
+ model.mediaSnapshot = model.media?.getSnapshot() ?? null;
2543
+ model.failure = null;
2544
+ if (model.syncSnapshot?.connection.phase !== "live") return yield* recover("sync");
2545
+ if (model.mediaSnapshot?.connection.phase !== "live") return yield* recover("media");
2546
+ mediaAttempt.expiresAt = null;
2547
+ diagnostics.record({ event: "recovery_succeeded", state: model.state, epoch, attempt });
2384
2548
  yield* transition("live");
2549
+ yield* publish();
2385
2550
  yield* scheduleRefresh();
2386
2551
  });
2387
- const failRecovery = (plan) => Effect2.gen(function* failRecoveryEffect() {
2388
- const code = plan.kind === "media" ? "media_recovery_exhausted" : "sync_recovery_exhausted";
2389
- model.recoveryQueued = null;
2390
- model.failure = toFailure(lifecycleFailure(code, false, `${plan.kind} recovery exhausted its retry budget`));
2391
- diagnostics.record({ event: "recovery_exhausted", state: model.state, epoch: model.epoch, code });
2392
- yield* stopPorts();
2393
- yield* transition("failed");
2552
+ const waitForTransportChange = (kind) => Effect2.callback((resume) => {
2553
+ const sync = model.sync;
2554
+ const media = model.media;
2555
+ const syncPhase2 = sync?.getSnapshot().connection.phase;
2556
+ const mediaPhase2 = media?.getSnapshot().connection.phase;
2557
+ const observe = () => {
2558
+ if (model.recoveryQueued !== kind || model.sync !== sync || model.media !== media || model.sync?.getSnapshot().connection.phase !== syncPhase2 || model.media?.getSnapshot().connection.phase !== mediaPhase2 || syncPhase2 === "live" && mediaPhase2 === "live") resume(Effect2.void);
2559
+ };
2560
+ listeners.add(observe);
2561
+ const unsubscribeSync = sync?.subscribe(observe);
2562
+ const unsubscribeMedia = media?.subscribe(observe);
2563
+ observe();
2564
+ return Effect2.sync(() => {
2565
+ listeners.delete(observe);
2566
+ unsubscribeSync?.();
2567
+ unsubscribeMedia?.();
2568
+ });
2569
+ });
2570
+ const waitForMediaLive = (media) => Effect2.callback((resume) => {
2571
+ const observe = () => {
2572
+ const snapshot = media.getSnapshot();
2573
+ if (snapshot.connection.phase === "live") resume(Effect2.void);
2574
+ else if (snapshot.connection.phase === "failed") resume(Effect2.fail(lifecycleFailure("media_start_failed", snapshot.failure?.recoverable ?? true, "Media transport needs recovery")));
2575
+ };
2576
+ const unsubscribe = media.subscribe(observe);
2577
+ observe();
2578
+ return Effect2.sync(unsubscribe);
2394
2579
  });
2395
- const recoverMedia = () => Effect2.gen(function* () {
2580
+ const recoverMedia = (attempt) => Effect2.gen(function* () {
2396
2581
  const media = model.media;
2397
- const sync = model.sync;
2398
- if (!media || !sync) return yield* Effect2.fail(lifecycleFailure("invalid_state", false, "Media recovery requires active ports"));
2582
+ if (!media) return yield* Effect2.fail(lifecycleFailure("invalid_state", false, "Media recovery requires active ports"));
2583
+ if (media.getSnapshot().connection.phase === "live") {
2584
+ attempt.expiresAt = null;
2585
+ return;
2586
+ }
2587
+ if (media.getSnapshot().connection.phase === "recovering") {
2588
+ const now = yield* Clock2.currentTimeMillis;
2589
+ attempt.expiresAt ??= now + MEDIA_RECOVERY_TIMEOUT_MS;
2590
+ attempt.expiresAt = Math.max(attempt.expiresAt, foregroundRecoveryDeadline);
2591
+ if (typeof document !== "undefined" && document.visibilityState === "hidden") attempt.expiresAt = now + MEDIA_RECOVERY_TIMEOUT_MS;
2592
+ if (now < attempt.expiresAt) return yield* waitForMediaLive(media);
2593
+ }
2399
2594
  recordReconnect(options.recordReconnect, "media_decision", { strategy: "full_rebuild" });
2400
2595
  const grant = yield* traceRecovery("access_refresh", access.refresh("media_recovery", true).pipe(Effect2.mapError(accessFailure)));
2401
2596
  const restartInput = grant.media.provider === "cloudflare_sfu" ? grant.media.clientPayload : grant.media;
2597
+ attempt.expiresAt = (yield* Clock2.currentTimeMillis) + MEDIA_RECOVERY_TIMEOUT_MS;
2402
2598
  yield* traceRecovery(
2403
2599
  "media_rebuild",
2404
- foreign7(() => media.restart(restartInput))
2600
+ foreign7(() => media.restart(restartInput), "media_start_failed", "Media transport could not restart")
2405
2601
  );
2406
- model.mediaSnapshot = media.getSnapshot();
2602
+ yield* waitForMediaLive(media);
2603
+ });
2604
+ const syncAttemptExpired = (attempt, sync, now) => {
2605
+ if (attempt.client !== sync) {
2606
+ attempt.client = sync;
2607
+ attempt.expiresAt = now + MEDIA_RECOVERY_TIMEOUT_MS;
2608
+ attempt.started = true;
2609
+ }
2610
+ attempt.expiresAt = Math.max(attempt.expiresAt ?? 0, foregroundRecoveryDeadline);
2611
+ if (typeof document !== "undefined" && document.visibilityState === "hidden") attempt.expiresAt = now + MEDIA_RECOVERY_TIMEOUT_MS;
2612
+ return now >= attempt.expiresAt && sync.getSnapshot().connection.phase !== "live";
2613
+ };
2614
+ const syncNeedsReplacement = (snapshot) => {
2615
+ if (snapshot.connection.phase === "idle" || snapshot.connection.phase === "stopped") return Effect2.succeed(true);
2616
+ if (snapshot.connection.phase !== "terminal") return Effect2.succeed(false);
2617
+ const failure3 = syncTerminalFailure(snapshot);
2618
+ return failure3.recoverable ? Effect2.succeed(true) : Effect2.fail(failure3);
2619
+ };
2620
+ const recoverSync = (attempt) => Effect2.gen(function* () {
2621
+ const sync = model.sync;
2622
+ if (!sync) return yield* restartSync(attempt);
2623
+ const snapshot = sync.getSnapshot();
2624
+ const now = yield* Clock2.currentTimeMillis;
2625
+ if (syncAttemptExpired(attempt, sync, now)) {
2626
+ if (!attempt.started || !sync.restartTransport) return yield* restartSync(attempt);
2627
+ sync.restartTransport();
2628
+ attempt.expiresAt = now + MEDIA_RECOVERY_TIMEOUT_MS;
2629
+ }
2630
+ if ((attempt.started || snapshot.connection.phase !== "idle") && (yield* syncNeedsReplacement(snapshot))) return yield* restartSync(attempt);
2407
2631
  yield* waitForSyncLive(sync, boundedInteger(options.recovery?.budgetMs, RECOVERY_BUDGET_MS, 1, 6e4));
2408
2632
  });
2409
- const recoverSync = () => Effect2.gen(function* () {
2410
- const media = model.media;
2411
- const scope = model.activeScope;
2412
- const grant = access.currentUnsafe();
2413
- if (!media || !scope || !grant) return yield* Effect2.fail(lifecycleFailure("invalid_state", false, "Sync recovery requires active ports"));
2633
+ const restartSync = (attempt) => Effect2.gen(function* () {
2634
+ const epoch = model.epoch;
2414
2635
  yield* access.getSyncToken("sync_recovery").pipe(Effect2.mapError(accessFailure));
2415
- model.syncBindingCleanup?.();
2416
- model.sync = null;
2417
- const sync = yield* Effect2.try({
2418
- try: () => platform.createSyncClient({ access: grant, token: () => toPromise(access.getSyncToken()), media, telemetry: options.telemetry, recordReconnect: options.recordReconnect }),
2419
- catch: (cause) => lifecycleFailure("sync_start_failed", true, "The sync layer could not start", cause)
2420
- });
2421
- model.sync = sync;
2422
- yield* bindSync(scope, sync);
2423
- yield* foreign7(() => sync.start());
2636
+ const sync = yield* enqueue(
2637
+ Effect2.gen(function* () {
2638
+ const media = model.media;
2639
+ const scope = model.activeScope;
2640
+ const grant = access.currentUnsafe();
2641
+ if (model.epoch !== epoch || !active(model) || !media || !scope || !grant) return yield* Effect2.fail(lifecycleFailure("invalid_state", false, "Sync recovery requires active ports"));
2642
+ model.syncBindingCleanup?.();
2643
+ model.sync = null;
2644
+ const sync2 = yield* Effect2.try({
2645
+ try: () => platform.createSyncClient({ access: grant, token: () => toPromise(access.getSyncToken()), media, telemetry: options.telemetry, recordReconnect: options.recordReconnect }),
2646
+ catch: (cause) => lifecycleFailure("sync_start_failed", true, "The sync layer could not start", cause)
2647
+ });
2648
+ model.sync = sync2;
2649
+ yield* bindSync(scope, sync2);
2650
+ return sync2;
2651
+ })
2652
+ );
2653
+ attempt.client = sync;
2654
+ attempt.started = false;
2655
+ attempt.expiresAt = (yield* Clock2.currentTimeMillis) + MEDIA_RECOVERY_TIMEOUT_MS;
2656
+ yield* foreign7(
2657
+ () => sync.start().then(() => {
2658
+ if (sync !== model.sync || model.epoch !== epoch) sync.stop();
2659
+ else if (attempt.client === sync) attempt.started = true;
2660
+ })
2661
+ ).pipe(
2662
+ Effect2.tapError(
2663
+ () => enqueue(
2664
+ Effect2.gen(function* () {
2665
+ if (sync !== model.sync) return;
2666
+ model.syncBindingCleanup?.();
2667
+ model.sync = null;
2668
+ model.syncSnapshot = null;
2669
+ yield* publish();
2670
+ })
2671
+ )
2672
+ )
2673
+ );
2424
2674
  yield* waitForSyncLive(sync, boundedInteger(options.recovery?.budgetMs, RECOVERY_BUDGET_MS, 1, 6e4));
2425
- yield* emitPorts();
2426
2675
  });
2427
2676
  const handleSyncSnapshot = (sync, snapshot) => Effect2.gen(function* handleSyncSnapshotEffect() {
2428
2677
  if (!isCurrentSyncSnapshot(sync, snapshot)) return;
2429
2678
  model.syncSnapshot = snapshot;
2430
2679
  if (syncSubjectMismatched(access.currentUnsafe()?.subject ?? null, snapshot)) return yield* failForSnapshot(lifecycleFailure("invalid_access", false, "Sync authenticated a different participant subject"));
2431
- if (episodeEnded(snapshot)) return yield* failForSnapshot(lifecycleFailure("episode_ended", false, "The Episode has ended"));
2680
+ if (yield* settleTerminalSnapshot(snapshot)) return;
2432
2681
  if (syncNeedsRecovery(snapshot)) yield* recover("sync");
2433
2682
  yield* publish();
2434
2683
  });
2684
+ const settleTerminalSnapshot = (snapshot) => Effect2.gen(function* () {
2685
+ const ended = episodeEnded(snapshot);
2686
+ if (ended || snapshot.connection.terminalReason === "participant_inactive") {
2687
+ if (ended) model.episodeEndConfirmed = true;
2688
+ yield* finishTerminalSnapshot();
2689
+ return true;
2690
+ }
2691
+ if (snapshot.connection.phase !== "terminal") return false;
2692
+ const failure3 = syncTerminalFailure(snapshot);
2693
+ if (failure3.recoverable) return false;
2694
+ yield* failForSnapshot(failure3);
2695
+ return true;
2696
+ });
2435
2697
  const isCurrentSyncSnapshot = (sync, snapshot) => sync === model.sync && snapshot !== model.syncSnapshot;
2436
2698
  const syncSubjectMismatched = (subject, snapshot) => subject !== null && snapshot.participantId !== null && (subject.participantId !== snapshot.participantId || subject.participantGeneration !== snapshot.participantGeneration);
2437
- const episodeEnded = (snapshot) => snapshot.control?.status === "ended" || snapshot.optimisticControl?.status === "ended";
2438
- const syncNeedsRecovery = (snapshot) => active(model) && snapshot.connection.phase === "terminal";
2699
+ const episodeEnded = (snapshot) => snapshot.control?.status === "ended" || snapshot.connection.terminalReason === "episode_ended";
2700
+ const finishTerminalSnapshot = () => Effect2.gen(function* () {
2701
+ model.failure = null;
2702
+ yield* transition("leaving");
2703
+ yield* stopPorts();
2704
+ yield* transition("left");
2705
+ });
2706
+ const syncNeedsRecovery = (snapshot) => active(model) && (snapshot.connection.phase === "connecting" || snapshot.connection.phase === "recovering" || snapshot.connection.phase === "terminal");
2707
+ const recordFailure = (failure3) => Effect2.sync(() => {
2708
+ const reason = model.sync?.getSnapshot().connection.terminalReason;
2709
+ const terminalReason = reason === "participant_inactive" || reason === "stale_participant_generation" || reason === "episode_ended" ? reason : failure3.code;
2710
+ diagnostics.record({ event: "connection_failed", state: model.state, epoch: model.epoch, code: failure3.code });
2711
+ recordReconnect(options.recordReconnect, "terminal_failure", { code: failure3.code, terminal_reason: terminalReason }, "failed");
2712
+ });
2439
2713
  const failForSnapshot = (failure3) => Effect2.gen(function* failForSnapshotEffect() {
2440
2714
  model.failure = toFailure(failure3);
2715
+ yield* recordFailure(failure3);
2441
2716
  yield* stopPorts();
2442
2717
  yield* transition("failed");
2443
2718
  });
@@ -2445,21 +2720,43 @@ var makeConnectionLifecycleLayerFromServices = (options) => Layer3.effect(
2445
2720
  if (media !== model.media || media.getSnapshot() !== snapshot) return;
2446
2721
  if (snapshot === model.mediaSnapshot) return;
2447
2722
  model.mediaSnapshot = snapshot;
2448
- if (active(model) && snapshot.connection.phase === "failed" && snapshot.failure?.recoverable) yield* recover("media");
2723
+ if (active(model) && (snapshot.connection.phase === "recovering" || snapshot.connection.phase === "failed" && snapshot.failure?.recoverable)) yield* recover("media");
2449
2724
  yield* publish();
2450
2725
  });
2451
2726
  const handleMediaFailure = () => model.media ? handleMediaSnapshot(model.media, model.media.getSnapshot()) : Effect2.void;
2452
2727
  const applyAutomaticMediaReplacement = (grant) => Effect2.gen(function* () {
2453
2728
  const media = model.media;
2454
- if (!media || !active(model)) return;
2729
+ const scope = model.activeScope;
2730
+ const epoch = model.epoch;
2731
+ if (!media || !scope || !active(model)) return;
2455
2732
  const restartInput = grant.media.provider === "cloudflare_sfu" ? grant.media.clientPayload : grant.media;
2456
- const restarted = yield* Effect2.exit(foreign7(() => media.restart(restartInput), "media_start_failed", "The media client could not apply a replacement access grant"));
2457
- if (restarted._tag === "Failure") {
2458
- yield* recover("media");
2459
- return;
2460
- }
2461
- model.mediaSnapshot = media.getSnapshot();
2462
- yield* publish();
2733
+ mediaAttempt.expiresAt = (yield* Clock2.currentTimeMillis) + MEDIA_RECOVERY_TIMEOUT_MS;
2734
+ yield* Effect2.forkIn(
2735
+ foreign7(
2736
+ () => media.restart(restartInput).then(() => {
2737
+ if (media !== model.media || model.epoch !== epoch) media.stop();
2738
+ }),
2739
+ "media_start_failed",
2740
+ "The media client could not apply a replacement access grant"
2741
+ ).pipe(
2742
+ (effect) => withinForegroundBudget(effect, MEDIA_RECOVERY_TIMEOUT_MS, lifecycleFailure("media_start_failed", true, "Automatic media replacement did not finish")),
2743
+ Effect2.match({ onSuccess: () => true, onFailure: () => false }),
2744
+ Effect2.flatMap(
2745
+ (restarted) => enqueue(
2746
+ Effect2.gen(function* () {
2747
+ if (model.epoch !== epoch || media !== model.media || !active(model)) return;
2748
+ if (!restarted) {
2749
+ yield* recover("media");
2750
+ return;
2751
+ }
2752
+ model.mediaSnapshot = media.getSnapshot();
2753
+ yield* publish();
2754
+ })
2755
+ )
2756
+ )
2757
+ ),
2758
+ scope
2759
+ );
2463
2760
  });
2464
2761
  const notifyScreenEnded = () => Effect2.sync(() => {
2465
2762
  if (!active(model)) return;
@@ -2480,7 +2777,10 @@ var makeConnectionLifecycleLayerFromServices = (options) => Layer3.effect(
2480
2777
  }) : Deferred.complete(work.deferred, work.effect).pipe(Effect2.asVoid);
2481
2778
  yield* Effect2.forkScoped(Queue.take(queue).pipe(Effect2.flatMap(process), Effect2.forever));
2482
2779
  const accessReplacementUnsubscribe = yield* access.subscribeAutomaticMediaReplacement((grant) => enqueueBackground(applyAutomaticMediaReplacement(grant)));
2483
- const foregroundUnsubscribe = platform.subscribeForeground?.(() => enqueueBackground(refreshAccess())) ?? null;
2780
+ const foregroundUnsubscribe = platform.subscribeForeground?.(() => {
2781
+ foregroundRecoveryDeadline = platform.clock.now() + MEDIA_RECOVERY_TIMEOUT_MS;
2782
+ enqueueBackground(refreshAccess());
2783
+ }) ?? null;
2484
2784
  yield* Effect2.addFinalizer(
2485
2785
  () => Effect2.gen(function* () {
2486
2786
  model.closed = true;
@@ -2509,8 +2809,9 @@ var makeConnectionLifecycleLayerFromServices = (options) => Layer3.effect(
2509
2809
  },
2510
2810
  getDiagnostics: () => diagnostics.snapshot(),
2511
2811
  getJoinTrace: () => diagnostics.joinTrace(),
2512
- setInitialMedia: (provider) => Effect2.sync(() => {
2812
+ setInitialMedia: (provider, intent) => Effect2.sync(() => {
2513
2813
  model.initialMedia = provider;
2814
+ model.initialMediaIntent = intent ?? null;
2514
2815
  }),
2515
2816
  configureJoin: (intent) => Effect2.sync(() => {
2516
2817
  if (model.state !== "idle" && model.state !== "left" && model.state !== "failed") throw lifecycleFailure("invalid_state", false, `Cannot configure Join while ${model.state}`);
@@ -2540,13 +2841,20 @@ var makeConnectionLifecycleLayerFromServices = (options) => Layer3.effect(
2540
2841
  runCommand: (operation) => enqueue(
2541
2842
  Effect2.gen(function* () {
2542
2843
  const ports = portsFor(model);
2543
- if (model.state !== "live" || !ports) return yield* Effect2.fail(lifecycleFailure("invalid_state", false, `Cannot run a command while ${model.state}`));
2544
- const value = yield* withFreshAccess(() => operation(ports));
2545
- if (model.state !== "live") return yield* Effect2.fail(lifecycleFailure("invalid_state", false, "The command belongs to an inactive Connection"));
2546
- return value;
2844
+ if (!active(model) || !ports || ports.sync.getSnapshot().connection.phase !== "live") return yield* Effect2.fail(lifecycleFailure("invalid_state", true, "Connection interrupted. Try again when reconnected."));
2845
+ return ports;
2547
2846
  })
2847
+ ).pipe(
2848
+ Effect2.flatMap(
2849
+ (ports) => withFreshAccess(
2850
+ () => Effect2.suspend(() => {
2851
+ if (!active(model) || ports.sync !== model.sync) return Effect2.fail(lifecycleFailure("invalid_state", true, "Connection interrupted. Try again when reconnected."));
2852
+ return operation(ports).pipe(Effect2.flatMap((value) => active(model) && ports.sync === model.sync ? Effect2.succeed(value) : Effect2.fail(lifecycleFailure("invalid_state", true, "The command belongs to an inactive Connection"))));
2853
+ })
2854
+ )
2855
+ )
2548
2856
  ),
2549
- runPortCommand: (operation) => enqueue(withFreshAccess(operation)),
2857
+ runPortCommand: (operation) => withFreshAccess(operation),
2550
2858
  nowUnsafe: () => clock.currentTimeMillisUnsafe(),
2551
2859
  scheduleUnsafe: (callback, milliseconds) => platform.clock.setTimeout(callback, milliseconds),
2552
2860
  cancelScheduleUnsafe: (handle) => platform.clock.clearTimeout(handle),
@@ -2564,7 +2872,8 @@ function snapshotFor(model, access) {
2564
2872
  subject: subjectFor(access),
2565
2873
  episode: episodeFor(access, control),
2566
2874
  connection: Object.freeze({ sync: model.syncSnapshot?.connection.phase === "live" && model.syncNoticeUnresponsive ? "unresponsive" : syncPhase(model.syncSnapshot?.connection.phase), media: mediaPhase(model.mediaSnapshot?.connection.phase) }),
2567
- failure: model.failure ? Object.freeze({ ...model.failure }) : null
2875
+ failure: model.failure ? Object.freeze({ ...model.failure }) : null,
2876
+ episodeEnded: model.episodeEndConfirmed
2568
2877
  });
2569
2878
  }
2570
2879
  function subjectFor(access) {
@@ -2583,7 +2892,7 @@ function portsFor(model) {
2583
2892
  return model.sync && model.media ? { sync: model.sync, media: model.media } : null;
2584
2893
  }
2585
2894
  function active(model) {
2586
- return model.state === "live" || model.state === "reconnecting";
2895
+ return !model.closed && (model.state === "live" || model.state === "reconnecting");
2587
2896
  }
2588
2897
  function lifecycleFailure(code, recoverable, message, cause) {
2589
2898
  return new ConnectionLifecycleFailure({ code, recoverable, message, ...cause === void 0 ? {} : { cause } });
@@ -2592,11 +2901,60 @@ function toFailure(value) {
2592
2901
  return Object.freeze({ code: value.code, action: null, recoverable: value.recoverable, message: value.message });
2593
2902
  }
2594
2903
  function accessFailure(value) {
2595
- return lifecycleFailure(value.code === "access.invalid" ? "invalid_access" : "access_unavailable", value.code === "access.unavailable", "Access was rejected", value.cause);
2904
+ return lifecycleFailure(value.code === "access.invalid" ? "invalid_access" : "access_unavailable", value.code === "access.unavailable", value.code === "access.invalid" ? "Access was rejected" : "Could not reach the Space. Check your network and try again.", value.cause);
2596
2905
  }
2597
2906
  function captureFailure(cause) {
2598
- if (cause instanceof DOMException && cause.name === "NotAllowedError") return lifecycleFailure("permission_denied", true, "Media permission was denied", cause);
2599
- return lifecycleFailure("unsupported_environment", false, "Browser media capture is unavailable", cause);
2907
+ if (cause instanceof ConnectionError && cause.cause === void 0) return lifecycleFailure(cause.code, cause.recoverable, cause.message, cause);
2908
+ const failure3 = describeMediaCaptureError(cause instanceof ConnectionError ? cause.cause : cause);
2909
+ if (failure3.code === "unsupported_environment") return lifecycleFailure("media_capture_failed", true, "Media capture failed. Try again or enter with devices off.", cause);
2910
+ return lifecycleFailure(failure3.code, failure3.recoverable, failure3.message, cause);
2911
+ }
2912
+ function acquireInitialMedia(provider, intent) {
2913
+ return provider(intent).pipe(
2914
+ Effect2.map((stream) => ({ stream, failure: null })),
2915
+ Effect2.catch((cause) => {
2916
+ const initialFailure = captureFailure(cause);
2917
+ if (!initialFailure.recoverable) return Effect2.fail(initialFailure);
2918
+ const tracks = /* @__PURE__ */ new Set();
2919
+ return Effect2.gen(function* () {
2920
+ const failures = [];
2921
+ for (const source of ["microphone", "camera"]) {
2922
+ if (!intent[source]) continue;
2923
+ if (!intent.microphone || !intent.camera) {
2924
+ failures.push({ source, failure: initialFailure });
2925
+ continue;
2926
+ }
2927
+ yield* provider({ microphone: source === "microphone", camera: source === "camera" }).pipe(
2928
+ Effect2.tap(
2929
+ (stream) => Effect2.sync(() => {
2930
+ for (const track of stream.getTracks()) tracks.add(track);
2931
+ })
2932
+ ),
2933
+ Effect2.catch(
2934
+ (cause2) => Effect2.sync(() => {
2935
+ failures.push({ source, failure: captureFailure(cause2) });
2936
+ })
2937
+ )
2938
+ );
2939
+ }
2940
+ if (tracks.size === 0) yield* provider({ microphone: false, camera: false }).pipe(Effect2.mapError(captureFailure));
2941
+ return { stream: streamFromTracks([...tracks]), failure: initialCaptureWarning(failures) };
2942
+ }).pipe(
2943
+ Effect2.onInterrupt(
2944
+ () => Effect2.sync(() => {
2945
+ for (const track of tracks) track.stop();
2946
+ })
2947
+ )
2948
+ );
2949
+ })
2950
+ );
2951
+ }
2952
+ function initialCaptureWarning(failures) {
2953
+ const first = failures[0];
2954
+ if (!first) return null;
2955
+ const names = failures.length === 2 ? "Microphone and camera" : first.source === "microphone" ? "Microphone" : "Camera";
2956
+ const message = failures.length === 2 && first.failure.code !== failures[1]?.failure.code ? failures.map(({ source, failure: failure3 }) => `${source === "microphone" ? "Microphone" : "Camera"}: ${failure3.message}`).join(" ") : `${names}: ${first.failure.message}`;
2957
+ return lifecycleFailure(first.failure.code, true, message, first.failure.cause);
2600
2958
  }
2601
2959
  function failureFromCause(cause, code, message) {
2602
2960
  if (cause instanceof ConnectionLifecycleFailure) return cause;
@@ -2630,6 +2988,12 @@ function mediaPhase(phase) {
2630
2988
  if (phase === "connecting") return "connecting";
2631
2989
  return "idle";
2632
2990
  }
2991
+ function syncTerminalFailure(snapshot) {
2992
+ const reason = snapshot.connection.terminalReason;
2993
+ if (reason === "episode_ended") return lifecycleFailure("episode_ended", false, "The Episode has ended");
2994
+ if (reason === "participant_inactive" || reason === "stale_participant_generation") return lifecycleFailure("invalid_access", false, "Participant access is no longer valid");
2995
+ return lifecycleFailure("sync_start_failed", true, "The Sync transport needs recovery");
2996
+ }
2633
2997
 
2634
2998
  // src/space-client/errors.ts
2635
2999
  import { Data as Data3 } from "effect";
@@ -2742,10 +3106,10 @@ var SpaceStore = class _SpaceStore {
2742
3106
  };
2743
3107
  updateConnection(snapshot) {
2744
3108
  const current = this.getSnapshot().connection;
2745
- const error2 = snapshot.failure ? empty({ code: mapCode(snapshot.failure.code), recoverable: snapshot.failure.recoverable, message: snapshot.failure.message }) : null;
3109
+ const error2 = snapshot.failure ? empty({ code: publicCode(snapshot.failure.code), recoverable: snapshot.failure.recoverable, message: snapshot.failure.message }) : null;
2746
3110
  const status = snapshot.state === "live" && (INTERRUPTED_CONNECTION_PHASES.has(snapshot.connection.sync) || INTERRUPTED_CONNECTION_PHASES.has(snapshot.connection.media)) ? "reconnecting" : snapshot.state;
2747
3111
  if (current.status === status && sameEpisode(current.episode, snapshot.episode) && sameError(current.lastError, error2)) return;
2748
- this.#replace("connection", empty({ status, episode: snapshot.episode, lastError: error2 }));
3112
+ this.#replace("connection", empty({ status, episode: snapshot.episode, lastError: sameError(current.lastError, error2) ? current.lastError : error2 }));
2749
3113
  }
2750
3114
  updateSelf(value) {
2751
3115
  this.#replace("self", value);
@@ -2793,7 +3157,16 @@ var SpaceStoreService = class extends Context4.Service()("@chalk/client/SpaceSto
2793
3157
  var makeSpaceStoreLayer = Layer4.effect(SpaceStoreService, SpaceStore.make());
2794
3158
  var makeFakeSpaceStoreLayer = (store = new SpaceStore()) => Layer4.succeed(SpaceStoreService, store);
2795
3159
  function mapCode(code) {
2796
- return { invalid_access: "access.invalid", episode_ended: "episode.ended", invalid_payload: "chat.payload_invalid", permission_denied: "media.permission_denied", unsupported_environment: "environment.unsupported", rate_limited: "command.rate_limited", command_rejected: "command.rejected" }[code] ?? "client.internal_error";
3160
+ return {
3161
+ invalid_access: "access.invalid",
3162
+ access_unavailable: "access.unavailable",
3163
+ episode_ended: "episode.ended",
3164
+ invalid_payload: "chat.payload_invalid",
3165
+ permission_denied: "media.permission_denied",
3166
+ unsupported_environment: "environment.unsupported",
3167
+ rate_limited: "command.rate_limited",
3168
+ command_rejected: "command.rejected"
3169
+ }[code] ?? "client.internal_error";
2797
3170
  }
2798
3171
  function sameEpisode(left, right) {
2799
3172
  if (left === right) return true;
@@ -2805,6 +3178,20 @@ function sameError(left, right) {
2805
3178
  if (!left || !right) return false;
2806
3179
  return left.code === right.code && left.recoverable === right.recoverable && left.message === right.message;
2807
3180
  }
3181
+ function publicCode(code) {
3182
+ switch (code) {
3183
+ case "device_not_found":
3184
+ return "media.device_not_found";
3185
+ case "device_busy":
3186
+ return "media.device_busy";
3187
+ case "device_constraint_invalid":
3188
+ return "media.device_constraint_invalid";
3189
+ case "media_capture_failed":
3190
+ return "media.capture_failed";
3191
+ default:
3192
+ return mapCode(code);
3193
+ }
3194
+ }
2808
3195
 
2809
3196
  // src/feedback/types.ts
2810
3197
  var FEEDBACK_EVIDENCE_SCHEMA_VERSION = "FeedbackEvidence/v1";
@@ -3834,6 +4221,7 @@ var ChatControllerRuntime = class {
3834
4221
  #offline;
3835
4222
  #catchUpRunning = false;
3836
4223
  #catchUpRequested = false;
4224
+ #contiguousSequence = null;
3837
4225
  #hasOlder = false;
3838
4226
  #historyTruncated = false;
3839
4227
  #initialCatchUpComplete = true;
@@ -4027,7 +4415,8 @@ var ChatControllerRuntime = class {
4027
4415
  this.#ports = ports;
4028
4416
  if (!ports) return this.#reset();
4029
4417
  this.#status = "ready";
4030
- this.#initialCatchUpComplete = ports.sync.getCollaborationExtensionState().chatHeadSequence === null;
4418
+ if (this.#contiguousSequence === null && ports.sync.getCollaborationExtensionState().chatHeadSequence === null) this.#contiguousSequence = "0";
4419
+ this.#initialCatchUpComplete = this.#contiguousSequence !== null;
4031
4420
  this.#unsubscribeEvents = ports.sync.subscribeCollaboration((event) => {
4032
4421
  if (event.type === "chat_message") this.#observeMessage(chatMessageFor(event.message), this.#initialCatchUpComplete);
4033
4422
  else if (event.type === "chat_read_receipt") this.#mergeReceipt(chatReceiptFor(event.receipt));
@@ -4039,7 +4428,12 @@ var ChatControllerRuntime = class {
4039
4428
  }
4040
4429
  #scheduleCatchUp() {
4041
4430
  const ports = this.#ports;
4042
- if (!ports || ports.sync.getSnapshot().connection.phase !== "live" || !catchUpRequest(this.#messages.at(-1)?.sequence ?? null, ports)) return;
4431
+ const floor = ports?.sync.getCollaborationExtensionState().retainedFloorSequence;
4432
+ if (this.#contiguousSequence === "0" && floor) {
4433
+ this.#contiguousSequence = String(BigInt(floor) - 1n);
4434
+ this.#advanceContiguousSequence();
4435
+ }
4436
+ if (!ports || ports.sync.getSnapshot().connection.phase !== "live" || !catchUpRequest(this.#contiguousSequence, this.#messages.at(-1)?.sequence ?? null, ports)) return;
4043
4437
  if (this.#catchUpRunning) {
4044
4438
  this.#catchUpRequested = true;
4045
4439
  return;
@@ -4059,7 +4453,6 @@ var ChatControllerRuntime = class {
4059
4453
  Effect4.ensuring(
4060
4454
  Effect4.sync(() => {
4061
4455
  this.#catchUpRunning = false;
4062
- this.#initialCatchUpComplete = true;
4063
4456
  if (this.#catchUpRequested) {
4064
4457
  this.#catchUpRequested = false;
4065
4458
  this.#scheduleCatchUp();
@@ -4072,17 +4465,19 @@ var ChatControllerRuntime = class {
4072
4465
  #catchUp(ports) {
4073
4466
  return Effect4.suspend(() => {
4074
4467
  if (this.#ports !== ports || ports.sync.getSnapshot().connection.phase !== "live") return Effect4.void;
4075
- const request = catchUpRequest(this.#messages.at(-1)?.sequence ?? null, ports);
4468
+ const request = catchUpRequest(this.#contiguousSequence, this.#messages.at(-1)?.sequence ?? null, ports);
4076
4469
  if (!request) return Effect4.void;
4077
4470
  return foreign(() => ports.sync.readChatPage(request.input)).pipe(
4078
4471
  Effect4.flatMap((result) => {
4079
4472
  if (this.#ports !== ports) return Effect4.void;
4080
4473
  if (result.status === "cursor_reset") return Effect4.sync(() => this.#applyCursorReset(result.retainedFloorSequence)).pipe(Effect4.andThen(this.#reloadAfterCursorReset(ports)));
4081
4474
  if (request.kind === "initial") {
4475
+ this.#completeInitialPage(ports);
4082
4476
  this.#hasOlder = result.hasOlder;
4083
4477
  this.#publish();
4084
4478
  }
4085
- return result.hasOlder ? this.#catchUp(ports) : Effect4.void;
4479
+ const progressed = request.kind === "initial" || this.#contiguousSequence !== request.input.afterSequence;
4480
+ return progressed ? this.#catchUp(ports) : Effect4.void;
4086
4481
  })
4087
4482
  );
4088
4483
  });
@@ -4092,11 +4487,12 @@ var ChatControllerRuntime = class {
4092
4487
  Effect4.tap(
4093
4488
  (result) => Effect4.sync(() => {
4094
4489
  if (this.#ports !== ports || result.status === "cursor_reset") return;
4490
+ this.#completeInitialPage(ports);
4095
4491
  this.#hasOlder = result.hasOlder;
4096
4492
  this.#publish();
4097
4493
  })
4098
4494
  ),
4099
- Effect4.asVoid
4495
+ Effect4.andThen(this.#catchUp(ports))
4100
4496
  );
4101
4497
  }
4102
4498
  #applyPage(result) {
@@ -4112,8 +4508,32 @@ var ChatControllerRuntime = class {
4112
4508
  this.#hasOlder = false;
4113
4509
  this.#historyTruncated = true;
4114
4510
  this.#retainedFloorSequence = retainedFloorSequence;
4511
+ this.#messages = Object.freeze(this.#messages.filter((message) => compareChatSequence(message.sequence, retainedFloorSequence) >= 0));
4512
+ this.#contiguousSequence = null;
4513
+ this.#initialCatchUpComplete = false;
4115
4514
  this.#publish();
4116
4515
  }
4516
+ #completeInitialPage(ports) {
4517
+ const floor = this.#messages[0]?.sequence ?? ports.sync.getCollaborationExtensionState().retainedFloorSequence;
4518
+ this.#contiguousSequence = floor === null ? "0" : String(BigInt(floor) - 1n);
4519
+ this.#advanceContiguousSequence();
4520
+ this.#initialCatchUpComplete = true;
4521
+ }
4522
+ #advanceContiguousSequence(message) {
4523
+ if (this.#contiguousSequence === null) return;
4524
+ let sequence = BigInt(this.#contiguousSequence);
4525
+ if (message) {
4526
+ if (BigInt(message.sequence) !== sequence + 1n) return;
4527
+ sequence += 1n;
4528
+ }
4529
+ for (const message2 of this.#messages) {
4530
+ const candidate = BigInt(message2.sequence);
4531
+ if (candidate <= sequence) continue;
4532
+ if (candidate !== sequence + 1n) break;
4533
+ sequence = candidate;
4534
+ }
4535
+ this.#contiguousSequence = String(sequence);
4536
+ }
4117
4537
  #observeMessage(message, countUnread) {
4118
4538
  const existing = this.#messages.find((candidate) => candidate.messageId === message.messageId || candidate.sequence === message.sequence);
4119
4539
  const next = this.#messagesWith(message, existing);
@@ -4121,11 +4541,13 @@ var ChatControllerRuntime = class {
4121
4541
  const unread = this.#unreadCountFor(message, existing, countUnread);
4122
4542
  if (this.#messageStateMatches(next, pendingSends, unread)) return existing ?? message;
4123
4543
  this.#messages = next;
4544
+ this.#advanceContiguousSequence(message);
4124
4545
  this.#pendingSends = Object.freeze(pendingSends);
4125
4546
  this.#unreadCount = unread;
4126
4547
  this.#status = "ready";
4127
4548
  this.#lastError = null;
4128
4549
  this.#publish();
4550
+ if (!this.#catchUpRunning && countUnread) this.#scheduleCatchUp();
4129
4551
  return message;
4130
4552
  }
4131
4553
  #messagesWith(message, existing) {
@@ -4170,6 +4592,7 @@ var ChatControllerRuntime = class {
4170
4592
  }
4171
4593
  #reset() {
4172
4594
  this.#catchUpRequested = false;
4595
+ this.#contiguousSequence = null;
4173
4596
  this.#hasOlder = false;
4174
4597
  this.#historyTruncated = false;
4175
4598
  this.#initialCatchUpComplete = true;
@@ -4213,11 +4636,11 @@ function validateUpload(file, bytes, clientAttachmentId) {
4213
4636
  function bytesFor(file) {
4214
4637
  return "fileName" in file ? Effect4.succeed(file.bytes) : foreign(() => file.arrayBuffer());
4215
4638
  }
4216
- function catchUpRequest(latestSequence, ports) {
4639
+ function catchUpRequest(contiguousSequence, latestSequence, ports) {
4217
4640
  const extension = ports.sync.getCollaborationExtensionState();
4218
- const head = extension.chatHeadSequence;
4219
- if (!extension.negotiated || head === null || latestSequence !== null && compareChatSequence(latestSequence, head) >= 0) return null;
4220
- return latestSequence === null ? { kind: "initial", input: { limit: MAX_CHAT_PAGE_SIZE } } : { kind: "newer", input: { afterSequence: latestSequence, limit: MAX_CHAT_PAGE_SIZE } };
4641
+ const head = latestSequence !== null && (extension.chatHeadSequence === null || compareChatSequence(latestSequence, extension.chatHeadSequence) > 0) ? latestSequence : extension.chatHeadSequence;
4642
+ if (!extension.negotiated || head === null || contiguousSequence !== null && compareChatSequence(contiguousSequence, head) >= 0) return null;
4643
+ return contiguousSequence === null ? { kind: "initial", input: { limit: MAX_CHAT_PAGE_SIZE } } : { kind: "newer", input: { afterSequence: contiguousSequence, limit: MAX_CHAT_PAGE_SIZE } };
4221
4644
  }
4222
4645
  function chatError(message) {
4223
4646
  return new SpaceClientError({ code: "chat.payload_invalid", recoverable: false, message });
@@ -4249,7 +4672,10 @@ var makeMediaController = (connection, store, selection, diagnostics) => Effect5
4249
4672
  fork,
4250
4673
  diagnostics
4251
4674
  );
4252
- yield* connection.setInitialMedia((intent) => controller.captureInitial(intent));
4675
+ yield* connection.setInitialMedia(
4676
+ (intent) => controller.captureInitial(intent),
4677
+ () => controller.initialIntent()
4678
+ );
4253
4679
  yield* Effect5.addFinalizer(() => Effect5.sync(() => controller.dispose()));
4254
4680
  controller.refreshDevicesInBackground();
4255
4681
  return controller;
@@ -4344,14 +4770,22 @@ var MediaControllerRuntime = class {
4344
4770
  Effect5.tapError(() => Effect5.sync(() => operation?.fail("decline_failed")))
4345
4771
  );
4346
4772
  };
4347
- captureInitial(_intent) {
4348
- if (!this.#intent.microphone && !this.#intent.camera) return Effect5.succeed(streamFromTracks([]));
4349
- return foreign2(() => this.#selection.getUserMedia({ audio: this.#intent.microphone, video: this.#intent.camera })).pipe(
4773
+ initialIntent() {
4774
+ return { ...this.#intent };
4775
+ }
4776
+ captureInitial(intent) {
4777
+ if (!intent.microphone && !intent.camera) {
4778
+ this.#intent = { microphone: this.#tracks.has("microphone"), camera: this.#tracks.has("camera") };
4779
+ this.#publish();
4780
+ return Effect5.succeed(streamFromTracks([...this.#tracks.values()]));
4781
+ }
4782
+ return foreign2(() => this.#selection.getUserMedia({ audio: intent.microphone, video: intent.camera })).pipe(
4350
4783
  Effect5.flatMap(
4351
- (stream) => Effect5.try({ try: () => selectInitialTracks(stream, this.#intent), catch: (cause) => cause }).pipe(
4784
+ (stream) => Effect5.try({ try: () => selectInitialTracks(stream, intent), catch: (cause) => cause }).pipe(
4352
4785
  Effect5.tap(
4353
4786
  (tracks) => Effect5.sync(() => {
4354
4787
  for (const [source, track] of tracks) this.#tracks.set(source, track);
4788
+ this.#intent = { microphone: this.#tracks.has("microphone"), camera: this.#tracks.has("camera") };
4355
4789
  this.#publish();
4356
4790
  })
4357
4791
  ),
@@ -4744,7 +5178,7 @@ var MediaControllerRuntime = class {
4744
5178
  }
4745
5179
  }
4746
5180
  #applyForcedMediaState(ports, snapshot) {
4747
- if (!snapshot.media || !snapshot.participantId) return;
5181
+ if (!canApplyForcedMediaState(snapshot)) return;
4748
5182
  for (const source of MEDIA_SOURCES) {
4749
5183
  const projected = snapshot.media.items.find((item) => item.participantId === snapshot.participantId && item.source === source);
4750
5184
  if (projected?.publicationId) {
@@ -4953,6 +5387,9 @@ function isAccessInvalid2(cause) {
4953
5387
  function isSyncRejection(cause) {
4954
5388
  return isAccessInvalid2(cause) || cause instanceof V1SyncError && cause.code === "terminal_failure";
4955
5389
  }
5390
+ function canApplyForcedMediaState(snapshot) {
5391
+ return !!snapshot.media && !!snapshot.participantId && snapshot.control?.status !== "ended" && snapshot.connection.phase !== "terminal";
5392
+ }
4956
5393
 
4957
5394
  // src/space-client/offline-actions.ts
4958
5395
  import { Context as Context7, Effect as Effect6, Layer as Layer7 } from "effect";
@@ -6563,7 +7000,7 @@ var makeSpaceClientCoreLayer = (options, platform = {}) => {
6563
7000
  now: baseDependencies.clock.now,
6564
7001
  setTimeout: baseDependencies.clock.setTimeout,
6565
7002
  clearTimeout: baseDependencies.clock.clearTimeout,
6566
- release: { id: "chalk-client@4.1.16" }
7003
+ release: { id: "chalk-client@4.1.17" }
6567
7004
  });
6568
7005
  const diagnosticSyncClients = /* @__PURE__ */ new Set();
6569
7006
  const dependencies = {
@@ -6645,6 +7082,7 @@ var SpaceClientCore = class {
6645
7082
  #episodeDiagnosticTracks = /* @__PURE__ */ new Map();
6646
7083
  #handlers = { participantJoined: /* @__PURE__ */ new Set(), participantLeft: /* @__PURE__ */ new Set(), episodeEnded: /* @__PURE__ */ new Set(), screenShareStarted: /* @__PURE__ */ new Set(), screenShareStopped: /* @__PURE__ */ new Set(), error: /* @__PURE__ */ new Set() };
6647
7084
  #disposed = false;
7085
+ #episodeEnded = false;
6648
7086
  #previous;
6649
7087
  #unsubscribeConnection = null;
6650
7088
  #unsubscribeStore = null;
@@ -6678,7 +7116,12 @@ var SpaceClientCore = class {
6678
7116
  this.#previous = this.#store.getSnapshot();
6679
7117
  this.#unsubscribeStore = this.#store.subscribe(() => this.#publishStore());
6680
7118
  this.#unsubscribeConnection = connection.subscribe(() => {
6681
- this.#store.updateConnection(connection.getSnapshot());
7119
+ const snapshot = connection.getSnapshot();
7120
+ const episode = snapshot.episode ?? this.#store.getSnapshot().connection.episode;
7121
+ this.#store.updateConnection(snapshot);
7122
+ const newlyEnded = snapshot.episodeEnded && !this.#episodeEnded;
7123
+ this.#episodeEnded = snapshot.episodeEnded ?? false;
7124
+ if (newlyEnded) this.#emit("episodeEnded", { episode });
6682
7125
  this.#publishConnectionDiagnostics();
6683
7126
  });
6684
7127
  this.#publishConnectionDiagnostics();
@@ -6708,6 +7151,7 @@ var SpaceClientCore = class {
6708
7151
  this.#syncConnectOperation?.fail("connect_failed");
6709
7152
  })
6710
7153
  ),
7154
+ Effect12.catchTag("ConnectionLifecycleFailure", (failure3) => Effect12.fail(failure3.code === "episode_ended" ? new SpaceClientError({ code: "episode.ended", recoverable: failure3.recoverable, message: failure3.message }) : failure3)),
6711
7155
  Effect12.mapError(normalizeClientError)
6712
7156
  );
6713
7157
  }
@@ -6720,11 +7164,10 @@ var SpaceClientCore = class {
6720
7164
  );
6721
7165
  }
6722
7166
  endEpisode() {
6723
- return trackedEffect(
6724
- this.#episodeDiagnostics,
6725
- "episode.end.authorized",
6726
- this.#connection.runCommand(({ sync }) => foreign6(() => sync.endEpisode()).pipe(Effect12.tap(() => this.#connection.confirmEpisodeEnded)))
6727
- ).pipe(Effect12.asVoid, Effect12.mapError(normalizeClientError));
7167
+ return trackedEffect(this.#episodeDiagnostics, "episode.end.authorized", this.#connection.runCommand(({ sync }) => foreign6(() => sync.endEpisode()).pipe(Effect12.tap(() => this.#connection.confirmEpisodeEnded))).pipe(Effect12.tap(() => this.#connection.leave()))).pipe(
7168
+ Effect12.asVoid,
7169
+ Effect12.mapError(normalizeClientError)
7170
+ );
6728
7171
  }
6729
7172
  extendEpisode(minutes) {
6730
7173
  return trackedEffect(
@@ -6812,7 +7255,6 @@ var SpaceClientCore = class {
6812
7255
  }
6813
7256
  #emitChanges(previous, next) {
6814
7257
  this.#emitParticipantChanges(previous, next);
6815
- this.#emitEpisodeEnd(previous, next);
6816
7258
  this.#emitScreenShareChanges(previous, next);
6817
7259
  this.#emitErrorChange(previous, next);
6818
7260
  }
@@ -6822,9 +7264,6 @@ var SpaceClientCore = class {
6822
7264
  for (const participant of after.values()) if (!before.has(participant.participantId)) this.#emit("participantJoined", { participant });
6823
7265
  for (const participant of before.values()) if (!after.has(participant.participantId)) this.#emit("participantLeft", { participant });
6824
7266
  }
6825
- #emitEpisodeEnd(previous, next) {
6826
- if (previous.connection.lastError?.code !== "episode.ended" && next.connection.lastError?.code === "episode.ended") this.#emit("episodeEnded", { episode: previous.connection.episode ?? next.connection.episode });
6827
- }
6828
7267
  #emitScreenShareChanges(previous, next) {
6829
7268
  const previousShares = screenShares(previous);
6830
7269
  const nextShares = screenShares(next);
@@ -6886,7 +7325,7 @@ function diagnosticAccessOperation(request) {
6886
7325
  }
6887
7326
  async function fetchAccessGrant2(input) {
6888
7327
  if (input.platform.connectionAccess) return input.platform.connectionAccess(input.request);
6889
- return input.options.getAccess({ space: input.options.space.trim(), reason: accessReason(input.request?.reason), signal: input.request?.signal });
7328
+ return input.options.getAccess({ space: input.options.space.trim(), reason: accessReason(input.request?.reason), replaceMediaConnection: input.request?.replaceMediaConnection ?? false, signal: input.request?.signal });
6890
7329
  }
6891
7330
  function accessReason(reason) {
6892
7331
  if (reason === void 0 || reason === "join") return "join";
@@ -6999,11 +7438,11 @@ export {
6999
7438
  ConnectionPlatformService,
7000
7439
  makeConnectionPlatformLayer,
7001
7440
  makeFakeConnectionPlatformLayer,
7441
+ ChalkWhiteboardV1Error,
7002
7442
  CHALK_CHAT_ATTACHMENT_LIMITS,
7003
7443
  CHALK_CHAT_ATTACHMENT_MIME_TYPES,
7004
7444
  ChalkChatFileError,
7005
7445
  createChalkChatFileHttpTransport,
7006
- ChalkWhiteboardV1Error,
7007
7446
  createChalkWhiteboardV1FileHttpTransport,
7008
7447
  encodeWhiteboardV1ClientFrame,
7009
7448
  decodeWhiteboardV1ServerFrame,