@drisp/cli 0.4.1 → 0.4.4

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.
@@ -1,110 +1,29 @@
1
1
  #!/usr/bin/env node
2
+ import {
3
+ loadChannelSidecars,
4
+ loadOrCreateToken,
5
+ requireTokenForBind,
6
+ timingSafeTokenEqual
7
+ } from "./chunk-6TJHAUNB.js";
2
8
  import {
3
9
  CHANNEL_REQUEST_ID_REGEX,
4
10
  createUdsServerTransport,
5
11
  generateChannelRequestId,
6
12
  isLoopbackHost,
7
13
  isValidChannelRequestId,
8
- loadOrCreateToken,
9
14
  refreshDashboardAccessToken,
10
- requireTokenForBind,
11
15
  resolveGatewayPaths,
12
16
  resolveListenSpec,
13
- timingSafeTokenEqual,
14
17
  traceGatewayFrame,
15
18
  trackGatewayRuntimeExpired,
16
19
  trackGatewayRuntimeRebind,
17
20
  trackGatewayTransportConnect,
18
21
  trackGatewayTransportDisconnect,
19
22
  writeGatewayTrace
20
- } from "./chunk-3FVULBV4.js";
23
+ } from "./chunk-4CRZXLIP.js";
21
24
 
22
25
  // src/gateway/daemon.ts
23
- import crypto from "crypto";
24
- import fs4 from "fs";
25
-
26
- // src/infra/config/channels.ts
27
- import fs from "fs";
28
- import os from "os";
29
- import path from "path";
30
- function channelSidecarDir(home = os.homedir()) {
31
- return path.join(home, ".config", "athena", "channels");
32
- }
33
- function loadChannelSidecars(home = os.homedir()) {
34
- const dir = channelSidecarDir(home);
35
- const sidecars = [];
36
- const errors = [];
37
- let entries;
38
- try {
39
- entries = fs.readdirSync(dir);
40
- } catch (err) {
41
- const code = err.code;
42
- if (code === "ENOENT") return { sidecars, errors };
43
- errors.push({
44
- path: dir,
45
- reason: `read dir failed: ${err instanceof Error ? err.message : String(err)}`
46
- });
47
- return { sidecars, errors };
48
- }
49
- for (const entry of entries) {
50
- if (!entry.endsWith(".json")) continue;
51
- const full = path.join(dir, entry);
52
- const name = entry.slice(0, -".json".length);
53
- const result = loadOne(name, full);
54
- if (result.ok) sidecars.push(result.sidecar);
55
- else errors.push({ path: full, reason: result.reason });
56
- }
57
- return { sidecars, errors };
58
- }
59
- function loadOne(name, filePath) {
60
- let raw;
61
- try {
62
- if (process.platform !== "win32") {
63
- const stat = fs.statSync(filePath);
64
- if ((stat.mode & 63) !== 0) {
65
- return {
66
- ok: false,
67
- reason: `file ${filePath} is too permissive (mode ${(stat.mode & 511).toString(8)}); chmod 600`
68
- };
69
- }
70
- }
71
- raw = JSON.parse(fs.readFileSync(filePath, "utf-8"));
72
- } catch (err) {
73
- return {
74
- ok: false,
75
- reason: err instanceof Error ? err.message : String(err)
76
- };
77
- }
78
- if (typeof raw !== "object" || raw === null) {
79
- return { ok: false, reason: "config root must be an object" };
80
- }
81
- const obj = raw;
82
- const userIdsRaw = obj["allowed_user_ids"];
83
- const allowedUserIds = [];
84
- if (userIdsRaw !== void 0) {
85
- if (!Array.isArray(userIdsRaw)) {
86
- return { ok: false, reason: "allowed_user_ids must be an array" };
87
- }
88
- for (const id of userIdsRaw) {
89
- if (typeof id === "string") allowedUserIds.push(id);
90
- else if (typeof id === "number") allowedUserIds.push(String(id));
91
- else
92
- return {
93
- ok: false,
94
- reason: "allowed_user_ids entries must be string or number"
95
- };
96
- }
97
- }
98
- const options = {};
99
- for (const [key, value] of Object.entries(obj)) {
100
- if (key === "allowed_user_ids") continue;
101
- options[key] = value;
102
- }
103
- return {
104
- ok: true,
105
- sidecar: { name, path: filePath, allowedUserIds, options }
106
- };
107
- }
26
+ import fs3 from "fs";
108
27
 
109
28
  // src/gateway/adapters/console/adapter.ts
110
29
  import { readFileSync as readFileSync2 } from "fs";
@@ -115,6 +34,8 @@ import { WebSocket } from "ws";
115
34
  var DEFAULT_CONNECT_TIMEOUT_MS = 1e4;
116
35
  var DEFAULT_INITIAL_RECONNECT_MS = 1e3;
117
36
  var DEFAULT_MAX_RECONNECT_MS = 3e4;
