@rk0429/agentic-relay 1.2.0 → 1.3.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 +216 -309
  2. package/package.json +1 -1
package/dist/relay.mjs CHANGED
@@ -46,6 +46,120 @@ var init_logger = __esm({
46
46
  }
47
47
  });
48
48
 
49
+ // src/core/metadata-validation.ts
50
+ function isPlainObject(value) {
51
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
52
+ return false;
53
+ }
54
+ const prototype = Object.getPrototypeOf(value);
55
+ return prototype === Object.prototype || prototype === null;
56
+ }
57
+ function utf8Size(value) {
58
+ return new TextEncoder().encode(value).length;
59
+ }
60
+ function validateMetadataKey(key) {
61
+ if (DANGEROUS_METADATA_KEYS.has(key)) {
62
+ throw new Error(`metadata key "${key}" is not allowed`);
63
+ }
64
+ if (key.length > MAX_METADATA_KEY_LENGTH) {
65
+ throw new Error(
66
+ `metadata key "${key}" exceeds ${MAX_METADATA_KEY_LENGTH} chars`
67
+ );
68
+ }
69
+ }
70
+ function validateMetadataValue(value, path2, depth) {
71
+ if (depth > MAX_METADATA_NESTING_DEPTH) {
72
+ throw new Error(
73
+ `metadata nesting depth exceeds ${MAX_METADATA_NESTING_DEPTH} at "${path2}"`
74
+ );
75
+ }
76
+ if (typeof value === "string") {
77
+ if (value.length > MAX_METADATA_STRING_VALUE_LENGTH) {
78
+ throw new Error(
79
+ `metadata string at "${path2}" exceeds ${MAX_METADATA_STRING_VALUE_LENGTH} chars`
80
+ );
81
+ }
82
+ return;
83
+ }
84
+ if (value === null || value === void 0 || typeof value === "number" || typeof value === "boolean") {
85
+ return;
86
+ }
87
+ if (Array.isArray(value)) {
88
+ for (let index = 0; index < value.length; index += 1) {
89
+ validateMetadataValue(value[index], `${path2}[${index}]`, depth + 1);
90
+ }
91
+ return;
92
+ }
93
+ if (typeof value === "object") {
94
+ if (!isPlainObject(value)) {
95
+ throw new Error(`metadata value at "${path2}" must be a plain object`);
96
+ }
97
+ for (const [key, nestedValue] of Object.entries(value)) {
98
+ validateMetadataKey(key);
99
+ validateMetadataValue(nestedValue, `${path2}.${key}`, depth + 1);
100
+ }
101
+ return;
102
+ }
103
+ throw new Error(`metadata value at "${path2}" has unsupported type`);
104
+ }
105
+ function validateMetadata(raw) {
106
+ if (raw === void 0 || raw === null) {
107
+ return {};
108
+ }
109
+ if (!isPlainObject(raw)) {
110
+ throw new Error("metadata must be a plain object");
111
+ }
112
+ let serialized;
113
+ try {
114
+ serialized = JSON.stringify(raw);
115
+ } catch {
116
+ throw new Error("metadata must be JSON-serializable");
117
+ }
118
+ if (utf8Size(serialized) > MAX_METADATA_SIZE_BYTES) {
119
+ throw new Error(`metadata exceeds ${MAX_METADATA_SIZE_BYTES} bytes`);
120
+ }
121
+ const entries = Object.entries(raw);
122
+ if (entries.length > MAX_METADATA_KEY_COUNT) {
123
+ throw new Error(`metadata has ${entries.length} keys, max is ${MAX_METADATA_KEY_COUNT}`);
124
+ }
125
+ for (const [key, value] of entries) {
126
+ validateMetadataKey(key);
127
+ validateMetadataValue(value, key, 1);
128
+ }
129
+ const typed = raw;
130
+ if (typed.taskId !== void 0 && typeof typed.taskId !== "string") {
131
+ throw new Error("metadata.taskId must be a string");
132
+ }
133
+ if (typed.taskId !== void 0 && typeof typed.taskId === "string" && !TASK_ID_PATTERN.test(typed.taskId)) {
134
+ throw new Error("metadata.taskId must match ^(TASK|GOAL)-\\d{3,}$");
135
+ }
136
+ if (typed.agentType !== void 0 && typeof typed.agentType !== "string") {
137
+ throw new Error("metadata.agentType must be a string");
138
+ }
139
+ if (typed.label !== void 0 && typeof typed.label !== "string") {
140
+ throw new Error("metadata.label must be a string");
141
+ }
142
+ if (typed.tags !== void 0) {
143
+ if (!Array.isArray(typed.tags) || !typed.tags.every((tag) => typeof tag === "string")) {
144
+ throw new Error("metadata.tags must be string[]");
145
+ }
146
+ }
147
+ return typed;
148
+ }
149
+ var DANGEROUS_METADATA_KEYS, TASK_ID_PATTERN, MAX_METADATA_SIZE_BYTES, MAX_METADATA_KEY_COUNT, MAX_METADATA_KEY_LENGTH, MAX_METADATA_STRING_VALUE_LENGTH, MAX_METADATA_NESTING_DEPTH;
150
+ var init_metadata_validation = __esm({
151
+ "src/core/metadata-validation.ts"() {
152
+ "use strict";
153
+ DANGEROUS_METADATA_KEYS = /* @__PURE__ */ new Set(["__proto__", "constructor", "prototype"]);
154
+ TASK_ID_PATTERN = /^(TASK|GOAL)-\d{3,}$/;
155
+ MAX_METADATA_SIZE_BYTES = 8 * 1024;
156
+ MAX_METADATA_KEY_COUNT = 20;
157
+ MAX_METADATA_KEY_LENGTH = 64;
158
+ MAX_METADATA_STRING_VALUE_LENGTH = 1024;
159
+ MAX_METADATA_NESTING_DEPTH = 3;
160
+ }
161
+ });
162
+
49
163
  // src/mcp-server/deferred-cleanup-task-store.ts
