@botcord/daemon 0.2.5 → 0.2.6

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.
Files changed (84) hide show
  1. package/dist/agent-discovery.d.ts +4 -0
  2. package/dist/agent-discovery.js +8 -0
  3. package/dist/agent-workspace.d.ts +62 -0
  4. package/dist/agent-workspace.js +140 -8
  5. package/dist/config.d.ts +49 -1
  6. package/dist/config.js +57 -1
  7. package/dist/daemon-config-map.d.ts +27 -9
  8. package/dist/daemon-config-map.js +105 -8
  9. package/dist/daemon.d.ts +2 -0
  10. package/dist/daemon.js +52 -5
  11. package/dist/doctor.d.ts +27 -1
  12. package/dist/doctor.js +22 -1
  13. package/dist/gateway/cli-resolver.d.ts +34 -0
  14. package/dist/gateway/cli-resolver.js +74 -0
  15. package/dist/gateway/dispatcher.d.ts +31 -1
  16. package/dist/gateway/dispatcher.js +337 -29
  17. package/dist/gateway/gateway.d.ts +29 -1
  18. package/dist/gateway/gateway.js +10 -0
  19. package/dist/gateway/index.d.ts +2 -0
  20. package/dist/gateway/index.js +2 -0
  21. package/dist/gateway/policy-resolver.d.ts +57 -0
  22. package/dist/gateway/policy-resolver.js +123 -0
  23. package/dist/gateway/runtimes/acp-stream.d.ts +99 -0
  24. package/dist/gateway/runtimes/acp-stream.js +394 -0
  25. package/dist/gateway/runtimes/codex.js +7 -0
  26. package/dist/gateway/runtimes/hermes-agent.d.ts +83 -0
  27. package/dist/gateway/runtimes/hermes-agent.js +180 -0
  28. package/dist/gateway/runtimes/ndjson-stream.d.ts +7 -2
  29. package/dist/gateway/runtimes/ndjson-stream.js +16 -3
  30. package/dist/gateway/runtimes/openclaw-acp.d.ts +44 -0
  31. package/dist/gateway/runtimes/openclaw-acp.js +500 -0
  32. package/dist/gateway/runtimes/registry.d.ts +4 -0
  33. package/dist/gateway/runtimes/registry.js +22 -0
  34. package/dist/gateway/transcript-paths.d.ts +30 -0
  35. package/dist/gateway/transcript-paths.js +114 -0
  36. package/dist/gateway/transcript.d.ts +123 -0
  37. package/dist/gateway/transcript.js +147 -0
  38. package/dist/gateway/types.d.ts +31 -0
  39. package/dist/index.js +286 -27
  40. package/dist/mention-scan.d.ts +22 -0
  41. package/dist/mention-scan.js +35 -0
  42. package/dist/provision.d.ts +72 -1
  43. package/dist/provision.js +370 -7
  44. package/dist/system-context.d.ts +5 -4
  45. package/dist/system-context.js +35 -5
  46. package/dist/url-utils.d.ts +9 -0
  47. package/dist/url-utils.js +18 -0
  48. package/package.json +2 -1
  49. package/src/__tests__/agent-workspace.test.ts +93 -0
  50. package/src/__tests__/daemon-config-map.test.ts +79 -0
  51. package/src/__tests__/openclaw-acp.test.ts +234 -0
  52. package/src/__tests__/policy-resolver.test.ts +124 -0
  53. package/src/__tests__/policy-updated-handler.test.ts +144 -0
  54. package/src/__tests__/provision.test.ts +160 -0
  55. package/src/__tests__/system-context.test.ts +52 -0
  56. package/src/__tests__/url-utils.test.ts +37 -0
  57. package/src/agent-discovery.ts +8 -0
  58. package/src/agent-workspace.ts +173 -7
  59. package/src/config.ts +132 -4
  60. package/src/daemon-config-map.ts +154 -9
  61. package/src/daemon.ts +66 -5
  62. package/src/doctor.ts +49 -2
  63. package/src/gateway/__tests__/dispatcher.test.ts +65 -0
  64. package/src/gateway/__tests__/hermes-agent-adapter.test.ts +302 -0
  65. package/src/gateway/__tests__/transcript.test.ts +496 -0
  66. package/src/gateway/cli-resolver.ts +92 -0
  67. package/src/gateway/dispatcher.ts +394 -26
  68. package/src/gateway/gateway.ts +46 -0
  69. package/src/gateway/index.ts +25 -0
  70. package/src/gateway/policy-resolver.ts +171 -0
  71. package/src/gateway/runtimes/acp-stream.ts +535 -0
  72. package/src/gateway/runtimes/codex.ts +7 -0
  73. package/src/gateway/runtimes/hermes-agent.ts +206 -0
  74. package/src/gateway/runtimes/ndjson-stream.ts +16 -3
  75. package/src/gateway/runtimes/openclaw-acp.ts +606 -0
  76. package/src/gateway/runtimes/registry.ts +24 -0
  77. package/src/gateway/transcript-paths.ts +145 -0
  78. package/src/gateway/transcript.ts +300 -0
  79. package/src/gateway/types.ts +32 -0
  80. package/src/index.ts +295 -30
  81. package/src/mention-scan.ts +38 -0
  82. package/src/provision.ts +438 -9
  83. package/src/system-context.ts +41 -9
  84. package/src/url-utils.ts +17 -0