37
+ var DEFAULT_HEARTBEAT_INTERVAL_MS = 3e4;
38
+ var DEFAULT_HEARTBEAT_TIMEOUT_MS = 9e4;
118
39
  function createConsoleBrokerClient(opts) {
119
40
  const hasStatic = typeof opts.pairingToken === "string" && opts.pairingToken.length > 0;
120
41
  const hasProvider = typeof opts.pairingTokenProvider === "function";
@@ -126,6 +47,8 @@ function createConsoleBrokerClient(opts) {
126
47
  const provider = hasStatic ? async () => opts.pairingToken : opts.pairingTokenProvider;
127
48
  const initialDelay = opts.reconnect?.initialDelayMs ?? DEFAULT_INITIAL_RECONNECT_MS;
128
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;
129
52
  let ws = null;
130
53
  let ready = null;
131
54
  let closeRequested = false;
@@ -133,6 +56,8 @@ function createConsoleBrokerClient(opts) {
133
56
  let reconnectTimer = null;
134
57
  let lastHello = null;
135
58
  let currentToken = null;
59
+ let heartbeatTimer = null;
60
+ let lastPongAt = null;
136
61
  const frameHandlers = /* @__PURE__ */ new Set();
137
62
  const closeHandlers = /* @__PURE__ */ new Set();
138
63
  const readyHandlers = /* @__PURE__ */ new Set();
@@ -141,6 +66,44 @@ function createConsoleBrokerClient(opts) {
141
66
  if (!currentToken) return message;
142
67
  return message.split(currentToken).join(tokenRedacted);
143
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
+ }
144
107
  function emitClose(reason) {
145
108
  for (const h of [...closeHandlers]) {
146
109
  try {
@@ -296,6 +259,10 @@ function createConsoleBrokerClient(opts) {
296
259
  );
297
260
  return;
298
261
  }
262
+ if (parsed.kind === "console.pong") {
263
+ lastPongAt = Date.now();
264
+ return;
265
+ }
299
266
  for (const h of [...frameHandlers]) {
300
267
  try {
301
268
  h(parsed);
@@ -320,12 +287,14 @@ function createConsoleBrokerClient(opts) {
320
287
  throw err;
321
288
  }
322
289
  next.on("close", (_code, reasonBuf) => {
290
+ stopHeartbeat();
323
291
  if (next !== ws) return;
324
292
  ws = null;
325
293
  ready = null;
326
294
  emitClose(reasonBuf.toString() || "closed");
327
295
  if (!closeRequested) scheduleReconnect();
328
296
  });
297
+ startHeartbeat(next);
329
298
  }
330
299
  async function connect(hello) {
331
300
  if (ws) throw new Error("console broker client already connected");
@@ -335,6 +304,7 @@ function createConsoleBrokerClient(opts) {
335
304
  }
336
305
  function close(reason) {
337
306
  closeRequested = true;
307
+ stopHeartbeat();
338
308
  if (reconnectTimer) {
339
309
  clearTimeout(reconnectTimer);
340
310
  reconnectTimer = null;
@@ -461,18 +431,12 @@ var ConsoleAdapter = class {
461
431
  runnerId: this.opts.runnerId,
462
432
  ...workspaceId.length > 0 ? { workspaceId } : {},
463
433
  ...msg.location.peer?.id !== void 0 ? { userId: msg.location.peer.id } : {},
464
- ...msg.location.thread?.id !== void 0 ? {
465
- conversationId: msg.location.thread.id,
466
- threadId: msg.location.thread.id
467
- } : {}
434
+ ...msg.location.thread?.id !== void 0 ? { threadId: msg.location.thread.id } : {}
468
435
  },
469
436
  messageId,
470
437
  idempotencyKey: msg.idempotencyKey,
471
438
  text: msg.text
472
439
  };
473
- writeGatewayTrace(
474
- `consoleAdapter send message.out runner=${this.opts.runnerId} workspace=${frame.address.workspaceId ?? ""} conversation=${frame.address.conversationId ?? ""} thread=${frame.address.threadId ?? ""} user=${frame.address.userId ?? ""} textLength=${msg.text.length} idempotencyKey=${msg.idempotencyKey}`
475
- );
476
440
  client.sendFrame(frame);
477
441
  return {
478
442
  providerMessageId: messageId,
@@ -1959,8 +1923,7 @@ var ChannelManager = class {
1959
1923
  // src/gateway/control/handlers.ts
1960
1924
  import { createRequire } from "module";
1961
1925
 
1962
- // src/gateway/sessionRegistry.ts
1963
- import { randomUUID } from "crypto";
1926
+ // src/gateway/runtimeBindingStore.ts
1964
1927
  var AlreadyRegisteredError = class extends Error {
1965
1928
  code = "already_registered";
1966
1929
  constructor(existing) {
@@ -1977,120 +1940,145 @@ var NotRegisteredError = class extends Error {
1977
1940
  this.name = "NotRegisteredError";
1978
1941
  }
1979
1942
  };
1980
- var UnknownDispatchError = class extends Error {
1981
- code = "unknown_dispatch";
1982
- constructor(id) {
1983
- super(`unknown dispatchId: ${id}`);
1984
- this.name = "UnknownDispatchError";
1985
- }
1986
- };
1987
1943
  function maybeLastRebindAt(value) {
1988
1944
  return value !== void 0 ? { lastRebindAt: value } : {};
1989
1945
  }
1990
- var SessionRegistry = class {
1946
+ var RuntimeBindingStore = class {
1991
1947
  current = null;
1992
- binding = null;
1993
- dispatches = /* @__PURE__ */ new Map();
1994
- idFactory;
1948
+ bindingState = null;
1949
+ staleTimer = null;
1950
+ staleSince = null;
1951
+ gracePeriodMs;
1952
+ observers;
1995
1953
  now;
1996
1954
  constructor(opts = {}) {
1997
- this.idFactory = opts.idFactory ?? randomUUID;
1955
+ this.gracePeriodMs = opts.gracePeriodMs ?? 0;
1956
+ this.observers = opts.observers ?? {};
1998
1957
  this.now = opts.now ?? Date.now;
1999
1958
  }
2000
- register(input) {
2001
- if (this.current) {
2002
- if (this.current.runtimeId === input.runtimeId) {
2003
- this.current = {
2004
- ...this.current,
2005
- defaultAgentId: input.defaultAgentId,
2006
- pid: input.pid
2007
- };
2008
- return this.current;
2009
- }
1959
+ // ── lifecycle ─────────────────────────────────────────────
1960
+ /** Register a runtime and bind its connection in one atomic step. */
1961
+ bind(input) {
1962
+ const previous = this.bindingState;
1963
+ const wasStale = previous?.state === "stale";
1964
+ const staleSince = wasStale ? previous.staleSince : null;
1965
+ if (!this.current) {
1966
+ this.current = {
1967
+ runtimeId: input.runtimeId,
1968
+ defaultAgentId: input.defaultAgentId,
1969
+ pid: input.pid,
1970
+ registeredAt: this.now()
1971
+ };
1972
+ } else if (this.current.runtimeId === input.runtimeId) {
1973
+ this.current = {
1974
+ ...this.current,
1975
+ defaultAgentId: input.defaultAgentId,
1976
+ pid: input.pid
1977
+ };
1978
+ } else {
2010
1979
  throw new AlreadyRegisteredError(this.current);
2011
1980
  }
2012
- this.current = { ...input, registeredAt: this.now() };
2013
- return this.current;
2014
- }
2015
- bindConnection(runtimeId, connectionId) {
2016
- if (!this.current || this.current.runtimeId !== runtimeId) {
2017
- throw new NotRegisteredError();
2018
- }
2019
- const previous = this.binding;
2020
1981
  const now = this.now();
2021
- const isRebind = previous !== null && (previous.state === "stale" || previous.connectionId !== connectionId);
1982
+ const isRebind = previous !== null && (previous.state === "stale" || previous.connectionId !== input.connectionId);
2022
1983
  const lastRebindAt = isRebind ? now : previous?.lastRebindAt;
2023
1984
  const epoch = previous ? previous.epoch + (isRebind ? 1 : 0) : 1;
2024
- this.binding = {
1985
+ this.bindingState = {
2025
1986
  state: "active",
2026
- connectionId,
1987
+ connectionId: input.connectionId,
2027
1988
  boundAt: now,
2028
1989
  epoch,
2029
1990
  ...maybeLastRebindAt(lastRebindAt)
2030
1991
  };
1992
+ this.clearStaleTimer();
1993
+ if (wasStale && staleSince !== null) {
1994
+ this.observers.onRuntimeRebind?.({
1995
+ runtimeId: input.runtimeId,
1996
+ gapMs: now - staleSince,
1997
+ epoch: this.bindingState.epoch
1998
+ });
1999
+ }
2000
+ return { registeredAt: this.current.registeredAt };
2001
+ }
2002
+ /** Fully unregister a runtime. Throws NotRegisteredError if id does not match. */
2003
+ unbind(runtimeId) {
2004
+ if (!this.current || this.current.runtimeId !== runtimeId) {
2005
+ throw new NotRegisteredError();
2006
+ }
2007
+ this.current = null;
2008
+ this.bindingState = null;
2009
+ this.clearStaleTimer();
2031
2010
  }
2032
- markConnectionStale(connectionId) {
2033
- if (!this.current || !this.binding || this.binding.connectionId !== connectionId || this.binding.state !== "active") {
2011
+ /**
2012
+ * Called when the transport connection closes.
2013
+ * Returns the runtimeId if the close was for the current binding (caller should
2014
+ * clear the push handle); returns null if the connectionId was not recognised.
2015
+ */
2016
+ notifyConnectionClosed(connectionId) {
2017
+ if (!this.current || !this.bindingState || this.bindingState.connectionId !== connectionId) {
2034
2018
  return null;
2035
2019
  }
2036
- this.binding = {
2020
+ const runtimeId = this.current.runtimeId;
2021
+ const now = this.now();
2022
+ this.bindingState = {
2037
2023
  state: "stale",
2038
2024
  connectionId,
2039
- staleSince: this.now(),
2040
- epoch: this.binding.epoch,
2041
- ...maybeLastRebindAt(this.binding.lastRebindAt)
2025
+ staleSince: now,
2026
+ epoch: this.bindingState.epoch,
2027
+ ...maybeLastRebindAt(this.bindingState.lastRebindAt)
2042
2028
  };
2043
- return this.current.runtimeId;
2029
+ if (this.gracePeriodMs <= 0) {
2030
+ this.current = null;
2031
+ this.bindingState = null;
2032
+ this.observers.onRuntimeConnectionLost?.({ runtimeId, graceful: false });
2033
+ return runtimeId;
2034
+ }
2035
+ this.staleSince = now;
2036
+ this.staleTimer = setTimeout(() => {
2037
+ this.expireStaleBinding(runtimeId);
2038
+ }, this.gracePeriodMs);
2039
+ return runtimeId;
2040
+ }
2041
+ stop() {
2042
+ this.clearStaleTimer();
2044
2043
  }
2044
+ // ── reads ─────────────────────────────────────────────────
2045
2045
  hasActiveBinding(runtimeId) {
2046
- if (!this.current || !this.binding || this.binding.state !== "active") {
2046
+ if (!this.current || !this.bindingState || this.bindingState.state !== "active") {
2047
2047
  return false;
2048
2048
  }
2049
2049
  return runtimeId === void 0 || this.current.runtimeId === runtimeId;
2050
2050
  }
2051
+ getCurrent() {
2052
+ return this.current;
2053
+ }
2051
2054
  getBinding() {
2052
- return this.binding;
2055
+ return this.bindingState;
2053
2056
  }
2054
2057
  getRuntimeIdByConnection(connectionId) {
2055
- if (!this.current || !this.binding) return null;
2056
- return this.binding.connectionId === connectionId ? this.current.runtimeId : null;
2057
- }
2058
- unregister(runtimeId) {
2059
- if (!this.current || this.current.runtimeId !== runtimeId) {
2060
- throw new NotRegisteredError();
2058
+ if (!this.current || !this.bindingState) return null;
2059
+ return this.bindingState.connectionId === connectionId ? this.current.runtimeId : null;
2060
+ }
2061
+ // ── private ───────────────────────────────────────────────
2062
+ expireStaleBinding(runtimeId) {
2063
+ this.staleTimer = null;
2064
+ const since = this.staleSince;
2065
+ this.staleSince = null;
2066
+ if (!this.current || this.current.runtimeId !== runtimeId || this.hasActiveBinding(runtimeId)) {
2067
+ return;
2061
2068
  }
2062
2069
  this.current = null;
2063
- this.binding = null;
2064
- this.dispatches.clear();
2065
- }
2066
- getCurrent() {
2067
- return this.current;
2068
- }
2069
- beginDispatch(input) {
2070
- if (!this.current) {
2071
- throw new NotRegisteredError();
2070
+ this.bindingState = null;
2071
+ this.observers.onRuntimeConnectionLost?.({ runtimeId, graceful: false });
2072
+ if (since !== null) {
2073
+ this.observers.onRuntimeExpired?.({ runtimeId, gapMs: this.now() - since });
2072
2074
  }
2073
- const dispatchId = this.idFactory();
2074
- const entry = {
2075
- dispatchId,
2076
- sessionKey: input.sessionKey,
2077
- agentId: input.agentId,
2078
- location: input.location,
2079
- createdAt: this.now()
2080
- };
2081
- this.dispatches.set(dispatchId, entry);
2082
- return entry;
2083
2075
  }
2084
- completeDispatch(dispatchId) {
2085
- const entry = this.dispatches.get(dispatchId);
2086
- if (!entry) {
2087
- throw new UnknownDispatchError(dispatchId);
2076
+ clearStaleTimer() {
2077
+ if (this.staleTimer) {
2078
+ clearTimeout(this.staleTimer);
2079
+ this.staleTimer = null;
2088
2080
  }
2089
- this.dispatches.delete(dispatchId);
2090
- return entry;
2091
- }
2092
- pendingDispatchCount() {
2093
- return this.dispatches.size;
2081
+ this.staleSince = null;
2094
2082
  }
2095
2083
  };
2096
2084
 
@@ -2100,7 +2088,7 @@ var cachedVersion = null;
2100
2088
  function readVersion() {
2101
2089
  if (cachedVersion !== null) return cachedVersion;
2102
2090
  try {
2103
- const injected = "0.4.1";
2091
+ const injected = "0.4.4";
2104
2092
  if (typeof injected === "string" && injected.length > 0) {
2105
2093
  cachedVersion = injected;
2106
2094
  return cachedVersion;
@@ -2115,273 +2103,258 @@ function readVersion() {
2115
2103
  }
2116
2104
  return cachedVersion;
2117
2105
  }
2106
+ var PING = {
2107
+ kind: "ping",
2108
+ handle: (_envelope, { ts, deps }) => {
2109
+ const payload = {
2110
+ pong: true,
2111
+ daemonPid: process.pid,
2112
+ uptimeMs: ts - deps.startedAt
2113
+ };
2114
+ return payload;
2115
+ }
2116
+ };
2117
+ var STATUS = {
2118
+ kind: "status",
2119
+ handle: (_envelope, { ts, deps }) => {
2120
+ const channels = (deps.channelManager?.listChannels() ?? []).map((c) => ({
2121
+ id: c.id,
2122
+ state: c.health?.transportOk === false ? "degraded" : "running",
2123
+ ...c.health?.at !== void 0 ? { lastHealthAt: c.health.at } : {},
2124
+ ...c.health?.note !== void 0 ? { note: c.health.note } : {}
2125
+ }));
2126
+ const payload = {
2127
+ daemonPid: process.pid,
2128
+ startedAt: deps.startedAt,
2129
+ uptimeMs: ts - deps.startedAt,
2130
+ version: readVersion(),
2131
+ listener: deps.getListener?.() ?? {
2132
+ kind: "uds",
2133
+ socketPath: "<unknown>"
2134
+ },
2135
+ channels,
2136
+ runtimes: runtimeStatusEntries(deps.pipeline)
2137
+ };
2138
+ return payload;
2139
+ }
2140
+ };
2141
+ var CHANNELS_RELOAD = {
2142
+ kind: "channels.reload",
2143
+ requires: ["reloadChannels"],
2144
+ unsupportedMessage: "channel reload not configured",
2145
+ handle: async (_envelope, { deps }) => {
2146
+ const payload = await deps.reloadChannels();
2147
+ return payload;
2148
+ }
2149
+ };
2150
+ var SESSION_REGISTER = {
2151
+ kind: "session.register",
2152
+ requires: ["pipeline"],
2153
+ unsupportedMessage: "session.register not configured",
2154
+ handle: (envelope, { deps, connection }) => {
2155
+ const req = envelope.payload;
2156
+ const reg = deps.pipeline.registerRuntime({
2157
+ runtimeId: req.runtimeId,
2158
+ defaultAgentId: req.defaultAgentId,
2159
+ pid: req.pid,
2160
+ connectionId: connection.connectionId,
2161
+ push: connection.push
2162
+ });
2163
+ const payload = {
2164
+ registeredAt: reg.registeredAt,
2165
+ gatewayStartedAt: deps.startedAt
2166
+ };
2167
+ return payload;
2168
+ }
2169
+ };
2170
+ var SESSION_UNREGISTER = {
2171
+ kind: "session.unregister",
2172
+ requires: ["pipeline"],
2173
+ unsupportedMessage: "session.unregister not configured",
2174
+ handle: (envelope, { deps, ts }) => {
2175
+ const req = envelope.payload;
2176
+ deps.pipeline.unregisterRuntime(req.runtimeId);
2177
+ const payload = {
2178
+ unregisteredAt: ts
2179
+ };
2180
+ return payload;
2181
+ }
2182
+ };
2183
+ var SESSION_TURN_COMPLETE = {
2184
+ kind: "session.turn.complete",
2185
+ requires: ["pipeline"],
2186
+ unsupportedMessage: "pipeline not configured",
2187
+ handle: async (envelope, { deps }) => {
2188
+ const req = envelope.payload;
2189
+ return await deps.pipeline.handleTurnComplete(req);
2190
+ }
2191
+ };
2192
+ var CHANNEL_SEND = {
2193
+ kind: "channel.send",
2194
+ requires: ["channelManager"],
2195
+ unsupportedMessage: "channel manager not configured",
2196
+ handle: async (envelope, { deps }) => {
2197
+ const req = envelope.payload;
2198
+ const result = await deps.channelManager.send(
2199
+ req.message.location.channelId,
2200
+ req.message
2201
+ );
2202
+ const payload = {
2203
+ providerMessageId: result.providerMessageId,
2204
+ deliveredAt: result.deliveredAt
2205
+ };
2206
+ return payload;
2207
+ }
2208
+ };
2209
+ var RELAY_PERMISSION_REQUEST = {
2210
+ kind: "relay.permission.request",
2211
+ requires: ["relayCoordinator"],
2212
+ unsupportedMessage: "relay coordinator not configured",
2213
+ requireRegisteredRuntime: true,
2214
+ handle: async (envelope, { deps, callerRuntimeId }) => {
2215
+ const req = envelope.payload;
2216
+ const broadcast = deps.relayCoordinator.requestPermission({
2217
+ ...req.channelRequestId !== void 0 ? { channelRequestId: req.channelRequestId } : {},
2218
+ toolName: req.toolName,
2219
+ description: req.description,
2220
+ inputPreview: req.inputPreview,
2221
+ ...req.ttlMs !== void 0 ? { ttlMs: req.ttlMs } : {},
2222
+ ...callerRuntimeId !== void 0 ? { runtimeId: callerRuntimeId } : {}
2223
+ });
2224
+ const result = await broadcast.result;
2225
+ const payload = {
2226
+ channelRequestId: broadcast.channelRequestId,
2227
+ result
2228
+ };
2229
+ return payload;
2230
+ }
2231
+ };
2232
+ var RELAY_PERMISSION_CANCEL = {
2233
+ kind: "relay.permission.cancel",
2234
+ requires: ["relayCoordinator"],
2235
+ unsupportedMessage: "relay coordinator not configured",
2236
+ requireRegisteredRuntime: true,
2237
+ handle: (envelope, { deps, callerRuntimeId }) => {
2238
+ const req = envelope.payload;
2239
+ const cancelled = deps.relayCoordinator.cancel(
2240
+ req.channelRequestId,
2241
+ req.reason,
2242
+ callerRuntimeId
2243
+ );
2244
+ const payload = { cancelled };
2245
+ return payload;
2246
+ }
2247
+ };
2248
+ var RELAY_QUESTION_REQUEST = {
2249
+ kind: "relay.question.request",
2250
+ requires: ["relayCoordinator"],
2251
+ unsupportedMessage: "relay coordinator not configured",
2252
+ requireRegisteredRuntime: true,
2253
+ handle: async (envelope, { deps, callerRuntimeId }) => {
2254
+ const req = envelope.payload;
2255
+ const broadcast = deps.relayCoordinator.requestQuestion({
2256
+ ...req.channelRequestId !== void 0 ? { channelRequestId: req.channelRequestId } : {},
2257
+ title: req.title,
2258
+ questions: req.questions,
2259
+ ...req.ttlMs !== void 0 ? { ttlMs: req.ttlMs } : {},
2260
+ ...callerRuntimeId !== void 0 ? { runtimeId: callerRuntimeId } : {}
2261
+ });
2262
+ const result = await broadcast.result;
2263
+ const payload = {
2264
+ channelRequestId: broadcast.channelRequestId,
2265
+ result
2266
+ };
2267
+ return payload;
2268
+ }
2269
+ };
2270
+ var RELAY_QUESTION_CANCEL = {
2271
+ kind: "relay.question.cancel",
2272
+ requires: ["relayCoordinator"],
2273
+ unsupportedMessage: "relay coordinator not configured",
2274
+ requireRegisteredRuntime: true,
2275
+ handle: (envelope, { deps, callerRuntimeId }) => {
2276
+ const req = envelope.payload;
2277
+ const cancelled = deps.relayCoordinator.cancel(
2278
+ req.channelRequestId,
2279
+ req.reason,
2280
+ callerRuntimeId
2281
+ );
2282
+ const payload = { cancelled };
2283
+ return payload;
2284
+ }
2285
+ };
2286
+ var HANDLERS = new Map(
2287
+ [
2288
+ PING,
2289
+ STATUS,
2290
+ CHANNELS_RELOAD,
2291
+ SESSION_REGISTER,
2292
+ SESSION_UNREGISTER,
2293
+ SESSION_TURN_COMPLETE,
2294
+ CHANNEL_SEND,
2295
+ RELAY_PERMISSION_REQUEST,
2296
+ RELAY_PERMISSION_CANCEL,
2297
+ RELAY_QUESTION_REQUEST,
2298
+ RELAY_QUESTION_CANCEL
2299
+ ].map((spec) => [spec.kind, spec])
2300
+ );
2118
2301
  function createDispatcher(deps) {
2119
2302
  const handle = async (envelope, connection) => {
2120
2303
  const ts = Date.now();
2121
- switch (envelope.kind) {
2122
- case "ping": {
2123
- const payload = {
2124
- pong: true,
2125
- daemonPid: process.pid,
2126
- uptimeMs: ts - deps.startedAt
2127
- };
2128
- return ok(envelope, ts, payload);
2129
- }
2130
- case "status": {
2131
- const channels = (deps.channelManager?.listChannels() ?? []).map((c) => ({
2132
- id: c.id,
2133
- state: c.health?.transportOk === false ? "degraded" : "running",
2134
- ...c.health?.at !== void 0 ? { lastHealthAt: c.health.at } : {},
2135
- ...c.health?.note !== void 0 ? { note: c.health.note } : {}
2136
- }));
2137
- const payload = {
2138
- daemonPid: process.pid,
2139
- startedAt: deps.startedAt,
2140
- uptimeMs: ts - deps.startedAt,
2141
- version: readVersion(),
2142
- listener: deps.getListener?.() ?? {
2143
- kind: "uds",
2144
- socketPath: "<unknown>"
2145
- },
2146
- channels,
2147
- runtimes: runtimeStatusEntries(deps.registry)
2148
- };
2149
- return ok(envelope, ts, payload);
2150
- }
2151
- case "channels.reload": {
2152
- if (!deps.reloadChannels) {
2153
- return error(
2154
- envelope,
2155
- ts,
2156
- "unsupported",
2157
- "channel reload not configured"
2158
- );
2159
- }
2160
- const payload = await deps.reloadChannels();
2161
- return ok(envelope, ts, payload);
2162
- }
2163
- case "session.register": {
2164
- if (!deps.registry)
2165
- return error(
2166
- envelope,
2167
- ts,
2168
- "unsupported",
2169
- "session.register not configured"
2170
- );
2171
- const req = envelope.payload;
2172
- try {
2173
- const reg = deps.registry.register({
2174
- runtimeId: req.runtimeId,
2175
- defaultAgentId: req.defaultAgentId,
2176
- pid: req.pid
2177
- });
2178
- deps.registerRuntimeConnection?.(req.runtimeId, connection);
2179
- try {
2180
- deps.dispatcher?.drainPending();
2181
- } catch (err) {
2182
- process.stderr.write(
2183
- `gateway: drainPending failed: ${err instanceof Error ? err.message : String(err)}
2184
- `
2185
- );
2186
- }
2187
- const payload = {
2188
- registeredAt: reg.registeredAt,
2189
- gatewayStartedAt: deps.startedAt
2190
- };
2191
- return ok(envelope, ts, payload);
2192
- } catch (err) {
2193
- if (err instanceof AlreadyRegisteredError) {
2194
- return error(envelope, ts, err.code, err.message);
2195
- }
2196
- throw err;
2197
- }
2198
- }
2199
- case "session.unregister": {
2200
- if (!deps.registry)
2304
+ const spec = HANDLERS.get(envelope.kind);
2305
+ if (!spec) {
2306
+ return error(
2307
+ envelope,
2308
+ ts,
2309
+ "unknown_kind",
2310
+ `unknown kind: ${envelope.kind}`
2311
+ );
2312
+ }
2313
+ if (spec.requires) {
2314
+ for (const name of spec.requires) {
2315
+ if (deps[name] === void 0) {
2201
2316
  return error(
2202
2317
  envelope,
2203
2318
  ts,
2204
2319
  "unsupported",
2205
- "session.unregister not configured"
2320
+ spec.unsupportedMessage ?? `${spec.kind} not configured`
2206
2321
  );
2207
- const req = envelope.payload;
2208
- try {
2209
- deps.registry.unregister(req.runtimeId);
2210
- deps.unregisterRuntimeConnection?.(req.runtimeId);
2211
- const payload = {
2212
- unregisteredAt: ts
2213
- };
2214
- return ok(envelope, ts, payload);
2215
- } catch (err) {
2216
- if (err instanceof NotRegisteredError) {
2217
- return error(envelope, ts, err.code, err.message);
2218
- }
2219
- throw err;
2220
2322
  }
2221
2323
  }
2222
- case "session.turn.complete": {
2223
- if (!deps.dispatcher)
2224
- return error(
2225
- envelope,
2226
- ts,
2227
- "unsupported",
2228
- "dispatcher not configured"
2229
- );
2230
- const req = envelope.payload;
2231
- const result = await deps.dispatcher.handleTurnComplete(req);
2232
- return ok(envelope, ts, result);
2233
- }
2234
- case "channel.send": {
2235
- if (!deps.channelManager)
2236
- return error(
2237
- envelope,
2238
- ts,
2239
- "unsupported",
2240
- "channel manager not configured"
2241
- );
2242
- const req = envelope.payload;
2243
- const result = await deps.channelManager.send(
2244
- req.message.location.channelId,
2245
- req.message
2324
+ }
2325
+ let callerRuntimeId;
2326
+ if (spec.requireRegisteredRuntime) {
2327
+ callerRuntimeId = deps.pipeline?.getRuntimeIdByConnection(connection.connectionId) ?? void 0;
2328
+ if (deps.pipeline && callerRuntimeId === void 0) {
2329
+ return error(
2330
+ envelope,
2331
+ ts,
2332
+ "not_registered",
2333
+ `${spec.kind} requires a registered runtime connection`
2246
2334
  );
2247
- const payload = {
2248
- providerMessageId: result.providerMessageId,
2249
- deliveredAt: result.deliveredAt
2250
- };
2251
- return ok(envelope, ts, payload);
2252
- }
2253
- case "relay.permission.request": {
2254
- if (!deps.relayCoordinator)
2255
- return error(
2256
- envelope,
2257
- ts,
2258
- "unsupported",
2259
- "relay coordinator not configured"
2260
- );
2261
- const req = envelope.payload;
2262
- const callerRuntimeId = deps.registry?.getRuntimeIdByConnection(connection.connectionId) ?? void 0;
2263
- if (deps.registry && callerRuntimeId === void 0) {
2264
- return error(
2265
- envelope,
2266
- ts,
2267
- "not_registered",
2268
- "relay.permission.request requires a registered runtime connection"
2269
- );
2270
- }
2271
- const broadcast = deps.relayCoordinator.requestPermission({
2272
- ...req.channelRequestId !== void 0 ? { channelRequestId: req.channelRequestId } : {},
2273
- toolName: req.toolName,
2274
- description: req.description,
2275
- inputPreview: req.inputPreview,
2276
- ...req.ttlMs !== void 0 ? { ttlMs: req.ttlMs } : {},
2277
- ...callerRuntimeId !== void 0 ? { runtimeId: callerRuntimeId } : {}
2278
- });
2279
- const result = await broadcast.result;
2280
- const payload = {
2281
- channelRequestId: broadcast.channelRequestId,
2282
- result
2283
- };
2284
- return ok(envelope, Date.now(), payload);
2285
2335
  }
2286
- case "relay.permission.cancel": {
2287
- if (!deps.relayCoordinator)
2288
- return error(
2289
- envelope,
2290
- ts,
2291
- "unsupported",
2292
- "relay coordinator not configured"
2293
- );
2294
- const req = envelope.payload;
2295
- const callerRuntimeId = deps.registry?.getRuntimeIdByConnection(connection.connectionId) ?? void 0;
2296
- if (deps.registry && callerRuntimeId === void 0) {
2297
- return error(
2298
- envelope,
2299
- ts,
2300
- "not_registered",
2301
- "relay.permission.cancel requires a registered runtime connection"
2302
- );
2303
- }
2304
- const cancelled = deps.relayCoordinator.cancel(
2305
- req.channelRequestId,
2306
- req.reason,
2307
- callerRuntimeId
2308
- );
2309
- const payload = { cancelled };
2310
- return ok(envelope, ts, payload);
2336
+ }
2337
+ try {
2338
+ const payload = await spec.handle(envelope, {
2339
+ deps,
2340
+ connection,
2341
+ callerRuntimeId,
2342
+ ts
2343
+ });
2344
+ return ok(envelope, Date.now(), payload);
2345
+ } catch (err) {
2346
+ if (err instanceof AlreadyRegisteredError || err instanceof NotRegisteredError) {
2347
+ return error(envelope, ts, err.code, err.message);
2311
2348
  }
2312
- case "relay.question.request": {
2313
- if (!deps.relayCoordinator)
2314
- return error(
2315
- envelope,
2316
- ts,
2317
- "unsupported",
2318
- "relay coordinator not configured"
2319
- );
2320
- const req = envelope.payload;
2321
- const callerRuntimeId = deps.registry?.getRuntimeIdByConnection(connection.connectionId) ?? void 0;
2322
- if (deps.registry && callerRuntimeId === void 0) {
2323
- return error(
2324
- envelope,
2325
- ts,
2326
- "not_registered",
2327
- "relay.question.request requires a registered runtime connection"
2328
- );
2329
- }
2330
- const broadcast = deps.relayCoordinator.requestQuestion({
2331
- ...req.channelRequestId !== void 0 ? { channelRequestId: req.channelRequestId } : {},
2332
- title: req.title,
2333
- questions: req.questions,
2334
- ...req.ttlMs !== void 0 ? { ttlMs: req.ttlMs } : {},
2335
- ...callerRuntimeId !== void 0 ? { runtimeId: callerRuntimeId } : {}
2336
- });
2337
- const result = await broadcast.result;
2338
- const payload = {
2339
- channelRequestId: broadcast.channelRequestId,
2340
- result
2341
- };
2342
- return ok(envelope, Date.now(), payload);
2343
- }
2344
- case "relay.question.cancel": {
2345
- if (!deps.relayCoordinator)
2346
- return error(
2347
- envelope,
2348
- ts,
2349
- "unsupported",
2350
- "relay coordinator not configured"
2351
- );
2352
- const req = envelope.payload;
2353
- const callerRuntimeId = deps.registry?.getRuntimeIdByConnection(connection.connectionId) ?? void 0;
2354
- if (deps.registry && callerRuntimeId === void 0) {
2355
- return error(
2356
- envelope,
2357
- ts,
2358
- "not_registered",
2359
- "relay.question.cancel requires a registered runtime connection"
2360
- );
2361
- }
2362
- const cancelled = deps.relayCoordinator.cancel(
2363
- req.channelRequestId,
2364
- req.reason,
2365
- callerRuntimeId
2366
- );
2367
- const payload = { cancelled };
2368
- return ok(envelope, ts, payload);
2369
- }
2370
- default:
2371
- return error(
2372
- envelope,
2373
- ts,
2374
- "unknown_kind",
2375
- `unknown kind: ${envelope.kind}`
2376
- );
2349
+ throw err;
2377
2350
  }
2378
2351
  };
2379
2352
  return handle;
2380
2353
  }
2381
- function runtimeStatusEntries(registry) {
2382
- const runtime = registry?.getCurrent();
2383
- if (!runtime || !registry) return [];
2384
- const binding = registry.getBinding();
2354
+ function runtimeStatusEntries(pipeline) {
2355
+ const runtime = pipeline?.getCurrentRuntime();
2356
+ if (!runtime || !pipeline) return [];
2357
+ const binding = pipeline.getBinding();
2385
2358
  return [
2386
2359
  {
2387
2360
  runtimeId: runtime.runtimeId,
@@ -2399,7 +2372,7 @@ function runtimeStatusEntries(registry) {
2399
2372
  epoch: binding.epoch,
2400
2373
  ...maybeLastRebindAt(binding.lastRebindAt)
2401
2374
  } : { state: "none" },
2402
- pendingDispatchCount: registry.pendingDispatchCount()
2375
+ pendingDispatchCount: pipeline.pendingDispatchCount()
2403
2376
  }
2404
2377
  ];
2405
2378
  }
@@ -2522,256 +2495,8 @@ function handleConnection(connection, expectedToken, startedAt, handler, logErro
2522
2495
  });
2523
2496
  }
2524
2497
 
2525
- // src/gateway/router/sessionKey.ts
2526
- function deriveSessionKey(loc) {
2527
- const c = loc.channelId;
2528
- const a = loc.accountId;
2529
- if (loc.peer?.id) {
2530
- const peer = loc.peer.id;
2531
- if (loc.thread?.id) {
2532
- return `peer:${c}:${a}:${peer}:${loc.thread.id}`;
2533
- }
2534
- return `peer:${c}:${a}:${peer}`;
2535
- }
2536
- if (loc.room?.id) {
2537
- const room = loc.room.id;
2538
- if (loc.thread?.id) {
2539
- return `room:${c}:${a}:${room}:${loc.thread.id}`;
2540
- }
2541
- return `room:${c}:${a}:${room}`;
2542
- }
2543
- return `default:${c}:${a}`;
2544
- }
2545
-
2546
- // src/gateway/dispatcher.ts
2547
- var Dispatcher = class {
2548
- registry;
2549
- pushDispatch;
2550
- sendOutbound;
2551
- resolveAgent;
2552
- inboundQueue;
2553
- canDispatch;
2554
- log;
2555
- constructor(opts) {
2556
- this.registry = opts.registry;
2557
- this.pushDispatch = opts.pushDispatch;
2558
- this.sendOutbound = opts.sendOutbound;
2559
- this.resolveAgent = opts.resolveAgent ?? ((input) => input.defaultAgentId);
2560
- this.inboundQueue = opts.inboundQueue;
2561
- this.canDispatch = opts.canDispatch ?? (() => true);
2562
- this.log = opts.log;
2563
- }
2564
- handleInbound(inbound) {
2565
- const current = this.registry.getCurrent();
2566
- if (!current || !this.canDispatch()) {
2567
- if (!this.inboundQueue) {
2568
- this.log?.(
2569
- "debug",
2570
- `no runtime registered; dropping inbound ${inbound.idempotencyKey}`
2571
- );
2572
- return { kind: "dropped", reason: "no_runtime" };
2573
- }
2574
- const result = this.inboundQueue.enqueue(inbound);
2575
- if (result.kind === "queued") {
2576
- this.log?.(
2577
- "info",
2578
- `no runtime registered; parked inbound ${inbound.idempotencyKey} as queue#${result.id}`
2579
- );
2580
- return { kind: "queued", queueId: result.id };
2581
- }
2582
- if (result.kind === "duplicate") {
2583
- this.log?.(
2584
- "debug",
2585
- `inbound ${inbound.idempotencyKey} already parked; ignoring duplicate`
2586
- );
2587
- return { kind: "dropped", reason: "no_runtime" };
2588
- }
2589
- this.log?.(
2590
- "warn",
2591
- `inbound queue full (>=${this.inboundQueue.size()}); dropping ${inbound.idempotencyKey}`
2592
- );
2593
- return { kind: "dropped", reason: "queue_full" };
2594
- }
2595
- const sessionKey = deriveSessionKey(inbound.location);
2596
- const agentId = this.resolveAgent({
2597
- sessionKey,
2598
- channelId: inbound.location.channelId,
2599
- defaultAgentId: current.defaultAgentId
2600
- });
2601
- if (!agentId) {
2602
- this.log?.(
2603
- "warn",
2604
- `agent resolution returned empty for sessionKey=${sessionKey}`
2605
- );
2606
- return { kind: "dropped", reason: "no_default_agent" };
2607
- }
2608
- const entry = this.registry.beginDispatch({
2609
- sessionKey,
2610
- agentId,
2611
- location: inbound.location
2612
- });
2613
- this.pushDispatch({
2614
- dispatchId: entry.dispatchId,
2615
- sessionKey,
2616
- agentId,
2617
- inbound
2618
- });
2619
- return {
2620
- kind: "dispatched",
2621
- dispatchId: entry.dispatchId,
2622
- sessionKey
2623
- };
2624
- }
2625
- /**
2626
- * Drain parked inbound messages and dispatch them in FIFO order. Called by
2627
- * the session.register handler after a runtime attaches. Safe to call when
2628
- * no queue is configured (no-op).
2629
- */
2630
- drainPending() {
2631
- if (!this.inboundQueue) return { dispatched: 0, dropped: 0 };
2632
- const current = this.registry.getCurrent();
2633
- if (!current || !this.canDispatch()) return { dispatched: 0, dropped: 0 };
2634
- const parked = this.inboundQueue.drain();
2635
- let dispatched = 0;
2636
- let dropped = 0;
2637
- for (const { inbound } of parked) {
2638
- const sessionKey = deriveSessionKey(inbound.location);
2639
- const agentId = this.resolveAgent({
2640
- sessionKey,
2641
- channelId: inbound.location.channelId,
2642
- defaultAgentId: current.defaultAgentId
2643
- });
2644
- if (!agentId) {
2645
- dropped += 1;
2646
- this.log?.(
2647
- "warn",
2648
- `drainPending: no agent for ${sessionKey}; dropping ${inbound.idempotencyKey}`
2649
- );
2650
- continue;
2651
- }
2652
- const entry = this.registry.beginDispatch({
2653
- sessionKey,
2654
- agentId,
2655
- location: inbound.location
2656
- });
2657
- this.pushDispatch({
2658
- dispatchId: entry.dispatchId,
2659
- sessionKey,
2660
- agentId,
2661
- inbound
2662
- });
2663
- dispatched += 1;
2664
- }
2665
- if (dispatched > 0 || dropped > 0) {
2666
- this.log?.(
2667
- "info",
2668
- `drainPending: dispatched=${dispatched} dropped=${dropped}`
2669
- );
2670
- }
2671
- return { dispatched, dropped };
2672
- }
2673
- async handleTurnComplete(payload) {
2674
- const current = this.registry.getCurrent();
2675
- writeGatewayTrace(
2676
- `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}`
2677
- );
2678
- if (!current || current.runtimeId !== payload.runtimeId) {
2679
- throw new Error("runtime mismatch on session.turn.complete");
2680
- }
2681
- let entry;
2682
- try {
2683
- entry = this.registry.completeDispatch(payload.dispatchId);
2684
- } catch (err) {
2685
- if (err instanceof UnknownDispatchError) {
2686
- writeGatewayTrace(
2687
- `dispatcher turn.complete unknown dispatchId=${payload.dispatchId}`
2688
- );
2689
- return { delivered: false };
2690
- }
2691
- throw err;
2692
- }
2693
- writeGatewayTrace(
2694
- `dispatcher sendOutbound channel=${entry.location.channelId} dispatchId=${payload.dispatchId} parkedAccount=${entry.location.accountId} parkedThread=${entry.location.thread?.id ?? ""}`
2695
- );
2696
- const result = await this.sendOutbound(entry.location.channelId, {
2697
- location: payload.location,
2698
- text: payload.text,
2699
- idempotencyKey: payload.idempotencyKey
2700
- });
2701
- writeGatewayTrace(
2702
- `dispatcher sendOutbound delivered dispatchId=${payload.dispatchId} providerMessageId=${result.providerMessageId}`
2703
- );
2704
- return {
2705
- delivered: true,
2706
- providerMessageId: result.providerMessageId
2707
- };
2708
- }
2709
- };
2710
-
2711
- // src/gateway/lock.ts
2712
- import fs2 from "fs";
2713
- import path2 from "path";
2714
- var GatewayAlreadyRunningError = class extends Error {
2715
- otherPid;
2716
- constructor(otherPid) {
2717
- super(`gateway already running (pid=${otherPid})`);
2718
- this.name = "GatewayAlreadyRunningError";
2719
- this.otherPid = otherPid;
2720
- }
2721
- };
2722
- function isProcessAlive(pid) {
2723
- try {
2724
- process.kill(pid, 0);
2725
- return true;
2726
- } catch (err) {
2727
- const code = err.code;
2728
- return code === "EPERM";
2729
- }
2730
- }
2731
- function readPidFile(p) {
2732
- try {
2733
- const text = fs2.readFileSync(p, "utf-8").trim();
2734
- const pid = Number.parseInt(text, 10);
2735
- return Number.isInteger(pid) && pid > 0 ? pid : null;
2736
- } catch {
2737
- return null;
2738
- }
2739
- }
2740
- function acquireLock(lockPath) {
2741
- fs2.mkdirSync(path2.dirname(lockPath), { recursive: true, mode: 448 });
2742
- for (let attempt = 0; attempt < 2; attempt++) {
2743
- try {
2744
- const fd = fs2.openSync(lockPath, "wx", 384);
2745
- fs2.writeSync(fd, String(process.pid) + "\n");
2746
- fs2.closeSync(fd);
2747
- return {
2748
- path: lockPath,
2749
- pid: process.pid,
2750
- release: () => {
2751
- try {
2752
- const pidNow = readPidFile(lockPath);
2753
- if (pidNow === process.pid) {
2754
- fs2.unlinkSync(lockPath);
2755
- }
2756
- } catch {
2757
- }
2758
- }
2759
- };
2760
- } catch (err) {
2761
- const code = err.code;
2762
- if (code !== "EEXIST") throw err;
2763
- const otherPid = readPidFile(lockPath);
2764
- if (otherPid !== null && isProcessAlive(otherPid)) {
2765
- throw new GatewayAlreadyRunningError(otherPid);
2766
- }
2767
- try {
2768
- fs2.unlinkSync(lockPath);
2769
- } catch {
2770
- }
2771
- }
2772
- }
2773
- throw new Error(`failed to acquire gateway lock at ${lockPath}`);
2774
- }
2498
+ // src/gateway/dispatchPipeline.ts
2499
+ import crypto from "crypto";
2775
2500
 
2776
2501
  // src/gateway/outboundDispatcher.ts
2777
2502
  var DEFAULT_BACKOFF = [
@@ -2870,43 +2595,504 @@ var OutboundDispatcher = class {
2870
2595
  } finally {
2871
2596
  this.draining = false;
2872
2597
  }
2873
- return { retried, succeeded, dropped };
2874
- }
2875
- async attempt(row) {
2876
- try {
2877
- await this.send(row.channelId, row.message);
2878
- this.outbox.delete(row.id);
2598
+ return { retried, succeeded, dropped };
2599
+ }
2600
+ async attempt(row) {
2601
+ try {
2602
+ await this.send(row.channelId, row.message);
2603
+ this.outbox.delete(row.id);
2604
+ this.log?.(
2605
+ "info",
2606
+ `outbox#${row.id} delivered to ${row.channelId} on attempt ${row.attempt + 1}`
2607
+ );
2608
+ return "succeeded";
2609
+ } catch (err) {
2610
+ const error2 = err instanceof Error ? err.message : String(err);
2611
+ const nextAttempt = row.attempt + 1;
2612
+ if (nextAttempt >= this.maxAttempts) {
2613
+ this.outbox.delete(row.id);
2614
+ this.log?.(
2615
+ "error",
2616
+ `outbox#${row.id} dropped after ${nextAttempt} attempts: ${error2}`
2617
+ );
2618
+ return "dropped";
2619
+ }
2620
+ const nextAttemptAt = this.now() + this.backoffFor(nextAttempt);
2621
+ this.outbox.recordFailure({
2622
+ id: row.id,
2623
+ nextAttemptAt,
2624
+ lastError: error2
2625
+ });
2626
+ return "requeued";
2627
+ }
2628
+ }
2629
+ backoffFor(attempt) {
2630
+ if (this.backoff.length === 0) return 1e3;
2631
+ const idx = Math.min(attempt, this.backoff.length - 1);
2632
+ return this.backoff[idx];
2633
+ }
2634
+ };
2635
+
2636
+ // src/gateway/router/sessionKey.ts
2637
+ function deriveSessionKey(loc) {
2638
+ const c = loc.channelId;
2639
+ const a = loc.accountId;
2640
+ if (loc.peer?.id) {
2641
+ const peer = loc.peer.id;
2642
+ if (loc.thread?.id) {
2643
+ return `peer:${c}:${a}:${peer}:${loc.thread.id}`;
2644
+ }
2645
+ return `peer:${c}:${a}:${peer}`;
2646
+ }
2647
+ if (loc.room?.id) {
2648
+ const room = loc.room.id;
2649
+ if (loc.thread?.id) {
2650
+ return `room:${c}:${a}:${room}:${loc.thread.id}`;
2651
+ }
2652
+ return `room:${c}:${a}:${room}`;
2653
+ }
2654
+ return `default:${c}:${a}`;
2655
+ }
2656
+
2657
+ // src/gateway/sessionRegistry.ts
2658
+ import { randomUUID } from "crypto";
2659
+ var UnknownDispatchError = class extends Error {
2660
+ code = "unknown_dispatch";
2661
+ constructor(id) {
2662
+ super(`unknown dispatchId: ${id}`);
2663
+ this.name = "UnknownDispatchError";
2664
+ }
2665
+ };
2666
+ var SessionRegistry = class {
2667
+ dispatches = /* @__PURE__ */ new Map();
2668
+ idFactory;
2669
+ now;
2670
+ constructor(opts = {}) {
2671
+ this.idFactory = opts.idFactory ?? randomUUID;
2672
+ this.now = opts.now ?? Date.now;
2673
+ }
2674
+ beginDispatch(input) {
2675
+ const dispatchId = this.idFactory();
2676
+ const entry = {
2677
+ dispatchId,
2678
+ sessionKey: input.sessionKey,
2679
+ agentId: input.agentId,
2680
+ location: input.location,
2681
+ createdAt: this.now()
2682
+ };
2683
+ this.dispatches.set(dispatchId, entry);
2684
+ return entry;
2685
+ }
2686
+ completeDispatch(dispatchId) {
2687
+ const entry = this.dispatches.get(dispatchId);
2688
+ if (!entry) {
2689
+ throw new UnknownDispatchError(dispatchId);
2690
+ }
2691
+ this.dispatches.delete(dispatchId);
2692
+ return entry;
2693
+ }
2694
+ pendingDispatchCount() {
2695
+ return this.dispatches.size;
2696
+ }
2697
+ clearDispatches() {
2698
+ this.dispatches.clear();
2699
+ }
2700
+ };
2701
+
2702
+ // src/gateway/state/inboundQueue.ts
2703
+ var DEFAULT_MAX_ENTRIES = 1e3;
2704
+ var InboundQueue = class {
2705
+ db;
2706
+ maxEntries;
2707
+ constructor(db, opts = {}) {
2708
+ this.db = db;
2709
+ this.maxEntries = opts.maxEntries ?? DEFAULT_MAX_ENTRIES;
2710
+ }
2711
+ size() {
2712
+ const row = this.db.prepare("SELECT COUNT(*) as n FROM inbound_queue").get();
2713
+ return row.n;
2714
+ }
2715
+ enqueue(inbound) {
2716
+ if (this.size() >= this.maxEntries) {
2717
+ return { kind: "rejected", reason: "queue_full" };
2718
+ }
2719
+ const stmt = this.db.prepare(
2720
+ `INSERT INTO inbound_queue
2721
+ (channel_id, account_id, idempotency_key, payload_json, created_at)
2722
+ VALUES (?, ?, ?, ?, ?)
2723
+ ON CONFLICT(channel_id, account_id, idempotency_key) DO NOTHING`
2724
+ );
2725
+ const result = stmt.run(
2726
+ inbound.location.channelId,
2727
+ inbound.location.accountId,
2728
+ inbound.idempotencyKey,
2729
+ JSON.stringify(inbound),
2730
+ Date.now()
2731
+ );
2732
+ if (result.changes === 0) {
2733
+ return { kind: "duplicate" };
2734
+ }
2735
+ return { kind: "queued", id: Number(result.lastInsertRowid) };
2736
+ }
2737
+ /** Atomically read and remove all parked entries in FIFO order. */
2738
+ drain() {
2739
+ return this.db.transaction(() => {
2740
+ const rows = this.db.prepare("SELECT id, payload_json FROM inbound_queue ORDER BY id ASC").all();
2741
+ if (rows.length > 0) {
2742
+ this.db.prepare("DELETE FROM inbound_queue").run();
2743
+ }
2744
+ return rows.map((r) => ({
2745
+ id: r.id,
2746
+ inbound: JSON.parse(r.payload_json)
2747
+ }));
2748
+ })();
2749
+ }
2750
+ };
2751
+
2752
+ // src/gateway/state/outbox.ts
2753
+ var Outbox = class {
2754
+ db;
2755
+ constructor(db) {
2756
+ this.db = db;
2757
+ }
2758
+ size() {
2759
+ const row = this.db.prepare("SELECT COUNT(*) as n FROM channel_outbox").get();
2760
+ return row.n;
2761
+ }
2762
+ enqueue(input) {
2763
+ const result = this.db.prepare(
2764
+ `INSERT INTO channel_outbox
2765
+ (channel_id, payload_json, attempt, next_attempt_at, last_error, created_at)
2766
+ VALUES (?, ?, 0, ?, ?, ?)`
2767
+ ).run(
2768
+ input.channelId,
2769
+ JSON.stringify(input.message),
2770
+ input.nextAttemptAt,
2771
+ input.lastError ?? null,
2772
+ Date.now()
2773
+ );
2774
+ return Number(result.lastInsertRowid);
2775
+ }
2776
+ /** Rows whose `next_attempt_at` is at or before `now`, oldest first. */
2777
+ peekDue(now, limit) {
2778
+ const rows = this.db.prepare(
2779
+ `SELECT id, channel_id, payload_json, attempt, next_attempt_at, last_error
2780
+ FROM channel_outbox
2781
+ WHERE next_attempt_at <= ?
2782
+ ORDER BY next_attempt_at ASC, id ASC
2783
+ LIMIT ?`
2784
+ ).all(now, limit);
2785
+ return rows.map((r) => ({
2786
+ id: r.id,
2787
+ channelId: r.channel_id,
2788
+ message: JSON.parse(r.payload_json),
2789
+ attempt: r.attempt,
2790
+ nextAttemptAt: r.next_attempt_at,
2791
+ lastError: r.last_error
2792
+ }));
2793
+ }
2794
+ delete(id) {
2795
+ this.db.prepare("DELETE FROM channel_outbox WHERE id = ?").run(id);
2796
+ }
2797
+ recordFailure(input) {
2798
+ this.db.prepare(
2799
+ `UPDATE channel_outbox
2800
+ SET attempt = attempt + 1,
2801
+ next_attempt_at = ?,
2802
+ last_error = ?
2803
+ WHERE id = ?`
2804
+ ).run(input.nextAttemptAt, input.lastError, input.id);
2805
+ }
2806
+ };
2807
+
2808
+ // src/gateway/dispatchPipeline.ts
2809
+ var DispatchPipeline = class {
2810
+ bindingStore;
2811
+ registry;
2812
+ inboundQueue;
2813
+ outbox;
2814
+ outboundDispatcher;
2815
+ resolveAgent;
2816
+ log;
2817
+ now;
2818
+ idFactory;
2819
+ push = null;
2820
+ constructor(opts) {
2821
+ this.bindingStore = new RuntimeBindingStore({
2822
+ gracePeriodMs: opts.gracePeriodMs,
2823
+ observers: opts.observers,
2824
+ now: opts.now
2825
+ });
2826
+ this.registry = new SessionRegistry({
2827
+ now: opts.now ?? Date.now,
2828
+ ...opts.idFactory ? { idFactory: opts.idFactory } : {}
2829
+ });
2830
+ this.inboundQueue = new InboundQueue(opts.stateDb, opts.inboundQueue ?? {});
2831
+ this.outbox = new Outbox(opts.stateDb);
2832
+ this.outboundDispatcher = new OutboundDispatcher({
2833
+ outbox: this.outbox,
2834
+ send: opts.send,
2835
+ ...opts.outbox?.backoffSchedule ? { backoffSchedule: opts.outbox.backoffSchedule } : {},
2836
+ ...opts.outbox?.maxAttempts !== void 0 ? { maxAttempts: opts.outbox.maxAttempts } : {},
2837
+ ...opts.outbox?.tickIntervalMs !== void 0 ? { tickIntervalMs: opts.outbox.tickIntervalMs } : {},
2838
+ ...opts.outbox?.drainBatchSize !== void 0 ? { drainBatchSize: opts.outbox.drainBatchSize } : {},
2839
+ ...opts.now ? { now: opts.now } : {},
2840
+ ...opts.log ? { log: opts.log } : {}
2841
+ });
2842
+ this.resolveAgent = opts.resolveAgent ?? ((input) => input.defaultAgentId);
2843
+ this.log = opts.log;
2844
+ this.now = opts.now ?? Date.now;
2845
+ this.idFactory = opts.idFactory ?? crypto.randomUUID;
2846
+ }
2847
+ // ── lifecycle ────────────────────────────────────────────
2848
+ start() {
2849
+ this.outboundDispatcher.start();
2850
+ }
2851
+ async stop() {
2852
+ this.outboundDispatcher.stop();
2853
+ this.bindingStore.stop();
2854
+ }
2855
+ // ── inbound (channel side) ───────────────────────────────
2856
+ handleInbound(inbound) {
2857
+ const current = this.bindingStore.getCurrent();
2858
+ if (!current || !this.bindingStore.hasActiveBinding(current.runtimeId)) {
2859
+ const result = this.inboundQueue.enqueue(inbound);
2860
+ if (result.kind === "queued") {
2861
+ this.log?.(
2862
+ "info",
2863
+ `no runtime registered; parked inbound ${inbound.idempotencyKey} as queue#${result.id}`
2864
+ );
2865
+ return { kind: "queued", queueId: result.id };
2866
+ }
2867
+ if (result.kind === "duplicate") {
2868
+ this.log?.(
2869
+ "debug",
2870
+ `inbound ${inbound.idempotencyKey} already parked; ignoring duplicate`
2871
+ );
2872
+ return { kind: "dropped", reason: "no_runtime" };
2873
+ }
2874
+ this.log?.(
2875
+ "warn",
2876
+ `inbound queue full (>=${this.inboundQueue.size()}); dropping ${inbound.idempotencyKey}`
2877
+ );
2878
+ return { kind: "dropped", reason: "queue_full" };
2879
+ }
2880
+ return this.dispatchInboundToRuntime(inbound, current);
2881
+ }
2882
+ dispatchInboundToRuntime(inbound, current) {
2883
+ const sessionKey = deriveSessionKey(inbound.location);
2884
+ const agentId = this.resolveAgent({
2885
+ sessionKey,
2886
+ channelId: inbound.location.channelId,
2887
+ defaultAgentId: current.defaultAgentId
2888
+ });
2889
+ if (!agentId) {
2890
+ this.log?.(
2891
+ "warn",
2892
+ `agent resolution returned empty for sessionKey=${sessionKey}`
2893
+ );
2894
+ return { kind: "dropped", reason: "no_default_agent" };
2895
+ }
2896
+ const entry = this.registry.beginDispatch({
2897
+ sessionKey,
2898
+ agentId,
2899
+ location: inbound.location
2900
+ });
2901
+ this.pushDispatch({
2902
+ dispatchId: entry.dispatchId,
2903
+ sessionKey,
2904
+ agentId,
2905
+ inbound
2906
+ });
2907
+ return { kind: "dispatched", dispatchId: entry.dispatchId, sessionKey };
2908
+ }
2909
+ pushDispatch(payload) {
2910
+ if (!this.push) return;
2911
+ this.push.push({
2912
+ push_id: this.idFactory(),
2913
+ ts: this.now(),
2914
+ kind: "session.dispatch.turn",
2915
+ payload
2916
+ });
2917
+ }
2918
+ // ── runtime side ─────────────────────────────────────────
2919
+ registerRuntime(input) {
2920
+ const result = this.bindingStore.bind({
2921
+ runtimeId: input.runtimeId,
2922
+ defaultAgentId: input.defaultAgentId,
2923
+ pid: input.pid,
2924
+ connectionId: input.connectionId
2925
+ });
2926
+ this.push = { connectionId: input.connectionId, push: input.push };
2927
+ writeGatewayTrace(
2928
+ `pipeline registered runtime runtimeId=${input.runtimeId} connectionId=${input.connectionId}`
2929
+ );
2930
+ this.drainPending();
2931
+ return { registeredAt: result.registeredAt };
2932
+ }
2933
+ unregisterRuntime(runtimeId) {
2934
+ this.bindingStore.unbind(runtimeId);
2935
+ this.registry.clearDispatches();
2936
+ this.push = null;
2937
+ writeGatewayTrace(`pipeline unregistered runtime runtimeId=${runtimeId}`);
2938
+ }
2939
+ notifyConnectionClosed(connectionId) {
2940
+ const runtimeId = this.bindingStore.notifyConnectionClosed(connectionId);
2941
+ if (runtimeId === null) return;
2942
+ writeGatewayTrace(
2943
+ `pipeline runtime connection closed runtimeId=${runtimeId} connectionId=${connectionId}`
2944
+ );
2945
+ this.push = null;
2946
+ }
2947
+ async handleTurnComplete(payload) {
2948
+ const current = this.bindingStore.getCurrent();
2949
+ writeGatewayTrace(
2950
+ `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}`
2951
+ );
2952
+ if (!current || current.runtimeId !== payload.runtimeId) {
2953
+ throw new Error("runtime mismatch on session.turn.complete");
2954
+ }
2955
+ let entry;
2956
+ try {
2957
+ entry = this.registry.completeDispatch(payload.dispatchId);
2958
+ } catch (err) {
2959
+ if (err instanceof UnknownDispatchError) {
2960
+ writeGatewayTrace(
2961
+ `pipeline turn.complete unknown dispatchId=${payload.dispatchId}`
2962
+ );
2963
+ return { delivered: false };
2964
+ }
2965
+ throw err;
2966
+ }
2967
+ const result = await this.sendOutbound(entry.location, payload);
2968
+ writeGatewayTrace(
2969
+ `pipeline sendOutbound delivered dispatchId=${payload.dispatchId} providerMessageId=${result.providerMessageId}`
2970
+ );
2971
+ return { delivered: true, providerMessageId: result.providerMessageId };
2972
+ }
2973
+ async sendOutbound(_parkedLocation, payload) {
2974
+ const out = {
2975
+ location: payload.location,
2976
+ text: payload.text,
2977
+ idempotencyKey: payload.idempotencyKey
2978
+ };
2979
+ const result = await this.outboundDispatcher.dispatch(
2980
+ payload.location.channelId,
2981
+ out
2982
+ );
2983
+ if (result.kind === "sent") return result.result;
2984
+ return {
2985
+ providerMessageId: `outbox:${result.outboxId}`,
2986
+ deliveredAt: this.now()
2987
+ };
2988
+ }
2989
+ drainPending() {
2990
+ const current = this.bindingStore.getCurrent();
2991
+ if (!current || !this.bindingStore.hasActiveBinding(current.runtimeId))
2992
+ return;
2993
+ const parked = this.inboundQueue.drain();
2994
+ let dispatched = 0;
2995
+ let dropped = 0;
2996
+ for (const { inbound } of parked) {
2997
+ const result = this.dispatchInboundToRuntime(inbound, current);
2998
+ if (result.kind === "dispatched") dispatched += 1;
2999
+ else dropped += 1;
3000
+ }
3001
+ if (dispatched > 0 || dropped > 0) {
2879
3002
  this.log?.(
2880
3003
  "info",
2881
- `outbox#${row.id} delivered to ${row.channelId} on attempt ${row.attempt + 1}`
3004
+ `drainPending: dispatched=${dispatched} dropped=${dropped}`
2882
3005
  );
2883
- return "succeeded";
2884
- } catch (err) {
2885
- const error2 = err instanceof Error ? err.message : String(err);
2886
- const nextAttempt = row.attempt + 1;
2887
- if (nextAttempt >= this.maxAttempts) {
2888
- this.outbox.delete(row.id);
2889
- this.log?.(
2890
- "error",
2891
- `outbox#${row.id} dropped after ${nextAttempt} attempts: ${error2}`
2892
- );
2893
- return "dropped";
2894
- }
2895
- const nextAttemptAt = this.now() + this.backoffFor(nextAttempt);
2896
- this.outbox.recordFailure({
2897
- id: row.id,
2898
- nextAttemptAt,
2899
- lastError: error2
2900
- });
2901
- return "requeued";
2902
3006
  }
2903
3007
  }
2904
- backoffFor(attempt) {
2905
- if (this.backoff.length === 0) return 1e3;
2906
- const idx = Math.min(attempt, this.backoff.length - 1);
2907
- return this.backoff[idx];
3008
+ // ── reads ────────────────────────────────────────────────
3009
+ getCurrentRuntime() {
3010
+ return this.bindingStore.getCurrent();
3011
+ }
3012
+ getBinding() {
3013
+ return this.bindingStore.getBinding();
3014
+ }
3015
+ hasActiveBinding(runtimeId) {
3016
+ return this.bindingStore.hasActiveBinding(runtimeId);
3017
+ }
3018
+ getRuntimeIdByConnection(connectionId) {
3019
+ return this.bindingStore.getRuntimeIdByConnection(connectionId);
3020
+ }
3021
+ pendingDispatchCount() {
3022
+ return this.registry.pendingDispatchCount();
3023
+ }
3024
+ pendingInboundCount() {
3025
+ return this.inboundQueue.size();
3026
+ }
3027
+ pendingOutboxCount() {
3028
+ return this.outbox.size();
3029
+ }
3030
+ };
3031
+
3032
+ // src/gateway/lock.ts
3033
+ import fs from "fs";
3034
+ import path from "path";
3035
+ var GatewayAlreadyRunningError = class extends Error {
3036
+ otherPid;
3037
+ constructor(otherPid) {
3038
+ super(`gateway already running (pid=${otherPid})`);
3039
+ this.name = "GatewayAlreadyRunningError";
3040
+ this.otherPid = otherPid;
2908
3041
  }
2909
3042
  };
3043
+ function isProcessAlive(pid) {
3044
+ try {
3045
+ process.kill(pid, 0);
3046
+ return true;
3047
+ } catch (err) {
3048
+ const code = err.code;
3049
+ return code === "EPERM";
3050
+ }
3051
+ }
3052
+ function readPidFile(p) {
3053
+ try {
3054
+ const text = fs.readFileSync(p, "utf-8").trim();
3055
+ const pid = Number.parseInt(text, 10);
3056
+ return Number.isInteger(pid) && pid > 0 ? pid : null;
3057
+ } catch {
3058
+ return null;
3059
+ }
3060
+ }
3061
+ function acquireLock(lockPath) {
3062
+ fs.mkdirSync(path.dirname(lockPath), { recursive: true, mode: 448 });
3063
+ for (let attempt = 0; attempt < 2; attempt++) {
3064
+ try {
3065
+ const fd = fs.openSync(lockPath, "wx", 384);
3066
+ fs.writeSync(fd, String(process.pid) + "\n");
3067
+ fs.closeSync(fd);
3068
+ return {
3069
+ path: lockPath,
3070
+ pid: process.pid,
3071
+ release: () => {
3072
+ try {
3073
+ const pidNow = readPidFile(lockPath);
3074
+ if (pidNow === process.pid) {
3075
+ fs.unlinkSync(lockPath);
3076
+ }
3077
+ } catch {
3078
+ }
3079
+ }
3080
+ };
3081
+ } catch (err) {
3082
+ const code = err.code;
3083
+ if (code !== "EEXIST") throw err;
3084
+ const otherPid = readPidFile(lockPath);
3085
+ if (otherPid !== null && isProcessAlive(otherPid)) {
3086
+ throw new GatewayAlreadyRunningError(otherPid);
3087
+ }
3088
+ try {
3089
+ fs.unlinkSync(lockPath);
3090
+ } catch {
3091
+ }
3092
+ }
3093
+ }
3094
+ throw new Error(`failed to acquire gateway lock at ${lockPath}`);
3095
+ }
2910
3096
 
2911
3097
  // src/gateway/relay/coordinator.ts
2912
3098
  var DEFAULT_RELAY_TTL_MS = 5 * 6e4;
@@ -2960,10 +3146,7 @@ var RelayCoordinator = class {
2960
3146
  resolveFn = resolve;
2961
3147
  });
2962
3148
  const timer = setTimeout(() => {
2963
- this.settlePermission(channelRequestId, {
2964
- kind: "cancelled",
2965
- reason: "timeout"
2966
- });
3149
+ this.settle(channelRequestId, { kind: "cancelled", reason: "timeout" });
2967
3150
  }, ttlMs);
2968
3151
  if (typeof timer.unref === "function") timer.unref();
2969
3152
  const entry = {
@@ -2988,10 +3171,7 @@ var RelayCoordinator = class {
2988
3171
  const ctrl = controllers[idx];
2989
3172
  Promise.resolve().then(() => adapter.requestPermissionVerdict(fullReq, ctrl.signal)).then((res) => {
2990
3173
  if (res.kind === "verdict") {
2991
- this.settlePermission(channelRequestId, {
2992
- ...res,
2993
- channelId: adapter.id
2994
- });
3174
+ this.settle(channelRequestId, { ...res, channelId: adapter.id });
2995
3175
  }
2996
3176
  }).catch((err) => {
2997
3177
  this.log?.(
@@ -3040,10 +3220,7 @@ var RelayCoordinator = class {
3040
3220
  resolveFn = resolve;
3041
3221
  });
3042
3222
  const timer = setTimeout(() => {
3043
- this.settleQuestion(channelRequestId, {
3044
- kind: "cancelled",
3045
- reason: "timeout"
3046
- });
3223
+ this.settle(channelRequestId, { kind: "cancelled", reason: "timeout" });
3047
3224
  }, ttlMs);
3048
3225
  if (typeof timer.unref === "function") timer.unref();
3049
3226
  const entry = {
@@ -3067,10 +3244,7 @@ var RelayCoordinator = class {
3067
3244
  const ctrl = controllers[idx];
3068
3245
  Promise.resolve().then(() => adapter.requestQuestionAnswer(fullReq, ctrl.signal)).then((res) => {
3069
3246
  if (res.kind === "answer") {
3070
- this.settleQuestion(channelRequestId, {
3071
- ...res,
3072
- channelId: adapter.id
3073
- });
3247
+ this.settle(channelRequestId, { ...res, channelId: adapter.id });
3074
3248
  }
3075
3249
  }).catch((err) => {
3076
3250
  this.log?.(
@@ -3087,11 +3261,7 @@ var RelayCoordinator = class {
3087
3261
  if (expectedRuntimeId !== void 0 && entry.runtimeId !== void 0 && entry.runtimeId !== expectedRuntimeId) {
3088
3262
  return false;
3089
3263
  }
3090
- if (entry.kind === "permission") {
3091
- this.settlePermission(channelRequestId, { kind: "cancelled", reason });
3092
- } else {
3093
- this.settleQuestion(channelRequestId, { kind: "cancelled", reason });
3094
- }
3264
+ this.settle(channelRequestId, { kind: "cancelled", reason });
3095
3265
  return true;
3096
3266
  }
3097
3267
  pendingCount() {
@@ -3102,27 +3272,18 @@ var RelayCoordinator = class {
3102
3272
  this.cancel(id, reason);
3103
3273
  }
3104
3274
  }
3105
- settlePermission(channelRequestId, result) {
3106
- const entry = this.pending.get(channelRequestId);
3107
- if (!entry || entry.kind !== "permission" || entry.settled) return;
3108
- entry.settled = true;
3109
- this.pending.delete(channelRequestId);
3110
- clearTimeout(entry.timer);
3111
- for (const ctrl of entry.controllers) {
3112
- if (!ctrl.signal.aborted) ctrl.abort();
3113
- }
3114
- entry.resolve(result);
3115
- }
3116
- settleQuestion(channelRequestId, result) {
3275
+ settle(channelRequestId, result) {
3117
3276
  const entry = this.pending.get(channelRequestId);
3118
- if (!entry || entry.kind !== "question" || entry.settled) return;
3277
+ if (!entry || entry.settled) return;
3119
3278
  entry.settled = true;
3120
3279
  this.pending.delete(channelRequestId);
3121
3280
  clearTimeout(entry.timer);
3122
3281
  for (const ctrl of entry.controllers) {
3123
3282
  if (!ctrl.signal.aborted) ctrl.abort();
3124
3283
  }
3125
- entry.resolve(result);
3284
+ entry.resolve(
3285
+ result
3286
+ );
3126
3287
  }
3127
3288
  };
3128
3289
  function permissionFingerprint(req) {
@@ -3133,13 +3294,13 @@ function questionFingerprint(req) {
3133
3294
  }
3134
3295
 
3135
3296
  // src/gateway/state/db.ts
3136
- import fs3 from "fs";
3137
- import path3 from "path";
3297
+ import fs2 from "fs";
3298
+ import path2 from "path";
3138
3299
  import Database from "better-sqlite3";
3139
3300
  var GATEWAY_STATE_VERSION = 1;
3140
3301
  function openGatewayState(dbPath) {
3141
3302
  if (dbPath !== ":memory:") {
3142
- fs3.mkdirSync(path3.dirname(dbPath), { recursive: true, mode: 448 });
3303
+ fs2.mkdirSync(path2.dirname(dbPath), { recursive: true, mode: 448 });
3143
3304
  }
3144
3305
  const db = new Database(dbPath);
3145
3306
  db.exec("PRAGMA journal_mode = WAL");
@@ -3147,7 +3308,7 @@ function openGatewayState(dbPath) {
3147
3308
  initGatewayStateSchema(db);
3148
3309
  if (dbPath !== ":memory:" && process.platform !== "win32") {
3149
3310
  try {
3150
- fs3.chmodSync(dbPath, 384);
3311
+ fs2.chmodSync(dbPath, 384);
3151
3312
  } catch {
3152
3313
  }
3153
3314
  }
@@ -3202,112 +3363,6 @@ function initGatewayStateSchema(db) {
3202
3363
  }
3203
3364
  }
3204
3365
 
3205
- // src/gateway/state/inboundQueue.ts
3206
- var DEFAULT_MAX_ENTRIES = 1e3;
3207
- var InboundQueue = class {
3208
- db;
3209
- maxEntries;
3210
- constructor(db, opts = {}) {
3211
- this.db = db;
3212
- this.maxEntries = opts.maxEntries ?? DEFAULT_MAX_ENTRIES;
3213
- }
3214
- size() {
3215
- const row = this.db.prepare("SELECT COUNT(*) as n FROM inbound_queue").get();
3216
- return row.n;
3217
- }
3218
- enqueue(inbound) {
3219
- if (this.size() >= this.maxEntries) {
3220
- return { kind: "rejected", reason: "queue_full" };
3221
- }
3222
- const stmt = this.db.prepare(
3223
- `INSERT INTO inbound_queue
3224
- (channel_id, account_id, idempotency_key, payload_json, created_at)
3225
- VALUES (?, ?, ?, ?, ?)
3226
- ON CONFLICT(channel_id, account_id, idempotency_key) DO NOTHING`
3227
- );
3228
- const result = stmt.run(
3229
- inbound.location.channelId,
3230
- inbound.location.accountId,
3231
- inbound.idempotencyKey,
3232
- JSON.stringify(inbound),
3233
- Date.now()
3234
- );
3235
- if (result.changes === 0) {
3236
- return { kind: "duplicate" };
3237
- }
3238
- return { kind: "queued", id: Number(result.lastInsertRowid) };
3239
- }
3240
- /** Atomically read and remove all parked entries in FIFO order. */
3241
- drain() {
3242
- return this.db.transaction(() => {
3243
- const rows = this.db.prepare("SELECT id, payload_json FROM inbound_queue ORDER BY id ASC").all();
3244
- if (rows.length > 0) {
3245
- this.db.prepare("DELETE FROM inbound_queue").run();
3246
- }
3247
- return rows.map((r) => ({
3248
- id: r.id,
3249
- inbound: JSON.parse(r.payload_json)
3250
- }));
3251
- })();
3252
- }
3253
- };
3254
-
3255
- // src/gateway/state/outbox.ts
3256
- var Outbox = class {
3257
- db;
3258
- constructor(db) {
3259
- this.db = db;
3260
- }
3261
- size() {
3262
- const row = this.db.prepare("SELECT COUNT(*) as n FROM channel_outbox").get();
3263
- return row.n;
3264
- }
3265
- enqueue(input) {
3266
- const result = this.db.prepare(
3267
- `INSERT INTO channel_outbox
3268
- (channel_id, payload_json, attempt, next_attempt_at, last_error, created_at)
3269
- VALUES (?, ?, 0, ?, ?, ?)`
3270
- ).run(
3271
- input.channelId,
3272
- JSON.stringify(input.message),
3273
- input.nextAttemptAt,
3274
- input.lastError ?? null,
3275
- Date.now()
3276
- );
3277
- return Number(result.lastInsertRowid);
3278
- }
3279
- /** Rows whose `next_attempt_at` is at or before `now`, oldest first. */
3280
- peekDue(now, limit) {
3281
- const rows = this.db.prepare(
3282
- `SELECT id, channel_id, payload_json, attempt, next_attempt_at, last_error
3283
- FROM channel_outbox
3284
- WHERE next_attempt_at <= ?
3285
- ORDER BY next_attempt_at ASC, id ASC
3286
- LIMIT ?`
3287
- ).all(now, limit);
3288
- return rows.map((r) => ({
3289
- id: r.id,
3290
- channelId: r.channel_id,
3291
- message: JSON.parse(r.payload_json),
3292
- attempt: r.attempt,
3293
- nextAttemptAt: r.next_attempt_at,
3294
- lastError: r.last_error
3295
- }));
3296
- }
3297
- delete(id) {
3298
- this.db.prepare("DELETE FROM channel_outbox WHERE id = ?").run(id);
3299
- }
3300
- recordFailure(input) {
3301
- this.db.prepare(
3302
- `UPDATE channel_outbox
3303
- SET attempt = attempt + 1,
3304
- next_attempt_at = ?,
3305
- last_error = ?
3306
- WHERE id = ?`
3307
- ).run(input.nextAttemptAt, input.lastError, input.id);
3308
- }
3309
- };
3310
-
3311
3366
  // src/gateway/transport/tlsWs.ts
3312
3367
  import { WebSocketServer } from "ws";
3313
3368
  import { createServer as createHttpsServer } from "https";
@@ -3533,12 +3588,12 @@ async function startDaemon(opts) {
3533
3588
  const startedAt = Date.now();
3534
3589
  const pid = process.pid;
3535
3590
  const paths = opts.paths ?? resolveGatewayPaths(opts.env);
3536
- fs4.mkdirSync(paths.runDir, { recursive: true, mode: 448 });
3537
- fs4.mkdirSync(paths.configDir, { recursive: true, mode: 448 });
3591
+ fs3.mkdirSync(paths.runDir, { recursive: true, mode: 448 });
3592
+ fs3.mkdirSync(paths.configDir, { recursive: true, mode: 448 });
3538
3593
  if (process.platform !== "win32") {
3539
3594
  try {
3540
- fs4.chmodSync(paths.runDir, 448);
3541
- fs4.chmodSync(paths.configDir, 448);
3595
+ fs3.chmodSync(paths.runDir, 448);
3596
+ fs3.chmodSync(paths.configDir, 448);
3542
3597
  } catch {
3543
3598
  }
3544
3599
  }
@@ -3552,62 +3607,35 @@ async function startDaemon(opts) {
3552
3607
  loopback: listenSpec.kind === "uds" || isLoopbackHost(listenSpec.host)
3553
3608
  };
3554
3609
  const stateDb = openGatewayState(paths.statePath);
3555
- const inboundQueue = new InboundQueue(stateDb);
3556
- const outbox = new Outbox(stateDb);
3557
- const registry = new SessionRegistry();
3558
3610
  const channelManager = new ChannelManager();
3559
3611
  const relayCoordinator = new RelayCoordinator({
3560
3612
  adapters: () => channelManager.listAdapters()
3561
3613
  });
3562
- const runtimeConnections = /* @__PURE__ */ new Map();
3563
- const staleRuntimeTimers = /* @__PURE__ */ new Map();
3564
3614
  const connectionOpenedAt = /* @__PURE__ */ new Map();
3565
3615
  const disconnectGracePeriodMs = opts.disconnectGracePeriodMs ?? 0;
3566
3616
  let listenerStatus = null;
3567
- const pushDispatch = (payload) => {
3568
- const current = registry.getCurrent();
3569
- if (!current) return;
3570
- const ctx = runtimeConnections.get(current.runtimeId);
3571
- if (!ctx) return;
3572
- ctx.push({
3573
- push_id: crypto.randomUUID(),
3574
- ts: Date.now(),
3575
- kind: "session.dispatch.turn",
3576
- payload
3577
- });
3578
- };
3579
3617
  const log = (level, message) => {
3580
3618
  if (opts.silent) return;
3581
3619
  const stream = level === "error" || level === "warn" ? "stderr" : "stdout";
3582
3620
  process[stream].write(`athena-gateway: [${level}] ${message}
3583
3621
  `);
3584
3622
  };
3585
- const outboundDispatcher = new OutboundDispatcher({
3586
- outbox,
3623
+ const pipeline = new DispatchPipeline({
3624
+ stateDb,
3587
3625
  send: (channelId, msg) => channelManager.send(channelId, msg),
3588
- log
3589
- });
3590
- outboundDispatcher.start();
3591
- const dispatcher = new Dispatcher({
3592
- registry,
3593
- pushDispatch,
3594
- canDispatch: () => {
3595
- const current = registry.getCurrent();
3596
- return current ? registry.hasActiveBinding(current.runtimeId) : false;
3597
- },
3598
- sendOutbound: async (channelId, msg) => {
3599
- const result = await outboundDispatcher.dispatch(channelId, msg);
3600
- if (result.kind === "sent") return result.result;
3601
- return {
3602
- providerMessageId: `outbox:${result.outboxId}`,
3603
- deliveredAt: Date.now()
3604
- };
3605
- },
3606
- inboundQueue,
3607
- log
3626
+ gracePeriodMs: disconnectGracePeriodMs,
3627
+ log,
3628
+ observers: {
3629
+ onRuntimeRebind: ({ gapMs, epoch }) => trackGatewayRuntimeRebind({ gapMs, epoch }),
3630
+ onRuntimeExpired: ({ gapMs }) => trackGatewayRuntimeExpired({ gapMs }),
3631
+ // Single-runtime v1: blanket dispose is safe. Multi-runtime must
3632
+ // scope to the disconnecting runtime via disposeAllForRuntime.
3633
+ onRuntimeConnectionLost: () => relayCoordinator.disposeAll("connection_lost")
3634
+ }
3608
3635
  });
3636
+ pipeline.start();
3609
3637
  channelManager.setInboundSink((inbound) => {
3610
- dispatcher.handleInbound(inbound);
3638
+ pipeline.handleInbound(inbound);
3611
3639
  });
3612
3640
  const channelConfigHome = opts.env?.HOME;
3613
3641
  const reloadChannels = async () => {
@@ -3721,45 +3749,11 @@ async function startDaemon(opts) {
3721
3749
  }
3722
3750
  const handler = createDispatcher({
3723
3751
  startedAt,
3724
- registry,
3725
- dispatcher,
3752
+ pipeline,
3726
3753
  channelManager,
3727
3754
  relayCoordinator,
3728
3755
  getListener: () => listenerStatus ?? buildListenerStatus(listenSpec, null),
3729
- reloadChannels,
3730
- registerRuntimeConnection: (runtimeId, ctx) => {
3731
- const timer = staleRuntimeTimers.get(runtimeId);
3732
- if (timer) {
3733
- clearTimeout(timer);
3734
- staleRuntimeTimers.delete(runtimeId);
3735
- }
3736
- const previousBinding = registry.getBinding();
3737
- const wasStale = previousBinding?.state === "stale";
3738
- const staleSince = wasStale ? previousBinding.staleSince : null;
3739
- registry.bindConnection(runtimeId, ctx.connectionId);
3740
- runtimeConnections.set(runtimeId, ctx);
3741
- writeGatewayTrace(
3742
- `daemon registered runtime runtimeId=${runtimeId} connectionId=${ctx.connectionId}`
3743
- );
3744
- if (wasStale && staleSince !== null) {
3745
- const newBinding = registry.getBinding();
3746
- if (newBinding?.state === "active") {
3747
- trackGatewayRuntimeRebind({
3748
- gapMs: Date.now() - staleSince,
3749
- epoch: newBinding.epoch
3750
- });
3751
- }
3752
- }
3753
- },
3754
- unregisterRuntimeConnection: (runtimeId) => {
3755
- const timer = staleRuntimeTimers.get(runtimeId);
3756
- if (timer) {
3757
- clearTimeout(timer);
3758
- staleRuntimeTimers.delete(runtimeId);
3759
- }
3760
- runtimeConnections.delete(runtimeId);
3761
- writeGatewayTrace(`daemon unregistered runtime runtimeId=${runtimeId}`);
3762
- }
3756
+ reloadChannels
3763
3757
  });
3764
3758
  let server;
3765
3759
  let listener;
@@ -3793,39 +3787,7 @@ async function startDaemon(opts) {
3793
3787
  reason: "closed",
3794
3788
  durationMs
3795
3789
  });
3796
- const current = registry.getCurrent();
3797
- if (current && runtimeConnections.get(current.runtimeId)?.connectionId === ctx.connectionId) {
3798
- writeGatewayTrace(
3799
- `daemon runtime connection disconnected runtimeId=${current.runtimeId} connectionId=${ctx.connectionId}`
3800
- );
3801
- runtimeConnections.delete(current.runtimeId);
3802
- const staleAt = Date.now();
3803
- registry.markConnectionStale(ctx.connectionId);
3804
- if (disconnectGracePeriodMs <= 0) {
3805
- try {
3806
- registry.unregister(current.runtimeId);
3807
- } catch {
3808
- }
3809
- relayCoordinator.disposeAll("connection_lost");
3810
- return;
3811
- }
3812
- const runtimeId = current.runtimeId;
3813
- const timer = setTimeout(() => {
3814
- staleRuntimeTimers.delete(runtimeId);
3815
- const latest = registry.getCurrent();
3816
- if (latest?.runtimeId === runtimeId && !registry.hasActiveBinding(runtimeId)) {
3817
- try {
3818
- registry.unregister(runtimeId);
3819
- } catch {
3820
- }
3821
- relayCoordinator.disposeAll("connection_lost");
3822
- trackGatewayRuntimeExpired({
3823
- gapMs: Date.now() - staleAt
3824
- });
3825
- }
3826
- }, disconnectGracePeriodMs);
3827
- staleRuntimeTimers.set(runtimeId, timer);
3828
- }
3790
+ pipeline.notifyConnectionClosed(ctx.connectionId);
3829
3791
  }
3830
3792
  });
3831
3793
  if (listenSpec.kind === "tcp") {
@@ -3861,11 +3823,7 @@ async function startDaemon(opts) {
3861
3823
  if (stopping) return;
3862
3824
  stopping = true;
3863
3825
  try {
3864
- outboundDispatcher.stop();
3865
- for (const timer of staleRuntimeTimers.values()) {
3866
- clearTimeout(timer);
3867
- }
3868
- staleRuntimeTimers.clear();
3826
+ await pipeline.stop();
3869
3827
  relayCoordinator.disposeAll("auto_resolved");
3870
3828
  await channelManager.stop();
3871
3829
  await server.close();
@@ -3890,13 +3848,9 @@ async function startDaemon(opts) {
3890
3848
  startedAt,
3891
3849
  pid,
3892
3850
  paths,
3893
- registry,
3894
- dispatcher,
3851
+ pipeline,
3895
3852
  channelManager,
3896
3853
  relayCoordinator,
3897
- inboundQueue,
3898
- outbox,
3899
- outboundDispatcher,
3900
3854
  listener,
3901
3855
  stop
3902
3856
  };