dextunnel 0.1.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 (76) hide show
  1. package/LICENSE +211 -0
  2. package/README.md +112 -0
  3. package/SECURITY.md +27 -0
  4. package/SUPPORT.md +43 -0
  5. package/package.json +44 -0
  6. package/public/client-shared.js +1831 -0
  7. package/public/favicon.svg +11 -0
  8. package/public/host.html +29 -0
  9. package/public/host.js +2079 -0
  10. package/public/index.html +28 -0
  11. package/public/index.js +98 -0
  12. package/public/live-bridge-lifecycle.js +258 -0
  13. package/public/live-bridge-retry-state.js +61 -0
  14. package/public/live-selection-intent.js +79 -0
  15. package/public/remote-operator-state.js +316 -0
  16. package/public/remote.html +167 -0
  17. package/public/remote.js +3967 -0
  18. package/public/styles.css +2793 -0
  19. package/public/surface-view-state.js +89 -0
  20. package/public/voice-dictation.js +45 -0
  21. package/src/bin/desktop-rehydration-smoke.mjs +111 -0
  22. package/src/bin/dextunnel.mjs +41 -0
  23. package/src/bin/doctor.mjs +48 -0
  24. package/src/bin/launch-attest.mjs +39 -0
  25. package/src/bin/launch-status.mjs +49 -0
  26. package/src/bin/mobile-link-proxy.mjs +221 -0
  27. package/src/bin/mobile-proof.mjs +164 -0
  28. package/src/bin/mobile-transport-smoke.mjs +200 -0
  29. package/src/bin/probe-codex-app-server-write.mjs +36 -0
  30. package/src/bin/probe-codex-app-server.mjs +30 -0
  31. package/src/lib/agent-room-context.mjs +54 -0
  32. package/src/lib/agent-room-runtime.mjs +355 -0
  33. package/src/lib/agent-room-service.mjs +335 -0
  34. package/src/lib/agent-room-state.mjs +406 -0
  35. package/src/lib/agent-room-store.mjs +71 -0
  36. package/src/lib/agent-room-text.mjs +48 -0
  37. package/src/lib/app-server-contract.mjs +66 -0
  38. package/src/lib/app-server-runtime.mjs +60 -0
  39. package/src/lib/attachment-service.mjs +119 -0
  40. package/src/lib/bridge-api-handler.mjs +719 -0
  41. package/src/lib/bridge-runtime-lifecycle.mjs +51 -0
  42. package/src/lib/bridge-status-builder.mjs +60 -0
  43. package/src/lib/codex-app-server-client.mjs +1511 -0
  44. package/src/lib/companion-state.mjs +453 -0
  45. package/src/lib/control-lease-service.mjs +180 -0
  46. package/src/lib/debug-harness-service.mjs +173 -0
  47. package/src/lib/desktop-integration.mjs +146 -0
  48. package/src/lib/desktop-rehydration-smoke.mjs +269 -0
  49. package/src/lib/dextunnel-cli.mjs +122 -0
  50. package/src/lib/discovery-docs.mjs +1321 -0
  51. package/src/lib/fake-codex-app-server-bridge.mjs +340 -0
  52. package/src/lib/install-preflight.mjs +373 -0
  53. package/src/lib/interaction-resolution-service.mjs +185 -0
  54. package/src/lib/interaction-state.mjs +360 -0
  55. package/src/lib/launch-release-bar.mjs +158 -0
  56. package/src/lib/live-control-state.mjs +107 -0
  57. package/src/lib/live-payload-builder.mjs +298 -0
  58. package/src/lib/live-selection-transition-state.mjs +49 -0
  59. package/src/lib/live-transcript-state.mjs +549 -0
  60. package/src/lib/mobile-network-profile.mjs +39 -0
  61. package/src/lib/mock-codex-adapter.mjs +62 -0
  62. package/src/lib/operator-diagnostics.mjs +82 -0
  63. package/src/lib/repo-changes-service.mjs +527 -0
  64. package/src/lib/runtime-config.mjs +106 -0
  65. package/src/lib/selection-state-service.mjs +214 -0
  66. package/src/lib/session-store.mjs +355 -0
  67. package/src/lib/shared-room-state.mjs +473 -0
  68. package/src/lib/shared-selection-state.mjs +40 -0
  69. package/src/lib/sse-hub.mjs +35 -0
  70. package/src/lib/static-surface-service.mjs +71 -0
  71. package/src/lib/surface-access.mjs +189 -0
  72. package/src/lib/surface-presence-service.mjs +118 -0
  73. package/src/lib/surface-request-guard.mjs +52 -0
  74. package/src/lib/thread-sync-state.mjs +536 -0
  75. package/src/lib/watcher-lifecycle.mjs +287 -0
  76. package/src/server.mjs +1446 -0