@@ -1,10 +1,19 @@
1
+ import { randomUUID } from "node:crypto";
2
+
1
3
  import type { GatewayLogger } from "./log.js";
2
4
  import { resolveRoute } from "./router.js";
3
5
  import { sessionKey, type SessionStore } from "./session-store.js";
6
+ import {
7
+ truncateTextField,
8
+ type DeliveryStatus,
9
+ type TranscriptBlockSummary,
10
+ type TranscriptWriter,
11
+ } from "./transcript.js";
4
12
  import type {
5
13
  ChannelAdapter,
6
14
  GatewayConfig,
7
15
  GatewayInboundEnvelope,
16
+ GatewayInboundMessage,
8
17
  GatewayOutboundMessage,
9
18
  GatewayRoute,
10
19
  GatewaySessionEntry,
@@ -83,13 +92,62 @@ export interface DispatcherOptions {
83
92
  * and suppressed so observer failures never break the turn.
84
93
  */
85
94
  onOutbound?: OutboundObserver;
95
+ /**
96
+ * Optional attention gate (PR3, design §4.2). Resolved AFTER `onInbound`
97
+ * runs and BEFORE the runtime turn enqueues, so working memory / activity
98
+ * tracking still observe the message even when the gate skips the wake.
99
+ *
100
+ * Return `true` to wake the runtime, `false` to skip the turn. Errors are
101
+ * logged and treated as `true` (fail-open) so a buggy gate cannot silence
102
+ * the agent.
103
+ */
104
+ attentionGate?: (
105
+ message: GatewayInboundMessage,
106
+ ) => Promise<boolean> | boolean;
107
+ /**
108
+ * Resolve the hub URL the inbound message's agent is registered against.
109
+ * Threaded into `RuntimeRunOptions.hubUrl` so spawned CLI subprocesses
110
+ * target the correct hub. If unset, runtimes leave `BOTCORD_HUB`
111
+ * unspecified and fall back to whatever the bundled CLI defaults to.
112
+ */
113
+ resolveHubUrl?: (accountId: string) => string | undefined;
114
+ /**
115
+ * Optional NDJSON transcript writer. When provided, dispatcher emits one
116
+ * inbound record + one path record + (for dispatched turns) one terminal
117
+ * record per `handle()` call. A noop writer is used by default so existing
118
+ * call sites keep working unchanged. See `docs/transcript-logging.md`.
119
+ */
120
+ transcript?: TranscriptWriter;
86
121
  }
87
122
 
123
+ /**
124
+ * Reason carried on `AbortController.abort()` when a cancel-previous wave
125
+ * is taking over the slot. Distinguishing this from a timeout abort lets
126
+ * `runTurn`'s finalize know NOT to write a `turn_error` — the supersede
127
+ * path already wrote a `dropped` record for the old turnId before the abort.
128
+ */
129
+ class TurnSupersededError extends Error {
130
+ constructor(public readonly supersededBy: string) {
131
+ super("turn superseded");
132
+ this.name = "TurnSupersededError";
133
+ }
134
+ }
135
+
136
+ const NOOP_TRANSCRIPT: TranscriptWriter = {
137
+ enabled: false,
138
+ rootDir: "",
139
+ write: () => {},
140
+ };
141
+
88
142
  interface TurnSlot {
143
+ turnId: string;
89
144
  controller: AbortController;
90
145
  timedOut: boolean;
91
146
  snapshot: TurnStatusSnapshot;
92
147
  done: Promise<void>;
148
+ dispatchedAt: number;
149
+ /** Streamed block summaries flushed into the terminal `outbound` record. */
150
+ blocks: TranscriptBlockSummary[];
93
151
  }
94
152
 
95
153
  /**
@@ -101,6 +159,8 @@ interface BufferedSerialEntry {
101
159
  route: GatewayRoute;
102
160
  msg: GatewayInboundEnvelope["message"];
103
161
  channel: ChannelAdapter;
162
+ /** Per-arrival turnId; preserved through merge so transcript can record dropped/dispatched correctly. */
163
+ turnId: string;
104
164
  }
