@rk0429/agentic-relay 0.6.4 → 0.8.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 +5 -1
- package/dist/relay.mjs +573 -226
- package/package.json +9 -2
package/README.md
CHANGED
|
@@ -156,6 +156,10 @@ Example configuration:
|
|
|
156
156
|
| `OPENAI_API_KEY` | Passed through to Codex CLI (optional with subscription) | -- |
|
|
157
157
|
| `GEMINI_API_KEY` | Passed through to Gemini CLI (optional with subscription) | -- |
|
|
158
158
|
|
|
159
|
+
### Security Considerations
|
|
160
|
+
|
|
161
|
+
- **Claude adapter permission bypass**: By default, the Claude adapter runs with `bypassPermissions` mode to enable non-interactive sub-agent execution. This means spawned Claude Code agents can execute tools without user confirmation. To change this behavior, set the `RELAY_CLAUDE_PERMISSION_MODE` environment variable to `default`.
|
|
162
|
+
|
|
159
163
|
## MCP Server Mode
|
|
160
164
|
|
|
161
165
|
agentic-relay can act as an MCP (Model Context Protocol) server, allowing any MCP-capable client to spawn sub-agents across all three backends. This is how nested sub-agent orchestration works -- a parent agent calls relay via MCP, which spawns a child agent on any backend, and that child can call relay again to spawn a grandchild.
|
|
@@ -317,7 +321,7 @@ src/
|
|
|
317
321
|
- **Process management**: execa (interactive modes, Gemini CLI)
|
|
318
322
|
- **Validation**: zod
|
|
319
323
|
- **Logging**: consola
|
|
320
|
-
- **Testing**: vitest (
|
|
324
|
+
- **Testing**: vitest (771 tests across 35 files)
|
|
321
325
|
- **Coverage**: @vitest/coverage-v8
|
|
322
326
|
|
|
323
327
|
## License
|
package/dist/relay.mjs
CHANGED
|
@@ -159,7 +159,7 @@ var init_recursion_guard = __esm({
|
|
|
159
159
|
import { z as z2 } from "zod";
|
|
160
160
|
import { nanoid as nanoid2 } from "nanoid";
|
|
161
161
|
import { existsSync, readFileSync } from "fs";
|
|
162
|
-
import { join as join6 } from "path";
|
|
162
|
+
import { join as join6, normalize, resolve, sep } from "path";
|
|
163
163
|
function buildContextInjection(metadata) {
|
|
164
164
|
const parts = [];
|
|
165
165
|
if (metadata.stateContent && typeof metadata.stateContent === "string") {
|
|
@@ -190,15 +190,28 @@ function readPreviousState(dailynoteDir) {
|
|
|
190
190
|
return null;
|
|
191
191
|
}
|
|
192
192
|
}
|
|
193
|
-
function
|
|
193
|
+
function validatePathWithinProject(filePath, projectRoot) {
|
|
194
|
+
if (filePath.trim().length === 0) {
|
|
195
|
+
throw new Error("Path is empty");
|
|
196
|
+
}
|
|
197
|
+
const normalizedProjectRoot = normalize(resolve(projectRoot));
|
|
198
|
+
const resolvedPath = normalize(resolve(normalizedProjectRoot, filePath));
|
|
199
|
+
const projectRootPrefix = normalizedProjectRoot.endsWith(sep) ? normalizedProjectRoot : `${normalizedProjectRoot}${sep}`;
|
|
200
|
+
if (resolvedPath !== normalizedProjectRoot && !resolvedPath.startsWith(projectRootPrefix)) {
|
|
201
|
+
throw new Error(`Path traversal detected: ${filePath}`);
|
|
202
|
+
}
|
|
203
|
+
return resolvedPath;
|
|
204
|
+
}
|
|
205
|
+
function readAgentDefinition(definitionPath, projectRoot = process.cwd()) {
|
|
194
206
|
try {
|
|
195
|
-
|
|
196
|
-
|
|
207
|
+
const safeDefinitionPath = validatePathWithinProject(definitionPath, projectRoot);
|
|
208
|
+
if (!existsSync(safeDefinitionPath)) {
|
|
209
|
+
logger.warn(`Agent definition file not found at ${safeDefinitionPath}`);
|
|
197
210
|
return null;
|
|
198
211
|
}
|
|
199
|
-
const content = readFileSync(
|
|
212
|
+
const content = readFileSync(safeDefinitionPath, "utf-8");
|
|
200
213
|
if (content.trim().length === 0) {
|
|
201
|
-
logger.warn(`Agent definition file is empty at ${
|
|
214
|
+
logger.warn(`Agent definition file is empty at ${safeDefinitionPath}`);
|
|
202
215
|
return null;
|
|
203
216
|
}
|
|
204
217
|
return content;
|
|
@@ -209,9 +222,10 @@ function readAgentDefinition(definitionPath) {
|
|
|
209
222
|
return null;
|
|
210
223
|
}
|
|
211
224
|
}
|
|
212
|
-
function readSkillContext(skillContext) {
|
|
225
|
+
function readSkillContext(skillContext, projectRoot = process.cwd()) {
|
|
213
226
|
try {
|
|
214
|
-
const
|
|
227
|
+
const safeSkillPath = validatePathWithinProject(skillContext.skillPath, projectRoot);
|
|
228
|
+
const skillMdPath = validatePathWithinProject(join6(safeSkillPath, "SKILL.md"), projectRoot);
|
|
215
229
|
if (!existsSync(skillMdPath)) {
|
|
216
230
|
logger.warn(
|
|
217
231
|
`SKILL.md not found at ${skillMdPath}`
|
|
@@ -222,12 +236,12 @@ function readSkillContext(skillContext) {
|
|
|
222
236
|
const skillContent = readFileSync(skillMdPath, "utf-8");
|
|
223
237
|
parts.push(skillContent);
|
|
224
238
|
if (skillContext.subskill) {
|
|
225
|
-
const subskillPath = join6(
|
|
226
|
-
|
|
239
|
+
const subskillPath = validatePathWithinProject(join6(
|
|
240
|
+
safeSkillPath,
|
|
227
241
|
"subskills",
|
|
228
242
|
skillContext.subskill,
|
|
229
243
|
"SUBSKILL.md"
|
|
230
|
-
);
|
|
244
|
+
), projectRoot);
|
|
231
245
|
if (existsSync(subskillPath)) {
|
|
232
246
|
const subskillContent = readFileSync(subskillPath, "utf-8");
|
|
233
247
|
parts.push(subskillContent);
|
|
@@ -299,8 +313,10 @@ function buildContextFromEnv() {
|
|
|
299
313
|
const depth = Number(process.env["RELAY_DEPTH"] ?? "0");
|
|
300
314
|
return { traceId, parentSessionId, depth };
|
|
301
315
|
}
|
|
302
|
-
async function executeSpawnAgent(input, registry2, sessionManager2, guard, hooksEngine2, contextMonitor2, backendSelector, childHttpUrl) {
|
|
316
|
+
async function executeSpawnAgent(input, registry2, sessionManager2, guard, hooksEngine2, contextMonitor2, backendSelector, childHttpUrl, onProgress) {
|
|
317
|
+
onProgress?.({ stage: "initializing", percent: 0 });
|
|
303
318
|
let effectiveBackend = input.backend;
|
|
319
|
+
let selectionReason = "direct";
|
|
304
320
|
if (backendSelector) {
|
|
305
321
|
const availableBackends = registry2.listIds();
|
|
306
322
|
const selectionContext = {
|
|
@@ -309,7 +325,9 @@ async function executeSpawnAgent(input, registry2, sessionManager2, guard, hooks
|
|
|
309
325
|
agentType: input.agent,
|
|
310
326
|
taskType: input.taskType
|
|
311
327
|
};
|
|
312
|
-
|
|
328
|
+
const selectionResult = backendSelector.selectBackendWithReason(selectionContext);
|
|
329
|
+
effectiveBackend = selectionResult.backend;
|
|
330
|
+
selectionReason = selectionResult.reason;
|
|
313
331
|
}
|
|
314
332
|
const envContext = buildContextFromEnv();
|
|
315
333
|
const promptHash = RecursionGuard.hashPrompt(input.prompt);
|
|
@@ -418,41 +436,91 @@ ${defText}
|
|
|
418
436
|
${wrapped}` : wrapped;
|
|
419
437
|
}
|
|
420
438
|
}
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
439
|
+
let effectivePrompt = input.prompt;
|
|
440
|
+
if (input.taskInstructionPath) {
|
|
441
|
+
try {
|
|
442
|
+
const projectRoot = process.cwd();
|
|
443
|
+
const safePath = validatePathWithinProject(input.taskInstructionPath, projectRoot);
|
|
444
|
+
if (!existsSync(safePath)) {
|
|
425
445
|
return {
|
|
426
|
-
sessionId:
|
|
446
|
+
sessionId: "",
|
|
427
447
|
exitCode: 1,
|
|
428
448
|
stdout: "",
|
|
429
|
-
stderr: `
|
|
449
|
+
stderr: `Task instruction file not found: ${input.taskInstructionPath}`
|
|
430
450
|
};
|
|
431
451
|
}
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
452
|
+
const instructionContent = readFileSync(safePath, "utf-8");
|
|
453
|
+
effectivePrompt = `${instructionContent}
|
|
454
|
+
|
|
455
|
+
${input.prompt}`;
|
|
456
|
+
} catch (error) {
|
|
457
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
458
|
+
return {
|
|
459
|
+
sessionId: "",
|
|
460
|
+
exitCode: 1,
|
|
461
|
+
stdout: "",
|
|
462
|
+
stderr: `Failed to read task instruction file: ${message}`
|
|
463
|
+
};
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
onProgress?.({ stage: "spawning", percent: 10 });
|
|
467
|
+
try {
|
|
468
|
+
let result;
|
|
469
|
+
const executePromise = (async () => {
|
|
470
|
+
if (input.resumeSessionId) {
|
|
471
|
+
if (!adapter.continueSession) {
|
|
472
|
+
return {
|
|
473
|
+
exitCode: 1,
|
|
474
|
+
stdout: "",
|
|
475
|
+
stderr: `Backend "${effectiveBackend}" does not support session continuation (continueSession).`,
|
|
476
|
+
_noSession: true
|
|
477
|
+
};
|
|
439
478
|
}
|
|
479
|
+
return adapter.continueSession(input.resumeSessionId, effectivePrompt);
|
|
480
|
+
} else {
|
|
481
|
+
let mcpServers;
|
|
482
|
+
if (childHttpUrl) {
|
|
483
|
+
const parentMcpServers = readProjectMcpJson();
|
|
484
|
+
if (Object.keys(parentMcpServers).length > 0) {
|
|
485
|
+
mcpServers = buildChildMcpServers(parentMcpServers, childHttpUrl);
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
return adapter.execute({
|
|
489
|
+
prompt: effectivePrompt,
|
|
490
|
+
agent: input.agent,
|
|
491
|
+
systemPrompt: enhancedSystemPrompt,
|
|
492
|
+
model: input.model,
|
|
493
|
+
maxTurns: input.maxTurns,
|
|
494
|
+
mcpContext: {
|
|
495
|
+
parentSessionId: session.relaySessionId,
|
|
496
|
+
depth: envContext.depth + 1,
|
|
497
|
+
maxDepth: guard.getConfig().maxDepth,
|
|
498
|
+
traceId: envContext.traceId
|
|
499
|
+
},
|
|
500
|
+
...mcpServers ? { mcpServers } : {}
|
|
501
|
+
});
|
|
440
502
|
}
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
503
|
+
})();
|
|
504
|
+
if (input.timeoutMs) {
|
|
505
|
+
const timeoutPromise = new Promise(
|
|
506
|
+
(_, reject) => setTimeout(
|
|
507
|
+
() => reject(new Error(`Agent execution timed out after ${input.timeoutMs}ms`)),
|
|
508
|
+
input.timeoutMs
|
|
509
|
+
)
|
|
510
|
+
);
|
|
511
|
+
result = await Promise.race([executePromise, timeoutPromise]);
|
|
512
|
+
} else {
|
|
513
|
+
result = await executePromise;
|
|
514
|
+
}
|
|
515
|
+
if (result && "_noSession" in result) {
|
|
516
|
+
return {
|
|
517
|
+
sessionId: session.relaySessionId,
|
|
518
|
+
exitCode: result.exitCode,
|
|
519
|
+
stdout: result.stdout,
|
|
520
|
+
stderr: result.stderr
|
|
521
|
+
};
|
|
455
522
|
}
|
|
523
|
+
onProgress?.({ stage: "executing", percent: 50 });
|
|
456
524
|
if (contextMonitor2) {
|
|
457
525
|
const estimatedTokens = Math.ceil(
|
|
458
526
|
(result.stdout.length + result.stderr.length) / 4
|
|
@@ -466,6 +534,16 @@ ${wrapped}` : wrapped;
|
|
|
466
534
|
guard.recordSpawn(context);
|
|
467
535
|
const status = result.exitCode === 0 ? "completed" : "error";
|
|
468
536
|
await sessionManager2.update(session.relaySessionId, { status });
|
|
537
|
+
const completedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
538
|
+
const metadata = {
|
|
539
|
+
durationMs: new Date(completedAt).getTime() - new Date(spawnStartedAt).getTime(),
|
|
540
|
+
selectedBackend: effectiveBackend,
|
|
541
|
+
...input.preferredBackend ? { requestedBackend: input.preferredBackend } : {},
|
|
542
|
+
selectionReason,
|
|
543
|
+
startedAt: spawnStartedAt,
|
|
544
|
+
completedAt
|
|
545
|
+
};
|
|
546
|
+
onProgress?.({ stage: "completed", percent: 100 });
|
|
469
547
|
if (hooksEngine2) {
|
|
470
548
|
try {
|
|
471
549
|
const postSpawnData = {
|
|
@@ -502,7 +580,8 @@ ${wrapped}` : wrapped;
|
|
|
502
580
|
exitCode: result.exitCode,
|
|
503
581
|
stdout: result.stdout,
|
|
504
582
|
stderr: result.stderr,
|
|
505
|
-
nativeSessionId: result.nativeSessionId
|
|
583
|
+
nativeSessionId: result.nativeSessionId,
|
|
584
|
+
metadata
|
|
506
585
|
};
|
|
507
586
|
} catch (error) {
|
|
508
587
|
await sessionManager2.update(session.relaySessionId, { status: "error" });
|
|
@@ -537,11 +616,223 @@ var init_spawn_agent = __esm({
|
|
|
537
616
|
definitionPath: z2.string().describe("Path to the agent definition file (e.g., '.claude/agents/software-engineer.md')")
|
|
538
617
|
}).optional().describe("Agent definition file to inject into the sub-agent's system prompt"),
|
|
539
618
|
preferredBackend: z2.enum(["claude", "codex", "gemini"]).optional().describe("Preferred backend override. Takes priority over automatic selection based on agent/task type."),
|
|
540
|
-
taskType: z2.enum(["code", "document", "analysis", "mixed"]).optional().describe("Task type hint for automatic backend selection when preferredBackend is not specified.")
|
|
619
|
+
taskType: z2.enum(["code", "document", "analysis", "mixed"]).optional().describe("Task type hint for automatic backend selection when preferredBackend is not specified."),
|
|
620
|
+
timeoutMs: z2.number().optional().describe("Timeout in milliseconds for agent execution. Default: no timeout."),
|
|
621
|
+
taskInstructionPath: z2.string().optional().describe(
|
|
622
|
+
"Path to a file containing task instructions. Content is prepended to the prompt. Path is resolved relative to the project root and validated against path traversal."
|
|
623
|
+
)
|
|
541
624
|
});
|
|
542
625
|
}
|
|
543
626
|
});
|
|
544
627
|
|
|
628
|
+
// src/mcp-server/tools/conflict-detector.ts
|
|
629
|
+
import { execFile } from "child_process";
|
|
630
|
+
import { promisify } from "util";
|
|
631
|
+
async function takeSnapshot(cwd) {
|
|
632
|
+
try {
|
|
633
|
+
const [diffResult, lsResult] = await Promise.all([
|
|
634
|
+
execFileAsync("git", ["diff", "--name-only", "HEAD"], { cwd }).catch(() => ({ stdout: "" })),
|
|
635
|
+
execFileAsync("git", ["ls-files", "-m"], { cwd }).catch(() => ({ stdout: "" }))
|
|
636
|
+
]);
|
|
637
|
+
const files = /* @__PURE__ */ new Set();
|
|
638
|
+
for (const line of diffResult.stdout.split("\n")) {
|
|
639
|
+
const trimmed = line.trim();
|
|
640
|
+
if (trimmed) files.add(trimmed);
|
|
641
|
+
}
|
|
642
|
+
for (const line of lsResult.stdout.split("\n")) {
|
|
643
|
+
const trimmed = line.trim();
|
|
644
|
+
if (trimmed) files.add(trimmed);
|
|
645
|
+
}
|
|
646
|
+
return files;
|
|
647
|
+
} catch (error) {
|
|
648
|
+
logger.warn(
|
|
649
|
+
`Failed to take git snapshot: ${error instanceof Error ? error.message : String(error)}`
|
|
650
|
+
);
|
|
651
|
+
return /* @__PURE__ */ new Set();
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
function extractMentionedPaths(stdout) {
|
|
655
|
+
const paths = /* @__PURE__ */ new Set();
|
|
656
|
+
const patterns = [
|
|
657
|
+
/(?:^|\s|["'`])(\.\/.+?\.[a-zA-Z]{1,5})(?:\s|["'`]|$)/gm,
|
|
658
|
+
/(?:^|\s|["'`])(src\/.+?\.[a-zA-Z]{1,5})(?:\s|["'`]|$)/gm,
|
|
659
|
+
/(?:^|\s|["'`])([a-zA-Z][a-zA-Z0-9_-]*(?:\/[a-zA-Z0-9_.-]+){1,10}\.[a-zA-Z]{1,5})(?:\s|["'`]|$)/gm
|
|
660
|
+
];
|
|
661
|
+
for (const pattern of patterns) {
|
|
662
|
+
let match;
|
|
663
|
+
while ((match = pattern.exec(stdout)) !== null) {
|
|
664
|
+
let path = match[1];
|
|
665
|
+
if (path.startsWith("./")) {
|
|
666
|
+
path = path.slice(2);
|
|
667
|
+
}
|
|
668
|
+
paths.add(path);
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
return paths;
|
|
672
|
+
}
|
|
673
|
+
async function detectConflicts(before, after, agentResults) {
|
|
674
|
+
const newlyModified = /* @__PURE__ */ new Set();
|
|
675
|
+
for (const file of after) {
|
|
676
|
+
if (!before.has(file)) {
|
|
677
|
+
newlyModified.add(file);
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
if (newlyModified.size === 0) {
|
|
681
|
+
return { conflicts: [], hasConflicts: false };
|
|
682
|
+
}
|
|
683
|
+
const agentPaths = /* @__PURE__ */ new Map();
|
|
684
|
+
for (const agent of agentResults) {
|
|
685
|
+
agentPaths.set(agent.index, extractMentionedPaths(agent.stdout));
|
|
686
|
+
}
|
|
687
|
+
const fileToAgents = /* @__PURE__ */ new Map();
|
|
688
|
+
for (const file of newlyModified) {
|
|
689
|
+
const matchingAgents = [];
|
|
690
|
+
for (const [index, paths] of agentPaths) {
|
|
691
|
+
for (const mentionedPath of paths) {
|
|
692
|
+
if (file === mentionedPath || file.endsWith(mentionedPath) || mentionedPath.endsWith(file)) {
|
|
693
|
+
matchingAgents.push(index);
|
|
694
|
+
break;
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
if (matchingAgents.length > 1) {
|
|
699
|
+
fileToAgents.set(file, matchingAgents);
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
const conflicts = Array.from(fileToAgents.entries()).map(
|
|
703
|
+
([path, agents]) => ({ path, agents })
|
|
704
|
+
);
|
|
705
|
+
return {
|
|
706
|
+
conflicts,
|
|
707
|
+
hasConflicts: conflicts.length > 0
|
|
708
|
+
};
|
|
709
|
+
}
|
|
710
|
+
var execFileAsync;
|
|
711
|
+
var init_conflict_detector = __esm({
|
|
712
|
+
"src/mcp-server/tools/conflict-detector.ts"() {
|
|
713
|
+
"use strict";
|
|
714
|
+
init_logger();
|
|
715
|
+
execFileAsync = promisify(execFile);
|
|
716
|
+
}
|
|
717
|
+
});
|
|
718
|
+
|
|
719
|
+
// src/mcp-server/tools/spawn-agents-parallel.ts
|
|
720
|
+
async function executeSpawnAgentsParallel(agents, registry2, sessionManager2, guard, hooksEngine2, contextMonitor2, backendSelector, childHttpUrl, onProgress) {
|
|
721
|
+
const envContext = buildContextFromEnv();
|
|
722
|
+
if (envContext.depth >= guard.getConfig().maxDepth) {
|
|
723
|
+
const reason = `Max depth exceeded: ${envContext.depth} >= ${guard.getConfig().maxDepth}`;
|
|
724
|
+
logger.warn(`Batch spawn blocked by RecursionGuard: ${reason}`);
|
|
725
|
+
return {
|
|
726
|
+
results: agents.map((_, index) => ({
|
|
727
|
+
index,
|
|
728
|
+
sessionId: "",
|
|
729
|
+
exitCode: 1,
|
|
730
|
+
stdout: "",
|
|
731
|
+
stderr: `Batch spawn blocked: ${reason}`,
|
|
732
|
+
error: reason
|
|
733
|
+
})),
|
|
734
|
+
totalCount: agents.length,
|
|
735
|
+
successCount: 0,
|
|
736
|
+
failureCount: agents.length
|
|
737
|
+
};
|
|
738
|
+
}
|
|
739
|
+
const currentCount = guard.getCallCount(envContext.traceId);
|
|
740
|
+
const maxCalls = guard.getConfig().maxCallsPerSession;
|
|
741
|
+
if (currentCount + agents.length > maxCalls) {
|
|
742
|
+
const reason = `Batch would exceed max calls per session: ${currentCount} + ${agents.length} > ${maxCalls}`;
|
|
743
|
+
logger.warn(`Batch spawn blocked by RecursionGuard: ${reason}`);
|
|
744
|
+
return {
|
|
745
|
+
results: agents.map((_, index) => ({
|
|
746
|
+
index,
|
|
747
|
+
sessionId: "",
|
|
748
|
+
exitCode: 1,
|
|
749
|
+
stdout: "",
|
|
750
|
+
stderr: `Batch spawn blocked: ${reason}`,
|
|
751
|
+
error: reason
|
|
752
|
+
})),
|
|
753
|
+
totalCount: agents.length,
|
|
754
|
+
successCount: 0,
|
|
755
|
+
failureCount: agents.length
|
|
756
|
+
};
|
|
757
|
+
}
|
|
758
|
+
const cwd = process.cwd();
|
|
759
|
+
const beforeSnapshot = await takeSnapshot(cwd);
|
|
760
|
+
onProgress?.({ stage: "spawning", percent: 5 });
|
|
761
|
+
let completedCount = 0;
|
|
762
|
+
const settled = await Promise.allSettled(
|
|
763
|
+
agents.map(
|
|
764
|
+
(agent) => executeSpawnAgent(
|
|
765
|
+
agent,
|
|
766
|
+
registry2,
|
|
767
|
+
sessionManager2,
|
|
768
|
+
guard,
|
|
769
|
+
hooksEngine2,
|
|
770
|
+
contextMonitor2,
|
|
771
|
+
backendSelector,
|
|
772
|
+
childHttpUrl
|
|
773
|
+
).then((result) => {
|
|
774
|
+
completedCount++;
|
|
775
|
+
onProgress?.({
|
|
776
|
+
stage: `completed ${completedCount}/${agents.length}`,
|
|
777
|
+
percent: Math.round(completedCount / agents.length * 90) + 5
|
|
778
|
+
});
|
|
779
|
+
return result;
|
|
780
|
+
})
|
|
781
|
+
)
|
|
782
|
+
);
|
|
783
|
+
const results = settled.map((outcome, index) => {
|
|
784
|
+
if (outcome.status === "fulfilled") {
|
|
785
|
+
const r = outcome.value;
|
|
786
|
+
const base = {
|
|
787
|
+
index,
|
|
788
|
+
sessionId: r.sessionId,
|
|
789
|
+
exitCode: r.exitCode,
|
|
790
|
+
stdout: r.stdout,
|
|
791
|
+
stderr: r.stderr,
|
|
792
|
+
...r.nativeSessionId ? { nativeSessionId: r.nativeSessionId } : {},
|
|
793
|
+
...r.metadata ? { metadata: r.metadata } : {}
|
|
794
|
+
};
|
|
795
|
+
if (r.exitCode !== 0) {
|
|
796
|
+
base.originalInput = agents[index];
|
|
797
|
+
}
|
|
798
|
+
return base;
|
|
799
|
+
}
|
|
800
|
+
const errorMessage = outcome.reason instanceof Error ? outcome.reason.message : String(outcome.reason);
|
|
801
|
+
return {
|
|
802
|
+
index,
|
|
803
|
+
sessionId: "",
|
|
804
|
+
exitCode: 1,
|
|
805
|
+
stdout: "",
|
|
806
|
+
stderr: errorMessage,
|
|
807
|
+
error: errorMessage,
|
|
808
|
+
originalInput: agents[index]
|
|
809
|
+
};
|
|
810
|
+
});
|
|
811
|
+
const successCount = results.filter((r) => r.exitCode === 0).length;
|
|
812
|
+
const afterSnapshot = await takeSnapshot(cwd);
|
|
813
|
+
const agentResultsForConflict = results.map((r) => ({
|
|
814
|
+
index: r.index,
|
|
815
|
+
stdout: r.stdout
|
|
816
|
+
}));
|
|
817
|
+
const conflictResult = await detectConflicts(beforeSnapshot, afterSnapshot, agentResultsForConflict);
|
|
818
|
+
onProgress?.({ stage: "completed", percent: 100 });
|
|
819
|
+
return {
|
|
820
|
+
results,
|
|
821
|
+
totalCount: agents.length,
|
|
822
|
+
successCount,
|
|
823
|
+
failureCount: agents.length - successCount,
|
|
824
|
+
...conflictResult.hasConflicts ? { conflicts: conflictResult.conflicts, hasConflicts: true } : {}
|
|
825
|
+
};
|
|
826
|
+
}
|
|
827
|
+
var init_spawn_agents_parallel = __esm({
|
|
828
|
+
"src/mcp-server/tools/spawn-agents-parallel.ts"() {
|
|
829
|
+
"use strict";
|
|
830
|
+
init_spawn_agent();
|
|
831
|
+
init_conflict_detector();
|
|
832
|
+
init_logger();
|
|
833
|
+
}
|
|
834
|
+
});
|
|
835
|
+
|
|
545
836
|
// src/mcp-server/tools/list-sessions.ts
|
|
546
837
|
import { z as z3 } from "zod";
|
|
547
838
|
async function executeListSessions(input, sessionManager2) {
|
|
@@ -654,32 +945,35 @@ var init_backend_selector = __esm({
|
|
|
654
945
|
this.agentToBackendMap = config?.agentToBackendMap ?? DEFAULT_AGENT_TO_BACKEND_MAP;
|
|
655
946
|
}
|
|
656
947
|
selectBackend(context) {
|
|
948
|
+
return this.selectBackendWithReason(context).backend;
|
|
949
|
+
}
|
|
950
|
+
selectBackendWithReason(context) {
|
|
657
951
|
const { availableBackends, preferredBackend, agentType, taskType } = context;
|
|
658
952
|
if (availableBackends.length === 0) {
|
|
659
953
|
throw new Error("No backends available");
|
|
660
954
|
}
|
|
661
955
|
if (preferredBackend && availableBackends.includes(preferredBackend)) {
|
|
662
|
-
return preferredBackend;
|
|
956
|
+
return { backend: preferredBackend, reason: "preferredBackend" };
|
|
663
957
|
}
|
|
664
958
|
if (agentType) {
|
|
665
959
|
const mapped = this.agentToBackendMap[agentType];
|
|
666
960
|
if (mapped && availableBackends.includes(mapped)) {
|
|
667
|
-
return mapped;
|
|
961
|
+
return { backend: mapped, reason: `agentType:${agentType}\u2192${mapped}` };
|
|
668
962
|
}
|
|
669
963
|
if (!mapped && availableBackends.includes("claude")) {
|
|
670
|
-
return "claude";
|
|
964
|
+
return { backend: "claude", reason: `agentType:${agentType}\u2192claude(unmapped)` };
|
|
671
965
|
}
|
|
672
966
|
}
|
|
673
967
|
if (taskType) {
|
|
674
968
|
const mapped = TASK_TYPE_TO_BACKEND_MAP[taskType];
|
|
675
969
|
if (mapped && availableBackends.includes(mapped)) {
|
|
676
|
-
return mapped;
|
|
970
|
+
return { backend: mapped, reason: `taskType:${taskType}\u2192${mapped}` };
|
|
677
971
|
}
|
|
678
972
|
}
|
|
679
973
|
if (availableBackends.includes(this.defaultBackend)) {
|
|
680
|
-
return this.defaultBackend;
|
|
974
|
+
return { backend: this.defaultBackend, reason: "default" };
|
|
681
975
|
}
|
|
682
|
-
return availableBackends[0];
|
|
976
|
+
return { backend: availableBackends[0], reason: "fallback" };
|
|
683
977
|
}
|
|
684
978
|
};
|
|
685
979
|
}
|
|
@@ -696,17 +990,24 @@ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/
|
|
|
696
990
|
import { createServer } from "http";
|
|
697
991
|
import { randomUUID } from "crypto";
|
|
698
992
|
import { z as z5 } from "zod";
|
|
699
|
-
var RelayMCPServer;
|
|
993
|
+
var spawnAgentsParallelInputShape, MAX_CHILD_HTTP_SESSIONS, RelayMCPServer;
|
|
700
994
|
var init_server = __esm({
|
|
701
995
|
"src/mcp-server/server.ts"() {
|
|
702
996
|
"use strict";
|
|
703
997
|
init_recursion_guard();
|
|
704
998
|
init_spawn_agent();
|
|
999
|
+
init_spawn_agents_parallel();
|
|
705
1000
|
init_list_sessions();
|
|
706
1001
|
init_get_context_status();
|
|
707
1002
|
init_list_available_backends();
|
|
708
1003
|
init_backend_selector();
|
|
709
1004
|
init_logger();
|
|
1005
|
+
spawnAgentsParallelInputShape = {
|
|
1006
|
+
agents: z5.array(spawnAgentInputSchema).min(1).max(10).describe(
|
|
1007
|
+
"Array of agent configurations to execute in parallel (1-10 agents)"
|
|
1008
|
+
)
|
|
1009
|
+
};
|
|
1010
|
+
MAX_CHILD_HTTP_SESSIONS = 100;
|
|
710
1011
|
RelayMCPServer = class {
|
|
711
1012
|
constructor(registry2, sessionManager2, guardConfig, hooksEngine2, contextMonitor2) {
|
|
712
1013
|
this.registry = registry2;
|
|
@@ -717,7 +1018,7 @@ var init_server = __esm({
|
|
|
717
1018
|
this.backendSelector = new BackendSelector();
|
|
718
1019
|
this.server = new McpServer({
|
|
719
1020
|
name: "agentic-relay",
|
|
720
|
-
version: "0.
|
|
1021
|
+
version: "0.8.0"
|
|
721
1022
|
});
|
|
722
1023
|
this.registerTools(this.server);
|
|
723
1024
|
}
|
|
@@ -738,28 +1039,23 @@ var init_server = __esm({
|
|
|
738
1039
|
server.tool(
|
|
739
1040
|
"spawn_agent",
|
|
740
1041
|
"Spawn a sub-agent on the specified backend CLI (Claude Code, Codex CLI, or Gemini CLI). The agent executes the given prompt in non-interactive mode and returns the result. Use 'agent' for named agent configurations (Claude only), 'systemPrompt' for custom role instructions (all backends), 'skillContext' to inject a skill definition (SKILL.md/SUBSKILL.md), 'agentDefinition' to inject an agent definition file into the sub-agent's system prompt, 'preferredBackend' to override automatic backend selection, or 'taskType' to hint at the task nature for backend selection.",
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
prompt: z5.string(),
|
|
744
|
-
agent: z5.string().optional().describe("Named agent configuration (Claude only)"),
|
|
745
|
-
systemPrompt: z5.string().optional().describe(
|
|
746
|
-
"System prompt / role instructions for the sub-agent (all backends)"
|
|
747
|
-
),
|
|
748
|
-
resumeSessionId: z5.string().optional(),
|
|
749
|
-
model: z5.string().optional(),
|
|
750
|
-
maxTurns: z5.number().optional(),
|
|
751
|
-
skillContext: z5.object({
|
|
752
|
-
skillPath: z5.string().describe("Path to the skill directory (e.g., '.agents/skills/software-engineer/')"),
|
|
753
|
-
subskill: z5.string().optional().describe("Specific subskill to activate")
|
|
754
|
-
}).optional().describe("Skill context to inject into the sub-agent's system prompt"),
|
|
755
|
-
agentDefinition: z5.object({
|
|
756
|
-
definitionPath: z5.string().describe("Path to the agent definition file (e.g., '.claude/agents/software-engineer.md')")
|
|
757
|
-
}).optional().describe("Agent definition file to inject into the sub-agent's system prompt"),
|
|
758
|
-
preferredBackend: z5.enum(["claude", "codex", "gemini"]).optional().describe("Preferred backend override. Takes priority over automatic selection based on agent/task type."),
|
|
759
|
-
taskType: z5.enum(["code", "document", "analysis", "mixed"]).optional().describe("Task type hint for automatic backend selection when preferredBackend is not specified.")
|
|
760
|
-
},
|
|
761
|
-
async (params) => {
|
|
1042
|
+
spawnAgentInputSchema.shape,
|
|
1043
|
+
async (params, extra) => {
|
|
762
1044
|
try {
|
|
1045
|
+
const onProgress = (progress) => {
|
|
1046
|
+
const progressToken = params._meta ? params._meta.progressToken : void 0;
|
|
1047
|
+
if (progressToken !== void 0) {
|
|
1048
|
+
void extra.sendNotification({
|
|
1049
|
+
method: "notifications/progress",
|
|
1050
|
+
params: {
|
|
1051
|
+
progressToken,
|
|
1052
|
+
progress: progress.percent ?? 0,
|
|
1053
|
+
total: 100,
|
|
1054
|
+
message: progress.stage
|
|
1055
|
+
}
|
|
1056
|
+
});
|
|
1057
|
+
}
|
|
1058
|
+
};
|
|
763
1059
|
const result = await executeSpawnAgent(
|
|
764
1060
|
params,
|
|
765
1061
|
this.registry,
|
|
@@ -768,12 +1064,110 @@ var init_server = __esm({
|
|
|
768
1064
|
this.hooksEngine,
|
|
769
1065
|
this.contextMonitor,
|
|
770
1066
|
this.backendSelector,
|
|
771
|
-
this._childHttpUrl
|
|
1067
|
+
this._childHttpUrl,
|
|
1068
|
+
onProgress
|
|
772
1069
|
);
|
|
773
1070
|
const isError = result.exitCode !== 0;
|
|
774
|
-
|
|
1071
|
+
let text = isError ? `Error (exit ${result.exitCode}): ${result.stderr || result.stdout}` : `Session: ${result.sessionId}
|
|
775
1072
|
|
|
776
1073
|
${result.stdout}`;
|
|
1074
|
+
if (result.metadata) {
|
|
1075
|
+
text += `
|
|
1076
|
+
|
|
1077
|
+
<metadata>
|
|
1078
|
+
${JSON.stringify(result.metadata, null, 2)}
|
|
1079
|
+
</metadata>`;
|
|
1080
|
+
}
|
|
1081
|
+
return {
|
|
1082
|
+
content: [{ type: "text", text }],
|
|
1083
|
+
isError
|
|
1084
|
+
};
|
|
1085
|
+
} catch (error) {
|
|
1086
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1087
|
+
return {
|
|
1088
|
+
content: [{ type: "text", text: `Error: ${message}` }],
|
|
1089
|
+
isError: true
|
|
1090
|
+
};
|
|
1091
|
+
}
|
|
1092
|
+
}
|
|
1093
|
+
);
|
|
1094
|
+
server.tool(
|
|
1095
|
+
"spawn_agents_parallel",
|
|
1096
|
+
"Spawn multiple sub-agents in parallel across available backends. Each agent entry accepts the same parameters as spawn_agent. All agents are executed concurrently via Promise.allSettled, and results are returned as an array with per-agent status. RecursionGuard batch pre-validation ensures the entire batch fits within call limits before execution begins. Failed results include 'originalInput' for retry via retry_failed_agents.",
|
|
1097
|
+
spawnAgentsParallelInputShape,
|
|
1098
|
+
async (params, extra) => {
|
|
1099
|
+
try {
|
|
1100
|
+
const onProgress = (progress) => {
|
|
1101
|
+
const progressToken = params._meta ? params._meta.progressToken : void 0;
|
|
1102
|
+
if (progressToken !== void 0) {
|
|
1103
|
+
void extra.sendNotification({
|
|
1104
|
+
method: "notifications/progress",
|
|
1105
|
+
params: {
|
|
1106
|
+
progressToken,
|
|
1107
|
+
progress: progress.percent ?? 0,
|
|
1108
|
+
total: 100,
|
|
1109
|
+
message: progress.stage
|
|
1110
|
+
}
|
|
1111
|
+
});
|
|
1112
|
+
}
|
|
1113
|
+
};
|
|
1114
|
+
const result = await executeSpawnAgentsParallel(
|
|
1115
|
+
params.agents,
|
|
1116
|
+
this.registry,
|
|
1117
|
+
this.sessionManager,
|
|
1118
|
+
this.guard,
|
|
1119
|
+
this.hooksEngine,
|
|
1120
|
+
this.contextMonitor,
|
|
1121
|
+
this.backendSelector,
|
|
1122
|
+
this._childHttpUrl,
|
|
1123
|
+
onProgress
|
|
1124
|
+
);
|
|
1125
|
+
const isError = result.failureCount === result.totalCount;
|
|
1126
|
+
let text = "";
|
|
1127
|
+
if (result.hasConflicts) {
|
|
1128
|
+
text += "\u26A0 FILE CONFLICTS DETECTED: Multiple agents modified the same files.\n";
|
|
1129
|
+
text += `Conflicting files: ${result.conflicts.map((c) => c.path).join(", ")}
|
|
1130
|
+
|
|
1131
|
+
`;
|
|
1132
|
+
}
|
|
1133
|
+
text += JSON.stringify(result, null, 2);
|
|
1134
|
+
return {
|
|
1135
|
+
content: [{ type: "text", text }],
|
|
1136
|
+
isError
|
|
1137
|
+
};
|
|
1138
|
+
} catch (error) {
|
|
1139
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1140
|
+
return {
|
|
1141
|
+
content: [{ type: "text", text: `Error: ${message}` }],
|
|
1142
|
+
isError: true
|
|
1143
|
+
};
|
|
1144
|
+
}
|
|
1145
|
+
}
|
|
1146
|
+
);
|
|
1147
|
+
server.tool(
|
|
1148
|
+
"retry_failed_agents",
|
|
1149
|
+
"Retry only the failed agents from a previous spawn_agents_parallel call. Pass the failed results array (with originalInput) directly.",
|
|
1150
|
+
{
|
|
1151
|
+
failedResults: z5.array(z5.object({
|
|
1152
|
+
index: z5.number(),
|
|
1153
|
+
originalInput: spawnAgentInputSchema
|
|
1154
|
+
})).min(1).describe("Array of failed results with their original input configurations")
|
|
1155
|
+
},
|
|
1156
|
+
async (params) => {
|
|
1157
|
+
try {
|
|
1158
|
+
const agents = params.failedResults.map((r) => r.originalInput);
|
|
1159
|
+
const result = await executeSpawnAgentsParallel(
|
|
1160
|
+
agents,
|
|
1161
|
+
this.registry,
|
|
1162
|
+
this.sessionManager,
|
|
1163
|
+
this.guard,
|
|
1164
|
+
this.hooksEngine,
|
|
1165
|
+
this.contextMonitor,
|
|
1166
|
+
this.backendSelector,
|
|
1167
|
+
this._childHttpUrl
|
|
1168
|
+
);
|
|
1169
|
+
const isError = result.failureCount === result.totalCount;
|
|
1170
|
+
const text = JSON.stringify(result, null, 2);
|
|
777
1171
|
return {
|
|
778
1172
|
content: [{ type: "text", text }],
|
|
779
1173
|
isError
|
|
@@ -899,14 +1293,14 @@ ${result.stdout}`;
|
|
|
899
1293
|
});
|
|
900
1294
|
this._httpServer = httpServer;
|
|
901
1295
|
await this.server.connect(httpTransport);
|
|
902
|
-
await new Promise((
|
|
903
|
-
httpServer.listen(port, () => {
|
|
1296
|
+
await new Promise((resolve2) => {
|
|
1297
|
+
httpServer.listen(port, "127.0.0.1", () => {
|
|
904
1298
|
logger.info(`MCP server listening on http://localhost:${port}/mcp`);
|
|
905
|
-
|
|
1299
|
+
resolve2();
|
|
906
1300
|
});
|
|
907
1301
|
});
|
|
908
|
-
await new Promise((
|
|
909
|
-
httpServer.on("close",
|
|
1302
|
+
await new Promise((resolve2) => {
|
|
1303
|
+
httpServer.on("close", resolve2);
|
|
910
1304
|
});
|
|
911
1305
|
}
|
|
912
1306
|
/**
|
|
@@ -937,7 +1331,7 @@ ${result.stdout}`;
|
|
|
937
1331
|
});
|
|
938
1332
|
const server = new McpServer({
|
|
939
1333
|
name: "agentic-relay",
|
|
940
|
-
version: "0.
|
|
1334
|
+
version: "0.8.0"
|
|
941
1335
|
});
|
|
942
1336
|
this.registerTools(server);
|
|
943
1337
|
transport.onclose = () => {
|
|
@@ -953,15 +1347,31 @@ ${result.stdout}`;
|
|
|
953
1347
|
if (sid) {
|
|
954
1348
|
sessions.set(sid, { transport, server });
|
|
955
1349
|
logger.debug(`Child MCP session created: ${sid}`);
|
|
1350
|
+
if (sessions.size > MAX_CHILD_HTTP_SESSIONS) {
|
|
1351
|
+
const oldestEntry = sessions.entries().next().value;
|
|
1352
|
+
if (oldestEntry) {
|
|
1353
|
+
const [oldestSessionId, oldestSession] = oldestEntry;
|
|
1354
|
+
sessions.delete(oldestSessionId);
|
|
1355
|
+
logger.warn(
|
|
1356
|
+
`Child MCP session evicted due to limit (${MAX_CHILD_HTTP_SESSIONS}): ${oldestSessionId}`
|
|
1357
|
+
);
|
|
1358
|
+
void oldestSession.transport.close().catch((error) => {
|
|
1359
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1360
|
+
logger.debug(
|
|
1361
|
+
`Failed to close evicted child MCP session ${oldestSessionId}: ${message}`
|
|
1362
|
+
);
|
|
1363
|
+
});
|
|
1364
|
+
}
|
|
1365
|
+
}
|
|
956
1366
|
}
|
|
957
1367
|
});
|
|
958
1368
|
this._childHttpServer = httpServer;
|
|
959
|
-
await new Promise((
|
|
1369
|
+
await new Promise((resolve2) => {
|
|
960
1370
|
httpServer.listen(0, "127.0.0.1", () => {
|
|
961
1371
|
const addr = httpServer.address();
|
|
962
1372
|
this._childHttpUrl = `http://127.0.0.1:${addr.port}/mcp`;
|
|
963
1373
|
logger.info(`Child MCP server listening on ${this._childHttpUrl}`);
|
|
964
|
-
|
|
1374
|
+
resolve2();
|
|
965
1375
|
});
|
|
966
1376
|
});
|
|
967
1377
|
}
|
|
@@ -1113,10 +1523,11 @@ var AdapterRegistry = class {
|
|
|
1113
1523
|
|
|
1114
1524
|
// src/adapters/base-adapter.ts
|
|
1115
1525
|
init_logger();
|
|
1116
|
-
var BaseAdapter = class {
|
|
1526
|
+
var BaseAdapter = class _BaseAdapter {
|
|
1117
1527
|
constructor(processManager2) {
|
|
1118
1528
|
this.processManager = processManager2;
|
|
1119
1529
|
}
|
|
1530
|
+
static HEALTH_TIMEOUT_MS = 5e3;
|
|
1120
1531
|
async isInstalled() {
|
|
1121
1532
|
try {
|
|
1122
1533
|
const result = await this.processManager.execute("which", [this.command]);
|
|
@@ -1144,11 +1555,13 @@ var BaseAdapter = class {
|
|
|
1144
1555
|
};
|
|
1145
1556
|
}
|
|
1146
1557
|
async checkHealth() {
|
|
1147
|
-
const HEALTH_TIMEOUT = 5e3;
|
|
1148
1558
|
const installed = await Promise.race([
|
|
1149
1559
|
this.isInstalled(),
|
|
1150
1560
|
new Promise(
|
|
1151
|
-
(_, reject) => setTimeout(
|
|
1561
|
+
(_, reject) => setTimeout(
|
|
1562
|
+
() => reject(new Error("timeout")),
|
|
1563
|
+
_BaseAdapter.HEALTH_TIMEOUT_MS
|
|
1564
|
+
)
|
|
1152
1565
|
)
|
|
1153
1566
|
]).catch(() => false);
|
|
1154
1567
|
if (!installed) {
|
|
@@ -1162,14 +1575,29 @@ var BaseAdapter = class {
|
|
|
1162
1575
|
const version = await Promise.race([
|
|
1163
1576
|
this.getVersion(),
|
|
1164
1577
|
new Promise(
|
|
1165
|
-
(_, reject) => setTimeout(
|
|
1578
|
+
(_, reject) => setTimeout(
|
|
1579
|
+
() => reject(new Error("timeout")),
|
|
1580
|
+
_BaseAdapter.HEALTH_TIMEOUT_MS
|
|
1581
|
+
)
|
|
1166
1582
|
)
|
|
1167
1583
|
]).catch(() => void 0);
|
|
1584
|
+
const authStatus = await Promise.race([
|
|
1585
|
+
this.checkAuthStatus(),
|
|
1586
|
+
new Promise(
|
|
1587
|
+
(_, reject) => setTimeout(
|
|
1588
|
+
() => reject(new Error("timeout")),
|
|
1589
|
+
_BaseAdapter.HEALTH_TIMEOUT_MS
|
|
1590
|
+
)
|
|
1591
|
+
)
|
|
1592
|
+
]).catch(() => ({ authenticated: true }));
|
|
1593
|
+
const authenticated = authStatus.authenticated;
|
|
1594
|
+
const message = authStatus.message ?? (!authenticated ? `${this.id} authentication not configured` : void 0);
|
|
1168
1595
|
return {
|
|
1169
1596
|
installed: true,
|
|
1170
|
-
authenticated
|
|
1171
|
-
healthy:
|
|
1172
|
-
version
|
|
1597
|
+
authenticated,
|
|
1598
|
+
healthy: authenticated,
|
|
1599
|
+
version,
|
|
1600
|
+
...message ? { message } : {}
|
|
1173
1601
|
};
|
|
1174
1602
|
}
|
|
1175
1603
|
async getMCPConfig() {
|
|
@@ -1284,45 +1712,14 @@ var ClaudeAdapter = class extends BaseAdapter {
|
|
|
1284
1712
|
getConfigPath() {
|
|
1285
1713
|
return join(homedir(), ".claude.json");
|
|
1286
1714
|
}
|
|
1287
|
-
async
|
|
1288
|
-
const
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
)
|
|
1294
|
-
]).catch(() => false);
|
|
1295
|
-
if (!installed) {
|
|
1296
|
-
return {
|
|
1297
|
-
installed: false,
|
|
1298
|
-
authenticated: false,
|
|
1299
|
-
healthy: false,
|
|
1300
|
-
message: "claude is not installed"
|
|
1301
|
-
};
|
|
1302
|
-
}
|
|
1303
|
-
const version = await Promise.race([
|
|
1304
|
-
this.getVersion(),
|
|
1305
|
-
new Promise(
|
|
1306
|
-
(_, reject) => setTimeout(() => reject(new Error("timeout")), HEALTH_TIMEOUT)
|
|
1307
|
-
)
|
|
1308
|
-
]).catch(() => void 0);
|
|
1309
|
-
let authenticated = true;
|
|
1310
|
-
try {
|
|
1311
|
-
const result = await Promise.race([
|
|
1312
|
-
this.processManager.execute(this.command, ["auth", "status"]),
|
|
1313
|
-
new Promise(
|
|
1314
|
-
(_, reject) => setTimeout(() => reject(new Error("timeout")), HEALTH_TIMEOUT)
|
|
1315
|
-
)
|
|
1316
|
-
]);
|
|
1317
|
-
authenticated = result.exitCode === 0;
|
|
1318
|
-
} catch {
|
|
1319
|
-
authenticated = true;
|
|
1320
|
-
}
|
|
1715
|
+
async checkAuthStatus() {
|
|
1716
|
+
const result = await this.processManager.execute(this.command, [
|
|
1717
|
+
"auth",
|
|
1718
|
+
"status"
|
|
1719
|
+
]);
|
|
1720
|
+
const authenticated = result.exitCode === 0;
|
|
1321
1721
|
return {
|
|
1322
|
-
installed: true,
|
|
1323
1722
|
authenticated,
|
|
1324
|
-
healthy: authenticated,
|
|
1325
|
-
version,
|
|
1326
1723
|
...!authenticated ? { message: "claude authentication not configured" } : {}
|
|
1327
1724
|
};
|
|
1328
1725
|
}
|
|
@@ -1761,48 +2158,32 @@ var CodexAdapter = class extends BaseAdapter {
|
|
|
1761
2158
|
getConfigPath() {
|
|
1762
2159
|
return join2(homedir2(), ".codex", "config.toml");
|
|
1763
2160
|
}
|
|
1764
|
-
async
|
|
1765
|
-
const
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
)
|
|
1771
|
-
]).catch(() => false);
|
|
1772
|
-
if (!installed) {
|
|
1773
|
-
return {
|
|
1774
|
-
installed: false,
|
|
1775
|
-
authenticated: false,
|
|
1776
|
-
healthy: false,
|
|
1777
|
-
message: "codex is not installed"
|
|
1778
|
-
};
|
|
1779
|
-
}
|
|
1780
|
-
const version = await Promise.race([
|
|
1781
|
-
this.getVersion(),
|
|
1782
|
-
new Promise(
|
|
1783
|
-
(_, reject) => setTimeout(() => reject(new Error("timeout")), HEALTH_TIMEOUT)
|
|
1784
|
-
)
|
|
1785
|
-
]).catch(() => void 0);
|
|
1786
|
-
let authenticated = true;
|
|
1787
|
-
try {
|
|
1788
|
-
const result = await Promise.race([
|
|
1789
|
-
this.processManager.execute(this.command, ["login", "status"]),
|
|
1790
|
-
new Promise(
|
|
1791
|
-
(_, reject) => setTimeout(() => reject(new Error("timeout")), HEALTH_TIMEOUT)
|
|
1792
|
-
)
|
|
1793
|
-
]);
|
|
1794
|
-
authenticated = result.exitCode === 0;
|
|
1795
|
-
} catch {
|
|
1796
|
-
authenticated = true;
|
|
1797
|
-
}
|
|
2161
|
+
async checkAuthStatus() {
|
|
2162
|
+
const result = await this.processManager.execute(this.command, [
|
|
2163
|
+
"login",
|
|
2164
|
+
"status"
|
|
2165
|
+
]);
|
|
2166
|
+
const authenticated = result.exitCode === 0;
|
|
1798
2167
|
return {
|
|
1799
|
-
installed: true,
|
|
1800
2168
|
authenticated,
|
|
1801
|
-
healthy: authenticated,
|
|
1802
|
-
version,
|
|
1803
2169
|
...!authenticated ? { message: "codex authentication not configured" } : {}
|
|
1804
2170
|
};
|
|
1805
2171
|
}
|
|
2172
|
+
buildCodexOptions(flags) {
|
|
2173
|
+
if (!flags.mcpContext) {
|
|
2174
|
+
return {};
|
|
2175
|
+
}
|
|
2176
|
+
const env = {};
|
|
2177
|
+
for (const [key, value] of Object.entries(process.env)) {
|
|
2178
|
+
if (value !== void 0) {
|
|
2179
|
+
env[key] = value;
|
|
2180
|
+
}
|
|
2181
|
+
}
|
|
2182
|
+
env.RELAY_TRACE_ID = flags.mcpContext.traceId;
|
|
2183
|
+
env.RELAY_PARENT_SESSION_ID = flags.mcpContext.parentSessionId;
|
|
2184
|
+
env.RELAY_DEPTH = String(flags.mcpContext.depth);
|
|
2185
|
+
return { env };
|
|
2186
|
+
}
|
|
1806
2187
|
mapFlags(flags) {
|
|
1807
2188
|
const args = mapCommonToNative("codex", flags);
|
|
1808
2189
|
if (flags.outputFormat === "json") {
|
|
@@ -1855,16 +2236,7 @@ ${prompt}`;
|
|
|
1855
2236
|
);
|
|
1856
2237
|
try {
|
|
1857
2238
|
const { Codex } = await loadCodexSDK();
|
|
1858
|
-
const
|
|
1859
|
-
if (flags.mcpContext) {
|
|
1860
|
-
codexOptions.env = {
|
|
1861
|
-
...process.env,
|
|
1862
|
-
RELAY_TRACE_ID: flags.mcpContext.traceId,
|
|
1863
|
-
RELAY_PARENT_SESSION_ID: flags.mcpContext.parentSessionId,
|
|
1864
|
-
RELAY_DEPTH: String(flags.mcpContext.depth)
|
|
1865
|
-
};
|
|
1866
|
-
}
|
|
1867
|
-
const codex = new Codex(codexOptions);
|
|
2239
|
+
const codex = new Codex(this.buildCodexOptions(flags));
|
|
1868
2240
|
const thread = codex.startThread({
|
|
1869
2241
|
...flags.model ? { model: flags.model } : {},
|
|
1870
2242
|
workingDirectory: process.cwd(),
|
|
@@ -1896,16 +2268,7 @@ ${prompt}`;
|
|
|
1896
2268
|
);
|
|
1897
2269
|
try {
|
|
1898
2270
|
const { Codex } = await loadCodexSDK();
|
|
1899
|
-
const
|
|
1900
|
-
if (flags.mcpContext) {
|
|
1901
|
-
codexOptions.env = {
|
|
1902
|
-
...process.env,
|
|
1903
|
-
RELAY_TRACE_ID: flags.mcpContext.traceId,
|
|
1904
|
-
RELAY_PARENT_SESSION_ID: flags.mcpContext.parentSessionId,
|
|
1905
|
-
RELAY_DEPTH: String(flags.mcpContext.depth)
|
|
1906
|
-
};
|
|
1907
|
-
}
|
|
1908
|
-
const codex = new Codex(codexOptions);
|
|
2271
|
+
const codex = new Codex(this.buildCodexOptions(flags));
|
|
1909
2272
|
const thread = codex.startThread({
|
|
1910
2273
|
...flags.model ? { model: flags.model } : {},
|
|
1911
2274
|
workingDirectory: process.cwd(),
|
|
@@ -1919,15 +2282,15 @@ ${prompt}`;
|
|
|
1919
2282
|
threadId = event.thread_id;
|
|
1920
2283
|
} else if (event.type === "item.started") {
|
|
1921
2284
|
const item = event.item;
|
|
1922
|
-
if (item
|
|
2285
|
+
if (item.type === "agent_message" && item.text) {
|
|
1923
2286
|
yield { type: "text", text: item.text };
|
|
1924
|
-
} else if (item
|
|
2287
|
+
} else if (item.type === "command_execution") {
|
|
1925
2288
|
yield {
|
|
1926
2289
|
type: "tool_start",
|
|
1927
2290
|
tool: item.command ?? "command",
|
|
1928
2291
|
id: item.id ?? ""
|
|
1929
2292
|
};
|
|
1930
|
-
} else if (item
|
|
2293
|
+
} else if (item.type === "file_change") {
|
|
1931
2294
|
yield {
|
|
1932
2295
|
type: "tool_start",
|
|
1933
2296
|
tool: "file_change",
|
|
@@ -1936,17 +2299,17 @@ ${prompt}`;
|
|
|
1936
2299
|
}
|
|
1937
2300
|
} else if (event.type === "item.completed") {
|
|
1938
2301
|
const item = event.item;
|
|
1939
|
-
if (item
|
|
2302
|
+
if (item.type === "agent_message" && item.text) {
|
|
1940
2303
|
completedMessages.push(item.text);
|
|
1941
2304
|
yield { type: "text", text: item.text };
|
|
1942
|
-
} else if (item
|
|
2305
|
+
} else if (item.type === "command_execution") {
|
|
1943
2306
|
yield {
|
|
1944
2307
|
type: "tool_end",
|
|
1945
2308
|
tool: item.command ?? "command",
|
|
1946
2309
|
id: item.id ?? "",
|
|
1947
2310
|
result: item.aggregated_output
|
|
1948
2311
|
};
|
|
1949
|
-
} else if (item
|
|
2312
|
+
} else if (item.type === "file_change") {
|
|
1950
2313
|
yield {
|
|
1951
2314
|
type: "tool_end",
|
|
1952
2315
|
tool: "file_change",
|
|
@@ -1969,7 +2332,7 @@ ${prompt}`;
|
|
|
1969
2332
|
nativeSessionId: threadId ?? thread.id ?? void 0
|
|
1970
2333
|
};
|
|
1971
2334
|
} else if (event.type === "turn.failed") {
|
|
1972
|
-
const errorMessage = event.error
|
|
2335
|
+
const errorMessage = event.error.message ?? "Turn failed";
|
|
1973
2336
|
yield {
|
|
1974
2337
|
type: "done",
|
|
1975
2338
|
result: { exitCode: 1, stdout: "", stderr: errorMessage },
|
|
@@ -2106,37 +2469,18 @@ var GeminiAdapter = class extends BaseAdapter {
|
|
|
2106
2469
|
getConfigPath() {
|
|
2107
2470
|
return join3(homedir3(), ".gemini", "settings.json");
|
|
2108
2471
|
}
|
|
2109
|
-
async
|
|
2110
|
-
const
|
|
2111
|
-
const
|
|
2112
|
-
|
|
2113
|
-
|
|
2114
|
-
(_, reject) => setTimeout(() => reject(new Error("timeout")), HEALTH_TIMEOUT)
|
|
2115
|
-
)
|
|
2116
|
-
]).catch(() => false);
|
|
2117
|
-
if (!installed) {
|
|
2472
|
+
async checkAuthStatus() {
|
|
2473
|
+
const hasApiKey = !!process.env["GEMINI_API_KEY"];
|
|
2474
|
+
const hasGoogleAdc = !!process.env["GOOGLE_APPLICATION_CREDENTIALS"] || !!process.env["CLOUDSDK_CONFIG"];
|
|
2475
|
+
const authenticated = hasApiKey || hasGoogleAdc;
|
|
2476
|
+
if (!authenticated) {
|
|
2118
2477
|
return {
|
|
2119
|
-
|
|
2120
|
-
authenticated:
|
|
2121
|
-
|
|
2122
|
-
message: "gemini is not installed"
|
|
2478
|
+
// Optimistic fallback: Gemini may still authenticate via ADC at runtime.
|
|
2479
|
+
authenticated: true,
|
|
2480
|
+
message: "Gemini authentication status unknown - ADC may be available at runtime"
|
|
2123
2481
|
};
|
|
2124
2482
|
}
|
|
2125
|
-
|
|
2126
|
-
this.getVersion(),
|
|
2127
|
-
new Promise(
|
|
2128
|
-
(_, reject) => setTimeout(() => reject(new Error("timeout")), HEALTH_TIMEOUT)
|
|
2129
|
-
)
|
|
2130
|
-
]).catch(() => void 0);
|
|
2131
|
-
const hasApiKey = !!process.env["GEMINI_API_KEY"];
|
|
2132
|
-
const hasGoogleAdc = !!process.env["GOOGLE_APPLICATION_CREDENTIALS"] || !!process.env["CLOUDSDK_CONFIG"];
|
|
2133
|
-
const authenticated = hasApiKey || hasGoogleAdc || true;
|
|
2134
|
-
return {
|
|
2135
|
-
installed: true,
|
|
2136
|
-
authenticated,
|
|
2137
|
-
healthy: true,
|
|
2138
|
-
version
|
|
2139
|
-
};
|
|
2483
|
+
return { authenticated };
|
|
2140
2484
|
}
|
|
2141
2485
|
mapFlags(flags) {
|
|
2142
2486
|
const args = mapCommonToNative("gemini", flags);
|
|
@@ -3025,6 +3369,7 @@ var HooksEngine = class _HooksEngine {
|
|
|
3025
3369
|
};
|
|
3026
3370
|
|
|
3027
3371
|
// src/core/context-monitor.ts
|
|
3372
|
+
init_logger();
|
|
3028
3373
|
var DEFAULT_BACKEND_CONTEXT = {
|
|
3029
3374
|
claude: { contextWindow: 2e5, compactThreshold: 19e4 },
|
|
3030
3375
|
codex: { contextWindow: 272e3, compactThreshold: 258400 },
|
|
@@ -3143,7 +3488,9 @@ var ContextMonitor = class {
|
|
|
3143
3488
|
remainingBeforeCompact
|
|
3144
3489
|
}
|
|
3145
3490
|
};
|
|
3146
|
-
void this.hooksEngine.emit("on-context-threshold", hookInput)
|
|
3491
|
+
void this.hooksEngine.emit("on-context-threshold", hookInput).catch(
|
|
3492
|
+
(e) => logger.debug("Context threshold hook error:", e)
|
|
3493
|
+
);
|
|
3147
3494
|
}
|
|
3148
3495
|
}
|
|
3149
3496
|
};
|
|
@@ -3919,7 +4266,7 @@ function createVersionCommand(registry2) {
|
|
|
3919
4266
|
description: "Show relay and backend versions"
|
|
3920
4267
|
},
|
|
3921
4268
|
async run() {
|
|
3922
|
-
const relayVersion = "0.
|
|
4269
|
+
const relayVersion = "0.8.0";
|
|
3923
4270
|
console.log(`agentic-relay v${relayVersion}`);
|
|
3924
4271
|
console.log("");
|
|
3925
4272
|
console.log("Backends:");
|
|
@@ -3946,9 +4293,9 @@ import { defineCommand as defineCommand8 } from "citty";
|
|
|
3946
4293
|
import { access, constants, readdir as readdir2 } from "fs/promises";
|
|
3947
4294
|
import { join as join7 } from "path";
|
|
3948
4295
|
import { homedir as homedir5 } from "os";
|
|
3949
|
-
import { execFile } from "child_process";
|
|
3950
|
-
import { promisify } from "util";
|
|
3951
|
-
var
|
|
4296
|
+
import { execFile as execFile2 } from "child_process";
|
|
4297
|
+
import { promisify as promisify2 } from "util";
|
|
4298
|
+
var execFileAsync2 = promisify2(execFile2);
|
|
3952
4299
|
async function checkNodeVersion() {
|
|
3953
4300
|
const version = process.version;
|
|
3954
4301
|
const major = Number(version.slice(1).split(".")[0]);
|
|
@@ -4048,7 +4395,7 @@ async function checkMCPServerCommands(configManager2) {
|
|
|
4048
4395
|
for (const [name, server] of Object.entries(mcpServers)) {
|
|
4049
4396
|
const command = server.command;
|
|
4050
4397
|
try {
|
|
4051
|
-
await
|
|
4398
|
+
await execFileAsync2("which", [command]);
|
|
4052
4399
|
results.push({
|
|
4053
4400
|
label: `MCP server: ${name}`,
|
|
4054
4401
|
ok: true,
|
|
@@ -4244,6 +4591,7 @@ function createInitCommand() {
|
|
|
4244
4591
|
}
|
|
4245
4592
|
|
|
4246
4593
|
// src/bin/relay.ts
|
|
4594
|
+
init_logger();
|
|
4247
4595
|
var processManager = new ProcessManager();
|
|
4248
4596
|
var registry = new AdapterRegistry();
|
|
4249
4597
|
registry.registerLazy("claude", () => new ClaudeAdapter(processManager));
|
|
@@ -4264,12 +4612,11 @@ void configManager.getConfig().then((config) => {
|
|
|
4264
4612
|
if (config.contextMonitor) {
|
|
4265
4613
|
contextMonitor = new ContextMonitor(hooksEngine, config.contextMonitor);
|
|
4266
4614
|
}
|
|
4267
|
-
}).catch(() =>
|
|
4268
|
-
});
|
|
4615
|
+
}).catch((e) => logger.debug("Config load failed:", e));
|
|
4269
4616
|
var main = defineCommand10({
|
|
4270
4617
|
meta: {
|
|
4271
4618
|
name: "relay",
|
|
4272
|
-
version: "0.
|
|
4619
|
+
version: "0.8.0",
|
|
4273
4620
|
description: "Unified CLI proxy for Claude Code, Codex CLI, and Gemini CLI"
|
|
4274
4621
|
},
|
|
4275
4622
|
subCommands: {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rk0429/agentic-relay",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.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",
|
|
@@ -40,7 +40,9 @@
|
|
|
40
40
|
"test": "vitest run",
|
|
41
41
|
"test:coverage": "vitest run --coverage",
|
|
42
42
|
"test:watch": "vitest",
|
|
43
|
-
"lint": "tsc --noEmit",
|
|
43
|
+
"lint": "tsc --noEmit && eslint .",
|
|
44
|
+
"format": "prettier --write .",
|
|
45
|
+
"format:check": "prettier --check .",
|
|
44
46
|
"prepublishOnly": "pnpm test && pnpm build"
|
|
45
47
|
},
|
|
46
48
|
"engines": {
|
|
@@ -57,10 +59,15 @@
|
|
|
57
59
|
"zod": "^3.24.2"
|
|
58
60
|
},
|
|
59
61
|
"devDependencies": {
|
|
62
|
+
"@eslint/js": "^10.0.1",
|
|
60
63
|
"@types/node": "^25.3.0",
|
|
61
64
|
"@vitest/coverage-v8": "^3.2.4",
|
|
65
|
+
"eslint": "^10.0.2",
|
|
66
|
+
"eslint-config-prettier": "^10.1.8",
|
|
67
|
+
"prettier": "^3.8.1",
|
|
62
68
|
"tsup": "^8.4.0",
|
|
63
69
|
"typescript": "^5.7.3",
|
|
70
|
+
"typescript-eslint": "^8.56.1",
|
|
64
71
|
"vitest": "^3.0.7"
|
|
65
72
|
},
|
|
66
73
|
"pnpm": {
|