@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.
- package/README.md +26 -8
- package/dist/relay.mjs +596 -63
- package/package.json +5 -1
package/README.md
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
# agentic-relay
|
|
2
2
|
|
|
3
|
+
[](https://github.com/RK0429/agentic-relay/actions/workflows/ci.yml)
|
|
4
|
+
[](https://www.npmjs.com/package/@rk0429/agentic-relay)
|
|
5
|
+
[](https://opensource.org/licenses/Apache-2.0)
|
|
6
|
+
[](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
|
|
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
|
-
| `
|
|
148
|
-
| `
|
|
149
|
-
| `
|
|
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:
|
|
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:
|
|
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 (
|
|
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
|
|
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
|
|
266
|
-
|
|
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
|
|
269
|
-
|
|
270
|
-
"-
|
|
271
|
-
|
|
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
|
|
275
|
-
|
|
276
|
-
|
|
402
|
+
async resumeSession(sessionId, flags) {
|
|
403
|
+
await this.processManager.spawnInteractive(
|
|
404
|
+
this.command,
|
|
405
|
+
["-r", sessionId],
|
|
406
|
+
{ env: this.buildCleanEnv(flags) }
|
|
277
407
|
);
|
|
278
|
-
|
|
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
|
-
|
|
325
|
-
|
|
326
|
-
|
|
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
|
-
|
|
329
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
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
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
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
|
-
|
|
1409
|
-
this.callCounts.
|
|
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
|
|
1412
|
-
|
|
1413
|
-
|
|
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.
|
|
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
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
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
|
|
2373
|
+
description: "Start relay as an MCP server"
|
|
1853
2374
|
},
|
|
1854
|
-
|
|
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.
|
|
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"
|