@rk0429/agentic-relay 0.7.0 → 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 +457 -252
- 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,13 +616,108 @@ 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
|
+
|
|
545
719
|
// src/mcp-server/tools/spawn-agents-parallel.ts
|
|
546
|
-
async function executeSpawnAgentsParallel(agents, registry2, sessionManager2, guard, hooksEngine2, contextMonitor2, backendSelector, childHttpUrl) {
|
|
720
|
+
async function executeSpawnAgentsParallel(agents, registry2, sessionManager2, guard, hooksEngine2, contextMonitor2, backendSelector, childHttpUrl, onProgress) {
|
|
547
721
|
const envContext = buildContextFromEnv();
|
|
548
722
|
if (envContext.depth >= guard.getConfig().maxDepth) {
|
|
549
723
|
const reason = `Max depth exceeded: ${envContext.depth} >= ${guard.getConfig().maxDepth}`;
|
|
@@ -581,6 +755,10 @@ async function executeSpawnAgentsParallel(agents, registry2, sessionManager2, gu
|
|
|
581
755
|
failureCount: agents.length
|
|
582
756
|
};
|
|
583
757
|
}
|
|
758
|
+
const cwd = process.cwd();
|
|
759
|
+
const beforeSnapshot = await takeSnapshot(cwd);
|
|
760
|
+
onProgress?.({ stage: "spawning", percent: 5 });
|
|
761
|
+
let completedCount = 0;
|
|
584
762
|
const settled = await Promise.allSettled(
|
|
585
763
|
agents.map(
|
|
586
764
|
(agent) => executeSpawnAgent(
|
|
@@ -592,20 +770,32 @@ async function executeSpawnAgentsParallel(agents, registry2, sessionManager2, gu
|
|
|
592
770
|
contextMonitor2,
|
|
593
771
|
backendSelector,
|
|
594
772
|
childHttpUrl
|
|
595
|
-
)
|
|
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
|
+
})
|
|
596
781
|
)
|
|
597
782
|
);
|
|
598
783
|
const results = settled.map((outcome, index) => {
|
|
599
784
|
if (outcome.status === "fulfilled") {
|
|
600
785
|
const r = outcome.value;
|
|
601
|
-
|
|
786
|
+
const base = {
|
|
602
787
|
index,
|
|
603
788
|
sessionId: r.sessionId,
|
|
604
789
|
exitCode: r.exitCode,
|
|
605
790
|
stdout: r.stdout,
|
|
606
791
|
stderr: r.stderr,
|
|
607
|
-
...r.nativeSessionId ? { nativeSessionId: r.nativeSessionId } : {}
|
|
792
|
+
...r.nativeSessionId ? { nativeSessionId: r.nativeSessionId } : {},
|
|
793
|
+
...r.metadata ? { metadata: r.metadata } : {}
|
|
608
794
|
};
|
|
795
|
+
if (r.exitCode !== 0) {
|
|
796
|
+
base.originalInput = agents[index];
|
|
797
|
+
}
|
|
798
|
+
return base;
|
|
609
799
|
}
|
|
610
800
|
const errorMessage = outcome.reason instanceof Error ? outcome.reason.message : String(outcome.reason);
|
|
611
801
|
return {
|
|
@@ -614,21 +804,31 @@ async function executeSpawnAgentsParallel(agents, registry2, sessionManager2, gu
|
|
|
614
804
|
exitCode: 1,
|
|
615
805
|
stdout: "",
|
|
616
806
|
stderr: errorMessage,
|
|
617
|
-
error: errorMessage
|
|
807
|
+
error: errorMessage,
|
|
808
|
+
originalInput: agents[index]
|
|
618
809
|
};
|
|
619
810
|
});
|
|
620
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 });
|
|
621
819
|
return {
|
|
622
820
|
results,
|
|
623
821
|
totalCount: agents.length,
|
|
624
822
|
successCount,
|
|
625
|
-
failureCount: agents.length - successCount
|
|
823
|
+
failureCount: agents.length - successCount,
|
|
824
|
+
...conflictResult.hasConflicts ? { conflicts: conflictResult.conflicts, hasConflicts: true } : {}
|
|
626
825
|
};
|
|
627
826
|
}
|
|
628
827
|
var init_spawn_agents_parallel = __esm({
|
|
629
828
|
"src/mcp-server/tools/spawn-agents-parallel.ts"() {
|
|
630
829
|
"use strict";
|
|
631
830
|
init_spawn_agent();
|
|
831
|
+
init_conflict_detector();
|
|
632
832
|
init_logger();
|
|
633
833
|
}
|
|
634
834
|
});
|
|
@@ -745,32 +945,35 @@ var init_backend_selector = __esm({
|
|
|
745
945
|
this.agentToBackendMap = config?.agentToBackendMap ?? DEFAULT_AGENT_TO_BACKEND_MAP;
|
|
746
946
|
}
|
|
747
947
|
selectBackend(context) {
|
|
948
|
+
return this.selectBackendWithReason(context).backend;
|
|
949
|
+
}
|
|
950
|
+
selectBackendWithReason(context) {
|
|
748
951
|
const { availableBackends, preferredBackend, agentType, taskType } = context;
|
|
749
952
|
if (availableBackends.length === 0) {
|
|
750
953
|
throw new Error("No backends available");
|
|
751
954
|
}
|
|
752
955
|
if (preferredBackend && availableBackends.includes(preferredBackend)) {
|
|
753
|
-
return preferredBackend;
|
|
956
|
+
return { backend: preferredBackend, reason: "preferredBackend" };
|
|
754
957
|
}
|
|
755
958
|
if (agentType) {
|
|
756
959
|
const mapped = this.agentToBackendMap[agentType];
|
|
757
960
|
if (mapped && availableBackends.includes(mapped)) {
|
|
758
|
-
return mapped;
|
|
961
|
+
return { backend: mapped, reason: `agentType:${agentType}\u2192${mapped}` };
|
|
759
962
|
}
|
|
760
963
|
if (!mapped && availableBackends.includes("claude")) {
|
|
761
|
-
return "claude";
|
|
964
|
+
return { backend: "claude", reason: `agentType:${agentType}\u2192claude(unmapped)` };
|
|
762
965
|
}
|
|
763
966
|
}
|
|
764
967
|
if (taskType) {
|
|
765
968
|
const mapped = TASK_TYPE_TO_BACKEND_MAP[taskType];
|
|
766
969
|
if (mapped && availableBackends.includes(mapped)) {
|
|
767
|
-
return mapped;
|
|
970
|
+
return { backend: mapped, reason: `taskType:${taskType}\u2192${mapped}` };
|
|
768
971
|
}
|
|
769
972
|
}
|
|
770
973
|
if (availableBackends.includes(this.defaultBackend)) {
|
|
771
|
-
return this.defaultBackend;
|
|
974
|
+
return { backend: this.defaultBackend, reason: "default" };
|
|
772
975
|
}
|
|
773
|
-
return availableBackends[0];
|
|
976
|
+
return { backend: availableBackends[0], reason: "fallback" };
|
|
774
977
|
}
|
|
775
978
|
};
|
|
776
979
|
}
|
|
@@ -787,7 +990,7 @@ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/
|
|
|
787
990
|
import { createServer } from "http";
|
|
788
991
|
import { randomUUID } from "crypto";
|
|
789
992
|
import { z as z5 } from "zod";
|
|
790
|
-
var RelayMCPServer;
|
|
993
|
+
var spawnAgentsParallelInputShape, MAX_CHILD_HTTP_SESSIONS, RelayMCPServer;
|
|
791
994
|
var init_server = __esm({
|
|
792
995
|
"src/mcp-server/server.ts"() {
|
|
793
996
|
"use strict";
|
|
@@ -799,6 +1002,12 @@ var init_server = __esm({
|
|
|
799
1002
|
init_list_available_backends();
|
|
800
1003
|
init_backend_selector();
|
|
801
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;
|
|
802
1011
|
RelayMCPServer = class {
|
|
803
1012
|
constructor(registry2, sessionManager2, guardConfig, hooksEngine2, contextMonitor2) {
|
|
804
1013
|
this.registry = registry2;
|
|
@@ -809,7 +1018,7 @@ var init_server = __esm({
|
|
|
809
1018
|
this.backendSelector = new BackendSelector();
|
|
810
1019
|
this.server = new McpServer({
|
|
811
1020
|
name: "agentic-relay",
|
|
812
|
-
version: "0.
|
|
1021
|
+
version: "0.8.0"
|
|
813
1022
|
});
|
|
814
1023
|
this.registerTools(this.server);
|
|
815
1024
|
}
|
|
@@ -830,28 +1039,23 @@ var init_server = __esm({
|
|
|
830
1039
|
server.tool(
|
|
831
1040
|
"spawn_agent",
|
|
832
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.",
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
prompt: z5.string(),
|
|
836
|
-
agent: z5.string().optional().describe("Named agent configuration (Claude only)"),
|
|
837
|
-
systemPrompt: z5.string().optional().describe(
|
|
838
|
-
"System prompt / role instructions for the sub-agent (all backends)"
|
|
839
|
-
),
|
|
840
|
-
resumeSessionId: z5.string().optional(),
|
|
841
|
-
model: z5.string().optional(),
|
|
842
|
-
maxTurns: z5.number().optional(),
|
|
843
|
-
skillContext: z5.object({
|
|
844
|
-
skillPath: z5.string().describe("Path to the skill directory (e.g., '.agents/skills/software-engineer/')"),
|
|
845
|
-
subskill: z5.string().optional().describe("Specific subskill to activate")
|
|
846
|
-
}).optional().describe("Skill context to inject into the sub-agent's system prompt"),
|
|
847
|
-
agentDefinition: z5.object({
|
|
848
|
-
definitionPath: z5.string().describe("Path to the agent definition file (e.g., '.claude/agents/software-engineer.md')")
|
|
849
|
-
}).optional().describe("Agent definition file to inject into the sub-agent's system prompt"),
|
|
850
|
-
preferredBackend: z5.enum(["claude", "codex", "gemini"]).optional().describe("Preferred backend override. Takes priority over automatic selection based on agent/task type."),
|
|
851
|
-
taskType: z5.enum(["code", "document", "analysis", "mixed"]).optional().describe("Task type hint for automatic backend selection when preferredBackend is not specified.")
|
|
852
|
-
},
|
|
853
|
-
async (params) => {
|
|
1042
|
+
spawnAgentInputSchema.shape,
|
|
1043
|
+
async (params, extra) => {
|
|
854
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
|
+
};
|
|
855
1059
|
const result = await executeSpawnAgent(
|
|
856
1060
|
params,
|
|
857
1061
|
this.registry,
|
|
@@ -860,12 +1064,20 @@ var init_server = __esm({
|
|
|
860
1064
|
this.hooksEngine,
|
|
861
1065
|
this.contextMonitor,
|
|
862
1066
|
this.backendSelector,
|
|
863
|
-
this._childHttpUrl
|
|
1067
|
+
this._childHttpUrl,
|
|
1068
|
+
onProgress
|
|
864
1069
|
);
|
|
865
1070
|
const isError = result.exitCode !== 0;
|
|
866
|
-
|
|
1071
|
+
let text = isError ? `Error (exit ${result.exitCode}): ${result.stderr || result.stdout}` : `Session: ${result.sessionId}
|
|
867
1072
|
|
|
868
1073
|
${result.stdout}`;
|
|
1074
|
+
if (result.metadata) {
|
|
1075
|
+
text += `
|
|
1076
|
+
|
|
1077
|
+
<metadata>
|
|
1078
|
+
${JSON.stringify(result.metadata, null, 2)}
|
|
1079
|
+
</metadata>`;
|
|
1080
|
+
}
|
|
869
1081
|
return {
|
|
870
1082
|
content: [{ type: "text", text }],
|
|
871
1083
|
isError
|
|
@@ -881,31 +1093,71 @@ ${result.stdout}`;
|
|
|
881
1093
|
);
|
|
882
1094
|
server.tool(
|
|
883
1095
|
"spawn_agents_parallel",
|
|
884
|
-
"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.",
|
|
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.",
|
|
885
1150
|
{
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
systemPrompt: z5.string().optional().describe("System prompt / role instructions for the sub-agent (all backends)"),
|
|
891
|
-
resumeSessionId: z5.string().optional(),
|
|
892
|
-
model: z5.string().optional(),
|
|
893
|
-
maxTurns: z5.number().optional(),
|
|
894
|
-
skillContext: z5.object({
|
|
895
|
-
skillPath: z5.string().describe("Path to the skill directory"),
|
|
896
|
-
subskill: z5.string().optional().describe("Specific subskill to activate")
|
|
897
|
-
}).optional().describe("Skill context to inject into the sub-agent's system prompt"),
|
|
898
|
-
agentDefinition: z5.object({
|
|
899
|
-
definitionPath: z5.string().describe("Path to the agent definition file")
|
|
900
|
-
}).optional().describe("Agent definition file to inject into the sub-agent's system prompt"),
|
|
901
|
-
preferredBackend: z5.enum(["claude", "codex", "gemini"]).optional().describe("Preferred backend override."),
|
|
902
|
-
taskType: z5.enum(["code", "document", "analysis", "mixed"]).optional().describe("Task type hint for automatic backend selection.")
|
|
903
|
-
})).min(1).max(10).describe("Array of agent configurations to execute in parallel (1-10 agents)")
|
|
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")
|
|
904
1155
|
},
|
|
905
1156
|
async (params) => {
|
|
906
1157
|
try {
|
|
1158
|
+
const agents = params.failedResults.map((r) => r.originalInput);
|
|
907
1159
|
const result = await executeSpawnAgentsParallel(
|
|
908
|
-
|
|
1160
|
+
agents,
|
|
909
1161
|
this.registry,
|
|
910
1162
|
this.sessionManager,
|
|
911
1163
|
this.guard,
|
|
@@ -1041,14 +1293,14 @@ ${result.stdout}`;
|
|
|
1041
1293
|
});
|
|
1042
1294
|
this._httpServer = httpServer;
|
|
1043
1295
|
await this.server.connect(httpTransport);
|
|
1044
|
-
await new Promise((
|
|
1045
|
-
httpServer.listen(port, () => {
|
|
1296
|
+
await new Promise((resolve2) => {
|
|
1297
|
+
httpServer.listen(port, "127.0.0.1", () => {
|
|
1046
1298
|
logger.info(`MCP server listening on http://localhost:${port}/mcp`);
|
|
1047
|
-
|
|
1299
|
+
resolve2();
|
|
1048
1300
|
});
|
|
1049
1301
|
});
|
|
1050
|
-
await new Promise((
|
|
1051
|
-
httpServer.on("close",
|
|
1302
|
+
await new Promise((resolve2) => {
|
|
1303
|
+
httpServer.on("close", resolve2);
|
|
1052
1304
|
});
|
|
1053
1305
|
}
|
|
1054
1306
|
/**
|
|
@@ -1079,7 +1331,7 @@ ${result.stdout}`;
|
|
|
1079
1331
|
});
|
|
1080
1332
|
const server = new McpServer({
|
|
1081
1333
|
name: "agentic-relay",
|
|
1082
|
-
version: "0.
|
|
1334
|
+
version: "0.8.0"
|
|
1083
1335
|
});
|
|
1084
1336
|
this.registerTools(server);
|
|
1085
1337
|
transport.onclose = () => {
|
|
@@ -1095,15 +1347,31 @@ ${result.stdout}`;
|
|
|
1095
1347
|
if (sid) {
|
|
1096
1348
|
sessions.set(sid, { transport, server });
|
|
1097
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
|
+
}
|
|
1098
1366
|
}
|
|
1099
1367
|
});
|
|
1100
1368
|
this._childHttpServer = httpServer;
|
|
1101
|
-
await new Promise((
|
|
1369
|
+
await new Promise((resolve2) => {
|
|
1102
1370
|
httpServer.listen(0, "127.0.0.1", () => {
|
|
1103
1371
|
const addr = httpServer.address();
|
|
1104
1372
|
this._childHttpUrl = `http://127.0.0.1:${addr.port}/mcp`;
|
|
1105
1373
|
logger.info(`Child MCP server listening on ${this._childHttpUrl}`);
|
|
1106
|
-
|
|
1374
|
+
resolve2();
|
|
1107
1375
|
});
|
|
1108
1376
|
});
|
|
1109
1377
|
}
|
|
@@ -1255,10 +1523,11 @@ var AdapterRegistry = class {
|
|
|
1255
1523
|
|
|
1256
1524
|
// src/adapters/base-adapter.ts
|
|
1257
1525
|
init_logger();
|
|
1258
|
-
var BaseAdapter = class {
|
|
1526
|
+
var BaseAdapter = class _BaseAdapter {
|
|
1259
1527
|
constructor(processManager2) {
|
|
1260
1528
|
this.processManager = processManager2;
|
|
1261
1529
|
}
|
|
1530
|
+
static HEALTH_TIMEOUT_MS = 5e3;
|
|
1262
1531
|
async isInstalled() {
|
|
1263
1532
|
try {
|
|
1264
1533
|
const result = await this.processManager.execute("which", [this.command]);
|
|
@@ -1286,11 +1555,13 @@ var BaseAdapter = class {
|
|
|
1286
1555
|
};
|
|
1287
1556
|
}
|
|
1288
1557
|
async checkHealth() {
|
|
1289
|
-
const HEALTH_TIMEOUT = 5e3;
|
|
1290
1558
|
const installed = await Promise.race([
|
|
1291
1559
|
this.isInstalled(),
|
|
1292
1560
|
new Promise(
|
|
1293
|
-
(_, reject) => setTimeout(
|
|
1561
|
+
(_, reject) => setTimeout(
|
|
1562
|
+
() => reject(new Error("timeout")),
|
|
1563
|
+
_BaseAdapter.HEALTH_TIMEOUT_MS
|
|
1564
|
+
)
|
|
1294
1565
|
)
|
|
1295
1566
|
]).catch(() => false);
|
|
1296
1567
|
if (!installed) {
|
|
@@ -1304,14 +1575,29 @@ var BaseAdapter = class {
|
|
|
1304
1575
|
const version = await Promise.race([
|
|
1305
1576
|
this.getVersion(),
|
|
1306
1577
|
new Promise(
|
|
1307
|
-
(_, reject) => setTimeout(
|
|
1578
|
+
(_, reject) => setTimeout(
|
|
1579
|
+
() => reject(new Error("timeout")),
|
|
1580
|
+
_BaseAdapter.HEALTH_TIMEOUT_MS
|
|
1581
|
+
)
|
|
1308
1582
|
)
|
|
1309
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);
|
|
1310
1595
|
return {
|
|
1311
1596
|
installed: true,
|
|
1312
|
-
authenticated
|
|
1313
|
-
healthy:
|
|
1314
|
-
version
|
|
1597
|
+
authenticated,
|
|
1598
|
+
healthy: authenticated,
|
|
1599
|
+
version,
|
|
1600
|
+
...message ? { message } : {}
|
|
1315
1601
|
};
|
|
1316
1602
|
}
|
|
1317
1603
|
async getMCPConfig() {
|
|
@@ -1426,45 +1712,14 @@ var ClaudeAdapter = class extends BaseAdapter {
|
|
|
1426
1712
|
getConfigPath() {
|
|
1427
1713
|
return join(homedir(), ".claude.json");
|
|
1428
1714
|
}
|
|
1429
|
-
async
|
|
1430
|
-
const
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
)
|
|
1436
|
-
]).catch(() => false);
|
|
1437
|
-
if (!installed) {
|
|
1438
|
-
return {
|
|
1439
|
-
installed: false,
|
|
1440
|
-
authenticated: false,
|
|
1441
|
-
healthy: false,
|
|
1442
|
-
message: "claude is not installed"
|
|
1443
|
-
};
|
|
1444
|
-
}
|
|
1445
|
-
const version = await Promise.race([
|
|
1446
|
-
this.getVersion(),
|
|
1447
|
-
new Promise(
|
|
1448
|
-
(_, reject) => setTimeout(() => reject(new Error("timeout")), HEALTH_TIMEOUT)
|
|
1449
|
-
)
|
|
1450
|
-
]).catch(() => void 0);
|
|
1451
|
-
let authenticated = true;
|
|
1452
|
-
try {
|
|
1453
|
-
const result = await Promise.race([
|
|
1454
|
-
this.processManager.execute(this.command, ["auth", "status"]),
|
|
1455
|
-
new Promise(
|
|
1456
|
-
(_, reject) => setTimeout(() => reject(new Error("timeout")), HEALTH_TIMEOUT)
|
|
1457
|
-
)
|
|
1458
|
-
]);
|
|
1459
|
-
authenticated = result.exitCode === 0;
|
|
1460
|
-
} catch {
|
|
1461
|
-
authenticated = true;
|
|
1462
|
-
}
|
|
1715
|
+
async checkAuthStatus() {
|
|
1716
|
+
const result = await this.processManager.execute(this.command, [
|
|
1717
|
+
"auth",
|
|
1718
|
+
"status"
|
|
1719
|
+
]);
|
|
1720
|
+
const authenticated = result.exitCode === 0;
|
|
1463
1721
|
return {
|
|
1464
|
-
installed: true,
|
|
1465
1722
|
authenticated,
|
|
1466
|
-
healthy: authenticated,
|
|
1467
|
-
version,
|
|
1468
1723
|
...!authenticated ? { message: "claude authentication not configured" } : {}
|
|
1469
1724
|
};
|
|
1470
1725
|
}
|
|
@@ -1903,48 +2158,32 @@ var CodexAdapter = class extends BaseAdapter {
|
|
|
1903
2158
|
getConfigPath() {
|
|
1904
2159
|
return join2(homedir2(), ".codex", "config.toml");
|
|
1905
2160
|
}
|
|
1906
|
-
async
|
|
1907
|
-
const
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
)
|
|
1913
|
-
]).catch(() => false);
|
|
1914
|
-
if (!installed) {
|
|
1915
|
-
return {
|
|
1916
|
-
installed: false,
|
|
1917
|
-
authenticated: false,
|
|
1918
|
-
healthy: false,
|
|
1919
|
-
message: "codex is not installed"
|
|
1920
|
-
};
|
|
1921
|
-
}
|
|
1922
|
-
const version = await Promise.race([
|
|
1923
|
-
this.getVersion(),
|
|
1924
|
-
new Promise(
|
|
1925
|
-
(_, reject) => setTimeout(() => reject(new Error("timeout")), HEALTH_TIMEOUT)
|
|
1926
|
-
)
|
|
1927
|
-
]).catch(() => void 0);
|
|
1928
|
-
let authenticated = true;
|
|
1929
|
-
try {
|
|
1930
|
-
const result = await Promise.race([
|
|
1931
|
-
this.processManager.execute(this.command, ["login", "status"]),
|
|
1932
|
-
new Promise(
|
|
1933
|
-
(_, reject) => setTimeout(() => reject(new Error("timeout")), HEALTH_TIMEOUT)
|
|
1934
|
-
)
|
|
1935
|
-
]);
|
|
1936
|
-
authenticated = result.exitCode === 0;
|
|
1937
|
-
} catch {
|
|
1938
|
-
authenticated = true;
|
|
1939
|
-
}
|
|
2161
|
+
async checkAuthStatus() {
|
|
2162
|
+
const result = await this.processManager.execute(this.command, [
|
|
2163
|
+
"login",
|
|
2164
|
+
"status"
|
|
2165
|
+
]);
|
|
2166
|
+
const authenticated = result.exitCode === 0;
|
|
1940
2167
|
return {
|
|
1941
|
-
installed: true,
|
|
1942
2168
|
authenticated,
|
|
1943
|
-
healthy: authenticated,
|
|
1944
|
-
version,
|
|
1945
2169
|
...!authenticated ? { message: "codex authentication not configured" } : {}
|
|
1946
2170
|
};
|
|
1947
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
|
+
}
|
|
1948
2187
|
mapFlags(flags) {
|
|
1949
2188
|
const args = mapCommonToNative("codex", flags);
|
|
1950
2189
|
if (flags.outputFormat === "json") {
|
|
@@ -1997,16 +2236,7 @@ ${prompt}`;
|
|
|
1997
2236
|
);
|
|
1998
2237
|
try {
|
|
1999
2238
|
const { Codex } = await loadCodexSDK();
|
|
2000
|
-
const
|
|
2001
|
-
if (flags.mcpContext) {
|
|
2002
|
-
codexOptions.env = {
|
|
2003
|
-
...process.env,
|
|
2004
|
-
RELAY_TRACE_ID: flags.mcpContext.traceId,
|
|
2005
|
-
RELAY_PARENT_SESSION_ID: flags.mcpContext.parentSessionId,
|
|
2006
|
-
RELAY_DEPTH: String(flags.mcpContext.depth)
|
|
2007
|
-
};
|
|
2008
|
-
}
|
|
2009
|
-
const codex = new Codex(codexOptions);
|
|
2239
|
+
const codex = new Codex(this.buildCodexOptions(flags));
|
|
2010
2240
|
const thread = codex.startThread({
|
|
2011
2241
|
...flags.model ? { model: flags.model } : {},
|
|
2012
2242
|
workingDirectory: process.cwd(),
|
|
@@ -2038,16 +2268,7 @@ ${prompt}`;
|
|
|
2038
2268
|
);
|
|
2039
2269
|
try {
|
|
2040
2270
|
const { Codex } = await loadCodexSDK();
|
|
2041
|
-
const
|
|
2042
|
-
if (flags.mcpContext) {
|
|
2043
|
-
codexOptions.env = {
|
|
2044
|
-
...process.env,
|
|
2045
|
-
RELAY_TRACE_ID: flags.mcpContext.traceId,
|
|
2046
|
-
RELAY_PARENT_SESSION_ID: flags.mcpContext.parentSessionId,
|
|
2047
|
-
RELAY_DEPTH: String(flags.mcpContext.depth)
|
|
2048
|
-
};
|
|
2049
|
-
}
|
|
2050
|
-
const codex = new Codex(codexOptions);
|
|
2271
|
+
const codex = new Codex(this.buildCodexOptions(flags));
|
|
2051
2272
|
const thread = codex.startThread({
|
|
2052
2273
|
...flags.model ? { model: flags.model } : {},
|
|
2053
2274
|
workingDirectory: process.cwd(),
|
|
@@ -2061,15 +2282,15 @@ ${prompt}`;
|
|
|
2061
2282
|
threadId = event.thread_id;
|
|
2062
2283
|
} else if (event.type === "item.started") {
|
|
2063
2284
|
const item = event.item;
|
|
2064
|
-
if (item
|
|
2285
|
+
if (item.type === "agent_message" && item.text) {
|
|
2065
2286
|
yield { type: "text", text: item.text };
|
|
2066
|
-
} else if (item
|
|
2287
|
+
} else if (item.type === "command_execution") {
|
|
2067
2288
|
yield {
|
|
2068
2289
|
type: "tool_start",
|
|
2069
2290
|
tool: item.command ?? "command",
|
|
2070
2291
|
id: item.id ?? ""
|
|
2071
2292
|
};
|
|
2072
|
-
} else if (item
|
|
2293
|
+
} else if (item.type === "file_change") {
|
|
2073
2294
|
yield {
|
|
2074
2295
|
type: "tool_start",
|
|
2075
2296
|
tool: "file_change",
|
|
@@ -2078,17 +2299,17 @@ ${prompt}`;
|
|
|
2078
2299
|
}
|
|
2079
2300
|
} else if (event.type === "item.completed") {
|
|
2080
2301
|
const item = event.item;
|
|
2081
|
-
if (item
|
|
2302
|
+
if (item.type === "agent_message" && item.text) {
|
|
2082
2303
|
completedMessages.push(item.text);
|
|
2083
2304
|
yield { type: "text", text: item.text };
|
|
2084
|
-
} else if (item
|
|
2305
|
+
} else if (item.type === "command_execution") {
|
|
2085
2306
|
yield {
|
|
2086
2307
|
type: "tool_end",
|
|
2087
2308
|
tool: item.command ?? "command",
|
|
2088
2309
|
id: item.id ?? "",
|
|
2089
2310
|
result: item.aggregated_output
|
|
2090
2311
|
};
|
|
2091
|
-
} else if (item
|
|
2312
|
+
} else if (item.type === "file_change") {
|
|
2092
2313
|
yield {
|
|
2093
2314
|
type: "tool_end",
|
|
2094
2315
|
tool: "file_change",
|
|
@@ -2111,7 +2332,7 @@ ${prompt}`;
|
|
|
2111
2332
|
nativeSessionId: threadId ?? thread.id ?? void 0
|
|
2112
2333
|
};
|
|
2113
2334
|
} else if (event.type === "turn.failed") {
|
|
2114
|
-
const errorMessage = event.error
|
|
2335
|
+
const errorMessage = event.error.message ?? "Turn failed";
|
|
2115
2336
|
yield {
|
|
2116
2337
|
type: "done",
|
|
2117
2338
|
result: { exitCode: 1, stdout: "", stderr: errorMessage },
|
|
@@ -2248,37 +2469,18 @@ var GeminiAdapter = class extends BaseAdapter {
|
|
|
2248
2469
|
getConfigPath() {
|
|
2249
2470
|
return join3(homedir3(), ".gemini", "settings.json");
|
|
2250
2471
|
}
|
|
2251
|
-
async
|
|
2252
|
-
const
|
|
2253
|
-
const
|
|
2254
|
-
|
|
2255
|
-
|
|
2256
|
-
(_, reject) => setTimeout(() => reject(new Error("timeout")), HEALTH_TIMEOUT)
|
|
2257
|
-
)
|
|
2258
|
-
]).catch(() => false);
|
|
2259
|
-
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) {
|
|
2260
2477
|
return {
|
|
2261
|
-
|
|
2262
|
-
authenticated:
|
|
2263
|
-
|
|
2264
|
-
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"
|
|
2265
2481
|
};
|
|
2266
2482
|
}
|
|
2267
|
-
|
|
2268
|
-
this.getVersion(),
|
|
2269
|
-
new Promise(
|
|
2270
|
-
(_, reject) => setTimeout(() => reject(new Error("timeout")), HEALTH_TIMEOUT)
|
|
2271
|
-
)
|
|
2272
|
-
]).catch(() => void 0);
|
|
2273
|
-
const hasApiKey = !!process.env["GEMINI_API_KEY"];
|
|
2274
|
-
const hasGoogleAdc = !!process.env["GOOGLE_APPLICATION_CREDENTIALS"] || !!process.env["CLOUDSDK_CONFIG"];
|
|
2275
|
-
const authenticated = hasApiKey || hasGoogleAdc || true;
|
|
2276
|
-
return {
|
|
2277
|
-
installed: true,
|
|
2278
|
-
authenticated,
|
|
2279
|
-
healthy: true,
|
|
2280
|
-
version
|
|
2281
|
-
};
|
|
2483
|
+
return { authenticated };
|
|
2282
2484
|
}
|
|
2283
2485
|
mapFlags(flags) {
|
|
2284
2486
|
const args = mapCommonToNative("gemini", flags);
|
|
@@ -3167,6 +3369,7 @@ var HooksEngine = class _HooksEngine {
|
|
|
3167
3369
|
};
|
|
3168
3370
|
|
|
3169
3371
|
// src/core/context-monitor.ts
|
|
3372
|
+
init_logger();
|
|
3170
3373
|
var DEFAULT_BACKEND_CONTEXT = {
|
|
3171
3374
|
claude: { contextWindow: 2e5, compactThreshold: 19e4 },
|
|
3172
3375
|
codex: { contextWindow: 272e3, compactThreshold: 258400 },
|
|
@@ -3285,7 +3488,9 @@ var ContextMonitor = class {
|
|
|
3285
3488
|
remainingBeforeCompact
|
|
3286
3489
|
}
|
|
3287
3490
|
};
|
|
3288
|
-
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
|
+
);
|
|
3289
3494
|
}
|
|
3290
3495
|
}
|
|
3291
3496
|
};
|
|
@@ -4061,7 +4266,7 @@ function createVersionCommand(registry2) {
|
|
|
4061
4266
|
description: "Show relay and backend versions"
|
|
4062
4267
|
},
|
|
4063
4268
|
async run() {
|
|
4064
|
-
const relayVersion = "0.
|
|
4269
|
+
const relayVersion = "0.8.0";
|
|
4065
4270
|
console.log(`agentic-relay v${relayVersion}`);
|
|
4066
4271
|
console.log("");
|
|
4067
4272
|
console.log("Backends:");
|
|
@@ -4088,9 +4293,9 @@ import { defineCommand as defineCommand8 } from "citty";
|
|
|
4088
4293
|
import { access, constants, readdir as readdir2 } from "fs/promises";
|
|
4089
4294
|
import { join as join7 } from "path";
|
|
4090
4295
|
import { homedir as homedir5 } from "os";
|
|
4091
|
-
import { execFile } from "child_process";
|
|
4092
|
-
import { promisify } from "util";
|
|
4093
|
-
var
|
|
4296
|
+
import { execFile as execFile2 } from "child_process";
|
|
4297
|
+
import { promisify as promisify2 } from "util";
|
|
4298
|
+
var execFileAsync2 = promisify2(execFile2);
|
|
4094
4299
|
async function checkNodeVersion() {
|
|
4095
4300
|
const version = process.version;
|
|
4096
4301
|
const major = Number(version.slice(1).split(".")[0]);
|
|
@@ -4190,7 +4395,7 @@ async function checkMCPServerCommands(configManager2) {
|
|
|
4190
4395
|
for (const [name, server] of Object.entries(mcpServers)) {
|
|
4191
4396
|
const command = server.command;
|
|
4192
4397
|
try {
|
|
4193
|
-
await
|
|
4398
|
+
await execFileAsync2("which", [command]);
|
|
4194
4399
|
results.push({
|
|
4195
4400
|
label: `MCP server: ${name}`,
|
|
4196
4401
|
ok: true,
|
|
@@ -4386,6 +4591,7 @@ function createInitCommand() {
|
|
|
4386
4591
|
}
|
|
4387
4592
|
|
|
4388
4593
|
// src/bin/relay.ts
|
|
4594
|
+
init_logger();
|
|
4389
4595
|
var processManager = new ProcessManager();
|
|
4390
4596
|
var registry = new AdapterRegistry();
|
|
4391
4597
|
registry.registerLazy("claude", () => new ClaudeAdapter(processManager));
|
|
@@ -4406,12 +4612,11 @@ void configManager.getConfig().then((config) => {
|
|
|
4406
4612
|
if (config.contextMonitor) {
|
|
4407
4613
|
contextMonitor = new ContextMonitor(hooksEngine, config.contextMonitor);
|
|
4408
4614
|
}
|
|
4409
|
-
}).catch(() =>
|
|
4410
|
-
});
|
|
4615
|
+
}).catch((e) => logger.debug("Config load failed:", e));
|
|
4411
4616
|
var main = defineCommand10({
|
|
4412
4617
|
meta: {
|
|
4413
4618
|
name: "relay",
|
|
4414
|
-
version: "0.
|
|
4619
|
+
version: "0.8.0",
|
|
4415
4620
|
description: "Unified CLI proxy for Claude Code, Codex CLI, and Gemini CLI"
|
|
4416
4621
|
},
|
|
4417
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": {
|