thinkpool-pair 0.7.285 → 0.7.286
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/account.mjs +46 -39
- package/bridge.mjs +2 -2
- package/cross-terminal.mjs +1 -1
- package/package.json +2 -1
- package/pair-bus.mjs +98 -0
package/account.mjs
CHANGED
|
@@ -22,6 +22,7 @@ import { resolveServeDir } from './serve-dir.mjs'
|
|
|
22
22
|
import { pairKeyFor, pairTopic, CROSSROOM_BUS } from './cross-terminal.mjs'
|
|
23
23
|
import { installedAgentCommands } from './agent-detect.mjs'
|
|
24
24
|
import { hostMemoryAdmission } from './host-memory.mjs'
|
|
25
|
+
import { createPairBusBroker, mergePairRoomRoster, pairBusStatusFromRealtime, PAIR_BUS_STATUS } from './pair-bus.mjs'
|
|
25
26
|
|
|
26
27
|
const VERSION = (() => { try { return JSON.parse(fs.readFileSync(new URL('./package.json', import.meta.url), 'utf8')).version } catch { return null } })()
|
|
27
28
|
|
|
@@ -431,8 +432,7 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
|
|
|
431
432
|
// relays peek/post requests between a local room child and the partner's supervisor.
|
|
432
433
|
// Spec: docs/specs/2026-06-30-cross-room-pair-bus.md
|
|
433
434
|
const roomPartner = new Map() // room -> the OTHER participant's uid (the pair partner), for 2-person rooms
|
|
434
|
-
const pairChannels = new Map() // partnerUid -> { channel, key } (one bus per distinct partner)
|
|
435
|
-
const busWaiters = new Map() // nonce -> { mode:'first'|'collect', resolve, timer, acc? } for OUR outbound bus requests
|
|
435
|
+
const pairChannels = new Map() // partnerUid -> { channel, key, status } (one bus per distinct partner)
|
|
436
436
|
const postBucket = new Map() // partnerUid -> { count, resetAt } inbound cross-room-post rate limit (runaway-cost backstop)
|
|
437
437
|
const POST_BUCKET = { max: 6, windowMs: 60_000 } // ≤6 inbound cross-room posts / partner / minute
|
|
438
438
|
let applyRequested = false // a user clicked "apply" in some room (Slice 3)
|
|
@@ -706,34 +706,17 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
|
|
|
706
706
|
}
|
|
707
707
|
|
|
708
708
|
// ── pair-bus plumbing (function declarations hoist, so order here is free) ──
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
const
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
const busSendAll = (event, payload) => {
|
|
720
|
-
let sent = 0
|
|
721
|
-
for (const { channel } of pairChannels.values()) { try { channel.send({ type: 'broadcast', event, payload }); sent++ } catch { /* channel down */ } }
|
|
722
|
-
return sent
|
|
709
|
+
const pairBus = createPairBusBroker({ channels: pairChannels, randomId: randomUUID })
|
|
710
|
+
const resolveBus = (payload) => pairBus.resolve(payload)
|
|
711
|
+
const busRequestFirst = (event, payload, timeoutMs) => pairBus.requestFirst(event, payload, timeoutMs)
|
|
712
|
+
const busRequestCollect = (event, payload, windowMs) => pairBus.requestCollect(event, payload, windowMs)
|
|
713
|
+
const authorizedPairRooms = async (sourceRoom) => {
|
|
714
|
+
try {
|
|
715
|
+
const listed = await withTimeout(sb.rpc('list_pair_rooms', { p_code: sourceRoom }), 2500, 'paired-room roster')
|
|
716
|
+
if (listed?.error || !Array.isArray(listed?.data)) return { verified: false, rooms: [] }
|
|
717
|
+
return { verified: true, rooms: listed.data }
|
|
718
|
+
} catch { return { verified: false, rooms: [] } }
|
|
723
719
|
}
|
|
724
|
-
const busRequestFirst = (event, payload, timeoutMs) => new Promise((resolve) => {
|
|
725
|
-
const nonce = randomUUID()
|
|
726
|
-
if (!busSendAll(event, { ...payload, nonce })) return resolve(null)
|
|
727
|
-
const timer = setTimeout(() => { busWaiters.delete(nonce); resolve(null) }, timeoutMs); timer.unref?.()
|
|
728
|
-
busWaiters.set(nonce, { mode: 'first', resolve, timer })
|
|
729
|
-
})
|
|
730
|
-
const busRequestCollect = (event, payload, windowMs) => new Promise((resolve) => {
|
|
731
|
-
const nonce = randomUUID()
|
|
732
|
-
if (!busSendAll(event, { ...payload, nonce })) return resolve([])
|
|
733
|
-
const acc = []
|
|
734
|
-
const timer = setTimeout(() => { busWaiters.delete(nonce); resolve(acc) }, windowMs); timer.unref?.()
|
|
735
|
-
busWaiters.set(nonce, { mode: 'collect', acc, resolve, timer })
|
|
736
|
-
})
|
|
737
720
|
// Inbound cross-room-post rate limit, per partner (a partner's agent posting into
|
|
738
721
|
// MY rooms spends MY tokens — the runaway-cost backstop, on top of the per-turn cap).
|
|
739
722
|
const allowInboundPost = (partnerUid) => {
|
|
@@ -787,15 +770,19 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
|
|
|
787
770
|
if (!partnerUid || pairChannels.has(partnerUid)) return
|
|
788
771
|
const topic = pairTopic(key); if (!topic) return
|
|
789
772
|
const ch = sb.channel(topic, { config: { private: true, broadcast: { self: false } } })
|
|
773
|
+
const entry = { channel: ch, key, status: PAIR_BUS_STATUS.JOINING }
|
|
790
774
|
ch.on('broadcast', { event: 'peer-list-req' }, ({ payload }) => onPeerListReq(partnerUid, ch, payload))
|
|
791
775
|
.on('broadcast', { event: 'peer-list-res' }, ({ payload }) => resolveBus(payload))
|
|
792
776
|
.on('broadcast', { event: 'peer-peek-req' }, ({ payload }) => onPeerPeekReq(partnerUid, ch, payload))
|
|
793
777
|
.on('broadcast', { event: 'peer-peek-res' }, ({ payload }) => resolveBus(payload))
|
|
794
778
|
.on('broadcast', { event: 'peer-post-req' }, ({ payload }) => onPeerPostReq(partnerUid, ch, payload))
|
|
795
779
|
.on('broadcast', { event: 'peer-post-res' }, ({ payload }) => resolveBus(payload))
|
|
796
|
-
.subscribe()
|
|
797
|
-
|
|
798
|
-
|
|
780
|
+
.subscribe((status) => {
|
|
781
|
+
entry.status = pairBusStatusFromRealtime(status)
|
|
782
|
+
if (status === 'SUBSCRIBED') process.stderr.write(`\n ◆ pair bus subscribed — cross-machine Ensemble reach with your partner.\n`)
|
|
783
|
+
else if (status === 'CHANNEL_ERROR' || status === 'TIMED_OUT') process.stderr.write(`\n ◇ pair bus ${status.toLowerCase().replace('_', ' ')} — partner sessions are offline until it reconnects.\n`)
|
|
784
|
+
})
|
|
785
|
+
pairChannels.set(partnerUid, entry)
|
|
799
786
|
}
|
|
800
787
|
// Forward a local child's cross-room request to the bus (Tier 2 peek / Tier 3 post)
|
|
801
788
|
// when the target room is NOT served on this machine. Used inside the child handler.
|
|
@@ -923,11 +910,15 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
|
|
|
923
910
|
;(async () => {
|
|
924
911
|
if (process.env.TP_CROSSROOM_OFF === '1') { try { child.send({ t: 'pair-list-res', reqId: m.reqId, rooms: [], error: 'Cross-session reach is turned off on this bridge.' }) } catch { /* child gone */ } return }
|
|
925
912
|
const local = [...children.keys()].filter((r) => r !== room).map((r) => ({ code: r, name: roomNames.get(r) || null }))
|
|
926
|
-
let
|
|
927
|
-
if (process.env.TP_PAIRBUS_OFF !== '1'
|
|
928
|
-
const
|
|
929
|
-
|
|
913
|
+
let authorized = []; let replies = []
|
|
914
|
+
if (process.env.TP_PAIRBUS_OFF !== '1') {
|
|
915
|
+
const result = await Promise.all([
|
|
916
|
+
authorizedPairRooms(room),
|
|
917
|
+
pairChannels.size ? busRequestCollect('peer-list-req', {}, 1200) : Promise.resolve([]),
|
|
918
|
+
])
|
|
919
|
+
authorized = result[0].rooms; replies = result[1]
|
|
930
920
|
}
|
|
921
|
+
const remote = mergePairRoomRoster({ authorized, replies, myUid: session.user.id, localCodes: children.keys() })
|
|
931
922
|
try { child.send({ t: 'pair-list-res', reqId: m.reqId, rooms: [...local, ...remote] }) } catch { /* child gone */ }
|
|
932
923
|
})()
|
|
933
924
|
}
|
|
@@ -946,7 +937,14 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
|
|
|
946
937
|
return
|
|
947
938
|
}
|
|
948
939
|
// REMOTE target — ask the pair bus(es).
|
|
949
|
-
if (process.env.TP_PAIRBUS_OFF === '1'
|
|
940
|
+
if (process.env.TP_PAIRBUS_OFF === '1') return reply({ error: 'Cross-session reach is turned off on this bridge.' })
|
|
941
|
+
const [authorized, live] = await Promise.all([
|
|
942
|
+
authorizedPairRooms(room),
|
|
943
|
+
pairChannels.size ? busRequestCollect('peer-list-req', {}, 800) : Promise.resolve([]),
|
|
944
|
+
])
|
|
945
|
+
if (authorized.verified && !authorized.rooms.some((r) => r?.code === target && r?.owner_id !== session.user.id)) return reply({ error: `Room ${target} is not shared with this pair.` })
|
|
946
|
+
if (!pairChannels.size) return reply({ error: authorized.verified ? `Room ${target} is paired with you, but its host bridge is offline.` : `Session ${target} is not running on this machine, and no pair bus is open to reach it.` })
|
|
947
|
+
if (!live.some((p) => p?.rooms?.some((r) => r?.code === target))) return reply({ error: `Room ${target} is paired with you, but its host bridge is offline.` })
|
|
950
948
|
const res = await busPeek(target, m.terminal, m.lines)
|
|
951
949
|
if (!res) return reply({ error: `Room ${target} did not respond — it may be offline, or not shared with you.` })
|
|
952
950
|
reply({ text: res.text, error: res.error })
|
|
@@ -966,8 +964,16 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
|
|
|
966
964
|
catch { pairPending.delete(reqId); reply({ error: `Could not reach session ${target}.` }) }
|
|
967
965
|
return
|
|
968
966
|
}
|
|
969
|
-
// REMOTE target —
|
|
970
|
-
|
|
967
|
+
// REMOTE target — prove the partner's bridge is currently answering
|
|
968
|
+
// before starting a 180s human-approval wait. The DB-backed roster may
|
|
969
|
+
// legitimately list an offline paired room; reachability comes from the bus.
|
|
970
|
+
const [authorized, live] = await Promise.all([
|
|
971
|
+
authorizedPairRooms(room),
|
|
972
|
+
pairChannels.size ? busRequestCollect('peer-list-req', {}, 1200) : Promise.resolve([]),
|
|
973
|
+
])
|
|
974
|
+
if (authorized.verified && !authorized.rooms.some((r) => r?.code === target && r?.owner_id !== session.user.id)) return reply({ error: `Room ${target} is not shared with this pair.` })
|
|
975
|
+
if (!pairChannels.size) return reply({ error: authorized.verified ? `Room ${target} is paired with you, but its host bridge is offline.` : `Room ${target} is not on this machine, and no pair bus is open to reach it.` })
|
|
976
|
+
if (!live.some((p) => p?.rooms?.some((r) => r?.code === target))) return reply({ error: `Room ${target} is paired with you, but its host bridge is offline — no recipient approval card can be raised yet.` })
|
|
971
977
|
const res = await busPost(target, m.terminal, m.text, room, m.fromTerminalName)
|
|
972
978
|
if (!res) return reply({ error: `Room ${target} did not respond to the hand-off in time.` })
|
|
973
979
|
reply({ ok: res.ok, ref: res.ref, error: res.error })
|
|
@@ -1101,6 +1107,7 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
|
|
|
1101
1107
|
keepFresh.cancel()
|
|
1102
1108
|
releaseSingleton()
|
|
1103
1109
|
for (const c of children.values()) { try { c.kill(sig || 'SIGTERM') } catch { /* noop */ } }
|
|
1110
|
+
pairBus.close()
|
|
1104
1111
|
for (const { channel } of pairChannels.values()) { try { sb.removeChannel(channel) } catch { /* noop */ } } // tear down pair buses (Tier 2/3)
|
|
1105
1112
|
// Flush the presence LEAVE before exiting so the dashboard flips to
|
|
1106
1113
|
// "not connected" in realtime (don't fire-and-forget the untrack).
|
package/bridge.mjs
CHANGED
|
@@ -113,7 +113,7 @@ const flowRedispatch = new Map()
|
|
|
113
113
|
// wave BEFORE overrun. Lives bridge-side because waves dispatch across separate
|
|
114
114
|
// broadcasts; without persistent state the cap can never bite.
|
|
115
115
|
const flowBudgets = new Map()
|
|
116
|
-
import { formatPeek, PEEK, siblingsOf, resolveSibling, crossPostDecision, CROSSPOST, spawnDecision, SPAWN, CROSSROOM, formatPairRoster, crossRoomPostDecision, formatRoomNow, formatClosableHint, closeLaneDecision, laneStatusOf, nativeClaudeFallbackHint, buildDispatchPreview, authorizeDirectDispatch } from './cross-terminal.mjs'
|
|
116
|
+
import { formatPeek, PEEK, siblingsOf, resolveSibling, crossPostDecision, CROSSPOST, spawnDecision, SPAWN, CROSSROOM, CROSSROOM_BUS, formatPairRoster, crossRoomPostDecision, formatRoomNow, formatClosableHint, closeLaneDecision, laneStatusOf, nativeClaudeFallbackHint, buildDispatchPreview, authorizeDirectDispatch } from './cross-terminal.mjs'
|
|
117
117
|
import { dispatchPendingRecap, recoverInterruptedTurn, recoverMissingResumeOnce, sendInterruptedContinue, shouldAttemptNativeResume, supersedeInterruptedResume } from './interrupted-resume.mjs'
|
|
118
118
|
import { supersedeDispatchLease } from './dispatch-lease.mjs'
|
|
119
119
|
import { turnInFlight } from './update-gate.mjs'
|
|
@@ -2306,7 +2306,7 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
|
|
|
2306
2306
|
if (target === room) return okText('That is this room — use read_terminal for terminals in your own room.')
|
|
2307
2307
|
entry.pairPeekCount = (entry.pairPeekCount || 0) + 1
|
|
2308
2308
|
if (entry.pairPeekCount > CROSSROOM.peekPerTurnCap) return okText(`Cross-session read limit reached for this turn (${CROSSROOM.peekPerTurnCap}). Continue with what you have.`)
|
|
2309
|
-
const res = await pairRequest('pair-peek-req', { targetRoom: target, terminal: args?.terminal, lines: args?.lines })
|
|
2309
|
+
const res = await pairRequest('pair-peek-req', { targetRoom: target, terminal: args?.terminal, lines: args?.lines }, CROSSROOM_BUS.busTimeoutMs + 2_000)
|
|
2310
2310
|
if (res?.error) return okText(res.error)
|
|
2311
2311
|
return okText(res?.text || `Room ${target} returned nothing.`)
|
|
2312
2312
|
},
|
package/cross-terminal.mjs
CHANGED
|
@@ -644,7 +644,7 @@ export const formatPairRoster = (rooms, { thisRoom, error } = {}) => {
|
|
|
644
644
|
// `host` is set for a Tier-2 cross-MACHINE room (it lives on a partner's Mac,
|
|
645
645
|
// reached over the pair bus); a same-machine Tier-1 room has none. Rendering the
|
|
646
646
|
// host tells the agent (and the people) which rooms are "over there".
|
|
647
|
-
const rows = list.map((r) => `- ${r.code}${r.name ? ` · "${r.name}"` : ''}${r.host ? ` · on ${r.host}` : ''}`)
|
|
647
|
+
const rows = list.map((r) => `- ${r.code}${r.name ? ` · "${r.name}"` : ''}${r.host ? ` · on ${r.host}` : ''}${r.online === false ? ' · partner bridge offline' : ''}`)
|
|
648
648
|
return `Your other ThinkPool Code sessions (call read_session with a room code to look inside one):\n${rows.join('\n')}`
|
|
649
649
|
}
|
|
650
650
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "thinkpool-pair",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.286",
|
|
4
4
|
"description": "Share a local coding-agent CLI (Claude Code, Codex, Gemini, Aider, …) into a ThinkPool Code room, live.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -55,6 +55,7 @@
|
|
|
55
55
|
"session-store.mjs",
|
|
56
56
|
"side-lane.mjs",
|
|
57
57
|
"cross-terminal.mjs",
|
|
58
|
+
"pair-bus.mjs",
|
|
58
59
|
"lane-lifecycle.mjs",
|
|
59
60
|
"interrupted-resume.mjs",
|
|
60
61
|
"dispatch-lease.mjs",
|
package/pair-bus.mjs
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// Deterministic request broker for the cross-machine pair bus.
|
|
2
|
+
//
|
|
3
|
+
// Supabase channel.send() is asynchronous and a channel is not usable until its
|
|
4
|
+
// subscription callback reports SUBSCRIBED. Keeping those facts in one tested
|
|
5
|
+
// primitive prevents a joining/dead channel from impersonating a reachable peer
|
|
6
|
+
// and prevents a fast reply from arriving before its waiter exists.
|
|
7
|
+
|
|
8
|
+
export const PAIR_BUS_STATUS = Object.freeze({
|
|
9
|
+
JOINING: 'joining',
|
|
10
|
+
SUBSCRIBED: 'subscribed',
|
|
11
|
+
ERROR: 'error',
|
|
12
|
+
CLOSED: 'closed',
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
export const pairBusStatusFromRealtime = (status) => {
|
|
16
|
+
if (status === 'SUBSCRIBED') return PAIR_BUS_STATUS.SUBSCRIBED
|
|
17
|
+
if (status === 'CLOSED') return PAIR_BUS_STATUS.CLOSED
|
|
18
|
+
if (status === 'CHANNEL_ERROR' || status === 'TIMED_OUT') return PAIR_BUS_STATUS.ERROR
|
|
19
|
+
return PAIR_BUS_STATUS.JOINING
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export const subscribedPairChannels = (channels) =>
|
|
23
|
+
[...(channels?.values?.() || [])].filter((entry) =>
|
|
24
|
+
entry?.status === PAIR_BUS_STATUS.SUBSCRIBED && entry?.channel?.send)
|
|
25
|
+
|
|
26
|
+
export async function sendPairBroadcast (channels, event, payload) {
|
|
27
|
+
const results = await Promise.all(subscribedPairChannels(channels).map(async ({ channel }) => {
|
|
28
|
+
try { return (await channel.send({ type: 'broadcast', event, payload })) === 'ok' }
|
|
29
|
+
catch { return false }
|
|
30
|
+
}))
|
|
31
|
+
return results.filter(Boolean).length
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function createPairBusBroker ({ channels, randomId, setTimer = setTimeout, clearTimer = clearTimeout } = {}) {
|
|
35
|
+
if (!channels?.values || typeof randomId !== 'function') throw new TypeError('channels and randomId are required')
|
|
36
|
+
const waiters = new Map()
|
|
37
|
+
|
|
38
|
+
const resolve = (payload) => {
|
|
39
|
+
if (!payload?.nonce) return false
|
|
40
|
+
const waiter = waiters.get(payload.nonce)
|
|
41
|
+
if (!waiter) return false
|
|
42
|
+
if (waiter.mode === 'collect') { waiter.acc.push(payload); return true }
|
|
43
|
+
clearTimer(waiter.timer); waiters.delete(payload.nonce); waiter.finish(payload)
|
|
44
|
+
return true
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const requestFirst = (event, payload = {}, timeoutMs) => new Promise((finish) => {
|
|
48
|
+
const nonce = randomId()
|
|
49
|
+
const timer = setTimer(() => { waiters.delete(nonce); finish(null) }, timeoutMs)
|
|
50
|
+
timer?.unref?.()
|
|
51
|
+
waiters.set(nonce, { mode: 'first', finish, timer })
|
|
52
|
+
void sendPairBroadcast(channels, event, { ...payload, nonce }).then((sent) => {
|
|
53
|
+
if (sent || !waiters.has(nonce)) return
|
|
54
|
+
clearTimer(timer); waiters.delete(nonce); finish(null)
|
|
55
|
+
})
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
const requestCollect = (event, payload = {}, windowMs) => new Promise((finish) => {
|
|
59
|
+
const nonce = randomId()
|
|
60
|
+
const acc = []
|
|
61
|
+
const timer = setTimer(() => { waiters.delete(nonce); finish(acc) }, windowMs)
|
|
62
|
+
timer?.unref?.()
|
|
63
|
+
waiters.set(nonce, { mode: 'collect', acc, finish, timer })
|
|
64
|
+
void sendPairBroadcast(channels, event, { ...payload, nonce }).then((sent) => {
|
|
65
|
+
if (sent || !waiters.has(nonce)) return
|
|
66
|
+
clearTimer(timer); waiters.delete(nonce); finish(acc)
|
|
67
|
+
})
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
const close = () => {
|
|
71
|
+
for (const [nonce, waiter] of waiters) {
|
|
72
|
+
clearTimer(waiter.timer)
|
|
73
|
+
waiter.finish(waiter.mode === 'collect' ? waiter.acc : null)
|
|
74
|
+
waiters.delete(nonce)
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return Object.freeze({ resolve, requestFirst, requestCollect, close, pendingCount: () => waiters.size })
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// The database is the authorization/discovery source; bus replies are the live
|
|
82
|
+
// reachability source. Paired rooms remain visible while their host bridge is
|
|
83
|
+
// offline and become online only when that host actually answers on the bus.
|
|
84
|
+
export function mergePairRoomRoster ({ authorized = [], replies = [], myUid, localCodes = [] } = {}) {
|
|
85
|
+
const local = new Set(localCodes)
|
|
86
|
+
const rooms = new Map()
|
|
87
|
+
for (const row of authorized) {
|
|
88
|
+
if (!row?.code || row.owner_id === myUid || local.has(row.code)) continue
|
|
89
|
+
rooms.set(row.code, { code: row.code, name: row.name || null, online: false })
|
|
90
|
+
}
|
|
91
|
+
for (const reply of replies) {
|
|
92
|
+
for (const row of Array.isArray(reply?.rooms) ? reply.rooms : []) {
|
|
93
|
+
if (!row?.code || local.has(row.code)) continue
|
|
94
|
+
rooms.set(row.code, { code: row.code, name: row.name || rooms.get(row.code)?.name || null, host: row.host || null, online: true })
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return [...rooms.values()]
|
|
98
|
+
}
|