@rk0429/agentic-relay 0.1.2 → 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 +26 -8
  2. package/dist/relay.mjs +596 -63
  3. package/package.json +5 -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
 
@@ -199,10 +207,10 @@ sequenceDiagram
199
207
  User->>Parent: Task request
200
208
  Parent->>Relay: spawn_agent(codex, prompt)
201
209
  Note over Relay: RecursionGuard check<br>depth=1, calls=1
202
- Relay->>Child: relay codex -p "prompt"<br>RELAY_DEPTH=1
210
+ Relay->>Child: Codex SDK thread.run("prompt")<br>RELAY_DEPTH=1
203
211
  Child->>Relay2: spawn_agent(gemini, sub-prompt)
204
212
  Note over Relay2: RecursionGuard check<br>depth=2, calls=1
205
- Relay2->>Grandchild: relay gemini -p "sub-prompt"<br>RELAY_DEPTH=2
213
+ Relay2->>Grandchild: gemini -p "sub-prompt"<br>RELAY_DEPTH=2
206
214
  Grandchild-->>Relay2: Result
207
215
  Relay2-->>Child: Result
208
216
  Child-->>Relay: Result
@@ -229,6 +237,14 @@ graph TD
229
237
 
230
238
  Each backend CLI has different flags, output formats, and session handling. The adapter layer normalizes these differences behind a common `BackendAdapter` interface. Adding a new backend means implementing this interface and registering it with the `AdapterRegistry`.
231
239
 
240
+ Non-interactive execution uses official SDKs where available:
241
+
242
+ | Backend | Non-interactive (`-p`) | Interactive | Session listing |
243
+ |---|---|---|---|
244
+ | Claude Code | Agent SDK `query()` | CLI spawn | Agent SDK `listSessions()` |
245
+ | Codex CLI | Codex SDK `thread.run()` | CLI spawn | -- |
246
+ | Gemini CLI | CLI spawn | CLI spawn | CLI `--list-sessions` |
247
+
232
248
  ### Hooks Engine
233
249
 
234
250
  An event-driven hook system that executes external commands via stdin/stdout JSON pipes.
@@ -296,11 +312,13 @@ src/
296
312
  - **Package manager**: pnpm
297
313
  - **CLI framework**: citty
298
314
  - **Bundler**: tsup (esbuild-based)
315
+ - **Backend SDKs**: @anthropic-ai/claude-agent-sdk, @openai/codex-sdk
299
316
  - **MCP**: @modelcontextprotocol/sdk
300
- - **Process management**: execa
317
+ - **Process management**: execa (interactive modes, Gemini CLI)
301
318
  - **Validation**: zod
302
319
  - **Logging**: consola
303
- - **Testing**: vitest (274 tests across 18 files)
320
+ - **Testing**: vitest (352 tests across 18 files)
321
+ - **Coverage**: @vitest/coverage-v8
304
322
 
305
323
  ## License
306
324
 
package/dist/relay.mjs CHANGED
@@ -48,6 +48,7 @@ var ProcessManager = class {
48
48
  stdio: "inherit",
49
49
  cwd: options?.cwd,
50
50
  env: options?.env,
51
+ extendEnv: options?.env ? false : true,
51
52
  timeout: options?.timeout,
52
53
  reject: false
53
54
  };
@@ -61,10 +62,13 @@ var ProcessManager = class {
61
62
  }
