@rk0429/agentic-relay 0.2.0 → 0.3.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 +485 -51
  3. package/package.json +3 -1
package/README.md CHANGED
@@ -1,5 +1,10 @@
1
1
  # agentic-relay
2
2
 
3
+ [![CI](https://github.com/RK0429/agentic-relay/actions/workflows/ci.yml/badge.svg)](https://github.com/RK0429/agentic-relay/actions/workflows/ci.yml)
4
+ [![npm version](https://img.shields.io/npm/v/@rk0429/agentic-relay)](https://www.npmjs.com/package/@rk0429/agentic-relay)
5
+ [![License: Apache-2.0](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](https://opensource.org/licenses/Apache-2.0)
6
+ [![Node.js](https://img.shields.io/badge/node-%3E%3D22-brightgreen)](https://nodejs.org/)
7
+
3
8
  A unified CLI that brings Claude Code, Codex CLI, and Gemini CLI under a single interface -- solving tool fragmentation, enabling multi-layer sub-agent orchestration via MCP, and providing proactive context window monitoring.
4
9
 
5
10
  ## Why agentic-relay?
@@ -94,7 +99,9 @@ relay mcp list # List MCP servers
94
99
  relay mcp add <name> -- CMD # Add MCP server
95
100
  relay mcp remove <name> # Remove MCP server
96
101
  relay mcp sync # Sync MCP config to backends
97
- relay mcp serve # Start relay as MCP server (stdio)
102
+ relay mcp serve # Start as MCP server (stdio, default)
103
+ relay mcp serve --transport http # Start as MCP server (HTTP)
104
+ relay mcp serve --transport http --port 8080 # Custom port
98
105
  ```
99
106
 
100
107
  ## Configuration
@@ -144,9 +151,10 @@ Example configuration:
144
151
  | `RELAY_LOG_LEVEL` | Log level (debug/info/warn/error) | `info` |
145
152
  | `RELAY_MAX_DEPTH` | Max recursion depth in MCP server mode | `5` |
146
153
  | `RELAY_CONTEXT_THRESHOLD` | Context warning threshold (%) | `75` |
147
- | `ANTHROPIC_API_KEY` | Passed through to Claude Code | -- |
148
- | `OPENAI_API_KEY` | Passed through to Codex CLI | -- |
149
- | `GEMINI_API_KEY` | Passed through to Gemini CLI | -- |
154
+ | `RELAY_CLAUDE_PERMISSION_MODE` | Claude permission mode (`bypassPermissions` or `default`) | `bypassPermissions` |
155
+ | `ANTHROPIC_API_KEY` | Passed through to Claude Code (optional with subscription) | -- |
156
+ | `OPENAI_API_KEY` | Passed through to Codex CLI (optional with subscription) | -- |
157
+ | `GEMINI_API_KEY` | Passed through to Gemini CLI (optional with subscription) | -- |
150
158
 
151
159
  ## MCP Server Mode
152
160
 
@@ -309,7 +317,8 @@ src/
309
317
  - **Process management**: execa (interactive modes, Gemini CLI)
310
318
  - **Validation**: zod
311
319
  - **Logging**: consola
312
- - **Testing**: vitest (283 tests across 18 files)
320
+ - **Testing**: vitest (352 tests across 18 files)
321
+ - **Coverage**: @vitest/coverage-v8
313
322
 
314
323
  ## License
315
324
 
package/dist/relay.mjs CHANGED
@@ -273,22 +273,29 @@ var ClaudeAdapter = class extends BaseAdapter {
273
273
  env: this.buildCleanEnv(flags)
274
274
  });
275
275
  }
276
+ getPermissionMode() {
277
+ return process.env["RELAY_CLAUDE_PERMISSION_MODE"] === "default" ? "default" : "bypassPermissions";
278
+ }
276
279
  async execute(flags) {
277
280
  if (!flags.prompt) {
278
281
  throw new Error("execute requires a prompt (-p flag)");
279
282
  }
280
283
  const env = this.buildCleanEnv(flags);
284
+ const permissionMode = this.getPermissionMode();
281
285
  try {
286
+ const options = {
287
+ env,
288
+ cwd: process.cwd(),
289
+ ...flags.model ? { model: flags.model } : {},
290
+ ...flags.maxTurns ? { maxTurns: flags.maxTurns } : {}
291
+ };
292
+ if (permissionMode === "bypassPermissions") {
293
+ options.permissionMode = "bypassPermissions";
294
+ options.allowDangerouslySkipPermissions = true;
295
+ }
282
296
  const q = query({
283
297
  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
- }
298
+ options
292
299
  });
293
300
  let resultText = "";
294
301
  let sessionId = "";
@@ -319,6 +326,79 @@ var ClaudeAdapter = class extends BaseAdapter {
319
326
  };
320
327
  }
321
328
  }
329
+ async *executeStreaming(flags) {
330
+ if (!flags.prompt) {
331
+ throw new Error("executeStreaming requires a prompt (-p flag)");
332
+ }
333
+ const env = this.buildCleanEnv(flags);
334
+ const permissionMode = this.getPermissionMode();
335
+ try {
336
+ const options = {
337
+ env,
338
+ cwd: process.cwd(),
339
+ ...flags.model ? { model: flags.model } : {},
340
+ ...flags.maxTurns ? { maxTurns: flags.maxTurns } : {}
341
+ };
342
+ if (permissionMode === "bypassPermissions") {
343
+ options.permissionMode = "bypassPermissions";
344
+ options.allowDangerouslySkipPermissions = true;
345
+ }
346
+ const q = query({
347
+ prompt: flags.prompt,
348
+ options
349
+ });
350
+ for await (const message of q) {
351
+ if (message.type === "assistant") {
352
+ const betaMessage = message.message;
353
+ if (betaMessage?.content) {
354
+ for (const block of betaMessage.content) {
355
+ if (block.type === "text" && block.text) {
356
+ yield { type: "text", text: block.text };
357
+ }
358
+ }
359
+ }
360
+ if (betaMessage?.usage) {
361
+ yield {
362
+ type: "usage",
363
+ inputTokens: betaMessage.usage.input_tokens ?? 0,
364
+ outputTokens: betaMessage.usage.output_tokens ?? 0
365
+ };
366
+ }
367
+ } else if (message.type === "tool_progress") {
368
+ const toolName = message.tool_name ?? "unknown";
369
+ yield {
370
+ type: "status",
371
+ message: `Running ${toolName}...`
372
+ };
373
+ } else if (message.type === "result") {
374
+ if (message.subtype === "success") {
375
+ const resultText = message.result;
376
+ yield {
377
+ type: "done",
378
+ result: { exitCode: 0, stdout: resultText, stderr: "" }
379
+ };
380
+ } else {
381
+ const errors = message.errors;
382
+ yield {
383
+ type: "done",
384
+ result: {
385
+ exitCode: 1,
386
+ stdout: "",
387
+ stderr: errors.join("\n")
388
+ }
389
+ };
390
+ }
391
+ }
392
+ }
393
+ } catch (error) {
394
+ const errorMessage = error instanceof Error ? error.message : String(error);
395
+ yield { type: "error", message: errorMessage };
396
+ yield {
397
+ type: "done",
398
+ result: { exitCode: 1, stdout: "", stderr: errorMessage }
399
+ };
400
+ }
401
+ }
322
402
  async resumeSession(sessionId, flags) {
323
403
  await this.processManager.spawnInteractive(
324
404
  this.command,
@@ -430,6 +510,106 @@ var CodexAdapter = class extends BaseAdapter {
430
510
  };
431
511
  }
432
512
  }
513
+ async *executeStreaming(flags) {
514
+ if (!flags.prompt) {
515
+ throw new Error("executeStreaming requires a prompt (-p flag)");
516
+ }
517
+ if (flags.agent) {
518
+ logger.warn(
519
+ `Codex CLI does not support --agent flag. Ignoring agent "${flags.agent}".`
520
+ );
521
+ }
522
+ try {
523
+ const codexOptions = {};
524
+ if (flags.mcpContext) {
525
+ codexOptions.env = {
526
+ ...process.env,
527
+ RELAY_TRACE_ID: flags.mcpContext.traceId,
528
+ RELAY_PARENT_SESSION_ID: flags.mcpContext.parentSessionId,
529
+ RELAY_DEPTH: String(flags.mcpContext.depth)
530
+ };
531
+ }
532
+ const codex = new Codex(codexOptions);
533
+ const thread = codex.startThread({
534
+ ...flags.model ? { model: flags.model } : {},
535
+ workingDirectory: process.cwd(),
536
+ approvalPolicy: "never"
537
+ });
538
+ const streamedTurn = await thread.runStreamed(flags.prompt);
539
+ const completedMessages = [];
540
+ for await (const event of streamedTurn.events) {
541
+ if (event.type === "item.started") {
542
+ const item = event.item;
543
+ if (item?.type === "agent_message" && item.text) {
544
+ yield { type: "text", text: item.text };
545
+ } else if (item?.type === "command_execution") {
546
+ yield {
547
+ type: "tool_start",
548
+ tool: item.command ?? "command",
549
+ id: item.id ?? ""
550
+ };
551
+ } else if (item?.type === "file_change") {
552
+ yield {
553
+ type: "tool_start",
554
+ tool: "file_change",
555
+ id: item.id ?? ""
556
+ };
557
+ }
558
+ } else if (event.type === "item.completed") {
559
+ const item = event.item;
560
+ if (item?.type === "agent_message" && item.text) {
561
+ completedMessages.push(item.text);
562
+ yield { type: "text", text: item.text };
563
+ } else if (item?.type === "command_execution") {
564
+ yield {
565
+ type: "tool_end",
566
+ tool: item.command ?? "command",
567
+ id: item.id ?? "",
568
+ result: item.aggregated_output
569
+ };
570
+ } else if (item?.type === "file_change") {
571
+ yield {
572
+ type: "tool_end",
573
+ tool: "file_change",
574
+ id: item.id ?? ""
575
+ };
576
+ }
577
+ } else if (event.type === "turn.completed") {
578
+ const usage = event.usage;
579
+ if (usage) {
580
+ yield {
581
+ type: "usage",
582
+ inputTokens: (usage.input_tokens ?? 0) + (usage.cached_input_tokens ?? 0),
583
+ outputTokens: usage.output_tokens ?? 0
584
+ };
585
+ }
586
+ const finalResponse = completedMessages.join("\n");
587
+ yield {
588
+ type: "done",
589
+ result: { exitCode: 0, stdout: finalResponse, stderr: "" }
590
+ };
591
+ } else if (event.type === "turn.failed") {
592
+ const errorMessage = event.error?.message ?? "Turn failed";
593
+ yield {
594
+ type: "done",
595
+ result: { exitCode: 1, stdout: "", stderr: errorMessage }
596
+ };
597
+ } else if (event.type === "error") {
598
+ yield {
599
+ type: "error",
600
+ message: event.message ?? "Unknown error"
601
+ };
602
+ }
603
+ }
604
+ } catch (error) {
605
+ const errorMessage = error instanceof Error ? error.message : String(error);
606
+ yield { type: "error", message: errorMessage };
607
+ yield {
608
+ type: "done",
609
+ result: { exitCode: 1, stdout: "", stderr: errorMessage }
610
+ };
611
+ }
612
+ }
433
613
  async resumeSession(sessionId, flags) {
434
614
  const args = [];
435
615
  if (flags.model) {
@@ -579,7 +759,7 @@ var GeminiAdapter = class extends BaseAdapter {
579
759
  };
580
760
 
581
761
  // src/core/session-manager.ts
582
- import { readFile, writeFile, readdir, mkdir } from "fs/promises";
762
+ import { readFile, writeFile, readdir, mkdir, chmod } from "fs/promises";
583
763
  import { join } from "path";
584
764
  import { homedir } from "os";
585
765
  import { nanoid } from "nanoid";
@@ -603,7 +783,8 @@ function fromSessionData(data) {
603
783
  updatedAt: new Date(data.updatedAt)
604
784
  };
605
785
  }
606
- var SessionManager = class {
786
+ var SessionManager = class _SessionManager {
787
+ static SESSION_ID_PATTERN = /^relay-[A-Za-z0-9_-]+$/;
607
788
  sessionsDir;
608
789
  constructor(sessionsDir) {
609
790
  this.sessionsDir = sessionsDir ?? getSessionsDir(getRelayHome());
@@ -613,6 +794,9 @@ var SessionManager = class {
613
794
  await mkdir(this.sessionsDir, { recursive: true });
614
795
  }
615
796
  sessionPath(relaySessionId) {
797
+ if (!_SessionManager.SESSION_ID_PATTERN.test(relaySessionId)) {
798
+ throw new Error(`Invalid session ID: ${relaySessionId}`);
799
+ }
616
800
  return join(this.sessionsDir, `${relaySessionId}.json`);
617
801
  }
618
802
  /** Create a new relay session. */
@@ -629,11 +813,13 @@ var SessionManager = class {
629
813
  updatedAt: now,
630
814
  status: "active"
631
815
  };
816
+ const sessionFilePath = this.sessionPath(session.relaySessionId);
632
817
  await writeFile(
633
- this.sessionPath(session.relaySessionId),
818
+ sessionFilePath,
634
819
  JSON.stringify(toSessionData(session), null, 2),
635
820
  "utf-8"
636
821
  );
822
+ await chmod(sessionFilePath, 384);
637
823
  return session;
638
824
  }
639
825
  /** Update an existing session. */
@@ -647,16 +833,19 @@ var SessionManager = class {
647
833
  ...updates,
648
834
  updatedAt: /* @__PURE__ */ new Date()
649
835
  };
836
+ const updateFilePath = this.sessionPath(relaySessionId);
650
837
  await writeFile(
651
- this.sessionPath(relaySessionId),
838
+ updateFilePath,
652
839
  JSON.stringify(toSessionData(updated), null, 2),
653
840
  "utf-8"
654
841
  );
842
+ await chmod(updateFilePath, 384);
655
843
  }
656
844
  /** Get a session by relay session ID. */
657
845
  async get(relaySessionId) {
846
+ const filePath = this.sessionPath(relaySessionId);
658
847
  try {
659
- const raw = await readFile(this.sessionPath(relaySessionId), "utf-8");
848
+ const raw = await readFile(filePath, "utf-8");
660
849
  return fromSessionData(JSON.parse(raw));
661
850
  } catch {
662
851
  return null;
@@ -700,7 +889,7 @@ var SessionManager = class {
700
889
  };
701
890
 
702
891
  // src/core/config-manager.ts
703
- import { readFile as readFile2, writeFile as writeFile2, mkdir as mkdir2 } from "fs/promises";
892
+ import { readFile as readFile2, writeFile as writeFile2, mkdir as mkdir2, chmod as chmod2 } from "fs/promises";
704
893
  import { join as join2 } from "path";
705
894
 
706
895
  // src/schemas/config.schema.ts
@@ -752,7 +941,8 @@ var relayConfigSchema = z.object({
752
941
  }).optional(),
753
942
  telemetry: z.object({
754
943
  enabled: z.boolean()
755
- }).optional()
944
+ }).optional(),
945
+ claudePermissionMode: z.enum(["default", "bypassPermissions"]).optional()
756
946
  });
757
947
 
758
948
  // src/core/config-manager.ts
@@ -845,6 +1035,7 @@ var ConfigManager = class {
845
1035
  const dir = filePath.substring(0, filePath.lastIndexOf("/"));
846
1036
  await mkdir2(dir, { recursive: true });
847
1037
  await writeFile2(filePath, JSON.stringify(existing, null, 2), "utf-8");
1038
+ await chmod2(filePath, 384);
848
1039
  }
849
1040
  /**
850
1041
  * Syncs MCP server config to all registered backend adapters.
@@ -998,18 +1189,54 @@ var DEFAULT_HOOK_OUTPUT = {
998
1189
  message: "",
999
1190
  metadata: {}
1000
1191
  };
1001
- var HooksEngine = class {
1192
+ var HooksEngine = class _HooksEngine {
1002
1193
  constructor(eventBus2, processManager2) {
1003
1194
  this.eventBus = eventBus2;
1004
1195
  this.processManager = processManager2;
1005
1196
  }
1197
+ static COMMAND_PATTERN = /^[a-zA-Z0-9_./-]+$/;
1198
+ static ARG_PATTERN = /^[a-zA-Z0-9_.=:/-]+$/;
1006
1199
  definitions = [];
1007
1200
  registered = false;
1201
+ validateCommand(command) {
1202
+ if (!command || command.trim().length === 0) {
1203
+ throw new Error("Hook command cannot be empty");
1204
+ }
1205
+ if (!_HooksEngine.COMMAND_PATTERN.test(command)) {
1206
+ throw new Error(
1207
+ `Hook command contains invalid characters: "${command}". Only alphanumeric characters, dots, underscores, hyphens, and slashes are allowed.`
1208
+ );
1209
+ }
1210
+ if (command.includes("..")) {
1211
+ throw new Error(
1212
+ `Hook command contains path traversal: "${command}"`
1213
+ );
1214
+ }
1215
+ }
1216
+ validateArgs(args) {
1217
+ for (const arg of args) {
1218
+ if (!_HooksEngine.ARG_PATTERN.test(arg)) {
1219
+ throw new Error(
1220
+ `Hook argument contains invalid characters: "${arg}". Only alphanumeric characters, dots, underscores, equals, colons, hyphens, and slashes are allowed.`
1221
+ );
1222
+ }
1223
+ }
1224
+ }
1008
1225
  /** Load hook definitions from config and register listeners on EventBus */
1009
1226
  loadConfig(config) {
1010
- this.definitions = config.definitions.filter(
1011
- (def) => def.enabled !== false
1012
- );
1227
+ this.definitions = config.definitions.filter((def) => {
1228
+ if (def.enabled === false) return false;
1229
+ try {
1230
+ this.validateCommand(def.command);
1231
+ this.validateArgs(def.args ?? []);
1232
+ return true;
1233
+ } catch (error) {
1234
+ logger.warn(
1235
+ `Skipping hook with invalid command: ${error instanceof Error ? error.message : String(error)}`
1236
+ );
1237
+ return false;
1238
+ }
1239
+ });
1013
1240
  if (!this.registered) {
1014
1241
  for (const event of this.getUniqueEvents()) {
1015
1242
  this.eventBus.on(event, async () => {
@@ -1056,6 +1283,8 @@ var HooksEngine = class {
1056
1283
  return this.definitions.filter((def) => def.event === event).length;
1057
1284
  }
1058
1285
  async executeHook(def, input) {
1286
+ this.validateCommand(def.command);
1287
+ this.validateArgs(def.args ?? []);
1059
1288
  const timeoutMs = def.timeout ?? DEFAULT_TIMEOUT_MS;
1060
1289
  const stdinData = JSON.stringify(input);
1061
1290
  const startTime = Date.now();
@@ -1167,7 +1396,7 @@ var INSTALL_GUIDES = {
1167
1396
  };
1168
1397
 
1169
1398
  // src/commands/backend.ts
1170
- function createBackendCommand(backendId, registry2, sessionManager2, hooksEngine2) {
1399
+ function createBackendCommand(backendId, registry2, sessionManager2, hooksEngine2, contextMonitor2) {
1171
1400
  return defineCommand({
1172
1401
  meta: {
1173
1402
  name: backendId,
@@ -1262,10 +1491,57 @@ function createBackendCommand(backendId, registry2, sessionManager2, hooksEngine
1262
1491
  try {
1263
1492
  if (flags.prompt) {
1264
1493
  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;
1494
+ if (adapter.executeStreaming) {
1495
+ for await (const event of adapter.executeStreaming(flags)) {
1496
+ switch (event.type) {
1497
+ case "text":
1498
+ process.stdout.write(event.text);
1499
+ break;
1500
+ case "tool_start":
1501
+ if (flags.verbose) {
1502
+ process.stderr.write(`
1503
+ [${event.tool}] started
1504
+ `);
1505
+ }
1506
+ break;
1507
+ case "tool_end":
1508
+ if (flags.verbose) {
1509
+ process.stderr.write(`[${event.tool}] completed
1510
+ `);
1511
+ }
1512
+ break;
1513
+ case "status":
1514
+ if (flags.verbose) {
1515
+ process.stderr.write(`[status] ${event.message}
1516
+ `);
1517
+ }
1518
+ break;
1519
+ case "error":
1520
+ process.stderr.write(event.message);
1521
+ break;
1522
+ case "usage": {
1523
+ if (contextMonitor2 && relaySessionId) {
1524
+ const maxTokens = backendId === "gemini" ? 128e3 : 2e5;
1525
+ contextMonitor2.updateUsage(
1526
+ relaySessionId,
1527
+ backendId,
1528
+ event.inputTokens + event.outputTokens,
1529
+ maxTokens
1530
+ );
1531
+ }
1532
+ break;
1533
+ }
1534
+ case "done":
1535
+ process.exitCode = event.result.exitCode;
1536
+ break;
1537
+ }
1538
+ }
1539
+ } else {
1540
+ const result = await adapter.execute(flags);
1541
+ if (result.stdout) process.stdout.write(result.stdout);
1542
+ if (result.stderr) process.stderr.write(result.stderr);
1543
+ process.exitCode = result.exitCode;
1544
+ }
1269
1545
  } else if (flags.continue) {
1270
1546
  logger.debug(`Continuing latest session on ${backendId}`);
1271
1547
  if (sessionManager2) {
@@ -1345,13 +1621,66 @@ function createBackendCommand(backendId, registry2, sessionManager2, hooksEngine
1345
1621
 
1346
1622
  // src/commands/update.ts
1347
1623
  import { defineCommand as defineCommand2 } from "citty";
1624
+
1625
+ // src/infrastructure/version-check.ts
1626
+ async function getLatestNpmVersion(packageName) {
1627
+ try {
1628
+ const response = await fetch(
1629
+ `https://registry.npmjs.org/${packageName}/latest`,
1630
+ {
1631
+ headers: { Accept: "application/json" },
1632
+ signal: AbortSignal.timeout(5e3)
1633
+ }
1634
+ );
1635
+ if (!response.ok) {
1636
+ logger.debug(
1637
+ `npm registry returned ${response.status} for ${packageName}`
1638
+ );
1639
+ return null;
1640
+ }
1641
+ const data = await response.json();
1642
+ return data.version ?? null;
1643
+ } catch (error) {
1644
+ logger.debug(
1645
+ `Failed to check npm registry: ${error instanceof Error ? error.message : String(error)}`
1646
+ );
1647
+ return null;
1648
+ }
1649
+ }
1650
+ function compareSemver(a, b) {
1651
+ const partsA = a.replace(/^v/, "").split(".").map(Number);
1652
+ const partsB = b.replace(/^v/, "").split(".").map(Number);
1653
+ for (let i = 0; i < 3; i++) {
1654
+ const diff = (partsA[i] ?? 0) - (partsB[i] ?? 0);
1655
+ if (diff !== 0) return diff;
1656
+ }
1657
+ return 0;
1658
+ }
1659
+
1660
+ // src/commands/update.ts
1661
+ var PACKAGE_NAME = "@rk0429/agentic-relay";
1662
+ var CURRENT_VERSION = "0.3.0";
1348
1663
  function createUpdateCommand(registry2) {
1349
1664
  return defineCommand2({
1350
1665
  meta: {
1351
1666
  name: "update",
1352
- description: "Update all installed backend CLI tools"
1667
+ description: "Update relay and all installed backend CLI tools"
1353
1668
  },
1354
1669
  async run() {
1670
+ logger.info("Checking for relay updates...");
1671
+ const latestVersion = await getLatestNpmVersion(PACKAGE_NAME);
1672
+ if (latestVersion) {
1673
+ if (compareSemver(latestVersion, CURRENT_VERSION) > 0) {
1674
+ logger.warn(
1675
+ `A newer version of relay is available: ${latestVersion} (current: ${CURRENT_VERSION})`
1676
+ );
1677
+ logger.info(` Run: npm install -g ${PACKAGE_NAME}@latest`);
1678
+ } else {
1679
+ logger.success(`relay is up to date (${CURRENT_VERSION})`);
1680
+ }
1681
+ } else {
1682
+ logger.debug("Could not check for relay updates");
1683
+ }
1355
1684
  const adapters = registry2.list();
1356
1685
  if (adapters.length === 0) {
1357
1686
  logger.warn("No backends registered");
@@ -1465,11 +1794,14 @@ import { defineCommand as defineCommand4 } from "citty";
1465
1794
  // src/mcp-server/server.ts
1466
1795
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
1467
1796
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
1797
+ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
1798
+ import { createServer } from "http";
1799
+ import { randomUUID } from "crypto";
1468
1800
  import { z as z5 } from "zod";
1469
1801
 
1470
1802
  // src/mcp-server/recursion-guard.ts
1471
1803
  import { createHash } from "crypto";
1472
- var RecursionGuard = class {
1804
+ var RecursionGuard = class _RecursionGuard {
1473
1805
  constructor(config = {
1474
1806
  maxDepth: 5,
1475
1807
  maxCallsPerSession: 20,
@@ -1477,17 +1809,50 @@ var RecursionGuard = class {
1477
1809
  }) {
1478
1810
  this.config = config;
1479
1811
  }
1812
+ static MAX_ENTRIES = 1e3;
1480
1813
  callCounts = /* @__PURE__ */ new Map();
1481
1814
  promptHashes = /* @__PURE__ */ new Map();
1815
+ /** Remove entries older than timeoutSec and enforce size limits */
1816
+ cleanup() {
1817
+ const now = Date.now();
1818
+ const ttlMs = this.config.timeoutSec * 1e3;
1819
+ for (const [key, value] of this.callCounts) {
1820
+ if (now - value.createdAt > ttlMs) {
1821
+ this.callCounts.delete(key);
1822
+ }
1823
+ }
1824
+ for (const [key, value] of this.promptHashes) {
1825
+ if (now - value.createdAt > ttlMs) {
1826
+ this.promptHashes.delete(key);
1827
+ }
1828
+ }
1829
+ if (this.callCounts.size > _RecursionGuard.MAX_ENTRIES) {
1830
+ const sorted = [...this.callCounts.entries()].sort(
1831
+ (a, b) => a[1].createdAt - b[1].createdAt
1832
+ );
1833
+ for (let i = 0; i < sorted.length - _RecursionGuard.MAX_ENTRIES; i++) {
1834
+ this.callCounts.delete(sorted[i][0]);
1835
+ }
1836
+ }
1837
+ if (this.promptHashes.size > _RecursionGuard.MAX_ENTRIES) {
1838
+ const sorted = [...this.promptHashes.entries()].sort(
1839
+ (a, b) => a[1].createdAt - b[1].createdAt
1840
+ );
1841
+ for (let i = 0; i < sorted.length - _RecursionGuard.MAX_ENTRIES; i++) {
1842
+ this.promptHashes.delete(sorted[i][0]);
1843
+ }
1844
+ }
1845
+ }
1482
1846
  /** Check if a spawn is allowed */
1483
1847
  canSpawn(context) {
1848
+ this.cleanup();
1484
1849
  if (context.depth >= this.config.maxDepth) {
1485
1850
  return {
1486
1851
  allowed: false,
1487
1852
  reason: `Max depth exceeded: ${context.depth} >= ${this.config.maxDepth}`
1488
1853
  };
1489
1854
  }
1490
- const currentCount = this.callCounts.get(context.traceId) ?? 0;
1855
+ const currentCount = this.callCounts.get(context.traceId)?.count ?? 0;
1491
1856
  if (currentCount >= this.config.maxCallsPerSession) {
1492
1857
  return {
1493
1858
  allowed: false,
@@ -1504,17 +1869,25 @@ var RecursionGuard = class {
1504
1869
  }
1505
1870
  /** Record a spawn invocation */
1506
1871
  recordSpawn(context) {
1507
- const currentCount = this.callCounts.get(context.traceId) ?? 0;
1508
- this.callCounts.set(context.traceId, currentCount + 1);
1872
+ this.cleanup();
1873
+ const entry = this.callCounts.get(context.traceId);
1874
+ if (entry) {
1875
+ entry.count += 1;
1876
+ } else {
1877
+ this.callCounts.set(context.traceId, { count: 1, createdAt: Date.now() });
1878
+ }
1509
1879
  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);
1880
+ const hashEntry = this.promptHashes.get(context.traceId);
1881
+ if (hashEntry) {
1882
+ hashEntry.hashes.push(key);
1883
+ } else {
1884
+ this.promptHashes.set(context.traceId, { hashes: [key], createdAt: Date.now() });
1885
+ }
1513
1886
  }
1514
1887
  /** Detect if the same (backend + promptHash) combination has appeared 3+ times */
1515
1888
  detectLoop(traceId, backend, promptHash) {
1516
1889
  const key = `${backend}:${promptHash}`;
1517
- const hashes = this.promptHashes.get(traceId) ?? [];
1890
+ const hashes = this.promptHashes.get(traceId)?.hashes ?? [];
1518
1891
  const count = hashes.filter((h) => h === key).length;
1519
1892
  return count >= 3;
1520
1893
  }
@@ -1524,7 +1897,7 @@ var RecursionGuard = class {
1524
1897
  }
1525
1898
  /** Get call count for a trace */
1526
1899
  getCallCount(traceId) {
1527
- return this.callCounts.get(traceId) ?? 0;
1900
+ return this.callCounts.get(traceId)?.count ?? 0;
1528
1901
  }
1529
1902
  /** Utility: compute a prompt hash */
1530
1903
  static hashPrompt(prompt) {
@@ -1549,7 +1922,7 @@ function buildContextFromEnv() {
1549
1922
  const depth = Number(process.env["RELAY_DEPTH"] ?? "0");
1550
1923
  return { traceId, parentSessionId, depth };
1551
1924
  }
1552
- async function executeSpawnAgent(input, registry2, sessionManager2, guard, hooksEngine2) {
1925
+ async function executeSpawnAgent(input, registry2, sessionManager2, guard, hooksEngine2, contextMonitor2) {
1553
1926
  const envContext = buildContextFromEnv();
1554
1927
  const promptHash = RecursionGuard.hashPrompt(input.prompt);
1555
1928
  const context = {
@@ -1617,6 +1990,18 @@ async function executeSpawnAgent(input, registry2, sessionManager2, guard, hooks
1617
1990
  traceId: envContext.traceId
1618
1991
  }
1619
1992
  });
1993
+ if (contextMonitor2) {
1994
+ const estimatedTokens = Math.ceil(
1995
+ (result.stdout.length + result.stderr.length) / 4
1996
+ );
1997
+ const maxTokens = input.backend === "gemini" ? 128e3 : 2e5;
1998
+ contextMonitor2.updateUsage(
1999
+ session.relaySessionId,
2000
+ input.backend,
2001
+ estimatedTokens,
2002
+ maxTokens
2003
+ );
2004
+ }
1620
2005
  guard.recordSpawn(context);
1621
2006
  const status = result.exitCode === 0 ? "completed" : "error";
1622
2007
  await sessionManager2.update(session.relaySessionId, { status });
@@ -1715,7 +2100,7 @@ var RelayMCPServer = class {
1715
2100
  this.guard = new RecursionGuard(guardConfig);
1716
2101
  this.server = new McpServer({
1717
2102
  name: "agentic-relay",
1718
- version: "0.1.0"
2103
+ version: "0.3.0"
1719
2104
  });
1720
2105
  this.registerTools();
1721
2106
  }
@@ -1740,7 +2125,8 @@ var RelayMCPServer = class {
1740
2125
  this.registry,
1741
2126
  this.sessionManager,
1742
2127
  this.guard,
1743
- this.hooksEngine
2128
+ this.hooksEngine,
2129
+ this.contextMonitor
1744
2130
  );
1745
2131
  const isError = result.exitCode !== 0;
1746
2132
  const text = isError ? `Error (exit ${result.exitCode}): ${result.stderr || result.stdout}` : `Session: ${result.sessionId}
@@ -1820,11 +2206,47 @@ ${result.stdout}`;
1820
2206
  }
1821
2207
  );
1822
2208
  }
1823
- async start() {
1824
- logger.info("Starting agentic-relay MCP server (stdio transport)...");
1825
- const transport = new StdioServerTransport();
1826
- await this.server.connect(transport);
2209
+ async start(options) {
2210
+ const transportType = options?.transport ?? "stdio";
2211
+ if (transportType === "stdio") {
2212
+ logger.info("Starting agentic-relay MCP server (stdio transport)...");
2213
+ const transport = new StdioServerTransport();
2214
+ await this.server.connect(transport);
2215
+ return;
2216
+ }
2217
+ const port = options?.port ?? 3100;
2218
+ logger.info(
2219
+ `Starting agentic-relay MCP server (HTTP transport on port ${port})...`
2220
+ );
2221
+ const httpTransport = new StreamableHTTPServerTransport({
2222
+ sessionIdGenerator: () => randomUUID()
2223
+ });
2224
+ const httpServer = createServer(async (req, res) => {
2225
+ const url = req.url ?? "";
2226
+ if (url === "/mcp" || url.startsWith("/mcp?")) {
2227
+ await httpTransport.handleRequest(req, res);
2228
+ } else {
2229
+ res.writeHead(404, { "Content-Type": "text/plain" });
2230
+ res.end("Not found");
2231
+ }
2232
+ });
2233
+ this._httpServer = httpServer;
2234
+ await this.server.connect(httpTransport);
2235
+ await new Promise((resolve) => {
2236
+ httpServer.listen(port, () => {
2237
+ logger.info(`MCP server listening on http://localhost:${port}/mcp`);
2238
+ resolve();
2239
+ });
2240
+ });
2241
+ await new Promise((resolve) => {
2242
+ httpServer.on("close", resolve);
2243
+ });
2244
+ }
2245
+ /** Exposed for testing and graceful shutdown */
2246
+ get httpServer() {
2247
+ return this._httpServer;
1827
2248
  }
2249
+ _httpServer;
1828
2250
  };
1829
2251
 
1830
2252
  // src/commands/mcp.ts
@@ -1948,9 +2370,21 @@ function createMCPCommand(configManager2, registry2, sessionManager2, hooksEngin
1948
2370
  serve: defineCommand4({
1949
2371
  meta: {
1950
2372
  name: "serve",
1951
- description: "Start relay as an MCP server (stdio transport)"
2373
+ description: "Start relay as an MCP server"
1952
2374
  },
1953
- async run() {
2375
+ args: {
2376
+ transport: {
2377
+ type: "string",
2378
+ description: "Transport type: stdio or http (default: stdio)",
2379
+ default: "stdio"
2380
+ },
2381
+ port: {
2382
+ type: "string",
2383
+ description: "Port for HTTP transport (default: 3100)",
2384
+ default: "3100"
2385
+ }
2386
+ },
2387
+ async run({ args }) {
1954
2388
  if (!sessionManager2) {
1955
2389
  logger.error("SessionManager is required for MCP server mode");
1956
2390
  process.exitCode = 1;
@@ -1968,6 +2402,8 @@ function createMCPCommand(configManager2, registry2, sessionManager2, hooksEngin
1968
2402
  }
1969
2403
  } catch {
1970
2404
  }
2405
+ const transport = args.transport === "http" ? "http" : "stdio";
2406
+ const port = parseInt(String(args.port), 10) || 3100;
1971
2407
  const server = new RelayMCPServer(
1972
2408
  registry2,
1973
2409
  sessionManager2,
@@ -1975,7 +2411,7 @@ function createMCPCommand(configManager2, registry2, sessionManager2, hooksEngin
1975
2411
  hooksEngine2,
1976
2412
  contextMonitor2
1977
2413
  );
1978
- await server.start();
2414
+ await server.start({ transport, port });
1979
2415
  }
1980
2416
  })
1981
2417
  },
@@ -2356,15 +2792,13 @@ var configManager = new ConfigManager(relayHome, projectRelayDir);
2356
2792
  var authManager = new AuthManager(registry);
2357
2793
  var eventBus = new EventBus();
2358
2794
  var hooksEngine = new HooksEngine(eventBus, processManager);
2359
- var contextMonitor = new ContextMonitor(hooksEngine, {
2360
- enabled: false
2361
- // Will be enabled from config at runtime
2362
- });
2795
+ var contextMonitor = new ContextMonitor(hooksEngine);
2363
2796
  void configManager.getConfig().then((config) => {
2364
2797
  if (config.hooks) {
2365
2798
  hooksEngine.loadConfig(config.hooks);
2366
2799
  }
2367
2800
  if (config.contextMonitor) {
2801
+ contextMonitor = new ContextMonitor(hooksEngine, config.contextMonitor);
2368
2802
  }
2369
2803
  }).catch(() => {
2370
2804
  });
@@ -2375,9 +2809,9 @@ var main = defineCommand10({
2375
2809
  description: "Unified CLI proxy for Claude Code, Codex CLI, and Gemini CLI"
2376
2810
  },
2377
2811
  subCommands: {
2378
- claude: createBackendCommand("claude", registry, sessionManager, hooksEngine),
2379
- codex: createBackendCommand("codex", registry, sessionManager, hooksEngine),
2380
- gemini: createBackendCommand("gemini", registry, sessionManager, hooksEngine),
2812
+ claude: createBackendCommand("claude", registry, sessionManager, hooksEngine, contextMonitor),
2813
+ codex: createBackendCommand("codex", registry, sessionManager, hooksEngine, contextMonitor),
2814
+ gemini: createBackendCommand("gemini", registry, sessionManager, hooksEngine, contextMonitor),
2381
2815
  update: createUpdateCommand(registry),
2382
2816
  config: createConfigCommand(configManager),
2383
2817
  mcp: createMCPCommand(configManager, registry, sessionManager, hooksEngine, contextMonitor),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rk0429/agentic-relay",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Unified CLI proxy for Claude Code, Codex CLI, and Gemini CLI with MCP-based multi-layer sub-agent orchestration",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -38,6 +38,7 @@
38
38
  "build": "tsup",
39
39
  "dev": "tsup --watch",
40
40
  "test": "vitest run",
41
+ "test:coverage": "vitest run --coverage",
41
42
  "test:watch": "vitest",
42
43
  "lint": "tsc --noEmit",
43
44
  "prepublishOnly": "pnpm test && pnpm build"
@@ -57,6 +58,7 @@
57
58
  },
58
59
  "devDependencies": {
59
60
  "@types/node": "^25.3.0",
61
+ "@vitest/coverage-v8": "^3.2.4",
60
62
  "tsup": "^8.4.0",
61
63
  "typescript": "^5.7.3",
62
64
  "vitest": "^3.0.7"