@rk0429/agentic-relay 0.7.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +5 -1
  2. package/dist/relay.mjs +565 -264
  3. 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 (507 tests across 28 files)
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 readAgentDefinition(definitionPath) {
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
- if (!existsSync(definitionPath)) {
196
- logger.warn(`Agent definition file not found at ${definitionPath}`);
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(definitionPath, "utf-8");
212
+ const content = readFileSync(safeDefinitionPath, "utf-8");
200
213
  if (content.trim().length === 0) {
201
- logger.warn(`Agent definition file is empty at ${definitionPath}`);
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 skillMdPath = join6(skillContext.skillPath, "SKILL.md");
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
- skillContext.skillPath,
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);
@@ -293,14 +307,22 @@ function buildChildMcpServers(parentMcpServers, childHttpUrl) {
293
307
  }
294
308
  return result;
295
309
  }
310
+ function inferFailureReason(stderr, stdout) {
311
+ const combined = `${stderr} ${stdout}`.toLowerCase();
312
+ if (combined.includes("timed out") || combined.includes("timeout")) return "timeout";
313
+ if (combined.includes("max turns") || combined.includes("max_turns") || combined.includes("turn limit")) return "max_turns_exhausted";
314
+ return "adapter_error";
315
+ }
296
316
  function buildContextFromEnv() {
297
317
  const traceId = process.env["RELAY_TRACE_ID"] ?? `trace-${nanoid2()}`;
298
318
  const parentSessionId = process.env["RELAY_PARENT_SESSION_ID"] ?? null;
299
319
  const depth = Number(process.env["RELAY_DEPTH"] ?? "0");
300
320
  return { traceId, parentSessionId, depth };
301
321
  }
302
- async function executeSpawnAgent(input, registry2, sessionManager2, guard, hooksEngine2, contextMonitor2, backendSelector, childHttpUrl) {
322
+ async function executeSpawnAgent(input, registry2, sessionManager2, guard, hooksEngine2, contextMonitor2, backendSelector, childHttpUrl, onProgress) {
323
+ onProgress?.({ stage: "initializing", percent: 0 });
303
324
  let effectiveBackend = input.backend;
325
+ let selectionReason = "direct";
304
326
  if (backendSelector) {
305
327
  const availableBackends = registry2.listIds();
306
328
  const selectionContext = {
@@ -309,7 +331,9 @@ async function executeSpawnAgent(input, registry2, sessionManager2, guard, hooks
309
331
  agentType: input.agent,
310
332
  taskType: input.taskType
311
333
  };
312
- effectiveBackend = backendSelector.selectBackend(selectionContext);
334
+ const selectionResult = backendSelector.selectBackendWithReason(selectionContext);
335
+ effectiveBackend = selectionResult.backend;
336
+ selectionReason = selectionResult.reason;
313
337
  }
314
338
  const envContext = buildContextFromEnv();
315
339
  const promptHash = RecursionGuard.hashPrompt(input.prompt);
@@ -326,7 +350,8 @@ async function executeSpawnAgent(input, registry2, sessionManager2, guard, hooks
326
350
  sessionId: "",
327
351
  exitCode: 1,
328
352
  stdout: "",
329
- stderr: `Spawn blocked: ${guardResult.reason}`
353
+ stderr: `Spawn blocked: ${guardResult.reason}`,
354
+ failureReason: "recursion_blocked"
330
355
  };
331
356
  }
332
357
  const adapter = registry2.get(effectiveBackend);
@@ -336,7 +361,8 @@ async function executeSpawnAgent(input, registry2, sessionManager2, guard, hooks
336
361
  sessionId: "",
337
362
  exitCode: 1,
338
363
  stdout: "",
339
- stderr: `Backend "${effectiveBackend}" is not available. Use list_available_backends to see available options.`
364
+ stderr: `Backend "${effectiveBackend}" is not available. Use list_available_backends to see available options.`,
365
+ failureReason: "backend_unavailable"
340
366
  };
341
367
  }
342
368
  const spawnStartedAt = (/* @__PURE__ */ new Date()).toISOString();
@@ -418,41 +444,95 @@ ${defText}
418
444
  ${wrapped}` : wrapped;
419
445
  }
420
446
  }
421
- try {
422
- let result;
423
- if (input.resumeSessionId) {
424
- if (!adapter.continueSession) {
447
+ let effectivePrompt = input.prompt;
448
+ if (input.taskInstructionPath) {
449
+ try {
450
+ const projectRoot = process.cwd();
451
+ const safePath = validatePathWithinProject(input.taskInstructionPath, projectRoot);
452
+ if (!existsSync(safePath)) {
425
453
  return {
426
- sessionId: session.relaySessionId,
454
+ sessionId: "",
427
455
  exitCode: 1,
428
456
  stdout: "",
429
- stderr: `Backend "${effectiveBackend}" does not support session continuation (continueSession).`
457
+ stderr: `Task instruction file not found: ${input.taskInstructionPath}`,
458
+ failureReason: "instruction_file_error"
430
459
  };
431
460
  }
432
- result = await adapter.continueSession(input.resumeSessionId, input.prompt);
433
- } else {
434
- let mcpServers;
435
- if (childHttpUrl) {
436
- const parentMcpServers = readProjectMcpJson();
437
- if (Object.keys(parentMcpServers).length > 0) {
438
- mcpServers = buildChildMcpServers(parentMcpServers, childHttpUrl);
461
+ const instructionContent = readFileSync(safePath, "utf-8");
462
+ effectivePrompt = `${instructionContent}
463
+
464
+ ${input.prompt}`;
465
+ } catch (error) {
466
+ const message = error instanceof Error ? error.message : String(error);
467
+ return {
468
+ sessionId: "",
469
+ exitCode: 1,
470
+ stdout: "",
471
+ stderr: `Failed to read task instruction file: ${message}`,
472
+ failureReason: "instruction_file_error"
473
+ };
474
+ }
475
+ }
476
+ onProgress?.({ stage: "spawning", percent: 10 });
477
+ try {
478
+ let result;
479
+ const executePromise = (async () => {
480
+ if (input.resumeSessionId) {
481
+ if (!adapter.continueSession) {
482
+ return {
483
+ exitCode: 1,
484
+ stdout: "",
485
+ stderr: `Backend "${effectiveBackend}" does not support session continuation (continueSession).`,
486
+ _noSession: true,
487
+ _failureReason: "session_continuation_unsupported"
488
+ };
489
+ }
490
+ return adapter.continueSession(input.resumeSessionId, effectivePrompt);
491
+ } else {
492
+ let mcpServers;
493
+ if (childHttpUrl) {
494
+ const parentMcpServers = readProjectMcpJson();
495
+ if (Object.keys(parentMcpServers).length > 0) {
496
+ mcpServers = buildChildMcpServers(parentMcpServers, childHttpUrl);
497
+ }
439
498
  }
499
+ return adapter.execute({
500
+ prompt: effectivePrompt,
501
+ agent: input.agent,
502
+ systemPrompt: enhancedSystemPrompt,
503
+ model: input.model,
504
+ maxTurns: input.maxTurns,
505
+ mcpContext: {
506
+ parentSessionId: session.relaySessionId,
507
+ depth: envContext.depth + 1,
508
+ maxDepth: guard.getConfig().maxDepth,
509
+ traceId: envContext.traceId
510
+ },
511
+ ...mcpServers ? { mcpServers } : {}
512
+ });
440
513
  }
441
- result = await adapter.execute({
442
- prompt: input.prompt,
443
- agent: input.agent,
444
- systemPrompt: enhancedSystemPrompt,
445
- model: input.model,
446
- maxTurns: input.maxTurns,
447
- mcpContext: {
448
- parentSessionId: session.relaySessionId,
449
- depth: envContext.depth + 1,
450
- maxDepth: guard.getConfig().maxDepth,
451
- traceId: envContext.traceId
452
- },
453
- ...mcpServers ? { mcpServers } : {}
454
- });
514
+ })();
515
+ if (input.timeoutMs) {
516
+ const timeoutPromise = new Promise(
517
+ (_, reject) => setTimeout(
518
+ () => reject(new Error(`Agent execution timed out after ${input.timeoutMs}ms`)),
519
+ input.timeoutMs
520
+ )
521
+ );
522
+ result = await Promise.race([executePromise, timeoutPromise]);
523
+ } else {
524
+ result = await executePromise;
525
+ }
526
+ if (result && "_noSession" in result) {
527
+ return {
528
+ sessionId: session.relaySessionId,
529
+ exitCode: result.exitCode,
530
+ stdout: result.stdout,
531
+ stderr: result.stderr,
532
+ ..."_failureReason" in result ? { failureReason: result._failureReason } : {}
533
+ };
455
534
  }
535
+ onProgress?.({ stage: "executing", percent: 50 });
456
536
  if (contextMonitor2) {
457
537
  const estimatedTokens = Math.ceil(
458
538
  (result.stdout.length + result.stderr.length) / 4
@@ -465,7 +545,19 @@ ${wrapped}` : wrapped;
465
545
  }
466
546
  guard.recordSpawn(context);
467
547
  const status = result.exitCode === 0 ? "completed" : "error";
548
+ const failureReason = result.exitCode !== 0 ? inferFailureReason(result.stderr, result.stdout) : void 0;
468
549
  await sessionManager2.update(session.relaySessionId, { status });
550
+ const completedAt = (/* @__PURE__ */ new Date()).toISOString();
551
+ const metadata = {
552
+ durationMs: new Date(completedAt).getTime() - new Date(spawnStartedAt).getTime(),
553
+ selectedBackend: effectiveBackend,
554
+ ...input.preferredBackend ? { requestedBackend: input.preferredBackend } : {},
555
+ selectionReason,
556
+ startedAt: spawnStartedAt,
557
+ completedAt,
558
+ ...result.tokenUsage ? { tokenUsage: result.tokenUsage } : {}
559
+ };
560
+ onProgress?.({ stage: "completed", percent: 100 });
469
561
  if (hooksEngine2) {
470
562
  try {
471
563
  const postSpawnData = {
@@ -502,16 +594,20 @@ ${wrapped}` : wrapped;
502
594
  exitCode: result.exitCode,
503
595
  stdout: result.stdout,
504
596
  stderr: result.stderr,
505
- nativeSessionId: result.nativeSessionId
597
+ nativeSessionId: result.nativeSessionId,
598
+ metadata,
599
+ ...failureReason ? { failureReason } : {}
506
600
  };
507
601
  } catch (error) {
508
602
  await sessionManager2.update(session.relaySessionId, { status: "error" });
509
603
  const message = error instanceof Error ? error.message : String(error);
604
+ const catchFailureReason = message.toLowerCase().includes("timed out") || message.toLowerCase().includes("timeout") ? "timeout" : "unknown";
510
605
  return {
511
606
  sessionId: session.relaySessionId,
512
607
  exitCode: 1,
513
608
  stdout: "",
514
- stderr: message
609
+ stderr: message,
610
+ failureReason: catchFailureReason
515
611
  };
516
612
  }
517
613
  }
@@ -537,25 +633,123 @@ var init_spawn_agent = __esm({
537
633
  definitionPath: z2.string().describe("Path to the agent definition file (e.g., '.claude/agents/software-engineer.md')")
538
634
  }).optional().describe("Agent definition file to inject into the sub-agent's system prompt"),
539
635
  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.")
636
+ taskType: z2.enum(["code", "document", "analysis", "mixed"]).optional().describe("Task type hint for automatic backend selection when preferredBackend is not specified."),
637
+ timeoutMs: z2.number().optional().describe("Timeout in milliseconds for agent execution. Default: no timeout."),
638
+ taskInstructionPath: z2.string().optional().describe(
639
+ "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."
640
+ ),
641
+ label: z2.string().optional().describe("Human-readable label for identifying this agent in parallel results")
541
642
  });
542
643
  }
543
644
  });
544
645
 
646
+ // src/mcp-server/tools/conflict-detector.ts
647
+ import { execFile } from "child_process";
648
+ import { promisify } from "util";
649
+ async function takeSnapshot(cwd) {
650
+ try {
651
+ const [diffResult, lsResult] = await Promise.all([
652
+ execFileAsync("git", ["diff", "--name-only", "HEAD"], { cwd }).catch(() => ({ stdout: "" })),
653
+ execFileAsync("git", ["ls-files", "-m"], { cwd }).catch(() => ({ stdout: "" }))
654
+ ]);
655
+ const files = /* @__PURE__ */ new Set();
656
+ for (const line of diffResult.stdout.split("\n")) {
657
+ const trimmed = line.trim();
658
+ if (trimmed) files.add(trimmed);
659
+ }
660
+ for (const line of lsResult.stdout.split("\n")) {
661
+ const trimmed = line.trim();
662
+ if (trimmed) files.add(trimmed);
663
+ }
664
+ return files;
665
+ } catch (error) {
666
+ logger.warn(
667
+ `Failed to take git snapshot: ${error instanceof Error ? error.message : String(error)}`
668
+ );
669
+ return /* @__PURE__ */ new Set();
670
+ }
671
+ }
672
+ function extractMentionedPaths(stdout) {
673
+ const paths = /* @__PURE__ */ new Set();
674
+ const patterns = [
675
+ /(?:^|\s|["'`])(\.\/.+?\.[a-zA-Z]{1,5})(?:\s|["'`]|$)/gm,
676
+ /(?:^|\s|["'`])(src\/.+?\.[a-zA-Z]{1,5})(?:\s|["'`]|$)/gm,
677
+ /(?:^|\s|["'`])([a-zA-Z][a-zA-Z0-9_-]*(?:\/[a-zA-Z0-9_.-]+){1,10}\.[a-zA-Z]{1,5})(?:\s|["'`]|$)/gm
678
+ ];
679
+ for (const pattern of patterns) {
680
+ let match;
681
+ while ((match = pattern.exec(stdout)) !== null) {
682
+ let path = match[1];
683
+ if (path.startsWith("./")) {
684
+ path = path.slice(2);
685
+ }
686
+ paths.add(path);
687
+ }
688
+ }
689
+ return paths;
690
+ }
691
+ async function detectConflicts(before, after, agentResults) {
692
+ const newlyModified = /* @__PURE__ */ new Set();
693
+ for (const file of after) {
694
+ if (!before.has(file)) {
695
+ newlyModified.add(file);
696
+ }
697
+ }
698
+ if (newlyModified.size === 0) {
699
+ return { conflicts: [], hasConflicts: false };
700
+ }
701
+ const agentPaths = /* @__PURE__ */ new Map();
702
+ for (const agent of agentResults) {
703
+ agentPaths.set(agent.index, extractMentionedPaths(agent.stdout));
704
+ }
705
+ const fileToAgents = /* @__PURE__ */ new Map();
706
+ for (const file of newlyModified) {
707
+ const matchingAgents = [];
708
+ for (const [index, paths] of agentPaths) {
709
+ for (const mentionedPath of paths) {
710
+ if (file === mentionedPath || file.endsWith(mentionedPath) || mentionedPath.endsWith(file)) {
711
+ matchingAgents.push(index);
712
+ break;
713
+ }
714
+ }
715
+ }
716
+ if (matchingAgents.length > 1) {
717
+ fileToAgents.set(file, matchingAgents);
718
+ }
719
+ }
720
+ const conflicts = Array.from(fileToAgents.entries()).map(
721
+ ([path, agents]) => ({ path, agents })
722
+ );
723
+ return {
724
+ conflicts,
725
+ hasConflicts: conflicts.length > 0
726
+ };
727
+ }
728
+ var execFileAsync;
729
+ var init_conflict_detector = __esm({
730
+ "src/mcp-server/tools/conflict-detector.ts"() {
731
+ "use strict";
732
+ init_logger();
733
+ execFileAsync = promisify(execFile);
734
+ }
735
+ });
736
+
545
737
  // src/mcp-server/tools/spawn-agents-parallel.ts
546
- async function executeSpawnAgentsParallel(agents, registry2, sessionManager2, guard, hooksEngine2, contextMonitor2, backendSelector, childHttpUrl) {
738
+ async function executeSpawnAgentsParallel(agents, registry2, sessionManager2, guard, hooksEngine2, contextMonitor2, backendSelector, childHttpUrl, onProgress) {
547
739
  const envContext = buildContextFromEnv();
548
740
  if (envContext.depth >= guard.getConfig().maxDepth) {
549
741
  const reason = `Max depth exceeded: ${envContext.depth} >= ${guard.getConfig().maxDepth}`;
550
742
  logger.warn(`Batch spawn blocked by RecursionGuard: ${reason}`);
551
743
  return {
552
- results: agents.map((_, index) => ({
744
+ results: agents.map((agent, index) => ({
553
745
  index,
554
746
  sessionId: "",
555
747
  exitCode: 1,
556
748
  stdout: "",
557
749
  stderr: `Batch spawn blocked: ${reason}`,
558
- error: reason
750
+ error: reason,
751
+ failureReason: "recursion_blocked",
752
+ ...agent.label ? { label: agent.label } : {}
559
753
  })),
560
754
  totalCount: agents.length,
561
755
  successCount: 0,
@@ -568,19 +762,25 @@ async function executeSpawnAgentsParallel(agents, registry2, sessionManager2, gu
568
762
  const reason = `Batch would exceed max calls per session: ${currentCount} + ${agents.length} > ${maxCalls}`;
569
763
  logger.warn(`Batch spawn blocked by RecursionGuard: ${reason}`);
570
764
  return {
571
- results: agents.map((_, index) => ({
765
+ results: agents.map((agent, index) => ({
572
766
  index,
573
767
  sessionId: "",
574
768
  exitCode: 1,
575
769
  stdout: "",
576
770
  stderr: `Batch spawn blocked: ${reason}`,
577
- error: reason
771
+ error: reason,
772
+ failureReason: "recursion_blocked",
773
+ ...agent.label ? { label: agent.label } : {}
578
774
  })),
579
775
  totalCount: agents.length,
580
776
  successCount: 0,
581
777
  failureCount: agents.length
582
778
  };
583
779
  }
780
+ const cwd = process.cwd();
781
+ const beforeSnapshot = await takeSnapshot(cwd);
782
+ onProgress?.({ stage: "spawning", percent: 5 });
783
+ let completedCount = 0;
584
784
  const settled = await Promise.allSettled(
585
785
  agents.map(
586
786
  (agent) => executeSpawnAgent(
@@ -592,20 +792,34 @@ async function executeSpawnAgentsParallel(agents, registry2, sessionManager2, gu
592
792
  contextMonitor2,
593
793
  backendSelector,
594
794
  childHttpUrl
595
- )
795
+ ).then((result) => {
796
+ completedCount++;
797
+ onProgress?.({
798
+ stage: `completed ${completedCount}/${agents.length}`,
799
+ percent: Math.round(completedCount / agents.length * 90) + 5
800
+ });
801
+ return result;
802
+ })
596
803
  )
597
804
  );
598
805
  const results = settled.map((outcome, index) => {
599
806
  if (outcome.status === "fulfilled") {
600
807
  const r = outcome.value;
601
- return {
808
+ const base = {
602
809
  index,
603
810
  sessionId: r.sessionId,
604
811
  exitCode: r.exitCode,
605
812
  stdout: r.stdout,
606
813
  stderr: r.stderr,
607
- ...r.nativeSessionId ? { nativeSessionId: r.nativeSessionId } : {}
814
+ ...r.nativeSessionId ? { nativeSessionId: r.nativeSessionId } : {},
815
+ ...r.metadata ? { metadata: r.metadata } : {},
816
+ ...r.failureReason ? { failureReason: r.failureReason } : {},
817
+ ...agents[index]?.label ? { label: agents[index].label } : {}
608
818
  };
819
+ if (r.exitCode !== 0) {
820
+ base.originalInput = agents[index];
821
+ }
822
+ return base;
609
823
  }
610
824
  const errorMessage = outcome.reason instanceof Error ? outcome.reason.message : String(outcome.reason);
611
825
  return {
@@ -614,21 +828,33 @@ async function executeSpawnAgentsParallel(agents, registry2, sessionManager2, gu
614
828
  exitCode: 1,
615
829
  stdout: "",
616
830
  stderr: errorMessage,
617
- error: errorMessage
831
+ error: errorMessage,
832
+ originalInput: agents[index],
833
+ failureReason: "unknown",
834
+ ...agents[index]?.label ? { label: agents[index].label } : {}
618
835
  };
619
836
  });
620
837
  const successCount = results.filter((r) => r.exitCode === 0).length;
838
+ const afterSnapshot = await takeSnapshot(cwd);
839
+ const agentResultsForConflict = results.map((r) => ({
840
+ index: r.index,
841
+ stdout: r.stdout
842
+ }));
843
+ const conflictResult = await detectConflicts(beforeSnapshot, afterSnapshot, agentResultsForConflict);
844
+ onProgress?.({ stage: "completed", percent: 100 });
621
845
  return {
622
846
  results,
623
847
  totalCount: agents.length,
624
848
  successCount,
625
- failureCount: agents.length - successCount
849
+ failureCount: agents.length - successCount,
850
+ ...conflictResult.hasConflicts ? { conflicts: conflictResult.conflicts, hasConflicts: true } : {}
626
851
  };
627
852
  }
628
853
  var init_spawn_agents_parallel = __esm({
629
854
  "src/mcp-server/tools/spawn-agents-parallel.ts"() {
630
855
  "use strict";
631
856
  init_spawn_agent();
857
+ init_conflict_detector();
632
858
  init_logger();
633
859
  }
634
860
  });
@@ -745,37 +971,104 @@ var init_backend_selector = __esm({
745
971
  this.agentToBackendMap = config?.agentToBackendMap ?? DEFAULT_AGENT_TO_BACKEND_MAP;
746
972
  }
747
973
  selectBackend(context) {
974
+ return this.selectBackendWithReason(context).backend;
975
+ }
976
+ selectBackendWithReason(context) {
748
977
  const { availableBackends, preferredBackend, agentType, taskType } = context;
749
978
  if (availableBackends.length === 0) {
750
979
  throw new Error("No backends available");
751
980
  }
752
981
  if (preferredBackend && availableBackends.includes(preferredBackend)) {
753
- return preferredBackend;
982
+ return { backend: preferredBackend, reason: "preferredBackend" };
754
983
  }
755
984
  if (agentType) {
756
985
  const mapped = this.agentToBackendMap[agentType];
757
986
  if (mapped && availableBackends.includes(mapped)) {
758
- return mapped;
987
+ return { backend: mapped, reason: `agentType:${agentType}\u2192${mapped}` };
759
988
  }
760
989
  if (!mapped && availableBackends.includes("claude")) {
761
- return "claude";
990
+ return { backend: "claude", reason: `agentType:${agentType}\u2192claude(unmapped)` };
762
991
  }
763
992
  }
764
993
  if (taskType) {
765
994
  const mapped = TASK_TYPE_TO_BACKEND_MAP[taskType];
766
995
  if (mapped && availableBackends.includes(mapped)) {
767
- return mapped;
996
+ return { backend: mapped, reason: `taskType:${taskType}\u2192${mapped}` };
768
997
  }
769
998
  }
770
999
  if (availableBackends.includes(this.defaultBackend)) {
771
- return this.defaultBackend;
1000
+ return { backend: this.defaultBackend, reason: "default" };
772
1001
  }
773
- return availableBackends[0];
1002
+ return { backend: availableBackends[0], reason: "fallback" };
774
1003
  }
775
1004
  };
776
1005
  }
777
1006
  });
778
1007
 
1008
+ // src/mcp-server/response-formatter.ts
1009
+ function formatSpawnAgentResponse(result) {
1010
+ const isError = result.exitCode !== 0;
1011
+ let text;
1012
+ if (isError) {
1013
+ const reasonPart = result.failureReason ? `, reason: ${result.failureReason}` : "";
1014
+ text = `FAILED (exit ${result.exitCode}${reasonPart})`;
1015
+ if (result.stderr) {
1016
+ text += `
1017
+ ${result.stderr}`;
1018
+ }
1019
+ } else {
1020
+ text = `Session: ${result.sessionId}
1021
+
1022
+ ${result.stdout}`;
1023
+ }
1024
+ if (result.metadata) {
1025
+ text += `
1026
+
1027
+ <metadata>
1028
+ ${JSON.stringify(result.metadata, null, 2)}
1029
+ </metadata>`;
1030
+ }
1031
+ return { text, isError };
1032
+ }
1033
+ function formatParallelResponse(result) {
1034
+ const isError = result.failureCount === result.totalCount;
1035
+ const parts = [];
1036
+ if (result.hasConflicts && result.conflicts) {
1037
+ parts.push(`\u26A0 FILE CONFLICTS DETECTED: Multiple agents modified the same files.`);
1038
+ parts.push(`Conflicting files: ${result.conflicts.map((c) => c.path).join(", ")}
1039
+ `);
1040
+ }
1041
+ const durations = result.results.map((r) => r.metadata?.durationMs).filter((d) => d !== void 0);
1042
+ const avgDuration = durations.length > 0 ? (durations.reduce((a, b) => a + b, 0) / durations.length / 1e3).toFixed(1) : "?";
1043
+ parts.push(
1044
+ `${result.totalCount} agents: ${result.successCount} succeeded, ${result.failureCount} failed (avg ${avgDuration}s)
1045
+ `
1046
+ );
1047
+ for (const r of result.results) {
1048
+ const labelPart = r.label ? ` [${r.label}]` : "";
1049
+ const backend = r.metadata?.selectedBackend ?? "?";
1050
+ const duration = r.metadata?.durationMs !== void 0 ? `${(r.metadata.durationMs / 1e3).toFixed(1)}s` : "?s";
1051
+ if (r.exitCode === 0) {
1052
+ parts.push(`--- Agent ${r.index}${labelPart} (${backend}, ${duration}) SUCCESS ---`);
1053
+ parts.push(r.stdout || "(no output)");
1054
+ } else {
1055
+ const reasonPart = r.failureReason ? `, reason: ${r.failureReason}` : "";
1056
+ parts.push(`--- Agent ${r.index}${labelPart} FAILED (${backend}, ${duration}${reasonPart}) ---`);
1057
+ parts.push(r.stderr || r.error || "(no output)");
1058
+ }
1059
+ parts.push("");
1060
+ }
1061
+ parts.push(`<metadata>
1062
+ ${JSON.stringify(result, null, 2)}
1063
+ </metadata>`);
1064
+ return { text: parts.join("\n"), isError };
1065
+ }
1066
+ var init_response_formatter = __esm({
1067
+ "src/mcp-server/response-formatter.ts"() {
1068
+ "use strict";
1069
+ }
1070
+ });
1071
+
779
1072
  // src/mcp-server/server.ts
780
1073
  var server_exports = {};
781
1074
  __export(server_exports, {
@@ -787,7 +1080,7 @@ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/
787
1080
  import { createServer } from "http";
788
1081
  import { randomUUID } from "crypto";
789
1082
  import { z as z5 } from "zod";
790
- var RelayMCPServer;
1083
+ var spawnAgentsParallelInputShape, MAX_CHILD_HTTP_SESSIONS, RelayMCPServer;
791
1084
  var init_server = __esm({
792
1085
  "src/mcp-server/server.ts"() {
793
1086
  "use strict";
@@ -799,6 +1092,13 @@ var init_server = __esm({
799
1092
  init_list_available_backends();
800
1093
  init_backend_selector();
801
1094
  init_logger();
1095
+ init_response_formatter();
1096
+ spawnAgentsParallelInputShape = {
1097
+ agents: z5.array(spawnAgentInputSchema).min(1).max(10).describe(
1098
+ "Array of agent configurations to execute in parallel (1-10 agents)"
1099
+ )
1100
+ };
1101
+ MAX_CHILD_HTTP_SESSIONS = 100;
802
1102
  RelayMCPServer = class {
803
1103
  constructor(registry2, sessionManager2, guardConfig, hooksEngine2, contextMonitor2) {
804
1104
  this.registry = registry2;
@@ -809,7 +1109,7 @@ var init_server = __esm({
809
1109
  this.backendSelector = new BackendSelector();
810
1110
  this.server = new McpServer({
811
1111
  name: "agentic-relay",
812
- version: "0.7.0"
1112
+ version: "0.9.0"
813
1113
  });
814
1114
  this.registerTools(this.server);
815
1115
  }
@@ -830,28 +1130,23 @@ var init_server = __esm({
830
1130
  server.tool(
831
1131
  "spawn_agent",
832
1132
  "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
- backend: z5.enum(["claude", "codex", "gemini"]),
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) => {
1133
+ spawnAgentInputSchema.shape,
1134
+ async (params, extra) => {
854
1135
  try {
1136
+ const onProgress = (progress) => {
1137
+ const progressToken = params._meta ? params._meta.progressToken : void 0;
1138
+ if (progressToken !== void 0) {
1139
+ void extra.sendNotification({
1140
+ method: "notifications/progress",
1141
+ params: {
1142
+ progressToken,
1143
+ progress: progress.percent ?? 0,
1144
+ total: 100,
1145
+ message: progress.stage
1146
+ }
1147
+ });
1148
+ }
1149
+ };
855
1150
  const result = await executeSpawnAgent(
856
1151
  params,
857
1152
  this.registry,
@@ -860,12 +1155,10 @@ var init_server = __esm({
860
1155
  this.hooksEngine,
861
1156
  this.contextMonitor,
862
1157
  this.backendSelector,
863
- this._childHttpUrl
1158
+ this._childHttpUrl,
1159
+ onProgress
864
1160
  );
865
- const isError = result.exitCode !== 0;
866
- const text = isError ? `Error (exit ${result.exitCode}): ${result.stderr || result.stdout}` : `Session: ${result.sessionId}
867
-
868
- ${result.stdout}`;
1161
+ const { text, isError } = formatSpawnAgentResponse(result);
869
1162
  return {
870
1163
  content: [{ type: "text", text }],
871
1164
  isError
@@ -881,31 +1174,76 @@ ${result.stdout}`;
881
1174
  );
882
1175
  server.tool(
883
1176
  "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.",
1177
+ "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.",
1178
+ spawnAgentsParallelInputShape,
1179
+ async (params, extra) => {
1180
+ try {
1181
+ const onProgress = (progress) => {
1182
+ const progressToken = params._meta ? params._meta.progressToken : void 0;
1183
+ if (progressToken !== void 0) {
1184
+ void extra.sendNotification({
1185
+ method: "notifications/progress",
1186
+ params: {
1187
+ progressToken,
1188
+ progress: progress.percent ?? 0,
1189
+ total: 100,
1190
+ message: progress.stage
1191
+ }
1192
+ });
1193
+ }
1194
+ };
1195
+ const result = await executeSpawnAgentsParallel(
1196
+ params.agents,
1197
+ this.registry,
1198
+ this.sessionManager,
1199
+ this.guard,
1200
+ this.hooksEngine,
1201
+ this.contextMonitor,
1202
+ this.backendSelector,
1203
+ this._childHttpUrl,
1204
+ onProgress
1205
+ );
1206
+ const { text, isError } = formatParallelResponse(result);
1207
+ return {
1208
+ content: [{ type: "text", text }],
1209
+ isError
1210
+ };
1211
+ } catch (error) {
1212
+ const message = error instanceof Error ? error.message : String(error);
1213
+ return {
1214
+ content: [{ type: "text", text: `Error: ${message}` }],
1215
+ isError: true
1216
+ };
1217
+ }
1218
+ }
1219
+ );
1220
+ server.tool(
1221
+ "retry_failed_agents",
1222
+ "Retry only the failed agents from a previous spawn_agents_parallel call. Pass the failed results array (with originalInput) directly.",
885
1223
  {
886
- agents: z5.array(z5.object({
887
- backend: z5.enum(["claude", "codex", "gemini"]),
888
- prompt: z5.string(),
889
- agent: z5.string().optional().describe("Named agent configuration (Claude only)"),
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(),
1224
+ failedResults: z5.array(z5.object({
1225
+ index: z5.number(),
1226
+ originalInput: spawnAgentInputSchema
1227
+ })).min(1).describe("Array of failed results with their original input configurations"),
1228
+ overrides: z5.object({
893
1229
  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)")
1230
+ timeoutMs: z5.number().optional(),
1231
+ preferredBackend: z5.enum(["claude", "codex", "gemini"]).optional()
1232
+ }).optional().describe("Parameter overrides applied to all retried agents")
904
1233
  },
905
1234
  async (params) => {
906
1235
  try {
1236
+ const agents = params.failedResults.map((r) => {
1237
+ const input = { ...r.originalInput };
1238
+ if (params.overrides) {
1239
+ if (params.overrides.maxTurns !== void 0) input.maxTurns = params.overrides.maxTurns;
1240
+ if (params.overrides.timeoutMs !== void 0) input.timeoutMs = params.overrides.timeoutMs;
1241
+ if (params.overrides.preferredBackend !== void 0) input.preferredBackend = params.overrides.preferredBackend;
1242
+ }
1243
+ return input;
1244
+ });
907
1245
  const result = await executeSpawnAgentsParallel(
908
- params.agents,
1246
+ agents,
909
1247
  this.registry,
910
1248
  this.sessionManager,
911
1249
  this.guard,
@@ -914,8 +1252,7 @@ ${result.stdout}`;
914
1252
  this.backendSelector,
915
1253
  this._childHttpUrl
916
1254
  );
917
- const isError = result.failureCount === result.totalCount;
918
- const text = JSON.stringify(result, null, 2);
1255
+ const { text, isError } = formatParallelResponse(result);
919
1256
  return {
920
1257
  content: [{ type: "text", text }],
921
1258
  isError
@@ -1041,14 +1378,14 @@ ${result.stdout}`;
1041
1378
  });
1042
1379
  this._httpServer = httpServer;
1043
1380
  await this.server.connect(httpTransport);
1044
- await new Promise((resolve) => {
1045
- httpServer.listen(port, () => {
1381
+ await new Promise((resolve2) => {
1382
+ httpServer.listen(port, "127.0.0.1", () => {
1046
1383
  logger.info(`MCP server listening on http://localhost:${port}/mcp`);
1047
- resolve();
1384
+ resolve2();
1048
1385
  });
1049
1386
  });
1050
- await new Promise((resolve) => {
1051
- httpServer.on("close", resolve);
1387
+ await new Promise((resolve2) => {
1388
+ httpServer.on("close", resolve2);
1052
1389
  });
1053
1390
  }
1054
1391
  /**
@@ -1079,7 +1416,7 @@ ${result.stdout}`;
1079
1416
  });
1080
1417
  const server = new McpServer({
1081
1418
  name: "agentic-relay",
1082
- version: "0.7.0"
1419
+ version: "0.9.0"
1083
1420
  });
1084
1421
  this.registerTools(server);
1085
1422
  transport.onclose = () => {
@@ -1095,15 +1432,31 @@ ${result.stdout}`;
1095
1432
  if (sid) {
1096
1433
  sessions.set(sid, { transport, server });
1097
1434
  logger.debug(`Child MCP session created: ${sid}`);
1435
+ if (sessions.size > MAX_CHILD_HTTP_SESSIONS) {
1436
+ const oldestEntry = sessions.entries().next().value;
1437
+ if (oldestEntry) {
1438
+ const [oldestSessionId, oldestSession] = oldestEntry;
1439
+ sessions.delete(oldestSessionId);
1440
+ logger.warn(
1441
+ `Child MCP session evicted due to limit (${MAX_CHILD_HTTP_SESSIONS}): ${oldestSessionId}`
1442
+ );
1443
+ void oldestSession.transport.close().catch((error) => {
1444
+ const message = error instanceof Error ? error.message : String(error);
1445
+ logger.debug(
1446
+ `Failed to close evicted child MCP session ${oldestSessionId}: ${message}`
1447
+ );
1448
+ });
1449
+ }
1450
+ }
1098
1451
  }
1099
1452
  });
1100
1453
  this._childHttpServer = httpServer;
1101
- await new Promise((resolve) => {
1454
+ await new Promise((resolve2) => {
1102
1455
  httpServer.listen(0, "127.0.0.1", () => {
1103
1456
  const addr = httpServer.address();
1104
1457
  this._childHttpUrl = `http://127.0.0.1:${addr.port}/mcp`;
1105
1458
  logger.info(`Child MCP server listening on ${this._childHttpUrl}`);
1106
- resolve();
1459
+ resolve2();
1107
1460
  });
1108
1461
  });
1109
1462
  }
@@ -1255,10 +1608,11 @@ var AdapterRegistry = class {
1255
1608
 
1256
1609
  // src/adapters/base-adapter.ts
1257
1610
  init_logger();
1258
- var BaseAdapter = class {
1611
+ var BaseAdapter = class _BaseAdapter {
1259
1612
  constructor(processManager2) {
1260
1613
  this.processManager = processManager2;
1261
1614
  }
1615
+ static HEALTH_TIMEOUT_MS = 5e3;
1262
1616
  async isInstalled() {
1263
1617
  try {
1264
1618
  const result = await this.processManager.execute("which", [this.command]);
@@ -1286,11 +1640,13 @@ var BaseAdapter = class {
1286
1640
  };
1287
1641
  }
1288
1642
  async checkHealth() {
1289
- const HEALTH_TIMEOUT = 5e3;
1290
1643
  const installed = await Promise.race([
1291
1644
  this.isInstalled(),
1292
1645
  new Promise(
1293
- (_, reject) => setTimeout(() => reject(new Error("timeout")), HEALTH_TIMEOUT)
1646
+ (_, reject) => setTimeout(
1647
+ () => reject(new Error("timeout")),
1648
+ _BaseAdapter.HEALTH_TIMEOUT_MS
1649
+ )
1294
1650
  )
1295
1651
  ]).catch(() => false);
1296
1652
  if (!installed) {
@@ -1304,14 +1660,29 @@ var BaseAdapter = class {
1304
1660
  const version = await Promise.race([
1305
1661
  this.getVersion(),
1306
1662
  new Promise(
1307
- (_, reject) => setTimeout(() => reject(new Error("timeout")), HEALTH_TIMEOUT)
1663
+ (_, reject) => setTimeout(
1664
+ () => reject(new Error("timeout")),
1665
+ _BaseAdapter.HEALTH_TIMEOUT_MS
1666
+ )
1308
1667
  )
1309
1668
  ]).catch(() => void 0);
1669
+ const authStatus = await Promise.race([
1670
+ this.checkAuthStatus(),
1671
+ new Promise(
1672
+ (_, reject) => setTimeout(
1673
+ () => reject(new Error("timeout")),
1674
+ _BaseAdapter.HEALTH_TIMEOUT_MS
1675
+ )
1676
+ )
1677
+ ]).catch(() => ({ authenticated: true }));
1678
+ const authenticated = authStatus.authenticated;
1679
+ const message = authStatus.message ?? (!authenticated ? `${this.id} authentication not configured` : void 0);
1310
1680
  return {
1311
1681
  installed: true,
1312
- authenticated: true,
1313
- healthy: true,
1314
- version
1682
+ authenticated,
1683
+ healthy: authenticated,
1684
+ version,
1685
+ ...message ? { message } : {}
1315
1686
  };
1316
1687
  }
1317
1688
  async getMCPConfig() {
@@ -1426,45 +1797,14 @@ var ClaudeAdapter = class extends BaseAdapter {
1426
1797
  getConfigPath() {
1427
1798
  return join(homedir(), ".claude.json");
1428
1799
  }
1429
- async checkHealth() {
1430
- const HEALTH_TIMEOUT = 5e3;
1431
- const installed = await Promise.race([
1432
- this.isInstalled(),
1433
- new Promise(
1434
- (_, reject) => setTimeout(() => reject(new Error("timeout")), HEALTH_TIMEOUT)
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
- }
1800
+ async checkAuthStatus() {
1801
+ const result = await this.processManager.execute(this.command, [
1802
+ "auth",
1803
+ "status"
1804
+ ]);
1805
+ const authenticated = result.exitCode === 0;
1463
1806
  return {
1464
- installed: true,
1465
1807
  authenticated,
1466
- healthy: authenticated,
1467
- version,
1468
1808
  ...!authenticated ? { message: "claude authentication not configured" } : {}
1469
1809
  };
1470
1810
  }
@@ -1515,7 +1855,16 @@ var ClaudeAdapter = class extends BaseAdapter {
1515
1855
  let sessionId = "";
1516
1856
  let isError = false;
1517
1857
  let errorMessages = [];
1858
+ let totalInputTokens = 0;
1859
+ let totalOutputTokens = 0;
1518
1860
  for await (const message of q) {
1861
+ if (message.type === "assistant") {
1862
+ const betaMessage = message.message;
1863
+ if (betaMessage?.usage) {
1864
+ totalInputTokens += betaMessage.usage.input_tokens ?? 0;
1865
+ totalOutputTokens += betaMessage.usage.output_tokens ?? 0;
1866
+ }
1867
+ }
1519
1868
  if (message.type === "result") {
1520
1869
  sessionId = message.session_id;
1521
1870
  if (message.subtype === "success") {
@@ -1527,11 +1876,13 @@ var ClaudeAdapter = class extends BaseAdapter {
1527
1876
  }
1528
1877
  }
1529
1878
  logger.debug(`Claude SDK session: ${sessionId}`);
1879
+ const hasTokenUsage = totalInputTokens > 0 || totalOutputTokens > 0;
1530
1880
  return {
1531
1881
  exitCode: isError ? 1 : 0,
1532
1882
  stdout: resultText,
1533
1883
  stderr: errorMessages.join("\n"),
1534
- ...sessionId ? { nativeSessionId: sessionId } : {}
1884
+ ...sessionId ? { nativeSessionId: sessionId } : {},
1885
+ ...hasTokenUsage ? { tokenUsage: { inputTokens: totalInputTokens, outputTokens: totalOutputTokens } } : {}
1535
1886
  };
1536
1887
  } catch (error) {
1537
1888
  if (abortController.signal.aborted) {
@@ -1903,48 +2254,32 @@ var CodexAdapter = class extends BaseAdapter {
1903
2254
  getConfigPath() {
1904
2255
  return join2(homedir2(), ".codex", "config.toml");
1905
2256
  }
1906
- async checkHealth() {
1907
- const HEALTH_TIMEOUT = 5e3;
1908
- const installed = await Promise.race([
1909
- this.isInstalled(),
1910
- new Promise(
1911
- (_, reject) => setTimeout(() => reject(new Error("timeout")), HEALTH_TIMEOUT)
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
- }
2257
+ async checkAuthStatus() {
2258
+ const result = await this.processManager.execute(this.command, [
2259
+ "login",
2260
+ "status"
2261
+ ]);
2262
+ const authenticated = result.exitCode === 0;
1940
2263
  return {
1941
- installed: true,
1942
2264
  authenticated,
1943
- healthy: authenticated,
1944
- version,
1945
2265
  ...!authenticated ? { message: "codex authentication not configured" } : {}
1946
2266
  };
1947
2267
  }
2268
+ buildCodexOptions(flags) {
2269
+ if (!flags.mcpContext) {
2270
+ return {};
2271
+ }
2272
+ const env = {};
2273
+ for (const [key, value] of Object.entries(process.env)) {
2274
+ if (value !== void 0) {
2275
+ env[key] = value;
2276
+ }
2277
+ }
2278
+ env.RELAY_TRACE_ID = flags.mcpContext.traceId;
2279
+ env.RELAY_PARENT_SESSION_ID = flags.mcpContext.parentSessionId;
2280
+ env.RELAY_DEPTH = String(flags.mcpContext.depth);
2281
+ return { env };
2282
+ }
1948
2283
  mapFlags(flags) {
1949
2284
  const args = mapCommonToNative("codex", flags);
1950
2285
  if (flags.outputFormat === "json") {
@@ -1997,16 +2332,7 @@ ${prompt}`;
1997
2332
  );
1998
2333
  try {
1999
2334
  const { Codex } = await loadCodexSDK();
2000
- const codexOptions = {};
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);
2335
+ const codex = new Codex(this.buildCodexOptions(flags));
2010
2336
  const thread = codex.startThread({
2011
2337
  ...flags.model ? { model: flags.model } : {},
2012
2338
  workingDirectory: process.cwd(),
@@ -2038,16 +2364,7 @@ ${prompt}`;
2038
2364
  );
2039
2365
  try {
2040
2366
  const { Codex } = await loadCodexSDK();
2041
- const codexOptions = {};
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);
2367
+ const codex = new Codex(this.buildCodexOptions(flags));
2051
2368
  const thread = codex.startThread({
2052
2369
  ...flags.model ? { model: flags.model } : {},
2053
2370
  workingDirectory: process.cwd(),
@@ -2061,15 +2378,15 @@ ${prompt}`;
2061
2378
  threadId = event.thread_id;
2062
2379
  } else if (event.type === "item.started") {
2063
2380
  const item = event.item;
2064
- if (item?.type === "agent_message" && item.text) {
2381
+ if (item.type === "agent_message" && item.text) {
2065
2382
  yield { type: "text", text: item.text };
2066
- } else if (item?.type === "command_execution") {
2383
+ } else if (item.type === "command_execution") {
2067
2384
  yield {
2068
2385
  type: "tool_start",
2069
2386
  tool: item.command ?? "command",
2070
2387
  id: item.id ?? ""
2071
2388
  };
2072
- } else if (item?.type === "file_change") {
2389
+ } else if (item.type === "file_change") {
2073
2390
  yield {
2074
2391
  type: "tool_start",
2075
2392
  tool: "file_change",
@@ -2078,17 +2395,17 @@ ${prompt}`;
2078
2395
  }
2079
2396
  } else if (event.type === "item.completed") {
2080
2397
  const item = event.item;
2081
- if (item?.type === "agent_message" && item.text) {
2398
+ if (item.type === "agent_message" && item.text) {
2082
2399
  completedMessages.push(item.text);
2083
2400
  yield { type: "text", text: item.text };
2084
- } else if (item?.type === "command_execution") {
2401
+ } else if (item.type === "command_execution") {
2085
2402
  yield {
2086
2403
  type: "tool_end",
2087
2404
  tool: item.command ?? "command",
2088
2405
  id: item.id ?? "",
2089
2406
  result: item.aggregated_output
2090
2407
  };
2091
- } else if (item?.type === "file_change") {
2408
+ } else if (item.type === "file_change") {
2092
2409
  yield {
2093
2410
  type: "tool_end",
2094
2411
  tool: "file_change",
@@ -2111,7 +2428,7 @@ ${prompt}`;
2111
2428
  nativeSessionId: threadId ?? thread.id ?? void 0
2112
2429
  };
2113
2430
  } else if (event.type === "turn.failed") {
2114
- const errorMessage = event.error?.message ?? "Turn failed";
2431
+ const errorMessage = event.error.message ?? "Turn failed";
2115
2432
  yield {
2116
2433
  type: "done",
2117
2434
  result: { exitCode: 1, stdout: "", stderr: errorMessage },
@@ -2248,37 +2565,18 @@ var GeminiAdapter = class extends BaseAdapter {
2248
2565
  getConfigPath() {
2249
2566
  return join3(homedir3(), ".gemini", "settings.json");
2250
2567
  }
2251
- async checkHealth() {
2252
- const HEALTH_TIMEOUT = 5e3;
2253
- const installed = await Promise.race([
2254
- this.isInstalled(),
2255
- new Promise(
2256
- (_, reject) => setTimeout(() => reject(new Error("timeout")), HEALTH_TIMEOUT)
2257
- )
2258
- ]).catch(() => false);
2259
- if (!installed) {
2568
+ async checkAuthStatus() {
2569
+ const hasApiKey = !!process.env["GEMINI_API_KEY"];
2570
+ const hasGoogleAdc = !!process.env["GOOGLE_APPLICATION_CREDENTIALS"] || !!process.env["CLOUDSDK_CONFIG"];
2571
+ const authenticated = hasApiKey || hasGoogleAdc;
2572
+ if (!authenticated) {
2260
2573
  return {
2261
- installed: false,
2262
- authenticated: false,
2263
- healthy: false,
2264
- message: "gemini is not installed"
2574
+ // Optimistic fallback: Gemini may still authenticate via ADC at runtime.
2575
+ authenticated: true,
2576
+ message: "Gemini authentication status unknown - ADC may be available at runtime"
2265
2577
  };
2266
2578
  }
2267
- const version = await Promise.race([
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
- };
2579
+ return { authenticated };
2282
2580
  }
2283
2581
  mapFlags(flags) {
2284
2582
  const args = mapCommonToNative("gemini", flags);
@@ -3167,6 +3465,7 @@ var HooksEngine = class _HooksEngine {
3167
3465
  };
3168
3466
 
3169
3467
  // src/core/context-monitor.ts
3468
+ init_logger();
3170
3469
  var DEFAULT_BACKEND_CONTEXT = {
3171
3470
  claude: { contextWindow: 2e5, compactThreshold: 19e4 },
3172
3471
  codex: { contextWindow: 272e3, compactThreshold: 258400 },
@@ -3285,7 +3584,9 @@ var ContextMonitor = class {
3285
3584
  remainingBeforeCompact
3286
3585
  }
3287
3586
  };
3288
- void this.hooksEngine.emit("on-context-threshold", hookInput);
3587
+ void this.hooksEngine.emit("on-context-threshold", hookInput).catch(
3588
+ (e) => logger.debug("Context threshold hook error:", e)
3589
+ );
3289
3590
  }
3290
3591
  }
3291
3592
  };
@@ -4061,7 +4362,7 @@ function createVersionCommand(registry2) {
4061
4362
  description: "Show relay and backend versions"
4062
4363
  },
4063
4364
  async run() {
4064
- const relayVersion = "0.7.0";
4365
+ const relayVersion = "0.9.0";
4065
4366
  console.log(`agentic-relay v${relayVersion}`);
4066
4367
  console.log("");
4067
4368
  console.log("Backends:");
@@ -4088,9 +4389,9 @@ import { defineCommand as defineCommand8 } from "citty";
4088
4389
  import { access, constants, readdir as readdir2 } from "fs/promises";
4089
4390
  import { join as join7 } from "path";
4090
4391
  import { homedir as homedir5 } from "os";
4091
- import { execFile } from "child_process";
4092
- import { promisify } from "util";
4093
- var execFileAsync = promisify(execFile);
4392
+ import { execFile as execFile2 } from "child_process";
4393
+ import { promisify as promisify2 } from "util";
4394
+ var execFileAsync2 = promisify2(execFile2);
4094
4395
  async function checkNodeVersion() {
4095
4396
  const version = process.version;
4096
4397
  const major = Number(version.slice(1).split(".")[0]);
@@ -4190,7 +4491,7 @@ async function checkMCPServerCommands(configManager2) {
4190
4491
  for (const [name, server] of Object.entries(mcpServers)) {
4191
4492
  const command = server.command;
4192
4493
  try {
4193
- await execFileAsync("which", [command]);
4494
+ await execFileAsync2("which", [command]);
4194
4495
  results.push({
4195
4496
  label: `MCP server: ${name}`,
4196
4497
  ok: true,
@@ -4386,6 +4687,7 @@ function createInitCommand() {
4386
4687
  }
4387
4688
 
4388
4689
  // src/bin/relay.ts
4690
+ init_logger();
4389
4691
  var processManager = new ProcessManager();
4390
4692
  var registry = new AdapterRegistry();
4391
4693
  registry.registerLazy("claude", () => new ClaudeAdapter(processManager));
@@ -4406,12 +4708,11 @@ void configManager.getConfig().then((config) => {
4406
4708
  if (config.contextMonitor) {
4407
4709
  contextMonitor = new ContextMonitor(hooksEngine, config.contextMonitor);
4408
4710
  }
4409
- }).catch(() => {
4410
- });
4711
+ }).catch((e) => logger.debug("Config load failed:", e));
4411
4712
  var main = defineCommand10({
4412
4713
  meta: {
4413
4714
  name: "relay",
4414
- version: "0.7.0",
4715
+ version: "0.9.0",
4415
4716
  description: "Unified CLI proxy for Claude Code, Codex CLI, and Gemini CLI"
4416
4717
  },
4417
4718
  subCommands: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rk0429/agentic-relay",
3
- "version": "0.7.0",
3
+ "version": "0.9.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": {