@rk0429/agentic-relay 0.5.0 → 0.6.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/dist/relay.mjs +417 -32
- package/package.json +1 -1
package/dist/relay.mjs
CHANGED
|
@@ -158,19 +158,117 @@ var init_recursion_guard = __esm({
|
|
|
158
158
|
// src/mcp-server/tools/spawn-agent.ts
|
|
159
159
|
import { z as z2 } from "zod";
|
|
160
160
|
import { nanoid as nanoid2 } from "nanoid";
|
|
161
|
+
import { existsSync, readFileSync } from "fs";
|
|
162
|
+
import { join as join6 } from "path";
|
|
163
|
+
function buildContextInjection(metadata) {
|
|
164
|
+
const parts = [];
|
|
165
|
+
if (metadata.stateContent && typeof metadata.stateContent === "string") {
|
|
166
|
+
parts.push(`<context-injection type="state">
|
|
167
|
+
${metadata.stateContent}
|
|
168
|
+
</context-injection>`);
|
|
169
|
+
}
|
|
170
|
+
if (Array.isArray(metadata.results) && metadata.results.length > 0) {
|
|
171
|
+
const formatted = metadata.results.map((r, i) => `[${i + 1}] ${r.title} (${r.date}, score: ${r.score})
|
|
172
|
+
${r.snippet}`).join("\n\n");
|
|
173
|
+
parts.push(`<context-injection type="search-results">
|
|
174
|
+
${formatted}
|
|
175
|
+
</context-injection>`);
|
|
176
|
+
}
|
|
177
|
+
return parts.join("\n\n");
|
|
178
|
+
}
|
|
179
|
+
function readPreviousState(dailynoteDir) {
|
|
180
|
+
try {
|
|
181
|
+
const statePath = join6(dailynoteDir, "_state.md");
|
|
182
|
+
if (existsSync(statePath)) {
|
|
183
|
+
return readFileSync(statePath, "utf-8");
|
|
184
|
+
}
|
|
185
|
+
return null;
|
|
186
|
+
} catch (error) {
|
|
187
|
+
logger.warn(
|
|
188
|
+
`Failed to read _state.md: ${error instanceof Error ? error.message : String(error)}`
|
|
189
|
+
);
|
|
190
|
+
return null;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
function readAgentDefinition(definitionPath) {
|
|
194
|
+
try {
|
|
195
|
+
if (!existsSync(definitionPath)) {
|
|
196
|
+
logger.warn(`Agent definition file not found at ${definitionPath}`);
|
|
197
|
+
return null;
|
|
198
|
+
}
|
|
199
|
+
const content = readFileSync(definitionPath, "utf-8");
|
|
200
|
+
if (content.trim().length === 0) {
|
|
201
|
+
logger.warn(`Agent definition file is empty at ${definitionPath}`);
|
|
202
|
+
return null;
|
|
203
|
+
}
|
|
204
|
+
return content;
|
|
205
|
+
} catch (error) {
|
|
206
|
+
logger.warn(
|
|
207
|
+
`Failed to read agent definition: ${error instanceof Error ? error.message : String(error)}`
|
|
208
|
+
);
|
|
209
|
+
return null;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
function readSkillContext(skillContext) {
|
|
213
|
+
try {
|
|
214
|
+
const skillMdPath = join6(skillContext.skillPath, "SKILL.md");
|
|
215
|
+
if (!existsSync(skillMdPath)) {
|
|
216
|
+
logger.warn(
|
|
217
|
+
`SKILL.md not found at ${skillMdPath}`
|
|
218
|
+
);
|
|
219
|
+
return null;
|
|
220
|
+
}
|
|
221
|
+
const parts = [];
|
|
222
|
+
const skillContent = readFileSync(skillMdPath, "utf-8");
|
|
223
|
+
parts.push(skillContent);
|
|
224
|
+
if (skillContext.subskill) {
|
|
225
|
+
const subskillPath = join6(
|
|
226
|
+
skillContext.skillPath,
|
|
227
|
+
"subskills",
|
|
228
|
+
skillContext.subskill,
|
|
229
|
+
"SUBSKILL.md"
|
|
230
|
+
);
|
|
231
|
+
if (existsSync(subskillPath)) {
|
|
232
|
+
const subskillContent = readFileSync(subskillPath, "utf-8");
|
|
233
|
+
parts.push(subskillContent);
|
|
234
|
+
} else {
|
|
235
|
+
logger.warn(
|
|
236
|
+
`SUBSKILL.md not found at ${subskillPath}`
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
return parts.join("\n\n");
|
|
241
|
+
} catch (error) {
|
|
242
|
+
logger.warn(
|
|
243
|
+
`Failed to read skill context: ${error instanceof Error ? error.message : String(error)}`
|
|
244
|
+
);
|
|
245
|
+
return null;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
161
248
|
function buildContextFromEnv() {
|
|
162
249
|
const traceId = process.env["RELAY_TRACE_ID"] ?? `trace-${nanoid2()}`;
|
|
163
250
|
const parentSessionId = process.env["RELAY_PARENT_SESSION_ID"] ?? null;
|
|
164
251
|
const depth = Number(process.env["RELAY_DEPTH"] ?? "0");
|
|
165
252
|
return { traceId, parentSessionId, depth };
|
|
166
253
|
}
|
|
167
|
-
async function executeSpawnAgent(input, registry2, sessionManager2, guard, hooksEngine2, contextMonitor2) {
|
|
254
|
+
async function executeSpawnAgent(input, registry2, sessionManager2, guard, hooksEngine2, contextMonitor2, backendSelector) {
|
|
255
|
+
let effectiveBackend = input.backend;
|
|
256
|
+
if (backendSelector) {
|
|
257
|
+
const availableBackends = registry2.listIds();
|
|
258
|
+
const selectionContext = {
|
|
259
|
+
availableBackends,
|
|
260
|
+
preferredBackend: input.preferredBackend,
|
|
261
|
+
agentType: input.agent,
|
|
262
|
+
taskType: input.taskType
|
|
263
|
+
};
|
|
264
|
+
effectiveBackend = backendSelector.selectBackend(selectionContext);
|
|
265
|
+
}
|
|
168
266
|
const envContext = buildContextFromEnv();
|
|
169
267
|
const promptHash = RecursionGuard.hashPrompt(input.prompt);
|
|
170
268
|
const context = {
|
|
171
269
|
traceId: envContext.traceId,
|
|
172
270
|
depth: envContext.depth,
|
|
173
|
-
backend:
|
|
271
|
+
backend: effectiveBackend,
|
|
174
272
|
promptHash
|
|
175
273
|
};
|
|
176
274
|
const guardResult = guard.canSpawn(context);
|
|
@@ -183,27 +281,54 @@ async function executeSpawnAgent(input, registry2, sessionManager2, guard, hooks
|
|
|
183
281
|
stderr: `Spawn blocked: ${guardResult.reason}`
|
|
184
282
|
};
|
|
185
283
|
}
|
|
186
|
-
const adapter = registry2.get(
|
|
284
|
+
const adapter = registry2.get(effectiveBackend);
|
|
187
285
|
const installed = await adapter.isInstalled();
|
|
188
286
|
if (!installed) {
|
|
189
287
|
return {
|
|
190
288
|
sessionId: "",
|
|
191
289
|
exitCode: 1,
|
|
192
290
|
stdout: "",
|
|
193
|
-
stderr: `Backend "${
|
|
291
|
+
stderr: `Backend "${effectiveBackend}" is not available. Use list_available_backends to see available options.`
|
|
194
292
|
};
|
|
195
293
|
}
|
|
294
|
+
const spawnStartedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
196
295
|
const session = await sessionManager2.create({
|
|
197
|
-
backendId:
|
|
296
|
+
backendId: effectiveBackend,
|
|
198
297
|
parentSessionId: envContext.parentSessionId ?? void 0,
|
|
199
298
|
depth: envContext.depth + 1
|
|
200
299
|
});
|
|
300
|
+
let collectedMetadata = {};
|
|
301
|
+
if (hooksEngine2 && !input.resumeSessionId) {
|
|
302
|
+
try {
|
|
303
|
+
const cwd = process.cwd();
|
|
304
|
+
const dailynoteDir = join6(cwd, "daily_note");
|
|
305
|
+
const hookInput = {
|
|
306
|
+
schemaVersion: "1.0",
|
|
307
|
+
event: "session-init",
|
|
308
|
+
sessionId: session.relaySessionId,
|
|
309
|
+
backendId: effectiveBackend,
|
|
310
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
311
|
+
data: {
|
|
312
|
+
workingDirectory: cwd,
|
|
313
|
+
dailynoteDir,
|
|
314
|
+
isFirstSession: !existsSync(join6(dailynoteDir, "_state.md")),
|
|
315
|
+
previousState: readPreviousState(dailynoteDir)
|
|
316
|
+
}
|
|
317
|
+
};
|
|
318
|
+
const { mergedMetadata } = await hooksEngine2.emitAndCollectMetadata("session-init", hookInput);
|
|
319
|
+
collectedMetadata = { ...collectedMetadata, ...mergedMetadata };
|
|
320
|
+
} catch (error) {
|
|
321
|
+
logger.warn(
|
|
322
|
+
`session-init hook error: ${error instanceof Error ? error.message : String(error)}`
|
|
323
|
+
);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
201
326
|
if (hooksEngine2) {
|
|
202
327
|
try {
|
|
203
328
|
const hookInput = {
|
|
204
329
|
event: "pre-spawn",
|
|
205
330
|
sessionId: session.relaySessionId,
|
|
206
|
-
backendId:
|
|
331
|
+
backendId: effectiveBackend,
|
|
207
332
|
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
208
333
|
data: {
|
|
209
334
|
prompt: input.prompt,
|
|
@@ -211,13 +336,40 @@ async function executeSpawnAgent(input, registry2, sessionManager2, guard, hooks
|
|
|
211
336
|
model: input.model
|
|
212
337
|
}
|
|
213
338
|
};
|
|
214
|
-
await hooksEngine2.
|
|
339
|
+
const { mergedMetadata } = await hooksEngine2.emitAndCollectMetadata("pre-spawn", hookInput);
|
|
340
|
+
collectedMetadata = { ...collectedMetadata, ...mergedMetadata };
|
|
215
341
|
} catch (error) {
|
|
216
342
|
logger.debug(
|
|
217
343
|
`pre-spawn hook error: ${error instanceof Error ? error.message : String(error)}`
|
|
218
344
|
);
|
|
219
345
|
}
|
|
220
346
|
}
|
|
347
|
+
const injectionText = buildContextInjection(collectedMetadata);
|
|
348
|
+
let enhancedSystemPrompt = injectionText ? input.systemPrompt ? `${input.systemPrompt}
|
|
349
|
+
|
|
350
|
+
${injectionText}` : injectionText : input.systemPrompt;
|
|
351
|
+
if (input.skillContext) {
|
|
352
|
+
const skillText = readSkillContext(input.skillContext);
|
|
353
|
+
if (skillText) {
|
|
354
|
+
const wrapped = `<skill-context path="${input.skillContext.skillPath}"${input.skillContext.subskill ? ` subskill="${input.skillContext.subskill}"` : ""}>
|
|
355
|
+
${skillText}
|
|
356
|
+
</skill-context>`;
|
|
357
|
+
enhancedSystemPrompt = enhancedSystemPrompt ? `${enhancedSystemPrompt}
|
|
358
|
+
|
|
359
|
+
${wrapped}` : wrapped;
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
if (input.agentDefinition) {
|
|
363
|
+
const defText = readAgentDefinition(input.agentDefinition.definitionPath);
|
|
364
|
+
if (defText) {
|
|
365
|
+
const wrapped = `<agent-definition source="${input.agentDefinition.definitionPath}">
|
|
366
|
+
${defText}
|
|
367
|
+
</agent-definition>`;
|
|
368
|
+
enhancedSystemPrompt = enhancedSystemPrompt ? `${enhancedSystemPrompt}
|
|
369
|
+
|
|
370
|
+
${wrapped}` : wrapped;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
221
373
|
try {
|
|
222
374
|
let result;
|
|
223
375
|
if (input.resumeSessionId) {
|
|
@@ -226,7 +378,7 @@ async function executeSpawnAgent(input, registry2, sessionManager2, guard, hooks
|
|
|
226
378
|
sessionId: session.relaySessionId,
|
|
227
379
|
exitCode: 1,
|
|
228
380
|
stdout: "",
|
|
229
|
-
stderr: `Backend "${
|
|
381
|
+
stderr: `Backend "${effectiveBackend}" does not support session continuation (continueSession).`
|
|
230
382
|
};
|
|
231
383
|
}
|
|
232
384
|
result = await adapter.continueSession(input.resumeSessionId, input.prompt);
|
|
@@ -234,7 +386,7 @@ async function executeSpawnAgent(input, registry2, sessionManager2, guard, hooks
|
|
|
234
386
|
result = await adapter.execute({
|
|
235
387
|
prompt: input.prompt,
|
|
236
388
|
agent: input.agent,
|
|
237
|
-
systemPrompt:
|
|
389
|
+
systemPrompt: enhancedSystemPrompt,
|
|
238
390
|
model: input.model,
|
|
239
391
|
maxTurns: input.maxTurns,
|
|
240
392
|
mcpContext: {
|
|
@@ -251,7 +403,7 @@ async function executeSpawnAgent(input, registry2, sessionManager2, guard, hooks
|
|
|
251
403
|
);
|
|
252
404
|
contextMonitor2.updateUsage(
|
|
253
405
|
session.relaySessionId,
|
|
254
|
-
|
|
406
|
+
effectiveBackend,
|
|
255
407
|
estimatedTokens
|
|
256
408
|
);
|
|
257
409
|
}
|
|
@@ -260,15 +412,27 @@ async function executeSpawnAgent(input, registry2, sessionManager2, guard, hooks
|
|
|
260
412
|
await sessionManager2.update(session.relaySessionId, { status });
|
|
261
413
|
if (hooksEngine2) {
|
|
262
414
|
try {
|
|
415
|
+
const postSpawnData = {
|
|
416
|
+
exitCode: result.exitCode,
|
|
417
|
+
status,
|
|
418
|
+
taskDescription: input.prompt,
|
|
419
|
+
startedAt: spawnStartedAt,
|
|
420
|
+
backgroundMode: false,
|
|
421
|
+
sessionId: session.relaySessionId,
|
|
422
|
+
selectedBackend: effectiveBackend
|
|
423
|
+
};
|
|
424
|
+
if (input.agent !== void 0) {
|
|
425
|
+
postSpawnData.agentType = input.agent;
|
|
426
|
+
}
|
|
427
|
+
if (input.model !== void 0) {
|
|
428
|
+
postSpawnData.model = input.model;
|
|
429
|
+
}
|
|
263
430
|
const hookInput = {
|
|
264
431
|
event: "post-spawn",
|
|
265
432
|
sessionId: session.relaySessionId,
|
|
266
|
-
backendId:
|
|
433
|
+
backendId: effectiveBackend,
|
|
267
434
|
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
268
|
-
data:
|
|
269
|
-
exitCode: result.exitCode,
|
|
270
|
-
status
|
|
271
|
-
}
|
|
435
|
+
data: postSpawnData
|
|
272
436
|
};
|
|
273
437
|
await hooksEngine2.emit("post-spawn", hookInput);
|
|
274
438
|
} catch (hookError) {
|
|
@@ -308,7 +472,16 @@ var init_spawn_agent = __esm({
|
|
|
308
472
|
systemPrompt: z2.string().optional(),
|
|
309
473
|
resumeSessionId: z2.string().optional(),
|
|
310
474
|
model: z2.string().optional(),
|
|
311
|
-
maxTurns: z2.number().optional()
|
|
475
|
+
maxTurns: z2.number().optional(),
|
|
476
|
+
skillContext: z2.object({
|
|
477
|
+
skillPath: z2.string().describe("Path to the skill directory (e.g., '.agents/skills/software-engineer/')"),
|
|
478
|
+
subskill: z2.string().optional().describe("Specific subskill to activate")
|
|
479
|
+
}).optional().describe("Skill context to inject into the subagent's system prompt"),
|
|
480
|
+
agentDefinition: z2.object({
|
|
481
|
+
definitionPath: z2.string().describe("Path to the agent definition file (e.g., '.claude/agents/software-engineer.md')")
|
|
482
|
+
}).optional().describe("Agent definition file to inject into the sub-agent's system prompt"),
|
|
483
|
+
preferredBackend: z2.enum(["claude", "codex", "gemini"]).optional().describe("Preferred backend override. Takes priority over automatic selection based on agent/task type."),
|
|
484
|
+
taskType: z2.enum(["code", "document", "analysis", "mixed"]).optional().describe("Task type hint for automatic backend selection when preferredBackend is not specified.")
|
|
312
485
|
});
|
|
313
486
|
}
|
|
314
487
|
});
|
|
@@ -397,6 +570,65 @@ var init_list_available_backends = __esm({
|
|
|
397
570
|
}
|
|
398
571
|
});
|
|
399
572
|
|
|
573
|
+
// src/core/backend-selector.ts
|
|
574
|
+
var DEFAULT_AGENT_TO_BACKEND_MAP, TASK_TYPE_TO_BACKEND_MAP, DEFAULT_BACKEND, BackendSelector;
|
|
575
|
+
var init_backend_selector = __esm({
|
|
576
|
+
"src/core/backend-selector.ts"() {
|
|
577
|
+
"use strict";
|
|
578
|
+
DEFAULT_AGENT_TO_BACKEND_MAP = {
|
|
579
|
+
"software-engineer": "codex",
|
|
580
|
+
"devops-engineer": "codex",
|
|
581
|
+
"skill-developer": "codex",
|
|
582
|
+
"security-auditor": "codex",
|
|
583
|
+
"analytics-engineer": "codex",
|
|
584
|
+
"payments-billing": "codex"
|
|
585
|
+
};
|
|
586
|
+
TASK_TYPE_TO_BACKEND_MAP = {
|
|
587
|
+
code: "codex",
|
|
588
|
+
document: "claude",
|
|
589
|
+
analysis: "claude",
|
|
590
|
+
mixed: "claude"
|
|
591
|
+
};
|
|
592
|
+
DEFAULT_BACKEND = "claude";
|
|
593
|
+
BackendSelector = class {
|
|
594
|
+
defaultBackend;
|
|
595
|
+
agentToBackendMap;
|
|
596
|
+
constructor(config) {
|
|
597
|
+
this.defaultBackend = config?.defaultBackend ?? DEFAULT_BACKEND;
|
|
598
|
+
this.agentToBackendMap = config?.agentToBackendMap ?? DEFAULT_AGENT_TO_BACKEND_MAP;
|
|
599
|
+
}
|
|
600
|
+
selectBackend(context) {
|
|
601
|
+
const { availableBackends, preferredBackend, agentType, taskType } = context;
|
|
602
|
+
if (availableBackends.length === 0) {
|
|
603
|
+
throw new Error("No backends available");
|
|
604
|
+
}
|
|
605
|
+
if (preferredBackend && availableBackends.includes(preferredBackend)) {
|
|
606
|
+
return preferredBackend;
|
|
607
|
+
}
|
|
608
|
+
if (agentType) {
|
|
609
|
+
const mapped = this.agentToBackendMap[agentType];
|
|
610
|
+
if (mapped && availableBackends.includes(mapped)) {
|
|
611
|
+
return mapped;
|
|
612
|
+
}
|
|
613
|
+
if (!mapped && availableBackends.includes("claude")) {
|
|
614
|
+
return "claude";
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
if (taskType) {
|
|
618
|
+
const mapped = TASK_TYPE_TO_BACKEND_MAP[taskType];
|
|
619
|
+
if (mapped && availableBackends.includes(mapped)) {
|
|
620
|
+
return mapped;
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
if (availableBackends.includes(this.defaultBackend)) {
|
|
624
|
+
return this.defaultBackend;
|
|
625
|
+
}
|
|
626
|
+
return availableBackends[0];
|
|
627
|
+
}
|
|
628
|
+
};
|
|
629
|
+
}
|
|
630
|
+
});
|
|
631
|
+
|
|
400
632
|
// src/mcp-server/server.ts
|
|
401
633
|
var server_exports = {};
|
|
402
634
|
__export(server_exports, {
|
|
@@ -417,6 +649,7 @@ var init_server = __esm({
|
|
|
417
649
|
init_list_sessions();
|
|
418
650
|
init_get_context_status();
|
|
419
651
|
init_list_available_backends();
|
|
652
|
+
init_backend_selector();
|
|
420
653
|
init_logger();
|
|
421
654
|
RelayMCPServer = class {
|
|
422
655
|
constructor(registry2, sessionManager2, guardConfig, hooksEngine2, contextMonitor2) {
|
|
@@ -425,6 +658,7 @@ var init_server = __esm({
|
|
|
425
658
|
this.hooksEngine = hooksEngine2;
|
|
426
659
|
this.contextMonitor = contextMonitor2;
|
|
427
660
|
this.guard = new RecursionGuard(guardConfig);
|
|
661
|
+
this.backendSelector = new BackendSelector();
|
|
428
662
|
this.server = new McpServer({
|
|
429
663
|
name: "agentic-relay",
|
|
430
664
|
version: "0.4.0"
|
|
@@ -433,10 +667,11 @@ var init_server = __esm({
|
|
|
433
667
|
}
|
|
434
668
|
server;
|
|
435
669
|
guard;
|
|
670
|
+
backendSelector;
|
|
436
671
|
registerTools() {
|
|
437
672
|
this.server.tool(
|
|
438
673
|
"spawn_agent",
|
|
439
|
-
"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),
|
|
674
|
+
"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.",
|
|
440
675
|
{
|
|
441
676
|
backend: z5.enum(["claude", "codex", "gemini"]),
|
|
442
677
|
prompt: z5.string(),
|
|
@@ -446,7 +681,16 @@ var init_server = __esm({
|
|
|
446
681
|
),
|
|
447
682
|
resumeSessionId: z5.string().optional(),
|
|
448
683
|
model: z5.string().optional(),
|
|
449
|
-
maxTurns: z5.number().optional()
|
|
684
|
+
maxTurns: z5.number().optional(),
|
|
685
|
+
skillContext: z5.object({
|
|
686
|
+
skillPath: z5.string().describe("Path to the skill directory (e.g., '.agents/skills/software-engineer/')"),
|
|
687
|
+
subskill: z5.string().optional().describe("Specific subskill to activate")
|
|
688
|
+
}).optional().describe("Skill context to inject into the sub-agent's system prompt"),
|
|
689
|
+
agentDefinition: z5.object({
|
|
690
|
+
definitionPath: z5.string().describe("Path to the agent definition file (e.g., '.claude/agents/software-engineer.md')")
|
|
691
|
+
}).optional().describe("Agent definition file to inject into the sub-agent's system prompt"),
|
|
692
|
+
preferredBackend: z5.enum(["claude", "codex", "gemini"]).optional().describe("Preferred backend override. Takes priority over automatic selection based on agent/task type."),
|
|
693
|
+
taskType: z5.enum(["code", "document", "analysis", "mixed"]).optional().describe("Task type hint for automatic backend selection when preferredBackend is not specified.")
|
|
450
694
|
},
|
|
451
695
|
async (params) => {
|
|
452
696
|
try {
|
|
@@ -456,7 +700,8 @@ var init_server = __esm({
|
|
|
456
700
|
this.sessionManager,
|
|
457
701
|
this.guard,
|
|
458
702
|
this.hooksEngine,
|
|
459
|
-
this.contextMonitor
|
|
703
|
+
this.contextMonitor,
|
|
704
|
+
this.backendSelector
|
|
460
705
|
);
|
|
461
706
|
const isError = result.exitCode !== 0;
|
|
462
707
|
const text = isError ? `Error (exit ${result.exitCode}): ${result.stderr || result.stdout}` : `Session: ${result.sessionId}
|
|
@@ -607,7 +852,7 @@ ${result.stdout}`;
|
|
|
607
852
|
|
|
608
853
|
// src/bin/relay.ts
|
|
609
854
|
import { defineCommand as defineCommand10, runMain } from "citty";
|
|
610
|
-
import { join as
|
|
855
|
+
import { join as join9 } from "path";
|
|
611
856
|
import { homedir as homedir6 } from "os";
|
|
612
857
|
|
|
613
858
|
// src/infrastructure/process-manager.ts
|
|
@@ -736,6 +981,10 @@ var AdapterRegistry = class {
|
|
|
736
981
|
this.factories.clear();
|
|
737
982
|
return [...this.adapters.values()];
|
|
738
983
|
}
|
|
984
|
+
/** Return registered backend IDs without instantiating lazy adapters */
|
|
985
|
+
listIds() {
|
|
986
|
+
return [.../* @__PURE__ */ new Set([...this.adapters.keys(), ...this.factories.keys()])];
|
|
987
|
+
}
|
|
739
988
|
};
|
|
740
989
|
|
|
741
990
|
// src/adapters/base-adapter.ts
|
|
@@ -2031,6 +2280,7 @@ var mcpServerConfigSchema = z.object({
|
|
|
2031
2280
|
env: z.record(z.string()).optional()
|
|
2032
2281
|
});
|
|
2033
2282
|
var hookEventSchema = z.enum([
|
|
2283
|
+
"session-init",
|
|
2034
2284
|
"pre-prompt",
|
|
2035
2285
|
"post-response",
|
|
2036
2286
|
"on-error",
|
|
@@ -2046,8 +2296,19 @@ var hookDefinitionSchema = z.object({
|
|
|
2046
2296
|
enabled: z.boolean().optional(),
|
|
2047
2297
|
onError: z.enum(["ignore", "warn", "abort"]).optional()
|
|
2048
2298
|
});
|
|
2299
|
+
var hookChainStepSchema = z.object({
|
|
2300
|
+
stepId: z.string(),
|
|
2301
|
+
hook: hookDefinitionSchema,
|
|
2302
|
+
pipeOutput: z.boolean().optional()
|
|
2303
|
+
});
|
|
2304
|
+
var hookChainSchema = z.object({
|
|
2305
|
+
name: z.string(),
|
|
2306
|
+
steps: z.array(hookChainStepSchema).min(1),
|
|
2307
|
+
onError: z.enum(["ignore", "warn", "fail"]).optional()
|
|
2308
|
+
});
|
|
2049
2309
|
var hooksConfigSchema = z.object({
|
|
2050
|
-
definitions: z.array(hookDefinitionSchema)
|
|
2310
|
+
definitions: z.array(hookDefinitionSchema),
|
|
2311
|
+
chains: z.array(hookChainSchema).optional()
|
|
2051
2312
|
});
|
|
2052
2313
|
var backendContextConfigSchema = z.object({
|
|
2053
2314
|
contextWindow: z.number().positive().optional(),
|
|
@@ -2345,6 +2606,7 @@ var HooksEngine = class _HooksEngine {
|
|
|
2345
2606
|
static COMMAND_PATTERN = /^[a-zA-Z0-9_./-]+$/;
|
|
2346
2607
|
static ARG_PATTERN = /^[a-zA-Z0-9_.=:/-]+$/;
|
|
2347
2608
|
definitions = [];
|
|
2609
|
+
chains = [];
|
|
2348
2610
|
registered = false;
|
|
2349
2611
|
validateCommand(command) {
|
|
2350
2612
|
if (!command || command.trim().length === 0) {
|
|
@@ -2385,6 +2647,7 @@ var HooksEngine = class _HooksEngine {
|
|
|
2385
2647
|
return false;
|
|
2386
2648
|
}
|
|
2387
2649
|
});
|
|
2650
|
+
this.chains = config.chains ?? [];
|
|
2388
2651
|
if (!this.registered) {
|
|
2389
2652
|
for (const event of this.getUniqueEvents()) {
|
|
2390
2653
|
this.eventBus.on(event, async () => {
|
|
@@ -2426,10 +2689,132 @@ var HooksEngine = class _HooksEngine {
|
|
|
2426
2689
|
await this.eventBus.emit(event, input);
|
|
2427
2690
|
return results;
|
|
2428
2691
|
}
|
|
2692
|
+
/**
|
|
2693
|
+
* Emit a hook event, execute all matching hooks, and return merged metadata.
|
|
2694
|
+
* Merge rule: later hooks overwrite earlier hooks' metadata fields.
|
|
2695
|
+
* If any hook returns allow: false, subsequent hooks are skipped.
|
|
2696
|
+
*/
|
|
2697
|
+
async emitAndCollectMetadata(event, hookInput) {
|
|
2698
|
+
const matchingDefs = this.definitions.filter((def) => def.event === event);
|
|
2699
|
+
const results = [];
|
|
2700
|
+
let mergedMetadata = {};
|
|
2701
|
+
for (const def of matchingDefs) {
|
|
2702
|
+
try {
|
|
2703
|
+
const result = await this.executeHook(def, hookInput);
|
|
2704
|
+
results.push(result);
|
|
2705
|
+
if (result.output.metadata && typeof result.output.metadata === "object") {
|
|
2706
|
+
mergedMetadata = { ...mergedMetadata, ...result.output.metadata };
|
|
2707
|
+
}
|
|
2708
|
+
if (!result.output.allow) {
|
|
2709
|
+
logger.info(
|
|
2710
|
+
`Hook "${def.command}" returned allow: false, skipping subsequent hooks for event "${event}"`
|
|
2711
|
+
);
|
|
2712
|
+
break;
|
|
2713
|
+
}
|
|
2714
|
+
} catch (error) {
|
|
2715
|
+
const strategy = def.onError ?? "warn";
|
|
2716
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
2717
|
+
if (strategy === "abort") {
|
|
2718
|
+
throw new Error(
|
|
2719
|
+
`Hook "${def.command}" failed (abort): ${message}`
|
|
2720
|
+
);
|
|
2721
|
+
}
|
|
2722
|
+
if (strategy === "warn") {
|
|
2723
|
+
logger.warn(
|
|
2724
|
+
`Hook "${def.command}" failed (warn): ${message}`
|
|
2725
|
+
);
|
|
2726
|
+
}
|
|
2727
|
+
results.push({
|
|
2728
|
+
exitCode: 1,
|
|
2729
|
+
stdout: "",
|
|
2730
|
+
stderr: message,
|
|
2731
|
+
durationMs: 0,
|
|
2732
|
+
output: { ...DEFAULT_HOOK_OUTPUT }
|
|
2733
|
+
});
|
|
2734
|
+
}
|
|
2735
|
+
}
|
|
2736
|
+
await this.eventBus.emit(event, hookInput);
|
|
2737
|
+
return { results, mergedMetadata };
|
|
2738
|
+
}
|
|
2429
2739
|
/** Get the count of enabled definitions for a given event */
|
|
2430
2740
|
getDefinitionCount(event) {
|
|
2431
2741
|
return this.definitions.filter((def) => def.event === event).length;
|
|
2432
2742
|
}
|
|
2743
|
+
/** Get all loaded chain names */
|
|
2744
|
+
getChainNames() {
|
|
2745
|
+
return this.chains.map((c) => c.name);
|
|
2746
|
+
}
|
|
2747
|
+
/**
|
|
2748
|
+
* Execute a named hook chain.
|
|
2749
|
+
* Steps execute in sequence. If pipeOutput is true, previous step's
|
|
2750
|
+
* HookOutput is merged into the next step's HookInput.data.
|
|
2751
|
+
*/
|
|
2752
|
+
async executeChain(chainName, initialInput) {
|
|
2753
|
+
const chain = this.chains.find((c) => c.name === chainName);
|
|
2754
|
+
if (!chain) {
|
|
2755
|
+
throw new Error(`Hook chain "${chainName}" not found`);
|
|
2756
|
+
}
|
|
2757
|
+
const errorStrategy = chain.onError ?? "warn";
|
|
2758
|
+
const stepResults = [];
|
|
2759
|
+
let mergedMetadata = {};
|
|
2760
|
+
let previousOutput;
|
|
2761
|
+
let overallSuccess = true;
|
|
2762
|
+
for (const step of chain.steps) {
|
|
2763
|
+
const startTime = Date.now();
|
|
2764
|
+
let stepInput;
|
|
2765
|
+
if (step.pipeOutput && previousOutput) {
|
|
2766
|
+
stepInput = {
|
|
2767
|
+
...initialInput,
|
|
2768
|
+
data: {
|
|
2769
|
+
...initialInput.data,
|
|
2770
|
+
...previousOutput.metadata,
|
|
2771
|
+
_previousMessage: previousOutput.message,
|
|
2772
|
+
_previousAllow: previousOutput.allow
|
|
2773
|
+
}
|
|
2774
|
+
};
|
|
2775
|
+
} else {
|
|
2776
|
+
stepInput = { ...initialInput };
|
|
2777
|
+
}
|
|
2778
|
+
try {
|
|
2779
|
+
const result = await this.executeHook(step.hook, stepInput);
|
|
2780
|
+
const durationMs = Date.now() - startTime;
|
|
2781
|
+
stepResults.push({
|
|
2782
|
+
stepId: step.stepId,
|
|
2783
|
+
success: true,
|
|
2784
|
+
output: result.output,
|
|
2785
|
+
durationMs
|
|
2786
|
+
});
|
|
2787
|
+
if (result.output.metadata && typeof result.output.metadata === "object") {
|
|
2788
|
+
mergedMetadata = { ...mergedMetadata, ...result.output.metadata };
|
|
2789
|
+
}
|
|
2790
|
+
previousOutput = result.output;
|
|
2791
|
+
} catch (error) {
|
|
2792
|
+
const durationMs = Date.now() - startTime;
|
|
2793
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
2794
|
+
stepResults.push({
|
|
2795
|
+
stepId: step.stepId,
|
|
2796
|
+
success: false,
|
|
2797
|
+
error: message,
|
|
2798
|
+
durationMs
|
|
2799
|
+
});
|
|
2800
|
+
overallSuccess = false;
|
|
2801
|
+
if (errorStrategy === "fail") {
|
|
2802
|
+
break;
|
|
2803
|
+
}
|
|
2804
|
+
if (errorStrategy === "warn") {
|
|
2805
|
+
logger.warn(
|
|
2806
|
+
`Hook chain "${chainName}" step "${step.stepId}" failed (warn): ${message}`
|
|
2807
|
+
);
|
|
2808
|
+
}
|
|
2809
|
+
}
|
|
2810
|
+
}
|
|
2811
|
+
return {
|
|
2812
|
+
chainName,
|
|
2813
|
+
stepResults,
|
|
2814
|
+
success: overallSuccess,
|
|
2815
|
+
mergedMetadata
|
|
2816
|
+
};
|
|
2817
|
+
}
|
|
2433
2818
|
async executeHook(def, input) {
|
|
2434
2819
|
this.validateCommand(def.command);
|
|
2435
2820
|
this.validateArgs(def.args ?? []);
|
|
@@ -3387,7 +3772,7 @@ function createVersionCommand(registry2) {
|
|
|
3387
3772
|
// src/commands/doctor.ts
|
|
3388
3773
|
import { defineCommand as defineCommand8 } from "citty";
|
|
3389
3774
|
import { access, constants, readdir as readdir2 } from "fs/promises";
|
|
3390
|
-
import { join as
|
|
3775
|
+
import { join as join7 } from "path";
|
|
3391
3776
|
import { homedir as homedir5 } from "os";
|
|
3392
3777
|
import { execFile } from "child_process";
|
|
3393
3778
|
import { promisify } from "util";
|
|
@@ -3448,8 +3833,8 @@ async function checkConfig(configManager2) {
|
|
|
3448
3833
|
}
|
|
3449
3834
|
}
|
|
3450
3835
|
async function checkSessionsDir() {
|
|
3451
|
-
const relayHome2 = process.env["RELAY_HOME"] ??
|
|
3452
|
-
const sessionsDir =
|
|
3836
|
+
const relayHome2 = process.env["RELAY_HOME"] ?? join7(homedir5(), ".relay");
|
|
3837
|
+
const sessionsDir = join7(relayHome2, "sessions");
|
|
3453
3838
|
try {
|
|
3454
3839
|
await access(sessionsDir, constants.W_OK);
|
|
3455
3840
|
return {
|
|
@@ -3562,8 +3947,8 @@ async function checkBackendAuthEnv() {
|
|
|
3562
3947
|
return results;
|
|
3563
3948
|
}
|
|
3564
3949
|
async function checkSessionsDiskUsage() {
|
|
3565
|
-
const relayHome2 = process.env["RELAY_HOME"] ??
|
|
3566
|
-
const sessionsDir =
|
|
3950
|
+
const relayHome2 = process.env["RELAY_HOME"] ?? join7(homedir5(), ".relay");
|
|
3951
|
+
const sessionsDir = join7(relayHome2, "sessions");
|
|
3567
3952
|
try {
|
|
3568
3953
|
const entries = await readdir2(sessionsDir);
|
|
3569
3954
|
const fileCount = entries.length;
|
|
@@ -3637,7 +4022,7 @@ function createDoctorCommand(registry2, configManager2) {
|
|
|
3637
4022
|
init_logger();
|
|
3638
4023
|
import { defineCommand as defineCommand9 } from "citty";
|
|
3639
4024
|
import { mkdir as mkdir6, writeFile as writeFile6, access as access2, readFile as readFile6 } from "fs/promises";
|
|
3640
|
-
import { join as
|
|
4025
|
+
import { join as join8 } from "path";
|
|
3641
4026
|
var DEFAULT_CONFIG2 = {
|
|
3642
4027
|
defaultBackend: "claude",
|
|
3643
4028
|
backends: {},
|
|
@@ -3651,8 +4036,8 @@ function createInitCommand() {
|
|
|
3651
4036
|
},
|
|
3652
4037
|
async run() {
|
|
3653
4038
|
const projectDir = process.cwd();
|
|
3654
|
-
const relayDir =
|
|
3655
|
-
const configPath =
|
|
4039
|
+
const relayDir = join8(projectDir, ".relay");
|
|
4040
|
+
const configPath = join8(relayDir, "config.json");
|
|
3656
4041
|
try {
|
|
3657
4042
|
await access2(relayDir);
|
|
3658
4043
|
logger.info(
|
|
@@ -3668,7 +4053,7 @@ function createInitCommand() {
|
|
|
3668
4053
|
"utf-8"
|
|
3669
4054
|
);
|
|
3670
4055
|
logger.success(`Created ${configPath}`);
|
|
3671
|
-
const gitignorePath =
|
|
4056
|
+
const gitignorePath = join8(projectDir, ".gitignore");
|
|
3672
4057
|
try {
|
|
3673
4058
|
const gitignoreContent = await readFile6(gitignorePath, "utf-8");
|
|
3674
4059
|
if (!gitignoreContent.includes(".relay/config.local.json")) {
|
|
@@ -3693,8 +4078,8 @@ registry.registerLazy("claude", () => new ClaudeAdapter(processManager));
|
|
|
3693
4078
|
registry.registerLazy("codex", () => new CodexAdapter(processManager));
|
|
3694
4079
|
registry.registerLazy("gemini", () => new GeminiAdapter(processManager));
|
|
3695
4080
|
var sessionManager = new SessionManager();
|
|
3696
|
-
var relayHome = process.env["RELAY_HOME"] ??
|
|
3697
|
-
var projectRelayDir =
|
|
4081
|
+
var relayHome = process.env["RELAY_HOME"] ?? join9(homedir6(), ".relay");
|
|
4082
|
+
var projectRelayDir = join9(process.cwd(), ".relay");
|
|
3698
4083
|
var configManager = new ConfigManager(relayHome, projectRelayDir);
|
|
3699
4084
|
var authManager = new AuthManager(registry);
|
|
3700
4085
|
var eventBus = new EventBus();
|
package/package.json
CHANGED