62
63
  async execute(command, args, options) {
63
64
  logger.debug(`Executing: ${command} ${args.join(" ")}`);
65
+ const stdinMode = options?.stdinMode ?? "pipe";
66
+ const stdio = stdinMode === "pipe" ? "pipe" : [stdinMode, "pipe", "pipe"];
64
67
  const execaOptions = {
65
- stdio: "pipe",
68
+ stdio,
66
69
  cwd: options?.cwd,
67
70
  env: options?.env,
71
+ extendEnv: options?.env ? false : true,
68
72
  timeout: options?.timeout,
69
73
  reject: false
70
74
  };
@@ -246,6 +250,15 @@ function mapCommonToNative(backendId, flags) {
246
250
  }
247
251
 
248
252
  // src/adapters/claude-adapter.ts
253
+ import {
254
+ query,
255
+ listSessions
256
+ } from "@anthropic-ai/claude-agent-sdk";
257
+ var CLAUDE_NESTING_ENV_VARS = [
258
+ "CLAUDECODE",
259
+ "CLAUDE_CODE_SSE_PORT",
260
+ "CLAUDE_CODE_ENTRYPOINT"
261
+ ];
249
262
  var ClaudeAdapter = class extends BaseAdapter {
250
263
  id = "claude";
251
264
  command = "claude";
@@ -256,26 +269,171 @@ var ClaudeAdapter = class extends BaseAdapter {
256
269
  }
257
270
  async startInteractive(flags) {
258
271
  const { args } = this.mapFlags(flags);
259
- await this.processManager.spawnInteractive(this.command, args);
272
+ await this.processManager.spawnInteractive(this.command, args, {
273
+ env: this.buildCleanEnv(flags)
274
+ });
275
+ }
276
+ getPermissionMode() {
277
+ return process.env["RELAY_CLAUDE_PERMISSION_MODE"] === "default" ? "default" : "bypassPermissions";
260
278
  }
261
279
  async execute(flags) {
262
280
  if (!flags.prompt) {
263
281
  throw new Error("execute requires a prompt (-p flag)");
264
282
  }
265
- const { args } = this.mapFlags(flags);
266
- return this.processManager.execute(this.command, args);
283
+ const env = this.buildCleanEnv(flags);
284
+ const permissionMode = this.getPermissionMode();
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
+ }
296
+ const q = query({
297
+ prompt: flags.prompt,
298
+ options
299
+ });
300
+ let resultText = "";
301
+ let sessionId = "";
302
+ let isError = false;
303
+ let errorMessages = [];
304
+ for await (const message of q) {
305
+ if (message.type === "result") {
306
+ sessionId = message.session_id;
307
+ if (message.subtype === "success") {
308
+ resultText = message.result;
309
+ } else {
310
+ isError = true;
311
+ errorMessages = message.errors;
312
+ }
313
+ }
314
+ }
315
+ logger.debug(`Claude SDK session: ${sessionId}`);
316
+ return {
317
+ exitCode: isError ? 1 : 0,
318
+ stdout: resultText,
319
+ stderr: errorMessages.join("\n")
320
+ };
321
+ } catch (error) {
322
+ return {
323
+ exitCode: 1,
324
+ stdout: "",
325
+ stderr: error instanceof Error ? error.message : String(error)
326
+ };
327
+ }
267
328
  }
268
- async resumeSession(sessionId, _flags) {
269
- await this.processManager.spawnInteractive(this.command, [
270
- "-r",
271
- sessionId
272
- ]);
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
+ }
273
401
  }
