agent-relay-orchestrator 0.104.2 → 0.104.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-relay-orchestrator",
3
- "version": "0.104.2",
3
+ "version": "0.104.5",
4
4
  "description": "Agent Relay orchestrator — manages agent lifecycle across hosts",
5
5
  "type": "module",
6
6
  "bin": {
@@ -9,7 +9,6 @@
9
9
  "files": [
10
10
  "src/**/*.ts",
11
11
  "!src/**/*.test.ts",
12
- "vendor/**",
13
12
  "README.md"
14
13
  ],
15
14
  "scripts": {
@@ -18,7 +17,8 @@
18
17
  },
19
18
  "dependencies": {
20
19
  "agent-relay-providers": "0.104.0",
21
- "agent-relay-sdk": "0.2.88"
20
+ "agent-relay-sdk": "0.2.90",
21
+ "callmux": "0.23.0"
22
22
  },
23
23
  "devDependencies": {
24
24
  "@types/bun": "latest",
@@ -1,7 +1,8 @@
1
1
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
- import { dirname, isAbsolute, join, resolve } from "node:path";
3
+ import { dirname, join } from "node:path";
4
4
  import { errMessage, isRecord } from "agent-relay-sdk";
5
+ import { createListener, type CallmuxConfig, type CreateListenerOptions, type ListenerHealthSnapshot, type ProgrammaticListener } from "callmux";
5
6
  import type { OrchestratorConfig } from "./config";
6
7
  import { agentRelayHome } from "./config";
7
8
 
@@ -10,14 +11,13 @@ export const SHARED_CALLMUX_HOST_ENV = "AGENT_RELAY_SHARED_CALLMUX_HOST";
10
11
  export const SHARED_CALLMUX_PORT_ENV = "AGENT_RELAY_SHARED_CALLMUX_PORT";
11
12
  export const SHARED_CALLMUX_CONFIG_ENV = "AGENT_RELAY_SHARED_CALLMUX_CONFIG";
12
13
  export const SHARED_CALLMUX_SOURCE_CONFIG_ENV = "AGENT_RELAY_SHARED_CALLMUX_SOURCE_CONFIG";
13
- export const SHARED_CALLMUX_COMMAND_ENV = "AGENT_RELAY_SHARED_CALLMUX_COMMAND";
14
14
  export const SHARED_CALLMUX_ENABLE_ENV = "AGENT_RELAY_SHARED_CALLMUX_ENABLE";
15
15
 
16
16
  export const DEFAULT_SHARED_CALLMUX_HOST = "127.0.0.1";
17
17
  export const DEFAULT_SHARED_CALLMUX_PORT = 4861;
18
18
  export const SHARED_CALLMUX_SERVER_NAMES = ["tokenlean", "github"] as const;
19
19
 
20
- const CONFIG_SCHEMA = "https://raw.githubusercontent.com/edimuj/callmux/main/schema.json";
20
+ const CONFIG_SCHEMA = "callmux/schema.json";
21
21
  const GITHUB_TOOLS = [
22
22
  "issue_read",
23
23
  "issue_write",
@@ -30,7 +30,6 @@ const GITHUB_TOOLS = [
30
30
  ];
31
31
 
32
32
  export interface SharedCallmuxOptions {
33
- command: string;
34
33
  host: string;
35
34
  port: number;
36
35
  url: string;
@@ -39,16 +38,8 @@ export interface SharedCallmuxOptions {
39
38
  enabled: boolean;
40
39
  }
41
40
 
42
- export interface SharedCallmuxProcess {
43
- pid?: number;
44
- exited: Promise<number | null>;
45
- kill(signal?: NodeJS.Signals): void;
46
- }
47
-
48
41
  export interface SharedCallmuxSupervisorDeps {
49
- which(command: string): string | null | undefined;
50
- spawn(command: string, args: string[], opts: { env: Record<string, string>; cwd: string }): SharedCallmuxProcess;
51
- fetch(url: string, init?: RequestInit): Promise<Response>;
42
+ createListener(options: CreateListenerOptions): Promise<ProgrammaticListener>;
52
43
  setInterval(fn: () => void, ms: number): Timer;
53
44
  clearInterval(timer: Timer): void;
54
45
  setTimeout(fn: () => void, ms: number): Timer;
@@ -58,11 +49,10 @@ export interface SharedCallmuxSupervisorDeps {
58
49
  }
59
50
 
60
51
  export interface SharedCallmuxHealthSnapshot {
61
- state: "disabled" | "missing" | "starting" | "running" | "unhealthy" | "restarting" | "stopped";
52
+ state: "disabled" | "starting" | "running" | "unhealthy" | "restarting" | "stopped";
62
53
  url: string;
63
- command?: string;
64
54
  reason?: string;
65
- pid?: number;
55
+ downstream?: ListenerHealthSnapshot["downstream"];
66
56
  }
67
57
 
68
58
  export function sharedCallmuxOptionsFromEnv(env: Record<string, string | undefined> = process.env): SharedCallmuxOptions {
@@ -72,7 +62,6 @@ export function sharedCallmuxOptionsFromEnv(env: Record<string, string | undefin
72
62
  const port = numberEnv(env[SHARED_CALLMUX_PORT_ENV]) ?? parsed?.port ?? DEFAULT_SHARED_CALLMUX_PORT;
73
63
  const url = explicitUrl ?? `http://${host}:${port}/mcp`;
74
64
  return {
75
- command: env[SHARED_CALLMUX_COMMAND_ENV] || bundledCallmuxCommand() || "callmux",
76
65
  host,
77
66
  port,
78
67
  url,
@@ -86,30 +75,31 @@ export function sharedMcpListenerUrl(): string {
86
75
  return sharedCallmuxOptionsFromEnv().url;
87
76
  }
88
77
 
89
- export function writeSharedCallmuxConfig(opts: Pick<SharedCallmuxOptions, "configPath" | "sourceConfigPath">): Record<string, unknown> {
78
+ export function writeSharedCallmuxConfig(opts: Pick<SharedCallmuxOptions, "configPath" | "sourceConfigPath">): CallmuxConfig {
90
79
  const source = readJsonObject(opts.sourceConfigPath);
91
80
  const sourceServers = isRecord(source.servers) ? source.servers : {};
92
- const servers = {
81
+ const servers: CallmuxConfig["servers"] = {
93
82
  tokenlean: cloneServer(sourceServers.tokenlean) ?? defaultTokenleanServer(),
94
83
  github: cloneServer(sourceServers.github) ?? defaultGithubServer(),
95
84
  };
96
- const generated: Record<string, unknown> = {
97
- $schema: CONFIG_SCHEMA,
85
+ const generated: CallmuxConfig = {
98
86
  servers,
99
87
  cacheTtlSeconds: numberFromRecord(source, "cacheTtlSeconds") ?? 10,
100
88
  maxConcurrency: numberFromRecord(source, "maxConcurrency") ?? 20,
101
89
  callTimeoutMs: numberFromRecord(source, "callTimeoutMs") ?? 180_000,
102
- outputFormat: stringFromRecord(source, "outputFormat") ?? "auto",
90
+ outputFormat: outputFormatFromRecord(source, "outputFormat") ?? "auto",
103
91
  // Relay workers consume only proxied tokenlean+github tools; suppress callmux meta-tools.
104
92
  exposeMetaTools: false,
105
93
  };
94
+ const persisted = { $schema: CONFIG_SCHEMA, ...generated };
106
95
  mkdirSync(dirname(opts.configPath), { recursive: true });
107
- writeFileSync(opts.configPath, JSON.stringify(generated, null, 2) + "\n", { mode: 0o600 });
96
+ writeFileSync(opts.configPath, JSON.stringify(persisted, null, 2) + "\n", { mode: 0o600 });
108
97
  return generated;
109
98
  }
110
99
 
111
100
  export class SharedCallmuxSupervisor {
112
- private proc: SharedCallmuxProcess | null = null;
101
+ private listener: ProgrammaticListener | null = null;
102
+ private readonly onStatus = (snapshot: ListenerHealthSnapshot) => this.reportCallmuxHealth(snapshot);
113
103
  private healthTimer: Timer | null = null;
114
104
  private restartTimer: Timer | null = null;
115
105
  private stopping = false;
@@ -131,13 +121,8 @@ export class SharedCallmuxSupervisor {
131
121
  return;
132
122
  }
133
123
  this.deps.log(`[orchestrator] Shared callmux listener: ${this.opts.url}`);
134
- if (!this.deps.which(this.opts.command)) {
135
- this.deps.log("[orchestrator] shared callmux not found — shared listener dormant");
136
- this.report("missing", "command not found");
137
- return;
138
- }
139
124
  this.report("starting");
140
- this.spawn();
125
+ void this.open();
141
126
  this.healthTimer = this.deps.setInterval(() => {
142
127
  void this.checkHealth();
143
128
  }, this.timing.healthIntervalMs ?? 10_000);
@@ -149,86 +134,85 @@ export class SharedCallmuxSupervisor {
149
134
  if (this.restartTimer) this.deps.clearTimeout(this.restartTimer);
150
135
  this.healthTimer = null;
151
136
  this.restartTimer = null;
152
- if (this.proc) {
153
- this.proc.kill("SIGTERM");
154
- this.proc = null;
137
+ if (this.listener) {
138
+ const listener = this.listener;
139
+ this.listener = null;
140
+ listener.off("status", this.onStatus);
141
+ void listener.stop().catch((err) => this.deps.log(`[orchestrator] Shared callmux listener stop failed: ${errMessage(err)}`));
155
142
  }
156
143
  }
157
144
 
158
145
  async checkHealth(): Promise<boolean> {
159
146
  if (!this.opts.enabled) return true;
160
- const readyUrl = new URL("/ready", this.opts.url).toString();
161
- let ok = false;
162
- try {
163
- const response = await this.deps.fetch(readyUrl, { signal: AbortSignal.timeout(2_000) });
164
- ok = response.ok;
165
- } catch {
166
- ok = false;
167
- }
168
- if (ok) {
147
+ const snapshot = this.listener?.health();
148
+ if (!snapshot) return false;
149
+ this.reportCallmuxHealth(snapshot);
150
+ if (snapshot.state === "running" || snapshot.state === "degraded" || snapshot.state === "reloading") {
169
151
  this.backoffMs = this.timing.restartBaseMs ?? 1_000;
170
- this.report("running", undefined, this.proc?.pid);
171
- return true;
152
+ return snapshot.state === "running";
172
153
  }
173
- this.deps.log(`[orchestrator] Shared callmux readiness failed at ${readyUrl}; restarting`);
174
- this.report("unhealthy", "readiness failed", this.proc?.pid);
175
- this.restart("readiness failed");
154
+ this.deps.log(`[orchestrator] Shared callmux listener is ${snapshot.state}; restarting`);
155
+ this.restart(`listener ${snapshot.state}`);
176
156
  return false;
177
157
  }
178
158
 
179
- private spawn(): void {
180
- if (this.stopping || this.proc) return;
181
- let proc: SharedCallmuxProcess;
159
+ private async open(): Promise<void> {
160
+ if (this.stopping || this.listener) return;
182
161
  try {
183
- writeSharedCallmuxConfig(this.opts);
184
- const args = ["--listen", String(this.opts.port), "--host", this.opts.host, "--config", this.opts.configPath];
185
- const env = {
186
- ...process.env as Record<string, string>,
162
+ const persistedConfig = writeSharedCallmuxConfig(this.opts);
163
+ const config = withRuntimeEnv(persistedConfig, {
187
164
  ...this.config.env,
188
165
  [SHARED_MCP_URL_ENV]: this.opts.url,
189
- };
190
- proc = this.deps.spawn(this.opts.command, args, { env, cwd: homedir() });
166
+ });
167
+ const listener = await this.deps.createListener({
168
+ host: this.opts.host,
169
+ port: this.opts.port,
170
+ config,
171
+ configPath: this.opts.configPath,
172
+ });
173
+ if (this.stopping) {
174
+ await listener.stop();
175
+ return;
176
+ }
177
+ this.listener = listener;
178
+ listener.on("status", this.onStatus);
179
+ this.deps.log(`[orchestrator] Started shared callmux listener at ${listener.mcpUrl}`);
180
+ this.reportCallmuxHealth(listener.health());
191
181
  } catch (err) {
192
182
  this.deps.log(`[orchestrator] Shared callmux listener failed to start: ${errMessage(err)}; scheduling restart`);
193
183
  this.report("restarting", errMessage(err));
194
184
  this.scheduleRestart();
195
- return;
196
185
  }
197
- this.proc = proc;
198
- this.deps.log(`[orchestrator] Started shared callmux listener pid=${proc.pid ?? "unknown"}`);
199
- this.report("running", undefined, proc.pid);
200
- proc.exited.then((code) => {
201
- if (this.proc !== proc || this.stopping) return;
202
- this.proc = null;
203
- this.deps.log(`[orchestrator] Shared callmux listener exited (${code ?? "signal"}); scheduling restart`);
204
- this.report("restarting", `exited ${code ?? "signal"}`);
205
- this.scheduleRestart();
206
- }).catch((err) => {
207
- if (this.proc !== proc || this.stopping) return;
208
- this.proc = null;
209
- this.deps.log(`[orchestrator] Shared callmux listener exit watcher failed: ${err}`);
210
- this.report("restarting", errMessage(err));
211
- this.scheduleRestart();
212
- });
213
186
  }
214
187
 
215
188
  private restart(reason: string): void {
216
- if (this.proc) {
189
+ if (this.listener) {
190
+ const listener = this.listener;
217
191
  this.deps.log(`[orchestrator] Stopping shared callmux listener: ${reason}`);
218
- this.proc.kill("SIGTERM");
219
- this.proc = null;
192
+ this.listener = null;
193
+ listener.off("status", this.onStatus);
194
+ void listener.stop().catch((err) => this.deps.log(`[orchestrator] Shared callmux listener stop failed: ${errMessage(err)}`));
220
195
  }
221
196
  this.report("restarting", reason);
222
197
  this.scheduleRestart();
223
198
  }
224
199
 
225
- private report(state: SharedCallmuxHealthSnapshot["state"], reason?: string, pid?: number): void {
200
+ private report(state: SharedCallmuxHealthSnapshot["state"], reason?: string, downstream?: ListenerHealthSnapshot["downstream"]): void {
226
201
  this.deps.report({
227
202
  state,
228
203
  url: this.opts.url,
229
- command: this.opts.command,
230
204
  ...(reason ? { reason } : {}),
231
- ...(pid ? { pid } : {}),
205
+ ...(downstream ? { downstream } : {}),
206
+ });
207
+ }
208
+
209
+ private reportCallmuxHealth(snapshot: ListenerHealthSnapshot): void {
210
+ const state = mapCallmuxState(snapshot);
211
+ this.deps.report({
212
+ state,
213
+ url: snapshot.mcpUrl,
214
+ ...(snapshot.reason ? { reason: snapshot.reason } : state === "unhealthy" && snapshot.downstream.failed > 0 ? { reason: `${snapshot.downstream.failed} downstream server(s) failed` } : {}),
215
+ downstream: snapshot.downstream,
232
216
  });
233
217
  }
234
218
 
@@ -238,29 +222,14 @@ export class SharedCallmuxSupervisor {
238
222
  this.backoffMs = Math.min(this.backoffMs * 2, this.timing.restartMaxMs ?? 30_000);
239
223
  this.restartTimer = this.deps.setTimeout(() => {
240
224
  this.restartTimer = null;
241
- this.spawn();
225
+ void this.open();
242
226
  }, delay);
243
227
  }
244
228
  }
245
229
 
246
230
  function defaultDeps(): SharedCallmuxSupervisorDeps {
247
231
  return {
248
- which: resolveCommand,
249
- spawn(command, args, opts) {
250
- const proc = Bun.spawn([command, ...args], {
251
- cwd: opts.cwd,
252
- env: opts.env,
253
- stdin: "ignore",
254
- stdout: "inherit",
255
- stderr: "inherit",
256
- });
257
- return {
258
- pid: proc.pid,
259
- exited: proc.exited,
260
- kill: (signal?: NodeJS.Signals) => proc.kill(signal),
261
- };
262
- },
263
- fetch,
232
+ createListener,
264
233
  setInterval: (fn, ms) => setInterval(fn, ms),
265
234
  clearInterval: (timer) => clearInterval(timer),
266
235
  setTimeout: (fn, ms) => setTimeout(fn, ms),
@@ -270,15 +239,11 @@ function defaultDeps(): SharedCallmuxSupervisorDeps {
270
239
  };
271
240
  }
272
241
 
273
- export function bundledCallmuxCommand(): string | null {
274
- const candidate = resolve(import.meta.dir, "../vendor/callmux/bin/callmux.js");
275
- return existsSync(candidate) ? candidate : null;
276
- }
277
-
278
- function resolveCommand(command: string): string | null {
279
- if (isAbsolute(command)) return existsSync(command) ? command : null;
280
- if (command.includes("/")) return existsSync(command) ? command : null;
281
- return Bun.which(command);
242
+ function mapCallmuxState(snapshot: ListenerHealthSnapshot): SharedCallmuxHealthSnapshot["state"] {
243
+ if (snapshot.state === "running") return "running";
244
+ if (snapshot.state === "starting" || snapshot.state === "reloading") return "starting";
245
+ if (snapshot.state === "stopped") return "stopped";
246
+ return "unhealthy";
282
247
  }
283
248
 
284
249
  function envOff(value: string | undefined): boolean {
@@ -292,12 +257,26 @@ function readJsonObject(path: string): Record<string, unknown> {
292
257
  return isRecord(parsed) ? parsed : {};
293
258
  }
294
259
 
295
- function cloneServer(value: unknown): Record<string, unknown> | undefined {
260
+ function cloneServer(value: unknown): CallmuxConfig["servers"][string] | undefined {
296
261
  if (!isRecord(value)) return undefined;
297
- return JSON.parse(JSON.stringify(value)) as Record<string, unknown>;
262
+ return JSON.parse(JSON.stringify(value)) as CallmuxConfig["servers"][string];
263
+ }
264
+
265
+ function withRuntimeEnv(config: CallmuxConfig, env: Record<string, string>): CallmuxConfig {
266
+ const envEntries = Object.entries(env).filter(([, value]) => typeof value === "string");
267
+ if (envEntries.length === 0) return config;
268
+ const inheritedEnv = Object.fromEntries(envEntries);
269
+ const servers = Object.fromEntries(Object.entries(config.servers).map(([name, server]) => {
270
+ if (!("command" in server)) return [name, server];
271
+ return [name, {
272
+ ...server,
273
+ env: { ...inheritedEnv, ...(isRecord(server.env) ? server.env as Record<string, string> : {}) },
274
+ }];
275
+ }));
276
+ return { ...config, servers };
298
277
  }
299
278
 
300
- function defaultTokenleanServer(): Record<string, unknown> {
279
+ function defaultTokenleanServer(): CallmuxConfig["servers"][string] {
301
280
  return {
302
281
  command: "tl-mcp",
303
282
  prefix: "",
@@ -306,7 +285,7 @@ function defaultTokenleanServer(): Record<string, unknown> {
306
285
  };
307
286
  }
308
287
 
309
- function defaultGithubServer(): Record<string, unknown> {
288
+ function defaultGithubServer(): CallmuxConfig["servers"][string] {
310
289
  return {
311
290
  command: "github-mcp-server",
312
291
  args: ["stdio"],
@@ -331,6 +310,11 @@ function stringFromRecord(value: Record<string, unknown>, key: string): string |
331
310
  return typeof value[key] === "string" ? value[key] : undefined;
332
311
  }
333
312
 
313
+ function outputFormatFromRecord(value: Record<string, unknown>, key: string): CallmuxConfig["outputFormat"] | undefined {
314
+ const outputFormat = stringFromRecord(value, key);
315
+ return outputFormat === "auto" || outputFormat === "json" || outputFormat === "toon" ? outputFormat : undefined;
316
+ }
317
+
334
318
  function parseListenerUrl(value: string): { hostname: string; port: number } | undefined {
335
319
  try {
336
320
  const url = new URL(value);