@drisp/cli 0.5.10 → 0.5.13

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.
@@ -24,6 +24,159 @@ import {
24
24
  // src/gateway/daemon.ts
25
25
  import fs4 from "fs";
26
26
 
27
+ // src/gateway/channelManager.ts
28
+ var DEFAULT_DEDUP_WINDOW = 1024;
29
+ var DuplicateChannelError = class extends Error {
30
+ constructor(id) {
31
+ super(`channel ${id} already registered`);
32
+ this.name = "DuplicateChannelError";
33
+ }
34
+ };
35
+ var UnknownChannelError = class extends Error {
36
+ constructor(id) {
37
+ super(`channel ${id} not registered`);
38
+ this.name = "UnknownChannelError";
39
+ }
40
+ };
41
+ var ChannelManager = class {
42
+ entries = /* @__PURE__ */ new Map();
43
+ dedup = [];
44
+ dedupSet = /* @__PURE__ */ new Set();
45
+ dedupMax;
46
+ log;
47
+ inboundSink = null;
48
+ healthSink = null;
49
+ stopped = false;
50
+ constructor(opts = {}) {
51
+ this.dedupMax = opts.dedupWindow ?? DEFAULT_DEDUP_WINDOW;
52
+ this.log = opts.log;
53
+ }
54
+ /** Register the single inbound dispatch target. M5 wires the router here. */
55
+ setInboundSink(sink) {
56
+ this.inboundSink = sink;
57
+ }
58
+ /**
59
+ * Returns the attachmentId associated with `channelId` at registration
60
+ * time, or undefined if the channel is unknown or registered without one.
61
+ */
62
+ getAttachmentId(channelId) {
63
+ return this.entries.get(channelId)?.attachmentId;
64
+ }
65
+ setHealthSink(sink) {
66
+ this.healthSink = sink;
67
+ }
68
+ listChannels() {
69
+ return [...this.entries.values()].map((e) => ({
70
+ id: e.adapter.id,
71
+ health: e.lastHealth
72
+ }));
73
+ }
74
+ /** Snapshot of currently registered adapters; used by the relay coordinator. */
75
+ listAdapters() {
76
+ return [...this.entries.values()].map((e) => e.adapter);
77
+ }
78
+ async register(adapter, opts = {}) {
79
+ if (this.stopped) {
80
+ throw new Error("channel manager already stopped");
81
+ }
82
+ if (this.entries.has(adapter.id)) {
83
+ throw new DuplicateChannelError(adapter.id);
84
+ }
85
+ const abort = new AbortController();
86
+ const inboundListener = (msg) => this.handleInbound(adapter.id, msg);
87
+ const healthListener = (sample) => {
88
+ const entry2 = this.entries.get(adapter.id);
89
+ if (entry2) entry2.lastHealth = sample;
90
+ this.healthSink?.(sample);
91
+ };
92
+ const startPromise = adapter.start({
93
+ log: (level, msg) => this.log?.(level, `[${adapter.id}] ${msg}`),
94
+ signal: abort.signal,
95
+ emitInbound: inboundListener,
96
+ emitHealth: healthListener
97
+ });
98
+ const entry = {
99
+ adapter,
100
+ abort,
101
+ startPromise
102
+ };
103
+ if (opts.attachmentId !== void 0) entry.attachmentId = opts.attachmentId;
104
+ this.entries.set(adapter.id, entry);
105
+ try {
106
+ await startPromise;
107
+ } catch (err) {
108
+ this.entries.delete(adapter.id);
109
+ throw err;
110
+ }
111
+ }
112
+ async unregister(id, reason) {
113
+ const entry = this.entries.get(id);
114
+ if (!entry) {
115
+ throw new UnknownChannelError(id);
116
+ }
117
+ this.entries.delete(id);
118
+ entry.abort.abort();
119
+ await entry.adapter.stop(reason);
120
+ }
121
+ async send(channelId, msg) {
122
+ const entry = this.entries.get(channelId);
123
+ if (!entry) {
124
+ throw new UnknownChannelError(channelId);
125
+ }
126
+ return entry.adapter.send(msg);
127
+ }
128
+ async probe(channelId) {
129
+ const entry = this.entries.get(channelId);
130
+ if (!entry) {
131
+ throw new UnknownChannelError(channelId);
132
+ }
133
+ return entry.adapter.probe();
134
+ }
135
+ async stop(reason = "shutdown") {
136
+ if (this.stopped) return;
137
+ this.stopped = true;
138
+ const ids = [...this.entries.keys()];
139
+ for (const id of ids.reverse()) {
140
+ try {
141
+ await this.unregister(id, reason);
142
+ } catch (err) {
143
+ this.log?.(
144
+ "warn",
145
+ `channel ${id} stop failed: ${err instanceof Error ? err.message : String(err)}`
146
+ );
147
+ }
148
+ }
149
+ }
150
+ handleInbound(channelId, msg) {
151
+ if (this.dedupSet.has(msg.idempotencyKey)) {
152
+ this.log?.("debug", `dropping duplicate inbound ${msg.idempotencyKey}`);
153
+ return;
154
+ }
155
+ this.dedupSet.add(msg.idempotencyKey);
156
+ this.dedup.push(msg.idempotencyKey);
157
+ while (this.dedup.length > this.dedupMax) {
158
+ const evicted = this.dedup.shift();
159
+ if (evicted !== void 0) this.dedupSet.delete(evicted);
160
+ }
161
+ const sink = this.inboundSink;
162
+ if (!sink) {
163
+ this.log?.(
164
+ "debug",
165
+ `no inbound sink registered; dropping ${msg.idempotencyKey}`
166
+ );
167
+ return;
168
+ }
169
+ try {
170
+ sink(msg, { attachmentId: this.entries.get(channelId)?.attachmentId });
171
+ } catch (err) {
172
+ this.log?.(
173
+ "warn",
174
+ `inbound sink threw: ${err instanceof Error ? err.message : String(err)}`
175
+ );
176
+ }
177
+ }
178
+ };
179
+
27
180
  // src/infra/config/channels.ts
28
181
  import fs from "fs";
29
182
  import os from "os";
@@ -1886,158 +2039,173 @@ function instantiateAdapter(sidecar) {
1886
2039
  return { ok: true, adapter: module.create(parsed.config, sidecar.instanceId) };
1887
2040
  }
1888
2041
 
1889
- // src/gateway/channelManager.ts
1890
- var DEFAULT_DEDUP_WINDOW = 1024;
1891
- var DuplicateChannelError = class extends Error {
1892
- constructor(id) {
1893
- super(`channel ${id} already registered`);
1894
- this.name = "DuplicateChannelError";
1895
- }
1896
- };
1897
- var UnknownChannelError = class extends Error {
1898
- constructor(id) {
1899
- super(`channel ${id} not registered`);
1900
- this.name = "UnknownChannelError";
1901
- }
1902
- };
1903
- var ChannelManager = class {
1904
- entries = /* @__PURE__ */ new Map();
1905
- dedup = [];
1906
- dedupSet = /* @__PURE__ */ new Set();
1907
- dedupMax;
1908
- log;
1909
- inboundSink = null;
1910
- healthSink = null;
1911
- stopped = false;
1912
- constructor(opts = {}) {
1913
- this.dedupMax = opts.dedupWindow ?? DEFAULT_DEDUP_WINDOW;
1914
- this.log = opts.log;
1915
- }
1916
- /** Register the single inbound dispatch target. M5 wires the router here. */
1917
- setInboundSink(sink) {
1918
- this.inboundSink = sink;
1919
- }
1920
- /**
1921
- * Returns the attachmentId associated with `channelId` at registration
1922
- * time, or undefined if the channel is unknown or registered without one.
1923
- */
1924
- getAttachmentId(channelId) {
1925
- return this.entries.get(channelId)?.attachmentId;
1926
- }
1927
- setHealthSink(sink) {
1928
- this.healthSink = sink;
1929
- }
1930
- listChannels() {
1931
- return [...this.entries.values()].map((e) => ({
1932
- id: e.adapter.id,
1933
- health: e.lastHealth
1934
- }));
1935
- }
1936
- /** Snapshot of currently registered adapters; used by the relay coordinator. */
1937
- listAdapters() {
1938
- return [...this.entries.values()].map((e) => e.adapter);
1939
- }
1940
- async register(adapter, opts = {}) {
1941
- if (this.stopped) {
1942
- throw new Error("channel manager already stopped");
1943
- }
1944
- if (this.entries.has(adapter.id)) {
1945
- throw new DuplicateChannelError(adapter.id);
1946
- }
1947
- const abort = new AbortController();
1948
- const inboundListener = (msg) => this.handleInbound(adapter.id, msg);
1949
- const healthListener = (sample) => {
1950
- const entry2 = this.entries.get(adapter.id);
1951
- if (entry2) entry2.lastHealth = sample;
1952
- this.healthSink?.(sample);
1953
- };
1954
- const startPromise = adapter.start({
1955
- log: (level, msg) => this.log?.(level, `[${adapter.id}] ${msg}`),
1956
- signal: abort.signal,
1957
- emitInbound: inboundListener,
1958
- emitHealth: healthListener
2042
+ // src/gateway/channelReconcilePlan.ts
2043
+ function planChannelReconciliation(input) {
2044
+ const actions = [];
2045
+ for (const error2 of input.loadErrors) {
2046
+ actions.push({
2047
+ kind: "load-error",
2048
+ id: pathIdFromSidecarPath(error2.path),
2049
+ path: error2.path,
2050
+ reason: error2.reason
1959
2051
  });
1960
- const entry = {
1961
- adapter,
1962
- abort,
1963
- startPromise
1964
- };
1965
- if (opts.attachmentId !== void 0) entry.attachmentId = opts.attachmentId;
1966
- this.entries.set(adapter.id, entry);
1967
- try {
1968
- await startPromise;
1969
- } catch (err) {
1970
- this.entries.delete(adapter.id);
1971
- throw err;
1972
- }
1973
2052
  }
1974
- async unregister(id, reason) {
1975
- const entry = this.entries.get(id);
1976
- if (!entry) {
1977
- throw new UnknownChannelError(id);
2053
+ if (input.unregisterStale) {
2054
+ const desiredIds = new Set(
2055
+ input.desired.map((sidecar) => sidecar.instanceId)
2056
+ );
2057
+ for (const id of input.currentChannelIds) {
2058
+ if (desiredIds.has(id)) continue;
2059
+ actions.push({ kind: "unregister-stale", id });
1978
2060
  }
1979
- this.entries.delete(id);
1980
- entry.abort.abort();
1981
- await entry.adapter.stop(reason);
1982
2061
  }
1983
- async send(channelId, msg) {
1984
- const entry = this.entries.get(channelId);
1985
- if (!entry) {
1986
- throw new UnknownChannelError(channelId);
2062
+ const currentIds = new Set(input.currentChannelIds);
2063
+ for (const sidecar of input.desired) {
2064
+ actions.push(
2065
+ currentIds.has(sidecar.instanceId) ? { kind: "replace", sidecar } : { kind: "register", sidecar }
2066
+ );
2067
+ }
2068
+ return { actions };
2069
+ }
2070
+ function pathIdFromSidecarPath(filePath) {
2071
+ const base = filePath.split(/[\\/]/).pop() ?? filePath;
2072
+ return base.endsWith(".json") ? base.slice(0, -".json".length) : base;
2073
+ }
2074
+
2075
+ // src/gateway/channelSidecarReconciler.ts
2076
+ var ChannelSidecarReconciler = class {
2077
+ channelManager;
2078
+ home;
2079
+ loadSidecars;
2080
+ instantiateAdapter;
2081
+ stdout;
2082
+ stderr;
2083
+ constructor(opts) {
2084
+ this.channelManager = opts.channelManager;
2085
+ this.home = opts.home;
2086
+ this.loadSidecars = opts.loadSidecars ?? loadChannelSidecars;
2087
+ this.instantiateAdapter = opts.instantiateAdapter ?? instantiateAdapter;
2088
+ this.stdout = opts.stdout ?? ((message) => process.stdout.write(message));
2089
+ this.stderr = opts.stderr ?? ((message) => process.stderr.write(message));
2090
+ }
2091
+ async reconcile(opts) {
2092
+ const { sidecars, errors } = this.loadSidecars(this.home);
2093
+ const plan = planChannelReconciliation({
2094
+ desired: sidecars,
2095
+ currentChannelIds: this.channelManager.listChannels().map((channel) => channel.id),
2096
+ loadErrors: errors,
2097
+ unregisterStale: opts.unregisterStale
2098
+ });
2099
+ const outcomes = [];
2100
+ for (const action of plan.actions) {
2101
+ outcomes.push(await this.executeAction(action));
2102
+ }
2103
+ for (const outcome of outcomes) {
2104
+ if (!outcome.log) continue;
2105
+ const enabled = outcome.log.stream === "err" ? opts.logFailures : opts.logRegistrations;
2106
+ if (!enabled) continue;
2107
+ const write = outcome.log.stream === "err" ? this.stderr : this.stdout;
2108
+ write(outcome.log.message);
2109
+ }
2110
+ return { results: outcomes.map((outcome) => outcome.result) };
2111
+ }
2112
+ async executeAction(action) {
2113
+ switch (action.kind) {
2114
+ case "load-error":
2115
+ return {
2116
+ result: {
2117
+ id: action.id,
2118
+ ok: false,
2119
+ action: "failed",
2120
+ reason: action.reason
2121
+ },
2122
+ log: {
2123
+ stream: "err",
2124
+ message: `athena-gateway: skipping ${action.path}: ${action.reason}
2125
+ `
2126
+ }
2127
+ };
2128
+ case "unregister-stale":
2129
+ return this.executeUnregisterStale(action.id);
2130
+ case "replace":
2131
+ return this.executeApply(action.sidecar, true);
2132
+ case "register":
2133
+ return this.executeApply(action.sidecar, false);
1987
2134
  }
1988
- return entry.adapter.send(msg);
1989
2135
  }
1990
- async probe(channelId) {
1991
- const entry = this.entries.get(channelId);
1992
- if (!entry) {
1993
- throw new UnknownChannelError(channelId);
2136
+ async executeUnregisterStale(id) {
2137
+ try {
2138
+ await this.channelManager.unregister(id, "shutdown");
2139
+ return { result: { id, ok: true, action: "unregistered" } };
2140
+ } catch (err) {
2141
+ const reason = errorReason(err);
2142
+ return {
2143
+ result: { id, ok: false, action: "failed", reason },
2144
+ log: {
2145
+ stream: "err",
2146
+ message: `athena-gateway: unregister ${id} failed: ${reason}
2147
+ `
2148
+ }
2149
+ };
1994
2150
  }
1995
- return entry.adapter.probe();
1996
2151
  }
1997
- async stop(reason = "shutdown") {
1998
- if (this.stopped) return;
1999
- this.stopped = true;
2000
- const ids = [...this.entries.keys()];
2001
- for (const id of ids.reverse()) {
2152
+ async executeApply(sidecar, existed) {
2153
+ const id = sidecar.instanceId;
2154
+ if (existed) {
2002
2155
  try {
2003
- await this.unregister(id, reason);
2156
+ await this.channelManager.unregister(id, "shutdown");
2004
2157
  } catch (err) {
2005
- this.log?.(
2006
- "warn",
2007
- `channel ${id} stop failed: ${err instanceof Error ? err.message : String(err)}`
2008
- );
2158
+ const reason = errorReason(err);
2159
+ return {
2160
+ result: { id, ok: false, action: "failed", reason },
2161
+ log: {
2162
+ stream: "err",
2163
+ message: `athena-gateway: unregister ${id} failed: ${reason}
2164
+ `
2165
+ }
2166
+ };
2009
2167
  }
2010
2168
  }
2011
- }
2012
- handleInbound(channelId, msg) {
2013
- if (this.dedupSet.has(msg.idempotencyKey)) {
2014
- this.log?.("debug", `dropping duplicate inbound ${msg.idempotencyKey}`);
2015
- return;
2016
- }
2017
- this.dedupSet.add(msg.idempotencyKey);
2018
- this.dedup.push(msg.idempotencyKey);
2019
- while (this.dedup.length > this.dedupMax) {
2020
- const evicted = this.dedup.shift();
2021
- if (evicted !== void 0) this.dedupSet.delete(evicted);
2022
- }
2023
- const sink = this.inboundSink;
2024
- if (!sink) {
2025
- this.log?.(
2026
- "debug",
2027
- `no inbound sink registered; dropping ${msg.idempotencyKey}`
2028
- );
2029
- return;
2169
+ const built = this.instantiateAdapter(sidecar);
2170
+ if (!built.ok) {
2171
+ return {
2172
+ result: { id, ok: false, action: "failed", reason: built.reason },
2173
+ log: {
2174
+ stream: "err",
2175
+ message: `athena-gateway: ${id}: ${built.reason}
2176
+ `
2177
+ }
2178
+ };
2030
2179
  }
2031
2180
  try {
2032
- sink(msg, { attachmentId: this.entries.get(channelId)?.attachmentId });
2033
- } catch (err) {
2034
- this.log?.(
2035
- "warn",
2036
- `inbound sink threw: ${err instanceof Error ? err.message : String(err)}`
2181
+ await this.channelManager.register(
2182
+ built.adapter,
2183
+ sidecar.attachmentId !== void 0 ? { attachmentId: sidecar.attachmentId } : {}
2037
2184
  );
2185
+ return {
2186
+ result: { id, ok: true, action: existed ? "replaced" : "registered" },
2187
+ log: {
2188
+ stream: "out",
2189
+ message: `athena-gateway: registered ${id}
2190
+ `
2191
+ }
2192
+ };
2193
+ } catch (err) {
2194
+ const reason = errorReason(err);
2195
+ return {
2196
+ result: { id, ok: false, action: "failed", reason },
2197
+ log: {
2198
+ stream: "err",
2199
+ message: `athena-gateway: register ${id} failed: ${reason}
2200
+ `
2201
+ }
2202
+ };
2038
2203
  }
2039
2204
  }
2040
2205
  };
2206
+ function errorReason(err) {
2207
+ return err instanceof Error ? err.message : String(err);
2208
+ }
2041
2209
 
2042
2210
  // src/gateway/control/handlers.ts
2043
2211
  import { createRequire } from "module";
@@ -2109,7 +2277,14 @@ var RuntimeBindingStore = class {
2109
2277
  epoch,
2110
2278
  ...maybeLastRebindAt(lastRebindAt)
2111
2279
  };
2112
- const slot = existing ? { ...existing, runtime, binding: newBinding } : { runtime, binding: newBinding, staleTimer: null, staleSince: null };
2280
+ const push = input.push ?? existing?.push ?? null;
2281
+ const slot = existing ? { ...existing, runtime, binding: newBinding, push } : {
2282
+ runtime,
2283
+ binding: newBinding,
2284
+ push,
2285
+ staleTimer: null,
2286
+ staleSince: null
2287
+ };
2113
2288
  this.clearStaleTimerForSlot(slot);
2114
2289
  this.slots.set(key, slot);
2115
2290
  if (wasStale && staleSince !== null) {
@@ -2132,9 +2307,10 @@ var RuntimeBindingStore = class {
2132
2307
  this.observers.onRuntimeConnectionLost?.({ runtimeId, graceful: true });
2133
2308
  }
2134
2309
  /**
2135
- * Called when the transport connection closes.
2136
- * Returns the runtimeId if the close was for a current binding (caller should
2137
- * clear the push handle); returns null if the connectionId was not recognised.
2310
+ * Called when the transport connection closes. Drops the slot's push handle
2311
+ * (immediately deleting the slot when no grace period is configured, otherwise
2312
+ * marking it stale). Returns the runtimeId if the close matched a current
2313
+ * binding; returns null if the connectionId was not recognised.
2138
2314
  */
2139
2315
  notifyConnectionClosed(connectionId) {
2140
2316
  const entry = this.findSlotByConnectionId(connectionId);
@@ -2155,6 +2331,7 @@ var RuntimeBindingStore = class {
2155
2331
  this.observers.onRuntimeConnectionLost?.({ runtimeId, graceful: false });
2156
2332
  return runtimeId;
2157
2333
  }
2334
+ slot.push = null;
2158
2335
  slot.staleSince = now;
2159
2336
  slot.staleTimer = setTimeout(() => {
2160
2337
  this.expireStaleBinding(key, runtimeId);
@@ -2189,6 +2366,28 @@ var RuntimeBindingStore = class {
2189
2366
  const entry = this.findSlotByConnectionId(connectionId);
2190
2367
  return entry ? entry.slot.runtime.runtimeId : null;
2191
2368
  }
2369
+ /**
2370
+ * Deliver a control-push envelope to the runtime occupying `key`. Returns
2371
+ * false (and pushes nothing) when the slot is empty or its connection has
2372
+ * already been lost.
2373
+ */
2374
+ pushTo(key, env) {
2375
+ const slot = this.slots.get(key);
2376
+ if (!slot || !slot.push) return false;
2377
+ slot.push(env);
2378
+ return true;
2379
+ }
2380
+ /**
2381
+ * Reportable state for every slot — both attachment-keyed and the legacy
2382
+ * fallback — so status reporting sees all registered runtimes.
2383
+ */
2384
+ snapshot() {
2385
+ const entries = [];
2386
+ for (const [key, slot] of this.slots) {
2387
+ entries.push({ key, runtime: slot.runtime, binding: slot.binding });
2388
+ }
2389
+ return entries;
2390
+ }
2192
2391
  /**
2193
2392
  * Returns the attachment slot key (or `undefined` for the legacy slot) that
2194
2393
  * holds the given runtime, or `null` if no slot does. Lets callers
@@ -2241,7 +2440,7 @@ var cachedVersion = null;
2241
2440
  function readVersion() {
2242
2441
  if (cachedVersion !== null) return cachedVersion;
2243
2442
  try {
2244
- const injected = "0.5.10";
2443
+ const injected = "0.5.13";
2245
2444
  if (typeof injected === "string" && injected.length > 0) {
2246
2445
  cachedVersion = injected;
2247
2446
  return cachedVersion;
@@ -2516,29 +2715,25 @@ function createDispatcher(deps) {
2516
2715
  return handle;
2517
2716
  }
2518
2717
  function runtimeStatusEntries(pipeline) {
2519
- const runtime = pipeline?.getCurrentRuntime();
2520
- if (!runtime || !pipeline) return [];
2521
- const binding = pipeline.getBinding();
2522
- return [
2523
- {
2524
- runtimeId: runtime.runtimeId,
2525
- defaultAgentId: runtime.defaultAgentId,
2526
- pid: runtime.pid,
2527
- registeredAt: runtime.registeredAt,
2528
- binding: binding?.state === "active" ? {
2529
- state: "active",
2530
- boundAt: binding.boundAt,
2531
- epoch: binding.epoch,
2532
- ...maybeLastRebindAt(binding.lastRebindAt)
2533
- } : binding?.state === "stale" ? {
2534
- state: "stale",
2535
- staleSince: binding.staleSince,
2536
- epoch: binding.epoch,
2537
- ...maybeLastRebindAt(binding.lastRebindAt)
2538
- } : { state: "none" },
2539
- pendingDispatchCount: pipeline.pendingDispatchCount()
2540
- }
2541
- ];
2718
+ if (!pipeline) return [];
2719
+ return pipeline.snapshotRuntimes().map(({ runtime, binding }) => ({
2720
+ runtimeId: runtime.runtimeId,
2721
+ defaultAgentId: runtime.defaultAgentId,
2722
+ pid: runtime.pid,
2723
+ registeredAt: runtime.registeredAt,
2724
+ binding: binding?.state === "active" ? {
2725
+ state: "active",
2726
+ boundAt: binding.boundAt,
2727
+ epoch: binding.epoch,
2728
+ ...maybeLastRebindAt(binding.lastRebindAt)
2729
+ } : binding?.state === "stale" ? {
2730
+ state: "stale",
2731
+ staleSince: binding.staleSince,
2732
+ epoch: binding.epoch,
2733
+ ...maybeLastRebindAt(binding.lastRebindAt)
2734
+ } : { state: "none" },
2735
+ pendingDispatchCount: pipeline.pendingDispatchCountFor(runtime.runtimeId)
2736
+ }));
2542
2737
  }
2543
2738
  function ok(envelope, ts, payload) {
2544
2739
  return { request_id: envelope.request_id, ts, ok: true, payload };
@@ -2820,13 +3015,6 @@ function deriveSessionKey(loc) {
2820
3015
 
2821
3016
  // src/gateway/sessionRegistry.ts
2822
3017
  import { randomUUID } from "crypto";
2823
- var UnknownDispatchError = class extends Error {
2824
- code = "unknown_dispatch";
2825
- constructor(id) {
2826
- super(`unknown dispatchId: ${id}`);
2827
- this.name = "UnknownDispatchError";
2828
- }
2829
- };
2830
3018
  var SessionRegistry = class {
2831
3019
  dispatches = /* @__PURE__ */ new Map();
2832
3020
  idFactory;
@@ -2841,25 +3029,54 @@ var SessionRegistry = class {
2841
3029
  dispatchId,
2842
3030
  sessionKey: input.sessionKey,
2843
3031
  agentId: input.agentId,
3032
+ runtimeId: input.runtimeId,
3033
+ ...input.attachmentKey !== void 0 ? { attachmentKey: input.attachmentKey } : {},
2844
3034
  location: input.location,
2845
3035
  createdAt: this.now()
2846
3036
  };
2847
3037
  this.dispatches.set(dispatchId, entry);
2848
3038
  return entry;
2849
3039
  }
2850
- completeDispatch(dispatchId) {
3040
+ /**
3041
+ * Resolve a parked turn for the runtime claiming to complete it. The entry is
3042
+ * consumed only when the claiming runtime matches the one the turn was
3043
+ * dispatched to — a mismatched runtime cannot cancel or steal another
3044
+ * runtime's turn, and an unknown id is reported rather than thrown.
3045
+ */
3046
+ completeDispatch(dispatchId, by) {
2851
3047
  const entry = this.dispatches.get(dispatchId);
2852
3048
  if (!entry) {
2853
- throw new UnknownDispatchError(dispatchId);
3049
+ return { kind: "unknown" };
3050
+ }
3051
+ if (entry.runtimeId !== by.runtimeId) {
3052
+ return { kind: "runtime_mismatch", entry };
2854
3053
  }
2855
3054
  this.dispatches.delete(dispatchId);
2856
- return entry;
3055
+ return { kind: "completed", entry };
2857
3056
  }
2858
3057
  pendingDispatchCount() {
2859
3058
  return this.dispatches.size;
2860
3059
  }
2861
- clearDispatches() {
2862
- this.dispatches.clear();
3060
+ /** Number of parked turns owned by the given runtime. */
3061
+ pendingDispatchCountFor(runtimeId) {
3062
+ let count = 0;
3063
+ for (const entry of this.dispatches.values()) {
3064
+ if (entry.runtimeId === runtimeId) count += 1;
3065
+ }
3066
+ return count;
3067
+ }
3068
+ /**
3069
+ * Remove only the parked turns owned by the given runtime, leaving every other
3070
+ * runtime's in-flight dispatches intact. Used when a single Registered runtime
3071
+ * unregisters or its connection is lost — clearing the correct slot's turns
3072
+ * without a global wipe.
3073
+ */
3074
+ clearDispatchesFor(runtimeId) {
3075
+ for (const [dispatchId, entry] of this.dispatches) {
3076
+ if (entry.runtimeId === runtimeId) {
3077
+ this.dispatches.delete(dispatchId);
3078
+ }
3079
+ }
2863
3080
  }
2864
3081
  };
2865
3082
 
@@ -2986,8 +3203,6 @@ var DispatchPipeline = class {
2986
3203
  log;
2987
3204
  now;
2988
3205
  idFactory;
2989
- pushes = /* @__PURE__ */ new Map();
2990
- connectionToKey = /* @__PURE__ */ new Map();
2991
3206
  constructor(opts) {
2992
3207
  this.bindingStore = new RuntimeBindingStore({
2993
3208
  gracePeriodMs: opts.gracePeriodMs,
@@ -3068,6 +3283,8 @@ var DispatchPipeline = class {
3068
3283
  const entry = this.registry.beginDispatch({
3069
3284
  sessionKey,
3070
3285
  agentId,
3286
+ runtimeId: current.runtimeId,
3287
+ ...key !== void 0 ? { attachmentKey: key } : {},
3071
3288
  location: inbound.location
3072
3289
  });
3073
3290
  this.pushDispatch(key, {
@@ -3079,9 +3296,7 @@ var DispatchPipeline = class {
3079
3296
  return { kind: "dispatched", dispatchId: entry.dispatchId, sessionKey };
3080
3297
  }
3081
3298
  pushDispatch(key, payload) {
3082
- const handle = this.pushes.get(key);
3083
- if (!handle) return;
3084
- handle.push({
3299
+ this.bindingStore.pushTo(key, {
3085
3300
  push_id: this.idFactory(),
3086
3301
  ts: this.now(),
3087
3302
  kind: "session.dispatch.turn",
@@ -3096,17 +3311,9 @@ var DispatchPipeline = class {
3096
3311
  defaultAgentId: input.defaultAgentId,
3097
3312
  pid: input.pid,
3098
3313
  connectionId: input.connectionId,
3314
+ push: input.push,
3099
3315
  ...input.attachmentId !== void 0 ? { attachmentId: input.attachmentId } : {}
3100
3316
  });
3101
- const previous = this.pushes.get(key);
3102
- if (previous && previous.connectionId !== input.connectionId) {
3103
- this.connectionToKey.delete(previous.connectionId);
3104
- }
3105
- this.pushes.set(key, {
3106
- connectionId: input.connectionId,
3107
- push: input.push
3108
- });
3109
- this.connectionToKey.set(input.connectionId, key);
3110
3317
  writeGatewayTrace(
3111
3318
  `pipeline registered runtime runtimeId=${input.runtimeId} connectionId=${input.connectionId}`
3112
3319
  );
@@ -3114,27 +3321,16 @@ var DispatchPipeline = class {
3114
3321
  return { registeredAt: result.registeredAt };
3115
3322
  }
3116
3323
  unregisterRuntime(runtimeId) {
3117
- const slot = this.findSlotByRuntimeId(runtimeId);
3118
3324
  this.bindingStore.unbind(runtimeId);
3119
- this.registry.clearDispatches();
3120
- if (slot) {
3121
- const handle = this.pushes.get(slot.key);
3122
- this.pushes.delete(slot.key);
3123
- if (handle) this.connectionToKey.delete(handle.connectionId);
3124
- }
3325
+ this.registry.clearDispatchesFor(runtimeId);
3125
3326
  writeGatewayTrace(`pipeline unregistered runtime runtimeId=${runtimeId}`);
3126
3327
  }
3127
3328
  notifyConnectionClosed(connectionId) {
3128
- const key = this.connectionToKey.get(connectionId);
3129
3329
  const runtimeId = this.bindingStore.notifyConnectionClosed(connectionId);
3130
3330
  if (runtimeId === null) return;
3131
3331
  writeGatewayTrace(
3132
3332
  `pipeline runtime connection closed runtimeId=${runtimeId} connectionId=${connectionId}`
3133
3333
  );
3134
- if (key !== void 0 || this.pushes.has(key)) {
3135
- this.pushes.delete(key);
3136
- this.connectionToKey.delete(connectionId);
3137
- }
3138
3334
  }
3139
3335
  /**
3140
3336
  * Streaming run-event from a registered runtime to its outbound channel
@@ -3174,32 +3370,35 @@ var DispatchPipeline = class {
3174
3370
  if (!slot) {
3175
3371
  throw new Error("runtime mismatch on session.turn.complete");
3176
3372
  }
3177
- let entry;
3178
- try {
3179
- entry = this.registry.completeDispatch(payload.dispatchId);
3180
- } catch (err) {
3181
- if (err instanceof UnknownDispatchError) {
3182
- writeGatewayTrace(
3183
- `pipeline turn.complete unknown dispatchId=${payload.dispatchId}`
3184
- );
3185
- return { delivered: false };
3186
- }
3187
- throw err;
3373
+ const completion = this.registry.completeDispatch(payload.dispatchId, {
3374
+ runtimeId: payload.runtimeId
3375
+ });
3376
+ if (completion.kind === "unknown") {
3377
+ writeGatewayTrace(
3378
+ `pipeline turn.complete unknown dispatchId=${payload.dispatchId}`
3379
+ );
3380
+ return { delivered: false };
3381
+ }
3382
+ if (completion.kind === "runtime_mismatch") {
3383
+ writeGatewayTrace(
3384
+ `pipeline turn.complete runtime mismatch dispatchId=${payload.dispatchId} authorized=${completion.entry.runtimeId} claimed=${payload.runtimeId}`
3385
+ );
3386
+ return { delivered: false };
3188
3387
  }
3189
- const result = await this.sendOutbound(entry.location, payload);
3388
+ const result = await this.sendOutbound(completion.entry.location, payload);
3190
3389
  writeGatewayTrace(
3191
3390
  `pipeline sendOutbound delivered dispatchId=${payload.dispatchId} providerMessageId=${result.providerMessageId}`
3192
3391
  );
3193
3392
  return { delivered: true, providerMessageId: result.providerMessageId };
3194
3393
  }
3195
- async sendOutbound(_parkedLocation, payload) {
3394
+ async sendOutbound(parkedLocation, payload) {
3196
3395
  const out = {
3197
- location: payload.location,
3396
+ location: parkedLocation,
3198
3397
  text: payload.text,
3199
3398
  idempotencyKey: payload.idempotencyKey
3200
3399
  };
3201
3400
  const result = await this.outboundDispatcher.dispatch(
3202
- payload.location.channelId,
3401
+ parkedLocation.channelId,
3203
3402
  out
3204
3403
  );
3205
3404
  if (result.kind === "sent") return result.result;
@@ -3234,6 +3433,10 @@ var DispatchPipeline = class {
3234
3433
  getCurrentRuntime() {
3235
3434
  return this.bindingStore.getCurrent();
3236
3435
  }
3436
+ /** Reportable state for every registered-runtime slot (legacy + attachment-keyed). */
3437
+ snapshotRuntimes() {
3438
+ return this.bindingStore.snapshot();
3439
+ }
3237
3440
  getCurrentRuntimeByAttachment(attachmentId) {
3238
3441
  return this.bindingStore.getCurrentByAttachment(attachmentId);
3239
3442
  }
@@ -3249,6 +3452,9 @@ var DispatchPipeline = class {
3249
3452
  pendingDispatchCount() {
3250
3453
  return this.registry.pendingDispatchCount();
3251
3454
  }
3455
+ pendingDispatchCountFor(runtimeId) {
3456
+ return this.registry.pendingDispatchCountFor(runtimeId);
3457
+ }
3252
3458
  pendingInboundCount() {
3253
3459
  return this.inboundQueue.size();
3254
3460
  }
@@ -3848,10 +4054,6 @@ function buildListenerStatus(spec, resolvedPort) {
3848
4054
  loopback: isLoopbackHost(spec.host)
3849
4055
  };
3850
4056
  }
3851
- function pathIdFromSidecarPath(filePath) {
3852
- const base = filePath.split(/[\\/]/).pop() ?? filePath;
3853
- return base.endsWith(".json") ? base.slice(0, -".json".length) : base;
3854
- }
3855
4057
  async function startDaemon(opts) {
3856
4058
  const startedAt = Date.now();
3857
4059
  const pid = process.pid;
@@ -3905,125 +4107,20 @@ async function startDaemon(opts) {
3905
4107
  channelManager.setInboundSink((inbound, ctx) => {
3906
4108
  pipeline.handleInbound(inbound, ctx);
3907
4109
  });
3908
- const channelConfigHome = opts.env?.HOME;
3909
- const reloadChannels = async () => {
3910
- const results = [];
3911
- const { sidecars, errors } = loadChannelSidecars(channelConfigHome);
3912
- for (const err of errors) {
3913
- const id = pathIdFromSidecarPath(err.path);
3914
- results.push({
3915
- id,
3916
- ok: false,
3917
- action: "failed",
3918
- reason: err.reason
3919
- });
3920
- }
3921
- const sidecarIds = new Set(sidecars.map((s) => s.instanceId));
3922
- for (const channel of channelManager.listChannels()) {
3923
- if (sidecarIds.has(channel.id)) continue;
3924
- try {
3925
- await channelManager.unregister(channel.id, "shutdown");
3926
- results.push({
3927
- id: channel.id,
3928
- ok: true,
3929
- action: "unregistered"
3930
- });
3931
- } catch (err) {
3932
- results.push({
3933
- id: channel.id,
3934
- ok: false,
3935
- action: "failed",
3936
- reason: err instanceof Error ? err.message : String(err)
3937
- });
3938
- }
3939
- }
3940
- for (const sidecar of sidecars) {
3941
- const existed = channelManager.listChannels().some((channel) => channel.id === sidecar.instanceId);
3942
- if (existed) {
3943
- try {
3944
- await channelManager.unregister(sidecar.instanceId, "shutdown");
3945
- } catch (err) {
3946
- results.push({
3947
- id: sidecar.instanceId,
3948
- ok: false,
3949
- action: "failed",
3950
- reason: err instanceof Error ? err.message : String(err)
3951
- });
3952
- continue;
3953
- }
3954
- }
3955
- const built = instantiateAdapter(sidecar);
3956
- if (!built.ok) {
3957
- results.push({
3958
- id: sidecar.instanceId,
3959
- ok: false,
3960
- action: "failed",
3961
- reason: built.reason
3962
- });
3963
- continue;
3964
- }
3965
- try {
3966
- await channelManager.register(
3967
- built.adapter,
3968
- sidecar.attachmentId !== void 0 ? { attachmentId: sidecar.attachmentId } : {}
3969
- );
3970
- results.push({
3971
- id: sidecar.instanceId,
3972
- ok: true,
3973
- action: existed ? "replaced" : "registered"
3974
- });
3975
- if (!opts.silent) {
3976
- process.stdout.write(
3977
- `athena-gateway: registered ${sidecar.instanceId}
3978
- `
3979
- );
3980
- }
3981
- } catch (err) {
3982
- results.push({
3983
- id: sidecar.instanceId,
3984
- ok: false,
3985
- action: "failed",
3986
- reason: err instanceof Error ? err.message : String(err)
3987
- });
3988
- }
3989
- }
3990
- return { results };
3991
- };
4110
+ const channelSidecarReconciler = new ChannelSidecarReconciler({
4111
+ channelManager,
4112
+ home: opts.env?.HOME
4113
+ });
4114
+ const reloadChannels = async () => channelSidecarReconciler.reconcile({
4115
+ unregisterStale: true,
4116
+ logRegistrations: !opts.silent
4117
+ });
3992
4118
  if (!opts.skipChannelLoad) {
3993
- const { sidecars, errors } = loadChannelSidecars(channelConfigHome);
3994
- for (const err of errors) {
3995
- process.stderr.write(
3996
- `athena-gateway: skipping ${err.path}: ${err.reason}
3997
- `
3998
- );
3999
- }
4000
- for (const sidecar of sidecars) {
4001
- const built = instantiateAdapter(sidecar);
4002
- if (!built.ok) {
4003
- process.stderr.write(
4004
- `athena-gateway: ${sidecar.instanceId}: ${built.reason}
4005
- `
4006
- );
4007
- continue;
4008
- }
4009
- try {
4010
- await channelManager.register(
4011
- built.adapter,
4012
- sidecar.attachmentId !== void 0 ? { attachmentId: sidecar.attachmentId } : {}
4013
- );
4014
- if (!opts.silent) {
4015
- process.stdout.write(
4016
- `athena-gateway: registered ${sidecar.instanceId}
4017
- `
4018
- );
4019
- }
4020
- } catch (err) {
4021
- process.stderr.write(
4022
- `athena-gateway: register ${sidecar.instanceId} failed: ${err instanceof Error ? err.message : String(err)}
4023
- `
4024
- );
4025
- }
4026
- }
4119
+ await channelSidecarReconciler.reconcile({
4120
+ unregisterStale: false,
4121
+ logFailures: true,
4122
+ logRegistrations: !opts.silent
4123
+ });
4027
4124
  }
4028
4125
  const handler = createDispatcher({
4029
4126
  startedAt,