105
165
 
106
166
  interface QueueState {
@@ -148,6 +208,11 @@ export class Dispatcher {
148
208
  private readonly onOutbound?: OutboundObserver;
149
209
  private readonly composeUserTurn?: UserTurnBuilder;
150
210
  private readonly managedRoutes?: Map<string, GatewayRoute>;
211
+ private readonly attentionGate?: (
212
+ message: GatewayInboundMessage,
213
+ ) => Promise<boolean> | boolean;
214
+ private readonly resolveHubUrl?: (accountId: string) => string | undefined;
215
+ private readonly transcript: TranscriptWriter;
151
216
  private readonly queues: Map<string, QueueState> = new Map();
152
217
 
153
218
  constructor(opts: DispatcherOptions) {
@@ -162,21 +227,32 @@ export class Dispatcher {
162
227
  this.onOutbound = opts.onOutbound;
163
228
  this.composeUserTurn = opts.composeUserTurn;
164
229
  this.managedRoutes = opts.managedRoutes;
230
+ this.attentionGate = opts.attentionGate;
231
+ this.resolveHubUrl = opts.resolveHubUrl;
232
+ this.transcript = opts.transcript ?? NOOP_TRANSCRIPT;
165
233
  }
166
234
 
167
235
  /** Consume one inbound envelope, ack it once ownership is decided, then run its turn. */
168
236
  async handle(envelope: GatewayInboundEnvelope): Promise<void> {
169
237
  const msg = envelope.message;
170
238
 
171
- // Skip rule: empty/whitespace text.
172
- const rawText = typeof msg.text === "string" ? msg.text.trim() : "";
173
- if (!rawText) {
174
- this.log.debug("dispatcher skip: empty text", { messageId: msg.id });
239
+ // ---- Pre-skip branches: NEVER write a transcript record (design §3.2).
240
+ // Order matters: unknown channel own echo → empty text. Each ack's the
241
+ // envelope (when applicable) and returns silently with only a debug/warn
242
+ // line in the daemon log.
243
+
244
+ // Pre-skip: unknown channel — configuration error, not a conversation event.
245
+ const channel = this.channels.get(msg.channel);
246
+ if (!channel) {
247
+ this.log.warn("dispatcher: unknown channel for outbound reply", {
248
+ channel: msg.channel,
249
+ messageId: msg.id,
250
+ });
175
251
  await this.safeAck(envelope);
176
252
  return;
177
253
  }
178
254
 
179
- // Skip rule: echo from the agent itself (own agent output looped back).
255
+ // Pre-skip: echo from the agent itself (own agent output looped back).
180
256
  // Owner/human messages in dashboard rooms share the agent's id as sender.id
181
257
  // but carry sender.kind === "user", so we only skip when kind === "agent".
182
258
  if (msg.sender.id === msg.accountId && msg.sender.kind === "agent") {
@@ -185,6 +261,18 @@ export class Dispatcher {
185
261
  return;
186
262
  }
187
263
 
264
+ // Pre-skip: empty/whitespace text.
265
+ const rawText = typeof msg.text === "string" ? msg.text.trim() : "";
266
+ if (!rawText) {
267
+ this.log.debug("dispatcher skip: empty text", { messageId: msg.id });
268
+ await this.safeAck(envelope);
269
+ return;
270
+ }
271
+
272
+ // From here on, the inbound is a real conversation event — generate a
273
+ // turnId and write the inbound transcript record.
274
+ const turnId = randomUUID();
275
+
188
276
  const managed = this.managedRoutes ? Array.from(this.managedRoutes.values()) : undefined;
189
277
  const route = resolveRoute(msg, this.config, managed);
190
278
  const mode = resolveQueueMode(route, msg.conversation.kind);
@@ -196,6 +284,7 @@ export class Dispatcher {
196
284
  // the full coalesced batch instead of any single arrival), so calling
197
285
  // the composer here would just be redundant work.
198
286
  let text = rawText;
287
+ let composeFailedError: string | undefined;
199
288
  if (mode === "cancel-previous" && this.composeUserTurn) {
200
289
  try {
201
290
  const composed = this.composeUserTurn(msg);
@@ -203,9 +292,10 @@ export class Dispatcher {
203
292
  text = composed;
204
293
  }
205
294
  } catch (err) {
295
+ composeFailedError = err instanceof Error ? err.message : String(err);
206
296
  this.log.warn("dispatcher: composeUserTurn threw — using raw text", {
207
297
  messageId: msg.id,
208
- error: err instanceof Error ? err.message : String(err),
298
+ error: composeFailedError,
209
299
  });
210
300
  }
211
301
  }
@@ -213,6 +303,10 @@ export class Dispatcher {
213
303
  // Ack immediately: once the dispatcher has a route + queue key, ownership is decided.
214
304
  await this.safeAck(envelope);
215
305
 
306
+ // Inbound transcript record — always before observers / gates so we have a
307
+ // grounded turnId for any downstream attention_skipped / dropped / etc.
308
+ this.emitInbound(turnId, msg);
309
+
216
310
  // Notify the optional observer (activity tracking, metrics, etc.) as soon
217
311
  // as the dispatcher owns the message. Errors must not abort the turn.
218
312
  if (this.onInbound) {
@@ -226,19 +320,58 @@ export class Dispatcher {
226
320
  }
227
321
  }
228
322
 
229
- const channel = this.channels.get(msg.channel);
230
- if (!channel) {
231
- this.log.warn("dispatcher: unknown channel for outbound reply", {
232
- channel: msg.channel,
233
- messageId: msg.id,
323
+ // Attention gate (PR3, design §4.2). Inserted AFTER `onInbound` so the
324
+ // working-memory append + activity tracking still see the message — only
325
+ // the runtime turn is suppressed. Errors are treated as wake (fail-open)
326
+ // so a buggy gate cannot silence the agent.
327
+ if (this.attentionGate) {
328
+ let wake = true;
329
+ try {
330
+ const result = this.attentionGate(msg);
331
+ wake = result instanceof Promise ? await result : result;
332
+ } catch (err) {
333
+ this.log.warn("dispatcher: attentionGate threw — waking", {
334
+ messageId: msg.id,
335
+ error: err instanceof Error ? err.message : String(err),
336
+ });
337
+ wake = true;
338
+ }
339
+ if (!wake) {
340
+ this.log.debug("dispatcher skip turn: attention policy", {
341
+ messageId: msg.id,
342
+ accountId: msg.accountId,
343
+ conversationId: msg.conversation.id,
344
+ });
345
+ this.transcript.write({
346
+ ts: nowIso(),
347
+ kind: "attention_skipped",
348
+ turnId,
349
+ agentId: msg.accountId,
350
+ roomId: msg.conversation.id,
351
+ topicId: msg.conversation.threadId ?? null,
352
+ reason: "attention_gate_false",
353
+ });
354
+ return;
355
+ }
356
+ }
357
+
358
+ if (composeFailedError) {
359
+ this.transcript.write({
360
+ ts: nowIso(),
361
+ kind: "compose_failed",
362
+ turnId,
363
+ agentId: msg.accountId,
364
+ roomId: msg.conversation.id,
365
+ topicId: msg.conversation.threadId ?? null,
366
+ error: composeFailedError,
367
+ fallback: "raw_text",
234
368
  });
235
- return;
236
369
  }
237
370
 
238
371
  if (mode === "cancel-previous") {
239
- await this.runCancelPrevious(queueKey, route, text, msg, channel);
372
+ await this.runCancelPrevious(queueKey, route, text, msg, channel, turnId);
240
373
  } else {
241
- await this.runSerial(queueKey, route, text, msg, channel);
374
+ await this.runSerial(queueKey, route, text, msg, channel, turnId);
242
375
  }
243
376
  }
244
377
 
@@ -288,6 +421,7 @@ export class Dispatcher {
288
421
  text: string,
289
422
  msg: GatewayInboundEnvelope["message"],
290
423
  channel: ChannelAdapter,
424
+ turnId: string,
291
425
  ): Promise<void> {
292
426
  const q = this.getQueue(queueKey);
293
427
  // Bump the generation on every arrival. Older arrivals still awaiting
@@ -298,7 +432,19 @@ export class Dispatcher {
298
432
  const prev = q.current;
299
433
  if (prev) {
300
434
  this.log.info("dispatcher: cancelling previous turn", { queueKey });
301
- prev.controller.abort();
435
+ // Record the supersede BEFORE aborting so the prev turn's finalize sees
436
+ // the abort reason (TurnSupersededError) and skips writing turn_error.
437
+ this.transcript.write({
438
+ ts: nowIso(),
439
+ kind: "dropped",
440
+ turnId: prev.turnId,
441
+ agentId: msg.accountId,
442
+ roomId: msg.conversation.id,
443
+ topicId: msg.conversation.threadId ?? null,
444
+ reason: "queue_cancel_previous",
445
+ supersededBy: turnId,
446
+ });
447
+ prev.controller.abort(new TurnSupersededError(turnId));
302
448
  // Wait for it to finish cleanup (it won't reply, won't persist).
303
449
  await prev.done.catch(() => undefined);
304
450
  }
@@ -307,9 +453,22 @@ export class Dispatcher {
307
453
  // drop out silently — the newest turn is the only one that should run.
308
454
  if (myGen !== q.cancelGen) {
309
455
  this.log.info("dispatcher: cancel-previous superseded", { queueKey });
456
+ // We didn't run the turn; emit dropped so the caller's inbound has a
457
+ // matching path record. supersededBy is unknown at this layer (newer
458
+ // arrival owns its own bump) — leave null.
459
+ this.transcript.write({
460
+ ts: nowIso(),
461
+ kind: "dropped",
462
+ turnId,
463
+ agentId: msg.accountId,
464
+ roomId: msg.conversation.id,
465
+ topicId: msg.conversation.threadId ?? null,
466
+ reason: "queue_cancel_previous",
467
+ supersededBy: null,
468
+ });
310
469
  return;
311
470
  }
312
- await this.runTurn(queueKey, route, text, msg, channel);
471
+ await this.runTurn(queueKey, route, text, msg, channel, turnId, []);
313
472
  }
314
473
 
315
474
  /**
@@ -336,9 +495,10 @@ export class Dispatcher {
336
495
  _text: string,
337
496
  msg: GatewayInboundEnvelope["message"],
338
497
  channel: ChannelAdapter,
498
+ turnId: string,
339
499
  ): Promise<void> {
340
500
  const q = this.getQueue(queueKey);
341
- q.serialBuffer.push({ route, msg, channel });
501
+ q.serialBuffer.push({ route, msg, channel, turnId });
342
502
  while (q.serialBuffer.length > MAX_BATCH_BUFFER_ENTRIES) {
343
503
  const dropped = q.serialBuffer.shift()!;
344
504
  this.log.warn("dispatcher: serial buffer overflow — dropped oldest entry", {
@@ -346,6 +506,16 @@ export class Dispatcher {
346
506
  droppedMessageId: dropped.msg.id,
347
507
  bufferCap: MAX_BATCH_BUFFER_ENTRIES,
348
508
  });
509
+ this.transcript.write({
510
+ ts: nowIso(),
511
+ kind: "dropped",
512
+ turnId: dropped.turnId,
513
+ agentId: dropped.msg.accountId,
514
+ roomId: dropped.msg.conversation.id,
515
+ topicId: dropped.msg.conversation.threadId ?? null,
516
+ reason: "queue_overflow",
517
+ supersededBy: null,
518
+ });
349
519
  }
350
520
  if (q.serialWorkerActive) return;
351
521
  q.serialWorkerActive = true;
@@ -354,12 +524,33 @@ export class Dispatcher {
354
524
  const drained = q.serialBuffer.splice(0, q.serialBuffer.length);
355
525
  const merged = this.mergeSerialBuffer(drained, queueKey);
356
526
  if (!merged) continue;
527
+ // Drained entries other than the winner get a `batch_merged` dropped
528
+ // record now (winner is always the last entry — see mergeSerialBuffer).
529
+ if (drained.length > 1) {
530
+ for (let i = 0; i < drained.length - 1; i++) {
531
+ const lost = drained[i]!;
532
+ this.transcript.write({
533
+ ts: nowIso(),
534
+ kind: "dropped",
535
+ turnId: lost.turnId,
536
+ agentId: lost.msg.accountId,
537
+ roomId: lost.msg.conversation.id,
538
+ topicId: lost.msg.conversation.threadId ?? null,
539
+ reason: "batch_merged",
540
+ supersededBy: merged.turnId,
541
+ });
542
+ }
543
+ }
544
+ const mergedFromTurnIds =
545
+ drained.length > 1 ? drained.slice(0, -1).map((e) => e.turnId) : [];
357
546
  await this.runTurn(
358
547
  queueKey,
359
548
  merged.route,
360
549
  merged.text,
361
550
  merged.msg,
362
551
  merged.channel,
552
+ merged.turnId,
553
+ mergedFromTurnIds,
363
554
  );
364
555
  }
365
556
  } finally {
@@ -384,6 +575,7 @@ export class Dispatcher {
384
575
  text: string;
385
576
  msg: GatewayInboundEnvelope["message"];
386
577
  channel: ChannelAdapter;
578
+ turnId: string;
387
579
  } | null {
388
580
  if (entries.length === 0) return null;
389
581
  if (entries.length === 1) {
@@ -393,6 +585,7 @@ export class Dispatcher {
393
585
  text: this.recomposeUserTurn(only.msg),
394
586
  msg: only.msg,
395
587
  channel: only.channel,
588
+ turnId: only.turnId,
396
589
  };
397
590
  }
398
591
 
@@ -448,6 +641,7 @@ export class Dispatcher {
448
641
  text: this.recomposeUserTurn(mergedMsg),
449
642
  msg: mergedMsg,
450
643
  channel: latest.channel,
644
+ turnId: latest.turnId,
451
645
  };
452
646
  }
453
647
 
@@ -478,6 +672,8 @@ export class Dispatcher {
478
672
  text: string,
479
673
  msg: GatewayInboundEnvelope["message"],
480
674
  channel: ChannelAdapter,
675
+ turnId: string,
676
+ mergedFromTurnIds: string[],
481
677
  ): Promise<void> {
482
678
  const q = this.getQueue(queueKey);
483
679
  const controller = new AbortController();
@@ -496,9 +692,35 @@ export class Dispatcher {
496
692
  const done = new Promise<void>((res) => {
497
693
  resolveDone = res;
498
694
  });
499
- const slot: TurnSlot = { controller, timedOut: false, snapshot, done };
695
+ const slot: TurnSlot = {
696
+ turnId,
697
+ controller,
698
+ timedOut: false,
699
+ snapshot,
700
+ done,
701
+ dispatchedAt: startedAt,
702
+ blocks: [],
703
+ };
500
704
  q.current = slot;
501
705
 
706
+ // Dispatched record — marks "this turn entered runtime".
707
+ {
708
+ const composedField = truncateTextField(text);
709
+ const dispatched: import("./transcript.js").DispatchedTranscriptRecord = {
710
+ ts: nowIso(),
711
+ kind: "dispatched",
712
+ turnId,
713
+ agentId: msg.accountId,
714
+ roomId: msg.conversation.id,
715
+ topicId: msg.conversation.threadId ?? null,
716
+ composedText: composedField.text,
717
+ runtime: route.runtime,
718
+ };
719
+ if (mergedFromTurnIds.length > 0) dispatched.mergedFromTurnIds = mergedFromTurnIds;
720
+ if (composedField.truncated) dispatched.truncated = { composedText: true };
721
+ this.transcript.write(dispatched);
722
+ }
723
+
502
724
  // Hard-cap turn with a timeout.
503
725
  const timer = setTimeout(() => {
504
726
  slot.timedOut = true;
@@ -526,8 +748,18 @@ export class Dispatcher {
526
748
  const traceId = msg.trace?.id;
527
749
  const canStream =
528
750
  streamable && typeof traceId === "string" && typeof channel.streamBlock === "function";
751
+ const recordBlock = (block: StreamBlock): void => {
752
+ const summary: TranscriptBlockSummary = { type: block.kind };
753
+ const raw = block.raw as { text?: unknown; name?: unknown } | null | undefined;
754
+ if (raw && typeof raw === "object") {
755
+ if (typeof raw.text === "string") summary.chars = raw.text.length;
756
+ if (typeof raw.name === "string") summary.name = raw.name;
757
+ }
758
+ slot.blocks.push(summary);
759
+ };
529
760
  const onBlock = canStream
530
761
  ? (block: StreamBlock) => {
762
+ recordBlock(block);
531
763
  // Fire-and-forget: stream errors must not break the turn.
532
764
  channel
533
765
  .streamBlock!({
@@ -574,11 +806,13 @@ export class Dispatcher {
574
806
  sessionId,
575
807
  cwd: route.cwd,
576
808
  accountId: msg.accountId,
809
+ hubUrl: this.resolveHubUrl?.(msg.accountId),
577
810
  extraArgs: route.extraArgs,
578
811
  signal: controller.signal,
579
812
  trustLevel,
580
813
  systemContext,
581
814
  onBlock,
815
+ gateway: route.gateway,
582
816
  });
583
817
  } catch (err) {
584
818
  threw = err;
@@ -593,6 +827,11 @@ export class Dispatcher {
593
827
  // until after the reply lets the new arrival trip our abort signal, and
594
828
  // this check then drops us silently. Timed-out turns still fall through
595
829
  // to send their error reply.
830
+ //
831
+ // Note on transcript: the supersede path already wrote the `dropped`
832
+ // record from `runCancelPrevious` BEFORE aborting, so we MUST NOT also
833
+ // emit a `turn_error` here — that would violate the "exactly one
834
+ // terminal record per turnId" invariant.
596
835
  if (controller.signal.aborted && !slot.timedOut) {
597
836
  return;
598
837
  }
@@ -616,6 +855,17 @@ export class Dispatcher {
616
855
  const isOwnerChat = isOwnerChatRoom(msg);
617
856
 
618
857
  if (slot.timedOut) {
858
+ this.transcript.write({
859
+ ts: nowIso(),
860
+ kind: "turn_error",
861
+ turnId,
862
+ agentId: msg.accountId,
863
+ roomId: msg.conversation.id,
864
+ topicId: msg.conversation.threadId ?? null,
865
+ phase: "timeout",
866
+ error: `runtime timeout after ${this.turnTimeoutMs}ms`,
867
+ durationMs: Date.now() - slot.dispatchedAt,
868
+ });
619
869
  if (isOwnerChat) {
620
870
  await this.sendReply(channel, {
621
871
  channel: msg.channel,
@@ -637,19 +887,30 @@ export class Dispatcher {
637
887
  }
638
888
 
639
889
  if (threw) {
890
+ const errMsg = threw instanceof Error ? threw.message : String(threw);
640
891
  this.log.error("dispatcher: runtime threw", {
641
892
  queueKey,
642
893
  runtime: route.runtime,
643
- error: threw instanceof Error ? threw.message : String(threw),
894
+ error: errMsg,
895
+ });
896
+ this.transcript.write({
897
+ ts: nowIso(),
898
+ kind: "turn_error",
899
+ turnId,
900
+ agentId: msg.accountId,
901
+ roomId: msg.conversation.id,
902
+ topicId: msg.conversation.threadId ?? null,
903
+ phase: "runtime",
904
+ error: errMsg,
905
+ durationMs: Date.now() - slot.dispatchedAt,
644
906
  });
645
907
  if (isOwnerChat) {
646
- const shortMsg = threw instanceof Error ? threw.message : String(threw);
647
908
  await this.sendReply(channel, {
648
909
  channel: msg.channel,
649
910
  accountId: msg.accountId,
650
911
  conversationId: msg.conversation.id,
651
912
  threadId: msg.conversation.threadId ?? null,
652
- text: `⚠️ Runtime error: ${truncate(shortMsg, 500)}`,
913
+ text: `⚠️ Runtime error: ${truncate(errMsg, 500)}`,
653
914
  replyTo: msg.id,
654
915
  traceId: msg.trace?.id ?? null,
655
916
  });
@@ -717,7 +978,23 @@ export class Dispatcher {
717
978
  }
718
979
 
719
980
  const replyText = (result.text || "").trim();
720
- if (!replyText) return;
981
+ const finalTextField = truncateTextField(result.text || "");
982
+
983
+ if (!replyText) {
984
+ this.emitOutbound({
985
+ turnId,
986
+ msg,
987
+ runtime: route.runtime,
988
+ runtimeSessionId: result.newSessionId || null,
989
+ startedAt: slot.dispatchedAt,
990
+ costUsd: result.costUsd,
991
+ finalText: finalTextField,
992
+ deliveryStatus: "empty_text",
993
+ deliveryReason: null,
994
+ blocks: slot.blocks,
995
+ });
996
+ return;
997
+ }
721
998
 
722
999
  if (!isOwnerChat) {
723
1000
  // Non-owner-chat rooms: result.text never goes out. The agent is
@@ -732,6 +1009,18 @@ export class Dispatcher {
732
1009
  replyTextLen: replyText.length,
733
1010
  },
734
1011
  );
1012
+ this.emitOutbound({
1013
+ turnId,
1014
+ msg,
1015
+ runtime: route.runtime,
1016
+ runtimeSessionId: result.newSessionId || null,
1017
+ startedAt: slot.dispatchedAt,
1018
+ costUsd: result.costUsd,
1019
+ finalText: finalTextField,
1020
+ deliveryStatus: "gated_non_owner_chat",
1021
+ deliveryReason: null,
1022
+ blocks: slot.blocks,
1023
+ });
735
1024
  return;
736
1025
  }
737
1026
 
@@ -742,7 +1031,7 @@ export class Dispatcher {
742
1031
  return;
743
1032
  }
744
1033
 
745
- await this.sendReply(channel, {
1034
+ const sendResult = await this.sendReply(channel, {
746
1035
  channel: msg.channel,
747
1036
  accountId: msg.accountId,
748
1037
  conversationId: msg.conversation.id,
@@ -751,6 +1040,18 @@ export class Dispatcher {
751
1040
  replyTo: msg.id,
752
1041
  traceId: msg.trace?.id ?? null,
753
1042
  });
1043
+ this.emitOutbound({
1044
+ turnId,
1045
+ msg,
1046
+ runtime: route.runtime,
1047
+ runtimeSessionId: result.newSessionId || null,
1048
+ startedAt: slot.dispatchedAt,
1049
+ costUsd: result.costUsd,
1050
+ finalText: finalTextField,
1051
+ deliveryStatus: sendResult.ok ? "delivered" : "send_failed",
1052
+ deliveryReason: sendResult.ok ? null : sendResult.error,
1053
+ blocks: slot.blocks,
1054
+ });
754
1055
  } finally {
755
1056
  // Clear slot ownership AFTER the reply has been sent (or skipped).
756
1057
  // Only then do cancel-previous arrivals stop finding this slot — which
@@ -765,16 +1066,17 @@ export class Dispatcher {
765
1066
  private async sendReply(
766
1067
  channel: ChannelAdapter,
767
1068
  outbound: GatewayOutboundMessage,
768
- ): Promise<void> {
1069
+ ): Promise<{ ok: true } | { ok: false; error: string }> {
769
1070
  try {
770
1071
  await channel.send({ message: outbound, log: this.log });
771
1072
  } catch (err) {
1073
+ const error = err instanceof Error ? err.message : String(err);
772
1074
  this.log.warn("dispatcher: channel.send failed", {
773
1075
  channel: outbound.channel,
774
1076
  conversationId: outbound.conversationId,
775
- error: err instanceof Error ? err.message : String(err),
1077
+ error,
776
1078
  });
777
- return;
1079
+ return { ok: false, error };
778
1080
  }
779
1081
  if (this.onOutbound) {
780
1082
  try {
@@ -786,7 +1088,73 @@ export class Dispatcher {
786
1088
  });
787
1089
  }
788
1090
  }
1091
+ return { ok: true };
1092
+ }
1093
+
1094
+ private emitInbound(turnId: string, msg: GatewayInboundEnvelope["message"]): void {
1095
+ if (!this.transcript.enabled) return;
1096
+ const rawText = typeof msg.text === "string" ? msg.text : "";
1097
+ const tField = truncateTextField(rawText);
1098
+ const raw = msg.raw as Record<string, unknown> | null | undefined;
1099
+ const batch =
1100
+ raw && typeof raw === "object" && Array.isArray((raw as { batch?: unknown }).batch)
1101
+ ? (raw as { batch: unknown[] }).batch.length
1102
+ : undefined;
1103
+ const rec: import("./transcript.js").InboundTranscriptRecord = {
1104
+ ts: nowIso(),
1105
+ kind: "inbound",
1106
+ turnId,
1107
+ agentId: msg.accountId,
1108
+ roomId: msg.conversation.id,
1109
+ topicId: msg.conversation.threadId ?? null,
1110
+ messageId: msg.id,
1111
+ sender: { id: msg.sender.id, kind: msg.sender.kind, ...(msg.sender.name ? { name: msg.sender.name } : {}) },
1112
+ text: tField.text,
1113
+ };
1114
+ if (batch !== undefined && batch > 1) rec.rawBatchEntries = batch;
1115
+ if (msg.trace?.id) {
1116
+ rec.trace = { id: msg.trace.id, ...(msg.trace.streamable ? { streamable: true } : {}) };
1117
+ }
1118
+ if (tField.truncated) rec.truncated = { text: true };
1119
+ this.transcript.write(rec);
789
1120
  }
1121
+
1122
+ private emitOutbound(args: {
1123
+ turnId: string;
1124
+ msg: GatewayInboundEnvelope["message"];
1125
+ runtime: string;
1126
+ runtimeSessionId: string | null;
1127
+ startedAt: number;
1128
+ costUsd?: number;
1129
+ finalText: { text: string; truncated: boolean };
1130
+ deliveryStatus: DeliveryStatus;
1131
+ deliveryReason: string | null;
1132
+ blocks: TranscriptBlockSummary[];
1133
+ }): void {
1134
+ if (!this.transcript.enabled) return;
1135
+ const rec: import("./transcript.js").OutboundTranscriptRecord = {
1136
+ ts: nowIso(),
1137
+ kind: "outbound",
1138
+ turnId: args.turnId,
1139
+ agentId: args.msg.accountId,
1140
+ roomId: args.msg.conversation.id,
1141
+ topicId: args.msg.conversation.threadId ?? null,
1142
+ runtime: args.runtime,
1143
+ runtimeSessionId: args.runtimeSessionId,
1144
+ durationMs: Date.now() - args.startedAt,
1145
+ finalText: args.finalText.text,
1146
+ deliveryStatus: args.deliveryStatus,
1147
+ deliveryReason: args.deliveryReason,
1148
+ };
1149
+ if (typeof args.costUsd === "number") rec.costUsd = args.costUsd;
1150
+ if (args.blocks.length > 0) rec.blocks = args.blocks;
1151
+ if (args.finalText.truncated) rec.truncated = { finalText: true };
1152
+ this.transcript.write(rec);
1153
+ }
1154
+ }
1155
+
1156
+ function nowIso(): string {
1157
+ return new Date().toISOString();
790
1158
  }
791
1159
 
792
1160
  function buildQueueKey(msg: GatewayInboundEnvelope["message"]): string {