50
164
  import { isTerminal } from "@modelcontextprotocol/sdk/experimental/tasks/interfaces.js";
51
165
  import { randomBytes } from "crypto";
@@ -856,116 +970,16 @@ function buildChildMcpServers(parentMcpServers, childHttpUrl) {
856
970
  }
857
971
  return result;
858
972
  }
859
- function isPlainObject2(value) {
860
- if (typeof value !== "object" || value === null || Array.isArray(value)) {
861
- return false;
862
- }
863
- const prototype = Object.getPrototypeOf(value);
864
- return prototype === Object.prototype || prototype === null;
865
- }
866
- function utf8Size(value) {
867
- return new TextEncoder().encode(value).length;
868
- }
869
- function validateMetadataKey(key) {
870
- if (DANGEROUS_METADATA_KEYS.has(key)) {
871
- throw new Error(`metadata key "${key}" is not allowed`);
872
- }
873
- if (key.length > MAX_METADATA_KEY_LENGTH) {
874
- throw new Error(
875
- `metadata key "${key}" exceeds ${MAX_METADATA_KEY_LENGTH} chars`
876
- );
877
- }
878
- }
879
- function validateMetadataValue(value, path2, depth) {
880
- if (depth > MAX_METADATA_NESTING_DEPTH) {
881
- throw new Error(
882
- `metadata nesting depth exceeds ${MAX_METADATA_NESTING_DEPTH} at "${path2}"`
883
- );
884
- }
885
- if (typeof value === "string") {
886
- if (value.length > MAX_METADATA_STRING_VALUE_LENGTH) {
887
- throw new Error(
888
- `metadata string at "${path2}" exceeds ${MAX_METADATA_STRING_VALUE_LENGTH} chars`
889
- );
890
- }
891
- return;
892
- }
893
- if (value === null || value === void 0 || typeof value === "number" || typeof value === "boolean") {
894
- return;
895
- }
896
- if (Array.isArray(value)) {
897
- for (let index = 0; index < value.length; index += 1) {
898
- validateMetadataValue(value[index], `${path2}[${index}]`, depth + 1);
899
- }
900
- return;
901
- }
902
- if (typeof value === "object") {
903
- if (!isPlainObject2(value)) {
904
- throw new Error(`metadata value at "${path2}" must be a plain object`);
905
- }
906
- for (const [key, nestedValue] of Object.entries(value)) {
907
- validateMetadataKey(key);
908
- validateMetadataValue(nestedValue, `${path2}.${key}`, depth + 1);
909
- }
910
- return;
911
- }
912
- throw new Error(`metadata value at "${path2}" has unsupported type`);
913
- }
914
- function validateMetadata(raw) {
915
- if (raw === void 0 || raw === null) {
916
- return {};
917
- }
918
- if (!isPlainObject2(raw)) {
919
- throw new Error("metadata must be a plain object");
920
- }
921
- let serialized;
922
- try {
923
- serialized = JSON.stringify(raw);
924
- } catch {
925
- throw new Error("metadata must be JSON-serializable");
973
+ function resolveValidatedSessionMetadata(input) {
974
+ const validated = validateMetadata(input.metadata);
975
+ const mergedMetadata = { ...validated };
976
+ if (mergedMetadata.agentType === void 0 && input.agent !== void 0) {
977
+ mergedMetadata.agentType = input.agent;
926
978
  }
927
- if (utf8Size(serialized) > MAX_METADATA_SIZE_BYTES) {
928
- throw new Error(`metadata exceeds ${MAX_METADATA_SIZE_BYTES} bytes`);
929
- }
930
- const entries = Object.entries(raw);
931
- if (entries.length > MAX_METADATA_KEY_COUNT) {
932
- throw new Error(`metadata has ${entries.length} keys, max is ${MAX_METADATA_KEY_COUNT}`);
933
- }
934
- for (const [key, value] of entries) {
935
- validateMetadataKey(key);
936
- validateMetadataValue(value, key, 1);
937
- }
938
- const typed = raw;
939
- if (typed.taskId !== void 0 && typeof typed.taskId !== "string") {
940
- throw new Error("metadata.taskId must be a string");
979
+ if (mergedMetadata.label === void 0 && input.label !== void 0) {
980
+ mergedMetadata.label = input.label;
941
981
  }
942
- if (typed.taskId !== void 0 && typeof typed.taskId === "string" && !TASK_ID_PATTERN.test(typed.taskId)) {
943
- throw new Error("metadata.taskId must match ^(TASK|GOAL)-\\d{3,}$");
944
- }
945
- if (typed.agentType !== void 0 && typeof typed.agentType !== "string") {
946
- throw new Error("metadata.agentType must be a string");
947
- }
948
- if (typed.label !== void 0 && typeof typed.label !== "string") {
949
- throw new Error("metadata.label must be a string");
950
- }
951
- if (typed.tags !== void 0) {
952
- if (!Array.isArray(typed.tags) || !typed.tags.every((tag) => typeof tag === "string")) {
953
- throw new Error("metadata.tags must be string[]");
954
- }
955
- }
956
- return typed;
957
- }
958
- function getLastActivityAtMs(session) {
959
- return Math.max(
960
- session.lastHeartbeatAt.getTime(),
961
- session.updatedAt.getTime()
962
- );
963
- }
964
- function isActiveSessionStale(session, staleThresholdMs, now) {
965
- if (session.status !== "active") {
966
- return false;
967
- }
968
- return getLastActivityAtMs(session) + staleThresholdMs < now;
982
+ return validateMetadata(mergedMetadata);
969
983
  }
970
984
  function inferFailureReason(stderr, stdout, sdkErrorMetadata) {
971
985
  if (sdkErrorMetadata) {
@@ -984,10 +998,6 @@ function failureReasonToErrorCode(reason) {
984
998
  return "RELAY_RECURSION_BLOCKED";
985
999
  case "metadata_validation":
986
1000
  return "RELAY_METADATA_VALIDATION";
987
- case "max_sessions_exceeded":
988
- return "RELAY_MAX_SESSIONS_EXCEEDED";
989
- case "concurrent_limit_race":
990
- return "RELAY_CONCURRENT_LIMIT_RACE";
991
1001
  case "backend_unavailable":
992
1002
  return "RELAY_BACKEND_UNAVAILABLE";
993
1003
  case "instruction_file_error":
@@ -1017,7 +1027,7 @@ function buildContextFromEnv() {
1017
1027
  const depth = Number(process.env["RELAY_DEPTH"] ?? "0");
1018
1028
  return { traceId, parentSessionId, depth };
1019
1029
  }
1020
- async function executeSpawnAgent(input, registry2, sessionManager2, guard, hooksEngine2, contextMonitor2, backendSelector, childHttpUrl, onProgress, agentEventStore, hookMemoryDir = "./memory", sessionHealthConfig) {
1030
+ async function executeSpawnAgent(input, registry2, sessionManager2, guard, hooksEngine2, contextMonitor2, backendSelector, childHttpUrl, onProgress, agentEventStore, hookMemoryDir = "./memory") {
1021
1031
  onProgress?.({ stage: "initializing", percent: 0 });
1022
1032
  let effectiveBackend;
1023
1033
  let selectionReason;
@@ -1073,15 +1083,7 @@ async function executeSpawnAgent(input, registry2, sessionManager2, guard, hooks
1073
1083
  const spawnStartedAt = (/* @__PURE__ */ new Date()).toISOString();
1074
1084
  let validatedMetadata;
1075
1085
  try {
1076
- validatedMetadata = validateMetadata(input.metadata);
1077
- const mergedMetadata = { ...validatedMetadata };
1078
- if (mergedMetadata.agentType === void 0 && input.agent !== void 0) {
1079
- mergedMetadata.agentType = input.agent;
1080
- }
1081
- if (mergedMetadata.label === void 0 && input.label !== void 0) {
1082
- mergedMetadata.label = input.label;
1083
- }
1084
- validatedMetadata = validateMetadata(mergedMetadata);
1086
+ validatedMetadata = resolveValidatedSessionMetadata(input);
1085
1087
  } catch (error) {
1086
1088
  const message = error instanceof Error ? error.message : String(error);
1087
1089
  return {
@@ -1092,79 +1094,22 @@ async function executeSpawnAgent(input, registry2, sessionManager2, guard, hooks
1092
1094
  failureReason: "metadata_validation"
1093
1095
  };
1094
1096
  }
1095
- const maxActiveSessions = Math.max(
1096
- 1,
1097
- sessionHealthConfig?.maxActiveSessions ?? DEFAULT_MAX_ACTIVE_SESSIONS
1098
- );
1099
- const staleThresholdMs = Math.max(
1100
- 1,
1101
- sessionHealthConfig?.staleThresholdMs ?? DEFAULT_STALE_THRESHOLD_MS
1102
- );
1103
- const warnThreshold = Math.ceil(maxActiveSessions * 0.8);
1104
- const maxCreateAttempts = 3;
1105
- let session = null;
1106
- let lastCreateError = "";
1107
- for (let attempt = 0; attempt < maxCreateAttempts; attempt += 1) {
1108
- const activeSessions = await sessionManager2.list({ status: "active" });
1109
- const now = Date.now();
1110
- const activeHealthyCount = activeSessions.filter(
1111
- (activeSession) => !isActiveSessionStale(activeSession, staleThresholdMs, now)
1112
- ).length;
1113
- if (activeHealthyCount >= maxActiveSessions) {
1114
- return {
1115
- sessionId: "",
1116
- exitCode: 1,
1117
- stdout: "",
1118
- stderr: `RELAY_MAX_SESSIONS_EXCEEDED: active sessions ${activeHealthyCount}/${maxActiveSessions}`,
1119
- failureReason: "max_sessions_exceeded"
1120
- };
1121
- }
1122
- if (activeHealthyCount >= warnThreshold) {
1123
- logger.warn(
1124
- `Active session usage high during spawn: ${activeHealthyCount}/${maxActiveSessions}`
1125
- );
1126
- }
1127
- try {
1128
- session = await sessionManager2.create({
1129
- backendId: effectiveBackend,
1130
- parentSessionId: envContext.parentSessionId ?? void 0,
1131
- depth: envContext.depth + 1,
1132
- metadata: validatedMetadata,
1133
- expectedActiveCount: activeHealthyCount,
1134
- expectedActiveStaleThresholdMs: staleThresholdMs
1135
- });
1136
- break;
1137
- } catch (error) {
1138
- const message = error instanceof Error ? error.message : String(error);
1139
- lastCreateError = message;
1140
- if (message.includes("RELAY_CONCURRENT_LIMIT_RACE") && attempt < maxCreateAttempts - 1) {
1141
- continue;
1142
- }
1143
- if (message.includes("RELAY_CONCURRENT_LIMIT_RACE")) {
1144
- return {
1145
- sessionId: "",
1146
- exitCode: 1,
1147
- stdout: "",
1148
- stderr: message,
1149
- failureReason: "concurrent_limit_race"
1150
- };
1151
- }
1152
- return {
1153
- sessionId: "",
1154
- exitCode: 1,
1155
- stdout: "",
1156
- stderr: message,
1157
- failureReason: "unknown"
1158
- };
1159
- }
1160
- }
1161
- if (!session) {
1097
+ let session;
1098
+ try {
1099
+ session = await sessionManager2.create({
1100
+ backendId: effectiveBackend,
1101
+ parentSessionId: envContext.parentSessionId ?? void 0,
1102
+ depth: envContext.depth + 1,
1103
+ metadata: validatedMetadata
1104
+ });
1105
+ } catch (error) {
1106
+ const message = error instanceof Error ? error.message : String(error);
1162
1107
  return {
1163
1108
  sessionId: "",
1164
1109
  exitCode: 1,
1165
1110
  stdout: "",
1166
- stderr: lastCreateError || "RELAY_CONCURRENT_LIMIT_RACE: failed to create session",
1167
- failureReason: "concurrent_limit_race"
1111
+ stderr: message,
1112
+ failureReason: "unknown"
1168
1113
  };
1169
1114
  }
1170
1115
  agentEventStore?.record({
@@ -1626,21 +1571,13 @@ ${input.prompt}`;
1626
1571
  };
1627
1572
  }
1628
1573
  }
1629
- var DANGEROUS_METADATA_KEYS, TASK_ID_PATTERN, MAX_METADATA_SIZE_BYTES, MAX_METADATA_KEY_COUNT, MAX_METADATA_KEY_LENGTH, MAX_METADATA_STRING_VALUE_LENGTH, MAX_METADATA_NESTING_DEPTH, DEFAULT_MAX_ACTIVE_SESSIONS, DEFAULT_STALE_THRESHOLD_MS, spawnAgentInputSchema;
1574
+ var spawnAgentInputSchema;
1630
1575
  var init_spawn_agent = __esm({
1631
1576
  "src/mcp-server/tools/spawn-agent.ts"() {
1632
1577
  "use strict";
1633
1578
  init_recursion_guard();
1634
1579
  init_logger();
1635
- DANGEROUS_METADATA_KEYS = /* @__PURE__ */ new Set(["__proto__", "constructor", "prototype"]);
1636
- TASK_ID_PATTERN = /^(TASK|GOAL)-\d{3,}$/;
1637
- MAX_METADATA_SIZE_BYTES = 8 * 1024;
1638
- MAX_METADATA_KEY_COUNT = 20;
1639
- MAX_METADATA_KEY_LENGTH = 64;
1640
- MAX_METADATA_STRING_VALUE_LENGTH = 1024;
1641
- MAX_METADATA_NESTING_DEPTH = 3;
1642
- DEFAULT_MAX_ACTIVE_SESSIONS = 20;
1643
- DEFAULT_STALE_THRESHOLD_MS = 3e5;
1580
+ init_metadata_validation();
1644
1581
  spawnAgentInputSchema = z2.object({
1645
1582
  fallbackBackend: z2.enum(["claude", "codex", "gemini"]).optional().describe(
1646
1583
  "Optional fallback backend. Used only when BackendSelector is not active or cannot determine a backend. When BackendSelector is active, backend is auto-selected by priority: preferredBackend > agentType mapping > taskType mapping > default (claude)."
@@ -1780,7 +1717,31 @@ var init_conflict_detector = __esm({
1780
1717
  });
1781
1718
 
1782
1719
  // src/mcp-server/tools/spawn-agents-parallel.ts
1783
- async function executeSpawnAgentsParallel(agents, registry2, sessionManager2, guard, hooksEngine2, contextMonitor2, backendSelector, childHttpUrl, onProgress, agentEventStore, hookMemoryDir = "./memory", sessionHealthConfig) {
1720
+ async function executeSpawnAgentsParallel(agents, registry2, sessionManager2, guard, hooksEngine2, contextMonitor2, backendSelector, childHttpUrl, onProgress, agentEventStore, hookMemoryDir = "./memory") {
1721
+ for (let index = 0; index < agents.length; index += 1) {
1722
+ try {
1723
+ resolveValidatedSessionMetadata(agents[index]);
1724
+ } catch (error) {
1725
+ const message = error instanceof Error ? error.message : String(error);
1726
+ const reason = `RELAY_METADATA_VALIDATION: agent index ${index}: ${message}`;
1727
+ return {
1728
+ results: agents.map((agent, resultIndex) => ({
1729
+ index: resultIndex,
1730
+ sessionId: "",
1731
+ exitCode: 1,
1732
+ stdout: "",
1733
+ stderr: reason,
1734
+ error: reason,
1735
+ failureReason: "metadata_validation",
1736
+ ...agent.label ? { label: agent.label } : {},
1737
+ originalInput: agent
1738
+ })),
1739
+ totalCount: agents.length,
1740
+ successCount: 0,
1741
+ failureCount: agents.length
1742
+ };
1743
+ }
1744
+ }
1784
1745
  const envContext = buildContextFromEnv();
1785
1746
  if (envContext.depth >= guard.getConfig().maxDepth) {
1786
1747
  const reason = `Max depth exceeded: ${envContext.depth} >= ${guard.getConfig().maxDepth}`;
@@ -1839,8 +1800,7 @@ async function executeSpawnAgentsParallel(agents, registry2, sessionManager2, gu
1839
1800
  childHttpUrl,
1840
1801
  void 0,
1841
1802
  agentEventStore,
1842
- hookMemoryDir,
1843
- sessionHealthConfig
1803
+ hookMemoryDir
1844
1804
  ).then((result) => {
1845
1805
  completedCount++;
1846
1806
  onProgress?.({
@@ -1911,21 +1871,22 @@ var init_spawn_agents_parallel = __esm({
1911
1871
  // src/mcp-server/tools/list-sessions.ts
1912
1872
  import { z as z3 } from "zod";
1913
1873
  async function executeListSessions(input, sessionManager2, options) {
1914
- const normalizedBackendId = input.backendId ?? input.backend;
1915
- if (input.backendId && input.backend && input.backendId !== input.backend) {
1874
+ const validatedInput = listSessionsInputSchema.parse(input);
1875
+ const normalizedBackendId = validatedInput.backendId ?? validatedInput.backend;
1876
+ if (validatedInput.backendId && validatedInput.backend && validatedInput.backendId !== validatedInput.backend) {
1916
1877
  logger.warn(
1917
- `list_sessions: both backendId and backend were provided; backendId="${input.backendId}" is used`
1878
+ `list_sessions: both backendId and backend were provided; backendId="${validatedInput.backendId}" is used`
1918
1879
  );
1919
1880
  }
1920
1881
  const staleThresholdMs = (options?.staleThresholdSec ?? 300) * 1e3;
1921
1882
  const sessions = await sessionManager2.list({
1922
1883
  backendId: normalizedBackendId,
1923
- limit: input.limit,
1924
- status: input.staleOnly ? "active" : input.status,
1925
- taskId: input.taskId,
1926
- label: input.label,
1927
- tags: input.tags,
1928
- staleOnly: input.staleOnly ?? false,
1884
+ limit: validatedInput.limit,
1885
+ status: validatedInput.staleOnly ? "active" : validatedInput.status,
1886
+ taskId: validatedInput.taskId,
1887
+ label: validatedInput.label,
1888
+ tags: validatedInput.tags,
1889
+ staleOnly: validatedInput.staleOnly ?? false,
1929
1890
  staleThresholdMs
1930
1891
  });
1931
1892
  return {
@@ -1946,12 +1907,13 @@ var init_list_sessions = __esm({
1946
1907
  "src/mcp-server/tools/list-sessions.ts"() {
1947
1908
  "use strict";
1948
1909
  init_logger();
1910
+ init_metadata_validation();
1949
1911
  listSessionsInputSchema = z3.object({
1950
1912
  backendId: z3.enum(["claude", "codex", "gemini"]).optional(),
1951
1913
  backend: z3.enum(["claude", "codex", "gemini"]).optional(),
1952
1914
  limit: z3.number().int().min(1).max(100).optional().default(10),
1953
1915
  status: z3.enum(["active", "completed", "error"]).optional(),
1954
- taskId: z3.string().optional(),
1916
+ taskId: z3.string().regex(TASK_ID_PATTERN).optional(),
1955
1917
  label: z3.string().optional(),
1956
1918
  tags: z3.array(z3.string()).optional(),
1957
1919
  staleOnly: z3.boolean().optional().default(false)
@@ -2351,6 +2313,7 @@ var init_server = __esm({
2351
2313
  init_get_context_status();
2352
2314
  init_list_available_backends();
2353
2315
  init_backend_selector();
2316
+ init_metadata_validation();
2354
2317
  init_logger();
2355
2318
  init_types();
2356
2319
  init_response_formatter();
@@ -2372,7 +2335,6 @@ var init_server = __esm({
2372
2335
  this.guard = new RecursionGuard(guardConfig);
2373
2336
  this.backendSelector = new BackendSelector();
2374
2337
  this.staleThresholdSec = relayConfig?.sessionHealth?.staleThresholdSec ?? 300;
2375
- this.maxActiveSessions = relayConfig?.sessionHealth?.maxActiveSessions ?? 20;
2376
2338
  this.hookMemoryDir = relayConfig?.hooks?.memoryDir ?? "./memory";
2377
2339
  this.agentEventStore = new AgentEventStore({
2378
2340
  backend: relayConfig?.eventStore?.backend,
@@ -2400,7 +2362,7 @@ var init_server = __esm({
2400
2362
  this.agentEventStore
2401
2363
  );
2402
2364
  this.server = new McpServer(
2403
- { name: "agentic-relay", version: "1.2.0" },
2365
+ { name: "agentic-relay", version: "1.3.1" },
2404
2366
  createMcpServerOptions()
2405
2367
  );
2406
2368
  this.registerTools(this.server);
@@ -2412,7 +2374,6 @@ var init_server = __esm({
2412
2374
  agentEventStore;
2413
2375
  sessionHealthMonitor;
2414
2376
  staleThresholdSec;
2415
- maxActiveSessions;
2416
2377
  hookMemoryDir;
2417
2378
  _childHttpServer;
2418
2379
  _childHttpUrl;
@@ -2442,11 +2403,7 @@ var init_server = __esm({
2442
2403
  this._childHttpUrl,
2443
2404
  void 0,
2444
2405
  this.agentEventStore,
2445
- this.hookMemoryDir,
2446
- {
2447
- maxActiveSessions: this.maxActiveSessions,
2448
- staleThresholdMs: this.staleThresholdSec * 1e3
2449
- }
2406
+ this.hookMemoryDir
2450
2407
  );
2451
2408
  const controlOptions = {
2452
2409
  inlineSummaryLength: this.inlineSummaryLength ?? DEFAULT_INLINE_SUMMARY_LENGTH,
@@ -2501,11 +2458,7 @@ var init_server = __esm({
2501
2458
  this._childHttpUrl,
2502
2459
  void 0,
2503
2460
  this.agentEventStore,
2504
- this.hookMemoryDir,
2505
- {
2506
- maxActiveSessions: this.maxActiveSessions,
2507
- staleThresholdMs: this.staleThresholdSec * 1e3
2508
- }
2461
+ this.hookMemoryDir
2509
2462
  );
2510
2463
  const controlOptions = {
2511
2464
  inlineSummaryLength: this.inlineSummaryLength ?? DEFAULT_INLINE_SUMMARY_LENGTH,
@@ -2626,11 +2579,7 @@ var init_server = __esm({
2626
2579
  this._childHttpUrl,
2627
2580
  void 0,
2628
2581
  this.agentEventStore,
2629
- this.hookMemoryDir,
2630
- {
2631
- maxActiveSessions: this.maxActiveSessions,
2632
- staleThresholdMs: this.staleThresholdSec * 1e3
2633
- }
2582
+ this.hookMemoryDir
2634
2583
  );
2635
2584
  const controlOptions = {
2636
2585
  inlineSummaryLength: this.inlineSummaryLength ?? DEFAULT_INLINE_SUMMARY_LENGTH,
@@ -2658,7 +2607,7 @@ var init_server = __esm({
2658
2607
  backend: z7.enum(["claude", "codex", "gemini"]).optional().describe("Filter sessions by backend type."),
2659
2608
  limit: z7.number().int().min(1).max(100).optional().describe("Maximum number of sessions to return. Default: 10."),
2660
2609
  status: z7.enum(["active", "completed", "error"]).optional().describe("Filter sessions by status."),
2661
- taskId: z7.string().optional().describe("Filter by metadata.taskId."),
2610
+ taskId: z7.string().regex(TASK_ID_PATTERN).optional().describe("Filter by metadata.taskId."),
2662
2611
  label: z7.string().optional().describe("Case-insensitive partial match for metadata.label."),
2663
2612
  tags: z7.array(z7.string()).optional().describe("Filter sessions containing all provided tags."),
2664
2613
  staleOnly: z7.boolean().optional().describe("When true, return only stale active sessions.")
@@ -2698,7 +2647,7 @@ var init_server = __esm({
2698
2647
  );
2699
2648
  server.tool(
2700
2649
  "check_session_health",
2701
- "Check relay session health without side effects.",
2650
+ "Check whether a relay session is alive, stale, or completed. Use to verify sub-agent progress.",
2702
2651
  checkSessionHealthInputSchema.shape,
2703
2652
  async (params) => {
2704
2653
  try {
@@ -2728,7 +2677,7 @@ var init_server = __esm({
2728
2677
  );
2729
2678
  server.tool(
2730
2679
  "poll_agent_events",
2731
- "Poll agent lifecycle events using a cursor.",
2680
+ "Get agent lifecycle events (spawned, completed, failed) since a cursor. Use for monitoring sub-agent progress.",
2732
2681
  pollAgentEventsInputSchema.shape,
2733
2682
  async (params) => {
2734
2683
  try {
@@ -2761,7 +2710,7 @@ var init_server = __esm({
2761
2710
  );
2762
2711
  server.tool(
2763
2712
  "get_context_status",
2764
- "Get the context usage status of a relay session. Returns usage data from ContextMonitor when available, otherwise estimated values.",
2713
+ "Get context window usage of a relay session. Use to check if a sub-agent is approaching context limits.",
2765
2714
  {
2766
2715
  sessionId: z7.string().describe("Relay session ID to query context usage for.")
2767
2716
  },
@@ -2791,7 +2740,7 @@ var init_server = __esm({
2791
2740
  );
2792
2741
  server.tool(
2793
2742
  "list_available_backends",
2794
- "List all registered backends with their health status. Use this before spawn_agent to check which backends are available.",
2743
+ "List registered backends and their health. spawn_agent auto-selects backends, so this is mainly for diagnostics.",
2795
2744
  {},
2796
2745
  async () => {
2797
2746
  try {
@@ -2881,7 +2830,7 @@ var init_server = __esm({
2881
2830
  sessionIdGenerator: () => randomUUID()
2882
2831
  });
2883
2832
  const server = new McpServer(
2884
- { name: "agentic-relay", version: "1.2.0" },
2833
+ { name: "agentic-relay", version: "1.3.1" },
2885
2834
  createMcpServerOptions()
2886
2835
  );
2887
2836
  this.registerTools(server);
@@ -4291,7 +4240,8 @@ ${prompt}`;
4291
4240
 
4292
4241
  // src/core/session-manager.ts
4293
4242
  init_logger();
4294
- import { readFile as readFile4, writeFile as writeFile4, readdir, mkdir as mkdir4, chmod, rename, unlink, open } from "fs/promises";
4243
+ init_metadata_validation();
4244
+ import { readFile as readFile4, writeFile as writeFile4, readdir, mkdir as mkdir4, chmod, rename, unlink } from "fs/promises";
4295
4245
  import { join as join4 } from "path";
4296
4246
  import { homedir as homedir4 } from "os";
4297
4247
  import { nanoid } from "nanoid";
@@ -4328,9 +4278,6 @@ function fromSessionData(data) {
4328
4278
  var SessionManager = class _SessionManager {
4329
4279
  static SESSION_ID_PATTERN = /^relay-[A-Za-z0-9_-]+$/;
4330
4280
  static DEFAULT_STALE_THRESHOLD_MS = 3e5;
4331
- static CREATE_LOCK_FILE = ".create.lock";
4332
- static CREATE_LOCK_RETRY_DELAY_MS = 10;
4333
- static CREATE_LOCK_MAX_RETRIES = 100;
4334
4281
  static PROTECTED_METADATA_KEYS = /* @__PURE__ */ new Set([
4335
4282
  "taskId",
4336
4283
  "parentTaskId",
@@ -4390,35 +4337,6 @@ var SessionManager = class _SessionManager {
4390
4337
  if (session.status !== "active") return false;
4391
4338
  return this.getLastActivityAtMs(session) + staleThresholdMs < now;
4392
4339
  }
4393
- async countHealthyActiveSessions(staleThresholdMs) {
4394
- const activeSessions = await this.list({ status: "active" });
4395
- const now = Date.now();
4396
- return activeSessions.filter(
4397
- (session) => !this.isStale(session, staleThresholdMs, now)
4398
- ).length;
4399
- }
4400
- async acquireCreateLock() {
4401
- await this.ensureDir();
4402
- const lockPath = join4(this.sessionsDir, _SessionManager.CREATE_LOCK_FILE);
4403
- for (let attempt = 0; attempt < _SessionManager.CREATE_LOCK_MAX_RETRIES; attempt += 1) {
4404
- try {
4405
- const handle = await open(lockPath, "wx", 384);
4406
- return async () => {
4407
- await handle.close().catch(() => void 0);
4408
- await unlink(lockPath).catch(() => void 0);
4409
- };
4410
- } catch (error) {
4411
- const code = error.code;
4412
- if (code !== "EEXIST") {
4413
- throw error;
4414
- }
4415
- }
4416
- await new Promise(
4417
- (resolve3) => setTimeout(resolve3, _SessionManager.CREATE_LOCK_RETRY_DELAY_MS)
4418
- );
4419
- }
4420
- throw new Error("RELAY_CONCURRENT_LIMIT_RACE: failed to acquire create lock");
4421
- }
4422
4340
  mergeMetadata(existing, updates) {
4423
4341
  const merged = { ...existing };
4424
4342
  for (const [key, value] of Object.entries(updates)) {
@@ -4432,36 +4350,23 @@ var SessionManager = class _SessionManager {
4432
4350
  }
4433
4351
  /** Create a new relay session. */
4434
4352
  async create(params) {
4435
- const releaseCreateLock = await this.acquireCreateLock();
4436
- try {
4437
- if (typeof params.expectedActiveCount === "number") {
4438
- const staleThresholdMs = params.expectedActiveStaleThresholdMs ?? _SessionManager.DEFAULT_STALE_THRESHOLD_MS;
4439
- const activeCount = await this.countHealthyActiveSessions(staleThresholdMs);
4440
- if (activeCount !== params.expectedActiveCount) {
4441
- throw new Error(
4442
- `RELAY_CONCURRENT_LIMIT_RACE: expected=${params.expectedActiveCount}, actual=${activeCount}`
4443
- );
4444
- }
4445
- }
4446
- const now = /* @__PURE__ */ new Date();
4447
- const session = {
4448
- relaySessionId: `relay-${nanoid()}`,
4449
- nativeSessionId: params.nativeSessionId ?? null,
4450
- backendId: params.backendId,
4451
- parentSessionId: params.parentSessionId ?? null,
4452
- depth: params.depth ?? 0,
4453
- createdAt: now,
4454
- updatedAt: now,
4455
- status: "active",
4456
- lastHeartbeatAt: now,
4457
- staleNotifiedAt: null,
4458
- metadata: params.metadata ?? {}
4459
- };
4460
- await this.writeSession(session);
4461
- return session;
4462
- } finally {
4463
- await releaseCreateLock();
4464
- }
4353
+ await this.ensureDir();
4354
+ const now = /* @__PURE__ */ new Date();
4355
+ const session = {
4356
+ relaySessionId: `relay-${nanoid()}`,
4357
+ nativeSessionId: params.nativeSessionId ?? null,
4358
+ backendId: params.backendId,
4359
+ parentSessionId: params.parentSessionId ?? null,
4360
+ depth: params.depth ?? 0,
4361
+ createdAt: now,
4362
+ updatedAt: now,
4363
+ status: "active",
4364
+ lastHeartbeatAt: now,
4365
+ staleNotifiedAt: null,
4366
+ metadata: validateMetadata(params.metadata)
4367
+ };
4368
+ await this.writeSession(session);
4369
+ return session;
4465
4370
  }
4466
4371
  /** Update an existing session. */
4467
4372
  async update(relaySessionId, updates) {
@@ -4475,7 +4380,9 @@ var SessionManager = class _SessionManager {
4475
4380
  const updated = {
4476
4381
  ...session,
4477
4382
  ...updates,
4478
- metadata: updates.metadata ? this.mergeMetadata(session.metadata, updates.metadata) : session.metadata,
4383
+ metadata: validateMetadata(
4384
+ updates.metadata ? this.mergeMetadata(session.metadata, updates.metadata) : session.metadata
4385
+ ),
4479
4386
  updatedAt: /* @__PURE__ */ new Date()
4480
4387
  };
4481
4388
  await this.writeSession(updated);
@@ -4720,7 +4627,7 @@ function deepMerge(target, source) {
4720
4627
  for (const key of Object.keys(source)) {
4721
4628
  const sourceVal = source[key];
4722
4629
  const targetVal = result[key];
4723
- if (isPlainObject(sourceVal) && isPlainObject(targetVal)) {
4630
+ if (isPlainObject2(sourceVal) && isPlainObject2(targetVal)) {
4724
4631
  result[key] = deepMerge(
4725
4632
  targetVal,
4726
4633
  sourceVal
@@ -4731,14 +4638,14 @@ function deepMerge(target, source) {
4731
4638
  }
4732
4639
  return result;
4733
4640
  }
4734
- function isPlainObject(value) {
4641
+ function isPlainObject2(value) {
4735
4642
  return typeof value === "object" && value !== null && !Array.isArray(value) && Object.getPrototypeOf(value) === Object.prototype;
4736
4643
  }
4737
4644
  function getByPath(obj, path2) {
4738
4645
  const parts = path2.split(".");
4739
4646
  let current = obj;
4740
4647
  for (const part of parts) {
4741
- if (!isPlainObject(current)) return void 0;
4648
+ if (!isPlainObject2(current)) return void 0;
4742
4649
  current = current[part];
4743
4650
  }
4744
4651
  return current;
@@ -4748,7 +4655,7 @@ function setByPath(obj, path2, value) {
4748
4655
  let current = obj;
4749
4656
  for (let i = 0; i < parts.length - 1; i++) {
4750
4657
  const part = parts[i];
4751
- if (!isPlainObject(current[part])) {
4658
+ if (!isPlainObject2(current[part])) {
4752
4659
  current[part] = {};
4753
4660
  }
4754
4661
  current = current[part];
@@ -4857,7 +4764,7 @@ var ConfigManager = class {
4857
4764
  try {
4858
4765
  const raw = await readFile5(filePath, "utf-8");
4859
4766
  const parsed = JSON.parse(raw);
4860
- if (!isPlainObject(parsed)) return {};
4767
+ if (!isPlainObject2(parsed)) return {};
4861
4768
  return parsed;
4862
4769
  } catch {
4863
4770
  return {};
@@ -6305,7 +6212,7 @@ function createVersionCommand(registry2) {
6305
6212
  description: "Show relay and backend versions"
6306
6213
  },
6307
6214
  async run() {
6308
- const relayVersion = "1.2.0";
6215
+ const relayVersion = "1.3.1";
6309
6216
  console.log(`agentic-relay v${relayVersion}`);
6310
6217
  console.log("");
6311
6218
  console.log("Backends:");
@@ -6658,7 +6565,7 @@ void configManager.getConfig().then((config) => {
6658
6565
  var main = defineCommand10({
6659
6566
  meta: {
6660
6567
  name: "relay",
6661
- version: "1.2.0",
6568
+ version: "1.3.1",
6662
6569
  description: "Unified CLI proxy for Claude Code, Codex CLI, and Gemini CLI"
6663
6570
  },
6664
6571
  subCommands: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rk0429/agentic-relay",
3
- "version": "1.2.0",
3
+ "version": "1.3.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",