@@ -0,0 +1,453 @@
1
+ export function createCompanionStateService({
2
+ ADVISORY_PARTICIPANT_IDS = [],
3
+ COMPANION_WAKEUP_LIMIT,
4
+ COMPANION_WAKEUP_SNOOZE_MS,
5
+ COMPANION_WAKEUP_STALE_MS,
6
+ COMPANION_WAKEUP_VISIBLE_MS,
7
+ buildParticipant,
8
+ liveState,
9
+ nowIso
10
+ }) {
11
+ function defaultAdvisoryState(id) {
12
+ return {
13
+ id,
14
+ lastWakeAt: null,
15
+ metaLabel: "dormant",
16
+ state: "dormant",
17
+ wakeKey: null,
18
+ wakeKind: null
19
+ };
20
+ }
21
+
22
+ function advisoryMetaLabel(state, wakeKind = null) {
23
+ if (state === "ready") {
24
+ return wakeKind === "summary" ? "summary ready" : "review ready";
25
+ }
26
+
27
+ return "dormant";
28
+ }
29
+
30
+ function isCompanionWakeupVisible(notice) {
31
+ return notice?.status === "ready";
32
+ }
33
+
34
+ function normalizeCompanionThreadState(state, { now = Date.now() } = {}) {
35
+ const baseState = state || {
36
+ advisories: Object.fromEntries(ADVISORY_PARTICIPANT_IDS.map((advisorId) => [advisorId, defaultAdvisoryState(advisorId)])),
37
+ wakeups: []
38
+ };
39
+ const currentWakeups = Array.isArray(baseState.wakeups) ? baseState.wakeups : [];
40
+ let changed = !Array.isArray(baseState.wakeups);
41
+ const nextWakeups = [];
42
+
43
+ for (const rawNotice of currentWakeups) {
44
+ const advisorId = ADVISORY_PARTICIPANT_IDS.includes(rawNotice?.advisorId) ? rawNotice.advisorId : null;
45
+ const key = String(rawNotice?.key || "").trim();
46
+ const atMs = new Date(rawNotice?.timestamp || 0).getTime();
47
+ if (!advisorId || !key || !atMs || now - atMs > COMPANION_WAKEUP_STALE_MS) {
48
+ changed = true;
49
+ continue;
50
+ }
51
+
52
+ let nextNotice = {
53
+ ...rawNotice,
54
+ advisorId,
55
+ key,
56
+ status: rawNotice?.status || "ready"
57
+ };
58
+
59
+ if (nextNotice.status === "snoozed") {
60
+ const snoozeUntilMs = new Date(nextNotice.snoozeUntil || 0).getTime();
61
+ if (!snoozeUntilMs || now >= snoozeUntilMs) {
62
+ nextNotice = {
63
+ ...nextNotice,
64
+ snoozeUntil: null,
65
+ status: "ready",
66
+ timestamp: nowIso()
67
+ };
68
+ changed = true;
69
+ }
70
+ }
71
+
72
+ const visibleAtMs = new Date(nextNotice.timestamp || 0).getTime();
73
+ if (nextNotice.status === "ready" && visibleAtMs && now - visibleAtMs > COMPANION_WAKEUP_VISIBLE_MS) {
74
+ nextNotice = {
75
+ ...nextNotice,
76
+ expiredAt: nextNotice.expiredAt || nowIso(),
77
+ status: "expired"
78
+ };
79
+ changed = true;
80
+ }
81
+
82
+ nextWakeups.push(nextNotice);
83
+ }
84
+
85
+ nextWakeups.sort((a, b) => new Date(b.timestamp || 0).getTime() - new Date(a.timestamp || 0).getTime());
86
+ if (nextWakeups.length > COMPANION_WAKEUP_LIMIT) {
87
+ changed = true;
88
+ }
89
+
90
+ const limitedWakeups = nextWakeups.slice(0, COMPANION_WAKEUP_LIMIT);
91
+ const nextAdvisories = {};
92
+ for (const advisorId of ADVISORY_PARTICIPANT_IDS) {
93
+ const current = baseState.advisories?.[advisorId] || defaultAdvisoryState(advisorId);
94
+ const readyWakeup = limitedWakeups.find((notice) => notice.advisorId === advisorId && isCompanionWakeupVisible(notice));
95
+ if (readyWakeup) {
96
+ nextAdvisories[advisorId] = {
97
+ ...current,
98
+ lastWakeAt: readyWakeup.timestamp,
99
+ metaLabel: advisoryMetaLabel("ready", readyWakeup.wakeKind),
100
+ state: "ready",
101
+ wakeKey: readyWakeup.key,
102
+ wakeKind: readyWakeup.wakeKind || null
103
+ };
104
+ continue;
105
+ }
106
+
107
+ nextAdvisories[advisorId] = {
108
+ ...current,
109
+ metaLabel: "dormant",
110
+ state: "dormant",
111
+ wakeKey: null,
112
+ wakeKind: null
113
+ };
114
+ }
115
+
116
+ const nextState = {
117
+ advisories: nextAdvisories,
118
+ wakeups: limitedWakeups
119
+ };
120
+
121
+ if (!changed) {
122
+ changed = JSON.stringify({
123
+ advisories: baseState.advisories || {},
124
+ wakeups: currentWakeups
125
+ }) !== JSON.stringify(nextState);
126
+ }
127
+
128
+ return {
129
+ changed,
130
+ nextState
131
+ };
132
+ }
133
+
134
+ function ensureThreadCompanionState(threadId) {
135
+ const id = String(threadId || "").trim();
136
+ if (!id) {
137
+ return {
138
+ advisories: Object.fromEntries(ADVISORY_PARTICIPANT_IDS.map((advisorId) => [advisorId, defaultAdvisoryState(advisorId)])),
139
+ wakeups: []
140
+ };
141
+ }
142
+
143
+ const existing = liveState.companionByThreadId[id];
144
+ if (existing) {
145
+ for (const advisorId of ADVISORY_PARTICIPANT_IDS) {
146
+ if (!existing.advisories[advisorId]) {
147
+ existing.advisories[advisorId] = defaultAdvisoryState(advisorId);
148
+ }
149
+ }
150
+ existing.wakeups = Array.isArray(existing.wakeups) ? existing.wakeups : [];
151
+ return existing;
152
+ }
153
+
154
+ const created = {
155
+ advisories: Object.fromEntries(ADVISORY_PARTICIPANT_IDS.map((advisorId) => [advisorId, defaultAdvisoryState(advisorId)])),
156
+ wakeups: []
157
+ };
158
+ liveState.companionByThreadId = {
159
+ ...liveState.companionByThreadId,
160
+ [id]: created
161
+ };
162
+ return created;
163
+ }
164
+
165
+ function setThreadCompanionState(threadId, state) {
166
+ const id = String(threadId || "").trim();
167
+ if (!id) {
168
+ return;
169
+ }
170
+
171
+ liveState.companionByThreadId = {
172
+ ...liveState.companionByThreadId,
173
+ [id]: state
174
+ };
175
+ }
176
+
177
+ function pruneCompanionWakeupsForThread(threadId, { now = Date.now() } = {}) {
178
+ const id = String(threadId || "").trim();
179
+ if (!id) {
180
+ return false;
181
+ }
182
+
183
+ const state = liveState.companionByThreadId[id];
184
+ if (!state) {
185
+ return false;
186
+ }
187
+
188
+ const { changed, nextState } = normalizeCompanionThreadState(state, { now });
189
+ if (changed) {
190
+ setThreadCompanionState(id, nextState);
191
+ }
192
+
193
+ return changed;
194
+ }
195
+
196
+ function pruneAllCompanionWakeups({ now = Date.now() } = {}) {
197
+ let changed = false;
198
+ for (const threadId of Object.keys(liveState.companionByThreadId || {})) {
199
+ if (pruneCompanionWakeupsForThread(threadId, { now })) {
200
+ changed = true;
201
+ }
202
+ }
203
+ return changed;
204
+ }
205
+
206
+ function resetCompanionWakeups(threadId, { preserveLastWake = true } = {}) {
207
+ const id = String(threadId || "").trim();
208
+ if (!id) {
209
+ return false;
210
+ }
211
+
212
+ const state = ensureThreadCompanionState(id);
213
+ const hadWakeups = Boolean(state.wakeups.length);
214
+ let changed = hadWakeups;
215
+ const advisories = { ...state.advisories };
216
+
217
+ for (const advisorId of ADVISORY_PARTICIPANT_IDS) {
218
+ const current = advisories[advisorId] || defaultAdvisoryState(advisorId);
219
+ if (current.state !== "dormant" || current.metaLabel !== "dormant" || current.wakeKey || current.wakeKind) {
220
+ advisories[advisorId] = {
221
+ ...current,
222
+ metaLabel: "dormant",
223
+ state: "dormant",
224
+ wakeKey: null,
225
+ wakeKind: null,
226
+ ...(preserveLastWake ? {} : { lastWakeAt: null })
227
+ };
228
+ changed = true;
229
+ }
230
+ }
231
+
232
+ if (!changed) {
233
+ return false;
234
+ }
235
+
236
+ setThreadCompanionState(id, {
237
+ advisories,
238
+ wakeups: []
239
+ });
240
+ return true;
241
+ }
242
+
243
+ function queueCompanionWakeup({
244
+ allowDuringPending = false,
245
+ advisorId,
246
+ text,
247
+ threadId,
248
+ timestamp = nowIso(),
249
+ turnId = null,
250
+ wakeKey,
251
+ wakeKind = "review"
252
+ } = {}) {
253
+ const id = String(threadId || "").trim();
254
+ const advisor = ADVISORY_PARTICIPANT_IDS.includes(advisorId) ? advisorId : null;
255
+ const body = String(text || "").trim();
256
+ const key = String(wakeKey || "").trim();
257
+ if (!id || !advisor || !body || !key) {
258
+ return false;
259
+ }
260
+
261
+ const pending = liveState.pendingInteraction || null;
262
+ if (!allowDuringPending && pending?.threadId && pending.threadId === id) {
263
+ return false;
264
+ }
265
+
266
+ pruneCompanionWakeupsForThread(id);
267
+ const state = ensureThreadCompanionState(id);
268
+ const existing = state.wakeups.find((notice) => notice.key === key);
269
+ if (existing) {
270
+ return false;
271
+ }
272
+
273
+ const nextWakeups = [
274
+ {
275
+ advisorId: advisor,
276
+ id: `companion-${advisor}-${turnId || Date.now()}`,
277
+ key,
278
+ kind: "commentary",
279
+ note: "advisory wakeup",
280
+ role: "assistant",
281
+ status: "ready",
282
+ text: body,
283
+ timestamp,
284
+ turnId,
285
+ wakeKind
286
+ },
287
+ ...state.wakeups.filter((notice) => notice.advisorId !== advisor)
288
+ ];
289
+ const { nextState } = normalizeCompanionThreadState({
290
+ advisories: {
291
+ ...state.advisories
292
+ },
293
+ wakeups: nextWakeups
294
+ });
295
+ setThreadCompanionState(id, nextState);
296
+ return true;
297
+ }
298
+
299
+ function companionActionMessage({ action, advisorId, snoozeUntil = null } = {}) {
300
+ const advisorLabel = advisorId === "gemini" ? "Gemini" : advisorId === "oracle" ? "Oracle" : "Advisor";
301
+ if (action === "summon") {
302
+ return advisorId === "oracle" ? "Oracle review ready." : "Gemini recap ready.";
303
+ }
304
+ if (action === "snooze") {
305
+ return `${advisorLabel} reminder snoozed until ${new Date(snoozeUntil || Date.now()).toLocaleTimeString([], { hour: "numeric", minute: "2-digit" })}.`;
306
+ }
307
+
308
+ return `${advisorLabel} reminder dismissed.`;
309
+ }
310
+
311
+ function summonCompanionWakeup({ advisorId = "", threadId = null } = {}) {
312
+ const nextThreadId = threadId || liveState.selectedThreadId || liveState.selectedThreadSnapshot?.thread?.id || null;
313
+ if (!nextThreadId) {
314
+ throw new Error("Select a live session before waking an advisor.");
315
+ }
316
+
317
+ const normalizedAdvisorId = String(advisorId || "").trim().toLowerCase();
318
+ if (!ADVISORY_PARTICIPANT_IDS.includes(normalizedAdvisorId)) {
319
+ throw new Error(`Unsupported advisor: ${advisorId}`);
320
+ }
321
+
322
+ const wakeKind = normalizedAdvisorId === "oracle" ? "review" : "summary";
323
+ const queued = queueCompanionWakeup({
324
+ advisorId: normalizedAdvisorId,
325
+ allowDuringPending: true,
326
+ text:
327
+ wakeKind === "review"
328
+ ? "Review ready: Oracle can stage a quick risk review draft for this channel."
329
+ : "Summary ready: Gemini can stage a quick recap draft for this channel.",
330
+ threadId: nextThreadId,
331
+ turnId: null,
332
+ wakeKey: `manual-${normalizedAdvisorId}:${Date.now()}`,
333
+ wakeKind
334
+ });
335
+
336
+ if (!queued) {
337
+ throw new Error("Could not stage the advisor wakeup.");
338
+ }
339
+
340
+ return {
341
+ action: "summon",
342
+ advisorId: normalizedAdvisorId,
343
+ message: companionActionMessage({ action: "summon", advisorId: normalizedAdvisorId }),
344
+ wakeKind
345
+ };
346
+ }
347
+
348
+ function applyCompanionWakeupAction({ action, threadId, wakeKey } = {}) {
349
+ const id = String(threadId || "").trim();
350
+ const key = String(wakeKey || "").trim();
351
+ if (!id || !key) {
352
+ throw new Error("A live advisory notice is required.");
353
+ }
354
+
355
+ const normalizedAction = action === "snooze" ? "snooze" : action === "dismiss" ? "dismiss" : "";
356
+ if (!normalizedAction) {
357
+ throw new Error(`Unsupported companion action: ${action}`);
358
+ }
359
+
360
+ pruneCompanionWakeupsForThread(id);
361
+ const state = ensureThreadCompanionState(id);
362
+ const existing = state.wakeups.find((notice) => notice.key === key);
363
+ if (!existing || !isCompanionWakeupVisible(existing)) {
364
+ throw new Error("That advisory notice is no longer active.");
365
+ }
366
+
367
+ const nextWakeup =
368
+ normalizedAction === "snooze"
369
+ ? {
370
+ ...existing,
371
+ snoozeUntil: new Date(Date.now() + COMPANION_WAKEUP_SNOOZE_MS).toISOString(),
372
+ status: "snoozed"
373
+ }
374
+ : {
375
+ ...existing,
376
+ dismissedAt: nowIso(),
377
+ snoozeUntil: null,
378
+ status: "dismissed"
379
+ };
380
+
381
+ const nextWakeups = state.wakeups.map((notice) => (notice.key === key ? nextWakeup : notice));
382
+ const { nextState } = normalizeCompanionThreadState({
383
+ advisories: {
384
+ ...state.advisories
385
+ },
386
+ wakeups: nextWakeups
387
+ });
388
+ setThreadCompanionState(id, nextState);
389
+
390
+ return {
391
+ action: normalizedAction,
392
+ advisorId: existing.advisorId,
393
+ message: companionActionMessage({
394
+ action: normalizedAction,
395
+ advisorId: existing.advisorId,
396
+ snoozeUntil: nextWakeup.snoozeUntil || null
397
+ }),
398
+ snoozeUntil: nextWakeup.snoozeUntil || null
399
+ };
400
+ }
401
+
402
+ function buildSelectedCompanionState(threadId = liveState.selectedThreadId || null) {
403
+ const id = String(threadId || "").trim();
404
+ if (!id) {
405
+ return {
406
+ advisories: ADVISORY_PARTICIPANT_IDS.map((advisorId) => defaultAdvisoryState(advisorId)),
407
+ wakeups: []
408
+ };
409
+ }
410
+
411
+ pruneCompanionWakeupsForThread(id);
412
+ const state = liveState.companionByThreadId[id] || ensureThreadCompanionState(id);
413
+ const visibleWakeups = (state.wakeups || []).filter((notice) => isCompanionWakeupVisible(notice));
414
+ return {
415
+ advisories: ADVISORY_PARTICIPANT_IDS.map((advisorId) => ({
416
+ ...(state.advisories[advisorId] || defaultAdvisoryState(advisorId)),
417
+ id: advisorId,
418
+ label: advisorId
419
+ })),
420
+ wakeups: visibleWakeups.map((notice) => ({
421
+ ...notice,
422
+ actions: [
423
+ { action: "snooze", busyLabel: "Later...", label: "Later" },
424
+ { action: "dismiss", busyLabel: "Dismissing...", label: "Dismiss" }
425
+ ],
426
+ lane: notice.advisorId,
427
+ origin: notice.advisorId,
428
+ participant: buildParticipant(notice.advisorId, {
429
+ metaLabel: advisoryMetaLabel("ready", notice.wakeKind),
430
+ state: "ready",
431
+ wakeKind: notice.wakeKind
432
+ })
433
+ }))
434
+ };
435
+ }
436
+
437
+ return {
438
+ advisoryMetaLabel,
439
+ applyCompanionWakeupAction,
440
+ buildSelectedCompanionState,
441
+ companionActionMessage,
442
+ defaultAdvisoryState,
443
+ ensureThreadCompanionState,
444
+ isCompanionWakeupVisible,
445
+ normalizeCompanionThreadState,
446
+ pruneAllCompanionWakeups,
447
+ pruneCompanionWakeupsForThread,
448
+ queueCompanionWakeup,
449
+ resetCompanionWakeups,
450
+ setThreadCompanionState,
451
+ summonCompanionWakeup
452
+ };
453
+ }
@@ -0,0 +1,180 @@
1
+ export function createControlLeaseService({
2
+ broadcast = () => {},
3
+ buildLivePayload = () => ({}),
4
+ clearControlLeaseState,
5
+ defaultTtlMs,
6
+ ensureRemoteControlLeaseState,
7
+ getControlLeaseForThreadState,
8
+ liveState,
9
+ nowMs = () => Date.now(),
10
+ recordControlEvent = () => {},
11
+ renewControlLeaseState,
12
+ setControlLeaseState,
13
+ setTimeoutFn = setTimeout,
14
+ clearTimeoutFn = clearTimeout
15
+ } = {}) {
16
+ let controlLeaseTimer = null;
17
+
18
+ function clearControlLease({
19
+ actor = "system",
20
+ actorClientId = null,
21
+ broadcastUpdate = false,
22
+ cause = "manual",
23
+ recordEvent = false,
24
+ threadId = null
25
+ } = {}) {
26
+ if (threadId && liveState.controlLease?.threadId && liveState.controlLease.threadId !== threadId) {
27
+ return;
28
+ }
29
+
30
+ if (controlLeaseTimer) {
31
+ clearTimeoutFn(controlLeaseTimer);
32
+ controlLeaseTimer = null;
33
+ }
34
+
35
+ if (!liveState.controlLease) {
36
+ return;
37
+ }
38
+
39
+ const previousLease = liveState.controlLease;
40
+ liveState.controlLease = clearControlLeaseState(liveState.controlLease, { threadId, now: nowMs() });
41
+ if (liveState.controlLease) {
42
+ return;
43
+ }
44
+
45
+ if (recordEvent) {
46
+ recordControlEvent({
47
+ action: "release",
48
+ actor,
49
+ actorClientId,
50
+ cause,
51
+ owner: previousLease.owner || null,
52
+ ownerClientId: previousLease.ownerClientId || null,
53
+ ownerLabel: previousLease.ownerLabel || null,
54
+ reason: previousLease.reason || null,
55
+ source: previousLease.source || null,
56
+ threadId: previousLease.threadId || threadId || null
57
+ });
58
+ }
59
+
60
+ if (broadcastUpdate) {
61
+ broadcast("live", buildLivePayload());
62
+ }
63
+ }
64
+
65
+ function scheduleControlLeaseExpiry() {
66
+ if (controlLeaseTimer) {
67
+ clearTimeoutFn(controlLeaseTimer);
68
+ controlLeaseTimer = null;
69
+ }
70
+
71
+ const lease = liveState.controlLease;
72
+ if (!lease?.expiresAt) {
73
+ return;
74
+ }
75
+
76
+ const delay = new Date(lease.expiresAt).getTime() - nowMs();
77
+ if (delay <= 0) {
78
+ clearControlLease({
79
+ actor: "system",
80
+ broadcastUpdate: true,
81
+ cause: "expired",
82
+ recordEvent: true,
83
+ threadId: lease.threadId
84
+ });
85
+ return;
86
+ }
87
+
88
+ controlLeaseTimer = setTimeoutFn(() => {
89
+ controlLeaseTimer = null;
90
+ if (liveState.controlLease?.threadId === lease.threadId && liveState.controlLease?.expiresAt === lease.expiresAt) {
91
+ clearControlLease({
92
+ actor: "system",
93
+ broadcastUpdate: true,
94
+ cause: "expired",
95
+ recordEvent: true,
96
+ threadId: lease.threadId
97
+ });
98
+ }
99
+ }, delay);
100
+ }
101
+
102
+ function setControlLease({
103
+ clientId = null,
104
+ owner = "remote",
105
+ reason = "compose",
106
+ source = "remote",
107
+ threadId = liveState.selectedThreadId || null,
108
+ ttlMs
109
+ } = {}) {
110
+ liveState.controlLease = setControlLeaseState({
111
+ clientId,
112
+ now: nowMs(),
113
+ owner,
114
+ reason,
115
+ source,
116
+ threadId,
117
+ ttlMs: ttlMs ?? defaultTtlMs
118
+ });
119
+ scheduleControlLeaseExpiry();
120
+ return liveState.controlLease;
121
+ }
122
+
123
+ function getControlLeaseForThread(threadId = null) {
124
+ const lease = getControlLeaseForThreadState(liveState.controlLease, threadId, { now: nowMs() });
125
+ if (!lease && liveState.controlLease?.expiresAt && new Date(liveState.controlLease.expiresAt).getTime() <= nowMs()) {
126
+ clearControlLease({ threadId: liveState.controlLease.threadId });
127
+ return null;
128
+ }
129
+
130
+ return lease;
131
+ }
132
+
133
+ function renewControlLease({
134
+ clientId = null,
135
+ owner = null,
136
+ reason = null,
137
+ source = null,
138
+ threadId = liveState.selectedThreadId || null,
139
+ ttlMs
140
+ } = {}) {
141
+ liveState.controlLease = renewControlLeaseState({
142
+ clientId,
143
+ lease: getControlLeaseForThread(threadId),
144
+ now: nowMs(),
145
+ owner,
146
+ reason,
147
+ source,
148
+ threadId,
149
+ ttlMs: ttlMs ?? defaultTtlMs
150
+ });
151
+ scheduleControlLeaseExpiry();
152
+ return liveState.controlLease;
153
+ }
154
+
155
+ function getControlLeaseForSelectedThread() {
156
+ return getControlLeaseForThread(liveState.selectedThreadId || null);
157
+ }
158
+
159
+ function ensureRemoteControlLease(threadId, source = "remote", clientId = null, ttlMs) {
160
+ liveState.controlLease = ensureRemoteControlLeaseState({
161
+ clientId,
162
+ lease: liveState.controlLease,
163
+ now: nowMs(),
164
+ source,
165
+ threadId,
166
+ ttlMs: ttlMs ?? defaultTtlMs
167
+ });
168
+ scheduleControlLeaseExpiry();
169
+ }
170
+
171
+ return {
172
+ clearControlLease,
173
+ ensureRemoteControlLease,
174
+ getControlLeaseForSelectedThread,
175
+ getControlLeaseForThread,
176
+ renewControlLease,
177
+ scheduleControlLeaseExpiry,
178
+ setControlLease
179
+ };
180
+ }