@zooid/transport-matrix 0.9.0 → 0.10.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.
package/src/transport.ts CHANGED
@@ -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: {
@@ -56,6 +57,16 @@ export interface CreateMatrixTransportOptions {
56
57
  * bindings this forms the set of "our bot users" whose ad-hoc invites are
57
58
  * declined. */
58
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
59
70
  }
60
71
 
61
72
  interface SessionContext {
@@ -223,7 +234,7 @@ function inboundThreadRoot(evt: MatrixEvent): string | undefined {
223
234
  }
224
235
 
225
236
  export function createMatrixTransport(opts: CreateMatrixTransportOptions) {
226
- const { agents, approvals, client, bindings, hsToken, adminUserId, botUserId } = opts
237
+ const { agents, approvals, client, bindings, hsToken, adminUserId, botUserId, mode = 'appservice' } = opts
227
238
  const drainQuietMs = opts.drainQuietMs ?? DRAIN_QUIET_MS
228
239
  const drainMaxMs = opts.drainMaxMs ?? DRAIN_MAX_MS
229
240
  const mediaClient = opts.media
@@ -248,9 +259,13 @@ export function createMatrixTransport(opts: CreateMatrixTransportOptions) {
248
259
  const sendQueue = new Map<string, Promise<void>>()
249
260
  // Thread participation index: keyed by thread root event_id.
250
261
  const threadStates = new Map<string, ThreadState>()
251
- // Drop events older than this — Tuwunel may replay a backlog after the
252
- // daemon was offline, and we don't want yesterday's "@docs hi" to fire now.
253
- const cutoffTs = Date.now() - STARTUP_GRACE_MS
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
254
269
  // Idempotency: appservice transactions are retried on 4xx/5xx/timeout, and
255
270
  // the same event_id can arrive twice. Skip ones we've already taken.
256
271
  const seenEventIds = new Set<string>()
@@ -465,263 +480,269 @@ export function createMatrixTransport(opts: CreateMatrixTransportOptions) {
465
480
  })
466
481
  })
467
482
 
468
- const app = new Hono()
469
-
470
- function authOk(authHeader: string | undefined): boolean {
471
- const h = authHeader ?? ''
472
- if (!h.startsWith('Bearer ')) return false
473
- const got = h.slice(7)
474
- if (got.length !== hsToken.length) return false
475
- return timingSafeEqual(Buffer.from(got), Buffer.from(hsToken))
476
- }
477
-
478
- app.put('/_matrix/app/v1/transactions/:txnId', async (c) => {
479
- if (!authOk(c.req.header('authorization'))) {
480
- return c.json({ errcode: 'M_FORBIDDEN' }, 403)
481
- }
482
- const body = (await c.req.json().catch(() => ({}))) as { events?: MatrixEvent[] }
483
- for (const evt of body.events ?? []) {
484
- if (evt.event_id) {
485
- if (seenEventIds.has(evt.event_id)) {
486
- continue
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
- }
483
+ async function handleInboundEvent(evt: MatrixEvent): Promise<void> {
484
+ if (evt.event_id) {
485
+ if (seenEventIds.has(evt.event_id)) {
486
+ return
493
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
494
508
  if (
495
- evt.origin_server_ts !== undefined &&
496
- evt.origin_server_ts < cutoffTs &&
497
- evt.type === 'm.room.message'
509
+ target &&
510
+ evt.room_id &&
511
+ ourBotUserIds.has(target) &&
512
+ (!inviter || !ourBotUserIds.has(inviter))
498
513
  ) {
499
514
  console.log(
500
- `[matrix] dropping stale message event ${evt.event_id} ` +
501
- `(ts=${evt.origin_server_ts}, daemon started at ${cutoffTs + STARTUP_GRACE_MS})`,
515
+ `[matrix] declining ad-hoc invite for ${target} in ${evt.room_id} ` +
516
+ `from ${inviter ?? 'unknown'}`,
502
517
  )
503
- continue
504
- }
505
- if (evt.type === 'm.room.member' && evt.content?.membership === 'invite') {
506
- const target = evt.state_key
507
- const inviter = evt.sender
508
- if (
509
- target &&
510
- evt.room_id &&
511
- ourBotUserIds.has(target) &&
512
- (!inviter || !ourBotUserIds.has(inviter))
513
- ) {
514
- console.log(
515
- `[matrix] declining ad-hoc invite for ${target} in ${evt.room_id} ` +
516
- `from ${inviter ?? 'unknown'}`,
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),
517
522
  )
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
- )
523
- }
524
- continue
525
523
  }
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
- continue
537
- }
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
- 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
547
537
  }
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 })
563
- }
564
- }
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
- })
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 })
573
563
  }
