thinkpool-pair 0.7.290 → 0.7.291
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/bridge.mjs +78 -26
- package/hermes-session.mjs +3 -1
- package/package.json +2 -1
- package/replay-transport.mjs +64 -0
package/bridge.mjs
CHANGED
|
@@ -120,6 +120,7 @@ import { supersedeDispatchLease } from './dispatch-lease.mjs'
|
|
|
120
120
|
import { turnInFlight } from './update-gate.mjs'
|
|
121
121
|
import { saveSession, flushSession, deleteSession, loadAll, canResume, loadPtyId, savePtyId, loadNames, saveNames, appendDurableEvents, seedDurableEvents, readDurablePage, readDurableOldestSeq, hasDurableArchive, servesHistoryPage } from './session-store.mjs'
|
|
122
122
|
import { stampEvent, makeSeqCounter, maxSeq, seqable, capReplayEvents, chunkReplayEvents, boundEventForBroadcast, inlineImageBlocks, ImageEventQueue, imageQueueConfig, uploadCodeImage as uploadCodeImageRequest, usageReportLine, codexUsageReportLine, buildRecapFromLog, RECAP_CAP, trimmedBeforeSeq, firstSeq } from './event-id.mjs'
|
|
123
|
+
import { createLatestReplayPump, requestedReplayIds } from './replay-transport.mjs'
|
|
123
124
|
import { classifyCodeEvent, unknownCodeEventNotice } from './code-event-contract.mjs'
|
|
124
125
|
import { fetchPairControlDeliveryAuthority } from './pair-control-authority.mjs'
|
|
125
126
|
import { SIDE_HANDOFF_PROMPT, SIDE_ROLE_PROMPT, appendSideContext, assistantTextSince, sideContextBlock, sideSnapshot } from './side-lane.mjs'
|
|
@@ -574,6 +575,7 @@ if (process.stdin.isTTY && !headless && process.env.THINKPOOL_PAIR_AUTOUPDATE !=
|
|
|
574
575
|
}
|
|
575
576
|
const name = process.env.TP_NAME || os.userInfo().username || 'host'
|
|
576
577
|
const BRIDGE_ID = (randomUUID?.().slice(0,8)) || 'bridgexx'
|
|
578
|
+
const BRIDGE_STARTED_AT = Date.now()
|
|
577
579
|
// host: this machine's short label — os.hostname() with any DNS/domain suffix
|
|
578
580
|
// stripped and capped ~24 chars. A /code room can be served by different bridges
|
|
579
581
|
// over time (Max's Mac vs Conrad's Linux box); the client uses this to show WHICH
|
|
@@ -957,6 +959,13 @@ function beginStructuredTurn(entry, now = Date.now()) {
|
|
|
957
959
|
return true
|
|
958
960
|
}
|
|
959
961
|
|
|
962
|
+
function advanceStructuredTurn(entry, now = Date.now()) {
|
|
963
|
+
entry._turnRev = (Number(entry._turnRev) || 0) + 1
|
|
964
|
+
entry._turnStart = now
|
|
965
|
+
entry._busyAnn = true
|
|
966
|
+
return true
|
|
967
|
+
}
|
|
968
|
+
|
|
960
969
|
function stampStructuredTurn(entry, event) {
|
|
961
970
|
if (event && event.turnRev == null && Number(entry?._turnRev) > 0) event.turnRev = entry._turnRev
|
|
962
971
|
return event
|
|
@@ -1064,17 +1073,21 @@ const defendFrame = (event, payload) => {
|
|
|
1064
1073
|
return p
|
|
1065
1074
|
}
|
|
1066
1075
|
|
|
1067
|
-
const
|
|
1076
|
+
const bcastAwait = async (event, payload, ch = channel) => {
|
|
1068
1077
|
if (event === 'pty-out' || event === 'code-event') lastActivity = Date.now()
|
|
1069
1078
|
payload = defendFrame(event, payload)
|
|
1070
1079
|
try {
|
|
1071
1080
|
if (ch.channelAdapter?.canPush?.() ?? true) {
|
|
1072
|
-
ch.send({ type: 'broadcast', event, payload })
|
|
1081
|
+
return await ch.send({ type: 'broadcast', event, payload })
|
|
1073
1082
|
} else {
|
|
1074
|
-
ch.httpSend(event, payload)
|
|
1083
|
+
return await ch.httpSend(event, payload)
|
|
1075
1084
|
}
|
|
1076
|
-
} catch { /*
|
|
1085
|
+
} catch { return null /* offline — replay covers it */ }
|
|
1077
1086
|
}
|
|
1087
|
+
const bcast = (event, payload, ch = channel) => { void bcastAwait(event, payload, ch) }
|
|
1088
|
+
const replayPump = createLatestReplayPump({
|
|
1089
|
+
send: ({ event, payload }) => bcastAwait(event, payload),
|
|
1090
|
+
})
|
|
1078
1091
|
|
|
1079
1092
|
// Per-room terminal display names (id -> label), set by the web's `term-rename`.
|
|
1080
1093
|
// Persisted on the host so a rename is cross-device + survives a bridge restart;
|
|
@@ -1093,7 +1106,7 @@ const announce = () => {
|
|
|
1093
1106
|
const provNames = providerNameMap()
|
|
1094
1107
|
const rev = ++announceRev
|
|
1095
1108
|
return bcast('bridge', {
|
|
1096
|
-
v: 2, name, bridge_id: BRIDGE_ID, rev, repo: repoLabel, branch: readBranch(),
|
|
1109
|
+
v: 2, name, bridge_id: BRIDGE_ID, started_at: BRIDGE_STARTED_AT, rev, repo: repoLabel, branch: readBranch(),
|
|
1097
1110
|
// sdkWarn: the auto-pulled agent SDK failed its boot compatibility smoke test —
|
|
1098
1111
|
// surfaced so the room can show a banner (turns may misbehave; pin a good SDK).
|
|
1099
1112
|
...(sdkStatus.ok === false ? { sdkWarn: `${sdkStatus.version}: ${sdkStatus.reason}` } : {}),
|
|
@@ -2876,6 +2889,20 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
|
|
|
2876
2889
|
// per-machine default (provider.mjs/applyProviderEnv). null for built-in/unknown → the
|
|
2877
2890
|
// default Claude env is left exactly as-is (unchanged path).
|
|
2878
2891
|
env: { ...process.env, ...buildConductorEnv({ flowSessionId, mode }), ...(resolveProviderEnv(provider) || {}), TP_MOCKUP_OUTBOX: mockupOutbox },
|
|
2892
|
+
onTurnStart: (options = {}) => {
|
|
2893
|
+
// Hermes promotes /queue items internally, without a second code-turn.
|
|
2894
|
+
// Advance the lifecycle before its first output and publish the deferred
|
|
2895
|
+
// human line under the new turn revision, keeping all viewers converged.
|
|
2896
|
+
if (entry._busyAnn === true) advanceStructuredTurn(entry)
|
|
2897
|
+
else beginStructuredTurn(entry)
|
|
2898
|
+
const queuedEcho = options._thinkpoolQueuedEcho
|
|
2899
|
+
if (queuedEcho) {
|
|
2900
|
+
const evt = { kind: 'you', ...queuedEcho }
|
|
2901
|
+
pushLog(entry, evt)
|
|
2902
|
+
bcast('code-event', { term: id, evt })
|
|
2903
|
+
}
|
|
2904
|
+
announce()
|
|
2905
|
+
},
|
|
2879
2906
|
onEvent: (evt) => {
|
|
2880
2907
|
if (!classifyCodeEvent(evt).known) {
|
|
2881
2908
|
// Never archive or replay an unknown provider payload as a known fact. Do
|
|
@@ -2933,7 +2960,9 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
|
|
|
2933
2960
|
// replay-union dedupes ONLY by cid, and SDK events carry none — without an
|
|
2934
2961
|
// id, an event that arrives both live AND in a reconnect replay renders
|
|
2935
2962
|
// twice (the 2026-06-19 duplicate-message bug). See event-id.mjs.
|
|
2936
|
-
const
|
|
2963
|
+
const continuesQueued = runtime === 'hermes' && (evt.kind === 'result' || evt.kind === 'error') && (entry.session?.queuedDepth || 0) > 0
|
|
2964
|
+
if (continuesQueued) evt.continuesQueued = true
|
|
2965
|
+
const busyChanged = continuesQueued ? false : syncStructuredTurn(entry)
|
|
2937
2966
|
stampStructuredTurn(entry, evt)
|
|
2938
2967
|
stampEvent(evt)
|
|
2939
2968
|
const stalledChanged = evt.kind === 'stalled' ? !entry.stalled : !!entry.stalled
|
|
@@ -3651,14 +3680,17 @@ channel
|
|
|
3651
3680
|
// Flush pending bytes first so the replay is complete up to "now"; both
|
|
3652
3681
|
// ride the same ordered socket, so the client sees replay-then-live.
|
|
3653
3682
|
flushAll()
|
|
3654
|
-
|
|
3683
|
+
const to = payload?.to ?? null
|
|
3684
|
+
const requestId = String(payload?.requestId || randomUUID?.() || `${Date.now()}`)
|
|
3685
|
+
const frames = []
|
|
3686
|
+
for (const [id, t] of requestedReplayIds(terms, payload?.terms, payload?.priority)) {
|
|
3655
3687
|
if (!t.scrollback) continue
|
|
3656
3688
|
// Cap scrollback too — a huge PTY buffer would blow the same frame limit.
|
|
3657
3689
|
const sb = t.scrollback.length > 100000 ? t.scrollback.slice(-100000) : t.scrollback
|
|
3658
|
-
|
|
3659
|
-
to
|
|
3690
|
+
frames.push({ event: 'pty-replay', payload: {
|
|
3691
|
+
to, term: id, requestId,
|
|
3660
3692
|
b64: Buffer.from(sb, 'utf8').toString('base64'),
|
|
3661
|
-
})
|
|
3693
|
+
} })
|
|
3662
3694
|
}
|
|
3663
3695
|
// Structured sessions replay their event log (reader rebuilds from it).
|
|
3664
3696
|
// C1 (RT-2): send only the tail past the client's per-term cursor (seqHi) when
|
|
@@ -3669,14 +3701,13 @@ channel
|
|
|
3669
3701
|
// cursor) still gets the full log.
|
|
3670
3702
|
// Hydrate the viewed lane first. Other terminals still warm in the background,
|
|
3671
3703
|
// but cannot queue ahead of the transcript the person is waiting to see.
|
|
3672
|
-
const replaySessions =
|
|
3673
|
-
a === payload?.priority ? -1 : b === payload?.priority ? 1 : 0)
|
|
3704
|
+
const replaySessions = requestedReplayIds(sessions, payload?.terms, payload?.priority)
|
|
3674
3705
|
for (const [id, s] of replaySessions) {
|
|
3675
3706
|
// An explicit empty replay is an acknowledgement, not data. It lets mixed
|
|
3676
3707
|
// clients clear their loading cover even if they missed the announce's
|
|
3677
3708
|
// hasTranscript:false state.
|
|
3678
3709
|
if (!s.log.length) {
|
|
3679
|
-
|
|
3710
|
+
frames.push({ event: 'code-replay', payload: { to, term: id, requestId, chunkIndex: 0, chunkCount: 1, events: [], empty: true } })
|
|
3680
3711
|
continue
|
|
3681
3712
|
}
|
|
3682
3713
|
const from = Number(payload?.cursors?.[id]) || 0
|
|
@@ -3716,10 +3747,14 @@ channel
|
|
|
3716
3747
|
// trimmedBefore rides the FIRST (oldest) chunk only — the later chunks are contiguous
|
|
3717
3748
|
// with it, so a stamp there would read as a second, phantom gap. Same rule as
|
|
3718
3749
|
// lastUsage above and hasMore on history-page below.
|
|
3719
|
-
let
|
|
3720
|
-
|
|
3721
|
-
|
|
3722
|
-
|
|
3750
|
+
for (let chunkIndex = 0; chunkIndex < chunks.length; chunkIndex++) {
|
|
3751
|
+
const events = chunks[chunkIndex]
|
|
3752
|
+
frames.push({ event: 'code-replay', payload: {
|
|
3753
|
+
to, term: id, requestId, chunkIndex, chunkCount: chunks.length, events,
|
|
3754
|
+
...(ahead ? { reset: true } : {}),
|
|
3755
|
+
...(chunkIndex === 0 && trimmedBefore != null ? { trimmedBefore } : {}),
|
|
3756
|
+
...(chunkIndex === 0 && s.lastUsage?.ctx ? { lastUsage: s.lastUsage } : {}),
|
|
3757
|
+
} })
|
|
3723
3758
|
}
|
|
3724
3759
|
}
|
|
3725
3760
|
// Re-send any still-pending permission/question cards. They ride a one-shot
|
|
@@ -3728,10 +3763,15 @@ channel
|
|
|
3728
3763
|
// (unanswered) tool row — the AskUserQuestion "vanished on reconnect" bug. Only
|
|
3729
3764
|
// truly-unresolved cards remain in `pending` (resolved ones are deleted), and
|
|
3730
3765
|
// the client dedupes code-perm-req by id, so this can't resurrect an answered one.
|
|
3731
|
-
|
|
3732
|
-
|
|
3733
|
-
|
|
3734
|
-
|
|
3766
|
+
// One awaited frame per event-loop turn. A newer request from this viewer
|
|
3767
|
+
// replaces every unsent frame from the older one, so visibility/online races
|
|
3768
|
+
// cannot multiply a cold replay into several concurrent megabyte bursts.
|
|
3769
|
+
replayPump.enqueue(to || '*', frames, async () => {
|
|
3770
|
+
for (const [, s] of sessions) {
|
|
3771
|
+
for (const [, p] of s.pending) await bcastAwait('code-perm-req', p.payload)
|
|
3772
|
+
}
|
|
3773
|
+
announce()
|
|
3774
|
+
})
|
|
3735
3775
|
})
|
|
3736
3776
|
// "Load earlier history": serve an OLDER page from the durable JSONL archive (the
|
|
3737
3777
|
// events the in-memory 2000-cap evicted). beforeSeq anchors the page at the client's
|
|
@@ -3750,11 +3790,12 @@ channel
|
|
|
3750
3790
|
if (!servesHistoryPage({ payloadHost: payload?.host, bridgeName: name, hasSession: sessions.has(id), hasArchive: hasDurableArchive(room, id) })) return
|
|
3751
3791
|
const { events, hasMore } = readDurablePage(room, id, Number(payload?.beforeSeq) || null, 200)
|
|
3752
3792
|
const to = payload?.to ?? null
|
|
3753
|
-
|
|
3793
|
+
const requestId = String(payload?.requestId || randomUUID?.() || `${Date.now()}`)
|
|
3794
|
+
if (!events.length) { bcast('history-page', { to, term: id, requestId, chunkIndex: 0, chunkCount: 1, events: [], hasMore: false }); return }
|
|
3754
3795
|
const chunks = chunkReplayEvents(events.map((e) => boundEventForBroadcast(e)))
|
|
3755
3796
|
// hasMore rides only the FIRST (oldest) chunk so the client sets the floor flag once
|
|
3756
3797
|
// from the true page boundary; later chunks are just more of the same page.
|
|
3757
|
-
chunks.forEach((evs, i) => bcast('history-page', { to, term: id, events: evs, hasMore: i === 0 ? hasMore : true }))
|
|
3798
|
+
chunks.forEach((evs, i) => bcast('history-page', { to, term: id, requestId, chunkIndex: i, chunkCount: chunks.length, events: evs, hasMore: i === 0 ? hasMore : true }))
|
|
3758
3799
|
})
|
|
3759
3800
|
.on('broadcast', { event: 'file-put' }, ({ payload }) => {
|
|
3760
3801
|
if (!payload?.id || !payload?.url) return
|
|
@@ -4030,13 +4071,24 @@ channel
|
|
|
4030
4071
|
const nativeImages = s.runtime === 'codex' || s.runtime === 'hermes'
|
|
4031
4072
|
? await waitForNativeImages(payload.files, { updir: UPDIR })
|
|
4032
4073
|
: []
|
|
4074
|
+
const deferHermesQueueEcho = s.runtime === 'hermes' && s.session.turnActive && /^\s*\/queue\s+/i.test(text)
|
|
4075
|
+
const turnOptions = {
|
|
4076
|
+
...(nativeImages.length ? { images: nativeImages } : {}),
|
|
4077
|
+
...(deferHermesQueueEcho && !payload.silent ? { _thinkpoolQueuedEcho: {
|
|
4078
|
+
text: payload.body != null ? String(payload.body) : text,
|
|
4079
|
+
cid: payload.cid,
|
|
4080
|
+
by: payload.by,
|
|
4081
|
+
...(Array.isArray(payload.files) && payload.files.length ? { files: payload.files } : {}),
|
|
4082
|
+
...(Array.isArray(payload.pastes) && payload.pastes.length ? { pastes: payload.pastes } : {}),
|
|
4083
|
+
} } : {}),
|
|
4084
|
+
}
|
|
4033
4085
|
// Sample the runtime before dispatch so a missed prior falling edge cannot
|
|
4034
4086
|
// make this genuinely new turn inherit the previous turn's revision.
|
|
4035
4087
|
syncStructuredTurn(s)
|
|
4036
|
-
const accepted = s.session.sendTurn(sendText,
|
|
4088
|
+
const accepted = s.session.sendTurn(sendText, Object.keys(turnOptions).length ? turnOptions : undefined)
|
|
4037
4089
|
if (accepted === false) syncStructuredTurn(s)
|
|
4038
4090
|
else beginStructuredTurn(s)
|
|
4039
|
-
echoYou()
|
|
4091
|
+
if (!deferHermesQueueEcho || accepted === false) echoYou()
|
|
4040
4092
|
if (accepted === false) {
|
|
4041
4093
|
// A runtime that did not accept a turn must still close the optimistic
|
|
4042
4094
|
// user-line lifecycle. Without this boundary the client truthfully shows
|
|
@@ -4312,7 +4364,7 @@ channel
|
|
|
4312
4364
|
.subscribe(async status => {
|
|
4313
4365
|
if (status === 'SUBSCRIBED') {
|
|
4314
4366
|
realtimeHealthy = true; brokenSince = 0
|
|
4315
|
-
trackPresence({ name, role: 'bridge', bridge_id: BRIDGE_ID })
|
|
4367
|
+
trackPresence({ name, role: 'bridge', bridge_id: BRIDGE_ID, started_at: BRIDGE_STARTED_AT })
|
|
4316
4368
|
const startCmd = attachedCmd || autoAgent // autoAgent: account-mode auto-open (headless)
|
|
4317
4369
|
if (startCmd && !terms.size && !sessions.size) {
|
|
4318
4370
|
// Claude + structured mode → Agent SDK session; everything else → PTY.
|
package/hermes-session.mjs
CHANGED
|
@@ -68,7 +68,7 @@ export function startHermesSession({
|
|
|
68
68
|
command = HERMES_COMMAND, args = ['acp'], clientFactory = createAcpClient,
|
|
69
69
|
mcpHttpFactory = startCodexMcpHttp, lazy = false, hermesRole = null,
|
|
70
70
|
crossPostGate = null, didSpawnTarget = null, crossRoomPostGate = null, effort = 'high',
|
|
71
|
-
admitStart = null,
|
|
71
|
+
admitStart = null, onTurnStart = null,
|
|
72
72
|
} = {}) {
|
|
73
73
|
let activeCwd = cwd
|
|
74
74
|
const requestedModel = model || null
|
|
@@ -503,6 +503,7 @@ export function startHermesSession({
|
|
|
503
503
|
const next = queuedTurns.shift()
|
|
504
504
|
const turnId = ++activeTurnId
|
|
505
505
|
turnActive = true
|
|
506
|
+
try { onTurnStart?.(next.options || {}) } catch { /* observer cannot break the FIFO */ }
|
|
506
507
|
try { await runPrompt(next.text, next.options, { steering: false, turnId, promptIndex: next.promptIndex, forceFull: next.forceFull }) }
|
|
507
508
|
catch (error) { turnActive = false; emit({ kind: 'error', message: `Hermes queued turn failed: ${error?.message || error}`, recoverable: true }) }
|
|
508
509
|
}
|
|
@@ -514,6 +515,7 @@ export function startHermesSession({
|
|
|
514
515
|
return {
|
|
515
516
|
get sessionId() { return sessionId },
|
|
516
517
|
get turnActive() { return turnActive },
|
|
518
|
+
get queuedDepth() { return queuedTurns.length },
|
|
517
519
|
get canSteer() { return !!client?.alive },
|
|
518
520
|
get started() { return started },
|
|
519
521
|
get models() { return [] },
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "thinkpool-pair",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.291",
|
|
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": {
|
|
@@ -49,6 +49,7 @@
|
|
|
49
49
|
"pair-control-authority.mjs",
|
|
50
50
|
"event-id.mjs",
|
|
51
51
|
"event-bounds.mjs",
|
|
52
|
+
"replay-transport.mjs",
|
|
52
53
|
"plan-meters.mjs",
|
|
53
54
|
"recap.mjs",
|
|
54
55
|
"transcript-sanitize.mjs",
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// Replay transport policy. Reconnects are allowed to supersede older work for
|
|
2
|
+
// the same viewer, and frames are deliberately paced so live events and Stop /
|
|
3
|
+
// permission traffic are not trapped behind a multi-megabyte replay burst.
|
|
4
|
+
|
|
5
|
+
export function requestedReplayIds(entries, terms, priority) {
|
|
6
|
+
const requested = Array.isArray(terms) ? new Set(terms.filter(Boolean)) : null
|
|
7
|
+
return [...entries]
|
|
8
|
+
.filter(([id]) => !requested || requested.has(id))
|
|
9
|
+
.sort(([a], [b]) => a === priority ? -1 : b === priority ? 1 : 0)
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const defaultYield = () => new Promise((resolve) => setTimeout(resolve, 0))
|
|
13
|
+
|
|
14
|
+
export function createLatestReplayPump({ send, yieldTurn = defaultYield } = {}) {
|
|
15
|
+
if (typeof send !== 'function') throw new TypeError('replay pump requires send(frame)')
|
|
16
|
+
const viewers = new Map()
|
|
17
|
+
|
|
18
|
+
const run = async (viewer, state) => {
|
|
19
|
+
if (state.running) return
|
|
20
|
+
state.running = true
|
|
21
|
+
try {
|
|
22
|
+
while (state.frames.length) {
|
|
23
|
+
const generation = state.generation
|
|
24
|
+
const frame = state.frames.shift()
|
|
25
|
+
await send(frame)
|
|
26
|
+
await yieldTurn()
|
|
27
|
+
// enqueue() replaces the remaining frames. The send already in flight is
|
|
28
|
+
// allowed to finish; nothing older can start after the replacement.
|
|
29
|
+
if (generation !== state.generation) continue
|
|
30
|
+
}
|
|
31
|
+
const done = state.onDone
|
|
32
|
+
state.onDone = null
|
|
33
|
+
if (done) await done()
|
|
34
|
+
} finally {
|
|
35
|
+
state.running = false
|
|
36
|
+
if (state.frames.length) void run(viewer, state)
|
|
37
|
+
else if (viewers.get(viewer) === state) viewers.delete(viewer)
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return {
|
|
42
|
+
enqueue(viewer, frames, onDone = null) {
|
|
43
|
+
const key = String(viewer || '*')
|
|
44
|
+
const state = viewers.get(key) || { generation: 0, frames: [], onDone: null, running: false }
|
|
45
|
+
state.generation += 1
|
|
46
|
+
state.frames = Array.isArray(frames) ? [...frames] : []
|
|
47
|
+
state.onDone = typeof onDone === 'function' ? onDone : null
|
|
48
|
+
viewers.set(key, state)
|
|
49
|
+
void run(key, state)
|
|
50
|
+
return state.generation
|
|
51
|
+
},
|
|
52
|
+
cancel(viewer) {
|
|
53
|
+
const state = viewers.get(String(viewer || '*'))
|
|
54
|
+
if (!state) return false
|
|
55
|
+
state.generation += 1
|
|
56
|
+
state.frames = []
|
|
57
|
+
state.onDone = null
|
|
58
|
+
return true
|
|
59
|
+
},
|
|
60
|
+
pending(viewer) {
|
|
61
|
+
return viewers.get(String(viewer || '*'))?.frames.length || 0
|
|
62
|
+
},
|
|
63
|
+
}
|
|
64
|
+
}
|