274
- async listNativeSessions() {
275
- logger.debug(
276
- "listNativeSessions: claude --resume is interactive, returning empty array"
402
+ async resumeSession(sessionId, flags) {
403
+ await this.processManager.spawnInteractive(
404
+ this.command,
405
+ ["-r", sessionId],
406
+ { env: this.buildCleanEnv(flags) }
277
407
  );
278
- return [];
408
+ }
409
+ buildCleanEnv(flags) {
410
+ const env = {};
411
+ for (const [key, value] of Object.entries(process.env)) {
412
+ if (value !== void 0 && !CLAUDE_NESTING_ENV_VARS.includes(key)) {
413
+ env[key] = value;
414
+ }
415
+ }
416
+ if (flags.mcpContext) {
417
+ env.RELAY_TRACE_ID = flags.mcpContext.traceId;
418
+ env.RELAY_PARENT_SESSION_ID = flags.mcpContext.parentSessionId;
419
+ env.RELAY_DEPTH = String(flags.mcpContext.depth);
420
+ }
421
+ return env;
422
+ }
423
+ async listNativeSessions() {
424
+ try {
425
+ const sessions = await listSessions({ limit: 20 });
426
+ return sessions.map((s) => ({
427
+ nativeId: s.sessionId,
428
+ startedAt: new Date(s.lastModified),
429
+ lastMessage: s.summary
430
+ }));
431
+ } catch (error) {
432
+ logger.debug(
433
+ `listNativeSessions failed: ${error instanceof Error ? error.message : String(error)}`
434
+ );
435
+ return [];
436
+ }
279
437
  }
280
438
  async auth(action) {
281
439
  await this.processManager.spawnInteractive(this.command, ["auth", action]);
@@ -292,6 +450,7 @@ var ClaudeAdapter = class extends BaseAdapter {
292
450
  };
293
451
 
294
452
  // src/adapters/codex-adapter.ts
453
+ import { Codex } from "@openai/codex-sdk";
295
454
  var CodexAdapter = class extends BaseAdapter {
296
455
  id = "codex";
297
456
  command = "codex";
@@ -321,15 +480,135 @@ var CodexAdapter = class extends BaseAdapter {
321
480
  `Codex CLI does not support --agent flag. Ignoring agent "${flags.agent}".`
322
481
  );
323
482
  }
324
- const args = [];
325
- if (flags.model) {
326
- args.push("--model", flags.model);
483
+ try {
484
+ const codexOptions = {};
485
+ if (flags.mcpContext) {
486
+ codexOptions.env = {
487
+ ...process.env,
488
+ RELAY_TRACE_ID: flags.mcpContext.traceId,
489
+ RELAY_PARENT_SESSION_ID: flags.mcpContext.parentSessionId,
490
+ RELAY_DEPTH: String(flags.mcpContext.depth)
491
+ };
492
+ }
493
+ const codex = new Codex(codexOptions);
494
+ const thread = codex.startThread({
495
+ ...flags.model ? { model: flags.model } : {},
496
+ workingDirectory: process.cwd(),
497
+ approvalPolicy: "never"
498
+ });
499
+ const result = await thread.run(flags.prompt);
500
+ return {
501
+ exitCode: 0,
502
+ stdout: result.finalResponse,
503
+ stderr: ""
504
+ };
505
+ } catch (error) {
506
+ return {
507
+ exitCode: 1,
508
+ stdout: "",
509
+ stderr: error instanceof Error ? error.message : String(error)
510
+ };
327
511
  }
328
- if (flags.outputFormat === "json") {
329
- args.push("--json");
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
+ };
330
611
  }
331
- args.push("exec", flags.prompt);
332
- return this.processManager.execute(this.command, args);
333
612
  }
334
613
  async resumeSession(sessionId, flags) {
335
614
  const args = [];
@@ -480,7 +759,7 @@ var GeminiAdapter = class extends BaseAdapter {
480
759
  };
481
760
 
482
761
  // src/core/session-manager.ts
483
- import { readFile, writeFile, readdir, mkdir } from "fs/promises";
762
+ import { readFile, writeFile, readdir, mkdir, chmod } from "fs/promises";
484
763
  import { join } from "path";
485
764
  import { homedir } from "os";
486
765
  import { nanoid } from "nanoid";
@@ -504,7 +783,8 @@ function fromSessionData(data) {
504
783
  updatedAt: new Date(data.updatedAt)
505
784
  };
506
785
  }
507
- var SessionManager = class {
786
+ var SessionManager = class _SessionManager {
787
+ static SESSION_ID_PATTERN = /^relay-[A-Za-z0-9_-]+$/;
508
788
  sessionsDir;
509
789
  constructor(sessionsDir) {
510
790
  this.sessionsDir = sessionsDir ?? getSessionsDir(getRelayHome());
@@ -514,6 +794,9 @@ var SessionManager = class {
514
794
  await mkdir(this.sessionsDir, { recursive: true });
515
795
  }
516
796
  sessionPath(relaySessionId) {
797
+ if (!_SessionManager.SESSION_ID_PATTERN.test(relaySessionId)) {
798
+ throw new Error(`Invalid session ID: ${relaySessionId}`);
799
+ }
517
800
  return join(this.sessionsDir, `${relaySessionId}.json`);
518
801
  }
519
802
  /** Create a new relay session. */
@@ -530,11 +813,13 @@ var SessionManager = class {
530
813
  updatedAt: now,
531
814
  status: "active"
532
815
  };
816
+ const sessionFilePath = this.sessionPath(session.relaySessionId);
533
817
  await writeFile(
534
- this.sessionPath(session.relaySessionId),
818
+ sessionFilePath,
535
819
  JSON.stringify(toSessionData(session), null, 2),
536
820
  "utf-8"
537
821
  );
822
+ await chmod(sessionFilePath, 384);
538
823
  return session;
539
824
  }
540
825
  /** Update an existing session. */
@@ -548,16 +833,19 @@ var SessionManager = class {
548
833
  ...updates,
549
834
  updatedAt: /* @__PURE__ */ new Date()
550
835
  };
836
+ const updateFilePath = this.sessionPath(relaySessionId);
551
837
  await writeFile(
552
- this.sessionPath(relaySessionId),
838
+ updateFilePath,
553
839
  JSON.stringify(toSessionData(updated), null, 2),
554
840
  "utf-8"
555
841
  );
842
+ await chmod(updateFilePath, 384);
556
843
  }
557
844
  /** Get a session by relay session ID. */
558
845
  async get(relaySessionId) {
846
+ const filePath = this.sessionPath(relaySessionId);
559
847
  try {
560
- const raw = await readFile(this.sessionPath(relaySessionId), "utf-8");
848
+ const raw = await readFile(filePath, "utf-8");
561
849
  return fromSessionData(JSON.parse(raw));
562
850
  } catch {
563
851
  return null;
@@ -601,7 +889,7 @@ var SessionManager = class {
601
889
  };
602
890
 
603
891
  // src/core/config-manager.ts
604
- 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";
605
893
  import { join as join2 } from "path";
606
894
 
607
895
  // src/schemas/config.schema.ts
@@ -653,7 +941,8 @@ var relayConfigSchema = z.object({
653
941
  }).optional(),
654
942
  telemetry: z.object({
655
943
  enabled: z.boolean()
656
- }).optional()
944
+ }).optional(),
945
+ claudePermissionMode: z.enum(["default", "bypassPermissions"]).optional()
657
946
  });
658
947
 
659
948
  // src/core/config-manager.ts
@@ -746,6 +1035,7 @@ var ConfigManager = class {
746
1035
  const dir = filePath.substring(0, filePath.lastIndexOf("/"));
747
1036
  await mkdir2(dir, { recursive: true });
748
1037
  await writeFile2(filePath, JSON.stringify(existing, null, 2), "utf-8");
1038
+ await chmod2(filePath, 384);
749
1039
  }
750
1040
  /**
751
1041
  * Syncs MCP server config to all registered backend adapters.
@@ -899,18 +1189,54 @@ var DEFAULT_HOOK_OUTPUT = {
899
1189
  message: "",
900
1190
  metadata: {}
901
1191
  };
902
- var HooksEngine = class {
1192
+ var HooksEngine = class _HooksEngine {
903
1193
  constructor(eventBus2, processManager2) {
904
1194
  this.eventBus = eventBus2;
905
1195
  this.processManager = processManager2;
906
1196
  }
1197
+ static COMMAND_PATTERN = /^[a-zA-Z0-9_./-]+$/;
1198
+ static ARG_PATTERN = /^[a-zA-Z0-9_.=:/-]+$/;
907
1199
  definitions = [];
908
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
+ }
909
1225
  /** Load hook definitions from config and register listeners on EventBus */
910
1226
  loadConfig(config) {
911
- this.definitions = config.definitions.filter(
912
- (def) => def.enabled !== false
913
- );
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
+ });
914
1240
  if (!this.registered) {
915
1241
  for (const event of this.getUniqueEvents()) {
916
1242
  this.eventBus.on(event, async () => {
@@ -957,6 +1283,8 @@ var HooksEngine = class {
957
1283
  return this.definitions.filter((def) => def.event === event).length;
958
1284
  }
959
1285
  async executeHook(def, input) {
1286
+ this.validateCommand(def.command);
1287
+ this.validateArgs(def.args ?? []);
960
1288
  const timeoutMs = def.timeout ?? DEFAULT_TIMEOUT_MS;
961
1289
  const stdinData = JSON.stringify(input);
962
1290
  const startTime = Date.now();
@@ -1068,7 +1396,7 @@ var INSTALL_GUIDES = {
1068
1396
  };
1069
1397
 
1070
1398
  // src/commands/backend.ts
1071
- function createBackendCommand(backendId, registry2, sessionManager2, hooksEngine2) {
1399
+ function createBackendCommand(backendId, registry2, sessionManager2, hooksEngine2, contextMonitor2) {
1072
1400
  return defineCommand({
1073
1401
  meta: {
1074
1402
  name: backendId,
@@ -1163,10 +1491,57 @@ function createBackendCommand(backendId, registry2, sessionManager2, hooksEngine
1163
1491
  try {
1164
1492
  if (flags.prompt) {
1165
1493
  logger.debug(`Executing prompt on ${backendId}`);
1166
- const result = await adapter.execute(flags);
1167
- if (result.stdout) process.stdout.write(result.stdout);
1168
- if (result.stderr) process.stderr.write(result.stderr);
1169
- 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
+ }
1170
1545
  } else if (flags.continue) {
1171
1546
  logger.debug(`Continuing latest session on ${backendId}`);
1172
1547
  if (sessionManager2) {
@@ -1246,13 +1621,66 @@ function createBackendCommand(backendId, registry2, sessionManager2, hooksEngine
1246
1621
 
1247
1622
  // src/commands/update.ts
1248
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";
1249
1663
  function createUpdateCommand(registry2) {
1250
1664
  return defineCommand2({
1251
1665
  meta: {
1252
1666
  name: "update",
1253
- description: "Update all installed backend CLI tools"
1667
+ description: "Update relay and all installed backend CLI tools"
1254
1668
  },
1255
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
+ }
1256
1684
  const adapters = registry2.list();
1257
1685
  if (adapters.length === 0) {
1258
1686
  logger.warn("No backends registered");
@@ -1366,11 +1794,14 @@ import { defineCommand as defineCommand4 } from "citty";
1366
1794
  // src/mcp-server/server.ts
1367
1795
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
1368
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";
1369
1800
  import { z as z5 } from "zod";
1370
1801
 
1371
1802
  // src/mcp-server/recursion-guard.ts
1372
1803
  import { createHash } from "crypto";
1373
- var RecursionGuard = class {
1804
+ var RecursionGuard = class _RecursionGuard {
1374
1805
  constructor(config = {
1375
1806
  maxDepth: 5,
1376
1807
  maxCallsPerSession: 20,
@@ -1378,17 +1809,50 @@ var RecursionGuard = class {
1378
1809
  }) {
1379
1810
  this.config = config;
1380
1811
  }
1812
+ static MAX_ENTRIES = 1e3;
1381
1813
  callCounts = /* @__PURE__ */ new Map();
1382
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
+ }
1383
1846
  /** Check if a spawn is allowed */
1384
1847
  canSpawn(context) {
1848
+ this.cleanup();
1385
1849
  if (context.depth >= this.config.maxDepth) {
1386
1850
  return {
1387
1851
  allowed: false,
1388
1852
  reason: `Max depth exceeded: ${context.depth} >= ${this.config.maxDepth}`
1389
1853
  };
1390
1854
  }
1391
- const currentCount = this.callCounts.get(context.traceId) ?? 0;
1855
+ const currentCount = this.callCounts.get(context.traceId)?.count ?? 0;
1392
1856
  if (currentCount >= this.config.maxCallsPerSession) {
1393
1857
  return {
1394
1858
  allowed: false,
@@ -1405,17 +1869,25 @@ var RecursionGuard = class {
1405
1869
  }
1406
1870
  /** Record a spawn invocation */
1407
1871
  recordSpawn(context) {
1408
- const currentCount = this.callCounts.get(context.traceId) ?? 0;
1409
- 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
+ }
1410
1879
  const key = `${context.backend}:${context.promptHash}`;
1411
- const existing = this.promptHashes.get(context.traceId) ?? [];
1412
- existing.push(key);
1413
- 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
+ }
1414
1886
  }
1415
1887
  /** Detect if the same (backend + promptHash) combination has appeared 3+ times */
1416
1888
  detectLoop(traceId, backend, promptHash) {
1417
1889
  const key = `${backend}:${promptHash}`;
1418
- const hashes = this.promptHashes.get(traceId) ?? [];
1890
+ const hashes = this.promptHashes.get(traceId)?.hashes ?? [];
1419
1891
  const count = hashes.filter((h) => h === key).length;
1420
1892
  return count >= 3;
1421
1893
  }
@@ -1425,7 +1897,7 @@ var RecursionGuard = class {
1425
1897
  }
1426
1898
  /** Get call count for a trace */
1427
1899
  getCallCount(traceId) {
1428
- return this.callCounts.get(traceId) ?? 0;
1900
+ return this.callCounts.get(traceId)?.count ?? 0;
1429
1901
  }
1430
1902
  /** Utility: compute a prompt hash */
1431
1903
  static hashPrompt(prompt) {
@@ -1450,7 +1922,7 @@ function buildContextFromEnv() {
1450
1922
  const depth = Number(process.env["RELAY_DEPTH"] ?? "0");
1451
1923
  return { traceId, parentSessionId, depth };
1452
1924
  }
1453
- async function executeSpawnAgent(input, registry2, sessionManager2, guard, hooksEngine2) {
1925
+ async function executeSpawnAgent(input, registry2, sessionManager2, guard, hooksEngine2, contextMonitor2) {
1454
1926
  const envContext = buildContextFromEnv();
1455
1927
  const promptHash = RecursionGuard.hashPrompt(input.prompt);
1456
1928
  const context = {
@@ -1518,6 +1990,18 @@ async function executeSpawnAgent(input, registry2, sessionManager2, guard, hooks
1518
1990
  traceId: envContext.traceId
1519
1991
  }
1520
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
+ }
1521
2005
  guard.recordSpawn(context);
1522
2006
  const status = result.exitCode === 0 ? "completed" : "error";
1523
2007
  await sessionManager2.update(session.relaySessionId, { status });
@@ -1616,7 +2100,7 @@ var RelayMCPServer = class {
1616
2100
  this.guard = new RecursionGuard(guardConfig);
1617
2101
  this.server = new McpServer({
1618
2102
  name: "agentic-relay",
1619
- version: "0.1.0"
2103
+ version: "0.3.0"
1620
2104
  });
1621
2105
  this.registerTools();
1622
2106
  }
@@ -1641,7 +2125,8 @@ var RelayMCPServer = class {
1641
2125
  this.registry,
1642
2126
  this.sessionManager,
1643
2127
  this.guard,
1644
- this.hooksEngine
2128
+ this.hooksEngine,
2129
+ this.contextMonitor
1645
2130
  );
1646
2131
  const isError = result.exitCode !== 0;
1647
2132
  const text = isError ? `Error (exit ${result.exitCode}): ${result.stderr || result.stdout}` : `Session: ${result.sessionId}
@@ -1721,11 +2206,47 @@ ${result.stdout}`;
1721
2206
  }
1722
2207
  );
1723
2208
  }
1724
- async start() {
1725
- logger.info("Starting agentic-relay MCP server (stdio transport)...");
1726
- const transport = new StdioServerTransport();
1727
- 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;
1728
2248
  }
2249
+ _httpServer;
1729
2250
  };
1730
2251
 
1731
2252
  // src/commands/mcp.ts
@@ -1849,9 +2370,21 @@ function createMCPCommand(configManager2, registry2, sessionManager2, hooksEngin
1849
2370
  serve: defineCommand4({
1850
2371
  meta: {
1851
2372
  name: "serve",
1852
- description: "Start relay as an MCP server (stdio transport)"
2373
+ description: "Start relay as an MCP server"
1853
2374
  },
1854
- 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 }) {
1855
2388
  if (!sessionManager2) {
1856
2389
  logger.error("SessionManager is required for MCP server mode");
1857
2390
  process.exitCode = 1;
@@ -1869,6 +2402,8 @@ function createMCPCommand(configManager2, registry2, sessionManager2, hooksEngin
1869
2402
  }
1870
2403
  } catch {
1871
2404
  }
2405
+ const transport = args.transport === "http" ? "http" : "stdio";
2406
+ const port = parseInt(String(args.port), 10) || 3100;
1872
2407
  const server = new RelayMCPServer(
1873
2408
  registry2,
1874
2409
  sessionManager2,
@@ -1876,7 +2411,7 @@ function createMCPCommand(configManager2, registry2, sessionManager2, hooksEngin
1876
2411
  hooksEngine2,
1877
2412
  contextMonitor2
1878
2413
  );
1879
- await server.start();
2414
+ await server.start({ transport, port });
1880
2415
  }
1881
2416
  })
1882
2417
  },
@@ -2257,15 +2792,13 @@ var configManager = new ConfigManager(relayHome, projectRelayDir);
2257
2792
  var authManager = new AuthManager(registry);
2258
2793
  var eventBus = new EventBus();
2259
2794
  var hooksEngine = new HooksEngine(eventBus, processManager);
2260
- var contextMonitor = new ContextMonitor(hooksEngine, {
2261
- enabled: false
2262
- // Will be enabled from config at runtime
2263
- });
2795
+ var contextMonitor = new ContextMonitor(hooksEngine);
2264
2796
  void configManager.getConfig().then((config) => {
2265
2797
  if (config.hooks) {
2266
2798
  hooksEngine.loadConfig(config.hooks);
2267
2799
  }
2268
2800
  if (config.contextMonitor) {
2801
+ contextMonitor = new ContextMonitor(hooksEngine, config.contextMonitor);
2269
2802
  }
2270
2803
  }).catch(() => {
2271
2804
  });
@@ -2276,9 +2809,9 @@ var main = defineCommand10({
2276
2809
  description: "Unified CLI proxy for Claude Code, Codex CLI, and Gemini CLI"
2277
2810
  },
2278
2811
  subCommands: {
2279
- claude: createBackendCommand("claude", registry, sessionManager, hooksEngine),
2280
- codex: createBackendCommand("codex", registry, sessionManager, hooksEngine),
2281
- 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),
2282
2815
  update: createUpdateCommand(registry),
2283
2816
  config: createConfigCommand(configManager),
2284
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.1.2",
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"
@@ -46,7 +47,9 @@
46
47
  "node": ">=22"
47
48
  },
48
49
  "dependencies": {
50
+ "@anthropic-ai/claude-agent-sdk": "^0.2.59",
49
51
  "@modelcontextprotocol/sdk": "^1.27.1",
52
+ "@openai/codex-sdk": "^0.105.0",
50
53
  "citty": "^0.1.6",
51
54
  "consola": "^3.4.0",
52
55
  "execa": "^9.5.2",
@@ -55,6 +58,7 @@
55
58
  },
56
59
  "devDependencies": {
57
60
  "@types/node": "^25.3.0",
61
+ "@vitest/coverage-v8": "^3.2.4",
58
62
  "tsup": "^8.4.0",
59
63
  "typescript": "^5.7.3",
60
64
  "vitest": "^3.0.7"