@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/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: 'eco.zoon.error',
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}] eco.zoon.error send failed:`, e))
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 — Tuwunel may replay a backlog after the
238
- // daemon was offline, and we don't want yesterday's "@docs hi" to fire now.
239
- 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
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;') +
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
- console.warn(`[matrix:${name}] no session ctx for ${event.sessionId}`)
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
- const current = buffers.get(event.sessionId) ?? ''
255
- // Within a message, tokens carry their own leading spaces, so we
256
- // concatenate raw. Two signals start a *new* message block that must not
257
- // run together with the previous text:
258
- // - an empty chunk (some agents emit one between blocks, e.g. after a
259
- // tool call), or
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
- ? 'eco.zoon.tool_call'
424
+ ? 'dev.zooid.tool_call'
316
425
  : event.type === 'tool_call_update'
317
- ? 'eco.zoon.tool_call_update'
318
- : 'eco.zoon.plan'
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
- : toPlanBody(event)
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: 'eco.zoon.approval_request',
478
+ eventType: 'dev.zooid.approval_request',
366
479
  content,
367
480
  })
368
481
  })
369
482
 
370
- const app = new Hono()
371
-
372
- function authOk(authHeader: string | undefined): boolean {
373
- const h = authHeader ?? ''
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
- evt.origin_server_ts !== undefined &&
398
- evt.origin_server_ts < cutoffTs &&
399
- evt.type === 'm.room.message'
509
+ target &&
510
+ evt.room_id &&
511
+ ourBotUserIds.has(target) &&
512
+ (!inviter || !ourBotUserIds.has(inviter))
400
513
  ) {
401
514
  console.log(
402
- `[matrix] dropping stale message event ${evt.event_id} ` +
403
- `(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'}`,
404
517
  )
405
- continue
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
- if (evt.type === 'eco.zoon.session_reset') {
408
- // Spec § /clear: room-scope reset is unsupported. Only thread-scoped
409
- // resets carry a thread relation; drop bare room-level resets silently.
410
- const relates = evt.content?.['m.relates_to'] as
411
- | { rel_type?: string; event_id?: string }
412
- | undefined
413
- const threadRoot =
414
- relates?.rel_type === 'm.thread' && relates.event_id ? relates.event_id : undefined
415
- if (!threadRoot) {
416
- console.log('[matrix] dropping eco.zoon.session_reset without thread relation')
417
- continue
418
- }
419
- console.log(`[matrix] inbound eco.zoon.session_reset in ${evt.room_id} thread=${threadRoot}`)
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
- if (evt.type === 'eco.zoon.interrupt') {
430
- const content = (evt.content ?? {}) as { session_id?: string; reason?: string }
431
- // Thread-relation form (client-friendly): /interrupt in a thread sends
432
- // an empty event with `m.relates_to: thread/<root>`. Cancel every
433
- // session whose threadRoot matches.
434
- const relates = evt.content?.['m.relates_to'] as
435
- | { rel_type?: string; event_id?: string }
436
- | undefined
437
- const threadRoot =
438
- relates?.rel_type === 'm.thread' && relates.event_id ? relates.event_id : undefined
439
- if (threadRoot) {
440
- const targets: Array<{ sessionId: string; agent: string }> = []
441
- for (const [sessionId, ctx] of sessions) {
442
- if (ctx.threadRoot === threadRoot) {
443
- targets.push({ sessionId, agent: ctx.agent.name })
444
- }
445
- }
446
- for (const t of targets) {
447
- console.log(
448
- `[matrix] interrupt session=${t.sessionId} agent=${t.agent} thread=${threadRoot}` +
449
- (content.reason ? ` reason=${content.reason}` : ''),
450
- )
451
- await agents.cancelSession(t.agent, t.sessionId).catch((err) => {
452
- console.error(`[matrix] cancelSession(${t.agent}, ${t.sessionId}) failed:`, err)
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 ctx = sessions.get(content.session_id)
463
- if (!ctx) {
464
- 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
+ })
465
573
  }
466
- console.log(
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
- if (evt.type === 'eco.zoon.approval_response') {
476
- const content = (evt.content ?? {}) as {
477
- approval_id?: string
478
- session_id?: string
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
- logInbound(evt)
495
-
496
- // Capture media events in the pending store; never route them to agents.
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
- // Agent-promotion: top-level inbound event becomes the thread root.
519
- // For in-thread messages the existing root is preserved.
520
- const promotedRoot = inboundThreadRoot(evt) ?? evt.event_id
521
- // Self-heal: if this is a thread reply but we have no in-memory state
522
- // for the root (e.g. daemon was just restarted), reconstruct it by
523
- // fetching the thread root + relations from the server.
524
- const inboundRel = inboundThreadRoot(evt)
525
- if (
526
- evt.type === 'm.room.message' &&
527
- inboundRel &&
528
- !threadStates.has(inboundRel) &&
529
- evt.room_id
530
- ) {
531
- try {
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
- const matches = route(evt, bindings, threadStates)
542
- // Suppress the no-match warning for events sent by our own bots.
543
- const senderIsBot = bindings.some((b) => b.userId === evt.sender)
544
- if (evt.type === 'm.room.message' && matches.length === 0 && !senderIsBot) {
545
- console.warn(
546
- `[matrix] no agent matched message in ${evt.room_id} from ${evt.sender}` +
547
- ` (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(',')}`,
548
655
  )
656
+ } catch (err) {
657
+ console.warn(`[matrix] failed to rebuild threadState for ${inboundRel}:`, err)
549
658
  }
550
- // Seed thread state for any agent mentions in this event.
551
- if (matches.length > 0 && promotedRoot) {
552
- let st = threadStates.get(promotedRoot)
553
- if (!st) {
554
- st = { participants: [], rootMentions: [] }
555
- threadStates.set(promotedRoot, st)
556
- }
557
- const msgMentions = new Set(extractMentions(evt as never))
558
- for (const a of bindings) {
559
- if (msgMentions.has(a.userId) && !st.rootMentions.includes(a.name)) {
560
- st.rootMentions.push(a.name)
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
- for (const a of matches) {
565
- console.log(`[matrix] → ${a.name} (${a.userId})`)
566
- void runTurn(a, evt)
567
- .then(() => {
568
- if (!promotedRoot) return
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 only when we have content AND it hasn't grown i.e. the
708
- // generation has actually started and is now done. An unchanged
709
- // empty buffer means the stream hasn't started yet; keep waiting.
710
- if (next === drained && next.length > 0) break
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
- const text = buffers.get(sessionId) ?? ''
714
- if (text.length > 0) {
715
- const html = toMatrixHtml(text)
716
- const content: { msgtype: string; body: string; [k: string]: unknown } = {
717
- msgtype: 'm.text',
718
- body: text,
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, '&amp;')
729
- .replace(/</g, '&lt;')
730
- .replace(/>/g, '&gt;') +
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