@botcord/daemon 0.1.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.
Files changed (149) hide show
  1. package/dist/activity-tracker.d.ts +43 -0
  2. package/dist/activity-tracker.js +110 -0
  3. package/dist/adapters/runtimes.d.ts +14 -0
  4. package/dist/adapters/runtimes.js +18 -0
  5. package/dist/agent-discovery.d.ts +81 -0
  6. package/dist/agent-discovery.js +181 -0
  7. package/dist/agent-workspace.d.ts +31 -0
  8. package/dist/agent-workspace.js +221 -0
  9. package/dist/config.d.ts +116 -0
  10. package/dist/config.js +180 -0
  11. package/dist/control-channel.d.ts +99 -0
  12. package/dist/control-channel.js +388 -0
  13. package/dist/cross-room.d.ts +23 -0
  14. package/dist/cross-room.js +55 -0
  15. package/dist/daemon-config-map.d.ts +61 -0
  16. package/dist/daemon-config-map.js +153 -0
  17. package/dist/daemon.d.ts +123 -0
  18. package/dist/daemon.js +349 -0
  19. package/dist/doctor.d.ts +89 -0
  20. package/dist/doctor.js +191 -0
  21. package/dist/gateway/channel-manager.d.ts +54 -0
  22. package/dist/gateway/channel-manager.js +292 -0
  23. package/dist/gateway/channels/botcord.d.ts +93 -0
  24. package/dist/gateway/channels/botcord.js +510 -0
  25. package/dist/gateway/channels/index.d.ts +2 -0
  26. package/dist/gateway/channels/index.js +1 -0
  27. package/dist/gateway/channels/sanitize.d.ts +20 -0
  28. package/dist/gateway/channels/sanitize.js +56 -0
  29. package/dist/gateway/dispatcher.d.ts +73 -0
  30. package/dist/gateway/dispatcher.js +431 -0
  31. package/dist/gateway/gateway.d.ts +87 -0
  32. package/dist/gateway/gateway.js +158 -0
  33. package/dist/gateway/index.d.ts +15 -0
  34. package/dist/gateway/index.js +15 -0
  35. package/dist/gateway/log.d.ts +9 -0
  36. package/dist/gateway/log.js +20 -0
  37. package/dist/gateway/router.d.ts +10 -0
  38. package/dist/gateway/router.js +48 -0
  39. package/dist/gateway/runtimes/claude-code.d.ts +30 -0
  40. package/dist/gateway/runtimes/claude-code.js +162 -0
  41. package/dist/gateway/runtimes/codex.d.ts +83 -0
  42. package/dist/gateway/runtimes/codex.js +272 -0
  43. package/dist/gateway/runtimes/gemini.d.ts +15 -0
  44. package/dist/gateway/runtimes/gemini.js +29 -0
  45. package/dist/gateway/runtimes/ndjson-stream.d.ts +43 -0
  46. package/dist/gateway/runtimes/ndjson-stream.js +169 -0
  47. package/dist/gateway/runtimes/probe.d.ts +17 -0
  48. package/dist/gateway/runtimes/probe.js +54 -0
  49. package/dist/gateway/runtimes/registry.d.ts +59 -0
  50. package/dist/gateway/runtimes/registry.js +94 -0
  51. package/dist/gateway/session-store.d.ts +39 -0
  52. package/dist/gateway/session-store.js +133 -0
  53. package/dist/gateway/types.d.ts +265 -0
  54. package/dist/gateway/types.js +1 -0
  55. package/dist/index.d.ts +2 -0
  56. package/dist/index.js +854 -0
  57. package/dist/log.d.ts +7 -0
  58. package/dist/log.js +44 -0
  59. package/dist/provision.d.ts +88 -0
  60. package/dist/provision.js +749 -0
  61. package/dist/room-context-fetcher.d.ts +18 -0
  62. package/dist/room-context-fetcher.js +101 -0
  63. package/dist/room-context.d.ts +53 -0
  64. package/dist/room-context.js +112 -0
  65. package/dist/sender-classify.d.ts +30 -0
  66. package/dist/sender-classify.js +32 -0
  67. package/dist/snapshot-writer.d.ts +37 -0
  68. package/dist/snapshot-writer.js +84 -0
  69. package/dist/status-render.d.ts +28 -0
  70. package/dist/status-render.js +97 -0
  71. package/dist/system-context.d.ts +57 -0
  72. package/dist/system-context.js +91 -0
  73. package/dist/turn-text.d.ts +36 -0
  74. package/dist/turn-text.js +57 -0
  75. package/dist/user-auth.d.ts +75 -0
  76. package/dist/user-auth.js +245 -0
  77. package/dist/working-memory.d.ts +46 -0
  78. package/dist/working-memory.js +274 -0
  79. package/package.json +39 -0
  80. package/src/__tests__/activity-tracker.test.ts +130 -0
  81. package/src/__tests__/agent-discovery.test.ts +191 -0
  82. package/src/__tests__/agent-workspace.test.ts +147 -0
  83. package/src/__tests__/control-channel.test.ts +327 -0
  84. package/src/__tests__/cross-room.test.ts +116 -0
  85. package/src/__tests__/daemon-config-map.test.ts +416 -0
  86. package/src/__tests__/daemon.test.ts +300 -0
  87. package/src/__tests__/device-code.test.ts +152 -0
  88. package/src/__tests__/doctor.test.ts +218 -0
  89. package/src/__tests__/protocol-core-reexport.test.ts +24 -0
  90. package/src/__tests__/provision.test.ts +922 -0
  91. package/src/__tests__/room-context.test.ts +233 -0
  92. package/src/__tests__/runtime-discovery.test.ts +173 -0
  93. package/src/__tests__/snapshot-writer.test.ts +141 -0
  94. package/src/__tests__/status-render.test.ts +137 -0
  95. package/src/__tests__/system-context.test.ts +315 -0
  96. package/src/__tests__/turn-text.test.ts +116 -0
  97. package/src/__tests__/user-auth.test.ts +125 -0
  98. package/src/__tests__/working-memory.test.ts +240 -0
  99. package/src/activity-tracker.ts +140 -0
  100. package/src/adapters/runtimes.ts +30 -0
  101. package/src/agent-discovery.ts +262 -0
  102. package/src/agent-workspace.ts +247 -0
  103. package/src/config.ts +290 -0
  104. package/src/control-channel.ts +455 -0
  105. package/src/cross-room.ts +89 -0
  106. package/src/daemon-config-map.ts +200 -0
  107. package/src/daemon.ts +478 -0
  108. package/src/doctor.ts +282 -0
  109. package/src/gateway/__tests__/.gitkeep +0 -0
  110. package/src/gateway/__tests__/botcord-channel.test.ts +480 -0
  111. package/src/gateway/__tests__/channel-manager.test.ts +475 -0
  112. package/src/gateway/__tests__/claude-code-adapter.test.ts +318 -0
  113. package/src/gateway/__tests__/codex-adapter.test.ts +350 -0
  114. package/src/gateway/__tests__/dispatcher.test.ts +1159 -0
  115. package/src/gateway/__tests__/gateway-add-channel.test.ts +180 -0
  116. package/src/gateway/__tests__/gateway-managed-routes.test.ts +181 -0
  117. package/src/gateway/__tests__/gateway.test.ts +222 -0
  118. package/src/gateway/__tests__/router.test.ts +247 -0
  119. package/src/gateway/__tests__/sanitize.test.ts +193 -0
  120. package/src/gateway/__tests__/session-store.test.ts +235 -0
  121. package/src/gateway/channel-manager.ts +349 -0
  122. package/src/gateway/channels/botcord.ts +605 -0
  123. package/src/gateway/channels/index.ts +6 -0
  124. package/src/gateway/channels/sanitize.ts +68 -0
  125. package/src/gateway/dispatcher.ts +554 -0
  126. package/src/gateway/gateway.ts +211 -0
  127. package/src/gateway/index.ts +29 -0
  128. package/src/gateway/log.ts +30 -0
  129. package/src/gateway/router.ts +60 -0
  130. package/src/gateway/runtimes/claude-code.ts +180 -0
  131. package/src/gateway/runtimes/codex.ts +312 -0
  132. package/src/gateway/runtimes/gemini.ts +43 -0
  133. package/src/gateway/runtimes/ndjson-stream.ts +225 -0
  134. package/src/gateway/runtimes/probe.ts +73 -0
  135. package/src/gateway/runtimes/registry.ts +143 -0
  136. package/src/gateway/session-store.ts +157 -0
  137. package/src/gateway/types.ts +325 -0
  138. package/src/index.ts +961 -0
  139. package/src/log.ts +47 -0
  140. package/src/provision.ts +879 -0
  141. package/src/room-context-fetcher.ts +124 -0
  142. package/src/room-context.ts +167 -0
  143. package/src/sender-classify.ts +46 -0
  144. package/src/snapshot-writer.ts +103 -0
  145. package/src/status-render.ts +132 -0
  146. package/src/system-context.ts +162 -0
  147. package/src/turn-text.ts +93 -0
  148. package/src/user-auth.ts +295 -0
  149. package/src/working-memory.ts +352 -0
