@drisp/cli 0.4.2 → 0.4.5

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.
@@ -4,7 +4,7 @@ import {
4
4
  loadOrCreateToken,
5
5
  requireTokenForBind,
6
6
  timingSafeTokenEqual
7
- } from "./chunk-6TJHAUNB.js";
7
+ } from "./chunk-M44KEGM7.js";
8
8
  import {
9
9
  CHANNEL_REQUEST_ID_REGEX,
10
10
  createUdsServerTransport,
@@ -23,7 +23,6 @@ import {
23
23
  } from "./chunk-4CRZXLIP.js";
24
24
 
25
25
  // src/gateway/daemon.ts
26
- import crypto from "crypto";
27
26
  import fs3 from "fs";
28
27
 
29
28
  // src/gateway/adapters/console/adapter.ts
@@ -35,6 +34,8 @@ import { WebSocket } from "ws";
35
34
  var DEFAULT_CONNECT_TIMEOUT_MS = 1e4;
36
35
  var DEFAULT_INITIAL_RECONNECT_MS = 1e3;
37
36
  var DEFAULT_MAX_RECONNECT_MS = 3e4;
37
+ var DEFAULT_HEARTBEAT_INTERVAL_MS = 3e4;
38
+ var DEFAULT_HEARTBEAT_TIMEOUT_MS = 9e4;
38
39
  function createConsoleBrokerClient(opts) {
39
40
  const hasStatic = typeof opts.pairingToken === "string" && opts.pairingToken.length > 0;
40
41
  const hasProvider = typeof opts.pairingTokenProvider === "function";
@@ -46,6 +47,8 @@ function createConsoleBrokerClient(opts) {
46
47
  const provider = hasStatic ? async () => opts.pairingToken : opts.pairingTokenProvider;
47
48
  const initialDelay = opts.reconnect?.initialDelayMs ?? DEFAULT_INITIAL_RECONNECT_MS;
48
49
  const maxDelay = opts.reconnect?.maxDelayMs ?? DEFAULT_MAX_RECONNECT_MS;
50
+ const heartbeatInterval = opts.heartbeat?.intervalMs ?? DEFAULT_HEARTBEAT_INTERVAL_MS;
51
+ const heartbeatTimeout = opts.heartbeat?.timeoutMs ?? DEFAULT_HEARTBEAT_TIMEOUT_MS;
49
52
  let ws = null;
50
53
  let ready = null;
51
54
  let closeRequested = false;
@@ -53,6 +56,8 @@ function createConsoleBrokerClient(opts) {
53
56
  let reconnectTimer = null;
54
57
  let lastHello = null;
55
58
  let currentToken = null;
59
+ let heartbeatTimer = null;
60
+ let lastPongAt = null;
56
61
  const frameHandlers = /* @__PURE__ */ new Set();
57
62
  const closeHandlers = /* @__PURE__ */ new Set();
58
63
  const readyHandlers = /* @__PURE__ */ new Set();
@@ -61,6 +66,44 @@ function createConsoleBrokerClient(opts) {
61
66
  if (!currentToken) return message;
62
67
  return message.split(currentToken).join(tokenRedacted);
63
68
  }
69
+ function stopHeartbeat() {
70
+ if (heartbeatTimer) {
71
+ clearInterval(heartbeatTimer);
72
+ heartbeatTimer = null;
73
+ }
74
+ lastPongAt = null;
75
+ }
76
+ function startHeartbeat(currentWs) {
77
+ stopHeartbeat();
78
+ lastPongAt = Date.now();
79
+ heartbeatTimer = setInterval(() => {
80
+ if (currentWs !== ws || currentWs.readyState !== WebSocket.OPEN) {
81
+ stopHeartbeat();
82
+ return;
83
+ }
84
+ if (lastPongAt !== null && Date.now() - lastPongAt >= heartbeatTimeout) {
85
+ opts.log(
86
+ "warn",
87
+ "console broker: heartbeat watchdog fired, no pong received \u2014 terminating"
88
+ );
89
+ stopHeartbeat();
90
+ try {
91
+ currentWs.terminate();
92
+ } catch {
93
+ }
94
+ return;
95
+ }
96
+ const ping = {
97
+ kind: "console.ping",
98
+ frameId: makeFrameId(),
99
+ sentAt: Date.now()
100
+ };
101
+ try {
102
+ currentWs.send(JSON.stringify(ping));
103
+ } catch {
104
+ }
105
+ }, heartbeatInterval);
106
+ }
64
107
  function emitClose(reason) {
65
108
  for (const h of [...closeHandlers]) {
66
109
  try {
@@ -216,6 +259,10 @@ function createConsoleBrokerClient(opts) {
216
259
  );
217
260
  return;
218
261
  }
262
+ if (parsed.kind === "console.pong") {
263
+ lastPongAt = Date.now();
264
+ return;
265
+ }
219
266
  for (const h of [...frameHandlers]) {
220
267
  try {
221
268
  h(parsed);
@@ -240,12 +287,14 @@ function createConsoleBrokerClient(opts) {
240
287
  throw err;
241
288
  }
242
289
  next.on("close", (_code, reasonBuf) => {
290
+ stopHeartbeat();
243
291
  if (next !== ws) return;
244
292
  ws = null;
245
293
  ready = null;
246
294
  emitClose(reasonBuf.toString() || "closed");
247
295
  if (!closeRequested) scheduleReconnect();
248
296
  });
297
+ startHeartbeat(next);
249
298
  }
250
299
  async function connect(hello) {
251
300
  if (ws) throw new Error("console broker client already connected");
@@ -255,6 +304,7 @@ function createConsoleBrokerClient(opts) {
255
304
  }
256
305
  function close(reason) {
257
306
  closeRequested = true;
307
+ stopHeartbeat();
258
308
  if (reconnectTimer) {
259
309
  clearTimeout(reconnectTimer);
260
310
  reconnectTimer = null;
@@ -303,11 +353,11 @@ function makeFrameId() {
303
353
  }
304
354
 
305
355
  // src/gateway/adapters/console/adapter.ts
306
- var CONSOLE_ID = "console";
356
+ var CONSOLE_DEFAULT_ID = "console";
307
357
  var CLIENT_NAME = "athena-cli";
308
358
  var CLIENT_VERSION = "0.0.0";
309
359
  var ConsoleAdapter = class {
310
- id = CONSOLE_ID;
360
+ id;
311
361
  capabilities = {
312
362
  chat: true,
313
363
  threads: true,
@@ -319,8 +369,9 @@ var ConsoleAdapter = class {
319
369
  ctx = null;
320
370
  pendingPermissions = /* @__PURE__ */ new Map();
321
371
  pendingQuestions = /* @__PURE__ */ new Map();
322
- constructor(opts) {
372
+ constructor(opts, id = CONSOLE_DEFAULT_ID) {
323
373
  this.opts = opts;
374
+ this.id = id;
324
375
  }
325
376
  async start(ctx) {
326
377
  if (this.client) {
@@ -452,7 +503,7 @@ var ConsoleAdapter = class {
452
503
  if (!entry) return;
453
504
  this.pendingPermissions.delete(channelRequestId);
454
505
  entry.signal.removeEventListener("abort", entry.abortListener);
455
- entry.resolve({ kind: "verdict", behavior: decision, channelId: CONSOLE_ID });
506
+ entry.resolve({ kind: "verdict", behavior: decision, channelId: this.id });
456
507
  }
457
508
  disposePermissions(reason = "auto_resolved") {
458
509
  for (const [id, entry] of [...this.pendingPermissions.entries()]) {
@@ -521,7 +572,7 @@ var ConsoleAdapter = class {
521
572
  entry.resolve({ kind: "cancelled", reason: "auto_resolved" });
522
573
  return;
523
574
  }
524
- entry.resolve({ kind: "answer", answers: filtered, channelId: CONSOLE_ID });
575
+ entry.resolve({ kind: "answer", answers: filtered, channelId: this.id });
525
576
  }
526
577
  disposeQuestions(reason = "auto_resolved") {
527
578
  for (const [id, entry] of [...this.pendingQuestions.entries()]) {
@@ -562,7 +613,7 @@ var ConsoleAdapter = class {
562
613
  this.ctx?.log("warn", "console.message.in dropped: missing messageId");
563
614
  return;
564
615
  }
565
- const inbound = normalizeInbound(frame, this.opts.runnerId);
616
+ const inbound = normalizeInbound(frame, this.opts.runnerId, this.id);
566
617
  if (!inbound || !this.ctx) return;
567
618
  try {
568
619
  this.ctx.emitInbound(inbound);
@@ -673,13 +724,13 @@ function isValidConsoleAddress(value) {
673
724
  }
674
725
  return true;
675
726
  }
676
- function normalizeInbound(frame, runnerId) {
727
+ function normalizeInbound(frame, runnerId, channelId) {
677
728
  if (typeof frame.text !== "string" || frame.text.length === 0) return null;
678
729
  const userId = frame.address.userId ?? "console-user";
679
730
  const idempotencyKey = typeof frame.idempotencyKey === "string" && frame.idempotencyKey.length > 0 ? frame.idempotencyKey : `console:${runnerId}:${frame.messageId}`;
680
731
  return {
681
732
  location: {
682
- channelId: CONSOLE_ID,
733
+ channelId,
683
734
  accountId: frame.address.workspaceId ?? runnerId,
684
735
  peer: { id: userId, kind: "user" },
685
736
  ...frame.address.threadId !== void 0 ? { thread: { id: frame.address.threadId } } : frame.address.conversationId !== void 0 ? { thread: { id: frame.address.conversationId } } : {}
@@ -760,8 +811,8 @@ var consoleModule = {
760
811
  };
761
812
  return { ok: true, config };
762
813
  },
763
- create(config) {
764
- return new ConsoleAdapter(config);
814
+ create(config, instanceId) {
815
+ return new ConsoleAdapter(config, instanceId);
765
816
  }
766
817
  };
767
818
  function isLoopbackUrl(url) {
@@ -1470,9 +1521,9 @@ function buildCancelText(reason) {
1470
1521
  }
1471
1522
 
1472
1523
  // src/gateway/adapters/telegram/adapter.ts
1473
- var TELEGRAM_ID = "telegram";
1524
+ var TELEGRAM_DEFAULT_ID = "telegram";
1474
1525
  var TelegramAdapter = class {
1475
- id = TELEGRAM_ID;
1526
+ id;
1476
1527
  capabilities = {
1477
1528
  chat: true,
1478
1529
  threads: true,
@@ -1488,8 +1539,9 @@ var TelegramAdapter = class {
1488
1539
  lastInboundAt;
1489
1540
  lastTransportOk = true;
1490
1541
  ctx = null;
1491
- constructor(opts) {
1542
+ constructor(opts, id = TELEGRAM_DEFAULT_ID) {
1492
1543
  this.opts = opts;
1544
+ this.id = id;
1493
1545
  this.relay = new TelegramRelay({
1494
1546
  resolveTarget: () => {
1495
1547
  if (this.opts.defaultChatId === void 0) return null;
@@ -1588,7 +1640,7 @@ var TelegramAdapter = class {
1588
1640
  this.markHealth(true);
1589
1641
  continue;
1590
1642
  }
1591
- const inbound = normalizeInbound2(update, allow);
1643
+ const inbound = normalizeInbound2(update, allow, this.id);
1592
1644
  if (!inbound) continue;
1593
1645
  this.lastInboundAt = inbound.receivedAt;
1594
1646
  this.markHealth(true);
@@ -1626,7 +1678,7 @@ var TelegramAdapter = class {
1626
1678
  }
1627
1679
  }
1628
1680
  };
1629
- function normalizeInbound2(update, allow) {
1681
+ function normalizeInbound2(update, allow, channelId) {
1630
1682
  const message = update.message ?? update.edited_message;
1631
1683
  if (!message) return null;
1632
1684
  const text = message.text;
@@ -1641,7 +1693,7 @@ function normalizeInbound2(update, allow) {
1641
1693
  const threadId = typeof message.message_thread_id === "number" ? String(message.message_thread_id) : void 0;
1642
1694
  return {
1643
1695
  location: {
1644
- channelId: TELEGRAM_ID,
1696
+ channelId,
1645
1697
  accountId,
1646
1698
  ...isPrivate ? { peer: { id: chatId, kind: "user" } } : {
1647
1699
  room: {
@@ -1696,8 +1748,8 @@ var telegramModule = {
1696
1748
  };
1697
1749
  return { ok: true, config };
1698
1750
  },
1699
- create(config) {
1700
- return new TelegramAdapter(config);
1751
+ create(config, instanceId) {
1752
+ return new TelegramAdapter(config, instanceId);
1701
1753
  }
1702
1754
  };
1703
1755
 
@@ -1712,18 +1764,18 @@ function findAdapterModule(name) {
1712
1764
 
1713
1765
  // src/gateway/adapters/factory.ts
1714
1766
  function instantiateAdapter(sidecar) {
1715
- const module = findAdapterModule(sidecar.name);
1767
+ const module = findAdapterModule(sidecar.kind);
1716
1768
  if (!module) {
1717
- return { ok: false, reason: `unknown channel: ${sidecar.name}` };
1769
+ return { ok: false, reason: `unknown channel kind: ${sidecar.kind}` };
1718
1770
  }
1719
1771
  const parsed = module.parseConfig({
1720
1772
  options: sidecar.options,
1721
1773
  allowedUserIds: sidecar.allowedUserIds
1722
1774
  });
1723
1775
  if (!parsed.ok) {
1724
- return { ok: false, reason: `${sidecar.name}: ${parsed.reason}` };
1776
+ return { ok: false, reason: `${sidecar.instanceId}: ${parsed.reason}` };
1725
1777
  }
1726
- return { ok: true, adapter: module.create(parsed.config) };
1778
+ return { ok: true, adapter: module.create(parsed.config, sidecar.instanceId) };
1727
1779
  }
1728
1780
 
1729
1781
  // src/gateway/channelManager.ts
@@ -1873,8 +1925,7 @@ var ChannelManager = class {
1873
1925
  // src/gateway/control/handlers.ts
1874
1926
  import { createRequire } from "module";
1875
1927
 
1876
- // src/gateway/sessionRegistry.ts
1877
- import { randomUUID } from "crypto";
1928
+ // src/gateway/runtimeBindingStore.ts
1878
1929
  var AlreadyRegisteredError = class extends Error {
1879
1930
  code = "already_registered";
1880
1931
  constructor(existing) {
@@ -1891,120 +1942,145 @@ var NotRegisteredError = class extends Error {
1891
1942
  this.name = "NotRegisteredError";
1892
1943
  }
1893
1944
  };
1894
- var UnknownDispatchError = class extends Error {
1895
- code = "unknown_dispatch";
1896
- constructor(id) {
1897
- super(`unknown dispatchId: ${id}`);
1898
- this.name = "UnknownDispatchError";
1899
- }
1900
- };
1901
1945
  function maybeLastRebindAt(value) {
1902
1946
  return value !== void 0 ? { lastRebindAt: value } : {};
1903
1947
  }
1904
- var SessionRegistry = class {
1948
+ var RuntimeBindingStore = class {
1905
1949
  current = null;
1906
- binding = null;
1907
- dispatches = /* @__PURE__ */ new Map();
1908
- idFactory;
1950
+ bindingState = null;
1951
+ staleTimer = null;
1952
+ staleSince = null;
1953
+ gracePeriodMs;
1954
+ observers;
1909
1955
  now;
1910
1956
  constructor(opts = {}) {
1911
- this.idFactory = opts.idFactory ?? randomUUID;
1957
+ this.gracePeriodMs = opts.gracePeriodMs ?? 0;
1958
+ this.observers = opts.observers ?? {};
1912
1959
  this.now = opts.now ?? Date.now;
1913
1960
  }
1914
- register(input) {
1915
- if (this.current) {
1916
- if (this.current.runtimeId === input.runtimeId) {
1917
- this.current = {
1918
- ...this.current,
1919
- defaultAgentId: input.defaultAgentId,
1920
- pid: input.pid
1921
- };
1922
- return this.current;
1923
- }
1961
+ // ── lifecycle ─────────────────────────────────────────────
1962
+ /** Register a runtime and bind its connection in one atomic step. */
1963
+ bind(input) {
1964
+ const previous = this.bindingState;
1965
+ const wasStale = previous?.state === "stale";
1966
+ const staleSince = wasStale ? previous.staleSince : null;
1967
+ if (!this.current) {
1968
+ this.current = {
1969
+ runtimeId: input.runtimeId,
1970
+ defaultAgentId: input.defaultAgentId,
1971
+ pid: input.pid,
1972
+ registeredAt: this.now()
1973
+ };
1974
+ } else if (this.current.runtimeId === input.runtimeId) {
1975
+ this.current = {
1976
+ ...this.current,
1977
+ defaultAgentId: input.defaultAgentId,
1978
+ pid: input.pid
1979
+ };
1980
+ } else {
1924
1981
  throw new AlreadyRegisteredError(this.current);
1925
1982
  }
1926
- this.current = { ...input, registeredAt: this.now() };
1927
- return this.current;
1928
- }
1929
- bindConnection(runtimeId, connectionId) {
1930
- if (!this.current || this.current.runtimeId !== runtimeId) {
1931
- throw new NotRegisteredError();
1932
- }
1933
- const previous = this.binding;
1934
1983
  const now = this.now();
1935
- const isRebind = previous !== null && (previous.state === "stale" || previous.connectionId !== connectionId);
1984
+ const isRebind = previous !== null && (previous.state === "stale" || previous.connectionId !== input.connectionId);
1936
1985
  const lastRebindAt = isRebind ? now : previous?.lastRebindAt;
1937
1986
  const epoch = previous ? previous.epoch + (isRebind ? 1 : 0) : 1;
1938
- this.binding = {
1987
+ this.bindingState = {
1939
1988
  state: "active",
1940
- connectionId,
1989
+ connectionId: input.connectionId,
1941
1990
  boundAt: now,
1942
1991
  epoch,
1943
1992
  ...maybeLastRebindAt(lastRebindAt)
1944
1993
  };
1994
+ this.clearStaleTimer();
1995
+ if (wasStale && staleSince !== null) {
1996
+ this.observers.onRuntimeRebind?.({
1997
+ runtimeId: input.runtimeId,
1998
+ gapMs: now - staleSince,
1999
+ epoch: this.bindingState.epoch
2000
+ });
2001
+ }
2002
+ return { registeredAt: this.current.registeredAt };
1945
2003
  }
1946
- markConnectionStale(connectionId) {
1947
- if (!this.current || !this.binding || this.binding.connectionId !== connectionId || this.binding.state !== "active") {
2004
+ /** Fully unregister a runtime. Throws NotRegisteredError if id does not match. */
2005
+ unbind(runtimeId) {
2006
+ if (!this.current || this.current.runtimeId !== runtimeId) {
2007
+ throw new NotRegisteredError();
2008
+ }
2009
+ this.current = null;
2010
+ this.bindingState = null;
2011
+ this.clearStaleTimer();
2012
+ }
2013
+ /**
2014
+ * Called when the transport connection closes.
2015
+ * Returns the runtimeId if the close was for the current binding (caller should
2016
+ * clear the push handle); returns null if the connectionId was not recognised.
2017
+ */
2018
+ notifyConnectionClosed(connectionId) {
2019
+ if (!this.current || !this.bindingState || this.bindingState.connectionId !== connectionId) {
1948
2020
  return null;
1949
2021
  }
1950
- this.binding = {
2022
+ const runtimeId = this.current.runtimeId;
2023
+ const now = this.now();
2024
+ this.bindingState = {
1951
2025
  state: "stale",
1952
2026
  connectionId,
1953
- staleSince: this.now(),
1954
- epoch: this.binding.epoch,
1955
- ...maybeLastRebindAt(this.binding.lastRebindAt)
2027
+ staleSince: now,
2028
+ epoch: this.bindingState.epoch,
2029
+ ...maybeLastRebindAt(this.bindingState.lastRebindAt)
1956
2030
  };
1957
- return this.current.runtimeId;
2031
+ if (this.gracePeriodMs <= 0) {
2032
+ this.current = null;
2033
+ this.bindingState = null;
2034
+ this.observers.onRuntimeConnectionLost?.({ runtimeId, graceful: false });
2035
+ return runtimeId;
2036
+ }
2037
+ this.staleSince = now;
2038
+ this.staleTimer = setTimeout(() => {
2039
+ this.expireStaleBinding(runtimeId);
2040
+ }, this.gracePeriodMs);
2041
+ return runtimeId;
1958
2042
  }
2043
+ stop() {
2044
+ this.clearStaleTimer();
2045
+ }
2046
+ // ── reads ─────────────────────────────────────────────────
1959
2047
  hasActiveBinding(runtimeId) {
1960
- if (!this.current || !this.binding || this.binding.state !== "active") {
2048
+ if (!this.current || !this.bindingState || this.bindingState.state !== "active") {
1961
2049
  return false;
1962
2050
  }
1963
2051
  return runtimeId === void 0 || this.current.runtimeId === runtimeId;
1964
2052
  }
2053
+ getCurrent() {
2054
+ return this.current;
2055
+ }
1965
2056
  getBinding() {
1966
- return this.binding;
2057
+ return this.bindingState;
1967
2058
  }
1968
2059
  getRuntimeIdByConnection(connectionId) {
1969
- if (!this.current || !this.binding) return null;
1970
- return this.binding.connectionId === connectionId ? this.current.runtimeId : null;
1971
- }
1972
- unregister(runtimeId) {
1973
- if (!this.current || this.current.runtimeId !== runtimeId) {
1974
- throw new NotRegisteredError();
2060
+ if (!this.current || !this.bindingState) return null;
2061
+ return this.bindingState.connectionId === connectionId ? this.current.runtimeId : null;
2062
+ }
2063
+ // ── private ───────────────────────────────────────────────
2064
+ expireStaleBinding(runtimeId) {
2065
+ this.staleTimer = null;
2066
+ const since = this.staleSince;
2067
+ this.staleSince = null;
2068
+ if (!this.current || this.current.runtimeId !== runtimeId || this.hasActiveBinding(runtimeId)) {
2069
+ return;
1975
2070
  }
1976
2071
  this.current = null;
1977
- this.binding = null;
1978
- this.dispatches.clear();
1979
- }
1980
- getCurrent() {
1981
- return this.current;
1982
- }
1983
- beginDispatch(input) {
1984
- if (!this.current) {
1985
- throw new NotRegisteredError();
2072
+ this.bindingState = null;
2073
+ this.observers.onRuntimeConnectionLost?.({ runtimeId, graceful: false });
2074
+ if (since !== null) {
2075
+ this.observers.onRuntimeExpired?.({ runtimeId, gapMs: this.now() - since });
1986
2076
  }
1987
- const dispatchId = this.idFactory();
1988
- const entry = {
1989
- dispatchId,
1990
- sessionKey: input.sessionKey,
1991
- agentId: input.agentId,
1992
- location: input.location,
1993
- createdAt: this.now()
1994
- };
1995
- this.dispatches.set(dispatchId, entry);
1996
- return entry;
1997
2077
  }
1998
- completeDispatch(dispatchId) {
1999
- const entry = this.dispatches.get(dispatchId);
2000
- if (!entry) {
2001
- throw new UnknownDispatchError(dispatchId);
2078
+ clearStaleTimer() {
2079
+ if (this.staleTimer) {
2080
+ clearTimeout(this.staleTimer);
2081
+ this.staleTimer = null;
2002
2082
  }
2003
- this.dispatches.delete(dispatchId);
2004
- return entry;
2005
- }
2006
- pendingDispatchCount() {
2007
- return this.dispatches.size;
2083
+ this.staleSince = null;
2008
2084
  }
2009
2085
  };
2010
2086
 
@@ -2014,7 +2090,7 @@ var cachedVersion = null;
2014
2090
  function readVersion() {
2015
2091
  if (cachedVersion !== null) return cachedVersion;
2016
2092
  try {
2017
- const injected = "0.4.2";
2093
+ const injected = "0.4.5";
2018
2094
  if (typeof injected === "string" && injected.length > 0) {
2019
2095
  cachedVersion = injected;
2020
2096
  return cachedVersion;
@@ -2029,273 +2105,258 @@ function readVersion() {
2029
2105
  }
2030
2106
  return cachedVersion;
2031
2107
  }
2108
+ var PING = {
2109
+ kind: "ping",
2110
+ handle: (_envelope, { ts, deps }) => {
2111
+ const payload = {
2112
+ pong: true,
2113
+ daemonPid: process.pid,
2114
+ uptimeMs: ts - deps.startedAt
2115
+ };
2116
+ return payload;
2117
+ }
2118
+ };
2119
+ var STATUS = {
2120
+ kind: "status",
2121
+ handle: (_envelope, { ts, deps }) => {
2122
+ const channels = (deps.channelManager?.listChannels() ?? []).map((c) => ({
2123
+ id: c.id,
2124
+ state: c.health?.transportOk === false ? "degraded" : "running",
2125
+ ...c.health?.at !== void 0 ? { lastHealthAt: c.health.at } : {},
2126
+ ...c.health?.note !== void 0 ? { note: c.health.note } : {}
2127
+ }));
2128
+ const payload = {
2129
+ daemonPid: process.pid,
2130
+ startedAt: deps.startedAt,
2131
+ uptimeMs: ts - deps.startedAt,
2132
+ version: readVersion(),
2133
+ listener: deps.getListener?.() ?? {
2134
+ kind: "uds",
2135
+ socketPath: "<unknown>"
2136
+ },
2137
+ channels,
2138
+ runtimes: runtimeStatusEntries(deps.pipeline)
2139
+ };
2140
+ return payload;
2141
+ }
2142
+ };
2143
+ var CHANNELS_RELOAD = {
2144
+ kind: "channels.reload",
2145
+ requires: ["reloadChannels"],
2146
+ unsupportedMessage: "channel reload not configured",
2147
+ handle: async (_envelope, { deps }) => {
2148
+ const payload = await deps.reloadChannels();
2149
+ return payload;
2150
+ }
2151
+ };
2152
+ var SESSION_REGISTER = {
2153
+ kind: "session.register",
2154
+ requires: ["pipeline"],
2155
+ unsupportedMessage: "session.register not configured",
2156
+ handle: (envelope, { deps, connection }) => {
2157
+ const req = envelope.payload;
2158
+ const reg = deps.pipeline.registerRuntime({
2159
+ runtimeId: req.runtimeId,
2160
+ defaultAgentId: req.defaultAgentId,
2161
+ pid: req.pid,
2162
+ connectionId: connection.connectionId,
2163
+ push: connection.push
2164
+ });
2165
+ const payload = {
2166
+ registeredAt: reg.registeredAt,
2167
+ gatewayStartedAt: deps.startedAt
2168
+ };
2169
+ return payload;
2170
+ }
2171
+ };
2172
+ var SESSION_UNREGISTER = {
2173
+ kind: "session.unregister",
2174
+ requires: ["pipeline"],
2175
+ unsupportedMessage: "session.unregister not configured",
2176
+ handle: (envelope, { deps, ts }) => {
2177
+ const req = envelope.payload;
2178
+ deps.pipeline.unregisterRuntime(req.runtimeId);
2179
+ const payload = {
2180
+ unregisteredAt: ts
2181
+ };
2182
+ return payload;
2183
+ }
2184
+ };
2185
+ var SESSION_TURN_COMPLETE = {
2186
+ kind: "session.turn.complete",
2187
+ requires: ["pipeline"],
2188
+ unsupportedMessage: "pipeline not configured",
2189
+ handle: async (envelope, { deps }) => {
2190
+ const req = envelope.payload;
2191
+ return await deps.pipeline.handleTurnComplete(req);
2192
+ }
2193
+ };
2194
+ var CHANNEL_SEND = {
2195
+ kind: "channel.send",
2196
+ requires: ["channelManager"],
2197
+ unsupportedMessage: "channel manager not configured",
2198
+ handle: async (envelope, { deps }) => {
2199
+ const req = envelope.payload;
2200
+ const result = await deps.channelManager.send(
2201
+ req.message.location.channelId,
2202
+ req.message
2203
+ );
2204
+ const payload = {
2205
+ providerMessageId: result.providerMessageId,
2206
+ deliveredAt: result.deliveredAt
2207
+ };
2208
+ return payload;
2209
+ }
2210
+ };
2211
+ var RELAY_PERMISSION_REQUEST = {
2212
+ kind: "relay.permission.request",
2213
+ requires: ["relayCoordinator"],
2214
+ unsupportedMessage: "relay coordinator not configured",
2215
+ requireRegisteredRuntime: true,
2216
+ handle: async (envelope, { deps, callerRuntimeId }) => {
2217
+ const req = envelope.payload;
2218
+ const broadcast = deps.relayCoordinator.requestPermission({
2219
+ ...req.channelRequestId !== void 0 ? { channelRequestId: req.channelRequestId } : {},
2220
+ toolName: req.toolName,
2221
+ description: req.description,
2222
+ inputPreview: req.inputPreview,
2223
+ ...req.ttlMs !== void 0 ? { ttlMs: req.ttlMs } : {},
2224
+ ...callerRuntimeId !== void 0 ? { runtimeId: callerRuntimeId } : {}
2225
+ });
2226
+ const result = await broadcast.result;
2227
+ const payload = {
2228
+ channelRequestId: broadcast.channelRequestId,
2229
+ result
2230
+ };
2231
+ return payload;
2232
+ }
2233
+ };
2234
+ var RELAY_PERMISSION_CANCEL = {
2235
+ kind: "relay.permission.cancel",
2236
+ requires: ["relayCoordinator"],
2237
+ unsupportedMessage: "relay coordinator not configured",
2238
+ requireRegisteredRuntime: true,
2239
+ handle: (envelope, { deps, callerRuntimeId }) => {
2240
+ const req = envelope.payload;
2241
+ const cancelled = deps.relayCoordinator.cancel(
2242
+ req.channelRequestId,
2243
+ req.reason,
2244
+ callerRuntimeId
2245
+ );
2246
+ const payload = { cancelled };
2247
+ return payload;
2248
+ }
2249
+ };
2250
+ var RELAY_QUESTION_REQUEST = {
2251
+ kind: "relay.question.request",
2252
+ requires: ["relayCoordinator"],
2253
+ unsupportedMessage: "relay coordinator not configured",
2254
+ requireRegisteredRuntime: true,
2255
+ handle: async (envelope, { deps, callerRuntimeId }) => {
2256
+ const req = envelope.payload;
2257
+ const broadcast = deps.relayCoordinator.requestQuestion({
2258
+ ...req.channelRequestId !== void 0 ? { channelRequestId: req.channelRequestId } : {},
2259
+ title: req.title,
2260
+ questions: req.questions,
2261
+ ...req.ttlMs !== void 0 ? { ttlMs: req.ttlMs } : {},
2262
+ ...callerRuntimeId !== void 0 ? { runtimeId: callerRuntimeId } : {}
2263
+ });
2264
+ const result = await broadcast.result;
2265
+ const payload = {
2266
+ channelRequestId: broadcast.channelRequestId,
2267
+ result
2268
+ };
2269
+ return payload;
2270
+ }
2271
+ };
2272
+ var RELAY_QUESTION_CANCEL = {
2273
+ kind: "relay.question.cancel",
2274
+ requires: ["relayCoordinator"],
2275
+ unsupportedMessage: "relay coordinator not configured",
2276
+ requireRegisteredRuntime: true,
2277
+ handle: (envelope, { deps, callerRuntimeId }) => {
2278
+ const req = envelope.payload;
2279
+ const cancelled = deps.relayCoordinator.cancel(
2280
+ req.channelRequestId,
2281
+ req.reason,
2282
+ callerRuntimeId
2283
+ );
2284
+ const payload = { cancelled };
2285
+ return payload;
2286
+ }
2287
+ };
2288
+ var HANDLERS = new Map(
2289
+ [
2290
+ PING,
2291
+ STATUS,
2292
+ CHANNELS_RELOAD,
2293
+ SESSION_REGISTER,
2294
+ SESSION_UNREGISTER,
2295
+ SESSION_TURN_COMPLETE,
2296
+ CHANNEL_SEND,
2297
+ RELAY_PERMISSION_REQUEST,
2298
+ RELAY_PERMISSION_CANCEL,
2299
+ RELAY_QUESTION_REQUEST,
2300
+ RELAY_QUESTION_CANCEL
2301
+ ].map((spec) => [spec.kind, spec])
2302
+ );
2032
2303
  function createDispatcher(deps) {
2033
2304
  const handle = async (envelope, connection) => {
2034
2305
  const ts = Date.now();
2035
- switch (envelope.kind) {
2036
- case "ping": {
2037
- const payload = {
2038
- pong: true,
2039
- daemonPid: process.pid,
2040
- uptimeMs: ts - deps.startedAt
2041
- };
2042
- return ok(envelope, ts, payload);
2043
- }
2044
- case "status": {
2045
- const channels = (deps.channelManager?.listChannels() ?? []).map((c) => ({
2046
- id: c.id,
2047
- state: c.health?.transportOk === false ? "degraded" : "running",
2048
- ...c.health?.at !== void 0 ? { lastHealthAt: c.health.at } : {},
2049
- ...c.health?.note !== void 0 ? { note: c.health.note } : {}
2050
- }));
2051
- const payload = {
2052
- daemonPid: process.pid,
2053
- startedAt: deps.startedAt,
2054
- uptimeMs: ts - deps.startedAt,
2055
- version: readVersion(),
2056
- listener: deps.getListener?.() ?? {
2057
- kind: "uds",
2058
- socketPath: "<unknown>"
2059
- },
2060
- channels,
2061
- runtimes: runtimeStatusEntries(deps.registry)
2062
- };
2063
- return ok(envelope, ts, payload);
2064
- }
2065
- case "channels.reload": {
2066
- if (!deps.reloadChannels) {
2067
- return error(
2068
- envelope,
2069
- ts,
2070
- "unsupported",
2071
- "channel reload not configured"
2072
- );
2073
- }
2074
- const payload = await deps.reloadChannels();
2075
- return ok(envelope, ts, payload);
2076
- }
2077
- case "session.register": {
2078
- if (!deps.registry)
2079
- return error(
2080
- envelope,
2081
- ts,
2082
- "unsupported",
2083
- "session.register not configured"
2084
- );
2085
- const req = envelope.payload;
2086
- try {
2087
- const reg = deps.registry.register({
2088
- runtimeId: req.runtimeId,
2089
- defaultAgentId: req.defaultAgentId,
2090
- pid: req.pid
2091
- });
2092
- deps.registerRuntimeConnection?.(req.runtimeId, connection);
2093
- try {
2094
- deps.dispatcher?.drainPending();
2095
- } catch (err) {
2096
- process.stderr.write(
2097
- `gateway: drainPending failed: ${err instanceof Error ? err.message : String(err)}
2098
- `
2099
- );
2100
- }
2101
- const payload = {
2102
- registeredAt: reg.registeredAt,
2103
- gatewayStartedAt: deps.startedAt
2104
- };
2105
- return ok(envelope, ts, payload);
2106
- } catch (err) {
2107
- if (err instanceof AlreadyRegisteredError) {
2108
- return error(envelope, ts, err.code, err.message);
2109
- }
2110
- throw err;
2111
- }
2112
- }
2113
- case "session.unregister": {
2114
- if (!deps.registry)
2115
- return error(
2116
- envelope,
2117
- ts,
2118
- "unsupported",
2119
- "session.unregister not configured"
2120
- );
2121
- const req = envelope.payload;
2122
- try {
2123
- deps.registry.unregister(req.runtimeId);
2124
- deps.unregisterRuntimeConnection?.(req.runtimeId);
2125
- const payload = {
2126
- unregisteredAt: ts
2127
- };
2128
- return ok(envelope, ts, payload);
2129
- } catch (err) {
2130
- if (err instanceof NotRegisteredError) {
2131
- return error(envelope, ts, err.code, err.message);
2132
- }
2133
- throw err;
2134
- }
2135
- }
2136
- case "session.turn.complete": {
2137
- if (!deps.dispatcher)
2138
- return error(
2139
- envelope,
2140
- ts,
2141
- "unsupported",
2142
- "dispatcher not configured"
2143
- );
2144
- const req = envelope.payload;
2145
- const result = await deps.dispatcher.handleTurnComplete(req);
2146
- return ok(envelope, ts, result);
2147
- }
2148
- case "channel.send": {
2149
- if (!deps.channelManager)
2150
- return error(
2151
- envelope,
2152
- ts,
2153
- "unsupported",
2154
- "channel manager not configured"
2155
- );
2156
- const req = envelope.payload;
2157
- const result = await deps.channelManager.send(
2158
- req.message.location.channelId,
2159
- req.message
2160
- );
2161
- const payload = {
2162
- providerMessageId: result.providerMessageId,
2163
- deliveredAt: result.deliveredAt
2164
- };
2165
- return ok(envelope, ts, payload);
2166
- }
2167
- case "relay.permission.request": {
2168
- if (!deps.relayCoordinator)
2169
- return error(
2170
- envelope,
2171
- ts,
2172
- "unsupported",
2173
- "relay coordinator not configured"
2174
- );
2175
- const req = envelope.payload;
2176
- const callerRuntimeId = deps.registry?.getRuntimeIdByConnection(connection.connectionId) ?? void 0;
2177
- if (deps.registry && callerRuntimeId === void 0) {
2178
- return error(
2179
- envelope,
2180
- ts,
2181
- "not_registered",
2182
- "relay.permission.request requires a registered runtime connection"
2183
- );
2184
- }
2185
- const broadcast = deps.relayCoordinator.requestPermission({
2186
- ...req.channelRequestId !== void 0 ? { channelRequestId: req.channelRequestId } : {},
2187
- toolName: req.toolName,
2188
- description: req.description,
2189
- inputPreview: req.inputPreview,
2190
- ...req.ttlMs !== void 0 ? { ttlMs: req.ttlMs } : {},
2191
- ...callerRuntimeId !== void 0 ? { runtimeId: callerRuntimeId } : {}
2192
- });
2193
- const result = await broadcast.result;
2194
- const payload = {
2195
- channelRequestId: broadcast.channelRequestId,
2196
- result
2197
- };
2198
- return ok(envelope, Date.now(), payload);
2199
- }
2200
- case "relay.permission.cancel": {
2201
- if (!deps.relayCoordinator)
2202
- return error(
2203
- envelope,
2204
- ts,
2205
- "unsupported",
2206
- "relay coordinator not configured"
2207
- );
2208
- const req = envelope.payload;
2209
- const callerRuntimeId = deps.registry?.getRuntimeIdByConnection(connection.connectionId) ?? void 0;
2210
- if (deps.registry && callerRuntimeId === void 0) {
2211
- return error(
2212
- envelope,
2213
- ts,
2214
- "not_registered",
2215
- "relay.permission.cancel requires a registered runtime connection"
2216
- );
2217
- }
2218
- const cancelled = deps.relayCoordinator.cancel(
2219
- req.channelRequestId,
2220
- req.reason,
2221
- callerRuntimeId
2222
- );
2223
- const payload = { cancelled };
2224
- return ok(envelope, ts, payload);
2225
- }
2226
- case "relay.question.request": {
2227
- if (!deps.relayCoordinator)
2228
- return error(
2229
- envelope,
2230
- ts,
2231
- "unsupported",
2232
- "relay coordinator not configured"
2233
- );
2234
- const req = envelope.payload;
2235
- const callerRuntimeId = deps.registry?.getRuntimeIdByConnection(connection.connectionId) ?? void 0;
2236
- if (deps.registry && callerRuntimeId === void 0) {
2237
- return error(
2238
- envelope,
2239
- ts,
2240
- "not_registered",
2241
- "relay.question.request requires a registered runtime connection"
2242
- );
2243
- }
2244
- const broadcast = deps.relayCoordinator.requestQuestion({
2245
- ...req.channelRequestId !== void 0 ? { channelRequestId: req.channelRequestId } : {},
2246
- title: req.title,
2247
- questions: req.questions,
2248
- ...req.ttlMs !== void 0 ? { ttlMs: req.ttlMs } : {},
2249
- ...callerRuntimeId !== void 0 ? { runtimeId: callerRuntimeId } : {}
2250
- });
2251
- const result = await broadcast.result;
2252
- const payload = {
2253
- channelRequestId: broadcast.channelRequestId,
2254
- result
2255
- };
2256
- return ok(envelope, Date.now(), payload);
2257
- }
2258
- case "relay.question.cancel": {
2259
- if (!deps.relayCoordinator)
2306
+ const spec = HANDLERS.get(envelope.kind);
2307
+ if (!spec) {
2308
+ return error(
2309
+ envelope,
2310
+ ts,
2311
+ "unknown_kind",
2312
+ `unknown kind: ${envelope.kind}`
2313
+ );
2314
+ }
2315
+ if (spec.requires) {
2316
+ for (const name of spec.requires) {
2317
+ if (deps[name] === void 0) {
2260
2318
  return error(
2261
2319
  envelope,
2262
2320
  ts,
2263
2321
  "unsupported",
2264
- "relay coordinator not configured"
2265
- );
2266
- const req = envelope.payload;
2267
- const callerRuntimeId = deps.registry?.getRuntimeIdByConnection(connection.connectionId) ?? void 0;
2268
- if (deps.registry && callerRuntimeId === void 0) {
2269
- return error(
2270
- envelope,
2271
- ts,
2272
- "not_registered",
2273
- "relay.question.cancel requires a registered runtime connection"
2322
+ spec.unsupportedMessage ?? `${spec.kind} not configured`
2274
2323
  );
2275
2324
  }
2276
- const cancelled = deps.relayCoordinator.cancel(
2277
- req.channelRequestId,
2278
- req.reason,
2279
- callerRuntimeId
2280
- );
2281
- const payload = { cancelled };
2282
- return ok(envelope, ts, payload);
2283
2325
  }
2284
- default:
2326
+ }
2327
+ let callerRuntimeId;
2328
+ if (spec.requireRegisteredRuntime) {
2329
+ callerRuntimeId = deps.pipeline?.getRuntimeIdByConnection(connection.connectionId) ?? void 0;
2330
+ if (deps.pipeline && callerRuntimeId === void 0) {
2285
2331
  return error(
2286
2332
  envelope,
2287
2333
  ts,
2288
- "unknown_kind",
2289
- `unknown kind: ${envelope.kind}`
2334
+ "not_registered",
2335
+ `${spec.kind} requires a registered runtime connection`
2290
2336
  );
2337
+ }
2338
+ }
2339
+ try {
2340
+ const payload = await spec.handle(envelope, {
2341
+ deps,
2342
+ connection,
2343
+ callerRuntimeId,
2344
+ ts
2345
+ });
2346
+ return ok(envelope, Date.now(), payload);
2347
+ } catch (err) {
2348
+ if (err instanceof AlreadyRegisteredError || err instanceof NotRegisteredError) {
2349
+ return error(envelope, ts, err.code, err.message);
2350
+ }
2351
+ throw err;
2291
2352
  }
2292
2353
  };
2293
2354
  return handle;
2294
2355
  }
2295
- function runtimeStatusEntries(registry) {
2296
- const runtime = registry?.getCurrent();
2297
- if (!runtime || !registry) return [];
2298
- const binding = registry.getBinding();
2356
+ function runtimeStatusEntries(pipeline) {
2357
+ const runtime = pipeline?.getCurrentRuntime();
2358
+ if (!runtime || !pipeline) return [];
2359
+ const binding = pipeline.getBinding();
2299
2360
  return [
2300
2361
  {
2301
2362
  runtimeId: runtime.runtimeId,
@@ -2313,7 +2374,7 @@ function runtimeStatusEntries(registry) {
2313
2374
  epoch: binding.epoch,
2314
2375
  ...maybeLastRebindAt(binding.lastRebindAt)
2315
2376
  } : { state: "none" },
2316
- pendingDispatchCount: registry.pendingDispatchCount()
2377
+ pendingDispatchCount: pipeline.pendingDispatchCount()
2317
2378
  }
2318
2379
  ];
2319
2380
  }
@@ -2436,55 +2497,367 @@ function handleConnection(connection, expectedToken, startedAt, handler, logErro
2436
2497
  });
2437
2498
  }
2438
2499
 
2439
- // src/gateway/router/sessionKey.ts
2440
- function deriveSessionKey(loc) {
2441
- const c = loc.channelId;
2442
- const a = loc.accountId;
2443
- if (loc.peer?.id) {
2444
- const peer = loc.peer.id;
2445
- if (loc.thread?.id) {
2446
- return `peer:${c}:${a}:${peer}:${loc.thread.id}`;
2447
- }
2448
- return `peer:${c}:${a}:${peer}`;
2449
- }
2450
- if (loc.room?.id) {
2451
- const room = loc.room.id;
2452
- if (loc.thread?.id) {
2453
- return `room:${c}:${a}:${room}:${loc.thread.id}`;
2454
- }
2500
+ // src/gateway/dispatchPipeline.ts
2501
+ import crypto from "crypto";
2502
+
2503
+ // src/gateway/outboundDispatcher.ts
2504
+ var DEFAULT_BACKOFF = [
2505
+ 1e3,
2506
+ // 1s
2507
+ 2e3,
2508
+ // 2s
2509
+ 4e3,
2510
+ // 4s
2511
+ 8e3,
2512
+ // 8s
2513
+ 16e3,
2514
+ // 16s
2515
+ 3e4
2516
+ // 30s
2517
+ ];
2518
+ var DEFAULT_MAX_ATTEMPTS = 10;
2519
+ var DEFAULT_TICK_MS = 1e3;
2520
+ var DEFAULT_BATCH = 16;
2521
+ var OutboundDispatcher = class {
2522
+ outbox;
2523
+ send;
2524
+ backoff;
2525
+ maxAttempts;
2526
+ tickMs;
2527
+ batchSize;
2528
+ now;
2529
+ log;
2530
+ timer = null;
2531
+ draining = false;
2532
+ stopped = false;
2533
+ constructor(opts) {
2534
+ this.outbox = opts.outbox;
2535
+ this.send = opts.send;
2536
+ this.backoff = opts.backoffSchedule ?? DEFAULT_BACKOFF;
2537
+ this.maxAttempts = opts.maxAttempts ?? DEFAULT_MAX_ATTEMPTS;
2538
+ this.tickMs = opts.tickIntervalMs ?? DEFAULT_TICK_MS;
2539
+ this.batchSize = opts.drainBatchSize ?? DEFAULT_BATCH;
2540
+ this.now = opts.now ?? Date.now;
2541
+ this.log = opts.log;
2542
+ }
2543
+ start() {
2544
+ if (this.timer) return;
2545
+ this.timer = setInterval(() => {
2546
+ void this.drain();
2547
+ }, this.tickMs);
2548
+ this.timer.unref();
2549
+ }
2550
+ stop() {
2551
+ this.stopped = true;
2552
+ if (this.timer) {
2553
+ clearInterval(this.timer);
2554
+ this.timer = null;
2555
+ }
2556
+ }
2557
+ async dispatch(channelId, msg) {
2558
+ try {
2559
+ const result = await this.send(channelId, msg);
2560
+ return { kind: "sent", result };
2561
+ } catch (err) {
2562
+ const error2 = err instanceof Error ? err.message : String(err);
2563
+ const nextAttemptAt = this.now() + this.backoffFor(0);
2564
+ const id = this.outbox.enqueue({
2565
+ channelId,
2566
+ message: msg,
2567
+ nextAttemptAt,
2568
+ lastError: error2
2569
+ });
2570
+ this.log?.(
2571
+ "warn",
2572
+ `send to ${channelId} failed; parked as outbox#${id}: ${error2}`
2573
+ );
2574
+ return { kind: "queued", outboxId: id, error: error2 };
2575
+ }
2576
+ }
2577
+ /**
2578
+ * Drain due entries. Exposed for tests; the timer also calls this. Safe
2579
+ * to call concurrently — a re-entry guard short-circuits.
2580
+ */
2581
+ async drain() {
2582
+ if (this.draining || this.stopped) {
2583
+ return { retried: 0, succeeded: 0, dropped: 0 };
2584
+ }
2585
+ this.draining = true;
2586
+ let retried = 0;
2587
+ let succeeded = 0;
2588
+ let dropped = 0;
2589
+ try {
2590
+ const due = this.outbox.peekDue(this.now(), this.batchSize);
2591
+ for (const row of due) {
2592
+ retried += 1;
2593
+ const outcome = await this.attempt(row);
2594
+ if (outcome === "succeeded") succeeded += 1;
2595
+ else if (outcome === "dropped") dropped += 1;
2596
+ }
2597
+ } finally {
2598
+ this.draining = false;
2599
+ }
2600
+ return { retried, succeeded, dropped };
2601
+ }
2602
+ async attempt(row) {
2603
+ try {
2604
+ await this.send(row.channelId, row.message);
2605
+ this.outbox.delete(row.id);
2606
+ this.log?.(
2607
+ "info",
2608
+ `outbox#${row.id} delivered to ${row.channelId} on attempt ${row.attempt + 1}`
2609
+ );
2610
+ return "succeeded";
2611
+ } catch (err) {
2612
+ const error2 = err instanceof Error ? err.message : String(err);
2613
+ const nextAttempt = row.attempt + 1;
2614
+ if (nextAttempt >= this.maxAttempts) {
2615
+ this.outbox.delete(row.id);
2616
+ this.log?.(
2617
+ "error",
2618
+ `outbox#${row.id} dropped after ${nextAttempt} attempts: ${error2}`
2619
+ );
2620
+ return "dropped";
2621
+ }
2622
+ const nextAttemptAt = this.now() + this.backoffFor(nextAttempt);
2623
+ this.outbox.recordFailure({
2624
+ id: row.id,
2625
+ nextAttemptAt,
2626
+ lastError: error2
2627
+ });
2628
+ return "requeued";
2629
+ }
2630
+ }
2631
+ backoffFor(attempt) {
2632
+ if (this.backoff.length === 0) return 1e3;
2633
+ const idx = Math.min(attempt, this.backoff.length - 1);
2634
+ return this.backoff[idx];
2635
+ }
2636
+ };
2637
+
2638
+ // src/gateway/router/sessionKey.ts
2639
+ function deriveSessionKey(loc) {
2640
+ const c = loc.channelId;
2641
+ const a = loc.accountId;
2642
+ if (loc.peer?.id) {
2643
+ const peer = loc.peer.id;
2644
+ if (loc.thread?.id) {
2645
+ return `peer:${c}:${a}:${peer}:${loc.thread.id}`;
2646
+ }
2647
+ return `peer:${c}:${a}:${peer}`;
2648
+ }
2649
+ if (loc.room?.id) {
2650
+ const room = loc.room.id;
2651
+ if (loc.thread?.id) {
2652
+ return `room:${c}:${a}:${room}:${loc.thread.id}`;
2653
+ }
2455
2654
  return `room:${c}:${a}:${room}`;
2456
2655
  }
2457
2656
  return `default:${c}:${a}`;
2458
2657
  }
2459
2658
 
2460
- // src/gateway/dispatcher.ts
2461
- var Dispatcher = class {
2659
+ // src/gateway/sessionRegistry.ts
2660
+ import { randomUUID } from "crypto";
2661
+ var UnknownDispatchError = class extends Error {
2662
+ code = "unknown_dispatch";
2663
+ constructor(id) {
2664
+ super(`unknown dispatchId: ${id}`);
2665
+ this.name = "UnknownDispatchError";
2666
+ }
2667
+ };
2668
+ var SessionRegistry = class {
2669
+ dispatches = /* @__PURE__ */ new Map();
2670
+ idFactory;
2671
+ now;
2672
+ constructor(opts = {}) {
2673
+ this.idFactory = opts.idFactory ?? randomUUID;
2674
+ this.now = opts.now ?? Date.now;
2675
+ }
2676
+ beginDispatch(input) {
2677
+ const dispatchId = this.idFactory();
2678
+ const entry = {
2679
+ dispatchId,
2680
+ sessionKey: input.sessionKey,
2681
+ agentId: input.agentId,
2682
+ location: input.location,
2683
+ createdAt: this.now()
2684
+ };
2685
+ this.dispatches.set(dispatchId, entry);
2686
+ return entry;
2687
+ }
2688
+ completeDispatch(dispatchId) {
2689
+ const entry = this.dispatches.get(dispatchId);
2690
+ if (!entry) {
2691
+ throw new UnknownDispatchError(dispatchId);
2692
+ }
2693
+ this.dispatches.delete(dispatchId);
2694
+ return entry;
2695
+ }
2696
+ pendingDispatchCount() {
2697
+ return this.dispatches.size;
2698
+ }
2699
+ clearDispatches() {
2700
+ this.dispatches.clear();
2701
+ }
2702
+ };
2703
+
2704
+ // src/gateway/state/inboundQueue.ts
2705
+ var DEFAULT_MAX_ENTRIES = 1e3;
2706
+ var InboundQueue = class {
2707
+ db;
2708
+ maxEntries;
2709
+ constructor(db, opts = {}) {
2710
+ this.db = db;
2711
+ this.maxEntries = opts.maxEntries ?? DEFAULT_MAX_ENTRIES;
2712
+ }
2713
+ size() {
2714
+ const row = this.db.prepare("SELECT COUNT(*) as n FROM inbound_queue").get();
2715
+ return row.n;
2716
+ }
2717
+ enqueue(inbound) {
2718
+ if (this.size() >= this.maxEntries) {
2719
+ return { kind: "rejected", reason: "queue_full" };
2720
+ }
2721
+ const stmt = this.db.prepare(
2722
+ `INSERT INTO inbound_queue
2723
+ (channel_id, account_id, idempotency_key, payload_json, created_at)
2724
+ VALUES (?, ?, ?, ?, ?)
2725
+ ON CONFLICT(channel_id, account_id, idempotency_key) DO NOTHING`
2726
+ );
2727
+ const result = stmt.run(
2728
+ inbound.location.channelId,
2729
+ inbound.location.accountId,
2730
+ inbound.idempotencyKey,
2731
+ JSON.stringify(inbound),
2732
+ Date.now()
2733
+ );
2734
+ if (result.changes === 0) {
2735
+ return { kind: "duplicate" };
2736
+ }
2737
+ return { kind: "queued", id: Number(result.lastInsertRowid) };
2738
+ }
2739
+ /** Atomically read and remove all parked entries in FIFO order. */
2740
+ drain() {
2741
+ return this.db.transaction(() => {
2742
+ const rows = this.db.prepare("SELECT id, payload_json FROM inbound_queue ORDER BY id ASC").all();
2743
+ if (rows.length > 0) {
2744
+ this.db.prepare("DELETE FROM inbound_queue").run();
2745
+ }
2746
+ return rows.map((r) => ({
2747
+ id: r.id,
2748
+ inbound: JSON.parse(r.payload_json)
2749
+ }));
2750
+ })();
2751
+ }
2752
+ };
2753
+
2754
+ // src/gateway/state/outbox.ts
2755
+ var Outbox = class {
2756
+ db;
2757
+ constructor(db) {
2758
+ this.db = db;
2759
+ }
2760
+ size() {
2761
+ const row = this.db.prepare("SELECT COUNT(*) as n FROM channel_outbox").get();
2762
+ return row.n;
2763
+ }
2764
+ enqueue(input) {
2765
+ const result = this.db.prepare(
2766
+ `INSERT INTO channel_outbox
2767
+ (channel_id, payload_json, attempt, next_attempt_at, last_error, created_at)
2768
+ VALUES (?, ?, 0, ?, ?, ?)`
2769
+ ).run(
2770
+ input.channelId,
2771
+ JSON.stringify(input.message),
2772
+ input.nextAttemptAt,
2773
+ input.lastError ?? null,
2774
+ Date.now()
2775
+ );
2776
+ return Number(result.lastInsertRowid);
2777
+ }
2778
+ /** Rows whose `next_attempt_at` is at or before `now`, oldest first. */
2779
+ peekDue(now, limit) {
2780
+ const rows = this.db.prepare(
2781
+ `SELECT id, channel_id, payload_json, attempt, next_attempt_at, last_error
2782
+ FROM channel_outbox
2783
+ WHERE next_attempt_at <= ?
2784
+ ORDER BY next_attempt_at ASC, id ASC
2785
+ LIMIT ?`
2786
+ ).all(now, limit);
2787
+ return rows.map((r) => ({
2788
+ id: r.id,
2789
+ channelId: r.channel_id,
2790
+ message: JSON.parse(r.payload_json),
2791
+ attempt: r.attempt,
2792
+ nextAttemptAt: r.next_attempt_at,
2793
+ lastError: r.last_error
2794
+ }));
2795
+ }
2796
+ delete(id) {
2797
+ this.db.prepare("DELETE FROM channel_outbox WHERE id = ?").run(id);
2798
+ }
2799
+ recordFailure(input) {
2800
+ this.db.prepare(
2801
+ `UPDATE channel_outbox
2802
+ SET attempt = attempt + 1,
2803
+ next_attempt_at = ?,
2804
+ last_error = ?
2805
+ WHERE id = ?`
2806
+ ).run(input.nextAttemptAt, input.lastError, input.id);
2807
+ }
2808
+ };
2809
+
2810
+ // src/gateway/dispatchPipeline.ts
2811
+ var DispatchPipeline = class {
2812
+ bindingStore;
2462
2813
  registry;
2463
- pushDispatch;
2464
- sendOutbound;
2465
- resolveAgent;
2466
2814
  inboundQueue;
2467
- canDispatch;
2815
+ outbox;
2816
+ outboundDispatcher;
2817
+ resolveAgent;
2468
2818
  log;
2819
+ now;
2820
+ idFactory;
2821
+ push = null;
2469
2822
  constructor(opts) {
2470
- this.registry = opts.registry;
2471
- this.pushDispatch = opts.pushDispatch;
2472
- this.sendOutbound = opts.sendOutbound;
2823
+ this.bindingStore = new RuntimeBindingStore({
2824
+ gracePeriodMs: opts.gracePeriodMs,
2825
+ observers: opts.observers,
2826
+ now: opts.now
2827
+ });
2828
+ this.registry = new SessionRegistry({
2829
+ now: opts.now ?? Date.now,
2830
+ ...opts.idFactory ? { idFactory: opts.idFactory } : {}
2831
+ });
2832
+ this.inboundQueue = new InboundQueue(opts.stateDb, opts.inboundQueue ?? {});
2833
+ this.outbox = new Outbox(opts.stateDb);
2834
+ this.outboundDispatcher = new OutboundDispatcher({
2835
+ outbox: this.outbox,
2836
+ send: opts.send,
2837
+ ...opts.outbox?.backoffSchedule ? { backoffSchedule: opts.outbox.backoffSchedule } : {},
2838
+ ...opts.outbox?.maxAttempts !== void 0 ? { maxAttempts: opts.outbox.maxAttempts } : {},
2839
+ ...opts.outbox?.tickIntervalMs !== void 0 ? { tickIntervalMs: opts.outbox.tickIntervalMs } : {},
2840
+ ...opts.outbox?.drainBatchSize !== void 0 ? { drainBatchSize: opts.outbox.drainBatchSize } : {},
2841
+ ...opts.now ? { now: opts.now } : {},
2842
+ ...opts.log ? { log: opts.log } : {}
2843
+ });
2473
2844
  this.resolveAgent = opts.resolveAgent ?? ((input) => input.defaultAgentId);
2474
- this.inboundQueue = opts.inboundQueue;
2475
- this.canDispatch = opts.canDispatch ?? (() => true);
2476
2845
  this.log = opts.log;
2846
+ this.now = opts.now ?? Date.now;
2847
+ this.idFactory = opts.idFactory ?? crypto.randomUUID;
2848
+ }
2849
+ // ── lifecycle ────────────────────────────────────────────
2850
+ start() {
2851
+ this.outboundDispatcher.start();
2852
+ }
2853
+ async stop() {
2854
+ this.outboundDispatcher.stop();
2855
+ this.bindingStore.stop();
2477
2856
  }
2857
+ // ── inbound (channel side) ───────────────────────────────
2478
2858
  handleInbound(inbound) {
2479
- const current = this.registry.getCurrent();
2480
- if (!current || !this.canDispatch()) {
2481
- if (!this.inboundQueue) {
2482
- this.log?.(
2483
- "debug",
2484
- `no runtime registered; dropping inbound ${inbound.idempotencyKey}`
2485
- );
2486
- return { kind: "dropped", reason: "no_runtime" };
2487
- }
2859
+ const current = this.bindingStore.getCurrent();
2860
+ if (!current || !this.bindingStore.hasActiveBinding(current.runtimeId)) {
2488
2861
  const result = this.inboundQueue.enqueue(inbound);
2489
2862
  if (result.kind === "queued") {
2490
2863
  this.log?.(
@@ -2506,6 +2879,9 @@ var Dispatcher = class {
2506
2879
  );
2507
2880
  return { kind: "dropped", reason: "queue_full" };
2508
2881
  }
2882
+ return this.dispatchInboundToRuntime(inbound, current);
2883
+ }
2884
+ dispatchInboundToRuntime(inbound, current) {
2509
2885
  const sessionKey = deriveSessionKey(inbound.location);
2510
2886
  const agentId = this.resolveAgent({
2511
2887
  sessionKey,
@@ -2530,64 +2906,50 @@ var Dispatcher = class {
2530
2906
  agentId,
2531
2907
  inbound
2532
2908
  });
2533
- return {
2534
- kind: "dispatched",
2535
- dispatchId: entry.dispatchId,
2536
- sessionKey
2537
- };
2909
+ return { kind: "dispatched", dispatchId: entry.dispatchId, sessionKey };
2538
2910
  }
2539
- /**
2540
- * Drain parked inbound messages and dispatch them in FIFO order. Called by
2541
- * the session.register handler after a runtime attaches. Safe to call when
2542
- * no queue is configured (no-op).
2543
- */
2544
- drainPending() {
2545
- if (!this.inboundQueue) return { dispatched: 0, dropped: 0 };
2546
- const current = this.registry.getCurrent();
2547
- if (!current || !this.canDispatch()) return { dispatched: 0, dropped: 0 };
2548
- const parked = this.inboundQueue.drain();
2549
- let dispatched = 0;
2550
- let dropped = 0;
2551
- for (const { inbound } of parked) {
2552
- const sessionKey = deriveSessionKey(inbound.location);
2553
- const agentId = this.resolveAgent({
2554
- sessionKey,
2555
- channelId: inbound.location.channelId,
2556
- defaultAgentId: current.defaultAgentId
2557
- });
2558
- if (!agentId) {
2559
- dropped += 1;
2560
- this.log?.(
2561
- "warn",
2562
- `drainPending: no agent for ${sessionKey}; dropping ${inbound.idempotencyKey}`
2563
- );
2564
- continue;
2565
- }
2566
- const entry = this.registry.beginDispatch({
2567
- sessionKey,
2568
- agentId,
2569
- location: inbound.location
2570
- });
2571
- this.pushDispatch({
2572
- dispatchId: entry.dispatchId,
2573
- sessionKey,
2574
- agentId,
2575
- inbound
2576
- });
2577
- dispatched += 1;
2578
- }
2579
- if (dispatched > 0 || dropped > 0) {
2580
- this.log?.(
2581
- "info",
2582
- `drainPending: dispatched=${dispatched} dropped=${dropped}`
2583
- );
2584
- }
2585
- return { dispatched, dropped };
2911
+ pushDispatch(payload) {
2912
+ if (!this.push) return;
2913
+ this.push.push({
2914
+ push_id: this.idFactory(),
2915
+ ts: this.now(),
2916
+ kind: "session.dispatch.turn",
2917
+ payload
2918
+ });
2919
+ }
2920
+ // ── runtime side ─────────────────────────────────────────
2921
+ registerRuntime(input) {
2922
+ const result = this.bindingStore.bind({
2923
+ runtimeId: input.runtimeId,
2924
+ defaultAgentId: input.defaultAgentId,
2925
+ pid: input.pid,
2926
+ connectionId: input.connectionId
2927
+ });
2928
+ this.push = { connectionId: input.connectionId, push: input.push };
2929
+ writeGatewayTrace(
2930
+ `pipeline registered runtime runtimeId=${input.runtimeId} connectionId=${input.connectionId}`
2931
+ );
2932
+ this.drainPending();
2933
+ return { registeredAt: result.registeredAt };
2934
+ }
2935
+ unregisterRuntime(runtimeId) {
2936
+ this.bindingStore.unbind(runtimeId);
2937
+ this.registry.clearDispatches();
2938
+ this.push = null;
2939
+ writeGatewayTrace(`pipeline unregistered runtime runtimeId=${runtimeId}`);
2940
+ }
2941
+ notifyConnectionClosed(connectionId) {
2942
+ const runtimeId = this.bindingStore.notifyConnectionClosed(connectionId);
2943
+ if (runtimeId === null) return;
2944
+ writeGatewayTrace(
2945
+ `pipeline runtime connection closed runtimeId=${runtimeId} connectionId=${connectionId}`
2946
+ );
2947
+ this.push = null;
2586
2948
  }
2587
2949
  async handleTurnComplete(payload) {
2588
- const current = this.registry.getCurrent();
2950
+ const current = this.bindingStore.getCurrent();
2589
2951
  writeGatewayTrace(
2590
- `dispatcher turn.complete received runtimeId=${payload.runtimeId} dispatchId=${payload.dispatchId} channel=${payload.location.channelId} account=${payload.location.accountId} thread=${payload.location.thread?.id ?? ""} textLength=${payload.text.length}`
2952
+ `pipeline turn.complete received runtimeId=${payload.runtimeId} dispatchId=${payload.dispatchId} channel=${payload.location.channelId} account=${payload.location.accountId} thread=${payload.location.thread?.id ?? ""} textLength=${payload.text.length}`
2591
2953
  );
2592
2954
  if (!current || current.runtimeId !== payload.runtimeId) {
2593
2955
  throw new Error("runtime mismatch on session.turn.complete");
@@ -2598,28 +2960,75 @@ var Dispatcher = class {
2598
2960
  } catch (err) {
2599
2961
  if (err instanceof UnknownDispatchError) {
2600
2962
  writeGatewayTrace(
2601
- `dispatcher turn.complete unknown dispatchId=${payload.dispatchId}`
2963
+ `pipeline turn.complete unknown dispatchId=${payload.dispatchId}`
2602
2964
  );
2603
2965
  return { delivered: false };
2604
2966
  }
2605
2967
  throw err;
2606
2968
  }
2969
+ const result = await this.sendOutbound(entry.location, payload);
2607
2970
  writeGatewayTrace(
2608
- `dispatcher sendOutbound channel=${entry.location.channelId} dispatchId=${payload.dispatchId} parkedAccount=${entry.location.accountId} parkedThread=${entry.location.thread?.id ?? ""}`
2971
+ `pipeline sendOutbound delivered dispatchId=${payload.dispatchId} providerMessageId=${result.providerMessageId}`
2609
2972
  );
2610
- const result = await this.sendOutbound(entry.location.channelId, {
2973
+ return { delivered: true, providerMessageId: result.providerMessageId };
2974
+ }
2975
+ async sendOutbound(_parkedLocation, payload) {
2976
+ const out = {
2611
2977
  location: payload.location,
2612
2978
  text: payload.text,
2613
2979
  idempotencyKey: payload.idempotencyKey
2614
- });
2615
- writeGatewayTrace(
2616
- `dispatcher sendOutbound delivered dispatchId=${payload.dispatchId} providerMessageId=${result.providerMessageId}`
2980
+ };
2981
+ const result = await this.outboundDispatcher.dispatch(
2982
+ payload.location.channelId,
2983
+ out
2617
2984
  );
2985
+ if (result.kind === "sent") return result.result;
2618
2986
  return {
2619
- delivered: true,
2620
- providerMessageId: result.providerMessageId
2987
+ providerMessageId: `outbox:${result.outboxId}`,
2988
+ deliveredAt: this.now()
2621
2989
  };
2622
2990
  }
2991
+ drainPending() {
2992
+ const current = this.bindingStore.getCurrent();
2993
+ if (!current || !this.bindingStore.hasActiveBinding(current.runtimeId))
2994
+ return;
2995
+ const parked = this.inboundQueue.drain();
2996
+ let dispatched = 0;
2997
+ let dropped = 0;
2998
+ for (const { inbound } of parked) {
2999
+ const result = this.dispatchInboundToRuntime(inbound, current);
3000
+ if (result.kind === "dispatched") dispatched += 1;
3001
+ else dropped += 1;
3002
+ }
3003
+ if (dispatched > 0 || dropped > 0) {
3004
+ this.log?.(
3005
+ "info",
3006
+ `drainPending: dispatched=${dispatched} dropped=${dropped}`
3007
+ );
3008
+ }
3009
+ }
3010
+ // ── reads ────────────────────────────────────────────────
3011
+ getCurrentRuntime() {
3012
+ return this.bindingStore.getCurrent();
3013
+ }
3014
+ getBinding() {
3015
+ return this.bindingStore.getBinding();
3016
+ }
3017
+ hasActiveBinding(runtimeId) {
3018
+ return this.bindingStore.hasActiveBinding(runtimeId);
3019
+ }
3020
+ getRuntimeIdByConnection(connectionId) {
3021
+ return this.bindingStore.getRuntimeIdByConnection(connectionId);
3022
+ }
3023
+ pendingDispatchCount() {
3024
+ return this.registry.pendingDispatchCount();
3025
+ }
3026
+ pendingInboundCount() {
3027
+ return this.inboundQueue.size();
3028
+ }
3029
+ pendingOutboxCount() {
3030
+ return this.outbox.size();
3031
+ }
2623
3032
  };
2624
3033
 
2625
3034
  // src/gateway/lock.ts
@@ -2687,140 +3096,63 @@ function acquireLock(lockPath) {
2687
3096
  throw new Error(`failed to acquire gateway lock at ${lockPath}`);
2688
3097
  }
2689
3098
 
2690
- // src/gateway/outboundDispatcher.ts
2691
- var DEFAULT_BACKOFF = [
2692
- 1e3,
2693
- // 1s
2694
- 2e3,
2695
- // 2s
2696
- 4e3,
2697
- // 4s
2698
- 8e3,
2699
- // 8s
2700
- 16e3,
2701
- // 16s
2702
- 3e4
2703
- // 30s
2704
- ];
2705
- var DEFAULT_MAX_ATTEMPTS = 10;
2706
- var DEFAULT_TICK_MS = 1e3;
2707
- var DEFAULT_BATCH = 16;
2708
- var OutboundDispatcher = class {
2709
- outbox;
2710
- send;
2711
- backoff;
2712
- maxAttempts;
2713
- tickMs;
2714
- batchSize;
2715
- now;
2716
- log;
2717
- timer = null;
2718
- draining = false;
2719
- stopped = false;
2720
- constructor(opts) {
2721
- this.outbox = opts.outbox;
2722
- this.send = opts.send;
2723
- this.backoff = opts.backoffSchedule ?? DEFAULT_BACKOFF;
2724
- this.maxAttempts = opts.maxAttempts ?? DEFAULT_MAX_ATTEMPTS;
2725
- this.tickMs = opts.tickIntervalMs ?? DEFAULT_TICK_MS;
2726
- this.batchSize = opts.drainBatchSize ?? DEFAULT_BATCH;
2727
- this.now = opts.now ?? Date.now;
2728
- this.log = opts.log;
2729
- }
2730
- start() {
2731
- if (this.timer) return;
2732
- this.timer = setInterval(() => {
2733
- void this.drain();
2734
- }, this.tickMs);
2735
- this.timer.unref();
2736
- }
2737
- stop() {
2738
- this.stopped = true;
2739
- if (this.timer) {
2740
- clearInterval(this.timer);
2741
- this.timer = null;
3099
+ // src/gateway/relay/pendingRegistry.ts
3100
+ var PendingRegistry = class {
3101
+ entries = /* @__PURE__ */ new Map();
3102
+ inspect(channelRequestId, kind, fingerprint, runtimeId) {
3103
+ const existing = this.entries.get(channelRequestId);
3104
+ if (!existing) return { kind: "absent" };
3105
+ if (existing.kind !== kind) return { kind: "collision", reason: "kind" };
3106
+ if (existing.fingerprint !== fingerprint) {
3107
+ return { kind: "collision", reason: "payload" };
2742
3108
  }
2743
- }
2744
- async dispatch(channelId, msg) {
2745
- try {
2746
- const result = await this.send(channelId, msg);
2747
- return { kind: "sent", result };
2748
- } catch (err) {
2749
- const error2 = err instanceof Error ? err.message : String(err);
2750
- const nextAttemptAt = this.now() + this.backoffFor(0);
2751
- const id = this.outbox.enqueue({
2752
- channelId,
2753
- message: msg,
2754
- nextAttemptAt,
2755
- lastError: error2
2756
- });
2757
- this.log?.(
2758
- "warn",
2759
- `send to ${channelId} failed; parked as outbox#${id}: ${error2}`
2760
- );
2761
- return { kind: "queued", outboxId: id, error: error2 };
3109
+ if (existing.runtimeId !== runtimeId) {
3110
+ return { kind: "collision", reason: "owner" };
2762
3111
  }
3112
+ return { kind: "attach", entry: existing };
2763
3113
  }
2764
- /**
2765
- * Drain due entries. Exposed for tests; the timer also calls this. Safe
2766
- * to call concurrently — a re-entry guard short-circuits.
2767
- */
2768
- async drain() {
2769
- if (this.draining || this.stopped) {
2770
- return { retried: 0, succeeded: 0, dropped: 0 };
3114
+ register(entry) {
3115
+ this.entries.set(entry.channelRequestId, entry);
3116
+ }
3117
+ settle(channelRequestId, result) {
3118
+ const entry = this.entries.get(channelRequestId);
3119
+ if (!entry || entry.settled) return false;
3120
+ entry.settled = true;
3121
+ this.entries.delete(channelRequestId);
3122
+ clearTimeout(entry.timer);
3123
+ for (const ctrl of entry.controllers) {
3124
+ if (!ctrl.signal.aborted) ctrl.abort();
2771
3125
  }
2772
- this.draining = true;
2773
- let retried = 0;
2774
- let succeeded = 0;
2775
- let dropped = 0;
2776
- try {
2777
- const due = this.outbox.peekDue(this.now(), this.batchSize);
2778
- for (const row of due) {
2779
- retried += 1;
2780
- const outcome = await this.attempt(row);
2781
- if (outcome === "succeeded") succeeded += 1;
2782
- else if (outcome === "dropped") dropped += 1;
2783
- }
2784
- } finally {
2785
- this.draining = false;
3126
+ entry.resolve(result);
3127
+ return true;
3128
+ }
3129
+ cancel(channelRequestId, reason, expectedRuntimeId) {
3130
+ const entry = this.entries.get(channelRequestId);
3131
+ if (!entry) return false;
3132
+ if (expectedRuntimeId !== void 0 && entry.runtimeId !== void 0 && entry.runtimeId !== expectedRuntimeId) {
3133
+ return false;
2786
3134
  }
2787
- return { retried, succeeded, dropped };
3135
+ return this.settle(channelRequestId, { kind: "cancelled", reason });
2788
3136
  }
2789
- async attempt(row) {
2790
- try {
2791
- await this.send(row.channelId, row.message);
2792
- this.outbox.delete(row.id);
2793
- this.log?.(
2794
- "info",
2795
- `outbox#${row.id} delivered to ${row.channelId} on attempt ${row.attempt + 1}`
2796
- );
2797
- return "succeeded";
2798
- } catch (err) {
2799
- const error2 = err instanceof Error ? err.message : String(err);
2800
- const nextAttempt = row.attempt + 1;
2801
- if (nextAttempt >= this.maxAttempts) {
2802
- this.outbox.delete(row.id);
2803
- this.log?.(
2804
- "error",
2805
- `outbox#${row.id} dropped after ${nextAttempt} attempts: ${error2}`
2806
- );
2807
- return "dropped";
2808
- }
2809
- const nextAttemptAt = this.now() + this.backoffFor(nextAttempt);
2810
- this.outbox.recordFailure({
2811
- id: row.id,
2812
- nextAttemptAt,
2813
- lastError: error2
2814
- });
2815
- return "requeued";
3137
+ disposeAll(reason) {
3138
+ for (const id of [...this.entries.keys()]) {
3139
+ this.cancel(id, reason, void 0);
2816
3140
  }
2817
3141
  }
2818
- backoffFor(attempt) {
2819
- if (this.backoff.length === 0) return 1e3;
2820
- const idx = Math.min(attempt, this.backoff.length - 1);
2821
- return this.backoff[idx];
3142
+ count() {
3143
+ return this.entries.size;
2822
3144
  }
2823
3145
  };
3146
+ function collisionMessage(channelRequestId, reason, newKind) {
3147
+ if (reason === "owner") {
3148
+ return `channel_request_owner_mismatch: ${channelRequestId} owned by a different runtime`;
3149
+ }
3150
+ if (reason === "kind") {
3151
+ const otherKind = newKind === "permission" ? "question" : "permission";
3152
+ return `channel_request_id_collision: ${channelRequestId} is bound to a ${otherKind} relay`;
3153
+ }
3154
+ return `channel_request_id_collision: ${channelRequestId} payload mismatch`;
3155
+ }
2824
3156
 
2825
3157
  // src/gateway/relay/coordinator.ts
2826
3158
  var DEFAULT_RELAY_TTL_MS = 5 * 6e4;
@@ -2829,214 +3161,160 @@ var RelayCoordinator = class {
2829
3161
  defaultTtlMs;
2830
3162
  idFactory;
2831
3163
  log;
2832
- pending = /* @__PURE__ */ new Map();
3164
+ registry = new PendingRegistry();
2833
3165
  constructor(opts) {
2834
3166
  this.adapters = opts.adapters;
2835
- this.defaultTtlMs = opts.defaultTtlMs ?? DEFAULT_RELAY_TTL_MS;
2836
- this.idFactory = opts.idFactory ?? generateChannelRequestId;
2837
- this.log = opts.log;
2838
- }
2839
- requestPermission(req) {
2840
- const channelRequestId = req.channelRequestId ?? this.idFactory();
2841
- const ttlMs = req.ttlMs ?? this.defaultTtlMs;
2842
- const targets = this.adapters().filter(
2843
- (a) => a.capabilities.relayPermission && typeof a.requestPermissionVerdict === "function"
2844
- );
2845
- if (targets.length === 0) {
2846
- return {
2847
- channelRequestId,
2848
- result: Promise.resolve({ kind: "no_relay" })
2849
- };
2850
- }
2851
- const fingerprint = permissionFingerprint(req);
2852
- const existing = this.pending.get(channelRequestId);
2853
- if (existing) {
2854
- if (existing.kind !== "permission") {
2855
- throw new Error(
2856
- `channel_request_id_collision: ${channelRequestId} is bound to a question relay`
2857
- );
2858
- }
2859
- if (existing.fingerprint !== fingerprint) {
2860
- throw new Error(
2861
- `channel_request_id_collision: ${channelRequestId} payload mismatch`
2862
- );
2863
- }
2864
- if (existing.runtimeId !== req.runtimeId) {
2865
- throw new Error(
2866
- `channel_request_owner_mismatch: ${channelRequestId} owned by a different runtime`
2867
- );
2868
- }
2869
- return { channelRequestId, result: existing.result };
2870
- }
2871
- const controllers = targets.map(() => new AbortController());
2872
- let resolveFn;
2873
- const result = new Promise((resolve) => {
2874
- resolveFn = resolve;
2875
- });
2876
- const timer = setTimeout(() => {
2877
- this.settlePermission(channelRequestId, {
2878
- kind: "cancelled",
2879
- reason: "timeout"
2880
- });
2881
- }, ttlMs);
2882
- if (typeof timer.unref === "function") timer.unref();
2883
- const entry = {
2884
- kind: "permission",
3167
+ this.defaultTtlMs = opts.defaultTtlMs ?? DEFAULT_RELAY_TTL_MS;
3168
+ this.idFactory = opts.idFactory ?? generateChannelRequestId;
3169
+ this.log = opts.log;
3170
+ }
3171
+ requestPermission(req) {
3172
+ const channelRequestId = req.channelRequestId ?? this.idFactory();
3173
+ const targets = this.adapters().filter(
3174
+ (a) => a.capabilities.relayPermission && typeof a.requestPermissionVerdict === "function"
3175
+ );
3176
+ if (targets.length === 0) {
3177
+ return { channelRequestId, result: Promise.resolve({ kind: "no_relay" }) };
3178
+ }
3179
+ const fingerprint = permissionFingerprint(req);
3180
+ const inspect = this.registry.inspect(
2885
3181
  channelRequestId,
3182
+ "permission",
2886
3183
  fingerprint,
2887
- ...req.runtimeId !== void 0 ? { runtimeId: req.runtimeId } : {},
2888
- controllers,
2889
- timer,
2890
- resolve: resolveFn,
2891
- result,
2892
- settled: false
2893
- };
2894
- this.pending.set(channelRequestId, entry);
3184
+ req.runtimeId
3185
+ );
3186
+ if (inspect.kind === "collision") {
3187
+ throw new Error(
3188
+ collisionMessage(channelRequestId, inspect.reason, "permission")
3189
+ );
3190
+ }
3191
+ if (inspect.kind === "attach") {
3192
+ return {
3193
+ channelRequestId,
3194
+ result: inspect.entry.result
3195
+ };
3196
+ }
2895
3197
  const fullReq = {
2896
3198
  channelRequestId,
2897
3199
  toolName: req.toolName,
2898
3200
  description: req.description,
2899
3201
  inputPreview: req.inputPreview
2900
3202
  };
2901
- targets.forEach((adapter, idx) => {
2902
- const ctrl = controllers[idx];
2903
- Promise.resolve().then(() => adapter.requestPermissionVerdict(fullReq, ctrl.signal)).then((res) => {
2904
- if (res.kind === "verdict") {
2905
- this.settlePermission(channelRequestId, {
2906
- ...res,
2907
- channelId: adapter.id
2908
- });
2909
- }
2910
- }).catch((err) => {
2911
- this.log?.(
2912
- "warn",
2913
- `adapter ${adapter.id} permission relay failed: ${err instanceof Error ? err.message : String(err)}`
2914
- );
2915
- });
3203
+ const result = this.broadcast({
3204
+ kind: "permission",
3205
+ channelRequestId,
3206
+ ttlMs: req.ttlMs ?? this.defaultTtlMs,
3207
+ runtimeId: req.runtimeId,
3208
+ fingerprint,
3209
+ targets,
3210
+ perAdapter: async (adapter, signal) => {
3211
+ const res = await adapter.requestPermissionVerdict(fullReq, signal);
3212
+ return res.kind === "verdict" ? { ...res, channelId: adapter.id } : null;
3213
+ }
2916
3214
  });
2917
3215
  return { channelRequestId, result };
2918
3216
  }
2919
3217
  requestQuestion(req) {
2920
3218
  const channelRequestId = req.channelRequestId ?? this.idFactory();
2921
- const ttlMs = req.ttlMs ?? this.defaultTtlMs;
2922
3219
  const targets = this.adapters().filter(
2923
3220
  (a) => a.capabilities.relayQuestion && typeof a.requestQuestionAnswer === "function"
2924
3221
  );
2925
3222
  if (targets.length === 0) {
3223
+ return { channelRequestId, result: Promise.resolve({ kind: "no_relay" }) };
3224
+ }
3225
+ const fingerprint = questionFingerprint(req);
3226
+ const inspect = this.registry.inspect(
3227
+ channelRequestId,
3228
+ "question",
3229
+ fingerprint,
3230
+ req.runtimeId
3231
+ );
3232
+ if (inspect.kind === "collision") {
3233
+ throw new Error(
3234
+ collisionMessage(channelRequestId, inspect.reason, "question")
3235
+ );
3236
+ }
3237
+ if (inspect.kind === "attach") {
2926
3238
  return {
2927
3239
  channelRequestId,
2928
- result: Promise.resolve({ kind: "no_relay" })
3240
+ result: inspect.entry.result
2929
3241
  };
2930
3242
  }
2931
- const fingerprint = questionFingerprint(req);
2932
- const existing = this.pending.get(channelRequestId);
2933
- if (existing) {
2934
- if (existing.kind !== "question") {
2935
- throw new Error(
2936
- `channel_request_id_collision: ${channelRequestId} is bound to a permission relay`
2937
- );
2938
- }
2939
- if (existing.fingerprint !== fingerprint) {
2940
- throw new Error(
2941
- `channel_request_id_collision: ${channelRequestId} payload mismatch`
2942
- );
2943
- }
2944
- if (existing.runtimeId !== req.runtimeId) {
2945
- throw new Error(
2946
- `channel_request_owner_mismatch: ${channelRequestId} owned by a different runtime`
2947
- );
3243
+ const fullReq = {
3244
+ channelRequestId,
3245
+ title: req.title,
3246
+ questions: req.questions
3247
+ };
3248
+ const result = this.broadcast({
3249
+ kind: "question",
3250
+ channelRequestId,
3251
+ ttlMs: req.ttlMs ?? this.defaultTtlMs,
3252
+ runtimeId: req.runtimeId,
3253
+ fingerprint,
3254
+ targets,
3255
+ perAdapter: async (adapter, signal) => {
3256
+ const res = await adapter.requestQuestionAnswer(fullReq, signal);
3257
+ return res.kind === "answer" ? { ...res, channelId: adapter.id } : null;
2948
3258
  }
2949
- return { channelRequestId, result: existing.result };
2950
- }
3259
+ });
3260
+ return { channelRequestId, result };
3261
+ }
3262
+ cancel(channelRequestId, reason, expectedRuntimeId) {
3263
+ return this.registry.cancel(channelRequestId, reason, expectedRuntimeId);
3264
+ }
3265
+ pendingCount() {
3266
+ return this.registry.count();
3267
+ }
3268
+ disposeAll(reason = "auto_resolved") {
3269
+ this.registry.disposeAll(reason);
3270
+ }
3271
+ broadcast(args) {
3272
+ const {
3273
+ kind,
3274
+ channelRequestId,
3275
+ ttlMs,
3276
+ runtimeId,
3277
+ fingerprint,
3278
+ targets,
3279
+ perAdapter
3280
+ } = args;
2951
3281
  const controllers = targets.map(() => new AbortController());
2952
3282
  let resolveFn;
2953
3283
  const result = new Promise((resolve) => {
2954
3284
  resolveFn = resolve;
2955
3285
  });
2956
3286
  const timer = setTimeout(() => {
2957
- this.settleQuestion(channelRequestId, {
3287
+ this.registry.settle(channelRequestId, {
2958
3288
  kind: "cancelled",
2959
3289
  reason: "timeout"
2960
3290
  });
2961
3291
  }, ttlMs);
2962
3292
  if (typeof timer.unref === "function") timer.unref();
2963
- const entry = {
2964
- kind: "question",
3293
+ this.registry.register({
3294
+ kind,
2965
3295
  channelRequestId,
2966
3296
  fingerprint,
2967
- ...req.runtimeId !== void 0 ? { runtimeId: req.runtimeId } : {},
3297
+ runtimeId,
2968
3298
  controllers,
2969
3299
  timer,
2970
3300
  resolve: resolveFn,
2971
3301
  result,
2972
3302
  settled: false
2973
- };
2974
- this.pending.set(channelRequestId, entry);
2975
- const fullReq = {
2976
- channelRequestId,
2977
- title: req.title,
2978
- questions: req.questions
2979
- };
3303
+ });
2980
3304
  targets.forEach((adapter, idx) => {
2981
3305
  const ctrl = controllers[idx];
2982
- Promise.resolve().then(() => adapter.requestQuestionAnswer(fullReq, ctrl.signal)).then((res) => {
2983
- if (res.kind === "answer") {
2984
- this.settleQuestion(channelRequestId, {
2985
- ...res,
2986
- channelId: adapter.id
2987
- });
3306
+ Promise.resolve().then(() => perAdapter(adapter, ctrl.signal)).then((res) => {
3307
+ if (res !== null) {
3308
+ this.registry.settle(channelRequestId, res);
2988
3309
  }
2989
3310
  }).catch((err) => {
2990
3311
  this.log?.(
2991
3312
  "warn",
2992
- `adapter ${adapter.id} question relay failed: ${err instanceof Error ? err.message : String(err)}`
3313
+ `adapter ${adapter.id} ${kind} relay failed: ${err instanceof Error ? err.message : String(err)}`
2993
3314
  );
2994
3315
  });
2995
3316
  });
2996
- return { channelRequestId, result };
2997
- }
2998
- cancel(channelRequestId, reason, expectedRuntimeId) {
2999
- const entry = this.pending.get(channelRequestId);
3000
- if (!entry) return false;
3001
- if (expectedRuntimeId !== void 0 && entry.runtimeId !== void 0 && entry.runtimeId !== expectedRuntimeId) {
3002
- return false;
3003
- }
3004
- if (entry.kind === "permission") {
3005
- this.settlePermission(channelRequestId, { kind: "cancelled", reason });
3006
- } else {
3007
- this.settleQuestion(channelRequestId, { kind: "cancelled", reason });
3008
- }
3009
- return true;
3010
- }
3011
- pendingCount() {
3012
- return this.pending.size;
3013
- }
3014
- disposeAll(reason = "auto_resolved") {
3015
- for (const id of [...this.pending.keys()]) {
3016
- this.cancel(id, reason);
3017
- }
3018
- }
3019
- settlePermission(channelRequestId, result) {
3020
- const entry = this.pending.get(channelRequestId);
3021
- if (!entry || entry.kind !== "permission" || entry.settled) return;
3022
- entry.settled = true;
3023
- this.pending.delete(channelRequestId);
3024
- clearTimeout(entry.timer);
3025
- for (const ctrl of entry.controllers) {
3026
- if (!ctrl.signal.aborted) ctrl.abort();
3027
- }
3028
- entry.resolve(result);
3029
- }
3030
- settleQuestion(channelRequestId, result) {
3031
- const entry = this.pending.get(channelRequestId);
3032
- if (!entry || entry.kind !== "question" || entry.settled) return;
3033
- entry.settled = true;
3034
- this.pending.delete(channelRequestId);
3035
- clearTimeout(entry.timer);
3036
- for (const ctrl of entry.controllers) {
3037
- if (!ctrl.signal.aborted) ctrl.abort();
3038
- }
3039
- entry.resolve(result);
3317
+ return result;
3040
3318
  }
3041
3319
  };
3042
3320
  function permissionFingerprint(req) {
@@ -3116,112 +3394,6 @@ function initGatewayStateSchema(db) {
3116
3394
  }
3117
3395
  }
3118
3396
 
3119
- // src/gateway/state/inboundQueue.ts
3120
- var DEFAULT_MAX_ENTRIES = 1e3;
3121
- var InboundQueue = class {
3122
- db;
3123
- maxEntries;
3124
- constructor(db, opts = {}) {
3125
- this.db = db;
3126
- this.maxEntries = opts.maxEntries ?? DEFAULT_MAX_ENTRIES;
3127
- }
3128
- size() {
3129
- const row = this.db.prepare("SELECT COUNT(*) as n FROM inbound_queue").get();
3130
- return row.n;
3131
- }
3132
- enqueue(inbound) {
3133
- if (this.size() >= this.maxEntries) {
3134
- return { kind: "rejected", reason: "queue_full" };
3135
- }
3136
- const stmt = this.db.prepare(
3137
- `INSERT INTO inbound_queue
3138
- (channel_id, account_id, idempotency_key, payload_json, created_at)
3139
- VALUES (?, ?, ?, ?, ?)
3140
- ON CONFLICT(channel_id, account_id, idempotency_key) DO NOTHING`
3141
- );
3142
- const result = stmt.run(
3143
- inbound.location.channelId,
3144
- inbound.location.accountId,
3145
- inbound.idempotencyKey,
3146
- JSON.stringify(inbound),
3147
- Date.now()
3148
- );
3149
- if (result.changes === 0) {
3150
- return { kind: "duplicate" };
3151
- }
3152
- return { kind: "queued", id: Number(result.lastInsertRowid) };
3153
- }
3154
- /** Atomically read and remove all parked entries in FIFO order. */
3155
- drain() {
3156
- return this.db.transaction(() => {
3157
- const rows = this.db.prepare("SELECT id, payload_json FROM inbound_queue ORDER BY id ASC").all();
3158
- if (rows.length > 0) {
3159
- this.db.prepare("DELETE FROM inbound_queue").run();
3160
- }
3161
- return rows.map((r) => ({
3162
- id: r.id,
3163
- inbound: JSON.parse(r.payload_json)
3164
- }));
3165
- })();
3166
- }
3167
- };
3168
-
3169
- // src/gateway/state/outbox.ts
3170
- var Outbox = class {
3171
- db;
3172
- constructor(db) {
3173
- this.db = db;
3174
- }
3175
- size() {
3176
- const row = this.db.prepare("SELECT COUNT(*) as n FROM channel_outbox").get();
3177
- return row.n;
3178
- }
3179
- enqueue(input) {
3180
- const result = this.db.prepare(
3181
- `INSERT INTO channel_outbox
3182
- (channel_id, payload_json, attempt, next_attempt_at, last_error, created_at)
3183
- VALUES (?, ?, 0, ?, ?, ?)`
3184
- ).run(
3185
- input.channelId,
3186
- JSON.stringify(input.message),
3187
- input.nextAttemptAt,
3188
- input.lastError ?? null,
3189
- Date.now()
3190
- );
3191
- return Number(result.lastInsertRowid);
3192
- }
3193
- /** Rows whose `next_attempt_at` is at or before `now`, oldest first. */
3194
- peekDue(now, limit) {
3195
- const rows = this.db.prepare(
3196
- `SELECT id, channel_id, payload_json, attempt, next_attempt_at, last_error
3197
- FROM channel_outbox
3198
- WHERE next_attempt_at <= ?
3199
- ORDER BY next_attempt_at ASC, id ASC
3200
- LIMIT ?`
3201
- ).all(now, limit);
3202
- return rows.map((r) => ({
3203
- id: r.id,
3204
- channelId: r.channel_id,
3205
- message: JSON.parse(r.payload_json),
3206
- attempt: r.attempt,
3207
- nextAttemptAt: r.next_attempt_at,
3208
- lastError: r.last_error
3209
- }));
3210
- }
3211
- delete(id) {
3212
- this.db.prepare("DELETE FROM channel_outbox WHERE id = ?").run(id);
3213
- }
3214
- recordFailure(input) {
3215
- this.db.prepare(
3216
- `UPDATE channel_outbox
3217
- SET attempt = attempt + 1,
3218
- next_attempt_at = ?,
3219
- last_error = ?
3220
- WHERE id = ?`
3221
- ).run(input.nextAttemptAt, input.lastError, input.id);
3222
- }
3223
- };
3224
-
3225
3397
  // src/gateway/transport/tlsWs.ts
3226
3398
  import { WebSocketServer } from "ws";
3227
3399
  import { createServer as createHttpsServer } from "https";
@@ -3466,62 +3638,35 @@ async function startDaemon(opts) {
3466
3638
  loopback: listenSpec.kind === "uds" || isLoopbackHost(listenSpec.host)
3467
3639
  };
3468
3640
  const stateDb = openGatewayState(paths.statePath);
3469
- const inboundQueue = new InboundQueue(stateDb);
3470
- const outbox = new Outbox(stateDb);
3471
- const registry = new SessionRegistry();
3472
3641
  const channelManager = new ChannelManager();
3473
3642
  const relayCoordinator = new RelayCoordinator({
3474
3643
  adapters: () => channelManager.listAdapters()
3475
3644
  });
3476
- const runtimeConnections = /* @__PURE__ */ new Map();
3477
- const staleRuntimeTimers = /* @__PURE__ */ new Map();
3478
3645
  const connectionOpenedAt = /* @__PURE__ */ new Map();
3479
3646
  const disconnectGracePeriodMs = opts.disconnectGracePeriodMs ?? 0;
3480
3647
  let listenerStatus = null;
3481
- const pushDispatch = (payload) => {
3482
- const current = registry.getCurrent();
3483
- if (!current) return;
3484
- const ctx = runtimeConnections.get(current.runtimeId);
3485
- if (!ctx) return;
3486
- ctx.push({
3487
- push_id: crypto.randomUUID(),
3488
- ts: Date.now(),
3489
- kind: "session.dispatch.turn",
3490
- payload
3491
- });
3492
- };
3493
3648
  const log = (level, message) => {
3494
3649
  if (opts.silent) return;
3495
3650
  const stream = level === "error" || level === "warn" ? "stderr" : "stdout";
3496
3651
  process[stream].write(`athena-gateway: [${level}] ${message}
3497
3652
  `);
3498
3653
  };
3499
- const outboundDispatcher = new OutboundDispatcher({
3500
- outbox,
3654
+ const pipeline = new DispatchPipeline({
3655
+ stateDb,
3501
3656
  send: (channelId, msg) => channelManager.send(channelId, msg),
3502
- log
3503
- });
3504
- outboundDispatcher.start();
3505
- const dispatcher = new Dispatcher({
3506
- registry,
3507
- pushDispatch,
3508
- canDispatch: () => {
3509
- const current = registry.getCurrent();
3510
- return current ? registry.hasActiveBinding(current.runtimeId) : false;
3511
- },
3512
- sendOutbound: async (channelId, msg) => {
3513
- const result = await outboundDispatcher.dispatch(channelId, msg);
3514
- if (result.kind === "sent") return result.result;
3515
- return {
3516
- providerMessageId: `outbox:${result.outboxId}`,
3517
- deliveredAt: Date.now()
3518
- };
3519
- },
3520
- inboundQueue,
3521
- log
3657
+ gracePeriodMs: disconnectGracePeriodMs,
3658
+ log,
3659
+ observers: {
3660
+ onRuntimeRebind: ({ gapMs, epoch }) => trackGatewayRuntimeRebind({ gapMs, epoch }),
3661
+ onRuntimeExpired: ({ gapMs }) => trackGatewayRuntimeExpired({ gapMs }),
3662
+ // Single-runtime v1: blanket dispose is safe. Multi-runtime must
3663
+ // scope to the disconnecting runtime via disposeAllForRuntime.
3664
+ onRuntimeConnectionLost: () => relayCoordinator.disposeAll("connection_lost")
3665
+ }
3522
3666
  });
3667
+ pipeline.start();
3523
3668
  channelManager.setInboundSink((inbound) => {
3524
- dispatcher.handleInbound(inbound);
3669
+ pipeline.handleInbound(inbound);
3525
3670
  });
3526
3671
  const channelConfigHome = opts.env?.HOME;
3527
3672
  const reloadChannels = async () => {
@@ -3536,7 +3681,7 @@ async function startDaemon(opts) {
3536
3681
  reason: err.reason
3537
3682
  });
3538
3683
  }
3539
- const sidecarIds = new Set(sidecars.map((s) => s.name));
3684
+ const sidecarIds = new Set(sidecars.map((s) => s.instanceId));
3540
3685
  for (const channel of channelManager.listChannels()) {
3541
3686
  if (sidecarIds.has(channel.id)) continue;
3542
3687
  try {
@@ -3556,13 +3701,13 @@ async function startDaemon(opts) {
3556
3701
  }
3557
3702
  }
3558
3703
  for (const sidecar of sidecars) {
3559
- const existed = channelManager.listChannels().some((channel) => channel.id === sidecar.name);
3704
+ const existed = channelManager.listChannels().some((channel) => channel.id === sidecar.instanceId);
3560
3705
  if (existed) {
3561
3706
  try {
3562
- await channelManager.unregister(sidecar.name, "shutdown");
3707
+ await channelManager.unregister(sidecar.instanceId, "shutdown");
3563
3708
  } catch (err) {
3564
3709
  results.push({
3565
- id: sidecar.name,
3710
+ id: sidecar.instanceId,
3566
3711
  ok: false,
3567
3712
  action: "failed",
3568
3713
  reason: err instanceof Error ? err.message : String(err)
@@ -3573,7 +3718,7 @@ async function startDaemon(opts) {
3573
3718
  const built = instantiateAdapter(sidecar);
3574
3719
  if (!built.ok) {
3575
3720
  results.push({
3576
- id: sidecar.name,
3721
+ id: sidecar.instanceId,
3577
3722
  ok: false,
3578
3723
  action: "failed",
3579
3724
  reason: built.reason
@@ -3583,17 +3728,19 @@ async function startDaemon(opts) {
3583
3728
  try {
3584
3729
  await channelManager.register(built.adapter);
3585
3730
  results.push({
3586
- id: sidecar.name,
3731
+ id: sidecar.instanceId,
3587
3732
  ok: true,
3588
3733
  action: existed ? "replaced" : "registered"
3589
3734
  });
3590
3735
  if (!opts.silent) {
3591
- process.stdout.write(`athena-gateway: registered ${sidecar.name}
3592
- `);
3736
+ process.stdout.write(
3737
+ `athena-gateway: registered ${sidecar.instanceId}
3738
+ `
3739
+ );
3593
3740
  }
3594
3741
  } catch (err) {
3595
3742
  results.push({
3596
- id: sidecar.name,
3743
+ id: sidecar.instanceId,
3597
3744
  ok: false,
3598
3745
  action: "failed",
3599
3746
  reason: err instanceof Error ? err.message : String(err)
@@ -3614,7 +3761,7 @@ async function startDaemon(opts) {
3614
3761
  const built = instantiateAdapter(sidecar);
3615
3762
  if (!built.ok) {
3616
3763
  process.stderr.write(
3617
- `athena-gateway: ${sidecar.name}: ${built.reason}
3764
+ `athena-gateway: ${sidecar.instanceId}: ${built.reason}
3618
3765
  `
3619
3766
  );
3620
3767
  continue;
@@ -3622,12 +3769,14 @@ async function startDaemon(opts) {
3622
3769
  try {
3623
3770
  await channelManager.register(built.adapter);
3624
3771
  if (!opts.silent) {
3625
- process.stdout.write(`athena-gateway: registered ${sidecar.name}
3626
- `);
3772
+ process.stdout.write(
3773
+ `athena-gateway: registered ${sidecar.instanceId}
3774
+ `
3775
+ );
3627
3776
  }
3628
3777
  } catch (err) {
3629
3778
  process.stderr.write(
3630
- `athena-gateway: register ${sidecar.name} failed: ${err instanceof Error ? err.message : String(err)}
3779
+ `athena-gateway: register ${sidecar.instanceId} failed: ${err instanceof Error ? err.message : String(err)}
3631
3780
  `
3632
3781
  );
3633
3782
  }
@@ -3635,45 +3784,11 @@ async function startDaemon(opts) {
3635
3784
  }
3636
3785
  const handler = createDispatcher({
3637
3786
  startedAt,
3638
- registry,
3639
- dispatcher,
3787
+ pipeline,
3640
3788
  channelManager,
3641
3789
  relayCoordinator,
3642
3790
  getListener: () => listenerStatus ?? buildListenerStatus(listenSpec, null),
3643
- reloadChannels,
3644
- registerRuntimeConnection: (runtimeId, ctx) => {
3645
- const timer = staleRuntimeTimers.get(runtimeId);
3646
- if (timer) {
3647
- clearTimeout(timer);
3648
- staleRuntimeTimers.delete(runtimeId);
3649
- }
3650
- const previousBinding = registry.getBinding();
3651
- const wasStale = previousBinding?.state === "stale";
3652
- const staleSince = wasStale ? previousBinding.staleSince : null;
3653
- registry.bindConnection(runtimeId, ctx.connectionId);
3654
- runtimeConnections.set(runtimeId, ctx);
3655
- writeGatewayTrace(
3656
- `daemon registered runtime runtimeId=${runtimeId} connectionId=${ctx.connectionId}`
3657
- );
3658
- if (wasStale && staleSince !== null) {
3659
- const newBinding = registry.getBinding();
3660
- if (newBinding?.state === "active") {
3661
- trackGatewayRuntimeRebind({
3662
- gapMs: Date.now() - staleSince,
3663
- epoch: newBinding.epoch
3664
- });
3665
- }
3666
- }
3667
- },
3668
- unregisterRuntimeConnection: (runtimeId) => {
3669
- const timer = staleRuntimeTimers.get(runtimeId);
3670
- if (timer) {
3671
- clearTimeout(timer);
3672
- staleRuntimeTimers.delete(runtimeId);
3673
- }
3674
- runtimeConnections.delete(runtimeId);
3675
- writeGatewayTrace(`daemon unregistered runtime runtimeId=${runtimeId}`);
3676
- }
3791
+ reloadChannels
3677
3792
  });
3678
3793
  let server;
3679
3794
  let listener;
@@ -3707,39 +3822,7 @@ async function startDaemon(opts) {
3707
3822
  reason: "closed",
3708
3823
  durationMs
3709
3824
  });
3710
- const current = registry.getCurrent();
3711
- if (current && runtimeConnections.get(current.runtimeId)?.connectionId === ctx.connectionId) {
3712
- writeGatewayTrace(
3713
- `daemon runtime connection disconnected runtimeId=${current.runtimeId} connectionId=${ctx.connectionId}`
3714
- );
3715
- runtimeConnections.delete(current.runtimeId);
3716
- const staleAt = Date.now();
3717
- registry.markConnectionStale(ctx.connectionId);
3718
- if (disconnectGracePeriodMs <= 0) {
3719
- try {
3720
- registry.unregister(current.runtimeId);
3721
- } catch {
3722
- }
3723
- relayCoordinator.disposeAll("connection_lost");
3724
- return;
3725
- }
3726
- const runtimeId = current.runtimeId;
3727
- const timer = setTimeout(() => {
3728
- staleRuntimeTimers.delete(runtimeId);
3729
- const latest = registry.getCurrent();
3730
- if (latest?.runtimeId === runtimeId && !registry.hasActiveBinding(runtimeId)) {
3731
- try {
3732
- registry.unregister(runtimeId);
3733
- } catch {
3734
- }
3735
- relayCoordinator.disposeAll("connection_lost");
3736
- trackGatewayRuntimeExpired({
3737
- gapMs: Date.now() - staleAt
3738
- });
3739
- }
3740
- }, disconnectGracePeriodMs);
3741
- staleRuntimeTimers.set(runtimeId, timer);
3742
- }
3825
+ pipeline.notifyConnectionClosed(ctx.connectionId);
3743
3826
  }
3744
3827
  });
3745
3828
  if (listenSpec.kind === "tcp") {
@@ -3775,11 +3858,7 @@ async function startDaemon(opts) {
3775
3858
  if (stopping) return;
3776
3859
  stopping = true;
3777
3860
  try {
3778
- outboundDispatcher.stop();
3779
- for (const timer of staleRuntimeTimers.values()) {
3780
- clearTimeout(timer);
3781
- }
3782
- staleRuntimeTimers.clear();
3861
+ await pipeline.stop();
3783
3862
  relayCoordinator.disposeAll("auto_resolved");
3784
3863
  await channelManager.stop();
3785
3864
  await server.close();
@@ -3804,13 +3883,9 @@ async function startDaemon(opts) {
3804
3883
  startedAt,
3805
3884
  pid,
3806
3885
  paths,
3807
- registry,
3808
- dispatcher,
3886
+ pipeline,
3809
3887
  channelManager,
3810
3888
  relayCoordinator,
3811
- inboundQueue,
3812
- outbox,
3813
- outboundDispatcher,
3814
3889
  listener,
3815
3890
  stop
3816
3891
  };