574
- continue
575
564
  }
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
- continue
580
- }
581
- const ctx = sessions.get(content.session_id)
582
- if (!ctx) {
583
- continue
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
+ })
584
573
  }
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
- continue
574
+ return
593
575
  }
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
600
- }
601
- if (!content.session_id || !content.approval_id || !content.decision) continue
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
- 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
612
580
  }
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
- continue
581
+ const ctx = sessions.get(content.session_id)
582
+ if (!ctx) {
583
+ return
635
584
  }
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(',')}`,
655
- )
656
- } catch (err) {
657
- console.warn(`[matrix] failed to rebuild threadState for ${inboundRel}:`, err)
658
- }
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
659
600
  }
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(', ')})`,
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(',')}`,
667
655
  )
656
+ } catch (err) {
657
+ console.warn(`[matrix] failed to rebuild threadState for ${inboundRel}:`, err)
668
658
  }
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)
675
- }
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
- }
681
- }
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: [], callers: {} }
674
+ threadStates.set(promotedRoot, st)
682
675
  }
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
- })
676
+ const msgMentions = new Set(extractMentions(evt as never))
677
+ const senderAgent = bindings.find((b) => b.userId === evt.sender)
678
+ for (const a of bindings) {
679
+ if (!msgMentions.has(a.userId)) continue
680
+ if (!st.rootMentions.includes(a.name)) st.rootMentions.push(a.name)
681
+ // Call edge: the (agent) sender is the caller of every agent it @mentions.
682
+ if (senderAgent && a.name !== senderAgent.name) st.callers[a.name] = senderAgent.name
723
683
  }
724
684
  }
685
+ for (const a of matches) {
686
+ console.log(`[matrix] → ${a.name} (${a.userId})`)
687
+ void runTurn(a, evt)
688
+ .then(() => {
689
+ if (!promotedRoot) return
690
+ let st = threadStates.get(promotedRoot)
691
+ if (!st) {
692
+ st = { participants: [], rootMentions: [], callers: {} }
693
+ threadStates.set(promotedRoot, st)
694
+ }
695
+ if (st.participants.at(-1) !== a.name) st.participants.push(a.name)
696
+ })
697
+ .catch((err) => {
698
+ console.error(`[matrix] runTurn failed for ${a.name}:`, err)
699
+ const c = classify(err)
700
+ const threadRoot = inboundThreadRoot(evt) ?? evt.event_id
701
+ if (!threadRoot || !evt.room_id) return
702
+ const body = toErrorBody(
703
+ {
704
+ kind: 'error',
705
+ agentId: a.name,
706
+ sessionId: null,
707
+ turnId: null,
708
+ code: c.code,
709
+ message: err instanceof Error ? err.message : String(err),
710
+ detail: err instanceof Error && err.stack ? err.stack.slice(0, 2000) : undefined,
711
+ transient: c.transient,
712
+ acp_error: c.acp_error,
713
+ },
714
+ threadRoot,
715
+ )
716
+ void client
717
+ .sendCustomEvent({
718
+ roomId: evt.room_id,
719
+ asUserId: a.userId,
720
+ eventType: 'dev.zooid.error',
721
+ content: body,
722
+ })
723
+ .catch((e) => console.warn(`[matrix:${a.name}] dev.zooid.error send failed:`, e))
724
+ })
725
+ }
726
+ }
727
+
728
+ const app = new Hono()
729
+
730
+ function authOk(authHeader: string | undefined): boolean {
731
+ const h = authHeader ?? ''
732
+ if (!h.startsWith('Bearer ')) return false
733
+ const got = h.slice(7)
734
+ if (got.length !== hsToken.length) return false
735
+ return timingSafeEqual(Buffer.from(got), Buffer.from(hsToken))
736
+ }
737
+
738
+ app.put('/_matrix/app/v1/transactions/:txnId', async (c) => {
739
+ if (!authOk(c.req.header('authorization'))) {
740
+ return c.json({ errcode: 'M_FORBIDDEN' }, 403)
741
+ }
742
+ const body = (await c.req.json().catch(() => ({}))) as { events?: MatrixEvent[] }
743
+ for (const evt of body.events ?? []) {
744
+ await handleInboundEvent(evt)
745
+ }
725
746
  return c.json({})
726
747
  })
727
748
 
@@ -864,8 +885,23 @@ export function createMatrixTransport(opts: CreateMatrixTransportOptions) {
864
885
  }
865
886
  }
866
887
 
888
+ const syncLoops: SyncLoop[] | undefined =
889
+ mode === 'client'
890
+ ? bindings.map(
891
+ (b) =>
892
+ new SyncLoop({
893
+ client: client as never,
894
+ asUserId: b.userId,
895
+ loadSince: () => opts.loadSince?.(b.userId) ?? null,
896
+ saveSince: (since) => opts.saveSince?.(b.userId, since),
897
+ onEvent: (evt) => handleInboundEvent(evt as MatrixEvent),
898
+ }),
899
+ )
900
+ : undefined
901
+
867
902
  return {
868
903
  app,
904
+ syncLoops,
869
905
  bootstrap: async (
870
906
  bootstrapOpts: {
871
907
  spaceRoomId?: string
@@ -898,7 +934,7 @@ export async function rebuildThreadState(
898
934
  rootEventId: string,
899
935
  bindings: AgentBinding[],
900
936
  ): Promise<ThreadState> {
901
- const state: ThreadState = { participants: [], rootMentions: [] }
937
+ const state: ThreadState = { participants: [], rootMentions: [], callers: {} }
902
938
  // Impersonate an agent that's actually a member of this room (AS reads
903
939
  // require room membership). Falling through to the first binding would
904
940
  // 403 if that agent never joined the target room.
@@ -908,10 +944,12 @@ export async function rebuildThreadState(
908
944
  const root = await client.fetchEvent(roomId, rootEventId, asUser)
909
945
  if (root) {
910
946
  const rootMentions = new Set(extractMentions(root as never))
947
+ const rootSender = (root as { sender?: string }).sender
948
+ const rootSenderAgent = rootSender ? bindings.find((b) => b.userId === rootSender) : undefined
911
949
  for (const a of bindings) {
912
- if (rootMentions.has(a.userId) && !state.rootMentions.includes(a.name)) {
913
- state.rootMentions.push(a.name)
914
- }
950
+ if (!rootMentions.has(a.userId)) continue
951
+ if (!state.rootMentions.includes(a.name)) state.rootMentions.push(a.name)
952
+ if (rootSenderAgent && a.name !== rootSenderAgent.name) state.callers[a.name] = rootSenderAgent.name
915
953
  }
916
954
  }
917
955
 
@@ -923,15 +961,16 @@ export async function rebuildThreadState(
923
961
  // Also seed root-mentions from any subsequent agent @mentions in the thread.
924
962
  for (const ev of thread) {
925
963
  const mentions = new Set(extractMentions(ev as never))
964
+ const evSender = (ev as { sender?: string }).sender
965
+ const evSenderAgent = evSender ? bindings.find((b) => b.userId === evSender) : undefined
926
966
  for (const a of bindings) {
927
- if (mentions.has(a.userId) && !state.rootMentions.includes(a.name)) {
928
- state.rootMentions.push(a.name)
929
- }
967
+ if (!mentions.has(a.userId)) continue
968
+ if (!state.rootMentions.includes(a.name)) state.rootMentions.push(a.name)
969
+ if (evSenderAgent && a.name !== evSenderAgent.name) state.callers[a.name] = evSenderAgent.name
930
970
  }
931
- const sender = (ev as { sender?: string }).sender
932
971
  const type = (ev as { type?: string }).type
933
- if (type === 'm.room.message' && sender) {
934
- const a = bindings.find((b) => b.userId === sender)
972
+ if (type === 'm.room.message' && evSender) {
973
+ const a = bindings.find((b) => b.userId === evSender)
935
974
  if (a && state.participants.at(-1) !== a.name) state.participants.push(a.name)
936
975
  }
937
976
  }