@@ -0,0 +1,554 @@
1
+ import type { GatewayLogger } from "./log.js";
2
+ import { resolveRoute } from "./router.js";
3
+ import { sessionKey, type SessionStore } from "./session-store.js";
4
+ import type {
5
+ ChannelAdapter,
6
+ GatewayConfig,
7
+ GatewayInboundEnvelope,
8
+ GatewayOutboundMessage,
9
+ GatewayRoute,
10
+ GatewaySessionEntry,
11
+ InboundObserver,
12
+ QueueMode,
13
+ RuntimeAdapter,
14
+ StreamBlock,
15
+ SystemContextBuilder,
16
+ TurnStatusSnapshot,
17
+ UserTurnBuilder,
18
+ } from "./types.js";
19
+
20
+ const DEFAULT_TURN_TIMEOUT_MS = 10 * 60 * 1000;
21
+
22
+ /** Factory signature for building a runtime adapter at turn dispatch time. */
23
+ export type RuntimeFactory = (
24
+ runtimeId: string,
25
+ extraArgs?: string[],
26
+ ) => RuntimeAdapter;
27
+
28
+ /** Constructor options for `Dispatcher`. */
29
+ export interface DispatcherOptions {
30
+ config: GatewayConfig;
31
+ channels: Map<string, ChannelAdapter>;
32
+ runtime: RuntimeFactory;
33
+ sessionStore: SessionStore;
34
+ log: GatewayLogger;
35
+ turnTimeoutMs?: number;
36
+ /**
37
+ * Live reference to the Gateway's managed-route map. Dispatcher reads
38
+ * `values()` on every `resolveRoute` call so hot-add/remove take effect
39
+ * without restart.
40
+ */
41
+ managedRoutes?: Map<string, GatewayRoute>;
42
+ /**
43
+ * Optional hook producing a `systemContext` string for each turn. Result is
44
+ * forwarded to the runtime as `RuntimeRunOptions.systemContext`. Errors are
45
+ * swallowed and logged — they never abort the turn.
46
+ */
47
+ buildSystemContext?: SystemContextBuilder;
48
+ /**
49
+ * Optional side-effect hook invoked after ack, before the turn runs.
50
+ * Intended for bookkeeping (e.g. activity tracking). Errors are logged
51
+ * and suppressed so the turn is never cancelled by observer failure.
52
+ */
53
+ onInbound?: InboundObserver;
54
+ /**
55
+ * Optional composer that wraps `message.text` with channel-specific
56
+ * metadata (sender label, room header, reply hints…) before it is handed
57
+ * to the runtime. Skipped if it throws — the raw trimmed text is used as
58
+ * a fallback so a buggy composer cannot drop turns.
59
+ */
60
+ composeUserTurn?: UserTurnBuilder;
61
+ }
62
+
63
+ interface TurnSlot {
64
+ controller: AbortController;
65
+ timedOut: boolean;
66
+ snapshot: TurnStatusSnapshot;
67
+ done: Promise<void>;
68
+ }
69
+
70
+ interface QueueState {
71
+ /** The currently executing turn on this queue key, if any. */
72
+ current: TurnSlot | null;
73
+ /** Tail of the serial-mode queue — chained via promises; replaced each append. */
74
+ tail: Promise<void>;
75
+ /**
76
+ * Generation counter bumped every time a cancel-previous turn arrives.
77
+ * Any in-flight cancel-previous arrival captures the value at entry; if a
78
+ * newer arrival bumps the counter while it's still awaiting the prior
79
+ * turn's teardown, the older one observes the mismatch and drops out. This
80
+ * closes the race where two cancel-previous calls could both observe
81
+ * `current === null` after an abort and run concurrently.
82
+ */
83
+ cancelGen: number;
84
+ }
85
+
86
+ /**
87
+ * Gateway dispatcher: consumes `GatewayInboundEnvelope` and drives a runtime
88
+ * turn per message, respecting queue mode, trust level, streaming, and
89
+ * session persistence rules from the plan (§7/§9/§10/§11/§12/§13).
90
+ *
91
+ * Deliberate deviation from daemon: this core does NOT wrap inbound text in
92
+ * BotCord-style XML envelopes for untrusted content. The channel adapter is
93
+ * responsible for any channel-specific sanitization; the dispatcher passes
94
+ * `message.text` through to the runtime as-is (plan §15).
95
+ */
96
+ export class Dispatcher {
97
+ private readonly config: GatewayConfig;
98
+ private readonly channels: Map<string, ChannelAdapter>;
99
+ private readonly runtimeFactory: RuntimeFactory;
100
+ private readonly sessionStore: SessionStore;
101
+ private readonly log: GatewayLogger;
102
+ private readonly turnTimeoutMs: number;
103
+ private readonly buildSystemContext?: SystemContextBuilder;
104
+ private readonly onInbound?: InboundObserver;
105
+ private readonly composeUserTurn?: UserTurnBuilder;
106
+ private readonly managedRoutes?: Map<string, GatewayRoute>;
107
+ private readonly queues: Map<string, QueueState> = new Map();
108
+
109
+ constructor(opts: DispatcherOptions) {
110
+ this.config = opts.config;
111
+ this.channels = opts.channels;
112
+ this.runtimeFactory = opts.runtime;
113
+ this.sessionStore = opts.sessionStore;
114
+ this.log = opts.log;
115
+ this.turnTimeoutMs = opts.turnTimeoutMs ?? DEFAULT_TURN_TIMEOUT_MS;
116
+ this.buildSystemContext = opts.buildSystemContext;
117
+ this.onInbound = opts.onInbound;
118
+ this.composeUserTurn = opts.composeUserTurn;
119
+ this.managedRoutes = opts.managedRoutes;
120
+ }
121
+
122
+ /** Consume one inbound envelope, ack it once ownership is decided, then run its turn. */
123
+ async handle(envelope: GatewayInboundEnvelope): Promise<void> {
124
+ const msg = envelope.message;
125
+
126
+ // Skip rule: empty/whitespace text.
127
+ const rawText = typeof msg.text === "string" ? msg.text.trim() : "";
128
+ if (!rawText) {
129
+ this.log.debug("dispatcher skip: empty text", { messageId: msg.id });
130
+ await this.safeAck(envelope);
131
+ return;
132
+ }
133
+
134
+ // Skip rule: echo from the agent itself (own agent output looped back).
135
+ // Owner/human messages in dashboard rooms share the agent's id as sender.id
136
+ // but carry sender.kind === "user", so we only skip when kind === "agent".
137
+ if (msg.sender.id === msg.accountId && msg.sender.kind === "agent") {
138
+ this.log.debug("dispatcher skip: own message", { messageId: msg.id });
139
+ await this.safeAck(envelope);
140
+ return;
141
+ }
142
+
143
+ // Compose the final user-turn text. The composer can enrich the raw
144
+ // message with sender label, room header, NO_REPLY hint, etc. — anything
145
+ // that should land in the runtime transcript. Failures fall back to the
146
+ // raw trimmed text so a buggy composer cannot drop turns.
147
+ let text = rawText;
148
+ if (this.composeUserTurn) {
149
+ try {
150
+ const composed = this.composeUserTurn(msg);
151
+ if (typeof composed === "string" && composed.length > 0) {
152
+ text = composed;
153
+ }
154
+ } catch (err) {
155
+ this.log.warn("dispatcher: composeUserTurn threw — using raw text", {
156
+ messageId: msg.id,
157
+ error: err instanceof Error ? err.message : String(err),
158
+ });
159
+ }
160
+ }
161
+
162
+ const managed = this.managedRoutes ? Array.from(this.managedRoutes.values()) : undefined;
163
+ const route = resolveRoute(msg, this.config, managed);
164
+ const mode = resolveQueueMode(route, msg.conversation.kind);
165
+ const queueKey = buildQueueKey(msg);
166
+
167
+ // Ack immediately: once the dispatcher has a route + queue key, ownership is decided.
168
+ await this.safeAck(envelope);
169
+
170
+ // Notify the optional observer (activity tracking, metrics, etc.) as soon
171
+ // as the dispatcher owns the message. Errors must not abort the turn.
172
+ if (this.onInbound) {
173
+ try {
174
+ await this.onInbound(msg);
175
+ } catch (err) {
176
+ this.log.warn("dispatcher: onInbound threw — continuing", {
177
+ messageId: msg.id,
178
+ error: err instanceof Error ? err.message : String(err),
179
+ });
180
+ }
181
+ }
182
+
183
+ const channel = this.channels.get(msg.channel);
184
+ if (!channel) {
185
+ this.log.warn("dispatcher: unknown channel for outbound reply", {
186
+ channel: msg.channel,
187
+ messageId: msg.id,
188
+ });
189
+ return;
190
+ }
191
+
192
+ if (mode === "cancel-previous") {
193
+ await this.runCancelPrevious(queueKey, route, text, msg, channel);
194
+ } else {
195
+ await this.runSerial(queueKey, route, text, msg, channel);
196
+ }
197
+ }
198
+
199
+ /** Snapshot of currently running turns keyed by queue key. */
200
+ turns(): Record<string, TurnStatusSnapshot> {
201
+ const out: Record<string, TurnStatusSnapshot> = {};
202
+ for (const [key, q] of this.queues) {
203
+ if (q.current) out[key] = { ...q.current.snapshot };
204
+ }
205
+ return out;
206
+ }
207
+
208
+ // ---------------------------------------------------------------------------
209
+ // Internals
210
+ // ---------------------------------------------------------------------------
211
+
212
+ private async safeAck(env: GatewayInboundEnvelope): Promise<void> {
213
+ const accept = env.ack?.accept;
214
+ if (!accept) return;
215
+ try {
216
+ await accept.call(env.ack);
217
+ } catch (err) {
218
+ this.log.warn("dispatcher: ack.accept failed", {
219
+ messageId: env.message.id,
220
+ error: err instanceof Error ? err.message : String(err),
221
+ });
222
+ }
223
+ }
224
+
225
+ private getQueue(key: string): QueueState {
226
+ let q = this.queues.get(key);
227
+ if (!q) {
228
+ q = {
229
+ current: null,
230
+ tail: Promise.resolve(),
231
+ cancelGen: 0,
232
+ };
233
+ this.queues.set(key, q);
234
+ }
235
+ return q;
236
+ }
237
+
238
+ private async runCancelPrevious(
239
+ queueKey: string,
240
+ route: GatewayRoute,
241
+ text: string,
242
+ msg: GatewayInboundEnvelope["message"],
243
+ channel: ChannelAdapter,
244
+ ): Promise<void> {
245
+ const q = this.getQueue(queueKey);
246
+ // Bump the generation on every arrival. Older arrivals still awaiting
247
+ // the prior turn's teardown will observe `myGen !== q.cancelGen` when
248
+ // they resume and drop out, so only the newest message reaches runTurn.
249
+ q.cancelGen += 1;
250
+ const myGen = q.cancelGen;
251
+ const prev = q.current;
252
+ if (prev) {
253
+ this.log.info("dispatcher: cancelling previous turn", { queueKey });
254
+ prev.controller.abort();
255
+ // Wait for it to finish cleanup (it won't reply, won't persist).
256
+ await prev.done.catch(() => undefined);
257
+ }
258
+ // After the await, a newer cancel-previous may have arrived and either
259
+ // already fired its own abort + runTurn, or be mid-await itself. If so,
260
+ // drop out silently — the newest turn is the only one that should run.
261
+ if (myGen !== q.cancelGen) {
262
+ this.log.info("dispatcher: cancel-previous superseded", { queueKey });
263
+ return;
264
+ }
265
+ await this.runTurn(queueKey, route, text, msg, channel);
266
+ }
267
+
268
+ private async runSerial(
269
+ queueKey: string,
270
+ route: GatewayRoute,
271
+ text: string,
272
+ msg: GatewayInboundEnvelope["message"],
273
+ channel: ChannelAdapter,
274
+ ): Promise<void> {
275
+ const q = this.getQueue(queueKey);
276
+ const prev = q.tail;
277
+ const next = prev.then(() => this.runTurn(queueKey, route, text, msg, channel));
278
+ q.tail = next.catch(() => undefined);
279
+ return next;
280
+ }
281
+
282
+ private async runTurn(
283
+ queueKey: string,
284
+ route: GatewayRoute,
285
+ text: string,
286
+ msg: GatewayInboundEnvelope["message"],
287
+ channel: ChannelAdapter,
288
+ ): Promise<void> {
289
+ const q = this.getQueue(queueKey);
290
+ const controller = new AbortController();
291
+ const startedAt = Date.now();
292
+ const snapshot: TurnStatusSnapshot = {
293
+ key: queueKey,
294
+ channel: msg.channel,
295
+ accountId: msg.accountId,
296
+ conversationId: msg.conversation.id,
297
+ runtime: route.runtime,
298
+ cwd: route.cwd,
299
+ startedAt,
300
+ };
301
+
302
+ let resolveDone!: () => void;
303
+ const done = new Promise<void>((res) => {
304
+ resolveDone = res;
305
+ });
306
+ const slot: TurnSlot = { controller, timedOut: false, snapshot, done };
307
+ q.current = slot;
308
+
309
+ // Hard-cap turn with a timeout.
310
+ const timer = setTimeout(() => {
311
+ slot.timedOut = true;
312
+ this.log.warn("dispatcher: turn timed out", {
313
+ queueKey,
314
+ timeoutMs: this.turnTimeoutMs,
315
+ });
316
+ controller.abort();
317
+ }, this.turnTimeoutMs);
318
+ if (typeof timer.unref === "function") timer.unref();
319
+
320
+ const key = sessionKey({
321
+ runtime: route.runtime,
322
+ channel: msg.channel,
323
+ accountId: msg.accountId,
324
+ conversationKind: msg.conversation.kind,
325
+ conversationId: msg.conversation.id,
326
+ threadId: msg.conversation.threadId ?? null,
327
+ });
328
+ const entry = this.sessionStore.get(key);
329
+ const sessionId = entry?.runtimeSessionId ?? null;
330
+ const trustLevel = route.trustLevel ?? "trusted";
331
+
332
+ const streamable = msg.trace?.streamable === true;
333
+ const traceId = msg.trace?.id;
334
+ const canStream =
335
+ streamable && typeof traceId === "string" && typeof channel.streamBlock === "function";
336
+ const onBlock = canStream
337
+ ? (block: StreamBlock) => {
338
+ // Fire-and-forget: stream errors must not break the turn.
339
+ channel
340
+ .streamBlock!({
341
+ traceId: traceId!,
342
+ accountId: msg.accountId,
343
+ conversationId: msg.conversation.id,
344
+ block,
345
+ log: this.log,
346
+ })
347
+ .catch((err) => {
348
+ this.log.warn("dispatcher: streamBlock failed", {
349
+ traceId,
350
+ error: err instanceof Error ? err.message : String(err),
351
+ });
352
+ });
353
+ }
354
+ : undefined;
355
+
356
+ // Compute systemContext right before dispatch. The builder must NOT block
357
+ // the turn on failure — log and continue so a flaky memory read can't
358
+ // silence the agent.
359
+ let systemContext: string | undefined;
360
+ if (this.buildSystemContext) {
361
+ try {
362
+ const result = await this.buildSystemContext(msg);
363
+ if (typeof result === "string" && result.length > 0) {
364
+ systemContext = result;
365
+ }
366
+ } catch (err) {
367
+ this.log.warn("buildSystemContext threw — continuing without systemContext", {
368
+ error: err instanceof Error ? err.message : String(err),
369
+ messageId: msg.id,
370
+ });
371
+ }
372
+ }
373
+
374
+ const runtime = this.runtimeFactory(route.runtime, route.extraArgs);
375
+ let result: { text: string; newSessionId: string; costUsd?: number; error?: string } | undefined;
376
+ let threw: unknown;
377
+ try {
378
+ try {
379
+ result = await runtime.run({
380
+ text,
381
+ sessionId,
382
+ cwd: route.cwd,
383
+ accountId: msg.accountId,
384
+ extraArgs: route.extraArgs,
385
+ signal: controller.signal,
386
+ trustLevel,
387
+ systemContext,
388
+ onBlock,
389
+ });
390
+ } catch (err) {
391
+ threw = err;
392
+ } finally {
393
+ clearTimeout(timer);
394
+ }
395
+
396
+ // Re-check the abort signal AFTER runtime.run resolves but BEFORE any
397
+ // side effects (session write, reply send). This closes the race where
398
+ // a cancel-previous arrives between runtime.run resolving and the
399
+ // post-runtime block running: keeping `q.current` pointing at this slot
400
+ // until after the reply lets the new arrival trip our abort signal, and
401
+ // this check then drops us silently. Timed-out turns still fall through
402
+ // to send their error reply.
403
+ if (controller.signal.aborted && !slot.timedOut) {
404
+ return;
405
+ }
406
+
407
+ if (slot.timedOut) {
408
+ await this.sendReply(channel, {
409
+ channel: msg.channel,
410
+ accountId: msg.accountId,
411
+ conversationId: msg.conversation.id,
412
+ threadId: msg.conversation.threadId ?? null,
413
+ text: `⚠️ Runtime timeout after ${Math.round(this.turnTimeoutMs / 60000)} minute(s); aborted`,
414
+ replyTo: msg.id,
415
+ traceId: msg.trace?.id ?? null,
416
+ });
417
+ return;
418
+ }
419
+
420
+ if (threw) {
421
+ this.log.error("dispatcher: runtime threw", {
422
+ queueKey,
423
+ runtime: route.runtime,
424
+ error: threw instanceof Error ? threw.message : String(threw),
425
+ });
426
+ const shortMsg = threw instanceof Error ? threw.message : String(threw);
427
+ await this.sendReply(channel, {
428
+ channel: msg.channel,
429
+ accountId: msg.accountId,
430
+ conversationId: msg.conversation.id,
431
+ threadId: msg.conversation.threadId ?? null,
432
+ text: `⚠️ Runtime error: ${truncate(shortMsg, 500)}`,
433
+ replyTo: msg.id,
434
+ traceId: msg.trace?.id ?? null,
435
+ });
436
+ return;
437
+ }
438
+
439
+ if (!result) return;
440
+
441
+ // Persist session before reply so next turn sees the new id even if send fails.
442
+ //
443
+ // Adapter contract:
444
+ // result.newSessionId truthy → upsert the entry
445
+ // result.newSessionId empty + had-inbound-sessionId + result.error
446
+ // → the prior session is dead (e.g. Claude Code
447
+ // "--resume <missing-uuid>"); delete the entry so
448
+ // we don't keep resuming a stale id every turn
449
+ // otherwise → no-op (e.g. codex intentionally never persists)
450
+ if (result.newSessionId) {
451
+ const session: GatewaySessionEntry = {
452
+ key,
453
+ runtime: route.runtime,
454
+ runtimeSessionId: result.newSessionId,
455
+ channel: msg.channel,
456
+ accountId: msg.accountId,
457
+ conversationKind: msg.conversation.kind,
458
+ conversationId: msg.conversation.id,
459
+ threadId: msg.conversation.threadId ?? null,
460
+ cwd: route.cwd,
461
+ updatedAt: Date.now(),
462
+ };
463
+ try {
464
+ const prevRuntimeSessionId = sessionId;
465
+ await this.sessionStore.set(session);
466
+ this.log.debug("dispatcher: persisted runtime session", {
467
+ key,
468
+ prevRuntimeSessionId,
469
+ nextRuntimeSessionId: result.newSessionId,
470
+ });
471
+ } catch (err) {
472
+ this.log.warn("dispatcher: session-store.set failed", {
473
+ key,
474
+ error: err instanceof Error ? err.message : String(err),
475
+ });
476
+ }
477
+ } else if (sessionId && result.error) {
478
+ try {
479
+ await this.sessionStore.delete(key);
480
+ this.log.info("dispatcher: dropped stale runtime session", {
481
+ key,
482
+ prevRuntimeSessionId: sessionId,
483
+ error: result.error,
484
+ });
485
+ } catch (err) {
486
+ this.log.warn("dispatcher: session-store.delete failed", {
487
+ key,
488
+ error: err instanceof Error ? err.message : String(err),
489
+ });
490
+ }
491
+ }
492
+
493
+ const replyText = (result.text || "").trim();
494
+ if (!replyText) return;
495
+
496
+ // One last abort check immediately before the send. Narrows the window
497
+ // in which a cancel-previous arriving during session-store.set could
498
+ // still slip a stale reply past us.
499
+ if (controller.signal.aborted && !slot.timedOut) {
500
+ return;
501
+ }
502
+
503
+ await this.sendReply(channel, {
504
+ channel: msg.channel,
505
+ accountId: msg.accountId,
506
+ conversationId: msg.conversation.id,
507
+ threadId: msg.conversation.threadId ?? null,
508
+ text: replyText,
509
+ replyTo: msg.id,
510
+ traceId: msg.trace?.id ?? null,
511
+ });
512
+ } finally {
513
+ // Clear slot ownership AFTER the reply has been sent (or skipped).
514
+ // Only then do cancel-previous arrivals stop finding this slot — which
515
+ // is exactly what we want: while we're in the post-runtime window, a
516
+ // newer arrival should find `q.current === slot`, call `abort()`, and
517
+ // let our abort-checks above drop this turn silently.
518
+ if (q.current === slot) q.current = null;
519
+ resolveDone();
520
+ }
521
+ }
522
+
523
+ private async sendReply(
524
+ channel: ChannelAdapter,
525
+ outbound: GatewayOutboundMessage,
526
+ ): Promise<void> {
527
+ try {
528
+ await channel.send({ message: outbound, log: this.log });
529
+ } catch (err) {
530
+ this.log.warn("dispatcher: channel.send failed", {
531
+ channel: outbound.channel,
532
+ conversationId: outbound.conversationId,
533
+ error: err instanceof Error ? err.message : String(err),
534
+ });
535
+ }
536
+ }
537
+ }
538
+
539
+ function buildQueueKey(msg: GatewayInboundEnvelope["message"]): string {
540
+ const thread = msg.conversation.threadId ?? "";
541
+ return `${msg.channel}:${msg.accountId}:${msg.conversation.id}:${thread}`;
542
+ }
543
+
544
+ function resolveQueueMode(
545
+ route: GatewayRoute,
546
+ kind: "direct" | "group",
547
+ ): QueueMode {
548
+ if (route.queueMode) return route.queueMode;
549
+ return kind === "direct" ? "cancel-previous" : "serial";
550
+ }
551
+
552
+ function truncate(s: string, max: number): string {
553
+ return s.length <= max ? s : s.slice(0, max) + "…";
554
+ }