@rk0429/agentic-relay 0.5.0 → 0.6.1

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 (2) hide show
  1. package/dist/relay.mjs +465 -34
  2. 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: input.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(input.backend);
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 "${input.backend}" is not available. Use list_available_backends to see available options.`
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: input.backend,
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: input.backend,
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.emit("pre-spawn", hookInput);
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 "${input.backend}" does not support session continuation (continueSession).`
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: input.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
- input.backend,
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: input.backend,
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), or 'systemPrompt' for custom role instructions (all backends).",
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 join8 } from "path";
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
@@ -894,6 +1143,17 @@ var CLAUDE_NESTING_ENV_VARS = [
894
1143
  "CLAUDE_CODE_SSE_PORT",
895
1144
  "CLAUDE_CODE_ENTRYPOINT"
896
1145
  ];
1146
+ var DEFAULT_CLAUDE_SDK_TIMEOUT_MS = 5 * 60 * 1e3;
1147
+ function getClaudeSdkTimeoutMs() {
1148
+ const envVal = process.env["RELAY_CLAUDE_TIMEOUT_MS"];
1149
+ if (envVal) {
1150
+ const parsed = Number(envVal);
1151
+ if (!Number.isNaN(parsed) && parsed > 0) {
1152
+ return parsed;
1153
+ }
1154
+ }
1155
+ return DEFAULT_CLAUDE_SDK_TIMEOUT_MS;
1156
+ }
897
1157
  var ClaudeAdapter = class extends BaseAdapter {
898
1158
  id = "claude";
899
1159
  command = "claude";
@@ -962,11 +1222,16 @@ var ClaudeAdapter = class extends BaseAdapter {
962
1222
  }
963
1223
  const env = this.buildCleanEnv(flags);
964
1224
  const permissionMode = this.getPermissionMode();
1225
+ const timeoutMs = getClaudeSdkTimeoutMs();
1226
+ const abortController = new AbortController();
1227
+ const timer = setTimeout(() => abortController.abort(), timeoutMs);
965
1228
  try {
966
1229
  const { query } = await loadClaudeSDK();
967
1230
  const options = {
1231
+ abortController,
968
1232
  env,
969
1233
  cwd: process.cwd(),
1234
+ mcpServers: {},
970
1235
  ...flags.model ? { model: flags.model } : {},
971
1236
  ...flags.maxTurns ? { maxTurns: flags.maxTurns } : {},
972
1237
  ...flags.systemPrompt ? { systemPrompt: flags.systemPrompt } : {}
@@ -1002,11 +1267,20 @@ var ClaudeAdapter = class extends BaseAdapter {
1002
1267
  ...sessionId ? { nativeSessionId: sessionId } : {}
1003
1268
  };
1004
1269
  } catch (error) {
1270
+ if (abortController.signal.aborted) {
1271
+ return {
1272
+ exitCode: 1,
1273
+ stdout: "",
1274
+ stderr: `Claude SDK query timed out after ${timeoutMs}ms`
1275
+ };
1276
+ }
1005
1277
  return {
1006
1278
  exitCode: 1,
1007
1279
  stdout: "",
1008
1280
  stderr: error instanceof Error ? error.message : String(error)
1009
1281
  };
1282
+ } finally {
1283
+ clearTimeout(timer);
1010
1284
  }
1011
1285
  }
1012
1286
  async *executeStreaming(flags) {
@@ -1015,11 +1289,16 @@ var ClaudeAdapter = class extends BaseAdapter {
1015
1289
  }
1016
1290
  const env = this.buildCleanEnv(flags);
1017
1291
  const permissionMode = this.getPermissionMode();
1292
+ const timeoutMs = getClaudeSdkTimeoutMs();
1293
+ const abortController = new AbortController();
1294
+ const timer = setTimeout(() => abortController.abort(), timeoutMs);
1018
1295
  try {
1019
1296
  const { query } = await loadClaudeSDK();
1020
1297
  const options = {
1298
+ abortController,
1021
1299
  env,
1022
1300
  cwd: process.cwd(),
1301
+ mcpServers: {},
1023
1302
  ...flags.model ? { model: flags.model } : {},
1024
1303
  ...flags.maxTurns ? { maxTurns: flags.maxTurns } : {},
1025
1304
  ...flags.systemPrompt ? { systemPrompt: flags.systemPrompt } : {}
@@ -1079,22 +1358,29 @@ var ClaudeAdapter = class extends BaseAdapter {
1079
1358
  }
1080
1359
  }
1081
1360
  } catch (error) {
1082
- const errorMessage = error instanceof Error ? error.message : String(error);
1361
+ const errorMessage = abortController.signal.aborted ? `Claude SDK query timed out after ${timeoutMs}ms` : error instanceof Error ? error.message : String(error);
1083
1362
  yield { type: "error", message: errorMessage };
1084
1363
  yield {
1085
1364
  type: "done",
1086
1365
  result: { exitCode: 1, stdout: "", stderr: errorMessage }
1087
1366
  };
1367
+ } finally {
1368
+ clearTimeout(timer);
1088
1369
  }
1089
1370
  }
1090
1371
  async continueSession(nativeSessionId, prompt) {
1372
+ const timeoutMs = getClaudeSdkTimeoutMs();
1373
+ const abortController = new AbortController();
1374
+ const timer = setTimeout(() => abortController.abort(), timeoutMs);
1091
1375
  try {
1092
1376
  const { query } = await loadClaudeSDK();
1093
1377
  const permissionMode = this.getPermissionMode();
1094
1378
  const options = {
1379
+ abortController,
1095
1380
  resume: nativeSessionId,
1096
1381
  maxTurns: 1,
1097
- cwd: process.cwd()
1382
+ cwd: process.cwd(),
1383
+ mcpServers: {}
1098
1384
  };
1099
1385
  if (permissionMode === "bypassPermissions") {
1100
1386
  options.permissionMode = "bypassPermissions";
@@ -1118,11 +1404,20 @@ var ClaudeAdapter = class extends BaseAdapter {
1118
1404
  stderr: ""
1119
1405
  };
1120
1406
  } catch (error) {
1407
+ if (abortController.signal.aborted) {
1408
+ return {
1409
+ exitCode: 1,
1410
+ stdout: "",
1411
+ stderr: `Claude SDK query timed out after ${timeoutMs}ms`
1412
+ };
1413
+ }
1121
1414
  return {
1122
1415
  exitCode: 1,
1123
1416
  stdout: "",
1124
1417
  stderr: error instanceof Error ? error.message : String(error)
1125
1418
  };
1419
+ } finally {
1420
+ clearTimeout(timer);
1126
1421
  }
1127
1422
  }
1128
1423
  async resumeSession(sessionId, flags) {
@@ -2031,6 +2326,7 @@ var mcpServerConfigSchema = z.object({
2031
2326
  env: z.record(z.string()).optional()
2032
2327
  });
2033
2328
  var hookEventSchema = z.enum([
2329
+ "session-init",
2034
2330
  "pre-prompt",
2035
2331
  "post-response",
2036
2332
  "on-error",
@@ -2046,8 +2342,19 @@ var hookDefinitionSchema = z.object({
2046
2342
  enabled: z.boolean().optional(),
2047
2343
  onError: z.enum(["ignore", "warn", "abort"]).optional()
2048
2344
  });
2345
+ var hookChainStepSchema = z.object({
2346
+ stepId: z.string(),
2347
+ hook: hookDefinitionSchema,
2348
+ pipeOutput: z.boolean().optional()
2349
+ });
2350
+ var hookChainSchema = z.object({
2351
+ name: z.string(),
2352
+ steps: z.array(hookChainStepSchema).min(1),
2353
+ onError: z.enum(["ignore", "warn", "fail"]).optional()
2354
+ });
2049
2355
  var hooksConfigSchema = z.object({
2050
- definitions: z.array(hookDefinitionSchema)
2356
+ definitions: z.array(hookDefinitionSchema),
2357
+ chains: z.array(hookChainSchema).optional()
2051
2358
  });
2052
2359
  var backendContextConfigSchema = z.object({
2053
2360
  contextWindow: z.number().positive().optional(),
@@ -2345,6 +2652,7 @@ var HooksEngine = class _HooksEngine {
2345
2652
  static COMMAND_PATTERN = /^[a-zA-Z0-9_./-]+$/;
2346
2653
  static ARG_PATTERN = /^[a-zA-Z0-9_.=:/-]+$/;
2347
2654
  definitions = [];
2655
+ chains = [];
2348
2656
  registered = false;
2349
2657
  validateCommand(command) {
2350
2658
  if (!command || command.trim().length === 0) {
@@ -2385,6 +2693,7 @@ var HooksEngine = class _HooksEngine {
2385
2693
  return false;
2386
2694
  }
2387
2695
  });
2696
+ this.chains = config.chains ?? [];
2388
2697
  if (!this.registered) {
2389
2698
  for (const event of this.getUniqueEvents()) {
2390
2699
  this.eventBus.on(event, async () => {
@@ -2426,10 +2735,132 @@ var HooksEngine = class _HooksEngine {
2426
2735
  await this.eventBus.emit(event, input);
2427
2736
  return results;
2428
2737
  }
2738
+ /**
2739
+ * Emit a hook event, execute all matching hooks, and return merged metadata.
2740
+ * Merge rule: later hooks overwrite earlier hooks' metadata fields.
2741
+ * If any hook returns allow: false, subsequent hooks are skipped.
2742
+ */
2743
+ async emitAndCollectMetadata(event, hookInput) {
2744
+ const matchingDefs = this.definitions.filter((def) => def.event === event);
2745
+ const results = [];
2746
+ let mergedMetadata = {};
2747
+ for (const def of matchingDefs) {
2748
+ try {
2749
+ const result = await this.executeHook(def, hookInput);
2750
+ results.push(result);
2751
+ if (result.output.metadata && typeof result.output.metadata === "object") {
2752
+ mergedMetadata = { ...mergedMetadata, ...result.output.metadata };
2753
+ }
2754
+ if (!result.output.allow) {
2755
+ logger.info(
2756
+ `Hook "${def.command}" returned allow: false, skipping subsequent hooks for event "${event}"`
2757
+ );
2758
+ break;
2759
+ }
2760
+ } catch (error) {
2761
+ const strategy = def.onError ?? "warn";
2762
+ const message = error instanceof Error ? error.message : String(error);
2763
+ if (strategy === "abort") {
2764
+ throw new Error(
2765
+ `Hook "${def.command}" failed (abort): ${message}`
2766
+ );
2767
+ }
2768
+ if (strategy === "warn") {
2769
+ logger.warn(
2770
+ `Hook "${def.command}" failed (warn): ${message}`
2771
+ );
2772
+ }
2773
+ results.push({
2774
+ exitCode: 1,
2775
+ stdout: "",
2776
+ stderr: message,
2777
+ durationMs: 0,
2778
+ output: { ...DEFAULT_HOOK_OUTPUT }
2779
+ });
2780
+ }
2781
+ }
2782
+ await this.eventBus.emit(event, hookInput);
2783
+ return { results, mergedMetadata };
2784
+ }
2429
2785
  /** Get the count of enabled definitions for a given event */
2430
2786
  getDefinitionCount(event) {
2431
2787
  return this.definitions.filter((def) => def.event === event).length;
2432
2788
  }
2789
+ /** Get all loaded chain names */
2790
+ getChainNames() {
2791
+ return this.chains.map((c) => c.name);
2792
+ }
2793
+ /**
2794
+ * Execute a named hook chain.
2795
+ * Steps execute in sequence. If pipeOutput is true, previous step's
2796
+ * HookOutput is merged into the next step's HookInput.data.
2797
+ */
2798
+ async executeChain(chainName, initialInput) {
2799
+ const chain = this.chains.find((c) => c.name === chainName);
2800
+ if (!chain) {
2801
+ throw new Error(`Hook chain "${chainName}" not found`);
2802
+ }
2803
+ const errorStrategy = chain.onError ?? "warn";
2804
+ const stepResults = [];
2805
+ let mergedMetadata = {};
2806
+ let previousOutput;
2807
+ let overallSuccess = true;
2808
+ for (const step of chain.steps) {
2809
+ const startTime = Date.now();
2810
+ let stepInput;
2811
+ if (step.pipeOutput && previousOutput) {
2812
+ stepInput = {
2813
+ ...initialInput,
2814
+ data: {
2815
+ ...initialInput.data,
2816
+ ...previousOutput.metadata,
2817
+ _previousMessage: previousOutput.message,
2818
+ _previousAllow: previousOutput.allow
2819
+ }
2820
+ };
2821
+ } else {
2822
+ stepInput = { ...initialInput };
2823
+ }
2824
+ try {
2825
+ const result = await this.executeHook(step.hook, stepInput);
2826
+ const durationMs = Date.now() - startTime;
2827
+ stepResults.push({
2828
+ stepId: step.stepId,
2829
+ success: true,
2830
+ output: result.output,
2831
+ durationMs
2832
+ });
2833
+ if (result.output.metadata && typeof result.output.metadata === "object") {
2834
+ mergedMetadata = { ...mergedMetadata, ...result.output.metadata };
2835
+ }
2836
+ previousOutput = result.output;
2837
+ } catch (error) {
2838
+ const durationMs = Date.now() - startTime;
2839
+ const message = error instanceof Error ? error.message : String(error);
2840
+ stepResults.push({
2841
+ stepId: step.stepId,
2842
+ success: false,
2843
+ error: message,
2844
+ durationMs
2845
+ });
2846
+ overallSuccess = false;
2847
+ if (errorStrategy === "fail") {
2848
+ break;
2849
+ }
2850
+ if (errorStrategy === "warn") {
2851
+ logger.warn(
2852
+ `Hook chain "${chainName}" step "${step.stepId}" failed (warn): ${message}`
2853
+ );
2854
+ }
2855
+ }
2856
+ }
2857
+ return {
2858
+ chainName,
2859
+ stepResults,
2860
+ success: overallSuccess,
2861
+ mergedMetadata
2862
+ };
2863
+ }
2433
2864
  async executeHook(def, input) {
2434
2865
  this.validateCommand(def.command);
2435
2866
  this.validateArgs(def.args ?? []);
@@ -3387,7 +3818,7 @@ function createVersionCommand(registry2) {
3387
3818
  // src/commands/doctor.ts
3388
3819
  import { defineCommand as defineCommand8 } from "citty";
3389
3820
  import { access, constants, readdir as readdir2 } from "fs/promises";
3390
- import { join as join6 } from "path";
3821
+ import { join as join7 } from "path";
3391
3822
  import { homedir as homedir5 } from "os";
3392
3823
  import { execFile } from "child_process";
3393
3824
  import { promisify } from "util";
@@ -3448,8 +3879,8 @@ async function checkConfig(configManager2) {
3448
3879
  }
3449
3880
  }
3450
3881
  async function checkSessionsDir() {
3451
- const relayHome2 = process.env["RELAY_HOME"] ?? join6(homedir5(), ".relay");
3452
- const sessionsDir = join6(relayHome2, "sessions");
3882
+ const relayHome2 = process.env["RELAY_HOME"] ?? join7(homedir5(), ".relay");
3883
+ const sessionsDir = join7(relayHome2, "sessions");
3453
3884
  try {
3454
3885
  await access(sessionsDir, constants.W_OK);
3455
3886
  return {
@@ -3562,8 +3993,8 @@ async function checkBackendAuthEnv() {
3562
3993
  return results;
3563
3994
  }
3564
3995
  async function checkSessionsDiskUsage() {
3565
- const relayHome2 = process.env["RELAY_HOME"] ?? join6(homedir5(), ".relay");
3566
- const sessionsDir = join6(relayHome2, "sessions");
3996
+ const relayHome2 = process.env["RELAY_HOME"] ?? join7(homedir5(), ".relay");
3997
+ const sessionsDir = join7(relayHome2, "sessions");
3567
3998
  try {
3568
3999
  const entries = await readdir2(sessionsDir);
3569
4000
  const fileCount = entries.length;
@@ -3637,7 +4068,7 @@ function createDoctorCommand(registry2, configManager2) {
3637
4068
  init_logger();
3638
4069
  import { defineCommand as defineCommand9 } from "citty";
3639
4070
  import { mkdir as mkdir6, writeFile as writeFile6, access as access2, readFile as readFile6 } from "fs/promises";
3640
- import { join as join7 } from "path";
4071
+ import { join as join8 } from "path";
3641
4072
  var DEFAULT_CONFIG2 = {
3642
4073
  defaultBackend: "claude",
3643
4074
  backends: {},
@@ -3651,8 +4082,8 @@ function createInitCommand() {
3651
4082
  },
3652
4083
  async run() {
3653
4084
  const projectDir = process.cwd();
3654
- const relayDir = join7(projectDir, ".relay");
3655
- const configPath = join7(relayDir, "config.json");
4085
+ const relayDir = join8(projectDir, ".relay");
4086
+ const configPath = join8(relayDir, "config.json");
3656
4087
  try {
3657
4088
  await access2(relayDir);
3658
4089
  logger.info(
@@ -3668,7 +4099,7 @@ function createInitCommand() {
3668
4099
  "utf-8"
3669
4100
  );
3670
4101
  logger.success(`Created ${configPath}`);
3671
- const gitignorePath = join7(projectDir, ".gitignore");
4102
+ const gitignorePath = join8(projectDir, ".gitignore");
3672
4103
  try {
3673
4104
  const gitignoreContent = await readFile6(gitignorePath, "utf-8");
3674
4105
  if (!gitignoreContent.includes(".relay/config.local.json")) {
@@ -3693,8 +4124,8 @@ registry.registerLazy("claude", () => new ClaudeAdapter(processManager));
3693
4124
  registry.registerLazy("codex", () => new CodexAdapter(processManager));
3694
4125
  registry.registerLazy("gemini", () => new GeminiAdapter(processManager));
3695
4126
  var sessionManager = new SessionManager();
3696
- var relayHome = process.env["RELAY_HOME"] ?? join8(homedir6(), ".relay");
3697
- var projectRelayDir = join8(process.cwd(), ".relay");
4127
+ var relayHome = process.env["RELAY_HOME"] ?? join9(homedir6(), ".relay");
4128
+ var projectRelayDir = join9(process.cwd(), ".relay");
3698
4129
  var configManager = new ConfigManager(relayHome, projectRelayDir);
3699
4130
  var authManager = new AuthManager(registry);
3700
4131
  var eventBus = new EventBus();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rk0429/agentic-relay",
3
- "version": "0.5.0",
3
+ "version": "0.6.1",
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",