@abraca/orchestrator 2.3.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.
@@ -0,0 +1,179 @@
1
+ export interface UserMetaField {
2
+ id: string
3
+ type: string
4
+ label?: string
5
+ key?: string
6
+ latKey?: string
7
+ lngKey?: string
8
+ startKey?: string
9
+ endKey?: string
10
+ allDayKey?: string
11
+ presets?: string[]
12
+ options?: string[]
13
+ min?: number
14
+ max?: number
15
+ step?: number
16
+ unit?: string
17
+ }
18
+
19
+ export interface PageMeta extends Record<string, unknown> {
20
+ // Universal display
21
+ color?: string
22
+ icon?: string
23
+ subtitle?: string
24
+ note?: string
25
+
26
+ // Datetime
27
+ datetimeStart?: string
28
+ datetimeEnd?: string
29
+ allDay?: boolean
30
+ dateStart?: string
31
+ dateEnd?: string
32
+ timeStart?: string
33
+ timeEnd?: string
34
+
35
+ // Task/status
36
+ checked?: boolean
37
+ priority?: number
38
+ status?: string
39
+ taskProgress?: number
40
+ rating?: number
41
+ tags?: string[]
42
+ members?: { id: string; label: string }[]
43
+
44
+ // Contact/value
45
+ url?: string
46
+ email?: string
47
+ phone?: string
48
+ number?: number
49
+ unit?: string
50
+
51
+ // Cover image
52
+ coverUploadId?: string
53
+ coverDocId?: string
54
+ coverMimeType?: string
55
+
56
+ // Geo/Map
57
+ geoType?: 'marker' | 'line' | 'measure'
58
+ geoLat?: number
59
+ geoLng?: number
60
+ geoDescription?: string
61
+
62
+ // Whiteboard
63
+ wbX?: number
64
+ wbY?: number
65
+ wbW?: number
66
+ wbH?: number
67
+ wbBg?: string
68
+
69
+ // Dashboard
70
+ deskX?: number
71
+ deskY?: number
72
+ deskZ?: number
73
+ deskMode?: string
74
+
75
+ // Mindmap
76
+ mmX?: number
77
+ mmY?: number
78
+
79
+ // Graph
80
+ graphX?: number
81
+ graphY?: number
82
+ graphPinned?: boolean
83
+ showRefEdges?: boolean
84
+
85
+ // Spatial (3D)
86
+ spShape?: string
87
+ spColor?: string
88
+ spOpacity?: number
89
+ spX?: number
90
+ spY?: number
91
+ spZ?: number
92
+ spRX?: number
93
+ spRY?: number
94
+ spRZ?: number
95
+ spSX?: number
96
+ spSY?: number
97
+ spSZ?: number
98
+ spModelUploadId?: string
99
+ spModelDocId?: string
100
+
101
+ // Slides
102
+ slidesTransition?: string
103
+ slidesTheme?: string
104
+
105
+ // Media metadata (extracted on import)
106
+ mediaDuration?: number
107
+ mediaWidth?: number
108
+ mediaHeight?: number
109
+ mediaCamera?: string
110
+ mediaLens?: string
111
+ mediaIso?: number
112
+ mediaFocalLength?: number
113
+ mediaAperture?: number
114
+ mediaShutterSpeed?: string
115
+ mediaArtist?: string
116
+ mediaAlbum?: string
117
+ mediaGenre?: string
118
+ mediaYear?: number
119
+ dateTaken?: string
120
+
121
+ // Sheets cell formatting
122
+ bold?: boolean
123
+ italic?: boolean
124
+ textColor?: string
125
+ bgColor?: string
126
+ align?: string
127
+ formula?: string
128
+
129
+ // Renderer config (set on the page doc itself, not children)
130
+ kanbanColumnWidth?: string
131
+ galleryColumns?: number
132
+ galleryAspect?: string
133
+ galleryCardStyle?: string
134
+ galleryShowLabels?: boolean
135
+ gallerySortBy?: string
136
+ calendarView?: string
137
+ calendarWeekStart?: string
138
+ calendarShowWeekNumbers?: boolean
139
+ tableMode?: string
140
+ tableSortDir?: string
141
+ tableColumns?: any[]
142
+ tableColumnWidths?: Record<string, number>
143
+ tableColumnOrder?: string[]
144
+ timelineZoom?: string
145
+ checklistFilter?: string
146
+ checklistSort?: string
147
+ mapShowLabels?: boolean
148
+ spatialGridVisible?: boolean
149
+ chartType?: string
150
+ chartMetric?: string
151
+ chartColorScheme?: string
152
+ chartLimit?: number
153
+ chartShowLegend?: boolean
154
+ chartShowValues?: boolean
155
+ mediaRepeat?: string
156
+ mediaShuffle?: boolean
157
+ sheetsDefaultColWidth?: number
158
+ sheetsDefaultRowHeight?: number
159
+ sheetsShowGridlines?: boolean
160
+ sheetsColumnWidths?: Record<string, number>
161
+ sheetsRowHeights?: Record<string, number>
162
+ sheetsFreezeRows?: number
163
+ sheetsFreezeCols?: number
164
+
165
+ // Internal
166
+ _metaFields?: UserMetaField[]
167
+ _metaInitialized?: boolean
168
+ }
169
+
170
+ export interface TreeEntry {
171
+ id: string
172
+ label: string
173
+ parentId: string | null
174
+ order: number
175
+ type?: string
176
+ meta?: PageMeta
177
+ createdAt?: number
178
+ updatedAt?: number
179
+ }
package/src/crypto.ts ADDED
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Ed25519 key generation, persistence, and challenge signing for actor auth.
3
+ * Copied from packages/mcp/src/crypto.ts
4
+ */
5
+ import * as ed from '@noble/ed25519'
6
+ import { sha512 } from '@noble/hashes/sha2.js'
7
+ import { readFile, writeFile, mkdir } from 'node:fs/promises'
8
+
9
+ // @noble/ed25519 v3 hash hook
10
+ ed.hashes.sha512 = sha512
11
+ ed.hashes.sha512Async = (m: Uint8Array) => Promise.resolve(sha512(m))
12
+ import { existsSync } from 'node:fs'
13
+ import { dirname } from 'node:path'
14
+
15
+ function toBase64url(bytes: Uint8Array): string {
16
+ return Buffer.from(bytes).toString('base64url')
17
+ }
18
+
19
+ function fromBase64url(b64: string): Uint8Array {
20
+ return new Uint8Array(Buffer.from(b64, 'base64url'))
21
+ }
22
+
23
+ export interface AgentKeypair {
24
+ privateKey: Uint8Array
25
+ publicKeyB64: string
26
+ }
27
+
28
+ /**
29
+ * Load an existing Ed25519 keypair from disk, or generate and persist a new one.
30
+ */
31
+ export async function loadOrCreateKeypair(keyPath: string): Promise<AgentKeypair> {
32
+ if (existsSync(keyPath)) {
33
+ const seed = await readFile(keyPath)
34
+ if (seed.length !== 32) {
35
+ throw new Error(`Invalid key file at ${keyPath}: expected 32 bytes, got ${seed.length}`)
36
+ }
37
+ const privateKey = new Uint8Array(seed)
38
+ const publicKey = ed.getPublicKey(privateKey)
39
+ return { privateKey, publicKeyB64: toBase64url(publicKey) }
40
+ }
41
+
42
+ const privateKey = ed.utils.randomSecretKey()
43
+ const publicKey = ed.getPublicKey(privateKey)
44
+
45
+ const dir = dirname(keyPath)
46
+ if (!existsSync(dir)) {
47
+ await mkdir(dir, { recursive: true, mode: 0o700 })
48
+ }
49
+ await writeFile(keyPath, Buffer.from(privateKey), { mode: 0o600 })
50
+
51
+ console.error(`[orchestrator] Generated new keypair at ${keyPath}`)
52
+ return { privateKey, publicKeyB64: toBase64url(publicKey) }
53
+ }
54
+
55
+ /**
56
+ * Generate a deterministic Ed25519 keypair from a seed string (e.g. actor name).
57
+ * The same seed always produces the same keypair.
58
+ */
59
+ export function deterministicKeypair(seed: string): AgentKeypair {
60
+ const hash = sha512(new TextEncoder().encode(seed + ':orchestrator-salt'))
61
+ const privateKey = hash.slice(0, 32)
62
+ const publicKey = ed.getPublicKey(privateKey)
63
+ return { privateKey, publicKeyB64: toBase64url(publicKey) }
64
+ }
65
+
66
+ /** Sign a base64url challenge with the agent's private key; returns base64url signature. */
67
+ export function signChallenge(challengeB64: string, privateKey: Uint8Array): string {
68
+ const challenge = fromBase64url(challengeB64)
69
+ const sig = ed.sign(challenge, privateKey)
70
+ return toBase64url(sig)
71
+ }
package/src/define.ts ADDED
@@ -0,0 +1,147 @@
1
+ /**
2
+ * Script definition helpers — typed factory functions for building scenes.
3
+ */
4
+ import type {
5
+ Scene,
6
+ ActorDef,
7
+ ConnectAction,
8
+ DisconnectAction,
9
+ NavigateAction,
10
+ TypeAction,
11
+ TypeDeleteAction,
12
+ SelectAction,
13
+ MoveCursorAction,
14
+ SetStatusAction,
15
+ SetAwarenessAction,
16
+ ClearAwarenessAction,
17
+ CreateDocumentAction,
18
+ MoveDocumentAction,
19
+ RenameDocumentAction,
20
+ WriteContentAction,
21
+ DeleteContentAction,
22
+ SetMetaAction,
23
+ PointerMoveAction,
24
+ ScrollToAction,
25
+ KanbanHoverAction,
26
+ KanbanDragAction,
27
+ SendChatAction,
28
+ WaitAction,
29
+ ParallelAction,
30
+ SequenceAction,
31
+ RepeatAction,
32
+ TimelineEntry,
33
+ EasingName,
34
+ } from './types.ts'
35
+
36
+ /** Define a complete scene. */
37
+ export function defineScene(scene: Scene): Scene {
38
+ return scene
39
+ }
40
+
41
+ /** Define an actor. */
42
+ export function actor(name: string, opts: Omit<ActorDef, 'name'>): ActorDef {
43
+ return { name, ...opts }
44
+ }
45
+
46
+ /** Factory functions for all action types. */
47
+ export const actions = {
48
+ connect(): ConnectAction {
49
+ return { type: 'connect' }
50
+ },
51
+
52
+ disconnect(): DisconnectAction {
53
+ return { type: 'disconnect' }
54
+ },
55
+
56
+ navigate(docId: string): NavigateAction {
57
+ return { type: 'navigate', docId }
58
+ },
59
+
60
+ type(docId: string, text: string, opts?: { speed?: number; variance?: number; position?: number }): TypeAction {
61
+ return { type: 'type', docId, text, ...opts }
62
+ },
63
+
64
+ typeDelete(docId: string, count: number, opts?: { speed?: number; variance?: number; position?: number }): TypeDeleteAction {
65
+ return { type: 'typeDelete', docId, count, ...opts }
66
+ },
67
+
68
+ select(docId: string, anchor: number, head: number): SelectAction {
69
+ return { type: 'select', docId, anchor, head }
70
+ },
71
+
72
+ moveCursor(docId: string, from: number, to: number, duration: number, easing?: EasingName): MoveCursorAction {
73
+ return { type: 'moveCursor', docId, from, to, duration, easing }
74
+ },
75
+
76
+ setStatus(status: string | null): SetStatusAction {
77
+ return { type: 'setStatus', status }
78
+ },
79
+
80
+ setAwareness(fields: Record<string, unknown>, docId?: string): SetAwarenessAction {
81
+ return { type: 'setAwareness', docId, fields }
82
+ },
83
+
84
+ clearAwareness(fields: string[], docId?: string): ClearAwarenessAction {
85
+ return { type: 'clearAwareness', docId, fields }
86
+ },
87
+
88
+ createDocument(parentId: string, label: string, opts?: { docType?: string; meta?: Record<string, unknown>; assignId?: string }): CreateDocumentAction {
89
+ return { type: 'createDocument', parentId, label, ...opts }
90
+ },
91
+
92
+ moveDocument(docId: string, newParentId: string, order?: number): MoveDocumentAction {
93
+ return { type: 'moveDocument', docId, newParentId, order }
94
+ },
95
+
96
+ renameDocument(docId: string, label: string): RenameDocumentAction {
97
+ return { type: 'renameDocument', docId, label }
98
+ },
99
+
100
+ writeContent(docId: string, markdown: string): WriteContentAction {
101
+ return { type: 'writeContent', docId, markdown }
102
+ },
103
+
104
+ deleteContent(docId: string, from: number, length: number): DeleteContentAction {
105
+ return { type: 'deleteContent', docId, from, length }
106
+ },
107
+
108
+ setMeta(docId: string, meta: Record<string, unknown>): SetMetaAction {
109
+ return { type: 'setMeta', docId, meta }
110
+ },
111
+
112
+ pointerMove(docId: string, from: { x: number; y: number }, to: { x: number; y: number }, duration: number, easing?: EasingName): PointerMoveAction {
113
+ return { type: 'pointerMove', docId, from, to, duration, easing }
114
+ },
115
+
116
+ scrollTo(docId: string, position: number): ScrollToAction {
117
+ return { type: 'scrollTo', docId, position }
118
+ },
119
+
120
+ kanbanHover(docId: string, cardId: string | null): KanbanHoverAction {
121
+ return { type: 'kanbanHover', docId, cardId }
122
+ },
123
+
124
+ kanbanDrag(docId: string, cardId: string, toColumnId: string, duration: number): KanbanDragAction {
125
+ return { type: 'kanbanDrag', docId, cardId, toColumnId, duration }
126
+ },
127
+
128
+ sendChat(channel: string, message: string): SendChatAction {
129
+ return { type: 'sendChat', channel, message }
130
+ },
131
+
132
+ wait(duration: number): WaitAction {
133
+ return { type: 'wait', duration }
134
+ },
135
+
136
+ parallel(entries: TimelineEntry[]): ParallelAction {
137
+ return { type: 'parallel', actions: entries }
138
+ },
139
+
140
+ sequence(entries: TimelineEntry[]): SequenceAction {
141
+ return { type: 'sequence', actions: entries }
142
+ },
143
+
144
+ repeat(times: number, entries: TimelineEntry[]): RepeatAction {
145
+ return { type: 'repeat', times, actions: entries }
146
+ },
147
+ }
package/src/easing.ts ADDED
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Easing functions for animated actions (cursor movement, pointer, etc.)
3
+ */
4
+ import type { EasingName, EasingFn } from './types.ts'
5
+
6
+ export const easings: Record<EasingName, EasingFn> = {
7
+ linear: (t) => t,
8
+ easeIn: (t) => t * t,
9
+ easeOut: (t) => t * (2 - t),
10
+ easeInOut: (t) => t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t,
11
+ }
12
+
13
+ export function getEasing(name?: EasingName): EasingFn {
14
+ return easings[name ?? 'easeInOut']
15
+ }
package/src/index.ts ADDED
@@ -0,0 +1,105 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Abracadabra Orchestrator — CouShell commercial director.
4
+ *
5
+ * Usage (after pnpm build:packages):
6
+ * node --experimental-transform-types \
7
+ * packages/orchestrator/dist/abracadabra-orchestrator.esm.js ./scripts/demo.ts
8
+ *
9
+ * Dry run:
10
+ * node --experimental-transform-types \
11
+ * packages/orchestrator/dist/abracadabra-orchestrator.esm.js --dry-run ./scripts/demo.ts
12
+ */
13
+ import { Orchestrator } from './orchestrator.ts'
14
+
15
+ // Re-export public API for script files
16
+ export { defineScene, actor, actions } from './define.ts'
17
+ export { Orchestrator } from './orchestrator.ts'
18
+ export type {
19
+ Scene,
20
+ ActorDef,
21
+ ServerConfig,
22
+ Action,
23
+ TimelineEntry,
24
+ ConnectAction,
25
+ DisconnectAction,
26
+ NavigateAction,
27
+ TypeAction,
28
+ TypeDeleteAction,
29
+ SelectAction,
30
+ MoveCursorAction,
31
+ SetStatusAction,
32
+ SetAwarenessAction,
33
+ ClearAwarenessAction,
34
+ CreateDocumentAction,
35
+ MoveDocumentAction,
36
+ RenameDocumentAction,
37
+ WriteContentAction,
38
+ DeleteContentAction,
39
+ SetMetaAction,
40
+ PointerMoveAction,
41
+ ScrollToAction,
42
+ KanbanHoverAction,
43
+ KanbanDragAction,
44
+ SendChatAction,
45
+ RepeatAction,
46
+ WaitAction,
47
+ ParallelAction,
48
+ SequenceAction,
49
+ EasingName,
50
+ } from './types.ts'
51
+
52
+ async function main() {
53
+ const args = process.argv.slice(2)
54
+ const dryRun = args.includes('--dry-run')
55
+ const scriptPath = args.find(a => !a.startsWith('--'))
56
+
57
+ if (!scriptPath) {
58
+ console.error('Usage: abracadabra-orchestrator [--dry-run] <script.ts>')
59
+ console.error('')
60
+ console.error(' The script file should export a Scene object (use defineScene()).')
61
+ console.error('')
62
+ console.error('Options:')
63
+ console.error(' --dry-run Validate the scene without connecting to the server')
64
+ console.error('')
65
+ console.error('Example:')
66
+ console.error(' node --conditions=source --experimental-transform-types \\')
67
+ console.error(' packages/orchestrator/src/index.ts ./scripts/demo.ts')
68
+ process.exit(1)
69
+ }
70
+
71
+ const orchestrator = new Orchestrator()
72
+
73
+ try {
74
+ await orchestrator.load(scriptPath)
75
+ orchestrator.prepare()
76
+
77
+ if (dryRun) {
78
+ orchestrator.dryRun()
79
+ } else {
80
+ await orchestrator.run()
81
+ }
82
+ } catch (err: any) {
83
+ console.error(`[orchestrator] Fatal: ${err.message}`)
84
+ process.exit(1)
85
+ } finally {
86
+ if (!dryRun) {
87
+ await orchestrator.cleanup()
88
+ }
89
+ }
90
+ }
91
+
92
+ // Only run CLI when executed directly (not imported as a library)
93
+ // Check if argv[1] points to a file within this package's directory
94
+ import { fileURLToPath } from 'node:url'
95
+ import { dirname, resolve } from 'node:path'
96
+
97
+ const __filename = fileURLToPath(import.meta.url)
98
+ const __dirname = dirname(__filename)
99
+
100
+ if (process.argv[1]) {
101
+ const resolved = resolve(process.argv[1])
102
+ if (resolved.startsWith(__dirname) || resolved.includes('abracadabra-orchestrator')) {
103
+ main()
104
+ }
105
+ }
@@ -0,0 +1,153 @@
1
+ /**
2
+ * Orchestrator — top-level class that loads a scene, manages actors, and runs the timeline.
3
+ */
4
+ import { resolve } from 'node:path'
5
+ import { pathToFileURL } from 'node:url'
6
+ import type { Scene } from './types.ts'
7
+ import { ActorConnection } from './actor-connection.ts'
8
+ import { TimelineRunner, type ErrorStrategy } from './timeline-runner.ts'
9
+ import { createExecutor } from './actions/index.ts'
10
+ import { log } from './utils.ts'
11
+
12
+ export class Orchestrator {
13
+ private scene: Scene | null = null
14
+ private actors = new Map<string, ActorConnection>()
15
+ private vars = new Map<string, string>()
16
+
17
+ /** Load a scene from a TypeScript file. */
18
+ async load(scriptPath: string): Promise<void> {
19
+ const absPath = resolve(scriptPath)
20
+ const fileUrl = pathToFileURL(absPath).href
21
+ const mod = await import(fileUrl)
22
+ const scene: Scene = mod.default ?? mod
23
+
24
+ if (!scene.server?.url) {
25
+ throw new Error('Scene must have server.url')
26
+ }
27
+ if (!scene.actors?.length) {
28
+ throw new Error('Scene must have at least one actor')
29
+ }
30
+ if (!scene.timeline?.length) {
31
+ throw new Error('Scene must have at least one timeline entry')
32
+ }
33
+
34
+ this.scene = scene
35
+ log(`Loaded scene: ${scene.actors.length} actors, ${scene.timeline.length} timeline entries`)
36
+ }
37
+
38
+ /** Prepare actor connections (does not connect yet — that's a timeline action). */
39
+ prepare(): void {
40
+ if (!this.scene) throw new Error('No scene loaded')
41
+
42
+ if (this.scene.vars) {
43
+ for (const [k, v] of Object.entries(this.scene.vars)) {
44
+ this.vars.set(k, v)
45
+ }
46
+ }
47
+
48
+ for (const actorDef of this.scene.actors) {
49
+ if (this.actors.has(actorDef.name)) {
50
+ throw new Error(`Duplicate actor name: ${actorDef.name}`)
51
+ }
52
+ const conn = new ActorConnection(actorDef, this.scene.server)
53
+ this.actors.set(actorDef.name, conn)
54
+ }
55
+
56
+ log(`Prepared ${this.actors.size} actors: ${[...this.actors.keys()].join(', ')}`)
57
+ }
58
+
59
+ /** Validate the scene without connecting. Logs the timeline structure. */
60
+ dryRun(): void {
61
+ if (!this.scene) throw new Error('No scene loaded')
62
+
63
+ const actorNames = new Set(this.scene.actors.map(a => a.name))
64
+ const errors: string[] = []
65
+
66
+ for (let i = 0; i < this.scene.timeline.length; i++) {
67
+ const entry = this.scene.timeline[i]!
68
+ const prefix = `timeline[${i}]`
69
+
70
+ if (entry.actor && !actorNames.has(entry.actor)) {
71
+ errors.push(`${prefix}: unknown actor "${entry.actor}"`)
72
+ }
73
+
74
+ const needsActor = !['wait', 'parallel', 'sequence', 'repeat'].includes(entry.action.type)
75
+ if (needsActor && !entry.actor) {
76
+ errors.push(`${prefix}: ${entry.action.type} requires an actor`)
77
+ }
78
+ }
79
+
80
+ if (errors.length) {
81
+ log(`Dry run found ${errors.length} issue(s):`)
82
+ for (const e of errors) log(` - ${e}`)
83
+ } else {
84
+ log('Dry run: no issues found')
85
+ }
86
+
87
+ // Print timeline summary
88
+ const sorted = [...this.scene.timeline].sort((a, b) => (a.at ?? 0) - (b.at ?? 0))
89
+ log('Timeline:')
90
+ for (const entry of sorted) {
91
+ const ts = `${((entry.at ?? 0) / 1000).toFixed(1)}s`
92
+ const actorLabel = entry.actor ?? '—'
93
+ log(` [${ts}] ${actorLabel}: ${entry.action.type}`)
94
+ }
95
+
96
+ if (this.scene.duration) {
97
+ log(`Scene duration: ${(this.scene.duration / 1000).toFixed(1)}s`)
98
+ }
99
+ }
100
+
101
+ /** Run the timeline. */
102
+ async run(): Promise<void> {
103
+ if (!this.scene) throw new Error('No scene loaded')
104
+
105
+ // Run onStart hook
106
+ if (this.scene.onStart) {
107
+ log('Running onStart hook...')
108
+ await this.scene.onStart()
109
+ }
110
+
111
+ const executor = createExecutor({
112
+ actors: this.actors,
113
+ serverConfig: this.scene.server,
114
+ vars: this.vars,
115
+ })
116
+
117
+ const runner = new TimelineRunner()
118
+ log('Starting timeline...')
119
+ const startTime = Date.now()
120
+
121
+ if (this.scene.duration) {
122
+ // Race timeline against duration timeout
123
+ const timelinePromise = runner.run(this.scene.timeline, executor)
124
+ const timeoutPromise = new Promise<void>(resolve => {
125
+ setTimeout(() => {
126
+ log(`Scene duration reached (${this.scene!.duration}ms), stopping...`)
127
+ resolve()
128
+ }, this.scene.duration)
129
+ })
130
+ await Promise.race([timelinePromise, timeoutPromise])
131
+ } else {
132
+ await runner.run(this.scene.timeline, executor)
133
+ }
134
+
135
+ const elapsed = ((Date.now() - startTime) / 1000).toFixed(1)
136
+ log(`Timeline complete in ${elapsed}s`)
137
+
138
+ // Run onEnd hook
139
+ if (this.scene.onEnd) {
140
+ log('Running onEnd hook...')
141
+ await this.scene.onEnd()
142
+ }
143
+ }
144
+
145
+ /** Disconnect all actors gracefully. */
146
+ async cleanup(): Promise<void> {
147
+ const disconnects = [...this.actors.values()].map(a => a.disconnect().catch(() => {}))
148
+ await Promise.all(disconnects)
149
+ this.actors.clear()
150
+ this.vars.clear()
151
+ log('All actors disconnected')
152
+ }
153
+ }