@zooid/transport-matrix 0.8.0 → 0.9.1
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/dist/index.d.ts +84 -1
- package/dist/index.js +366 -194
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
- package/src/bot-pool.test.ts +7 -1
- package/src/bot-pool.ts +8 -2
- package/src/context-provider.ts +1 -1
- package/src/event-encoders.test.ts +22 -0
- package/src/event-encoders.ts +13 -0
- package/src/identity.test.ts +52 -0
- package/src/identity.ts +27 -0
- package/src/index.ts +11 -0
- package/src/matrix-client.test.ts +58 -2
- package/src/matrix-client.ts +51 -0
- package/src/registration.test.ts +20 -0
- package/src/sync-loop.test.ts +113 -0
- package/src/sync-loop.ts +74 -0
- package/src/transport.test.ts +161 -32
- package/src/transport.ts +423 -280
- package/src/workforce-publisher.test.ts +2 -2
- package/src/workforce-publisher.ts +1 -1
package/src/transport.ts
CHANGED
|
@@ -6,7 +6,7 @@ import { MatrixClient } from './matrix-client.js'
|
|
|
6
6
|
import { BotPool } from './bot-pool.js'
|
|
7
7
|
import { route, isMediaMsgtype, type AgentBinding, type ThreadState } from './router.js'
|
|
8
8
|
import { stripMention, extractMentions } from './mentions.js'
|
|
9
|
-
import { toToolCallBody, toUpdateBody, toPlanBody, toErrorBody } from './event-encoders.js'
|
|
9
|
+
import { toToolCallBody, toUpdateBody, toPlanBody, toAvailableCommandsBody, toErrorBody } from './event-encoders.js'
|
|
10
10
|
import { classify } from '@zooid/acp-client'
|
|
11
11
|
import { toMatrixHtml } from './markdown-to-matrix-html.js'
|
|
12
12
|
import {
|
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
INLINE_IMAGE_MIMES,
|
|
20
20
|
} from './media-client.js'
|
|
21
21
|
import { writeAttachment } from './attachments.js'
|
|
22
|
+
import { SyncLoop } from './sync-loop.js'
|
|
22
23
|
|
|
23
24
|
export interface MediaClientLike {
|
|
24
25
|
download(input: {
|
|
@@ -52,6 +53,20 @@ export interface CreateMatrixTransportOptions {
|
|
|
52
53
|
media?: MediaClientLike
|
|
53
54
|
/** Injected attachment writer (defaults to the real writeAttachment). */
|
|
54
55
|
writeAttachmentFn?: typeof writeAttachment
|
|
56
|
+
/** AS sender-bot MXID (@<sender_localpart>:<server>). Together with the agent
|
|
57
|
+
* bindings this forms the set of "our bot users" whose ad-hoc invites are
|
|
58
|
+
* declined. */
|
|
59
|
+
botUserId?: string
|
|
60
|
+
/**
|
|
61
|
+
* Transport ingestion mode.
|
|
62
|
+
* - `'appservice'` (default): Tuwunel pushes events to the HTTP transaction endpoint.
|
|
63
|
+
* - `'client'`: daemon polls via impersonated `/sync` per agent (pull mode).
|
|
64
|
+
*/
|
|
65
|
+
mode?: 'appservice' | 'client'
|
|
66
|
+
/** Pull mode: load the persisted `since` cursor for an agent user ID. */
|
|
67
|
+
loadSince?: (agentUserId: string) => string | null
|
|
68
|
+
/** Pull mode: persist the `since` cursor after each sync poll. */
|
|
69
|
+
saveSince?: (agentUserId: string, since: string) => void
|
|
55
70
|
}
|
|
56
71
|
|
|
57
72
|
interface SessionContext {
|
|
@@ -67,9 +82,12 @@ interface MatrixEvent {
|
|
|
67
82
|
origin_server_ts?: number
|
|
68
83
|
room_id?: string
|
|
69
84
|
sender?: string
|
|
85
|
+
/** Present on state events (m.room.member → the affected user). */
|
|
86
|
+
state_key?: string
|
|
70
87
|
content?: Record<string, unknown> & {
|
|
71
88
|
msgtype?: string
|
|
72
89
|
body?: string
|
|
90
|
+
membership?: string
|
|
73
91
|
'm.relates_to'?: { rel_type?: string; event_id?: string }
|
|
74
92
|
}
|
|
75
93
|
}
|
|
@@ -173,7 +191,7 @@ async function sendMediaError(
|
|
|
173
191
|
.sendCustomEvent({
|
|
174
192
|
roomId: ctx.roomId,
|
|
175
193
|
asUserId: ctx.agent.userId,
|
|
176
|
-
eventType: '
|
|
194
|
+
eventType: 'dev.zooid.error',
|
|
177
195
|
content: toErrorBody(
|
|
178
196
|
{
|
|
179
197
|
kind: 'error' as const,
|
|
@@ -187,7 +205,7 @@ async function sendMediaError(
|
|
|
187
205
|
ctx.threadRoot,
|
|
188
206
|
),
|
|
189
207
|
})
|
|
190
|
-
.catch((e) => console.warn(`[matrix:${ctx.agent.name}]
|
|
208
|
+
.catch((e) => console.warn(`[matrix:${ctx.agent.name}] dev.zooid.error send failed:`, e))
|
|
191
209
|
}
|
|
192
210
|
const SEEN_EVENT_CAP = 5_000
|
|
193
211
|
|
|
@@ -216,13 +234,20 @@ function inboundThreadRoot(evt: MatrixEvent): string | undefined {
|
|
|
216
234
|
}
|
|
217
235
|
|
|
218
236
|
export function createMatrixTransport(opts: CreateMatrixTransportOptions) {
|
|
219
|
-
const { agents, approvals, client, bindings, hsToken, adminUserId } = opts
|
|
237
|
+
const { agents, approvals, client, bindings, hsToken, adminUserId, botUserId, mode = 'appservice' } = opts
|
|
220
238
|
const drainQuietMs = opts.drainQuietMs ?? DRAIN_QUIET_MS
|
|
221
239
|
const drainMaxMs = opts.drainMaxMs ?? DRAIN_MAX_MS
|
|
222
240
|
const mediaClient = opts.media
|
|
223
241
|
const writeAttachmentFn = opts.writeAttachmentFn ?? writeAttachment
|
|
224
242
|
const pendingMedia = new PendingMediaStore()
|
|
225
243
|
const pool = new BotPool(client, bindings)
|
|
244
|
+
const ourBotUserIds = new Set<string>([
|
|
245
|
+
...(botUserId ? [botUserId] : []),
|
|
246
|
+
...bindings.map((b) => b.userId),
|
|
247
|
+
])
|
|
248
|
+
const DECLINE_REASON =
|
|
249
|
+
'Bots are placed in rooms only by the zooid daemon (workforce-as-code). ' +
|
|
250
|
+
'Ad-hoc invites are declined — add the bot to the room in zooid.yaml.'
|
|
226
251
|
const sessions = new Map<string, SessionContext>()
|
|
227
252
|
const buffers = new Map<string, string>()
|
|
228
253
|
// Last messageId seen per session's buffer. opencode streams each assistant
|
|
@@ -234,44 +259,123 @@ export function createMatrixTransport(opts: CreateMatrixTransportOptions) {
|
|
|
234
259
|
const sendQueue = new Map<string, Promise<void>>()
|
|
235
260
|
// Thread participation index: keyed by thread root event_id.
|
|
236
261
|
const threadStates = new Map<string, ThreadState>()
|
|
237
|
-
// Drop events older than this —
|
|
238
|
-
// daemon was offline, and we don't want yesterday's
|
|
239
|
-
|
|
262
|
+
// Drop events older than this — in push (appservice) mode Tuwunel may replay
|
|
263
|
+
// a backlog after the daemon was offline, and we don't want yesterday's
|
|
264
|
+
// "@docs hi" to fire now. In pull (client) mode the persisted `since` cursor
|
|
265
|
+
// is the authoritative replay boundary (process everything after it — that's
|
|
266
|
+
// exactly the offline-resume feature), so the timestamp guard must NOT apply:
|
|
267
|
+
// the missed-while-offline mention is older than startup by design.
|
|
268
|
+
const cutoffTs = mode === 'client' ? Number.NEGATIVE_INFINITY : Date.now() - STARTUP_GRACE_MS
|
|
240
269
|
// Idempotency: appservice transactions are retried on 4xx/5xx/timeout, and
|
|
241
270
|
// the same event_id can arrive twice. Skip ones we've already taken.
|
|
242
271
|
const seenEventIds = new Set<string>()
|
|
272
|
+
// Messages flushed per session this turn. Lets the drain loop tell "stream
|
|
273
|
+
// not started yet" (0 flushes, empty buffer → keep waiting) from "turn done,
|
|
274
|
+
// last message already flushed mid-stream" (>0 flushes, empty buffer → stop).
|
|
275
|
+
const flushedCounts = new Map<string, number>()
|
|
276
|
+
// Commands a shim advertises during session load/new — i.e. before runTurn
|
|
277
|
+
// registers the session ctx (sessions.set). Stashed here keyed by sessionId
|
|
278
|
+
// and replayed once the ctx exists, so `available_commands_update` (which is
|
|
279
|
+
// only ever emitted at session establishment, never mid-turn) isn't dropped.
|
|
280
|
+
const pendingCommands = new Map<string, AgentEvent>()
|
|
281
|
+
|
|
282
|
+
// Build the m.text content for a chunk of assistant prose, attaching a
|
|
283
|
+
// formatted_body only when the HTML render adds rich text the plain body
|
|
284
|
+
// can't carry (marked wraps plain prose in <p>…</p>; skip that — most
|
|
285
|
+
// clients render `body` better than a stripped re-encode).
|
|
286
|
+
const buildTextContent = (
|
|
287
|
+
text: string,
|
|
288
|
+
): { msgtype: string; body: string; [k: string]: unknown } => {
|
|
289
|
+
const content: { msgtype: string; body: string; [k: string]: unknown } = {
|
|
290
|
+
msgtype: 'm.text',
|
|
291
|
+
body: text,
|
|
292
|
+
}
|
|
293
|
+
const html = toMatrixHtml(text)
|
|
294
|
+
if (html) {
|
|
295
|
+
const escapedPlain =
|
|
296
|
+
'<p>' +
|
|
297
|
+
text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>') +
|
|
298
|
+
'</p>'
|
|
299
|
+
const norm = (s: string) => s.replace(/\s+/g, ' ').trim()
|
|
300
|
+
if (norm(html) !== norm(escapedPlain)) {
|
|
301
|
+
content.format = 'org.matrix.custom.html'
|
|
302
|
+
content.formatted_body = html
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
return content
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// Flush a session's buffered assistant text as its own Matrix message and
|
|
309
|
+
// clear the buffer. No-op on an empty buffer. The send is chained onto
|
|
310
|
+
// sendQueue so it orders correctly against tool_call/plan events from the
|
|
311
|
+
// same turn. The buffer is cleared synchronously (before the first await),
|
|
312
|
+
// so a chunk for the *next* message that arrives during the send starts
|
|
313
|
+
// fresh. Returns true when a message was enqueued.
|
|
314
|
+
const flushBuffer = (sessionId: string): boolean => {
|
|
315
|
+
const ctx = sessions.get(sessionId)
|
|
316
|
+
const text = buffers.get(sessionId) ?? ''
|
|
317
|
+
if (!ctx || text.length === 0) return false
|
|
318
|
+
buffers.set(sessionId, '')
|
|
319
|
+
flushedCounts.set(sessionId, (flushedCounts.get(sessionId) ?? 0) + 1)
|
|
320
|
+
const content = buildTextContent(text)
|
|
321
|
+
const tail = (sendQueue.get(sessionId) ?? Promise.resolve()).then(async () => {
|
|
322
|
+
try {
|
|
323
|
+
await client.sendMessage({
|
|
324
|
+
roomId: ctx.roomId,
|
|
325
|
+
asUserId: ctx.agent.userId,
|
|
326
|
+
content,
|
|
327
|
+
threadRoot: ctx.threadRoot,
|
|
328
|
+
})
|
|
329
|
+
} catch (err) {
|
|
330
|
+
console.warn(`[matrix:${ctx.agent.name}] sendMessage flush failed:`, err)
|
|
331
|
+
}
|
|
332
|
+
})
|
|
333
|
+
sendQueue.set(sessionId, tail)
|
|
334
|
+
return true
|
|
335
|
+
}
|
|
243
336
|
|
|
244
337
|
agents.onEvent = async (name, event: AgentEvent) => {
|
|
245
338
|
const ctx = sessions.get(event.sessionId)
|
|
246
339
|
if (!ctx) {
|
|
247
|
-
|
|
340
|
+
// available_commands_update is advertised during ensureSession (session
|
|
341
|
+
// load/new), before runTurn calls sessions.set — so the ctx isn't there
|
|
342
|
+
// yet. Stash the latest roster and replay it once runTurn registers the
|
|
343
|
+
// ctx. Other event types arriving without a ctx are genuinely orphaned
|
|
344
|
+
// (e.g. replayed history for a thread we're not handling) — drop them.
|
|
345
|
+
if (event.type === 'available_commands') {
|
|
346
|
+
pendingCommands.set(event.sessionId, event)
|
|
347
|
+
} else {
|
|
348
|
+
console.warn(`[matrix:${name}] no session ctx for ${event.sessionId}`)
|
|
349
|
+
}
|
|
248
350
|
return
|
|
249
351
|
}
|
|
250
352
|
|
|
251
353
|
if (event.type === 'agent_message_chunk') {
|
|
252
354
|
const block = event.content as { type?: string; text?: string; data?: string; mimeType?: string }
|
|
253
355
|
if (block.type === 'text' && typeof block.text === 'string') {
|
|
254
|
-
|
|
255
|
-
//
|
|
256
|
-
//
|
|
257
|
-
//
|
|
258
|
-
//
|
|
259
|
-
//
|
|
260
|
-
// - a change in messageId — opencode streams each assistant message
|
|
261
|
-
// under its own id and emits no delimiter chunk between them, and the
|
|
262
|
-
// first token of the new message has no leading space, so without
|
|
263
|
-
// this they weld together ("…one.🅿️").
|
|
356
|
+
// A change in ACP messageId marks the previous assistant message as
|
|
357
|
+
// complete. opencode streams each assistant message under its own id
|
|
358
|
+
// with no delimiter chunk between them, so a change here is the only
|
|
359
|
+
// boundary signal. Flush the previous message as its own Matrix
|
|
360
|
+
// message — each ACP message lands separately (and interleaves with
|
|
361
|
+
// tool_call/plan events) instead of welding into one turn-end blob.
|
|
264
362
|
const prevMessageId = bufferMessageIds.get(event.sessionId)
|
|
265
363
|
const messageChanged =
|
|
266
364
|
event.messageId !== undefined &&
|
|
267
365
|
prevMessageId !== undefined &&
|
|
268
366
|
event.messageId !== prevMessageId
|
|
269
|
-
const needsBreak =
|
|
270
|
-
current.length > 0 && (block.text === '' || messageChanged)
|
|
271
|
-
const prefix = needsBreak ? '\n\n' : ''
|
|
272
|
-
buffers.set(event.sessionId, current + prefix + block.text)
|
|
273
367
|
if (event.messageId !== undefined)
|
|
274
368
|
bufferMessageIds.set(event.sessionId, event.messageId)
|
|
369
|
+
// flushBuffer clears the buffer synchronously, so the new message's
|
|
370
|
+
// text below starts fresh.
|
|
371
|
+
if (messageChanged) flushBuffer(event.sessionId)
|
|
372
|
+
// Within a single message, tokens carry their own leading spaces, so we
|
|
373
|
+
// concatenate raw. An empty chunk (some agents emit one between blocks,
|
|
374
|
+
// e.g. after a tool call within the same message) is a paragraph break.
|
|
375
|
+
const current = buffers.get(event.sessionId) ?? ''
|
|
376
|
+
const needsBreak = current.length > 0 && block.text === ''
|
|
377
|
+
const prefix = needsBreak ? '\n\n' : ''
|
|
378
|
+
buffers.set(event.sessionId, current + prefix + block.text)
|
|
275
379
|
} else if (
|
|
276
380
|
block.type === 'image' &&
|
|
277
381
|
typeof block.data === 'string' &&
|
|
@@ -310,18 +414,27 @@ export function createMatrixTransport(opts: CreateMatrixTransportOptions) {
|
|
|
310
414
|
return
|
|
311
415
|
}
|
|
312
416
|
|
|
417
|
+
// An out-of-band event (tool_call / tool_call_update / plan) after some
|
|
418
|
+
// buffered text means that assistant message is complete — flush it first
|
|
419
|
+
// so it lands before this event on the wire, preserving interleaving.
|
|
420
|
+
flushBuffer(event.sessionId)
|
|
421
|
+
|
|
313
422
|
const eventType =
|
|
314
423
|
event.type === 'tool_call'
|
|
315
|
-
? '
|
|
424
|
+
? 'dev.zooid.tool_call'
|
|
316
425
|
: event.type === 'tool_call_update'
|
|
317
|
-
? '
|
|
318
|
-
:
|
|
426
|
+
? 'dev.zooid.tool_call_update'
|
|
427
|
+
: event.type === 'available_commands'
|
|
428
|
+
? 'dev.zooid.available_commands_update'
|
|
429
|
+
: 'dev.zooid.plan'
|
|
319
430
|
const body =
|
|
320
431
|
event.type === 'tool_call'
|
|
321
432
|
? toToolCallBody(event)
|
|
322
433
|
: event.type === 'tool_call_update'
|
|
323
434
|
? toUpdateBody(event)
|
|
324
|
-
:
|
|
435
|
+
: event.type === 'available_commands'
|
|
436
|
+
? toAvailableCommandsBody(event)
|
|
437
|
+
: toPlanBody(event)
|
|
325
438
|
body['m.relates_to'] = { rel_type: 'm.thread', event_id: ctx.threadRoot }
|
|
326
439
|
const tail = (sendQueue.get(event.sessionId) ?? Promise.resolve()).then(async () => {
|
|
327
440
|
try {
|
|
@@ -362,247 +475,272 @@ export function createMatrixTransport(opts: CreateMatrixTransportOptions) {
|
|
|
362
475
|
void client.sendCustomEvent({
|
|
363
476
|
roomId: ctx.roomId,
|
|
364
477
|
asUserId: ctx.agent.userId,
|
|
365
|
-
eventType: '
|
|
478
|
+
eventType: 'dev.zooid.approval_request',
|
|
366
479
|
content,
|
|
367
480
|
})
|
|
368
481
|
})
|
|
369
482
|
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
if (!h.startsWith('Bearer ')) return false
|
|
375
|
-
const got = h.slice(7)
|
|
376
|
-
if (got.length !== hsToken.length) return false
|
|
377
|
-
return timingSafeEqual(Buffer.from(got), Buffer.from(hsToken))
|
|
378
|
-
}
|
|
379
|
-
|
|
380
|
-
app.put('/_matrix/app/v1/transactions/:txnId', async (c) => {
|
|
381
|
-
if (!authOk(c.req.header('authorization'))) {
|
|
382
|
-
return c.json({ errcode: 'M_FORBIDDEN' }, 403)
|
|
383
|
-
}
|
|
384
|
-
const body = (await c.req.json().catch(() => ({}))) as { events?: MatrixEvent[] }
|
|
385
|
-
for (const evt of body.events ?? []) {
|
|
386
|
-
if (evt.event_id) {
|
|
387
|
-
if (seenEventIds.has(evt.event_id)) {
|
|
388
|
-
continue
|
|
389
|
-
}
|
|
390
|
-
seenEventIds.add(evt.event_id)
|
|
391
|
-
if (seenEventIds.size > SEEN_EVENT_CAP) {
|
|
392
|
-
const first = seenEventIds.values().next().value
|
|
393
|
-
if (first !== undefined) seenEventIds.delete(first)
|
|
394
|
-
}
|
|
483
|
+
async function handleInboundEvent(evt: MatrixEvent): Promise<void> {
|
|
484
|
+
if (evt.event_id) {
|
|
485
|
+
if (seenEventIds.has(evt.event_id)) {
|
|
486
|
+
return
|
|
395
487
|
}
|
|
488
|
+
seenEventIds.add(evt.event_id)
|
|
489
|
+
if (seenEventIds.size > SEEN_EVENT_CAP) {
|
|
490
|
+
const first = seenEventIds.values().next().value
|
|
491
|
+
if (first !== undefined) seenEventIds.delete(first)
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
if (
|
|
495
|
+
evt.origin_server_ts !== undefined &&
|
|
496
|
+
evt.origin_server_ts < cutoffTs &&
|
|
497
|
+
evt.type === 'm.room.message'
|
|
498
|
+
) {
|
|
499
|
+
console.log(
|
|
500
|
+
`[matrix] dropping stale message event ${evt.event_id} ` +
|
|
501
|
+
`(ts=${evt.origin_server_ts}, daemon started at ${cutoffTs + STARTUP_GRACE_MS})`,
|
|
502
|
+
)
|
|
503
|
+
return
|
|
504
|
+
}
|
|
505
|
+
if (evt.type === 'm.room.member' && evt.content?.membership === 'invite') {
|
|
506
|
+
const target = evt.state_key
|
|
507
|
+
const inviter = evt.sender
|
|
396
508
|
if (
|
|
397
|
-
|
|
398
|
-
evt.
|
|
399
|
-
|
|
509
|
+
target &&
|
|
510
|
+
evt.room_id &&
|
|
511
|
+
ourBotUserIds.has(target) &&
|
|
512
|
+
(!inviter || !ourBotUserIds.has(inviter))
|
|
400
513
|
) {
|
|
401
514
|
console.log(
|
|
402
|
-
`[matrix]
|
|
403
|
-
`
|
|
515
|
+
`[matrix] declining ad-hoc invite for ${target} in ${evt.room_id} ` +
|
|
516
|
+
`from ${inviter ?? 'unknown'}`,
|
|
404
517
|
)
|
|
405
|
-
|
|
518
|
+
await client
|
|
519
|
+
.leaveRoom(evt.room_id, target, { reason: DECLINE_REASON })
|
|
520
|
+
.catch((err) =>
|
|
521
|
+
console.warn(`[matrix] leaveRoom(${evt.room_id}, ${target}) failed:`, err),
|
|
522
|
+
)
|
|
406
523
|
}
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
for (const a of bindings) {
|
|
421
|
-
agents.endSession(a.name, threadRoot)
|
|
422
|
-
}
|
|
423
|
-
// NB: keep threadStates intact. Per ZOD039 § /clear, only the agent's
|
|
424
|
-
// session memory is wiped — thread-routing state (participants /
|
|
425
|
-
// root-mentions) must survive so the next bare reply still routes to
|
|
426
|
-
// the most-recently-posting agent under the same sessionKey.
|
|
427
|
-
continue
|
|
524
|
+
return
|
|
525
|
+
}
|
|
526
|
+
if (evt.type === 'dev.zooid.session_reset') {
|
|
527
|
+
// Spec § /clear: room-scope reset is unsupported. Only thread-scoped
|
|
528
|
+
// resets carry a thread relation; drop bare room-level resets silently.
|
|
529
|
+
const relates = evt.content?.['m.relates_to'] as
|
|
530
|
+
| { rel_type?: string; event_id?: string }
|
|
531
|
+
| undefined
|
|
532
|
+
const threadRoot =
|
|
533
|
+
relates?.rel_type === 'm.thread' && relates.event_id ? relates.event_id : undefined
|
|
534
|
+
if (!threadRoot) {
|
|
535
|
+
console.log('[matrix] dropping dev.zooid.session_reset without thread relation')
|
|
536
|
+
return
|
|
428
537
|
}
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
})
|
|
538
|
+
console.log(`[matrix] inbound dev.zooid.session_reset in ${evt.room_id} thread=${threadRoot}`)
|
|
539
|
+
for (const a of bindings) {
|
|
540
|
+
agents.endSession(a.name, threadRoot)
|
|
541
|
+
}
|
|
542
|
+
// NB: keep threadStates intact. Per ZOD039 § /clear, only the agent's
|
|
543
|
+
// session memory is wiped — thread-routing state (participants /
|
|
544
|
+
// root-mentions) must survive so the next bare reply still routes to
|
|
545
|
+
// the most-recently-posting agent under the same sessionKey.
|
|
546
|
+
return
|
|
547
|
+
}
|
|
548
|
+
if (evt.type === 'dev.zooid.interrupt') {
|
|
549
|
+
const content = (evt.content ?? {}) as { session_id?: string; reason?: string }
|
|
550
|
+
// Thread-relation form (client-friendly): /interrupt in a thread sends
|
|
551
|
+
// an empty event with `m.relates_to: thread/<root>`. Cancel every
|
|
552
|
+
// session whose threadRoot matches.
|
|
553
|
+
const relates = evt.content?.['m.relates_to'] as
|
|
554
|
+
| { rel_type?: string; event_id?: string }
|
|
555
|
+
| undefined
|
|
556
|
+
const threadRoot =
|
|
557
|
+
relates?.rel_type === 'm.thread' && relates.event_id ? relates.event_id : undefined
|
|
558
|
+
if (threadRoot) {
|
|
559
|
+
const targets: Array<{ sessionId: string; agent: string }> = []
|
|
560
|
+
for (const [sessionId, ctx] of sessions) {
|
|
561
|
+
if (ctx.threadRoot === threadRoot) {
|
|
562
|
+
targets.push({ sessionId, agent: ctx.agent.name })
|
|
454
563
|
}
|
|
455
|
-
continue
|
|
456
|
-
}
|
|
457
|
-
// Legacy form: explicit session_id in content.
|
|
458
|
-
if (!content.session_id) {
|
|
459
|
-
console.warn(`[matrix] eco.zoon.interrupt missing session_id (event_id=${evt.event_id})`)
|
|
460
|
-
continue
|
|
461
564
|
}
|
|
462
|
-
const
|
|
463
|
-
|
|
464
|
-
|
|
565
|
+
for (const t of targets) {
|
|
566
|
+
console.log(
|
|
567
|
+
`[matrix] interrupt session=${t.sessionId} agent=${t.agent} thread=${threadRoot}` +
|
|
568
|
+
(content.reason ? ` reason=${content.reason}` : ''),
|
|
569
|
+
)
|
|
570
|
+
await agents.cancelSession(t.agent, t.sessionId).catch((err) => {
|
|
571
|
+
console.error(`[matrix] cancelSession(${t.agent}, ${t.sessionId}) failed:`, err)
|
|
572
|
+
})
|
|
465
573
|
}
|
|
466
|
-
|
|
467
|
-
`[matrix] interrupt session=${content.session_id} agent=${ctx.agent.name}` +
|
|
468
|
-
(content.reason ? ` reason=${content.reason}` : ''),
|
|
469
|
-
)
|
|
470
|
-
await agents.cancelSession(ctx.agent.name, content.session_id).catch((err) => {
|
|
471
|
-
console.error(`[matrix] cancelSession(${ctx.agent.name}, ${content.session_id}) failed:`, err)
|
|
472
|
-
})
|
|
473
|
-
continue
|
|
574
|
+
return
|
|
474
575
|
}
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
decision?: string
|
|
480
|
-
option_id?: string
|
|
481
|
-
}
|
|
482
|
-
if (!content.session_id || !content.approval_id || !content.decision) continue
|
|
483
|
-
const decision = content.option_id
|
|
484
|
-
? { decision: content.decision, optionId: content.option_id }
|
|
485
|
-
: { decision: content.decision }
|
|
486
|
-
const ok = approvals.resolve(
|
|
487
|
-
content.session_id,
|
|
488
|
-
content.approval_id,
|
|
489
|
-
decision as never,
|
|
490
|
-
)
|
|
491
|
-
if (!ok) console.warn(`[matrix] unknown approval ${content.approval_id}`)
|
|
492
|
-
continue
|
|
576
|
+
// Legacy form: explicit session_id in content.
|
|
577
|
+
if (!content.session_id) {
|
|
578
|
+
console.warn(`[matrix] dev.zooid.interrupt missing session_id (event_id=${evt.event_id})`)
|
|
579
|
+
return
|
|
493
580
|
}
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
if (
|
|
498
|
-
evt.type === 'm.room.message' &&
|
|
499
|
-
isMediaMsgtype(evt.content?.msgtype) &&
|
|
500
|
-
evt.room_id &&
|
|
501
|
-
evt.event_id &&
|
|
502
|
-
evt.sender &&
|
|
503
|
-
evt.content?.url &&
|
|
504
|
-
!bindings.some((b) => b.userId === evt.sender)
|
|
505
|
-
) {
|
|
506
|
-
pendingMedia.add(evt.room_id, inboundThreadRoot(evt), {
|
|
507
|
-
eventId: evt.event_id,
|
|
508
|
-
sender: evt.sender,
|
|
509
|
-
msgtype: evt.content.msgtype as string,
|
|
510
|
-
body: (evt.content.body as string | undefined) ?? '',
|
|
511
|
-
filename: evt.content.filename as string | undefined,
|
|
512
|
-
url: evt.content.url as string,
|
|
513
|
-
info: evt.content.info as PendingMediaItem['info'],
|
|
514
|
-
})
|
|
515
|
-
continue
|
|
581
|
+
const ctx = sessions.get(content.session_id)
|
|
582
|
+
if (!ctx) {
|
|
583
|
+
return
|
|
516
584
|
}
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
const rebuilt = await rebuildThreadState(client, evt.room_id, inboundRel, bindings)
|
|
533
|
-
threadStates.set(inboundRel, rebuilt)
|
|
534
|
-
console.log(
|
|
535
|
-
`[matrix] rebuilt threadState for ${inboundRel}: participants=${rebuilt.participants.join(',')} rootMentions=${rebuilt.rootMentions.join(',')}`,
|
|
536
|
-
)
|
|
537
|
-
} catch (err) {
|
|
538
|
-
console.warn(`[matrix] failed to rebuild threadState for ${inboundRel}:`, err)
|
|
539
|
-
}
|
|
585
|
+
console.log(
|
|
586
|
+
`[matrix] interrupt session=${content.session_id} agent=${ctx.agent.name}` +
|
|
587
|
+
(content.reason ? ` reason=${content.reason}` : ''),
|
|
588
|
+
)
|
|
589
|
+
await agents.cancelSession(ctx.agent.name, content.session_id).catch((err) => {
|
|
590
|
+
console.error(`[matrix] cancelSession(${ctx.agent.name}, ${content.session_id}) failed:`, err)
|
|
591
|
+
})
|
|
592
|
+
return
|
|
593
|
+
}
|
|
594
|
+
if (evt.type === 'dev.zooid.approval_response') {
|
|
595
|
+
const content = (evt.content ?? {}) as {
|
|
596
|
+
approval_id?: string
|
|
597
|
+
session_id?: string
|
|
598
|
+
decision?: string
|
|
599
|
+
option_id?: string
|
|
540
600
|
}
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
601
|
+
if (!content.session_id || !content.approval_id || !content.decision) return
|
|
602
|
+
const decision = content.option_id
|
|
603
|
+
? { decision: content.decision, optionId: content.option_id }
|
|
604
|
+
: { decision: content.decision }
|
|
605
|
+
const ok = approvals.resolve(
|
|
606
|
+
content.session_id,
|
|
607
|
+
content.approval_id,
|
|
608
|
+
decision as never,
|
|
609
|
+
)
|
|
610
|
+
if (!ok) console.warn(`[matrix] unknown approval ${content.approval_id}`)
|
|
611
|
+
return
|
|
612
|
+
}
|
|
613
|
+
logInbound(evt)
|
|
614
|
+
|
|
615
|
+
// Capture media events in the pending store; never route them to agents.
|
|
616
|
+
if (
|
|
617
|
+
evt.type === 'm.room.message' &&
|
|
618
|
+
isMediaMsgtype(evt.content?.msgtype) &&
|
|
619
|
+
evt.room_id &&
|
|
620
|
+
evt.event_id &&
|
|
621
|
+
evt.sender &&
|
|
622
|
+
evt.content?.url &&
|
|
623
|
+
!bindings.some((b) => b.userId === evt.sender)
|
|
624
|
+
) {
|
|
625
|
+
pendingMedia.add(evt.room_id, inboundThreadRoot(evt), {
|
|
626
|
+
eventId: evt.event_id,
|
|
627
|
+
sender: evt.sender,
|
|
628
|
+
msgtype: evt.content.msgtype as string,
|
|
629
|
+
body: (evt.content.body as string | undefined) ?? '',
|
|
630
|
+
filename: evt.content.filename as string | undefined,
|
|
631
|
+
url: evt.content.url as string,
|
|
632
|
+
info: evt.content.info as PendingMediaItem['info'],
|
|
633
|
+
})
|
|
634
|
+
return
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
// Agent-promotion: top-level inbound event becomes the thread root.
|
|
638
|
+
// For in-thread messages the existing root is preserved.
|
|
639
|
+
const promotedRoot = inboundThreadRoot(evt) ?? evt.event_id
|
|
640
|
+
// Self-heal: if this is a thread reply but we have no in-memory state
|
|
641
|
+
// for the root (e.g. daemon was just restarted), reconstruct it by
|
|
642
|
+
// fetching the thread root + relations from the server.
|
|
643
|
+
const inboundRel = inboundThreadRoot(evt)
|
|
644
|
+
if (
|
|
645
|
+
evt.type === 'm.room.message' &&
|
|
646
|
+
inboundRel &&
|
|
647
|
+
!threadStates.has(inboundRel) &&
|
|
648
|
+
evt.room_id
|
|
649
|
+
) {
|
|
650
|
+
try {
|
|
651
|
+
const rebuilt = await rebuildThreadState(client, evt.room_id, inboundRel, bindings)
|
|
652
|
+
threadStates.set(inboundRel, rebuilt)
|
|
653
|
+
console.log(
|
|
654
|
+
`[matrix] rebuilt threadState for ${inboundRel}: participants=${rebuilt.participants.join(',')} rootMentions=${rebuilt.rootMentions.join(',')}`,
|
|
548
655
|
)
|
|
656
|
+
} catch (err) {
|
|
657
|
+
console.warn(`[matrix] failed to rebuild threadState for ${inboundRel}:`, err)
|
|
549
658
|
}
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
}
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
659
|
+
}
|
|
660
|
+
const matches = route(evt, bindings, threadStates)
|
|
661
|
+
// Suppress the no-match warning for events sent by our own bots.
|
|
662
|
+
const senderIsBot = bindings.some((b) => b.userId === evt.sender)
|
|
663
|
+
if (evt.type === 'm.room.message' && matches.length === 0 && !senderIsBot) {
|
|
664
|
+
console.warn(
|
|
665
|
+
`[matrix] no agent matched message in ${evt.room_id} from ${evt.sender}` +
|
|
666
|
+
` (bindings: ${bindings.map((b) => `${b.name}@${b.userId}[${b.trigger}]`).join(', ')})`,
|
|
667
|
+
)
|
|
668
|
+
}
|
|
669
|
+
// Seed thread state for any agent mentions in this event.
|
|
670
|
+
if (matches.length > 0 && promotedRoot) {
|
|
671
|
+
let st = threadStates.get(promotedRoot)
|
|
672
|
+
if (!st) {
|
|
673
|
+
st = { participants: [], rootMentions: [] }
|
|
674
|
+
threadStates.set(promotedRoot, st)
|
|
563
675
|
}
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
.
|
|
568
|
-
|
|
569
|
-
let st = threadStates.get(promotedRoot)
|
|
570
|
-
if (!st) {
|
|
571
|
-
st = { participants: [], rootMentions: [] }
|
|
572
|
-
threadStates.set(promotedRoot, st)
|
|
573
|
-
}
|
|
574
|
-
if (st.participants.at(-1) !== a.name) st.participants.push(a.name)
|
|
575
|
-
})
|
|
576
|
-
.catch((err) => {
|
|
577
|
-
console.error(`[matrix] runTurn failed for ${a.name}:`, err)
|
|
578
|
-
const c = classify(err)
|
|
579
|
-
const threadRoot = inboundThreadRoot(evt) ?? evt.event_id
|
|
580
|
-
if (!threadRoot || !evt.room_id) return
|
|
581
|
-
const body = toErrorBody(
|
|
582
|
-
{
|
|
583
|
-
kind: 'error',
|
|
584
|
-
agentId: a.name,
|
|
585
|
-
sessionId: null,
|
|
586
|
-
turnId: null,
|
|
587
|
-
code: c.code,
|
|
588
|
-
message: err instanceof Error ? err.message : String(err),
|
|
589
|
-
detail: err instanceof Error && err.stack ? err.stack.slice(0, 2000) : undefined,
|
|
590
|
-
transient: c.transient,
|
|
591
|
-
acp_error: c.acp_error,
|
|
592
|
-
},
|
|
593
|
-
threadRoot,
|
|
594
|
-
)
|
|
595
|
-
void client
|
|
596
|
-
.sendCustomEvent({
|
|
597
|
-
roomId: evt.room_id,
|
|
598
|
-
asUserId: a.userId,
|
|
599
|
-
eventType: 'eco.zoon.error',
|
|
600
|
-
content: body,
|
|
601
|
-
})
|
|
602
|
-
.catch((e) => console.warn(`[matrix:${a.name}] eco.zoon.error send failed:`, e))
|
|
603
|
-
})
|
|
676
|
+
const msgMentions = new Set(extractMentions(evt as never))
|
|
677
|
+
for (const a of bindings) {
|
|
678
|
+
if (msgMentions.has(a.userId) && !st.rootMentions.includes(a.name)) {
|
|
679
|
+
st.rootMentions.push(a.name)
|
|
680
|
+
}
|
|
604
681
|
}
|
|
605
682
|
}
|
|
683
|
+
for (const a of matches) {
|
|
684
|
+
console.log(`[matrix] → ${a.name} (${a.userId})`)
|
|
685
|
+
void runTurn(a, evt)
|
|
686
|
+
.then(() => {
|
|
687
|
+
if (!promotedRoot) return
|
|
688
|
+
let st = threadStates.get(promotedRoot)
|
|
689
|
+
if (!st) {
|
|
690
|
+
st = { participants: [], rootMentions: [] }
|
|
691
|
+
threadStates.set(promotedRoot, st)
|
|
692
|
+
}
|
|
693
|
+
if (st.participants.at(-1) !== a.name) st.participants.push(a.name)
|
|
694
|
+
})
|
|
695
|
+
.catch((err) => {
|
|
696
|
+
console.error(`[matrix] runTurn failed for ${a.name}:`, err)
|
|
697
|
+
const c = classify(err)
|
|
698
|
+
const threadRoot = inboundThreadRoot(evt) ?? evt.event_id
|
|
699
|
+
if (!threadRoot || !evt.room_id) return
|
|
700
|
+
const body = toErrorBody(
|
|
701
|
+
{
|
|
702
|
+
kind: 'error',
|
|
703
|
+
agentId: a.name,
|
|
704
|
+
sessionId: null,
|
|
705
|
+
turnId: null,
|
|
706
|
+
code: c.code,
|
|
707
|
+
message: err instanceof Error ? err.message : String(err),
|
|
708
|
+
detail: err instanceof Error && err.stack ? err.stack.slice(0, 2000) : undefined,
|
|
709
|
+
transient: c.transient,
|
|
710
|
+
acp_error: c.acp_error,
|
|
711
|
+
},
|
|
712
|
+
threadRoot,
|
|
713
|
+
)
|
|
714
|
+
void client
|
|
715
|
+
.sendCustomEvent({
|
|
716
|
+
roomId: evt.room_id,
|
|
717
|
+
asUserId: a.userId,
|
|
718
|
+
eventType: 'dev.zooid.error',
|
|
719
|
+
content: body,
|
|
720
|
+
})
|
|
721
|
+
.catch((e) => console.warn(`[matrix:${a.name}] dev.zooid.error send failed:`, e))
|
|
722
|
+
})
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
const app = new Hono()
|
|
727
|
+
|
|
728
|
+
function authOk(authHeader: string | undefined): boolean {
|
|
729
|
+
const h = authHeader ?? ''
|
|
730
|
+
if (!h.startsWith('Bearer ')) return false
|
|
731
|
+
const got = h.slice(7)
|
|
732
|
+
if (got.length !== hsToken.length) return false
|
|
733
|
+
return timingSafeEqual(Buffer.from(got), Buffer.from(hsToken))
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
app.put('/_matrix/app/v1/transactions/:txnId', async (c) => {
|
|
737
|
+
if (!authOk(c.req.header('authorization'))) {
|
|
738
|
+
return c.json({ errcode: 'M_FORBIDDEN' }, 403)
|
|
739
|
+
}
|
|
740
|
+
const body = (await c.req.json().catch(() => ({}))) as { events?: MatrixEvent[] }
|
|
741
|
+
for (const evt of body.events ?? []) {
|
|
742
|
+
await handleInboundEvent(evt)
|
|
743
|
+
}
|
|
606
744
|
return c.json({})
|
|
607
745
|
})
|
|
608
746
|
|
|
@@ -637,6 +775,15 @@ export function createMatrixTransport(opts: CreateMatrixTransportOptions) {
|
|
|
637
775
|
sessions.set(sessionId, { agent, roomId: evt.room_id, threadRoot })
|
|
638
776
|
buffers.set(sessionId, '')
|
|
639
777
|
bufferMessageIds.delete(sessionId)
|
|
778
|
+
flushedCounts.set(sessionId, 0)
|
|
779
|
+
// Commands the shim advertised during ensureSession (session load/new)
|
|
780
|
+
// arrived before the ctx above existed and were stashed — replay the latest
|
|
781
|
+
// now that the session is fully registered, so the palette actually fills.
|
|
782
|
+
const stashedCommands = pendingCommands.get(sessionId)
|
|
783
|
+
if (stashedCommands) {
|
|
784
|
+
pendingCommands.delete(sessionId)
|
|
785
|
+
void agents.onEvent?.(agent.name, stashedCommands)
|
|
786
|
+
}
|
|
640
787
|
|
|
641
788
|
const roomId = evt.room_id
|
|
642
789
|
const TYPING_TTL_MS = 30_000
|
|
@@ -704,44 +851,23 @@ export function createMatrixTransport(opts: CreateMatrixTransportOptions) {
|
|
|
704
851
|
while (drainQuietMs > 0 && Date.now() - drainStart < drainMaxMs) {
|
|
705
852
|
await delay(drainQuietMs)
|
|
706
853
|
const next = buffers.get(sessionId) ?? ''
|
|
707
|
-
// Stop
|
|
708
|
-
//
|
|
709
|
-
// empty buffer means the
|
|
710
|
-
|
|
854
|
+
// Stop when the buffer is quiet (unchanged) and either it holds the
|
|
855
|
+
// final message to flush, or we already flushed a message this turn
|
|
856
|
+
// (so an empty, quiet buffer means the turn is genuinely done — the
|
|
857
|
+
// last message was flushed mid-stream). An unchanged *empty* buffer
|
|
858
|
+
// with nothing flushed yet means the stream hasn't started; keep
|
|
859
|
+
// waiting up to drainMaxMs.
|
|
860
|
+
if (next === drained && (next.length > 0 || (flushedCounts.get(sessionId) ?? 0) > 0))
|
|
861
|
+
break
|
|
711
862
|
drained = next
|
|
712
863
|
}
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
// Only attach formatted_body when it adds rich-text the plain body
|
|
721
|
-
// can't carry. marked wraps plain prose in <p>…</p>; if that's all
|
|
722
|
-
// we'd add, skip — most clients render `body` better than a stripped
|
|
723
|
-
// re-encode.
|
|
724
|
-
if (html) {
|
|
725
|
-
const escapedPlain =
|
|
726
|
-
'<p>' +
|
|
727
|
-
text
|
|
728
|
-
.replace(/&/g, '&')
|
|
729
|
-
.replace(/</g, '<')
|
|
730
|
-
.replace(/>/g, '>') +
|
|
731
|
-
'</p>'
|
|
732
|
-
const norm = (s: string) => s.replace(/\s+/g, ' ').trim()
|
|
733
|
-
if (norm(html) !== norm(escapedPlain)) {
|
|
734
|
-
content.format = 'org.matrix.custom.html'
|
|
735
|
-
content.formatted_body = html
|
|
736
|
-
}
|
|
737
|
-
}
|
|
738
|
-
await client.sendMessage({
|
|
739
|
-
roomId: evt.room_id,
|
|
740
|
-
asUserId: agent.userId,
|
|
741
|
-
content,
|
|
742
|
-
threadRoot, // every reply threads, full stop
|
|
743
|
-
})
|
|
744
|
-
} else {
|
|
864
|
+
// Flush the final assistant message — the one with no following messageId
|
|
865
|
+
// change or out-of-band event to have triggered an earlier flush.
|
|
866
|
+
flushBuffer(sessionId)
|
|
867
|
+
// Wait for every queued send (mid-turn flushes, tool/plan events, final
|
|
868
|
+
// flush) to settle before tearing the session down.
|
|
869
|
+
await (sendQueue.get(sessionId) ?? Promise.resolve())
|
|
870
|
+
if ((flushedCounts.get(sessionId) ?? 0) === 0) {
|
|
745
871
|
console.warn(
|
|
746
872
|
`[matrix:${agent.name}] turn finished with empty buffer (session=${sessionId}); nothing sent to ${evt.room_id}`,
|
|
747
873
|
)
|
|
@@ -752,11 +878,28 @@ export function createMatrixTransport(opts: CreateMatrixTransportOptions) {
|
|
|
752
878
|
await safePresence('online')
|
|
753
879
|
buffers.delete(sessionId)
|
|
754
880
|
bufferMessageIds.delete(sessionId)
|
|
881
|
+
flushedCounts.delete(sessionId)
|
|
882
|
+
sendQueue.delete(sessionId)
|
|
755
883
|
}
|
|
756
884
|
}
|
|
757
885
|
|
|
886
|
+
const syncLoops: SyncLoop[] | undefined =
|
|
887
|
+
mode === 'client'
|
|
888
|
+
? bindings.map(
|
|
889
|
+
(b) =>
|
|
890
|
+
new SyncLoop({
|
|
891
|
+
client: client as never,
|
|
892
|
+
asUserId: b.userId,
|
|
893
|
+
loadSince: () => opts.loadSince?.(b.userId) ?? null,
|
|
894
|
+
saveSince: (since) => opts.saveSince?.(b.userId, since),
|
|
895
|
+
onEvent: (evt) => handleInboundEvent(evt as MatrixEvent),
|
|
896
|
+
}),
|
|
897
|
+
)
|
|
898
|
+
: undefined
|
|
899
|
+
|
|
758
900
|
return {
|
|
759
901
|
app,
|
|
902
|
+
syncLoops,
|
|
760
903
|
bootstrap: async (
|
|
761
904
|
bootstrapOpts: {
|
|
762
905
|
spaceRoomId?: string
|