@rk0429/agentic-relay 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +14 -5
  2. package/dist/relay.mjs +1628 -685
  3. package/package.json +3 -1
package/dist/relay.mjs CHANGED
@@ -1,27 +1,16 @@
1
1
  #!/usr/bin/env node
2
-
3
- // src/bin/relay.ts
4
- import { defineCommand as defineCommand10, runMain } from "citty";
5
- import { join as join5 } from "path";
6
- import { homedir as homedir3 } from "os";
7
-
8
- // src/infrastructure/process-manager.ts
9
- import { execa } from "execa";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropNames = Object.getOwnPropertyNames;
4
+ var __esm = (fn, res) => function __init() {
5
+ return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
6
+ };
7
+ var __export = (target, all) => {
8
+ for (var name in all)
9
+ __defProp(target, name, { get: all[name], enumerable: true });
10
+ };
10
11
 
11
12
  // src/infrastructure/logger.ts
12
13
  import { createConsola } from "consola";
13
- var LOG_LEVEL_MAP = {
14
- silent: -1,
15
- fatal: 0,
16
- error: 0,
17
- warn: 1,
18
- log: 2,
19
- info: 3,
20
- success: 3,
21
- debug: 4,
22
- trace: 5,
23
- verbose: 5
24
- };
25
14
  function resolveLogLevel() {
26
15
  const envLevel = process.env["RELAY_LOG_LEVEL"]?.toLowerCase();
27
16
  if (envLevel && envLevel in LOG_LEVEL_MAP) {
@@ -29,129 +18,671 @@ function resolveLogLevel() {
29
18
  }
30
19
  return 3;
31
20
  }
32
- var logger = createConsola({
33
- level: resolveLogLevel(),
34
- defaults: {
35
- tag: "relay"
21
+ var LOG_LEVEL_MAP, logger;
22
+ var init_logger = __esm({
23
+ "src/infrastructure/logger.ts"() {
24
+ "use strict";
25
+ LOG_LEVEL_MAP = {
26
+ silent: -1,
27
+ fatal: 0,
28
+ error: 0,
29
+ warn: 1,
30
+ log: 2,
31
+ info: 3,
32
+ success: 3,
33
+ debug: 4,
34
+ trace: 5,
35
+ verbose: 5
36
+ };
37
+ logger = createConsola({
38
+ level: resolveLogLevel(),
39
+ defaults: {
40
+ tag: "relay"
41
+ }
42
+ });
36
43
  }
37
44
  });
38
45
 
39
- // src/infrastructure/process-manager.ts
40
- var ProcessManager = class {
41
- activeProcesses = /* @__PURE__ */ new Set();
42
- constructor() {
43
- this.setupSignalHandlers();
44
- }
45
- async spawnInteractive(command, args, options) {
46
- logger.debug(`Spawning interactive: ${command} ${args.join(" ")}`);
47
- const execaOptions = {
48
- stdio: "inherit",
49
- cwd: options?.cwd,
50
- env: options?.env,
51
- extendEnv: options?.env ? false : true,
52
- timeout: options?.timeout,
53
- reject: false
46
+ // src/mcp-server/recursion-guard.ts
47
+ import { createHash } from "crypto";
48
+ var RecursionGuard;
49
+ var init_recursion_guard = __esm({
50
+ "src/mcp-server/recursion-guard.ts"() {
51
+ "use strict";
52
+ RecursionGuard = class _RecursionGuard {
53
+ constructor(config = {
54
+ maxDepth: 5,
55
+ maxCallsPerSession: 20,
56
+ timeoutSec: 300
57
+ }) {
58
+ this.config = config;
59
+ }
60
+ static MAX_ENTRIES = 1e3;
61
+ callCounts = /* @__PURE__ */ new Map();
62
+ promptHashes = /* @__PURE__ */ new Map();
63
+ /** Remove entries older than timeoutSec and enforce size limits */
64
+ cleanup() {
65
+ const now = Date.now();
66
+ const ttlMs = this.config.timeoutSec * 1e3;
67
+ for (const [key, value] of this.callCounts) {
68
+ if (now - value.createdAt > ttlMs) {
69
+ this.callCounts.delete(key);
70
+ }
71
+ }
72
+ for (const [key, value] of this.promptHashes) {
73
+ if (now - value.createdAt > ttlMs) {
74
+ this.promptHashes.delete(key);
75
+ }
76
+ }
77
+ if (this.callCounts.size > _RecursionGuard.MAX_ENTRIES) {
78
+ const sorted = [...this.callCounts.entries()].sort(
79
+ (a, b) => a[1].createdAt - b[1].createdAt
80
+ );
81
+ for (let i = 0; i < sorted.length - _RecursionGuard.MAX_ENTRIES; i++) {
82
+ this.callCounts.delete(sorted[i][0]);
83
+ }
84
+ }
85
+ if (this.promptHashes.size > _RecursionGuard.MAX_ENTRIES) {
86
+ const sorted = [...this.promptHashes.entries()].sort(
87
+ (a, b) => a[1].createdAt - b[1].createdAt
88
+ );
89
+ for (let i = 0; i < sorted.length - _RecursionGuard.MAX_ENTRIES; i++) {
90
+ this.promptHashes.delete(sorted[i][0]);
91
+ }
92
+ }
93
+ }
94
+ /** Check if a spawn is allowed */
95
+ canSpawn(context) {
96
+ this.cleanup();
97
+ if (context.depth >= this.config.maxDepth) {
98
+ return {
99
+ allowed: false,
100
+ reason: `Max depth exceeded: ${context.depth} >= ${this.config.maxDepth}`
101
+ };
102
+ }
103
+ const currentCount = this.callCounts.get(context.traceId)?.count ?? 0;
104
+ if (currentCount >= this.config.maxCallsPerSession) {
105
+ return {
106
+ allowed: false,
107
+ reason: `Max calls per session exceeded: ${currentCount} >= ${this.config.maxCallsPerSession}`
108
+ };
109
+ }
110
+ if (this.detectLoop(context.traceId, context.backend, context.promptHash)) {
111
+ return {
112
+ allowed: false,
113
+ reason: `Loop detected: same (backend=${context.backend}, promptHash=${context.promptHash}) appeared 3+ times in trace ${context.traceId}`
114
+ };
115
+ }
116
+ return { allowed: true };
117
+ }
118
+ /** Record a spawn invocation */
119
+ recordSpawn(context) {
120
+ this.cleanup();
121
+ const entry = this.callCounts.get(context.traceId);
122
+ if (entry) {
123
+ entry.count += 1;
124
+ } else {
125
+ this.callCounts.set(context.traceId, { count: 1, createdAt: Date.now() });
126
+ }
127
+ const key = `${context.backend}:${context.promptHash}`;
128
+ const hashEntry = this.promptHashes.get(context.traceId);
129
+ if (hashEntry) {
130
+ hashEntry.hashes.push(key);
131
+ } else {
132
+ this.promptHashes.set(context.traceId, { hashes: [key], createdAt: Date.now() });
133
+ }
134
+ }
135
+ /** Detect if the same (backend + promptHash) combination has appeared 3+ times */
136
+ detectLoop(traceId, backend, promptHash) {
137
+ const key = `${backend}:${promptHash}`;
138
+ const hashes = this.promptHashes.get(traceId)?.hashes ?? [];
139
+ const count = hashes.filter((h) => h === key).length;
140
+ return count >= 3;
141
+ }
142
+ /** Get current config (for testing/inspection) */
143
+ getConfig() {
144
+ return { ...this.config };
145
+ }
146
+ /** Get call count for a trace */
147
+ getCallCount(traceId) {
148
+ return this.callCounts.get(traceId)?.count ?? 0;
149
+ }
150
+ /** Utility: compute a prompt hash */
151
+ static hashPrompt(prompt) {
152
+ return createHash("sha256").update(prompt).digest("hex").slice(0, 16);
153
+ }
54
154
  };
55
- const proc = execa(command, args, execaOptions);
56
- this.activeProcesses.add(proc);
57
- try {
58
- await proc;
59
- } finally {
60
- this.activeProcesses.delete(proc);
61
- }
62
155
  }
63
- async execute(command, args, options) {
64
- logger.debug(`Executing: ${command} ${args.join(" ")}`);
65
- const stdinMode = options?.stdinMode ?? "pipe";
66
- const stdio = stdinMode === "pipe" ? "pipe" : [stdinMode, "pipe", "pipe"];
67
- const execaOptions = {
68
- stdio,
69
- cwd: options?.cwd,
70
- env: options?.env,
71
- extendEnv: options?.env ? false : true,
72
- timeout: options?.timeout,
73
- reject: false
156
+ });
157
+
158
+ // src/mcp-server/tools/spawn-agent.ts
159
+ import { z as z2 } from "zod";
160
+ import { nanoid as nanoid2 } from "nanoid";
161
+ function buildContextFromEnv() {
162
+ const traceId = process.env["RELAY_TRACE_ID"] ?? `trace-${nanoid2()}`;
163
+ const parentSessionId = process.env["RELAY_PARENT_SESSION_ID"] ?? null;
164
+ const depth = Number(process.env["RELAY_DEPTH"] ?? "0");
165
+ return { traceId, parentSessionId, depth };
166
+ }
167
+ async function executeSpawnAgent(input, registry2, sessionManager2, guard, hooksEngine2, contextMonitor2) {
168
+ const envContext = buildContextFromEnv();
169
+ const promptHash = RecursionGuard.hashPrompt(input.prompt);
170
+ const context = {
171
+ traceId: envContext.traceId,
172
+ depth: envContext.depth,
173
+ backend: input.backend,
174
+ promptHash
175
+ };
176
+ const guardResult = guard.canSpawn(context);
177
+ if (!guardResult.allowed) {
178
+ logger.warn(`Spawn blocked by RecursionGuard: ${guardResult.reason}`);
179
+ return {
180
+ sessionId: "",
181
+ exitCode: 1,
182
+ stdout: "",
183
+ stderr: `Spawn blocked: ${guardResult.reason}`
74
184
  };
75
- const proc = execa(command, args, execaOptions);
76
- this.activeProcesses.add(proc);
77
- try {
78
- const result = await proc;
79
- return {
80
- exitCode: result.exitCode ?? 1,
81
- stdout: result.stdout?.toString() ?? "",
82
- stderr: result.stderr?.toString() ?? ""
83
- };
84
- } finally {
85
- this.activeProcesses.delete(proc);
86
- }
87
185
  }
88
- async executeWithInput(command, args, stdinData, options) {
89
- logger.debug(`Executing with stdin: ${command} ${args.join(" ")}`);
90
- const execaOptions = {
91
- stdio: "pipe",
92
- input: stdinData,
93
- cwd: options?.cwd,
94
- env: options?.env,
95
- timeout: options?.timeout,
96
- reject: false
186
+ const adapter = registry2.get(input.backend);
187
+ const installed = await adapter.isInstalled();
188
+ if (!installed) {
189
+ return {
190
+ sessionId: "",
191
+ exitCode: 1,
192
+ stdout: "",
193
+ stderr: `Backend "${input.backend}" is not installed`
97
194
  };
98
- const proc = execa(command, args, execaOptions);
99
- this.activeProcesses.add(proc);
195
+ }
196
+ const session = await sessionManager2.create({
197
+ backendId: input.backend,
198
+ parentSessionId: envContext.parentSessionId ?? void 0,
199
+ depth: envContext.depth + 1
200
+ });
201
+ if (hooksEngine2) {
100
202
  try {
101
- const result = await proc;
102
- return {
103
- exitCode: result.exitCode ?? 1,
104
- stdout: result.stdout?.toString() ?? "",
105
- stderr: result.stderr?.toString() ?? ""
203
+ const hookInput = {
204
+ event: "pre-spawn",
205
+ sessionId: session.relaySessionId,
206
+ backendId: input.backend,
207
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
208
+ data: {
209
+ prompt: input.prompt,
210
+ agent: input.agent,
211
+ model: input.model
212
+ }
106
213
  };
107
- } finally {
108
- this.activeProcesses.delete(proc);
214
+ await hooksEngine2.emit("pre-spawn", hookInput);
215
+ } catch (error) {
216
+ logger.debug(
217
+ `pre-spawn hook error: ${error instanceof Error ? error.message : String(error)}`
218
+ );
109
219
  }
110
220
  }
111
- setupSignalHandlers() {
112
- const handleSignal = (signal) => {
113
- logger.debug(`Received ${signal}, forwarding to child processes`);
114
- for (const proc of this.activeProcesses) {
115
- proc.kill(signal);
221
+ try {
222
+ const result = await adapter.execute({
223
+ prompt: input.prompt,
224
+ agent: input.agent,
225
+ model: input.model,
226
+ maxTurns: input.maxTurns,
227
+ resume: input.resumeSessionId,
228
+ mcpContext: {
229
+ parentSessionId: session.relaySessionId,
230
+ depth: envContext.depth + 1,
231
+ maxDepth: guard.getConfig().maxDepth,
232
+ traceId: envContext.traceId
116
233
  }
117
- };
118
- process.on("SIGINT", () => handleSignal("SIGINT"));
119
- process.on("SIGTERM", () => handleSignal("SIGTERM"));
120
- }
121
- };
122
-
123
- // src/adapters/adapter-registry.ts
124
- var AdapterRegistry = class {
125
- adapters = /* @__PURE__ */ new Map();
126
- register(adapter) {
127
- this.adapters.set(adapter.id, adapter);
128
- }
129
- get(id) {
130
- const adapter = this.adapters.get(id);
131
- if (!adapter) {
132
- throw new Error(
133
- `Backend "${id}" is not registered. Available: ${[...this.adapters.keys()].join(", ")}`
234
+ });
235
+ if (contextMonitor2) {
236
+ const estimatedTokens = Math.ceil(
237
+ (result.stdout.length + result.stderr.length) / 4
238
+ );
239
+ const maxTokens = input.backend === "gemini" ? 128e3 : 2e5;
240
+ contextMonitor2.updateUsage(
241
+ session.relaySessionId,
242
+ input.backend,
243
+ estimatedTokens,
244
+ maxTokens
134
245
  );
135
246
  }
136
- return adapter;
137
- }
138
- has(id) {
139
- return this.adapters.has(id);
247
+ guard.recordSpawn(context);
248
+ const status = result.exitCode === 0 ? "completed" : "error";
249
+ await sessionManager2.update(session.relaySessionId, { status });
250
+ if (hooksEngine2) {
251
+ try {
252
+ const hookInput = {
253
+ event: "post-spawn",
254
+ sessionId: session.relaySessionId,
255
+ backendId: input.backend,
256
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
257
+ data: {
258
+ exitCode: result.exitCode,
259
+ status
260
+ }
261
+ };
262
+ await hooksEngine2.emit("post-spawn", hookInput);
263
+ } catch (hookError) {
264
+ logger.debug(
265
+ `post-spawn hook error: ${hookError instanceof Error ? hookError.message : String(hookError)}`
266
+ );
267
+ }
268
+ }
269
+ return {
270
+ sessionId: session.relaySessionId,
271
+ exitCode: result.exitCode,
272
+ stdout: result.stdout,
273
+ stderr: result.stderr
274
+ };
275
+ } catch (error) {
276
+ await sessionManager2.update(session.relaySessionId, { status: "error" });
277
+ const message = error instanceof Error ? error.message : String(error);
278
+ return {
279
+ sessionId: session.relaySessionId,
280
+ exitCode: 1,
281
+ stdout: "",
282
+ stderr: message
283
+ };
140
284
  }
141
- list() {
142
- return [...this.adapters.values()];
285
+ }
286
+ var spawnAgentInputSchema;
287
+ var init_spawn_agent = __esm({
288
+ "src/mcp-server/tools/spawn-agent.ts"() {
289
+ "use strict";
290
+ init_recursion_guard();
291
+ init_logger();
292
+ spawnAgentInputSchema = z2.object({
293
+ backend: z2.enum(["claude", "codex", "gemini"]),
294
+ prompt: z2.string(),
295
+ agent: z2.string().optional(),
296
+ resumeSessionId: z2.string().optional(),
297
+ model: z2.string().optional(),
298
+ maxTurns: z2.number().optional()
299
+ });
143
300
  }
144
- };
301
+ });
145
302
 
146
- // src/adapters/base-adapter.ts
147
- var BaseAdapter = class {
148
- constructor(processManager2) {
149
- this.processManager = processManager2;
150
- }
151
- async isInstalled() {
152
- try {
153
- const result = await this.processManager.execute("which", [this.command]);
154
- return result.exitCode === 0;
303
+ // src/mcp-server/tools/list-sessions.ts
304
+ import { z as z3 } from "zod";
305
+ async function executeListSessions(input, sessionManager2) {
306
+ const sessions = await sessionManager2.list({
307
+ backendId: input.backend,
308
+ limit: input.limit
309
+ });
310
+ return {
311
+ sessions: sessions.map((s) => ({
312
+ relaySessionId: s.relaySessionId,
313
+ backendId: s.backendId,
314
+ status: s.status,
315
+ createdAt: s.createdAt.toISOString()
316
+ }))
317
+ };
318
+ }
319
+ var listSessionsInputSchema;
320
+ var init_list_sessions = __esm({
321
+ "src/mcp-server/tools/list-sessions.ts"() {
322
+ "use strict";
323
+ listSessionsInputSchema = z3.object({
324
+ backend: z3.enum(["claude", "codex", "gemini"]).optional(),
325
+ limit: z3.number().optional().default(10)
326
+ });
327
+ }
328
+ });
329
+
330
+ // src/mcp-server/tools/get-context-status.ts
331
+ import { z as z4 } from "zod";
332
+ async function executeGetContextStatus(input, sessionManager2, contextMonitor2) {
333
+ const session = await sessionManager2.get(input.sessionId);
334
+ if (!session) {
335
+ throw new Error(`Session not found: ${input.sessionId}`);
336
+ }
337
+ if (contextMonitor2) {
338
+ const usage = contextMonitor2.getUsage(input.sessionId);
339
+ if (usage) {
340
+ return {
341
+ sessionId: input.sessionId,
342
+ usagePercent: usage.usagePercent,
343
+ isEstimated: usage.isEstimated
344
+ };
345
+ }
346
+ }
347
+ return {
348
+ sessionId: input.sessionId,
349
+ usagePercent: 0,
350
+ isEstimated: true
351
+ };
352
+ }
353
+ var getContextStatusInputSchema;
354
+ var init_get_context_status = __esm({
355
+ "src/mcp-server/tools/get-context-status.ts"() {
356
+ "use strict";
357
+ getContextStatusInputSchema = z4.object({
358
+ sessionId: z4.string()
359
+ });
360
+ }
361
+ });
362
+
363
+ // src/mcp-server/server.ts
364
+ var server_exports = {};
365
+ __export(server_exports, {
366
+ RelayMCPServer: () => RelayMCPServer
367
+ });
368
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
369
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
370
+ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
371
+ import { createServer } from "http";
372
+ import { randomUUID } from "crypto";
373
+ import { z as z5 } from "zod";
374
+ var RelayMCPServer;
375
+ var init_server = __esm({
376
+ "src/mcp-server/server.ts"() {
377
+ "use strict";
378
+ init_recursion_guard();
379
+ init_spawn_agent();
380
+ init_list_sessions();
381
+ init_get_context_status();
382
+ init_logger();
383
+ RelayMCPServer = class {
384
+ constructor(registry2, sessionManager2, guardConfig, hooksEngine2, contextMonitor2) {
385
+ this.registry = registry2;
386
+ this.sessionManager = sessionManager2;
387
+ this.hooksEngine = hooksEngine2;
388
+ this.contextMonitor = contextMonitor2;
389
+ this.guard = new RecursionGuard(guardConfig);
390
+ this.server = new McpServer({
391
+ name: "agentic-relay",
392
+ version: "0.4.0"
393
+ });
394
+ this.registerTools();
395
+ }
396
+ server;
397
+ guard;
398
+ registerTools() {
399
+ this.server.tool(
400
+ "spawn_agent",
401
+ "Spawn a sub-agent on the specified backend CLI (Claude Code, Codex CLI, or Gemini CLI). The agent executes the given prompt in non-interactive mode and returns the result.",
402
+ {
403
+ backend: z5.enum(["claude", "codex", "gemini"]),
404
+ prompt: z5.string(),
405
+ agent: z5.string().optional(),
406
+ resumeSessionId: z5.string().optional(),
407
+ model: z5.string().optional(),
408
+ maxTurns: z5.number().optional()
409
+ },
410
+ async (params) => {
411
+ try {
412
+ const result = await executeSpawnAgent(
413
+ params,
414
+ this.registry,
415
+ this.sessionManager,
416
+ this.guard,
417
+ this.hooksEngine,
418
+ this.contextMonitor
419
+ );
420
+ const isError = result.exitCode !== 0;
421
+ const text = isError ? `Error (exit ${result.exitCode}): ${result.stderr || result.stdout}` : `Session: ${result.sessionId}
422
+
423
+ ${result.stdout}`;
424
+ return {
425
+ content: [{ type: "text", text }],
426
+ isError
427
+ };
428
+ } catch (error) {
429
+ const message = error instanceof Error ? error.message : String(error);
430
+ return {
431
+ content: [{ type: "text", text: `Error: ${message}` }],
432
+ isError: true
433
+ };
434
+ }
435
+ }
436
+ );
437
+ this.server.tool(
438
+ "list_sessions",
439
+ "List relay sessions, optionally filtered by backend.",
440
+ {
441
+ backend: z5.enum(["claude", "codex", "gemini"]).optional(),
442
+ limit: z5.number().optional()
443
+ },
444
+ async (params) => {
445
+ try {
446
+ const result = await executeListSessions(
447
+ { backend: params.backend, limit: params.limit ?? 10 },
448
+ this.sessionManager
449
+ );
450
+ return {
451
+ content: [
452
+ {
453
+ type: "text",
454
+ text: JSON.stringify(result, null, 2)
455
+ }
456
+ ]
457
+ };
458
+ } catch (error) {
459
+ const message = error instanceof Error ? error.message : String(error);
460
+ return {
461
+ content: [{ type: "text", text: `Error: ${message}` }],
462
+ isError: true
463
+ };
464
+ }
465
+ }
466
+ );
467
+ this.server.tool(
468
+ "get_context_status",
469
+ "Get the context usage status of a relay session. Returns usage data from ContextMonitor when available, otherwise estimated values.",
470
+ {
471
+ sessionId: z5.string()
472
+ },
473
+ async (params) => {
474
+ try {
475
+ const result = await executeGetContextStatus(
476
+ params,
477
+ this.sessionManager,
478
+ this.contextMonitor
479
+ );
480
+ return {
481
+ content: [
482
+ {
483
+ type: "text",
484
+ text: JSON.stringify(result, null, 2)
485
+ }
486
+ ]
487
+ };
488
+ } catch (error) {
489
+ const message = error instanceof Error ? error.message : String(error);
490
+ return {
491
+ content: [{ type: "text", text: `Error: ${message}` }],
492
+ isError: true
493
+ };
494
+ }
495
+ }
496
+ );
497
+ }
498
+ async start(options) {
499
+ const transportType = options?.transport ?? "stdio";
500
+ if (transportType === "stdio") {
501
+ logger.info("Starting agentic-relay MCP server (stdio transport)...");
502
+ const transport = new StdioServerTransport();
503
+ await this.server.connect(transport);
504
+ return;
505
+ }
506
+ const port = options?.port ?? 3100;
507
+ logger.info(
508
+ `Starting agentic-relay MCP server (HTTP transport on port ${port})...`
509
+ );
510
+ const httpTransport = new StreamableHTTPServerTransport({
511
+ sessionIdGenerator: () => randomUUID()
512
+ });
513
+ const httpServer = createServer(async (req, res) => {
514
+ const url = req.url ?? "";
515
+ if (url === "/mcp" || url.startsWith("/mcp?")) {
516
+ await httpTransport.handleRequest(req, res);
517
+ } else {
518
+ res.writeHead(404, { "Content-Type": "text/plain" });
519
+ res.end("Not found");
520
+ }
521
+ });
522
+ this._httpServer = httpServer;
523
+ await this.server.connect(httpTransport);
524
+ await new Promise((resolve) => {
525
+ httpServer.listen(port, () => {
526
+ logger.info(`MCP server listening on http://localhost:${port}/mcp`);
527
+ resolve();
528
+ });
529
+ });
530
+ await new Promise((resolve) => {
531
+ httpServer.on("close", resolve);
532
+ });
533
+ }
534
+ /** Exposed for testing and graceful shutdown */
535
+ get httpServer() {
536
+ return this._httpServer;
537
+ }
538
+ _httpServer;
539
+ };
540
+ }
541
+ });
542
+
543
+ // src/bin/relay.ts
544
+ import { defineCommand as defineCommand10, runMain } from "citty";
545
+ import { join as join8 } from "path";
546
+ import { homedir as homedir6 } from "os";
547
+
548
+ // src/infrastructure/process-manager.ts
549
+ init_logger();
550
+ import { execa } from "execa";
551
+ var ProcessManager = class {
552
+ activeProcesses = /* @__PURE__ */ new Set();
553
+ constructor() {
554
+ this.setupSignalHandlers();
555
+ }
556
+ async spawnInteractive(command, args, options) {
557
+ logger.debug(`Spawning interactive: ${command} ${args.join(" ")}`);
558
+ const execaOptions = {
559
+ stdio: "inherit",
560
+ cwd: options?.cwd,
561
+ env: options?.env,
562
+ extendEnv: options?.env ? false : true,
563
+ timeout: options?.timeout,
564
+ reject: false
565
+ };
566
+ const proc = execa(command, args, execaOptions);
567
+ this.activeProcesses.add(proc);
568
+ try {
569
+ await proc;
570
+ } finally {
571
+ this.activeProcesses.delete(proc);
572
+ }
573
+ }
574
+ async execute(command, args, options) {
575
+ logger.debug(`Executing: ${command} ${args.join(" ")}`);
576
+ const stdinMode = options?.stdinMode ?? "pipe";
577
+ const stdio = stdinMode === "pipe" ? "pipe" : [stdinMode, "pipe", "pipe"];
578
+ const execaOptions = {
579
+ stdio,
580
+ cwd: options?.cwd,
581
+ env: options?.env,
582
+ extendEnv: options?.env ? false : true,
583
+ timeout: options?.timeout,
584
+ reject: false
585
+ };
586
+ const proc = execa(command, args, execaOptions);
587
+ this.activeProcesses.add(proc);
588
+ try {
589
+ const result = await proc;
590
+ return {
591
+ exitCode: result.exitCode ?? 1,
592
+ stdout: result.stdout?.toString() ?? "",
593
+ stderr: result.stderr?.toString() ?? ""
594
+ };
595
+ } finally {
596
+ this.activeProcesses.delete(proc);
597
+ }
598
+ }
599
+ async executeWithInput(command, args, stdinData, options) {
600
+ logger.debug(`Executing with stdin: ${command} ${args.join(" ")}`);
601
+ const execaOptions = {
602
+ stdio: "pipe",
603
+ input: stdinData,
604
+ cwd: options?.cwd,
605
+ env: options?.env,
606
+ timeout: options?.timeout,
607
+ reject: false
608
+ };
609
+ const proc = execa(command, args, execaOptions);
610
+ this.activeProcesses.add(proc);
611
+ try {
612
+ const result = await proc;
613
+ return {
614
+ exitCode: result.exitCode ?? 1,
615
+ stdout: result.stdout?.toString() ?? "",
616
+ stderr: result.stderr?.toString() ?? ""
617
+ };
618
+ } finally {
619
+ this.activeProcesses.delete(proc);
620
+ }
621
+ }
622
+ setupSignalHandlers() {
623
+ const handleSignal = (signal) => {
624
+ logger.debug(`Received ${signal}, forwarding to child processes`);
625
+ for (const proc of this.activeProcesses) {
626
+ proc.kill(signal);
627
+ }
628
+ };
629
+ process.on("SIGINT", () => handleSignal("SIGINT"));
630
+ process.on("SIGTERM", () => handleSignal("SIGTERM"));
631
+ }
632
+ };
633
+
634
+ // src/adapters/adapter-registry.ts
635
+ var AdapterRegistry = class {
636
+ adapters = /* @__PURE__ */ new Map();
637
+ factories = /* @__PURE__ */ new Map();
638
+ register(adapter) {
639
+ this.factories.delete(adapter.id);
640
+ this.adapters.set(adapter.id, adapter);
641
+ }
642
+ registerLazy(id, factory) {
643
+ this.adapters.delete(id);
644
+ this.factories.set(id, factory);
645
+ }
646
+ get(id) {
647
+ let adapter = this.adapters.get(id);
648
+ if (!adapter) {
649
+ const factory = this.factories.get(id);
650
+ if (factory) {
651
+ adapter = factory();
652
+ this.adapters.set(id, adapter);
653
+ this.factories.delete(id);
654
+ }
655
+ }
656
+ if (!adapter) {
657
+ const allIds = [...this.adapters.keys(), ...this.factories.keys()];
658
+ throw new Error(
659
+ `Backend "${id}" is not registered. Available: ${allIds.join(", ")}`
660
+ );
661
+ }
662
+ return adapter;
663
+ }
664
+ has(id) {
665
+ return this.adapters.has(id) || this.factories.has(id);
666
+ }
667
+ list() {
668
+ for (const [id, factory] of this.factories) {
669
+ this.adapters.set(id, factory());
670
+ }
671
+ this.factories.clear();
672
+ return [...this.adapters.values()];
673
+ }
674
+ };
675
+
676
+ // src/adapters/base-adapter.ts
677
+ init_logger();
678
+ var BaseAdapter = class {
679
+ constructor(processManager2) {
680
+ this.processManager = processManager2;
681
+ }
682
+ async isInstalled() {
683
+ try {
684
+ const result = await this.processManager.execute("which", [this.command]);
685
+ return result.exitCode === 0;
155
686
  } catch {
156
687
  return false;
157
688
  }
@@ -250,10 +781,13 @@ function mapCommonToNative(backendId, flags) {
250
781
  }
251
782
 
252
783
  // src/adapters/claude-adapter.ts
253
- import {
254
- query,
255
- listSessions
256
- } from "@anthropic-ai/claude-agent-sdk";
784
+ init_logger();
785
+ import { readFile, writeFile, mkdir } from "fs/promises";
786
+ import { homedir } from "os";
787
+ import { join, dirname } from "path";
788
+ async function loadClaudeSDK() {
789
+ return await import("@anthropic-ai/claude-agent-sdk");
790
+ }
257
791
  var CLAUDE_NESTING_ENV_VARS = [
258
792
  "CLAUDECODE",
259
793
  "CLAUDE_CODE_SSE_PORT",
@@ -262,6 +796,9 @@ var CLAUDE_NESTING_ENV_VARS = [
262
796
  var ClaudeAdapter = class extends BaseAdapter {
263
797
  id = "claude";
264
798
  command = "claude";
799
+ getConfigPath() {
800
+ return join(homedir(), ".claude.json");
801
+ }
265
802
  mapFlags(flags) {
266
803
  return {
267
804
  args: mapCommonToNative("claude", flags)
@@ -273,22 +810,30 @@ var ClaudeAdapter = class extends BaseAdapter {
273
810
  env: this.buildCleanEnv(flags)
274
811
  });
275
812
  }
813
+ getPermissionMode() {
814
+ return process.env["RELAY_CLAUDE_PERMISSION_MODE"] === "default" ? "default" : "bypassPermissions";
815
+ }
276
816
  async execute(flags) {
277
817
  if (!flags.prompt) {
278
818
  throw new Error("execute requires a prompt (-p flag)");
279
819
  }
280
820
  const env = this.buildCleanEnv(flags);
821
+ const permissionMode = this.getPermissionMode();
281
822
  try {
823
+ const { query } = await loadClaudeSDK();
824
+ const options = {
825
+ env,
826
+ cwd: process.cwd(),
827
+ ...flags.model ? { model: flags.model } : {},
828
+ ...flags.maxTurns ? { maxTurns: flags.maxTurns } : {}
829
+ };
830
+ if (permissionMode === "bypassPermissions") {
831
+ options.permissionMode = "bypassPermissions";
832
+ options.allowDangerouslySkipPermissions = true;
833
+ }
282
834
  const q = query({
283
835
  prompt: flags.prompt,
284
- options: {
285
- env,
286
- cwd: process.cwd(),
287
- ...flags.model ? { model: flags.model } : {},
288
- ...flags.maxTurns ? { maxTurns: flags.maxTurns } : {},
289
- permissionMode: "bypassPermissions",
290
- allowDangerouslySkipPermissions: true
291
- }
836
+ options
292
837
  });
293
838
  let resultText = "";
294
839
  let sessionId = "";
@@ -309,7 +854,8 @@ var ClaudeAdapter = class extends BaseAdapter {
309
854
  return {
310
855
  exitCode: isError ? 1 : 0,
311
856
  stdout: resultText,
312
- stderr: errorMessages.join("\n")
857
+ stderr: errorMessages.join("\n"),
858
+ ...sessionId ? { nativeSessionId: sessionId } : {}
313
859
  };
314
860
  } catch (error) {
315
861
  return {
@@ -319,6 +865,83 @@ var ClaudeAdapter = class extends BaseAdapter {
319
865
  };
320
866
  }
321
867
  }
868
+ async *executeStreaming(flags) {
869
+ if (!flags.prompt) {
870
+ throw new Error("executeStreaming requires a prompt (-p flag)");
871
+ }
872
+ const env = this.buildCleanEnv(flags);
873
+ const permissionMode = this.getPermissionMode();
874
+ try {
875
+ const { query } = await loadClaudeSDK();
876
+ const options = {
877
+ env,
878
+ cwd: process.cwd(),
879
+ ...flags.model ? { model: flags.model } : {},
880
+ ...flags.maxTurns ? { maxTurns: flags.maxTurns } : {}
881
+ };
882
+ if (permissionMode === "bypassPermissions") {
883
+ options.permissionMode = "bypassPermissions";
884
+ options.allowDangerouslySkipPermissions = true;
885
+ }
886
+ const q = query({
887
+ prompt: flags.prompt,
888
+ options
889
+ });
890
+ for await (const message of q) {
891
+ if (message.type === "assistant") {
892
+ const betaMessage = message.message;
893
+ if (betaMessage?.content) {
894
+ for (const block of betaMessage.content) {
895
+ if (block.type === "text" && block.text) {
896
+ yield { type: "text", text: block.text };
897
+ }
898
+ }
899
+ }
900
+ if (betaMessage?.usage) {
901
+ yield {
902
+ type: "usage",
903
+ inputTokens: betaMessage.usage.input_tokens ?? 0,
904
+ outputTokens: betaMessage.usage.output_tokens ?? 0
905
+ };
906
+ }
907
+ } else if (message.type === "tool_progress") {
908
+ const toolName = message.tool_name ?? "unknown";
909
+ yield {
910
+ type: "status",
911
+ message: `Running ${toolName}...`
912
+ };
913
+ } else if (message.type === "result") {
914
+ const nativeSessionId = message.session_id || void 0;
915
+ if (message.subtype === "success") {
916
+ const resultText = message.result;
917
+ yield {
918
+ type: "done",
919
+ result: { exitCode: 0, stdout: resultText, stderr: "" },
920
+ nativeSessionId
921
+ };
922
+ } else {
923
+ const errors = message.errors;
924
+ yield {
925
+ type: "done",
926
+ result: {
927
+ exitCode: 1,
928
+ stdout: "",
929
+ stderr: errors.join("\n")
930
+ },
931
+ nativeSessionId
932
+ };
933
+ }
934
+ }
935
+ }
936
+ } catch (error) {
937
+ const errorMessage = error instanceof Error ? error.message : String(error);
938
+ yield { type: "error", message: errorMessage };
939
+ yield {
940
+ type: "done",
941
+ result: { exitCode: 1, stdout: "", stderr: errorMessage }
942
+ };
943
+ }
944
+ }
322
945
  async resumeSession(sessionId, flags) {
323
946
  await this.processManager.spawnInteractive(
324
947
  this.command,
@@ -342,6 +965,7 @@ var ClaudeAdapter = class extends BaseAdapter {
342
965
  }
343
966
  async listNativeSessions() {
344
967
  try {
968
+ const { listSessions } = await loadClaudeSDK();
345
969
  const sessions = await listSessions({ limit: 20 });
346
970
  return sessions.map((s) => ({
347
971
  nativeId: s.sessionId,
@@ -367,13 +991,172 @@ var ClaudeAdapter = class extends BaseAdapter {
367
991
  logger.success(`Claude CLI updated: ${result.stdout.trim()}`);
368
992
  }
369
993
  }
370
- };
371
-
372
- // src/adapters/codex-adapter.ts
373
- import { Codex } from "@openai/codex-sdk";
994
+ async getMCPConfig() {
995
+ const configPath = this.getConfigPath();
996
+ try {
997
+ const raw = await readFile(configPath, "utf-8");
998
+ const config = JSON.parse(raw);
999
+ const mcpServers = config.mcpServers;
1000
+ if (!mcpServers || typeof mcpServers !== "object") {
1001
+ return [];
1002
+ }
1003
+ return Object.entries(mcpServers).map(([name, entry]) => {
1004
+ const server = {
1005
+ name,
1006
+ command: entry.command
1007
+ };
1008
+ if (entry.args) server.args = entry.args;
1009
+ if (entry.env) server.env = entry.env;
1010
+ return server;
1011
+ });
1012
+ } catch {
1013
+ return [];
1014
+ }
1015
+ }
1016
+ async setMCPConfig(servers) {
1017
+ const configPath = this.getConfigPath();
1018
+ let config = {};
1019
+ try {
1020
+ const raw = await readFile(configPath, "utf-8");
1021
+ config = JSON.parse(raw);
1022
+ } catch {
1023
+ }
1024
+ const mcpServers = {};
1025
+ for (const server of servers) {
1026
+ const entry = {
1027
+ command: server.command
1028
+ };
1029
+ if (server.args) entry.args = server.args;
1030
+ if (server.env) entry.env = server.env;
1031
+ const serverName = server.name ?? server.command;
1032
+ mcpServers[serverName] = entry;
1033
+ }
1034
+ config.mcpServers = mcpServers;
1035
+ await mkdir(dirname(configPath), { recursive: true });
1036
+ await writeFile(configPath, JSON.stringify(config, null, 2) + "\n", {
1037
+ mode: 384
1038
+ });
1039
+ }
1040
+ };
1041
+
1042
+ // src/adapters/codex-adapter.ts
1043
+ init_logger();
1044
+ import { readFile as readFile2, writeFile as writeFile2, mkdir as mkdir2 } from "fs/promises";
1045
+ import { homedir as homedir2 } from "os";
1046
+ import { join as join2, dirname as dirname2 } from "path";
1047
+ async function loadCodexSDK() {
1048
+ return await import("@openai/codex-sdk");
1049
+ }
1050
+ function parseTOMLMcpServers(content) {
1051
+ const lines = content.split("\n");
1052
+ const preambleLines = [];
1053
+ const postambleLines = [];
1054
+ const mcpServers = /* @__PURE__ */ new Map();
1055
+ let currentServerName = null;
1056
+ let currentServer = { command: "" };
1057
+ let inMcpSection = false;
1058
+ let pastMcpSections = false;
1059
+ for (const line of lines) {
1060
+ const tableMatch = line.match(/^\s*\[([^\]]+)\]\s*$/);
1061
+ if (tableMatch) {
1062
+ if (currentServerName !== null) {
1063
+ mcpServers.set(currentServerName, currentServer);
1064
+ currentServerName = null;
1065
+ }
1066
+ const tableName = tableMatch[1].trim();
1067
+ const mcpMatch = tableName.match(/^mcp_servers\.(.+)$/);
1068
+ if (mcpMatch) {
1069
+ inMcpSection = true;
1070
+ pastMcpSections = false;
1071
+ currentServerName = mcpMatch[1];
1072
+ currentServer = { command: "" };
1073
+ continue;
1074
+ } else {
1075
+ if (inMcpSection) {
1076
+ pastMcpSections = true;
1077
+ inMcpSection = false;
1078
+ }
1079
+ }
1080
+ }
1081
+ if (inMcpSection && currentServerName !== null) {
1082
+ const kvMatch = line.match(/^\s*(\w+)\s*=\s*(.+)$/);
1083
+ if (kvMatch) {
1084
+ const key = kvMatch[1];
1085
+ const rawValue = kvMatch[2].trim();
1086
+ if (key === "command") {
1087
+ currentServer.command = parseTOMLString(rawValue);
1088
+ } else if (key === "args") {
1089
+ currentServer.args = parseTOMLStringArray(rawValue);
1090
+ }
1091
+ }
1092
+ continue;
1093
+ }
1094
+ if (pastMcpSections || mcpServers.size > 0 && !inMcpSection && currentServerName === null && tableMatch) {
1095
+ postambleLines.push(line);
1096
+ } else if (!inMcpSection && currentServerName === null) {
1097
+ preambleLines.push(line);
1098
+ } else {
1099
+ postambleLines.push(line);
1100
+ }
1101
+ }
1102
+ if (currentServerName !== null) {
1103
+ mcpServers.set(currentServerName, currentServer);
1104
+ }
1105
+ return { preambleLines, postambleLines, mcpServers };
1106
+ }
1107
+ function parseTOMLString(raw) {
1108
+ const match = raw.match(/^"(.*)"$/);
1109
+ if (match) {
1110
+ return match[1].replace(/\\"/g, '"').replace(/\\\\/g, "\\");
1111
+ }
1112
+ const singleMatch = raw.match(/^'(.*)'$/);
1113
+ if (singleMatch) {
1114
+ return singleMatch[1];
1115
+ }
1116
+ return raw;
1117
+ }
1118
+ function parseTOMLStringArray(raw) {
1119
+ const match = raw.match(/^\[(.*)]\s*$/);
1120
+ if (!match) return [];
1121
+ const inner = match[1].trim();
1122
+ if (!inner) return [];
1123
+ const result = [];
1124
+ const regex = /"([^"\\]*(?:\\.[^"\\]*)*)"|'([^']*)'/g;
1125
+ let m;
1126
+ while ((m = regex.exec(inner)) !== null) {
1127
+ if (m[1] !== void 0) {
1128
+ result.push(m[1].replace(/\\"/g, '"').replace(/\\\\/g, "\\"));
1129
+ } else if (m[2] !== void 0) {
1130
+ result.push(m[2]);
1131
+ }
1132
+ }
1133
+ return result;
1134
+ }
1135
+ function toTOMLString(value) {
1136
+ return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
1137
+ }
1138
+ function toTOMLStringArray(values) {
1139
+ return `[${values.map(toTOMLString).join(", ")}]`;
1140
+ }
1141
+ function generateMcpServersTOML(servers) {
1142
+ const parts = [];
1143
+ for (const server of servers) {
1144
+ const serverName = server.name ?? server.command;
1145
+ parts.push(`[mcp_servers.${serverName}]`);
1146
+ parts.push(`command = ${toTOMLString(server.command)}`);
1147
+ if (server.args && server.args.length > 0) {
1148
+ parts.push(`args = ${toTOMLStringArray(server.args)}`);
1149
+ }
1150
+ parts.push("");
1151
+ }
1152
+ return parts.join("\n");
1153
+ }
374
1154
  var CodexAdapter = class extends BaseAdapter {
375
1155
  id = "codex";
376
1156
  command = "codex";
1157
+ getConfigPath() {
1158
+ return join2(homedir2(), ".codex", "config.toml");
1159
+ }
377
1160
  mapFlags(flags) {
378
1161
  const args = mapCommonToNative("codex", flags);
379
1162
  if (flags.outputFormat === "json") {
@@ -401,6 +1184,7 @@ var CodexAdapter = class extends BaseAdapter {
401
1184
  );
402
1185
  }
403
1186
  try {
1187
+ const { Codex } = await loadCodexSDK();
404
1188
  const codexOptions = {};
405
1189
  if (flags.mcpContext) {
406
1190
  codexOptions.env = {
@@ -430,6 +1214,107 @@ var CodexAdapter = class extends BaseAdapter {
430
1214
  };
431
1215
  }
432
1216
  }
1217
+ async *executeStreaming(flags) {
1218
+ if (!flags.prompt) {
1219
+ throw new Error("executeStreaming requires a prompt (-p flag)");
1220
+ }
1221
+ if (flags.agent) {
1222
+ logger.warn(
1223
+ `Codex CLI does not support --agent flag. Ignoring agent "${flags.agent}".`
1224
+ );
1225
+ }
1226
+ try {
1227
+ const { Codex } = await loadCodexSDK();
1228
+ const codexOptions = {};
1229
+ if (flags.mcpContext) {
1230
+ codexOptions.env = {
1231
+ ...process.env,
1232
+ RELAY_TRACE_ID: flags.mcpContext.traceId,
1233
+ RELAY_PARENT_SESSION_ID: flags.mcpContext.parentSessionId,
1234
+ RELAY_DEPTH: String(flags.mcpContext.depth)
1235
+ };
1236
+ }
1237
+ const codex = new Codex(codexOptions);
1238
+ const thread = codex.startThread({
1239
+ ...flags.model ? { model: flags.model } : {},
1240
+ workingDirectory: process.cwd(),
1241
+ approvalPolicy: "never"
1242
+ });
1243
+ const streamedTurn = await thread.runStreamed(flags.prompt);
1244
+ const completedMessages = [];
1245
+ for await (const event of streamedTurn.events) {
1246
+ if (event.type === "item.started") {
1247
+ const item = event.item;
1248
+ if (item?.type === "agent_message" && item.text) {
1249
+ yield { type: "text", text: item.text };
1250
+ } else if (item?.type === "command_execution") {
1251
+ yield {
1252
+ type: "tool_start",
1253
+ tool: item.command ?? "command",
1254
+ id: item.id ?? ""
1255
+ };
1256
+ } else if (item?.type === "file_change") {
1257
+ yield {
1258
+ type: "tool_start",
1259
+ tool: "file_change",
1260
+ id: item.id ?? ""
1261
+ };
1262
+ }
1263
+ } else if (event.type === "item.completed") {
1264
+ const item = event.item;
1265
+ if (item?.type === "agent_message" && item.text) {
1266
+ completedMessages.push(item.text);
1267
+ yield { type: "text", text: item.text };
1268
+ } else if (item?.type === "command_execution") {
1269
+ yield {
1270
+ type: "tool_end",
1271
+ tool: item.command ?? "command",
1272
+ id: item.id ?? "",
1273
+ result: item.aggregated_output
1274
+ };
1275
+ } else if (item?.type === "file_change") {
1276
+ yield {
1277
+ type: "tool_end",
1278
+ tool: "file_change",
1279
+ id: item.id ?? ""
1280
+ };
1281
+ }
1282
+ } else if (event.type === "turn.completed") {
1283
+ const usage = event.usage;
1284
+ if (usage) {
1285
+ yield {
1286
+ type: "usage",
1287
+ inputTokens: (usage.input_tokens ?? 0) + (usage.cached_input_tokens ?? 0),
1288
+ outputTokens: usage.output_tokens ?? 0
1289
+ };
1290
+ }
1291
+ const finalResponse = completedMessages.join("\n");
1292
+ yield {
1293
+ type: "done",
1294
+ result: { exitCode: 0, stdout: finalResponse, stderr: "" }
1295
+ };
1296
+ } else if (event.type === "turn.failed") {
1297
+ const errorMessage = event.error?.message ?? "Turn failed";
1298
+ yield {
1299
+ type: "done",
1300
+ result: { exitCode: 1, stdout: "", stderr: errorMessage }
1301
+ };
1302
+ } else if (event.type === "error") {
1303
+ yield {
1304
+ type: "error",
1305
+ message: event.message ?? "Unknown error"
1306
+ };
1307
+ }
1308
+ }
1309
+ } catch (error) {
1310
+ const errorMessage = error instanceof Error ? error.message : String(error);
1311
+ yield { type: "error", message: errorMessage };
1312
+ yield {
1313
+ type: "done",
1314
+ result: { exitCode: 1, stdout: "", stderr: errorMessage }
1315
+ };
1316
+ }
1317
+ }
433
1318
  async resumeSession(sessionId, flags) {
434
1319
  const args = [];
435
1320
  if (flags.model) {
@@ -467,12 +1352,62 @@ var CodexAdapter = class extends BaseAdapter {
467
1352
  logger.success(`Codex CLI updated: ${result.stdout.trim()}`);
468
1353
  }
469
1354
  }
1355
+ async getMCPConfig() {
1356
+ const configPath = this.getConfigPath();
1357
+ try {
1358
+ const raw = await readFile2(configPath, "utf-8");
1359
+ const parsed = parseTOMLMcpServers(raw);
1360
+ return Array.from(parsed.mcpServers.entries()).map(
1361
+ ([name, entry]) => {
1362
+ const server = {
1363
+ name,
1364
+ command: entry.command
1365
+ };
1366
+ if (entry.args && entry.args.length > 0) {
1367
+ server.args = entry.args;
1368
+ }
1369
+ return server;
1370
+ }
1371
+ );
1372
+ } catch {
1373
+ return [];
1374
+ }
1375
+ }
1376
+ async setMCPConfig(servers) {
1377
+ const configPath = this.getConfigPath();
1378
+ let existingContent = "";
1379
+ try {
1380
+ existingContent = await readFile2(configPath, "utf-8");
1381
+ } catch {
1382
+ }
1383
+ const parsed = parseTOMLMcpServers(existingContent);
1384
+ let preamble = parsed.preambleLines.join("\n");
1385
+ preamble = preamble.replace(/\n+$/, "");
1386
+ const mcpSection = generateMcpServersTOML(servers);
1387
+ let postamble = parsed.postambleLines.join("\n");
1388
+ postamble = postamble.replace(/^\n+/, "");
1389
+ const parts = [];
1390
+ if (preamble) parts.push(preamble);
1391
+ if (mcpSection) parts.push(mcpSection);
1392
+ if (postamble) parts.push(postamble);
1393
+ let output = parts.join("\n\n");
1394
+ if (!output.endsWith("\n")) output += "\n";
1395
+ await mkdir2(dirname2(configPath), { recursive: true });
1396
+ await writeFile2(configPath, output, { mode: 384 });
1397
+ }
470
1398
  };
471
1399
 
472
1400
  // src/adapters/gemini-adapter.ts
1401
+ init_logger();
1402
+ import { readFile as readFile3, writeFile as writeFile3, mkdir as mkdir3 } from "fs/promises";
1403
+ import { homedir as homedir3 } from "os";
1404
+ import { join as join3, dirname as dirname3 } from "path";
473
1405
  var GeminiAdapter = class extends BaseAdapter {
474
1406
  id = "gemini";
475
1407
  command = "gemini";
1408
+ getConfigPath() {
1409
+ return join3(homedir3(), ".gemini", "settings.json");
1410
+ }
476
1411
  mapFlags(flags) {
477
1412
  const args = mapCommonToNative("gemini", flags);
478
1413
  if (flags.outputFormat) {
@@ -576,18 +1511,64 @@ var GeminiAdapter = class extends BaseAdapter {
576
1511
  logger.success(`Gemini CLI updated: ${result.stdout.trim()}`);
577
1512
  }
578
1513
  }
1514
+ async getMCPConfig() {
1515
+ const configPath = this.getConfigPath();
1516
+ try {
1517
+ const raw = await readFile3(configPath, "utf-8");
1518
+ const config = JSON.parse(raw);
1519
+ const mcpServers = config.mcpServers;
1520
+ if (!mcpServers || typeof mcpServers !== "object") {
1521
+ return [];
1522
+ }
1523
+ return Object.entries(mcpServers).map(([name, entry]) => {
1524
+ const server = {
1525
+ name,
1526
+ command: entry.command
1527
+ };
1528
+ if (entry.args) server.args = entry.args;
1529
+ if (entry.env) server.env = entry.env;
1530
+ return server;
1531
+ });
1532
+ } catch {
1533
+ return [];
1534
+ }
1535
+ }
1536
+ async setMCPConfig(servers) {
1537
+ const configPath = this.getConfigPath();
1538
+ let config = {};
1539
+ try {
1540
+ const raw = await readFile3(configPath, "utf-8");
1541
+ config = JSON.parse(raw);
1542
+ } catch {
1543
+ }
1544
+ const mcpServers = {};
1545
+ for (const server of servers) {
1546
+ const entry = {
1547
+ command: server.command
1548
+ };
1549
+ if (server.args) entry.args = server.args;
1550
+ if (server.env) entry.env = server.env;
1551
+ const serverName = server.name ?? server.command;
1552
+ mcpServers[serverName] = entry;
1553
+ }
1554
+ config.mcpServers = mcpServers;
1555
+ await mkdir3(dirname3(configPath), { recursive: true });
1556
+ await writeFile3(configPath, JSON.stringify(config, null, 2) + "\n", {
1557
+ mode: 384
1558
+ });
1559
+ }
579
1560
  };
580
1561
 
581
1562
  // src/core/session-manager.ts
582
- import { readFile, writeFile, readdir, mkdir } from "fs/promises";
583
- import { join } from "path";
584
- import { homedir } from "os";
1563
+ import { readFile as readFile4, writeFile as writeFile4, readdir, mkdir as mkdir4, chmod } from "fs/promises";
1564
+ import { join as join4 } from "path";
1565
+ import { homedir as homedir4 } from "os";
585
1566
  import { nanoid } from "nanoid";
586
1567
  function getRelayHome() {
587
- return process.env["RELAY_HOME"] ?? join(homedir(), ".relay");
1568
+ return process.env["RELAY_HOME"] ?? join4(homedir4(), ".relay");
588
1569
  }
589
1570
  function getSessionsDir(relayHome2) {
590
- return join(relayHome2, "sessions");
1571
+ return join4(relayHome2, "sessions");
591
1572
  }
592
1573
  function toSessionData(session) {
593
1574
  return {
@@ -603,17 +1584,21 @@ function fromSessionData(data) {
603
1584
  updatedAt: new Date(data.updatedAt)
604
1585
  };
605
1586
  }
606
- var SessionManager = class {
1587
+ var SessionManager = class _SessionManager {
1588
+ static SESSION_ID_PATTERN = /^relay-[A-Za-z0-9_-]+$/;
607
1589
  sessionsDir;
608
1590
  constructor(sessionsDir) {
609
1591
  this.sessionsDir = sessionsDir ?? getSessionsDir(getRelayHome());
610
1592
  }
611
1593
  /** Ensure the sessions directory exists. */
612
1594
  async ensureDir() {
613
- await mkdir(this.sessionsDir, { recursive: true });
1595
+ await mkdir4(this.sessionsDir, { recursive: true });
614
1596
  }
615
1597
  sessionPath(relaySessionId) {
616
- return join(this.sessionsDir, `${relaySessionId}.json`);
1598
+ if (!_SessionManager.SESSION_ID_PATTERN.test(relaySessionId)) {
1599
+ throw new Error(`Invalid session ID: ${relaySessionId}`);
1600
+ }
1601
+ return join4(this.sessionsDir, `${relaySessionId}.json`);
617
1602
  }
618
1603
  /** Create a new relay session. */
619
1604
  async create(params) {
@@ -629,11 +1614,13 @@ var SessionManager = class {
629
1614
  updatedAt: now,
630
1615
  status: "active"
631
1616
  };
632
- await writeFile(
633
- this.sessionPath(session.relaySessionId),
1617
+ const sessionFilePath = this.sessionPath(session.relaySessionId);
1618
+ await writeFile4(
1619
+ sessionFilePath,
634
1620
  JSON.stringify(toSessionData(session), null, 2),
635
1621
  "utf-8"
636
1622
  );
1623
+ await chmod(sessionFilePath, 384);
637
1624
  return session;
638
1625
  }
639
1626
  /** Update an existing session. */
@@ -647,16 +1634,19 @@ var SessionManager = class {
647
1634
  ...updates,
648
1635
  updatedAt: /* @__PURE__ */ new Date()
649
1636
  };
650
- await writeFile(
651
- this.sessionPath(relaySessionId),
1637
+ const updateFilePath = this.sessionPath(relaySessionId);
1638
+ await writeFile4(
1639
+ updateFilePath,
652
1640
  JSON.stringify(toSessionData(updated), null, 2),
653
1641
  "utf-8"
654
1642
  );
1643
+ await chmod(updateFilePath, 384);
655
1644
  }
656
1645
  /** Get a session by relay session ID. */
657
1646
  async get(relaySessionId) {
1647
+ const filePath = this.sessionPath(relaySessionId);
658
1648
  try {
659
- const raw = await readFile(this.sessionPath(relaySessionId), "utf-8");
1649
+ const raw = await readFile4(filePath, "utf-8");
660
1650
  return fromSessionData(JSON.parse(raw));
661
1651
  } catch {
662
1652
  return null;
@@ -680,7 +1670,7 @@ var SessionManager = class {
680
1670
  const sessions = [];
681
1671
  for (const file of jsonFiles) {
682
1672
  try {
683
- const raw = await readFile(join(this.sessionsDir, file), "utf-8");
1673
+ const raw = await readFile4(join4(this.sessionsDir, file), "utf-8");
684
1674
  const session = fromSessionData(JSON.parse(raw));
685
1675
  if (filter?.backendId && session.backendId !== filter.backendId) {
686
1676
  continue;
@@ -700,14 +1690,14 @@ var SessionManager = class {
700
1690
  };
701
1691
 
702
1692
  // src/core/config-manager.ts
703
- import { readFile as readFile2, writeFile as writeFile2, mkdir as mkdir2 } from "fs/promises";
704
- import { join as join2 } from "path";
1693
+ import { readFile as readFile5, writeFile as writeFile5, mkdir as mkdir5, chmod as chmod2 } from "fs/promises";
1694
+ import { join as join5 } from "path";
705
1695
 
706
1696
  // src/schemas/config.schema.ts
707
1697
  import { z } from "zod";
708
1698
  var backendIdSchema = z.enum(["claude", "codex", "gemini"]);
709
1699
  var mcpServerConfigSchema = z.object({
710
- name: z.string(),
1700
+ name: z.string().optional(),
711
1701
  command: z.string(),
712
1702
  args: z.array(z.string()).optional(),
713
1703
  env: z.record(z.string()).optional()
@@ -752,10 +1742,12 @@ var relayConfigSchema = z.object({
752
1742
  }).optional(),
753
1743
  telemetry: z.object({
754
1744
  enabled: z.boolean()
755
- }).optional()
1745
+ }).optional(),
1746
+ claudePermissionMode: z.enum(["default", "bypassPermissions"]).optional()
756
1747
  });
757
1748
 
758
1749
  // src/core/config-manager.ts
1750
+ init_logger();
759
1751
  function deepMerge(target, source) {
760
1752
  const result = { ...target };
761
1753
  for (const key of Object.keys(source)) {
@@ -843,8 +1835,9 @@ var ConfigManager = class {
843
1835
  const existing = await this.readConfigFile(filePath);
844
1836
  setByPath(existing, key, value);
845
1837
  const dir = filePath.substring(0, filePath.lastIndexOf("/"));
846
- await mkdir2(dir, { recursive: true });
847
- await writeFile2(filePath, JSON.stringify(existing, null, 2), "utf-8");
1838
+ await mkdir5(dir, { recursive: true });
1839
+ await writeFile5(filePath, JSON.stringify(existing, null, 2), "utf-8");
1840
+ await chmod2(filePath, 384);
848
1841
  }
849
1842
  /**
850
1843
  * Syncs MCP server config to all registered backend adapters.
@@ -852,7 +1845,12 @@ var ConfigManager = class {
852
1845
  async syncMCPConfig(registry2) {
853
1846
  const config = await this.getConfig();
854
1847
  const mcpServers = config.mcpServers ?? {};
855
- const servers = Object.values(mcpServers);
1848
+ const servers = Object.entries(mcpServers).map(
1849
+ ([key, server]) => ({
1850
+ ...server,
1851
+ name: server.name || key
1852
+ })
1853
+ );
856
1854
  const adapters = registry2.list();
857
1855
  for (const adapter of adapters) {
858
1856
  try {
@@ -869,11 +1867,11 @@ var ConfigManager = class {
869
1867
  getFilePath(scope) {
870
1868
  switch (scope) {
871
1869
  case "global":
872
- return join2(this.globalDir, "config.json");
1870
+ return join5(this.globalDir, "config.json");
873
1871
  case "project":
874
- return join2(this.projectDir ?? "", "config.json");
1872
+ return join5(this.projectDir ?? "", "config.json");
875
1873
  case "local":
876
- return join2(this.projectDir ?? "", "config.local.json");
1874
+ return join5(this.projectDir ?? "", "config.local.json");
877
1875
  }
878
1876
  }
879
1877
  mapConfigScope(scope) {
@@ -890,7 +1888,7 @@ var ConfigManager = class {
890
1888
  }
891
1889
  async readConfigFile(filePath) {
892
1890
  try {
893
- const raw = await readFile2(filePath, "utf-8");
1891
+ const raw = await readFile5(filePath, "utf-8");
894
1892
  const parsed = JSON.parse(raw);
895
1893
  if (!isPlainObject(parsed)) return {};
896
1894
  return parsed;
@@ -919,6 +1917,7 @@ var ConfigManager = class {
919
1917
  };
920
1918
 
921
1919
  // src/core/auth-manager.ts
1920
+ init_logger();
922
1921
  var AuthManager = class {
923
1922
  constructor(registry2) {
924
1923
  this.registry = registry2;
@@ -992,24 +1991,61 @@ var EventBus = class {
992
1991
  };
993
1992
 
994
1993
  // src/core/hooks-engine.ts
1994
+ init_logger();
995
1995
  var DEFAULT_TIMEOUT_MS = 1e4;
996
1996
  var DEFAULT_HOOK_OUTPUT = {
997
1997
  allow: true,
998
1998
  message: "",
999
1999
  metadata: {}
1000
2000
  };
1001
- var HooksEngine = class {
2001
+ var HooksEngine = class _HooksEngine {
1002
2002
  constructor(eventBus2, processManager2) {
1003
2003
  this.eventBus = eventBus2;
1004
2004
  this.processManager = processManager2;
1005
2005
  }
2006
+ static COMMAND_PATTERN = /^[a-zA-Z0-9_./-]+$/;
2007
+ static ARG_PATTERN = /^[a-zA-Z0-9_.=:/-]+$/;
1006
2008
  definitions = [];
1007
2009
  registered = false;
2010
+ validateCommand(command) {
2011
+ if (!command || command.trim().length === 0) {
2012
+ throw new Error("Hook command cannot be empty");
2013
+ }
2014
+ if (!_HooksEngine.COMMAND_PATTERN.test(command)) {
2015
+ throw new Error(
2016
+ `Hook command contains invalid characters: "${command}". Only alphanumeric characters, dots, underscores, hyphens, and slashes are allowed.`
2017
+ );
2018
+ }
2019
+ if (command.includes("..")) {
2020
+ throw new Error(
2021
+ `Hook command contains path traversal: "${command}"`
2022
+ );
2023
+ }
2024
+ }
2025
+ validateArgs(args) {
2026
+ for (const arg of args) {
2027
+ if (!_HooksEngine.ARG_PATTERN.test(arg)) {
2028
+ throw new Error(
2029
+ `Hook argument contains invalid characters: "${arg}". Only alphanumeric characters, dots, underscores, equals, colons, hyphens, and slashes are allowed.`
2030
+ );
2031
+ }
2032
+ }
2033
+ }
1008
2034
  /** Load hook definitions from config and register listeners on EventBus */
1009
2035
  loadConfig(config) {
1010
- this.definitions = config.definitions.filter(
1011
- (def) => def.enabled !== false
1012
- );
2036
+ this.definitions = config.definitions.filter((def) => {
2037
+ if (def.enabled === false) return false;
2038
+ try {
2039
+ this.validateCommand(def.command);
2040
+ this.validateArgs(def.args ?? []);
2041
+ return true;
2042
+ } catch (error) {
2043
+ logger.warn(
2044
+ `Skipping hook with invalid command: ${error instanceof Error ? error.message : String(error)}`
2045
+ );
2046
+ return false;
2047
+ }
2048
+ });
1013
2049
  if (!this.registered) {
1014
2050
  for (const event of this.getUniqueEvents()) {
1015
2051
  this.eventBus.on(event, async () => {
@@ -1056,6 +2092,8 @@ var HooksEngine = class {
1056
2092
  return this.definitions.filter((def) => def.event === event).length;
1057
2093
  }
1058
2094
  async executeHook(def, input) {
2095
+ this.validateCommand(def.command);
2096
+ this.validateArgs(def.args ?? []);
1059
2097
  const timeoutMs = def.timeout ?? DEFAULT_TIMEOUT_MS;
1060
2098
  const stdinData = JSON.stringify(input);
1061
2099
  const startTime = Date.now();
@@ -1157,6 +2195,7 @@ var ContextMonitor = class {
1157
2195
  };
1158
2196
 
1159
2197
  // src/commands/backend.ts
2198
+ init_logger();
1160
2199
  import { defineCommand } from "citty";
1161
2200
 
1162
2201
  // src/adapters/install-guides.ts
@@ -1167,7 +2206,7 @@ var INSTALL_GUIDES = {
1167
2206
  };
1168
2207
 
1169
2208
  // src/commands/backend.ts
1170
- function createBackendCommand(backendId, registry2, sessionManager2, hooksEngine2) {
2209
+ function createBackendCommand(backendId, registry2, sessionManager2, hooksEngine2, contextMonitor2) {
1171
2210
  return defineCommand({
1172
2211
  meta: {
1173
2212
  name: backendId,
@@ -1208,6 +2247,11 @@ function createBackendCommand(backendId, registry2, sessionManager2, hooksEngine
1208
2247
  verbose: {
1209
2248
  type: "boolean",
1210
2249
  description: "Enable verbose output"
2250
+ },
2251
+ dryRun: {
2252
+ type: "boolean",
2253
+ description: "Show what would be executed without running it",
2254
+ default: false
1211
2255
  }
1212
2256
  },
1213
2257
  async run({ args }) {
@@ -1231,6 +2275,23 @@ function createBackendCommand(backendId, registry2, sessionManager2, hooksEngine
1231
2275
  flags.outputFormat = args.outputFormat;
1232
2276
  }
1233
2277
  if (args.verbose) flags.verbose = true;
2278
+ if (args.dryRun) {
2279
+ const nativeFlags = adapter.mapFlags(flags);
2280
+ const dryRunOutput = {
2281
+ backend: backendId,
2282
+ installed: await adapter.isInstalled(),
2283
+ nativeArgs: nativeFlags.args,
2284
+ relayEnv: {
2285
+ RELAY_HOME: process.env["RELAY_HOME"] ?? "~/.relay",
2286
+ RELAY_LOG_LEVEL: process.env["RELAY_LOG_LEVEL"] ?? "info"
2287
+ },
2288
+ sessionTracking: !!sessionManager2,
2289
+ hooksEnabled: !!hooksEngine2,
2290
+ contextMonitorEnabled: !!contextMonitor2
2291
+ };
2292
+ console.log(JSON.stringify(dryRunOutput, null, 2));
2293
+ return;
2294
+ }
1234
2295
  let relaySessionId;
1235
2296
  if (sessionManager2) {
1236
2297
  try {
@@ -1262,10 +2323,73 @@ function createBackendCommand(backendId, registry2, sessionManager2, hooksEngine
1262
2323
  try {
1263
2324
  if (flags.prompt) {
1264
2325
  logger.debug(`Executing prompt on ${backendId}`);
1265
- const result = await adapter.execute(flags);
1266
- if (result.stdout) process.stdout.write(result.stdout);
1267
- if (result.stderr) process.stderr.write(result.stderr);
1268
- process.exitCode = result.exitCode;
2326
+ if (adapter.executeStreaming) {
2327
+ for await (const event of adapter.executeStreaming(flags)) {
2328
+ switch (event.type) {
2329
+ case "text":
2330
+ process.stdout.write(event.text);
2331
+ break;
2332
+ case "tool_start":
2333
+ if (flags.verbose) {
2334
+ process.stderr.write(`
2335
+ [${event.tool}] started
2336
+ `);
2337
+ }
2338
+ break;
2339
+ case "tool_end":
2340
+ if (flags.verbose) {
2341
+ process.stderr.write(`[${event.tool}] completed
2342
+ `);
2343
+ }
2344
+ break;
2345
+ case "status":
2346
+ if (flags.verbose) {
2347
+ process.stderr.write(`[status] ${event.message}
2348
+ `);
2349
+ }
2350
+ break;
2351
+ case "error":
2352
+ process.stderr.write(event.message);
2353
+ break;
2354
+ case "usage": {
2355
+ if (contextMonitor2 && relaySessionId) {
2356
+ const maxTokens = backendId === "gemini" ? 128e3 : 2e5;
2357
+ contextMonitor2.updateUsage(
2358
+ relaySessionId,
2359
+ backendId,
2360
+ event.inputTokens + event.outputTokens,
2361
+ maxTokens
2362
+ );
2363
+ }
2364
+ break;
2365
+ }
2366
+ case "done":
2367
+ process.exitCode = event.result.exitCode;
2368
+ if (event.nativeSessionId && sessionManager2 && relaySessionId) {
2369
+ try {
2370
+ await sessionManager2.update(relaySessionId, {
2371
+ nativeSessionId: event.nativeSessionId
2372
+ });
2373
+ } catch {
2374
+ }
2375
+ }
2376
+ break;
2377
+ }
2378
+ }
2379
+ } else {
2380
+ const result = await adapter.execute(flags);
2381
+ if (result.stdout) process.stdout.write(result.stdout);
2382
+ if (result.stderr) process.stderr.write(result.stderr);
2383
+ process.exitCode = result.exitCode;
2384
+ if (result.nativeSessionId && sessionManager2 && relaySessionId) {
2385
+ try {
2386
+ await sessionManager2.update(relaySessionId, {
2387
+ nativeSessionId: result.nativeSessionId
2388
+ });
2389
+ } catch {
2390
+ }
2391
+ }
2392
+ }
1269
2393
  } else if (flags.continue) {
1270
2394
  logger.debug(`Continuing latest session on ${backendId}`);
1271
2395
  if (sessionManager2) {
@@ -1332,502 +2456,193 @@ function createBackendCommand(backendId, registry2, sessionManager2, hooksEngine
1332
2456
  data: {
1333
2457
  error: error instanceof Error ? error.message : String(error)
1334
2458
  }
1335
- };
1336
- await hooksEngine2.emit("on-error", hookInput);
1337
- } catch {
1338
- }
1339
- }
1340
- throw error;
1341
- }
1342
- }
1343
- });
1344
- }
1345
-
1346
- // src/commands/update.ts
1347
- import { defineCommand as defineCommand2 } from "citty";
1348
- function createUpdateCommand(registry2) {
1349
- return defineCommand2({
1350
- meta: {
1351
- name: "update",
1352
- description: "Update all installed backend CLI tools"
1353
- },
1354
- async run() {
1355
- const adapters = registry2.list();
1356
- if (adapters.length === 0) {
1357
- logger.warn("No backends registered");
1358
- return;
1359
- }
1360
- for (const adapter of adapters) {
1361
- const installed = await adapter.isInstalled();
1362
- if (!installed) {
1363
- logger.info(`Skipping ${adapter.id}: not installed`);
1364
- continue;
1365
- }
1366
- logger.info(`Updating ${adapter.id}...`);
1367
- try {
1368
- await adapter.update();
1369
- } catch (error) {
1370
- logger.error(
1371
- `Failed to update ${adapter.id}: ${error instanceof Error ? error.message : String(error)}`
1372
- );
1373
- }
1374
- }
1375
- }
1376
- });
1377
- }
1378
-
1379
- // src/commands/config.ts
1380
- import { defineCommand as defineCommand3 } from "citty";
1381
- function createConfigCommand(configManager2) {
1382
- return defineCommand3({
1383
- meta: {
1384
- name: "config",
1385
- description: "Manage relay configuration"
1386
- },
1387
- subCommands: {
1388
- get: defineCommand3({
1389
- meta: {
1390
- name: "get",
1391
- description: "Get a config value by key (dot-path notation)"
1392
- },
1393
- args: {
1394
- key: {
1395
- type: "positional",
1396
- description: "Config key (e.g. mcpServerMode.maxDepth)",
1397
- required: true
1398
- }
1399
- },
1400
- async run({ args }) {
1401
- const key = String(args.key);
1402
- const value = await configManager2.get(key);
1403
- if (value === void 0) {
1404
- logger.info(`Key "${key}" is not set`);
1405
- } else {
1406
- const output = typeof value === "object" ? JSON.stringify(value, null, 2) : String(value);
1407
- console.log(output);
1408
- }
1409
- }
1410
- }),
1411
- set: defineCommand3({
1412
- meta: {
1413
- name: "set",
1414
- description: "Set a config value (default: global scope)"
1415
- },
1416
- args: {
1417
- project: {
1418
- type: "boolean",
1419
- description: "Write to project scope instead of global"
1420
- },
1421
- key: {
1422
- type: "positional",
1423
- description: "Config key (e.g. defaultBackend)",
1424
- required: true
1425
- },
1426
- value: {
1427
- type: "positional",
1428
- description: "Config value",
1429
- required: true
1430
- }
1431
- },
1432
- async run({ args }) {
1433
- const scope = args.project ? "project" : "global";
1434
- const key = String(args.key);
1435
- const rawValue = String(args.value);
1436
- const parsed = parseValue(rawValue);
1437
- await configManager2.set(scope, key, parsed);
1438
- logger.success(`Set ${key} = ${JSON.stringify(parsed)} (${scope})`);
1439
- }
1440
- })
1441
- },
1442
- async run() {
1443
- const config = await configManager2.getConfig();
1444
- console.log(JSON.stringify(config, null, 2));
1445
- }
1446
- });
1447
- }
1448
- function parseValue(raw) {
1449
- if (raw === "true") return true;
1450
- if (raw === "false") return false;
1451
- if (raw === "null") return null;
1452
- const num = Number(raw);
1453
- if (!Number.isNaN(num) && raw.trim() !== "") return num;
1454
- try {
1455
- const parsed = JSON.parse(raw);
1456
- if (typeof parsed === "object") return parsed;
1457
- } catch {
1458
- }
1459
- return raw;
1460
- }
1461
-
1462
- // src/commands/mcp.ts
1463
- import { defineCommand as defineCommand4 } from "citty";
1464
-
1465
- // src/mcp-server/server.ts
1466
- import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
1467
- import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
1468
- import { z as z5 } from "zod";
1469
-
1470
- // src/mcp-server/recursion-guard.ts
1471
- import { createHash } from "crypto";
1472
- var RecursionGuard = class {
1473
- constructor(config = {
1474
- maxDepth: 5,
1475
- maxCallsPerSession: 20,
1476
- timeoutSec: 300
1477
- }) {
1478
- this.config = config;
1479
- }
1480
- callCounts = /* @__PURE__ */ new Map();
1481
- promptHashes = /* @__PURE__ */ new Map();
1482
- /** Check if a spawn is allowed */
1483
- canSpawn(context) {
1484
- if (context.depth >= this.config.maxDepth) {
1485
- return {
1486
- allowed: false,
1487
- reason: `Max depth exceeded: ${context.depth} >= ${this.config.maxDepth}`
1488
- };
1489
- }
1490
- const currentCount = this.callCounts.get(context.traceId) ?? 0;
1491
- if (currentCount >= this.config.maxCallsPerSession) {
1492
- return {
1493
- allowed: false,
1494
- reason: `Max calls per session exceeded: ${currentCount} >= ${this.config.maxCallsPerSession}`
1495
- };
1496
- }
1497
- if (this.detectLoop(context.traceId, context.backend, context.promptHash)) {
1498
- return {
1499
- allowed: false,
1500
- reason: `Loop detected: same (backend=${context.backend}, promptHash=${context.promptHash}) appeared 3+ times in trace ${context.traceId}`
1501
- };
1502
- }
1503
- return { allowed: true };
1504
- }
1505
- /** Record a spawn invocation */
1506
- recordSpawn(context) {
1507
- const currentCount = this.callCounts.get(context.traceId) ?? 0;
1508
- this.callCounts.set(context.traceId, currentCount + 1);
1509
- const key = `${context.backend}:${context.promptHash}`;
1510
- const existing = this.promptHashes.get(context.traceId) ?? [];
1511
- existing.push(key);
1512
- this.promptHashes.set(context.traceId, existing);
1513
- }
1514
- /** Detect if the same (backend + promptHash) combination has appeared 3+ times */
1515
- detectLoop(traceId, backend, promptHash) {
1516
- const key = `${backend}:${promptHash}`;
1517
- const hashes = this.promptHashes.get(traceId) ?? [];
1518
- const count = hashes.filter((h) => h === key).length;
1519
- return count >= 3;
1520
- }
1521
- /** Get current config (for testing/inspection) */
1522
- getConfig() {
1523
- return { ...this.config };
1524
- }
1525
- /** Get call count for a trace */
1526
- getCallCount(traceId) {
1527
- return this.callCounts.get(traceId) ?? 0;
1528
- }
1529
- /** Utility: compute a prompt hash */
1530
- static hashPrompt(prompt) {
1531
- return createHash("sha256").update(prompt).digest("hex").slice(0, 16);
1532
- }
1533
- };
1534
-
1535
- // src/mcp-server/tools/spawn-agent.ts
1536
- import { z as z2 } from "zod";
1537
- import { nanoid as nanoid2 } from "nanoid";
1538
- var spawnAgentInputSchema = z2.object({
1539
- backend: z2.enum(["claude", "codex", "gemini"]),
1540
- prompt: z2.string(),
1541
- agent: z2.string().optional(),
1542
- resumeSessionId: z2.string().optional(),
1543
- model: z2.string().optional(),
1544
- maxTurns: z2.number().optional()
1545
- });
1546
- function buildContextFromEnv() {
1547
- const traceId = process.env["RELAY_TRACE_ID"] ?? `trace-${nanoid2()}`;
1548
- const parentSessionId = process.env["RELAY_PARENT_SESSION_ID"] ?? null;
1549
- const depth = Number(process.env["RELAY_DEPTH"] ?? "0");
1550
- return { traceId, parentSessionId, depth };
1551
- }
1552
- async function executeSpawnAgent(input, registry2, sessionManager2, guard, hooksEngine2) {
1553
- const envContext = buildContextFromEnv();
1554
- const promptHash = RecursionGuard.hashPrompt(input.prompt);
1555
- const context = {
1556
- traceId: envContext.traceId,
1557
- depth: envContext.depth,
1558
- backend: input.backend,
1559
- promptHash
1560
- };
1561
- const guardResult = guard.canSpawn(context);
1562
- if (!guardResult.allowed) {
1563
- logger.warn(`Spawn blocked by RecursionGuard: ${guardResult.reason}`);
1564
- return {
1565
- sessionId: "",
1566
- exitCode: 1,
1567
- stdout: "",
1568
- stderr: `Spawn blocked: ${guardResult.reason}`
1569
- };
1570
- }
1571
- const adapter = registry2.get(input.backend);
1572
- const installed = await adapter.isInstalled();
1573
- if (!installed) {
1574
- return {
1575
- sessionId: "",
1576
- exitCode: 1,
1577
- stdout: "",
1578
- stderr: `Backend "${input.backend}" is not installed`
1579
- };
1580
- }
1581
- const session = await sessionManager2.create({
1582
- backendId: input.backend,
1583
- parentSessionId: envContext.parentSessionId ?? void 0,
1584
- depth: envContext.depth + 1
1585
- });
1586
- if (hooksEngine2) {
1587
- try {
1588
- const hookInput = {
1589
- event: "pre-spawn",
1590
- sessionId: session.relaySessionId,
1591
- backendId: input.backend,
1592
- timestamp: (/* @__PURE__ */ new Date()).toISOString(),
1593
- data: {
1594
- prompt: input.prompt,
1595
- agent: input.agent,
1596
- model: input.model
1597
- }
1598
- };
1599
- await hooksEngine2.emit("pre-spawn", hookInput);
1600
- } catch (error) {
1601
- logger.debug(
1602
- `pre-spawn hook error: ${error instanceof Error ? error.message : String(error)}`
1603
- );
1604
- }
1605
- }
1606
- try {
1607
- const result = await adapter.execute({
1608
- prompt: input.prompt,
1609
- agent: input.agent,
1610
- model: input.model,
1611
- maxTurns: input.maxTurns,
1612
- resume: input.resumeSessionId,
1613
- mcpContext: {
1614
- parentSessionId: session.relaySessionId,
1615
- depth: envContext.depth + 1,
1616
- maxDepth: guard.getConfig().maxDepth,
1617
- traceId: envContext.traceId
1618
- }
1619
- });
1620
- guard.recordSpawn(context);
1621
- const status = result.exitCode === 0 ? "completed" : "error";
1622
- await sessionManager2.update(session.relaySessionId, { status });
1623
- if (hooksEngine2) {
1624
- try {
1625
- const hookInput = {
1626
- event: "post-spawn",
1627
- sessionId: session.relaySessionId,
1628
- backendId: input.backend,
1629
- timestamp: (/* @__PURE__ */ new Date()).toISOString(),
1630
- data: {
1631
- exitCode: result.exitCode,
1632
- status
1633
- }
1634
- };
1635
- await hooksEngine2.emit("post-spawn", hookInput);
1636
- } catch (hookError) {
1637
- logger.debug(
1638
- `post-spawn hook error: ${hookError instanceof Error ? hookError.message : String(hookError)}`
1639
- );
1640
- }
1641
- }
1642
- return {
1643
- sessionId: session.relaySessionId,
1644
- exitCode: result.exitCode,
1645
- stdout: result.stdout,
1646
- stderr: result.stderr
1647
- };
1648
- } catch (error) {
1649
- await sessionManager2.update(session.relaySessionId, { status: "error" });
1650
- const message = error instanceof Error ? error.message : String(error);
1651
- return {
1652
- sessionId: session.relaySessionId,
1653
- exitCode: 1,
1654
- stdout: "",
1655
- stderr: message
1656
- };
1657
- }
1658
- }
1659
-
1660
- // src/mcp-server/tools/list-sessions.ts
1661
- import { z as z3 } from "zod";
1662
- var listSessionsInputSchema = z3.object({
1663
- backend: z3.enum(["claude", "codex", "gemini"]).optional(),
1664
- limit: z3.number().optional().default(10)
1665
- });
1666
- async function executeListSessions(input, sessionManager2) {
1667
- const sessions = await sessionManager2.list({
1668
- backendId: input.backend,
1669
- limit: input.limit
2459
+ };
2460
+ await hooksEngine2.emit("on-error", hookInput);
2461
+ } catch {
2462
+ }
2463
+ }
2464
+ throw error;
2465
+ }
2466
+ }
1670
2467
  });
1671
- return {
1672
- sessions: sessions.map((s) => ({
1673
- relaySessionId: s.relaySessionId,
1674
- backendId: s.backendId,
1675
- status: s.status,
1676
- createdAt: s.createdAt.toISOString()
1677
- }))
1678
- };
1679
2468
  }
1680
2469
 
1681
- // src/mcp-server/tools/get-context-status.ts
1682
- import { z as z4 } from "zod";
1683
- var getContextStatusInputSchema = z4.object({
1684
- sessionId: z4.string()
1685
- });
1686
- async function executeGetContextStatus(input, sessionManager2, contextMonitor2) {
1687
- const session = await sessionManager2.get(input.sessionId);
1688
- if (!session) {
1689
- throw new Error(`Session not found: ${input.sessionId}`);
1690
- }
1691
- if (contextMonitor2) {
1692
- const usage = contextMonitor2.getUsage(input.sessionId);
1693
- if (usage) {
1694
- return {
1695
- sessionId: input.sessionId,
1696
- usagePercent: usage.usagePercent,
1697
- isEstimated: usage.isEstimated
1698
- };
2470
+ // src/commands/update.ts
2471
+ init_logger();
2472
+ import { defineCommand as defineCommand2 } from "citty";
2473
+
2474
+ // src/infrastructure/version-check.ts
2475
+ init_logger();
2476
+ async function getLatestNpmVersion(packageName) {
2477
+ try {
2478
+ const response = await fetch(
2479
+ `https://registry.npmjs.org/${packageName}/latest`,
2480
+ {
2481
+ headers: { Accept: "application/json" },
2482
+ signal: AbortSignal.timeout(5e3)
2483
+ }
2484
+ );
2485
+ if (!response.ok) {
2486
+ logger.debug(
2487
+ `npm registry returned ${response.status} for ${packageName}`
2488
+ );
2489
+ return null;
1699
2490
  }
2491
+ const data = await response.json();
2492
+ return data.version ?? null;
2493
+ } catch (error) {
2494
+ logger.debug(
2495
+ `Failed to check npm registry: ${error instanceof Error ? error.message : String(error)}`
2496
+ );
2497
+ return null;
1700
2498
  }
1701
- return {
1702
- sessionId: input.sessionId,
1703
- usagePercent: 0,
1704
- isEstimated: true
1705
- };
2499
+ }
2500
+ function compareSemver(a, b) {
2501
+ const partsA = a.replace(/^v/, "").split(".").map(Number);
2502
+ const partsB = b.replace(/^v/, "").split(".").map(Number);
2503
+ for (let i = 0; i < 3; i++) {
2504
+ const diff = (partsA[i] ?? 0) - (partsB[i] ?? 0);
2505
+ if (diff !== 0) return diff;
2506
+ }
2507
+ return 0;
1706
2508
  }
1707
2509
 
1708
- // src/mcp-server/server.ts
1709
- var RelayMCPServer = class {
1710
- constructor(registry2, sessionManager2, guardConfig, hooksEngine2, contextMonitor2) {
1711
- this.registry = registry2;
1712
- this.sessionManager = sessionManager2;
1713
- this.hooksEngine = hooksEngine2;
1714
- this.contextMonitor = contextMonitor2;
1715
- this.guard = new RecursionGuard(guardConfig);
1716
- this.server = new McpServer({
1717
- name: "agentic-relay",
1718
- version: "0.1.0"
1719
- });
1720
- this.registerTools();
1721
- }
1722
- server;
1723
- guard;
1724
- registerTools() {
1725
- this.server.tool(
1726
- "spawn_agent",
1727
- "Spawn a sub-agent on the specified backend CLI (Claude Code, Codex CLI, or Gemini CLI). The agent executes the given prompt in non-interactive mode and returns the result.",
1728
- {
1729
- backend: z5.enum(["claude", "codex", "gemini"]),
1730
- prompt: z5.string(),
1731
- agent: z5.string().optional(),
1732
- resumeSessionId: z5.string().optional(),
1733
- model: z5.string().optional(),
1734
- maxTurns: z5.number().optional()
1735
- },
1736
- async (params) => {
1737
- try {
1738
- const result = await executeSpawnAgent(
1739
- params,
1740
- this.registry,
1741
- this.sessionManager,
1742
- this.guard,
1743
- this.hooksEngine
2510
+ // src/commands/update.ts
2511
+ var PACKAGE_NAME = "@rk0429/agentic-relay";
2512
+ var CURRENT_VERSION = "0.4.0";
2513
+ function createUpdateCommand(registry2) {
2514
+ return defineCommand2({
2515
+ meta: {
2516
+ name: "update",
2517
+ description: "Update relay and all installed backend CLI tools"
2518
+ },
2519
+ async run() {
2520
+ logger.info("Checking for relay updates...");
2521
+ const latestVersion = await getLatestNpmVersion(PACKAGE_NAME);
2522
+ if (latestVersion) {
2523
+ if (compareSemver(latestVersion, CURRENT_VERSION) > 0) {
2524
+ logger.warn(
2525
+ `A newer version of relay is available: ${latestVersion} (current: ${CURRENT_VERSION})`
1744
2526
  );
1745
- const isError = result.exitCode !== 0;
1746
- const text = isError ? `Error (exit ${result.exitCode}): ${result.stderr || result.stdout}` : `Session: ${result.sessionId}
1747
-
1748
- ${result.stdout}`;
1749
- return {
1750
- content: [{ type: "text", text }],
1751
- isError
1752
- };
1753
- } catch (error) {
1754
- const message = error instanceof Error ? error.message : String(error);
1755
- return {
1756
- content: [{ type: "text", text: `Error: ${message}` }],
1757
- isError: true
1758
- };
2527
+ logger.info(` Run: npm install -g ${PACKAGE_NAME}@latest`);
2528
+ } else {
2529
+ logger.success(`relay is up to date (${CURRENT_VERSION})`);
1759
2530
  }
2531
+ } else {
2532
+ logger.debug("Could not check for relay updates");
1760
2533
  }
1761
- );
1762
- this.server.tool(
1763
- "list_sessions",
1764
- "List relay sessions, optionally filtered by backend.",
1765
- {
1766
- backend: z5.enum(["claude", "codex", "gemini"]).optional(),
1767
- limit: z5.number().optional()
1768
- },
1769
- async (params) => {
1770
- try {
1771
- const result = await executeListSessions(
1772
- { backend: params.backend, limit: params.limit ?? 10 },
1773
- this.sessionManager
1774
- );
1775
- return {
1776
- content: [
1777
- {
1778
- type: "text",
1779
- text: JSON.stringify(result, null, 2)
1780
- }
1781
- ]
1782
- };
1783
- } catch (error) {
1784
- const message = error instanceof Error ? error.message : String(error);
1785
- return {
1786
- content: [{ type: "text", text: `Error: ${message}` }],
1787
- isError: true
1788
- };
1789
- }
2534
+ const adapters = registry2.list();
2535
+ if (adapters.length === 0) {
2536
+ logger.warn("No backends registered");
2537
+ return;
1790
2538
  }
1791
- );
1792
- this.server.tool(
1793
- "get_context_status",
1794
- "Get the context usage status of a relay session. Returns usage data from ContextMonitor when available, otherwise estimated values.",
1795
- {
1796
- sessionId: z5.string()
1797
- },
1798
- async (params) => {
2539
+ for (const adapter of adapters) {
2540
+ const installed = await adapter.isInstalled();
2541
+ if (!installed) {
2542
+ logger.info(`Skipping ${adapter.id}: not installed`);
2543
+ continue;
2544
+ }
2545
+ logger.info(`Updating ${adapter.id}...`);
1799
2546
  try {
1800
- const result = await executeGetContextStatus(
1801
- params,
1802
- this.sessionManager,
1803
- this.contextMonitor
1804
- );
1805
- return {
1806
- content: [
1807
- {
1808
- type: "text",
1809
- text: JSON.stringify(result, null, 2)
1810
- }
1811
- ]
1812
- };
2547
+ await adapter.update();
1813
2548
  } catch (error) {
1814
- const message = error instanceof Error ? error.message : String(error);
1815
- return {
1816
- content: [{ type: "text", text: `Error: ${message}` }],
1817
- isError: true
1818
- };
2549
+ logger.error(
2550
+ `Failed to update ${adapter.id}: ${error instanceof Error ? error.message : String(error)}`
2551
+ );
1819
2552
  }
1820
2553
  }
1821
- );
1822
- }
1823
- async start() {
1824
- logger.info("Starting agentic-relay MCP server (stdio transport)...");
1825
- const transport = new StdioServerTransport();
1826
- await this.server.connect(transport);
2554
+ }
2555
+ });
2556
+ }
2557
+
2558
+ // src/commands/config.ts
2559
+ init_logger();
2560
+ import { defineCommand as defineCommand3 } from "citty";
2561
+ function createConfigCommand(configManager2) {
2562
+ return defineCommand3({
2563
+ meta: {
2564
+ name: "config",
2565
+ description: "Manage relay configuration"
2566
+ },
2567
+ subCommands: {
2568
+ get: defineCommand3({
2569
+ meta: {
2570
+ name: "get",
2571
+ description: "Get a config value by key (dot-path notation)"
2572
+ },
2573
+ args: {
2574
+ key: {
2575
+ type: "positional",
2576
+ description: "Config key (e.g. mcpServerMode.maxDepth)",
2577
+ required: true
2578
+ }
2579
+ },
2580
+ async run({ args }) {
2581
+ const key = String(args.key);
2582
+ const value = await configManager2.get(key);
2583
+ if (value === void 0) {
2584
+ logger.info(`Key "${key}" is not set`);
2585
+ } else {
2586
+ const output = typeof value === "object" ? JSON.stringify(value, null, 2) : String(value);
2587
+ console.log(output);
2588
+ }
2589
+ }
2590
+ }),
2591
+ set: defineCommand3({
2592
+ meta: {
2593
+ name: "set",
2594
+ description: "Set a config value (default: global scope)"
2595
+ },
2596
+ args: {
2597
+ project: {
2598
+ type: "boolean",
2599
+ description: "Write to project scope instead of global"
2600
+ },
2601
+ key: {
2602
+ type: "positional",
2603
+ description: "Config key (e.g. defaultBackend)",
2604
+ required: true
2605
+ },
2606
+ value: {
2607
+ type: "positional",
2608
+ description: "Config value",
2609
+ required: true
2610
+ }
2611
+ },
2612
+ async run({ args }) {
2613
+ const scope = args.project ? "project" : "global";
2614
+ const key = String(args.key);
2615
+ const rawValue = String(args.value);
2616
+ const parsed = parseValue(rawValue);
2617
+ await configManager2.set(scope, key, parsed);
2618
+ logger.success(`Set ${key} = ${JSON.stringify(parsed)} (${scope})`);
2619
+ }
2620
+ })
2621
+ },
2622
+ async run() {
2623
+ const config = await configManager2.getConfig();
2624
+ console.log(JSON.stringify(config, null, 2));
2625
+ }
2626
+ });
2627
+ }
2628
+ function parseValue(raw) {
2629
+ if (raw === "true") return true;
2630
+ if (raw === "false") return false;
2631
+ if (raw === "null") return null;
2632
+ const num = Number(raw);
2633
+ if (!Number.isNaN(num) && raw.trim() !== "") return num;
2634
+ try {
2635
+ const parsed = JSON.parse(raw);
2636
+ if (typeof parsed === "object") return parsed;
2637
+ } catch {
1827
2638
  }
1828
- };
2639
+ return raw;
2640
+ }
1829
2641
 
1830
2642
  // src/commands/mcp.ts
2643
+ init_server();
2644
+ init_logger();
2645
+ import { defineCommand as defineCommand4 } from "citty";
1831
2646
  function createMCPCommand(configManager2, registry2, sessionManager2, hooksEngine2, contextMonitor2) {
1832
2647
  return defineCommand4({
1833
2648
  meta: {
@@ -1948,9 +2763,21 @@ function createMCPCommand(configManager2, registry2, sessionManager2, hooksEngin
1948
2763
  serve: defineCommand4({
1949
2764
  meta: {
1950
2765
  name: "serve",
1951
- description: "Start relay as an MCP server (stdio transport)"
2766
+ description: "Start relay as an MCP server"
1952
2767
  },
1953
- async run() {
2768
+ args: {
2769
+ transport: {
2770
+ type: "string",
2771
+ description: "Transport type: stdio or http (default: stdio)",
2772
+ default: "stdio"
2773
+ },
2774
+ port: {
2775
+ type: "string",
2776
+ description: "Port for HTTP transport (default: 3100)",
2777
+ default: "3100"
2778
+ }
2779
+ },
2780
+ async run({ args }) {
1954
2781
  if (!sessionManager2) {
1955
2782
  logger.error("SessionManager is required for MCP server mode");
1956
2783
  process.exitCode = 1;
@@ -1968,6 +2795,8 @@ function createMCPCommand(configManager2, registry2, sessionManager2, hooksEngin
1968
2795
  }
1969
2796
  } catch {
1970
2797
  }
2798
+ const transport = args.transport === "http" ? "http" : "stdio";
2799
+ const port = parseInt(String(args.port), 10) || 3100;
1971
2800
  const server = new RelayMCPServer(
1972
2801
  registry2,
1973
2802
  sessionManager2,
@@ -1975,7 +2804,7 @@ function createMCPCommand(configManager2, registry2, sessionManager2, hooksEngin
1975
2804
  hooksEngine2,
1976
2805
  contextMonitor2
1977
2806
  );
1978
- await server.start();
2807
+ await server.start({ transport, port });
1979
2808
  }
1980
2809
  })
1981
2810
  },
@@ -1988,6 +2817,7 @@ function createMCPCommand(configManager2, registry2, sessionManager2, hooksEngin
1988
2817
  }
1989
2818
 
1990
2819
  // src/commands/auth.ts
2820
+ init_logger();
1991
2821
  import { defineCommand as defineCommand5 } from "citty";
1992
2822
  function createBackendAuthCommand(backendId, authManager2) {
1993
2823
  return defineCommand5({
@@ -2042,6 +2872,7 @@ function createAuthCommand(authManager2) {
2042
2872
  }
2043
2873
 
2044
2874
  // src/commands/sessions.ts
2875
+ init_logger();
2045
2876
  import { defineCommand as defineCommand6 } from "citty";
2046
2877
  function formatDate(date) {
2047
2878
  const y = date.getFullYear();
@@ -2157,9 +2988,12 @@ function createVersionCommand(registry2) {
2157
2988
 
2158
2989
  // src/commands/doctor.ts
2159
2990
  import { defineCommand as defineCommand8 } from "citty";
2160
- import { access, constants } from "fs/promises";
2161
- import { join as join3 } from "path";
2162
- import { homedir as homedir2 } from "os";
2991
+ import { access, constants, readdir as readdir2 } from "fs/promises";
2992
+ import { join as join6 } from "path";
2993
+ import { homedir as homedir5 } from "os";
2994
+ import { execFile } from "child_process";
2995
+ import { promisify } from "util";
2996
+ var execFileAsync = promisify(execFile);
2163
2997
  async function checkNodeVersion() {
2164
2998
  const version = process.version;
2165
2999
  const major = Number(version.slice(1).split(".")[0]);
@@ -2216,8 +3050,8 @@ async function checkConfig(configManager2) {
2216
3050
  }
2217
3051
  }
2218
3052
  async function checkSessionsDir() {
2219
- const relayHome2 = process.env["RELAY_HOME"] ?? join3(homedir2(), ".relay");
2220
- const sessionsDir = join3(relayHome2, "sessions");
3053
+ const relayHome2 = process.env["RELAY_HOME"] ?? join6(homedir5(), ".relay");
3054
+ const sessionsDir = join6(relayHome2, "sessions");
2221
3055
  try {
2222
3056
  await access(sessionsDir, constants.W_OK);
2223
3057
  return {
@@ -2251,6 +3085,107 @@ async function checkMCPSDK() {
2251
3085
  };
2252
3086
  }
2253
3087
  }
3088
+ async function checkMCPServerCommands(configManager2) {
3089
+ const results = [];
3090
+ try {
3091
+ const config = await configManager2.getConfig();
3092
+ const mcpServers = config.mcpServers ?? {};
3093
+ for (const [name, server] of Object.entries(mcpServers)) {
3094
+ const command = server.command;
3095
+ try {
3096
+ await execFileAsync("which", [command]);
3097
+ results.push({
3098
+ label: `MCP server: ${name}`,
3099
+ ok: true,
3100
+ detail: `MCP server "${name}": command "${command}" found`
3101
+ });
3102
+ } catch {
3103
+ results.push({
3104
+ label: `MCP server: ${name}`,
3105
+ ok: true,
3106
+ detail: `MCP server "${name}": command "${command}" not found in PATH`,
3107
+ hint: `Ensure "${command}" is installed and available in your PATH`
3108
+ });
3109
+ }
3110
+ }
3111
+ } catch {
3112
+ }
3113
+ return results;
3114
+ }
3115
+ async function checkMCPServerInstantiation() {
3116
+ try {
3117
+ const { RelayMCPServer: RelayMCPServer2 } = await Promise.resolve().then(() => (init_server(), server_exports));
3118
+ if (typeof RelayMCPServer2 !== "function") {
3119
+ return {
3120
+ label: "MCP server",
3121
+ ok: false,
3122
+ detail: "MCP server module is not a constructor",
3123
+ hint: "Reinstall dependencies: pnpm install"
3124
+ };
3125
+ }
3126
+ return {
3127
+ label: "MCP server",
3128
+ ok: true,
3129
+ detail: "MCP server module loadable"
3130
+ };
3131
+ } catch (error) {
3132
+ return {
3133
+ label: "MCP server",
3134
+ ok: false,
3135
+ detail: `MCP server module not loadable: ${error instanceof Error ? error.message : String(error)}`,
3136
+ hint: "Reinstall dependencies: pnpm install"
3137
+ };
3138
+ }
3139
+ }
3140
+ async function checkBackendAuthEnv() {
3141
+ const envVars = {
3142
+ claude: "ANTHROPIC_API_KEY",
3143
+ codex: "OPENAI_API_KEY",
3144
+ gemini: "GEMINI_API_KEY"
3145
+ };
3146
+ const results = [];
3147
+ for (const [backend, envVar] of Object.entries(envVars)) {
3148
+ const value = process.env[envVar];
3149
+ if (value) {
3150
+ results.push({
3151
+ label: `Auth: ${backend}`,
3152
+ ok: true,
3153
+ detail: `${envVar} is set`
3154
+ });
3155
+ } else {
3156
+ results.push({
3157
+ label: `Auth: ${backend}`,
3158
+ ok: true,
3159
+ detail: `${envVar} not set`,
3160
+ hint: "API key not set; may use subscription auth"
3161
+ });
3162
+ }
3163
+ }
3164
+ return results;
3165
+ }
3166
+ async function checkSessionsDiskUsage() {
3167
+ const relayHome2 = process.env["RELAY_HOME"] ?? join6(homedir5(), ".relay");
3168
+ const sessionsDir = join6(relayHome2, "sessions");
3169
+ try {
3170
+ const entries = await readdir2(sessionsDir);
3171
+ const fileCount = entries.length;
3172
+ if (fileCount >= 100) {
3173
+ return {
3174
+ label: "Sessions disk usage",
3175
+ ok: true,
3176
+ detail: `${fileCount} session files in ${sessionsDir}`,
3177
+ hint: "Consider cleaning up old sessions: relay sessions list --limit 10"
3178
+ };
3179
+ }
3180
+ return {
3181
+ label: "Sessions disk usage",
3182
+ ok: true,
3183
+ detail: `${fileCount} session files in ${sessionsDir}`
3184
+ };
3185
+ } catch {
3186
+ return null;
3187
+ }
3188
+ }
2254
3189
  function createDoctorCommand(registry2, configManager2) {
2255
3190
  return defineCommand8({
2256
3191
  meta: {
@@ -2268,15 +3203,24 @@ function createDoctorCommand(registry2, configManager2) {
2268
3203
  checks.push(await checkConfig(configManager2));
2269
3204
  checks.push(await checkSessionsDir());
2270
3205
  checks.push(await checkMCPSDK());
3206
+ const mcpCommandChecks = await checkMCPServerCommands(configManager2);
3207
+ checks.push(...mcpCommandChecks);
3208
+ checks.push(await checkMCPServerInstantiation());
3209
+ const authChecks = await checkBackendAuthEnv();
3210
+ checks.push(...authChecks);
3211
+ const diskCheck = await checkSessionsDiskUsage();
3212
+ if (diskCheck) {
3213
+ checks.push(diskCheck);
3214
+ }
2271
3215
  let hasFailures = false;
2272
3216
  for (const check of checks) {
2273
3217
  const icon = check.ok ? "\u2713" : "\u2717";
2274
3218
  console.log(` ${icon} ${check.detail}`);
2275
3219
  if (!check.ok) {
2276
3220
  hasFailures = true;
2277
- if (check.hint) {
2278
- console.log(` ${check.hint}`);
2279
- }
3221
+ }
3222
+ if (check.hint) {
3223
+ console.log(` ${check.hint}`);
2280
3224
  }
2281
3225
  }
2282
3226
  if (hasFailures) {
@@ -2292,9 +3236,10 @@ function createDoctorCommand(registry2, configManager2) {
2292
3236
  }
2293
3237
 
2294
3238
  // src/commands/init.ts
3239
+ init_logger();
2295
3240
  import { defineCommand as defineCommand9 } from "citty";
2296
- import { mkdir as mkdir3, writeFile as writeFile3, access as access2, readFile as readFile3 } from "fs/promises";
2297
- import { join as join4 } from "path";
3241
+ import { mkdir as mkdir6, writeFile as writeFile6, access as access2, readFile as readFile6 } from "fs/promises";
3242
+ import { join as join7 } from "path";
2298
3243
  var DEFAULT_CONFIG2 = {
2299
3244
  defaultBackend: "claude",
2300
3245
  backends: {},
@@ -2308,8 +3253,8 @@ function createInitCommand() {
2308
3253
  },
2309
3254
  async run() {
2310
3255
  const projectDir = process.cwd();
2311
- const relayDir = join4(projectDir, ".relay");
2312
- const configPath = join4(relayDir, "config.json");
3256
+ const relayDir = join7(projectDir, ".relay");
3257
+ const configPath = join7(relayDir, "config.json");
2313
3258
  try {
2314
3259
  await access2(relayDir);
2315
3260
  logger.info(
@@ -2318,16 +3263,16 @@ function createInitCommand() {
2318
3263
  return;
2319
3264
  } catch {
2320
3265
  }
2321
- await mkdir3(relayDir, { recursive: true });
2322
- await writeFile3(
3266
+ await mkdir6(relayDir, { recursive: true });
3267
+ await writeFile6(
2323
3268
  configPath,
2324
3269
  JSON.stringify(DEFAULT_CONFIG2, null, 2) + "\n",
2325
3270
  "utf-8"
2326
3271
  );
2327
3272
  logger.success(`Created ${configPath}`);
2328
- const gitignorePath = join4(projectDir, ".gitignore");
3273
+ const gitignorePath = join7(projectDir, ".gitignore");
2329
3274
  try {
2330
- const gitignoreContent = await readFile3(gitignorePath, "utf-8");
3275
+ const gitignoreContent = await readFile6(gitignorePath, "utf-8");
2331
3276
  if (!gitignoreContent.includes(".relay/config.local.json")) {
2332
3277
  logger.info(
2333
3278
  'Tip: Add ".relay/config.local.json" to your .gitignore to keep local config out of version control.'
@@ -2346,25 +3291,23 @@ function createInitCommand() {
2346
3291
  // src/bin/relay.ts
2347
3292
  var processManager = new ProcessManager();
2348
3293
  var registry = new AdapterRegistry();
2349
- registry.register(new ClaudeAdapter(processManager));
2350
- registry.register(new CodexAdapter(processManager));
2351
- registry.register(new GeminiAdapter(processManager));
3294
+ registry.registerLazy("claude", () => new ClaudeAdapter(processManager));
3295
+ registry.registerLazy("codex", () => new CodexAdapter(processManager));
3296
+ registry.registerLazy("gemini", () => new GeminiAdapter(processManager));
2352
3297
  var sessionManager = new SessionManager();
2353
- var relayHome = process.env["RELAY_HOME"] ?? join5(homedir3(), ".relay");
2354
- var projectRelayDir = join5(process.cwd(), ".relay");
3298
+ var relayHome = process.env["RELAY_HOME"] ?? join8(homedir6(), ".relay");
3299
+ var projectRelayDir = join8(process.cwd(), ".relay");
2355
3300
  var configManager = new ConfigManager(relayHome, projectRelayDir);
2356
3301
  var authManager = new AuthManager(registry);
2357
3302
  var eventBus = new EventBus();
2358
3303
  var hooksEngine = new HooksEngine(eventBus, processManager);
2359
- var contextMonitor = new ContextMonitor(hooksEngine, {
2360
- enabled: false
2361
- // Will be enabled from config at runtime
2362
- });
3304
+ var contextMonitor = new ContextMonitor(hooksEngine);
2363
3305
  void configManager.getConfig().then((config) => {
2364
3306
  if (config.hooks) {
2365
3307
  hooksEngine.loadConfig(config.hooks);
2366
3308
  }
2367
3309
  if (config.contextMonitor) {
3310
+ contextMonitor = new ContextMonitor(hooksEngine, config.contextMonitor);
2368
3311
  }
2369
3312
  }).catch(() => {
2370
3313
  });
@@ -2375,9 +3318,9 @@ var main = defineCommand10({
2375
3318
  description: "Unified CLI proxy for Claude Code, Codex CLI, and Gemini CLI"
2376
3319
  },
2377
3320
  subCommands: {
2378
- claude: createBackendCommand("claude", registry, sessionManager, hooksEngine),
2379
- codex: createBackendCommand("codex", registry, sessionManager, hooksEngine),
2380
- gemini: createBackendCommand("gemini", registry, sessionManager, hooksEngine),
3321
+ claude: createBackendCommand("claude", registry, sessionManager, hooksEngine, contextMonitor),
3322
+ codex: createBackendCommand("codex", registry, sessionManager, hooksEngine, contextMonitor),
3323
+ gemini: createBackendCommand("gemini", registry, sessionManager, hooksEngine, contextMonitor),
2381
3324
  update: createUpdateCommand(registry),
2382
3325
  config: createConfigCommand(configManager),
2383
3326
  mcp: createMCPCommand(configManager, registry, sessionManager, hooksEngine, contextMonitor),