@rk0429/agentic-relay 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -5
- package/dist/relay.mjs +1628 -685
- package/package.json +3 -1
package/dist/relay.mjs
CHANGED
|
@@ -1,27 +1,16 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
4
|
+
var __esm = (fn, res) => function __init() {
|
|
5
|
+
return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
|
|
6
|
+
};
|
|
7
|
+
var __export = (target, all) => {
|
|
8
|
+
for (var name in all)
|
|
9
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
10
|
+
};
|
|
10
11
|
|
|
11
12
|
// src/infrastructure/logger.ts
|
|
12
13
|
import { createConsola } from "consola";
|
|
13
|
-
var LOG_LEVEL_MAP = {
|
|
14
|
-
silent: -1,
|
|
15
|
-
fatal: 0,
|
|
16
|
-
error: 0,
|
|
17
|
-
warn: 1,
|
|
18
|
-
log: 2,
|
|
19
|
-
info: 3,
|
|
20
|
-
success: 3,
|
|
21
|
-
debug: 4,
|
|
22
|
-
trace: 5,
|
|
23
|
-
verbose: 5
|
|
24
|
-
};
|
|
25
14
|
function resolveLogLevel() {
|
|
26
15
|
const envLevel = process.env["RELAY_LOG_LEVEL"]?.toLowerCase();
|
|
27
16
|
if (envLevel && envLevel in LOG_LEVEL_MAP) {
|
|
@@ -29,129 +18,671 @@ function resolveLogLevel() {
|
|
|
29
18
|
}
|
|
30
19
|
return 3;
|
|
31
20
|
}
|
|
32
|
-
var logger
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
21
|
+
var LOG_LEVEL_MAP, logger;
|
|
22
|
+
var init_logger = __esm({
|
|
23
|
+
"src/infrastructure/logger.ts"() {
|
|
24
|
+
"use strict";
|
|
25
|
+
LOG_LEVEL_MAP = {
|
|
26
|
+
silent: -1,
|
|
27
|
+
fatal: 0,
|
|
28
|
+
error: 0,
|
|
29
|
+
warn: 1,
|
|
30
|
+
log: 2,
|
|
31
|
+
info: 3,
|
|
32
|
+
success: 3,
|
|
33
|
+
debug: 4,
|
|
34
|
+
trace: 5,
|
|
35
|
+
verbose: 5
|
|
36
|
+
};
|
|
37
|
+
logger = createConsola({
|
|
38
|
+
level: resolveLogLevel(),
|
|
39
|
+
defaults: {
|
|
40
|
+
tag: "relay"
|
|
41
|
+
}
|
|
42
|
+
});
|
|
36
43
|
}
|
|
37
44
|
});
|
|
38
45
|
|
|
39
|
-
// src/
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
46
|
+
// src/mcp-server/recursion-guard.ts
|
|
47
|
+
import { createHash } from "crypto";
|
|
48
|
+
var RecursionGuard;
|
|
49
|
+
var init_recursion_guard = __esm({
|
|
50
|
+
"src/mcp-server/recursion-guard.ts"() {
|
|
51
|
+
"use strict";
|
|
52
|
+
RecursionGuard = class _RecursionGuard {
|
|
53
|
+
constructor(config = {
|
|
54
|
+
maxDepth: 5,
|
|
55
|
+
maxCallsPerSession: 20,
|
|
56
|
+
timeoutSec: 300
|
|
57
|
+
}) {
|
|
58
|
+
this.config = config;
|
|
59
|
+
}
|
|
60
|
+
static MAX_ENTRIES = 1e3;
|
|
61
|
+
callCounts = /* @__PURE__ */ new Map();
|
|
62
|
+
promptHashes = /* @__PURE__ */ new Map();
|
|
63
|
+
/** Remove entries older than timeoutSec and enforce size limits */
|
|
64
|
+
cleanup() {
|
|
65
|
+
const now = Date.now();
|
|
66
|
+
const ttlMs = this.config.timeoutSec * 1e3;
|
|
67
|
+
for (const [key, value] of this.callCounts) {
|
|
68
|
+
if (now - value.createdAt > ttlMs) {
|
|
69
|
+
this.callCounts.delete(key);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
for (const [key, value] of this.promptHashes) {
|
|
73
|
+
if (now - value.createdAt > ttlMs) {
|
|
74
|
+
this.promptHashes.delete(key);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
if (this.callCounts.size > _RecursionGuard.MAX_ENTRIES) {
|
|
78
|
+
const sorted = [...this.callCounts.entries()].sort(
|
|
79
|
+
(a, b) => a[1].createdAt - b[1].createdAt
|
|
80
|
+
);
|
|
81
|
+
for (let i = 0; i < sorted.length - _RecursionGuard.MAX_ENTRIES; i++) {
|
|
82
|
+
this.callCounts.delete(sorted[i][0]);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
if (this.promptHashes.size > _RecursionGuard.MAX_ENTRIES) {
|
|
86
|
+
const sorted = [...this.promptHashes.entries()].sort(
|
|
87
|
+
(a, b) => a[1].createdAt - b[1].createdAt
|
|
88
|
+
);
|
|
89
|
+
for (let i = 0; i < sorted.length - _RecursionGuard.MAX_ENTRIES; i++) {
|
|
90
|
+
this.promptHashes.delete(sorted[i][0]);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
/** Check if a spawn is allowed */
|
|
95
|
+
canSpawn(context) {
|
|
96
|
+
this.cleanup();
|
|
97
|
+
if (context.depth >= this.config.maxDepth) {
|
|
98
|
+
return {
|
|
99
|
+
allowed: false,
|
|
100
|
+
reason: `Max depth exceeded: ${context.depth} >= ${this.config.maxDepth}`
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
const currentCount = this.callCounts.get(context.traceId)?.count ?? 0;
|
|
104
|
+
if (currentCount >= this.config.maxCallsPerSession) {
|
|
105
|
+
return {
|
|
106
|
+
allowed: false,
|
|
107
|
+
reason: `Max calls per session exceeded: ${currentCount} >= ${this.config.maxCallsPerSession}`
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
if (this.detectLoop(context.traceId, context.backend, context.promptHash)) {
|
|
111
|
+
return {
|
|
112
|
+
allowed: false,
|
|
113
|
+
reason: `Loop detected: same (backend=${context.backend}, promptHash=${context.promptHash}) appeared 3+ times in trace ${context.traceId}`
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
return { allowed: true };
|
|
117
|
+
}
|
|
118
|
+
/** Record a spawn invocation */
|
|
119
|
+
recordSpawn(context) {
|
|
120
|
+
this.cleanup();
|
|
121
|
+
const entry = this.callCounts.get(context.traceId);
|
|
122
|
+
if (entry) {
|
|
123
|
+
entry.count += 1;
|
|
124
|
+
} else {
|
|
125
|
+
this.callCounts.set(context.traceId, { count: 1, createdAt: Date.now() });
|
|
126
|
+
}
|
|
127
|
+
const key = `${context.backend}:${context.promptHash}`;
|
|
128
|
+
const hashEntry = this.promptHashes.get(context.traceId);
|
|
129
|
+
if (hashEntry) {
|
|
130
|
+
hashEntry.hashes.push(key);
|
|
131
|
+
} else {
|
|
132
|
+
this.promptHashes.set(context.traceId, { hashes: [key], createdAt: Date.now() });
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
/** Detect if the same (backend + promptHash) combination has appeared 3+ times */
|
|
136
|
+
detectLoop(traceId, backend, promptHash) {
|
|
137
|
+
const key = `${backend}:${promptHash}`;
|
|
138
|
+
const hashes = this.promptHashes.get(traceId)?.hashes ?? [];
|
|
139
|
+
const count = hashes.filter((h) => h === key).length;
|
|
140
|
+
return count >= 3;
|
|
141
|
+
}
|
|
142
|
+
/** Get current config (for testing/inspection) */
|
|
143
|
+
getConfig() {
|
|
144
|
+
return { ...this.config };
|
|
145
|
+
}
|
|
146
|
+
/** Get call count for a trace */
|
|
147
|
+
getCallCount(traceId) {
|
|
148
|
+
return this.callCounts.get(traceId)?.count ?? 0;
|
|
149
|
+
}
|
|
150
|
+
/** Utility: compute a prompt hash */
|
|
151
|
+
static hashPrompt(prompt) {
|
|
152
|
+
return createHash("sha256").update(prompt).digest("hex").slice(0, 16);
|
|
153
|
+
}
|
|
54
154
|
};
|
|
55
|
-
const proc = execa(command, args, execaOptions);
|
|
56
|
-
this.activeProcesses.add(proc);
|
|
57
|
-
try {
|
|
58
|
-
await proc;
|
|
59
|
-
} finally {
|
|
60
|
-
this.activeProcesses.delete(proc);
|
|
61
|
-
}
|
|
62
155
|
}
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
// src/mcp-server/tools/spawn-agent.ts
|
|
159
|
+
import { z as z2 } from "zod";
|
|
160
|
+
import { nanoid as nanoid2 } from "nanoid";
|
|
161
|
+
function buildContextFromEnv() {
|
|
162
|
+
const traceId = process.env["RELAY_TRACE_ID"] ?? `trace-${nanoid2()}`;
|
|
163
|
+
const parentSessionId = process.env["RELAY_PARENT_SESSION_ID"] ?? null;
|
|
164
|
+
const depth = Number(process.env["RELAY_DEPTH"] ?? "0");
|
|
165
|
+
return { traceId, parentSessionId, depth };
|
|
166
|
+
}
|
|
167
|
+
async function executeSpawnAgent(input, registry2, sessionManager2, guard, hooksEngine2, contextMonitor2) {
|
|
168
|
+
const envContext = buildContextFromEnv();
|
|
169
|
+
const promptHash = RecursionGuard.hashPrompt(input.prompt);
|
|
170
|
+
const context = {
|
|
171
|
+
traceId: envContext.traceId,
|
|
172
|
+
depth: envContext.depth,
|
|
173
|
+
backend: input.backend,
|
|
174
|
+
promptHash
|
|
175
|
+
};
|
|
176
|
+
const guardResult = guard.canSpawn(context);
|
|
177
|
+
if (!guardResult.allowed) {
|
|
178
|
+
logger.warn(`Spawn blocked by RecursionGuard: ${guardResult.reason}`);
|
|
179
|
+
return {
|
|
180
|
+
sessionId: "",
|
|
181
|
+
exitCode: 1,
|
|
182
|
+
stdout: "",
|
|
183
|
+
stderr: `Spawn blocked: ${guardResult.reason}`
|
|
74
184
|
};
|
|
75
|
-
const proc = execa(command, args, execaOptions);
|
|
76
|
-
this.activeProcesses.add(proc);
|
|
77
|
-
try {
|
|
78
|
-
const result = await proc;
|
|
79
|
-
return {
|
|
80
|
-
exitCode: result.exitCode ?? 1,
|
|
81
|
-
stdout: result.stdout?.toString() ?? "",
|
|
82
|
-
stderr: result.stderr?.toString() ?? ""
|
|
83
|
-
};
|
|
84
|
-
} finally {
|
|
85
|
-
this.activeProcesses.delete(proc);
|
|
86
|
-
}
|
|
87
185
|
}
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
reject: false
|
|
186
|
+
const adapter = registry2.get(input.backend);
|
|
187
|
+
const installed = await adapter.isInstalled();
|
|
188
|
+
if (!installed) {
|
|
189
|
+
return {
|
|
190
|
+
sessionId: "",
|
|
191
|
+
exitCode: 1,
|
|
192
|
+
stdout: "",
|
|
193
|
+
stderr: `Backend "${input.backend}" is not installed`
|
|
97
194
|
};
|
|
98
|
-
|
|
99
|
-
|
|
195
|
+
}
|
|
196
|
+
const session = await sessionManager2.create({
|
|
197
|
+
backendId: input.backend,
|
|
198
|
+
parentSessionId: envContext.parentSessionId ?? void 0,
|
|
199
|
+
depth: envContext.depth + 1
|
|
200
|
+
});
|
|
201
|
+
if (hooksEngine2) {
|
|
100
202
|
try {
|
|
101
|
-
const
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
203
|
+
const hookInput = {
|
|
204
|
+
event: "pre-spawn",
|
|
205
|
+
sessionId: session.relaySessionId,
|
|
206
|
+
backendId: input.backend,
|
|
207
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
208
|
+
data: {
|
|
209
|
+
prompt: input.prompt,
|
|
210
|
+
agent: input.agent,
|
|
211
|
+
model: input.model
|
|
212
|
+
}
|
|
106
213
|
};
|
|
107
|
-
|
|
108
|
-
|
|
214
|
+
await hooksEngine2.emit("pre-spawn", hookInput);
|
|
215
|
+
} catch (error) {
|
|
216
|
+
logger.debug(
|
|
217
|
+
`pre-spawn hook error: ${error instanceof Error ? error.message : String(error)}`
|
|
218
|
+
);
|
|
109
219
|
}
|
|
110
220
|
}
|
|
111
|
-
|
|
112
|
-
const
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
221
|
+
try {
|
|
222
|
+
const result = await adapter.execute({
|
|
223
|
+
prompt: input.prompt,
|
|
224
|
+
agent: input.agent,
|
|
225
|
+
model: input.model,
|
|
226
|
+
maxTurns: input.maxTurns,
|
|
227
|
+
resume: input.resumeSessionId,
|
|
228
|
+
mcpContext: {
|
|
229
|
+
parentSessionId: session.relaySessionId,
|
|
230
|
+
depth: envContext.depth + 1,
|
|
231
|
+
maxDepth: guard.getConfig().maxDepth,
|
|
232
|
+
traceId: envContext.traceId
|
|
116
233
|
}
|
|
117
|
-
};
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
}
|
|
129
|
-
get(id) {
|
|
130
|
-
const adapter = this.adapters.get(id);
|
|
131
|
-
if (!adapter) {
|
|
132
|
-
throw new Error(
|
|
133
|
-
`Backend "${id}" is not registered. Available: ${[...this.adapters.keys()].join(", ")}`
|
|
234
|
+
});
|
|
235
|
+
if (contextMonitor2) {
|
|
236
|
+
const estimatedTokens = Math.ceil(
|
|
237
|
+
(result.stdout.length + result.stderr.length) / 4
|
|
238
|
+
);
|
|
239
|
+
const maxTokens = input.backend === "gemini" ? 128e3 : 2e5;
|
|
240
|
+
contextMonitor2.updateUsage(
|
|
241
|
+
session.relaySessionId,
|
|
242
|
+
input.backend,
|
|
243
|
+
estimatedTokens,
|
|
244
|
+
maxTokens
|
|
134
245
|
);
|
|
135
246
|
}
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
247
|
+
guard.recordSpawn(context);
|
|
248
|
+
const status = result.exitCode === 0 ? "completed" : "error";
|
|
249
|
+
await sessionManager2.update(session.relaySessionId, { status });
|
|
250
|
+
if (hooksEngine2) {
|
|
251
|
+
try {
|
|
252
|
+
const hookInput = {
|
|
253
|
+
event: "post-spawn",
|
|
254
|
+
sessionId: session.relaySessionId,
|
|
255
|
+
backendId: input.backend,
|
|
256
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
257
|
+
data: {
|
|
258
|
+
exitCode: result.exitCode,
|
|
259
|
+
status
|
|
260
|
+
}
|
|
261
|
+
};
|
|
262
|
+
await hooksEngine2.emit("post-spawn", hookInput);
|
|
263
|
+
} catch (hookError) {
|
|
264
|
+
logger.debug(
|
|
265
|
+
`post-spawn hook error: ${hookError instanceof Error ? hookError.message : String(hookError)}`
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
return {
|
|
270
|
+
sessionId: session.relaySessionId,
|
|
271
|
+
exitCode: result.exitCode,
|
|
272
|
+
stdout: result.stdout,
|
|
273
|
+
stderr: result.stderr
|
|
274
|
+
};
|
|
275
|
+
} catch (error) {
|
|
276
|
+
await sessionManager2.update(session.relaySessionId, { status: "error" });
|
|
277
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
278
|
+
return {
|
|
279
|
+
sessionId: session.relaySessionId,
|
|
280
|
+
exitCode: 1,
|
|
281
|
+
stdout: "",
|
|
282
|
+
stderr: message
|
|
283
|
+
};
|
|
140
284
|
}
|
|
141
|
-
|
|
142
|
-
|
|
285
|
+
}
|
|
286
|
+
var spawnAgentInputSchema;
|
|
287
|
+
var init_spawn_agent = __esm({
|
|
288
|
+
"src/mcp-server/tools/spawn-agent.ts"() {
|
|
289
|
+
"use strict";
|
|
290
|
+
init_recursion_guard();
|
|
291
|
+
init_logger();
|
|
292
|
+
spawnAgentInputSchema = z2.object({
|
|
293
|
+
backend: z2.enum(["claude", "codex", "gemini"]),
|
|
294
|
+
prompt: z2.string(),
|
|
295
|
+
agent: z2.string().optional(),
|
|
296
|
+
resumeSessionId: z2.string().optional(),
|
|
297
|
+
model: z2.string().optional(),
|
|
298
|
+
maxTurns: z2.number().optional()
|
|
299
|
+
});
|
|
143
300
|
}
|
|
144
|
-
};
|
|
301
|
+
});
|
|
145
302
|
|
|
146
|
-
// src/
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
303
|
+
// src/mcp-server/tools/list-sessions.ts
|
|
304
|
+
import { z as z3 } from "zod";
|
|
305
|
+
async function executeListSessions(input, sessionManager2) {
|
|
306
|
+
const sessions = await sessionManager2.list({
|
|
307
|
+
backendId: input.backend,
|
|
308
|
+
limit: input.limit
|
|
309
|
+
});
|
|
310
|
+
return {
|
|
311
|
+
sessions: sessions.map((s) => ({
|
|
312
|
+
relaySessionId: s.relaySessionId,
|
|
313
|
+
backendId: s.backendId,
|
|
314
|
+
status: s.status,
|
|
315
|
+
createdAt: s.createdAt.toISOString()
|
|
316
|
+
}))
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
var listSessionsInputSchema;
|
|
320
|
+
var init_list_sessions = __esm({
|
|
321
|
+
"src/mcp-server/tools/list-sessions.ts"() {
|
|
322
|
+
"use strict";
|
|
323
|
+
listSessionsInputSchema = z3.object({
|
|
324
|
+
backend: z3.enum(["claude", "codex", "gemini"]).optional(),
|
|
325
|
+
limit: z3.number().optional().default(10)
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
// src/mcp-server/tools/get-context-status.ts
|
|
331
|
+
import { z as z4 } from "zod";
|
|
332
|
+
async function executeGetContextStatus(input, sessionManager2, contextMonitor2) {
|
|
333
|
+
const session = await sessionManager2.get(input.sessionId);
|
|
334
|
+
if (!session) {
|
|
335
|
+
throw new Error(`Session not found: ${input.sessionId}`);
|
|
336
|
+
}
|
|
337
|
+
if (contextMonitor2) {
|
|
338
|
+
const usage = contextMonitor2.getUsage(input.sessionId);
|
|
339
|
+
if (usage) {
|
|
340
|
+
return {
|
|
341
|
+
sessionId: input.sessionId,
|
|
342
|
+
usagePercent: usage.usagePercent,
|
|
343
|
+
isEstimated: usage.isEstimated
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
return {
|
|
348
|
+
sessionId: input.sessionId,
|
|
349
|
+
usagePercent: 0,
|
|
350
|
+
isEstimated: true
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
var getContextStatusInputSchema;
|
|
354
|
+
var init_get_context_status = __esm({
|
|
355
|
+
"src/mcp-server/tools/get-context-status.ts"() {
|
|
356
|
+
"use strict";
|
|
357
|
+
getContextStatusInputSchema = z4.object({
|
|
358
|
+
sessionId: z4.string()
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
// src/mcp-server/server.ts
|
|
364
|
+
var server_exports = {};
|
|
365
|
+
__export(server_exports, {
|
|
366
|
+
RelayMCPServer: () => RelayMCPServer
|
|
367
|
+
});
|
|
368
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
369
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
370
|
+
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
371
|
+
import { createServer } from "http";
|
|
372
|
+
import { randomUUID } from "crypto";
|
|
373
|
+
import { z as z5 } from "zod";
|
|
374
|
+
var RelayMCPServer;
|
|
375
|
+
var init_server = __esm({
|
|
376
|
+
"src/mcp-server/server.ts"() {
|
|
377
|
+
"use strict";
|
|
378
|
+
init_recursion_guard();
|
|
379
|
+
init_spawn_agent();
|
|
380
|
+
init_list_sessions();
|
|
381
|
+
init_get_context_status();
|
|
382
|
+
init_logger();
|
|
383
|
+
RelayMCPServer = class {
|
|
384
|
+
constructor(registry2, sessionManager2, guardConfig, hooksEngine2, contextMonitor2) {
|
|
385
|
+
this.registry = registry2;
|
|
386
|
+
this.sessionManager = sessionManager2;
|
|
387
|
+
this.hooksEngine = hooksEngine2;
|
|
388
|
+
this.contextMonitor = contextMonitor2;
|
|
389
|
+
this.guard = new RecursionGuard(guardConfig);
|
|
390
|
+
this.server = new McpServer({
|
|
391
|
+
name: "agentic-relay",
|
|
392
|
+
version: "0.4.0"
|
|
393
|
+
});
|
|
394
|
+
this.registerTools();
|
|
395
|
+
}
|
|
396
|
+
server;
|
|
397
|
+
guard;
|
|
398
|
+
registerTools() {
|
|
399
|
+
this.server.tool(
|
|
400
|
+
"spawn_agent",
|
|
401
|
+
"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.",
|
|
402
|
+
{
|
|
403
|
+
backend: z5.enum(["claude", "codex", "gemini"]),
|
|
404
|
+
prompt: z5.string(),
|
|
405
|
+
agent: z5.string().optional(),
|
|
406
|
+
resumeSessionId: z5.string().optional(),
|
|
407
|
+
model: z5.string().optional(),
|
|
408
|
+
maxTurns: z5.number().optional()
|
|
409
|
+
},
|
|
410
|
+
async (params) => {
|
|
411
|
+
try {
|
|
412
|
+
const result = await executeSpawnAgent(
|
|
413
|
+
params,
|
|
414
|
+
this.registry,
|
|
415
|
+
this.sessionManager,
|
|
416
|
+
this.guard,
|
|
417
|
+
this.hooksEngine,
|
|
418
|
+
this.contextMonitor
|
|
419
|
+
);
|
|
420
|
+
const isError = result.exitCode !== 0;
|
|
421
|
+
const text = isError ? `Error (exit ${result.exitCode}): ${result.stderr || result.stdout}` : `Session: ${result.sessionId}
|
|
422
|
+
|
|
423
|
+
${result.stdout}`;
|
|
424
|
+
return {
|
|
425
|
+
content: [{ type: "text", text }],
|
|
426
|
+
isError
|
|
427
|
+
};
|
|
428
|
+
} catch (error) {
|
|
429
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
430
|
+
return {
|
|
431
|
+
content: [{ type: "text", text: `Error: ${message}` }],
|
|
432
|
+
isError: true
|
|
433
|
+
};
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
);
|
|
437
|
+
this.server.tool(
|
|
438
|
+
"list_sessions",
|
|
439
|
+
"List relay sessions, optionally filtered by backend.",
|
|
440
|
+
{
|
|
441
|
+
backend: z5.enum(["claude", "codex", "gemini"]).optional(),
|
|
442
|
+
limit: z5.number().optional()
|
|
443
|
+
},
|
|
444
|
+
async (params) => {
|
|
445
|
+
try {
|
|
446
|
+
const result = await executeListSessions(
|
|
447
|
+
{ backend: params.backend, limit: params.limit ?? 10 },
|
|
448
|
+
this.sessionManager
|
|
449
|
+
);
|
|
450
|
+
return {
|
|
451
|
+
content: [
|
|
452
|
+
{
|
|
453
|
+
type: "text",
|
|
454
|
+
text: JSON.stringify(result, null, 2)
|
|
455
|
+
}
|
|
456
|
+
]
|
|
457
|
+
};
|
|
458
|
+
} catch (error) {
|
|
459
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
460
|
+
return {
|
|
461
|
+
content: [{ type: "text", text: `Error: ${message}` }],
|
|
462
|
+
isError: true
|
|
463
|
+
};
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
);
|
|
467
|
+
this.server.tool(
|
|
468
|
+
"get_context_status",
|
|
469
|
+
"Get the context usage status of a relay session. Returns usage data from ContextMonitor when available, otherwise estimated values.",
|
|
470
|
+
{
|
|
471
|
+
sessionId: z5.string()
|
|
472
|
+
},
|
|
473
|
+
async (params) => {
|
|
474
|
+
try {
|
|
475
|
+
const result = await executeGetContextStatus(
|
|
476
|
+
params,
|
|
477
|
+
this.sessionManager,
|
|
478
|
+
this.contextMonitor
|
|
479
|
+
);
|
|
480
|
+
return {
|
|
481
|
+
content: [
|
|
482
|
+
{
|
|
483
|
+
type: "text",
|
|
484
|
+
text: JSON.stringify(result, null, 2)
|
|
485
|
+
}
|
|
486
|
+
]
|
|
487
|
+
};
|
|
488
|
+
} catch (error) {
|
|
489
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
490
|
+
return {
|
|
491
|
+
content: [{ type: "text", text: `Error: ${message}` }],
|
|
492
|
+
isError: true
|
|
493
|
+
};
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
);
|
|
497
|
+
}
|
|
498
|
+
async start(options) {
|
|
499
|
+
const transportType = options?.transport ?? "stdio";
|
|
500
|
+
if (transportType === "stdio") {
|
|
501
|
+
logger.info("Starting agentic-relay MCP server (stdio transport)...");
|
|
502
|
+
const transport = new StdioServerTransport();
|
|
503
|
+
await this.server.connect(transport);
|
|
504
|
+
return;
|
|
505
|
+
}
|
|
506
|
+
const port = options?.port ?? 3100;
|
|
507
|
+
logger.info(
|
|
508
|
+
`Starting agentic-relay MCP server (HTTP transport on port ${port})...`
|
|
509
|
+
);
|
|
510
|
+
const httpTransport = new StreamableHTTPServerTransport({
|
|
511
|
+
sessionIdGenerator: () => randomUUID()
|
|
512
|
+
});
|
|
513
|
+
const httpServer = createServer(async (req, res) => {
|
|
514
|
+
const url = req.url ?? "";
|
|
515
|
+
if (url === "/mcp" || url.startsWith("/mcp?")) {
|
|
516
|
+
await httpTransport.handleRequest(req, res);
|
|
517
|
+
} else {
|
|
518
|
+
res.writeHead(404, { "Content-Type": "text/plain" });
|
|
519
|
+
res.end("Not found");
|
|
520
|
+
}
|
|
521
|
+
});
|
|
522
|
+
this._httpServer = httpServer;
|
|
523
|
+
await this.server.connect(httpTransport);
|
|
524
|
+
await new Promise((resolve) => {
|
|
525
|
+
httpServer.listen(port, () => {
|
|
526
|
+
logger.info(`MCP server listening on http://localhost:${port}/mcp`);
|
|
527
|
+
resolve();
|
|
528
|
+
});
|
|
529
|
+
});
|
|
530
|
+
await new Promise((resolve) => {
|
|
531
|
+
httpServer.on("close", resolve);
|
|
532
|
+
});
|
|
533
|
+
}
|
|
534
|
+
/** Exposed for testing and graceful shutdown */
|
|
535
|
+
get httpServer() {
|
|
536
|
+
return this._httpServer;
|
|
537
|
+
}
|
|
538
|
+
_httpServer;
|
|
539
|
+
};
|
|
540
|
+
}
|
|
541
|
+
});
|
|
542
|
+
|
|
543
|
+
// src/bin/relay.ts
|
|
544
|
+
import { defineCommand as defineCommand10, runMain } from "citty";
|
|
545
|
+
import { join as join8 } from "path";
|
|
546
|
+
import { homedir as homedir6 } from "os";
|
|
547
|
+
|
|
548
|
+
// src/infrastructure/process-manager.ts
|
|
549
|
+
init_logger();
|
|
550
|
+
import { execa } from "execa";
|
|
551
|
+
var ProcessManager = class {
|
|
552
|
+
activeProcesses = /* @__PURE__ */ new Set();
|
|
553
|
+
constructor() {
|
|
554
|
+
this.setupSignalHandlers();
|
|
555
|
+
}
|
|
556
|
+
async spawnInteractive(command, args, options) {
|
|
557
|
+
logger.debug(`Spawning interactive: ${command} ${args.join(" ")}`);
|
|
558
|
+
const execaOptions = {
|
|
559
|
+
stdio: "inherit",
|
|
560
|
+
cwd: options?.cwd,
|
|
561
|
+
env: options?.env,
|
|
562
|
+
extendEnv: options?.env ? false : true,
|
|
563
|
+
timeout: options?.timeout,
|
|
564
|
+
reject: false
|
|
565
|
+
};
|
|
566
|
+
const proc = execa(command, args, execaOptions);
|
|
567
|
+
this.activeProcesses.add(proc);
|
|
568
|
+
try {
|
|
569
|
+
await proc;
|
|
570
|
+
} finally {
|
|
571
|
+
this.activeProcesses.delete(proc);
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
async execute(command, args, options) {
|
|
575
|
+
logger.debug(`Executing: ${command} ${args.join(" ")}`);
|
|
576
|
+
const stdinMode = options?.stdinMode ?? "pipe";
|
|
577
|
+
const stdio = stdinMode === "pipe" ? "pipe" : [stdinMode, "pipe", "pipe"];
|
|
578
|
+
const execaOptions = {
|
|
579
|
+
stdio,
|
|
580
|
+
cwd: options?.cwd,
|
|
581
|
+
env: options?.env,
|
|
582
|
+
extendEnv: options?.env ? false : true,
|
|
583
|
+
timeout: options?.timeout,
|
|
584
|
+
reject: false
|
|
585
|
+
};
|
|
586
|
+
const proc = execa(command, args, execaOptions);
|
|
587
|
+
this.activeProcesses.add(proc);
|
|
588
|
+
try {
|
|
589
|
+
const result = await proc;
|
|
590
|
+
return {
|
|
591
|
+
exitCode: result.exitCode ?? 1,
|
|
592
|
+
stdout: result.stdout?.toString() ?? "",
|
|
593
|
+
stderr: result.stderr?.toString() ?? ""
|
|
594
|
+
};
|
|
595
|
+
} finally {
|
|
596
|
+
this.activeProcesses.delete(proc);
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
async executeWithInput(command, args, stdinData, options) {
|
|
600
|
+
logger.debug(`Executing with stdin: ${command} ${args.join(" ")}`);
|
|
601
|
+
const execaOptions = {
|
|
602
|
+
stdio: "pipe",
|
|
603
|
+
input: stdinData,
|
|
604
|
+
cwd: options?.cwd,
|
|
605
|
+
env: options?.env,
|
|
606
|
+
timeout: options?.timeout,
|
|
607
|
+
reject: false
|
|
608
|
+
};
|
|
609
|
+
const proc = execa(command, args, execaOptions);
|
|
610
|
+
this.activeProcesses.add(proc);
|
|
611
|
+
try {
|
|
612
|
+
const result = await proc;
|
|
613
|
+
return {
|
|
614
|
+
exitCode: result.exitCode ?? 1,
|
|
615
|
+
stdout: result.stdout?.toString() ?? "",
|
|
616
|
+
stderr: result.stderr?.toString() ?? ""
|
|
617
|
+
};
|
|
618
|
+
} finally {
|
|
619
|
+
this.activeProcesses.delete(proc);
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
setupSignalHandlers() {
|
|
623
|
+
const handleSignal = (signal) => {
|
|
624
|
+
logger.debug(`Received ${signal}, forwarding to child processes`);
|
|
625
|
+
for (const proc of this.activeProcesses) {
|
|
626
|
+
proc.kill(signal);
|
|
627
|
+
}
|
|
628
|
+
};
|
|
629
|
+
process.on("SIGINT", () => handleSignal("SIGINT"));
|
|
630
|
+
process.on("SIGTERM", () => handleSignal("SIGTERM"));
|
|
631
|
+
}
|
|
632
|
+
};
|
|
633
|
+
|
|
634
|
+
// src/adapters/adapter-registry.ts
|
|
635
|
+
var AdapterRegistry = class {
|
|
636
|
+
adapters = /* @__PURE__ */ new Map();
|
|
637
|
+
factories = /* @__PURE__ */ new Map();
|
|
638
|
+
register(adapter) {
|
|
639
|
+
this.factories.delete(adapter.id);
|
|
640
|
+
this.adapters.set(adapter.id, adapter);
|
|
641
|
+
}
|
|
642
|
+
registerLazy(id, factory) {
|
|
643
|
+
this.adapters.delete(id);
|
|
644
|
+
this.factories.set(id, factory);
|
|
645
|
+
}
|
|
646
|
+
get(id) {
|
|
647
|
+
let adapter = this.adapters.get(id);
|
|
648
|
+
if (!adapter) {
|
|
649
|
+
const factory = this.factories.get(id);
|
|
650
|
+
if (factory) {
|
|
651
|
+
adapter = factory();
|
|
652
|
+
this.adapters.set(id, adapter);
|
|
653
|
+
this.factories.delete(id);
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
if (!adapter) {
|
|
657
|
+
const allIds = [...this.adapters.keys(), ...this.factories.keys()];
|
|
658
|
+
throw new Error(
|
|
659
|
+
`Backend "${id}" is not registered. Available: ${allIds.join(", ")}`
|
|
660
|
+
);
|
|
661
|
+
}
|
|
662
|
+
return adapter;
|
|
663
|
+
}
|
|
664
|
+
has(id) {
|
|
665
|
+
return this.adapters.has(id) || this.factories.has(id);
|
|
666
|
+
}
|
|
667
|
+
list() {
|
|
668
|
+
for (const [id, factory] of this.factories) {
|
|
669
|
+
this.adapters.set(id, factory());
|
|
670
|
+
}
|
|
671
|
+
this.factories.clear();
|
|
672
|
+
return [...this.adapters.values()];
|
|
673
|
+
}
|
|
674
|
+
};
|
|
675
|
+
|
|
676
|
+
// src/adapters/base-adapter.ts
|
|
677
|
+
init_logger();
|
|
678
|
+
var BaseAdapter = class {
|
|
679
|
+
constructor(processManager2) {
|
|
680
|
+
this.processManager = processManager2;
|
|
681
|
+
}
|
|
682
|
+
async isInstalled() {
|
|
683
|
+
try {
|
|
684
|
+
const result = await this.processManager.execute("which", [this.command]);
|
|
685
|
+
return result.exitCode === 0;
|
|
155
686
|
} catch {
|
|
156
687
|
return false;
|
|
157
688
|
}
|
|
@@ -250,10 +781,13 @@ function mapCommonToNative(backendId, flags) {
|
|
|
250
781
|
}
|
|
251
782
|
|
|
252
783
|
// src/adapters/claude-adapter.ts
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
} from "
|
|
784
|
+
init_logger();
|
|
785
|
+
import { readFile, writeFile, mkdir } from "fs/promises";
|
|
786
|
+
import { homedir } from "os";
|
|
787
|
+
import { join, dirname } from "path";
|
|
788
|
+
async function loadClaudeSDK() {
|
|
789
|
+
return await import("@anthropic-ai/claude-agent-sdk");
|
|
790
|
+
}
|
|
257
791
|
var CLAUDE_NESTING_ENV_VARS = [
|
|
258
792
|
"CLAUDECODE",
|
|
259
793
|
"CLAUDE_CODE_SSE_PORT",
|
|
@@ -262,6 +796,9 @@ var CLAUDE_NESTING_ENV_VARS = [
|
|
|
262
796
|
var ClaudeAdapter = class extends BaseAdapter {
|
|
263
797
|
id = "claude";
|
|
264
798
|
command = "claude";
|
|
799
|
+
getConfigPath() {
|
|
800
|
+
return join(homedir(), ".claude.json");
|
|
801
|
+
}
|
|
265
802
|
mapFlags(flags) {
|
|
266
803
|
return {
|
|
267
804
|
args: mapCommonToNative("claude", flags)
|
|
@@ -273,22 +810,30 @@ var ClaudeAdapter = class extends BaseAdapter {
|
|
|
273
810
|
env: this.buildCleanEnv(flags)
|
|
274
811
|
});
|
|
275
812
|
}
|
|
813
|
+
getPermissionMode() {
|
|
814
|
+
return process.env["RELAY_CLAUDE_PERMISSION_MODE"] === "default" ? "default" : "bypassPermissions";
|
|
815
|
+
}
|
|
276
816
|
async execute(flags) {
|
|
277
817
|
if (!flags.prompt) {
|
|
278
818
|
throw new Error("execute requires a prompt (-p flag)");
|
|
279
819
|
}
|
|
280
820
|
const env = this.buildCleanEnv(flags);
|
|
821
|
+
const permissionMode = this.getPermissionMode();
|
|
281
822
|
try {
|
|
823
|
+
const { query } = await loadClaudeSDK();
|
|
824
|
+
const options = {
|
|
825
|
+
env,
|
|
826
|
+
cwd: process.cwd(),
|
|
827
|
+
...flags.model ? { model: flags.model } : {},
|
|
828
|
+
...flags.maxTurns ? { maxTurns: flags.maxTurns } : {}
|
|
829
|
+
};
|
|
830
|
+
if (permissionMode === "bypassPermissions") {
|
|
831
|
+
options.permissionMode = "bypassPermissions";
|
|
832
|
+
options.allowDangerouslySkipPermissions = true;
|
|
833
|
+
}
|
|
282
834
|
const q = query({
|
|
283
835
|
prompt: flags.prompt,
|
|
284
|
-
options
|
|
285
|
-
env,
|
|
286
|
-
cwd: process.cwd(),
|
|
287
|
-
...flags.model ? { model: flags.model } : {},
|
|
288
|
-
...flags.maxTurns ? { maxTurns: flags.maxTurns } : {},
|
|
289
|
-
permissionMode: "bypassPermissions",
|
|
290
|
-
allowDangerouslySkipPermissions: true
|
|
291
|
-
}
|
|
836
|
+
options
|
|
292
837
|
});
|
|
293
838
|
let resultText = "";
|
|
294
839
|
let sessionId = "";
|
|
@@ -309,7 +854,8 @@ var ClaudeAdapter = class extends BaseAdapter {
|
|
|
309
854
|
return {
|
|
310
855
|
exitCode: isError ? 1 : 0,
|
|
311
856
|
stdout: resultText,
|
|
312
|
-
stderr: errorMessages.join("\n")
|
|
857
|
+
stderr: errorMessages.join("\n"),
|
|
858
|
+
...sessionId ? { nativeSessionId: sessionId } : {}
|
|
313
859
|
};
|
|
314
860
|
} catch (error) {
|
|
315
861
|
return {
|
|
@@ -319,6 +865,83 @@ var ClaudeAdapter = class extends BaseAdapter {
|
|
|
319
865
|
};
|
|
320
866
|
}
|
|
321
867
|
}
|
|
868
|
+
async *executeStreaming(flags) {
|
|
869
|
+
if (!flags.prompt) {
|
|
870
|
+
throw new Error("executeStreaming requires a prompt (-p flag)");
|
|
871
|
+
}
|
|
872
|
+
const env = this.buildCleanEnv(flags);
|
|
873
|
+
const permissionMode = this.getPermissionMode();
|
|
874
|
+
try {
|
|
875
|
+
const { query } = await loadClaudeSDK();
|
|
876
|
+
const options = {
|
|
877
|
+
env,
|
|
878
|
+
cwd: process.cwd(),
|
|
879
|
+
...flags.model ? { model: flags.model } : {},
|
|
880
|
+
...flags.maxTurns ? { maxTurns: flags.maxTurns } : {}
|
|
881
|
+
};
|
|
882
|
+
if (permissionMode === "bypassPermissions") {
|
|
883
|
+
options.permissionMode = "bypassPermissions";
|
|
884
|
+
options.allowDangerouslySkipPermissions = true;
|
|
885
|
+
}
|
|
886
|
+
const q = query({
|
|
887
|
+
prompt: flags.prompt,
|
|
888
|
+
options
|
|
889
|
+
});
|
|
890
|
+
for await (const message of q) {
|
|
891
|
+
if (message.type === "assistant") {
|
|
892
|
+
const betaMessage = message.message;
|
|
893
|
+
if (betaMessage?.content) {
|
|
894
|
+
for (const block of betaMessage.content) {
|
|
895
|
+
if (block.type === "text" && block.text) {
|
|
896
|
+
yield { type: "text", text: block.text };
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
if (betaMessage?.usage) {
|
|
901
|
+
yield {
|
|
902
|
+
type: "usage",
|
|
903
|
+
inputTokens: betaMessage.usage.input_tokens ?? 0,
|
|
904
|
+
outputTokens: betaMessage.usage.output_tokens ?? 0
|
|
905
|
+
};
|
|
906
|
+
}
|
|
907
|
+
} else if (message.type === "tool_progress") {
|
|
908
|
+
const toolName = message.tool_name ?? "unknown";
|
|
909
|
+
yield {
|
|
910
|
+
type: "status",
|
|
911
|
+
message: `Running ${toolName}...`
|
|
912
|
+
};
|
|
913
|
+
} else if (message.type === "result") {
|
|
914
|
+
const nativeSessionId = message.session_id || void 0;
|
|
915
|
+
if (message.subtype === "success") {
|
|
916
|
+
const resultText = message.result;
|
|
917
|
+
yield {
|
|
918
|
+
type: "done",
|
|
919
|
+
result: { exitCode: 0, stdout: resultText, stderr: "" },
|
|
920
|
+
nativeSessionId
|
|
921
|
+
};
|
|
922
|
+
} else {
|
|
923
|
+
const errors = message.errors;
|
|
924
|
+
yield {
|
|
925
|
+
type: "done",
|
|
926
|
+
result: {
|
|
927
|
+
exitCode: 1,
|
|
928
|
+
stdout: "",
|
|
929
|
+
stderr: errors.join("\n")
|
|
930
|
+
},
|
|
931
|
+
nativeSessionId
|
|
932
|
+
};
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
} catch (error) {
|
|
937
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
938
|
+
yield { type: "error", message: errorMessage };
|
|
939
|
+
yield {
|
|
940
|
+
type: "done",
|
|
941
|
+
result: { exitCode: 1, stdout: "", stderr: errorMessage }
|
|
942
|
+
};
|
|
943
|
+
}
|
|
944
|
+
}
|
|
322
945
|
async resumeSession(sessionId, flags) {
|
|
323
946
|
await this.processManager.spawnInteractive(
|
|
324
947
|
this.command,
|
|
@@ -342,6 +965,7 @@ var ClaudeAdapter = class extends BaseAdapter {
|
|
|
342
965
|
}
|
|
343
966
|
async listNativeSessions() {
|
|
344
967
|
try {
|
|
968
|
+
const { listSessions } = await loadClaudeSDK();
|
|
345
969
|
const sessions = await listSessions({ limit: 20 });
|
|
346
970
|
return sessions.map((s) => ({
|
|
347
971
|
nativeId: s.sessionId,
|
|
@@ -367,13 +991,172 @@ var ClaudeAdapter = class extends BaseAdapter {
|
|
|
367
991
|
logger.success(`Claude CLI updated: ${result.stdout.trim()}`);
|
|
368
992
|
}
|
|
369
993
|
}
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
994
|
+
async getMCPConfig() {
|
|
995
|
+
const configPath = this.getConfigPath();
|
|
996
|
+
try {
|
|
997
|
+
const raw = await readFile(configPath, "utf-8");
|
|
998
|
+
const config = JSON.parse(raw);
|
|
999
|
+
const mcpServers = config.mcpServers;
|
|
1000
|
+
if (!mcpServers || typeof mcpServers !== "object") {
|
|
1001
|
+
return [];
|
|
1002
|
+
}
|
|
1003
|
+
return Object.entries(mcpServers).map(([name, entry]) => {
|
|
1004
|
+
const server = {
|
|
1005
|
+
name,
|
|
1006
|
+
command: entry.command
|
|
1007
|
+
};
|
|
1008
|
+
if (entry.args) server.args = entry.args;
|
|
1009
|
+
if (entry.env) server.env = entry.env;
|
|
1010
|
+
return server;
|
|
1011
|
+
});
|
|
1012
|
+
} catch {
|
|
1013
|
+
return [];
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
async setMCPConfig(servers) {
|
|
1017
|
+
const configPath = this.getConfigPath();
|
|
1018
|
+
let config = {};
|
|
1019
|
+
try {
|
|
1020
|
+
const raw = await readFile(configPath, "utf-8");
|
|
1021
|
+
config = JSON.parse(raw);
|
|
1022
|
+
} catch {
|
|
1023
|
+
}
|
|
1024
|
+
const mcpServers = {};
|
|
1025
|
+
for (const server of servers) {
|
|
1026
|
+
const entry = {
|
|
1027
|
+
command: server.command
|
|
1028
|
+
};
|
|
1029
|
+
if (server.args) entry.args = server.args;
|
|
1030
|
+
if (server.env) entry.env = server.env;
|
|
1031
|
+
const serverName = server.name ?? server.command;
|
|
1032
|
+
mcpServers[serverName] = entry;
|
|
1033
|
+
}
|
|
1034
|
+
config.mcpServers = mcpServers;
|
|
1035
|
+
await mkdir(dirname(configPath), { recursive: true });
|
|
1036
|
+
await writeFile(configPath, JSON.stringify(config, null, 2) + "\n", {
|
|
1037
|
+
mode: 384
|
|
1038
|
+
});
|
|
1039
|
+
}
|
|
1040
|
+
};
|
|
1041
|
+
|
|
1042
|
+
// src/adapters/codex-adapter.ts
|
|
1043
|
+
init_logger();
|
|
1044
|
+
import { readFile as readFile2, writeFile as writeFile2, mkdir as mkdir2 } from "fs/promises";
|
|
1045
|
+
import { homedir as homedir2 } from "os";
|
|
1046
|
+
import { join as join2, dirname as dirname2 } from "path";
|
|
1047
|
+
async function loadCodexSDK() {
|
|
1048
|
+
return await import("@openai/codex-sdk");
|
|
1049
|
+
}
|
|
1050
|
+
function parseTOMLMcpServers(content) {
|
|
1051
|
+
const lines = content.split("\n");
|
|
1052
|
+
const preambleLines = [];
|
|
1053
|
+
const postambleLines = [];
|
|
1054
|
+
const mcpServers = /* @__PURE__ */ new Map();
|
|
1055
|
+
let currentServerName = null;
|
|
1056
|
+
let currentServer = { command: "" };
|
|
1057
|
+
let inMcpSection = false;
|
|
1058
|
+
let pastMcpSections = false;
|
|
1059
|
+
for (const line of lines) {
|
|
1060
|
+
const tableMatch = line.match(/^\s*\[([^\]]+)\]\s*$/);
|
|
1061
|
+
if (tableMatch) {
|
|
1062
|
+
if (currentServerName !== null) {
|
|
1063
|
+
mcpServers.set(currentServerName, currentServer);
|
|
1064
|
+
currentServerName = null;
|
|
1065
|
+
}
|
|
1066
|
+
const tableName = tableMatch[1].trim();
|
|
1067
|
+
const mcpMatch = tableName.match(/^mcp_servers\.(.+)$/);
|
|
1068
|
+
if (mcpMatch) {
|
|
1069
|
+
inMcpSection = true;
|
|
1070
|
+
pastMcpSections = false;
|
|
1071
|
+
currentServerName = mcpMatch[1];
|
|
1072
|
+
currentServer = { command: "" };
|
|
1073
|
+
continue;
|
|
1074
|
+
} else {
|
|
1075
|
+
if (inMcpSection) {
|
|
1076
|
+
pastMcpSections = true;
|
|
1077
|
+
inMcpSection = false;
|
|
1078
|
+
}
|
|
1079
|
+
}
|
|
1080
|
+
}
|
|
1081
|
+
if (inMcpSection && currentServerName !== null) {
|
|
1082
|
+
const kvMatch = line.match(/^\s*(\w+)\s*=\s*(.+)$/);
|
|
1083
|
+
if (kvMatch) {
|
|
1084
|
+
const key = kvMatch[1];
|
|
1085
|
+
const rawValue = kvMatch[2].trim();
|
|
1086
|
+
if (key === "command") {
|
|
1087
|
+
currentServer.command = parseTOMLString(rawValue);
|
|
1088
|
+
} else if (key === "args") {
|
|
1089
|
+
currentServer.args = parseTOMLStringArray(rawValue);
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
continue;
|
|
1093
|
+
}
|
|
1094
|
+
if (pastMcpSections || mcpServers.size > 0 && !inMcpSection && currentServerName === null && tableMatch) {
|
|
1095
|
+
postambleLines.push(line);
|
|
1096
|
+
} else if (!inMcpSection && currentServerName === null) {
|
|
1097
|
+
preambleLines.push(line);
|
|
1098
|
+
} else {
|
|
1099
|
+
postambleLines.push(line);
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
if (currentServerName !== null) {
|
|
1103
|
+
mcpServers.set(currentServerName, currentServer);
|
|
1104
|
+
}
|
|
1105
|
+
return { preambleLines, postambleLines, mcpServers };
|
|
1106
|
+
}
|
|
1107
|
+
function parseTOMLString(raw) {
|
|
1108
|
+
const match = raw.match(/^"(.*)"$/);
|
|
1109
|
+
if (match) {
|
|
1110
|
+
return match[1].replace(/\\"/g, '"').replace(/\\\\/g, "\\");
|
|
1111
|
+
}
|
|
1112
|
+
const singleMatch = raw.match(/^'(.*)'$/);
|
|
1113
|
+
if (singleMatch) {
|
|
1114
|
+
return singleMatch[1];
|
|
1115
|
+
}
|
|
1116
|
+
return raw;
|
|
1117
|
+
}
|
|
1118
|
+
function parseTOMLStringArray(raw) {
|
|
1119
|
+
const match = raw.match(/^\[(.*)]\s*$/);
|
|
1120
|
+
if (!match) return [];
|
|
1121
|
+
const inner = match[1].trim();
|
|
1122
|
+
if (!inner) return [];
|
|
1123
|
+
const result = [];
|
|
1124
|
+
const regex = /"([^"\\]*(?:\\.[^"\\]*)*)"|'([^']*)'/g;
|
|
1125
|
+
let m;
|
|
1126
|
+
while ((m = regex.exec(inner)) !== null) {
|
|
1127
|
+
if (m[1] !== void 0) {
|
|
1128
|
+
result.push(m[1].replace(/\\"/g, '"').replace(/\\\\/g, "\\"));
|
|
1129
|
+
} else if (m[2] !== void 0) {
|
|
1130
|
+
result.push(m[2]);
|
|
1131
|
+
}
|
|
1132
|
+
}
|
|
1133
|
+
return result;
|
|
1134
|
+
}
|
|
1135
|
+
function toTOMLString(value) {
|
|
1136
|
+
return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
|
|
1137
|
+
}
|
|
1138
|
+
function toTOMLStringArray(values) {
|
|
1139
|
+
return `[${values.map(toTOMLString).join(", ")}]`;
|
|
1140
|
+
}
|
|
1141
|
+
function generateMcpServersTOML(servers) {
|
|
1142
|
+
const parts = [];
|
|
1143
|
+
for (const server of servers) {
|
|
1144
|
+
const serverName = server.name ?? server.command;
|
|
1145
|
+
parts.push(`[mcp_servers.${serverName}]`);
|
|
1146
|
+
parts.push(`command = ${toTOMLString(server.command)}`);
|
|
1147
|
+
if (server.args && server.args.length > 0) {
|
|
1148
|
+
parts.push(`args = ${toTOMLStringArray(server.args)}`);
|
|
1149
|
+
}
|
|
1150
|
+
parts.push("");
|
|
1151
|
+
}
|
|
1152
|
+
return parts.join("\n");
|
|
1153
|
+
}
|
|
374
1154
|
var CodexAdapter = class extends BaseAdapter {
|
|
375
1155
|
id = "codex";
|
|
376
1156
|
command = "codex";
|
|
1157
|
+
getConfigPath() {
|
|
1158
|
+
return join2(homedir2(), ".codex", "config.toml");
|
|
1159
|
+
}
|
|
377
1160
|
mapFlags(flags) {
|
|
378
1161
|
const args = mapCommonToNative("codex", flags);
|
|
379
1162
|
if (flags.outputFormat === "json") {
|
|
@@ -401,6 +1184,7 @@ var CodexAdapter = class extends BaseAdapter {
|
|
|
401
1184
|
);
|
|
402
1185
|
}
|
|
403
1186
|
try {
|
|
1187
|
+
const { Codex } = await loadCodexSDK();
|
|
404
1188
|
const codexOptions = {};
|
|
405
1189
|
if (flags.mcpContext) {
|
|
406
1190
|
codexOptions.env = {
|
|
@@ -430,6 +1214,107 @@ var CodexAdapter = class extends BaseAdapter {
|
|
|
430
1214
|
};
|
|
431
1215
|
}
|
|
432
1216
|
}
|
|
1217
|
+
async *executeStreaming(flags) {
|
|
1218
|
+
if (!flags.prompt) {
|
|
1219
|
+
throw new Error("executeStreaming requires a prompt (-p flag)");
|
|
1220
|
+
}
|
|
1221
|
+
if (flags.agent) {
|
|
1222
|
+
logger.warn(
|
|
1223
|
+
`Codex CLI does not support --agent flag. Ignoring agent "${flags.agent}".`
|
|
1224
|
+
);
|
|
1225
|
+
}
|
|
1226
|
+
try {
|
|
1227
|
+
const { Codex } = await loadCodexSDK();
|
|
1228
|
+
const codexOptions = {};
|
|
1229
|
+
if (flags.mcpContext) {
|
|
1230
|
+
codexOptions.env = {
|
|
1231
|
+
...process.env,
|
|
1232
|
+
RELAY_TRACE_ID: flags.mcpContext.traceId,
|
|
1233
|
+
RELAY_PARENT_SESSION_ID: flags.mcpContext.parentSessionId,
|
|
1234
|
+
RELAY_DEPTH: String(flags.mcpContext.depth)
|
|
1235
|
+
};
|
|
1236
|
+
}
|
|
1237
|
+
const codex = new Codex(codexOptions);
|
|
1238
|
+
const thread = codex.startThread({
|
|
1239
|
+
...flags.model ? { model: flags.model } : {},
|
|
1240
|
+
workingDirectory: process.cwd(),
|
|
1241
|
+
approvalPolicy: "never"
|
|
1242
|
+
});
|
|
1243
|
+
const streamedTurn = await thread.runStreamed(flags.prompt);
|
|
1244
|
+
const completedMessages = [];
|
|
1245
|
+
for await (const event of streamedTurn.events) {
|
|
1246
|
+
if (event.type === "item.started") {
|
|
1247
|
+
const item = event.item;
|
|
1248
|
+
if (item?.type === "agent_message" && item.text) {
|
|
1249
|
+
yield { type: "text", text: item.text };
|
|
1250
|
+
} else if (item?.type === "command_execution") {
|
|
1251
|
+
yield {
|
|
1252
|
+
type: "tool_start",
|
|
1253
|
+
tool: item.command ?? "command",
|
|
1254
|
+
id: item.id ?? ""
|
|
1255
|
+
};
|
|
1256
|
+
} else if (item?.type === "file_change") {
|
|
1257
|
+
yield {
|
|
1258
|
+
type: "tool_start",
|
|
1259
|
+
tool: "file_change",
|
|
1260
|
+
id: item.id ?? ""
|
|
1261
|
+
};
|
|
1262
|
+
}
|
|
1263
|
+
} else if (event.type === "item.completed") {
|
|
1264
|
+
const item = event.item;
|
|
1265
|
+
if (item?.type === "agent_message" && item.text) {
|
|
1266
|
+
completedMessages.push(item.text);
|
|
1267
|
+
yield { type: "text", text: item.text };
|
|
1268
|
+
} else if (item?.type === "command_execution") {
|
|
1269
|
+
yield {
|
|
1270
|
+
type: "tool_end",
|
|
1271
|
+
tool: item.command ?? "command",
|
|
1272
|
+
id: item.id ?? "",
|
|
1273
|
+
result: item.aggregated_output
|
|
1274
|
+
};
|
|
1275
|
+
} else if (item?.type === "file_change") {
|
|
1276
|
+
yield {
|
|
1277
|
+
type: "tool_end",
|
|
1278
|
+
tool: "file_change",
|
|
1279
|
+
id: item.id ?? ""
|
|
1280
|
+
};
|
|
1281
|
+
}
|
|
1282
|
+
} else if (event.type === "turn.completed") {
|
|
1283
|
+
const usage = event.usage;
|
|
1284
|
+
if (usage) {
|
|
1285
|
+
yield {
|
|
1286
|
+
type: "usage",
|
|
1287
|
+
inputTokens: (usage.input_tokens ?? 0) + (usage.cached_input_tokens ?? 0),
|
|
1288
|
+
outputTokens: usage.output_tokens ?? 0
|
|
1289
|
+
};
|
|
1290
|
+
}
|
|
1291
|
+
const finalResponse = completedMessages.join("\n");
|
|
1292
|
+
yield {
|
|
1293
|
+
type: "done",
|
|
1294
|
+
result: { exitCode: 0, stdout: finalResponse, stderr: "" }
|
|
1295
|
+
};
|
|
1296
|
+
} else if (event.type === "turn.failed") {
|
|
1297
|
+
const errorMessage = event.error?.message ?? "Turn failed";
|
|
1298
|
+
yield {
|
|
1299
|
+
type: "done",
|
|
1300
|
+
result: { exitCode: 1, stdout: "", stderr: errorMessage }
|
|
1301
|
+
};
|
|
1302
|
+
} else if (event.type === "error") {
|
|
1303
|
+
yield {
|
|
1304
|
+
type: "error",
|
|
1305
|
+
message: event.message ?? "Unknown error"
|
|
1306
|
+
};
|
|
1307
|
+
}
|
|
1308
|
+
}
|
|
1309
|
+
} catch (error) {
|
|
1310
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
1311
|
+
yield { type: "error", message: errorMessage };
|
|
1312
|
+
yield {
|
|
1313
|
+
type: "done",
|
|
1314
|
+
result: { exitCode: 1, stdout: "", stderr: errorMessage }
|
|
1315
|
+
};
|
|
1316
|
+
}
|
|
1317
|
+
}
|
|
433
1318
|
async resumeSession(sessionId, flags) {
|
|
434
1319
|
const args = [];
|
|
435
1320
|
if (flags.model) {
|
|
@@ -467,12 +1352,62 @@ var CodexAdapter = class extends BaseAdapter {
|
|
|
467
1352
|
logger.success(`Codex CLI updated: ${result.stdout.trim()}`);
|
|
468
1353
|
}
|
|
469
1354
|
}
|
|
1355
|
+
async getMCPConfig() {
|
|
1356
|
+
const configPath = this.getConfigPath();
|
|
1357
|
+
try {
|
|
1358
|
+
const raw = await readFile2(configPath, "utf-8");
|
|
1359
|
+
const parsed = parseTOMLMcpServers(raw);
|
|
1360
|
+
return Array.from(parsed.mcpServers.entries()).map(
|
|
1361
|
+
([name, entry]) => {
|
|
1362
|
+
const server = {
|
|
1363
|
+
name,
|
|
1364
|
+
command: entry.command
|
|
1365
|
+
};
|
|
1366
|
+
if (entry.args && entry.args.length > 0) {
|
|
1367
|
+
server.args = entry.args;
|
|
1368
|
+
}
|
|
1369
|
+
return server;
|
|
1370
|
+
}
|
|
1371
|
+
);
|
|
1372
|
+
} catch {
|
|
1373
|
+
return [];
|
|
1374
|
+
}
|
|
1375
|
+
}
|
|
1376
|
+
async setMCPConfig(servers) {
|
|
1377
|
+
const configPath = this.getConfigPath();
|
|
1378
|
+
let existingContent = "";
|
|
1379
|
+
try {
|
|
1380
|
+
existingContent = await readFile2(configPath, "utf-8");
|
|
1381
|
+
} catch {
|
|
1382
|
+
}
|
|
1383
|
+
const parsed = parseTOMLMcpServers(existingContent);
|
|
1384
|
+
let preamble = parsed.preambleLines.join("\n");
|
|
1385
|
+
preamble = preamble.replace(/\n+$/, "");
|
|
1386
|
+
const mcpSection = generateMcpServersTOML(servers);
|
|
1387
|
+
let postamble = parsed.postambleLines.join("\n");
|
|
1388
|
+
postamble = postamble.replace(/^\n+/, "");
|
|
1389
|
+
const parts = [];
|
|
1390
|
+
if (preamble) parts.push(preamble);
|
|
1391
|
+
if (mcpSection) parts.push(mcpSection);
|
|
1392
|
+
if (postamble) parts.push(postamble);
|
|
1393
|
+
let output = parts.join("\n\n");
|
|
1394
|
+
if (!output.endsWith("\n")) output += "\n";
|
|
1395
|
+
await mkdir2(dirname2(configPath), { recursive: true });
|
|
1396
|
+
await writeFile2(configPath, output, { mode: 384 });
|
|
1397
|
+
}
|
|
470
1398
|
};
|
|
471
1399
|
|
|
472
1400
|
// src/adapters/gemini-adapter.ts
|
|
1401
|
+
init_logger();
|
|
1402
|
+
import { readFile as readFile3, writeFile as writeFile3, mkdir as mkdir3 } from "fs/promises";
|
|
1403
|
+
import { homedir as homedir3 } from "os";
|
|
1404
|
+
import { join as join3, dirname as dirname3 } from "path";
|
|
473
1405
|
var GeminiAdapter = class extends BaseAdapter {
|
|
474
1406
|
id = "gemini";
|
|
475
1407
|
command = "gemini";
|
|
1408
|
+
getConfigPath() {
|
|
1409
|
+
return join3(homedir3(), ".gemini", "settings.json");
|
|
1410
|
+
}
|
|
476
1411
|
mapFlags(flags) {
|
|
477
1412
|
const args = mapCommonToNative("gemini", flags);
|
|
478
1413
|
if (flags.outputFormat) {
|
|
@@ -576,18 +1511,64 @@ var GeminiAdapter = class extends BaseAdapter {
|
|
|
576
1511
|
logger.success(`Gemini CLI updated: ${result.stdout.trim()}`);
|
|
577
1512
|
}
|
|
578
1513
|
}
|
|
1514
|
+
async getMCPConfig() {
|
|
1515
|
+
const configPath = this.getConfigPath();
|
|
1516
|
+
try {
|
|
1517
|
+
const raw = await readFile3(configPath, "utf-8");
|
|
1518
|
+
const config = JSON.parse(raw);
|
|
1519
|
+
const mcpServers = config.mcpServers;
|
|
1520
|
+
if (!mcpServers || typeof mcpServers !== "object") {
|
|
1521
|
+
return [];
|
|
1522
|
+
}
|
|
1523
|
+
return Object.entries(mcpServers).map(([name, entry]) => {
|
|
1524
|
+
const server = {
|
|
1525
|
+
name,
|
|
1526
|
+
command: entry.command
|
|
1527
|
+
};
|
|
1528
|
+
if (entry.args) server.args = entry.args;
|
|
1529
|
+
if (entry.env) server.env = entry.env;
|
|
1530
|
+
return server;
|
|
1531
|
+
});
|
|
1532
|
+
} catch {
|
|
1533
|
+
return [];
|
|
1534
|
+
}
|
|
1535
|
+
}
|
|
1536
|
+
async setMCPConfig(servers) {
|
|
1537
|
+
const configPath = this.getConfigPath();
|
|
1538
|
+
let config = {};
|
|
1539
|
+
try {
|
|
1540
|
+
const raw = await readFile3(configPath, "utf-8");
|
|
1541
|
+
config = JSON.parse(raw);
|
|
1542
|
+
} catch {
|
|
1543
|
+
}
|
|
1544
|
+
const mcpServers = {};
|
|
1545
|
+
for (const server of servers) {
|
|
1546
|
+
const entry = {
|
|
1547
|
+
command: server.command
|
|
1548
|
+
};
|
|
1549
|
+
if (server.args) entry.args = server.args;
|
|
1550
|
+
if (server.env) entry.env = server.env;
|
|
1551
|
+
const serverName = server.name ?? server.command;
|
|
1552
|
+
mcpServers[serverName] = entry;
|
|
1553
|
+
}
|
|
1554
|
+
config.mcpServers = mcpServers;
|
|
1555
|
+
await mkdir3(dirname3(configPath), { recursive: true });
|
|
1556
|
+
await writeFile3(configPath, JSON.stringify(config, null, 2) + "\n", {
|
|
1557
|
+
mode: 384
|
|
1558
|
+
});
|
|
1559
|
+
}
|
|
579
1560
|
};
|
|
580
1561
|
|
|
581
1562
|
// src/core/session-manager.ts
|
|
582
|
-
import { readFile, writeFile, readdir, mkdir } from "fs/promises";
|
|
583
|
-
import { join } from "path";
|
|
584
|
-
import { homedir } from "os";
|
|
1563
|
+
import { readFile as readFile4, writeFile as writeFile4, readdir, mkdir as mkdir4, chmod } from "fs/promises";
|
|
1564
|
+
import { join as join4 } from "path";
|
|
1565
|
+
import { homedir as homedir4 } from "os";
|
|
585
1566
|
import { nanoid } from "nanoid";
|
|
586
1567
|
function getRelayHome() {
|
|
587
|
-
return process.env["RELAY_HOME"] ??
|
|
1568
|
+
return process.env["RELAY_HOME"] ?? join4(homedir4(), ".relay");
|
|
588
1569
|
}
|
|
589
1570
|
function getSessionsDir(relayHome2) {
|
|
590
|
-
return
|
|
1571
|
+
return join4(relayHome2, "sessions");
|
|
591
1572
|
}
|
|
592
1573
|
function toSessionData(session) {
|
|
593
1574
|
return {
|
|
@@ -603,17 +1584,21 @@ function fromSessionData(data) {
|
|
|
603
1584
|
updatedAt: new Date(data.updatedAt)
|
|
604
1585
|
};
|
|
605
1586
|
}
|
|
606
|
-
var SessionManager = class {
|
|
1587
|
+
var SessionManager = class _SessionManager {
|
|
1588
|
+
static SESSION_ID_PATTERN = /^relay-[A-Za-z0-9_-]+$/;
|
|
607
1589
|
sessionsDir;
|
|
608
1590
|
constructor(sessionsDir) {
|
|
609
1591
|
this.sessionsDir = sessionsDir ?? getSessionsDir(getRelayHome());
|
|
610
1592
|
}
|
|
611
1593
|
/** Ensure the sessions directory exists. */
|
|
612
1594
|
async ensureDir() {
|
|
613
|
-
await
|
|
1595
|
+
await mkdir4(this.sessionsDir, { recursive: true });
|
|
614
1596
|
}
|
|
615
1597
|
sessionPath(relaySessionId) {
|
|
616
|
-
|
|
1598
|
+
if (!_SessionManager.SESSION_ID_PATTERN.test(relaySessionId)) {
|
|
1599
|
+
throw new Error(`Invalid session ID: ${relaySessionId}`);
|
|
1600
|
+
}
|
|
1601
|
+
return join4(this.sessionsDir, `${relaySessionId}.json`);
|
|
617
1602
|
}
|
|
618
1603
|
/** Create a new relay session. */
|
|
619
1604
|
async create(params) {
|
|
@@ -629,11 +1614,13 @@ var SessionManager = class {
|
|
|
629
1614
|
updatedAt: now,
|
|
630
1615
|
status: "active"
|
|
631
1616
|
};
|
|
632
|
-
|
|
633
|
-
|
|
1617
|
+
const sessionFilePath = this.sessionPath(session.relaySessionId);
|
|
1618
|
+
await writeFile4(
|
|
1619
|
+
sessionFilePath,
|
|
634
1620
|
JSON.stringify(toSessionData(session), null, 2),
|
|
635
1621
|
"utf-8"
|
|
636
1622
|
);
|
|
1623
|
+
await chmod(sessionFilePath, 384);
|
|
637
1624
|
return session;
|
|
638
1625
|
}
|
|
639
1626
|
/** Update an existing session. */
|
|
@@ -647,16 +1634,19 @@ var SessionManager = class {
|
|
|
647
1634
|
...updates,
|
|
648
1635
|
updatedAt: /* @__PURE__ */ new Date()
|
|
649
1636
|
};
|
|
650
|
-
|
|
651
|
-
|
|
1637
|
+
const updateFilePath = this.sessionPath(relaySessionId);
|
|
1638
|
+
await writeFile4(
|
|
1639
|
+
updateFilePath,
|
|
652
1640
|
JSON.stringify(toSessionData(updated), null, 2),
|
|
653
1641
|
"utf-8"
|
|
654
1642
|
);
|
|
1643
|
+
await chmod(updateFilePath, 384);
|
|
655
1644
|
}
|
|
656
1645
|
/** Get a session by relay session ID. */
|
|
657
1646
|
async get(relaySessionId) {
|
|
1647
|
+
const filePath = this.sessionPath(relaySessionId);
|
|
658
1648
|
try {
|
|
659
|
-
const raw = await
|
|
1649
|
+
const raw = await readFile4(filePath, "utf-8");
|
|
660
1650
|
return fromSessionData(JSON.parse(raw));
|
|
661
1651
|
} catch {
|
|
662
1652
|
return null;
|
|
@@ -680,7 +1670,7 @@ var SessionManager = class {
|
|
|
680
1670
|
const sessions = [];
|
|
681
1671
|
for (const file of jsonFiles) {
|
|
682
1672
|
try {
|
|
683
|
-
const raw = await
|
|
1673
|
+
const raw = await readFile4(join4(this.sessionsDir, file), "utf-8");
|
|
684
1674
|
const session = fromSessionData(JSON.parse(raw));
|
|
685
1675
|
if (filter?.backendId && session.backendId !== filter.backendId) {
|
|
686
1676
|
continue;
|
|
@@ -700,14 +1690,14 @@ var SessionManager = class {
|
|
|
700
1690
|
};
|
|
701
1691
|
|
|
702
1692
|
// src/core/config-manager.ts
|
|
703
|
-
import { readFile as
|
|
704
|
-
import { join as
|
|
1693
|
+
import { readFile as readFile5, writeFile as writeFile5, mkdir as mkdir5, chmod as chmod2 } from "fs/promises";
|
|
1694
|
+
import { join as join5 } from "path";
|
|
705
1695
|
|
|
706
1696
|
// src/schemas/config.schema.ts
|
|
707
1697
|
import { z } from "zod";
|
|
708
1698
|
var backendIdSchema = z.enum(["claude", "codex", "gemini"]);
|
|
709
1699
|
var mcpServerConfigSchema = z.object({
|
|
710
|
-
name: z.string(),
|
|
1700
|
+
name: z.string().optional(),
|
|
711
1701
|
command: z.string(),
|
|
712
1702
|
args: z.array(z.string()).optional(),
|
|
713
1703
|
env: z.record(z.string()).optional()
|
|
@@ -752,10 +1742,12 @@ var relayConfigSchema = z.object({
|
|
|
752
1742
|
}).optional(),
|
|
753
1743
|
telemetry: z.object({
|
|
754
1744
|
enabled: z.boolean()
|
|
755
|
-
}).optional()
|
|
1745
|
+
}).optional(),
|
|
1746
|
+
claudePermissionMode: z.enum(["default", "bypassPermissions"]).optional()
|
|
756
1747
|
});
|
|
757
1748
|
|
|
758
1749
|
// src/core/config-manager.ts
|
|
1750
|
+
init_logger();
|
|
759
1751
|
function deepMerge(target, source) {
|
|
760
1752
|
const result = { ...target };
|
|
761
1753
|
for (const key of Object.keys(source)) {
|
|
@@ -843,8 +1835,9 @@ var ConfigManager = class {
|
|
|
843
1835
|
const existing = await this.readConfigFile(filePath);
|
|
844
1836
|
setByPath(existing, key, value);
|
|
845
1837
|
const dir = filePath.substring(0, filePath.lastIndexOf("/"));
|
|
846
|
-
await
|
|
847
|
-
await
|
|
1838
|
+
await mkdir5(dir, { recursive: true });
|
|
1839
|
+
await writeFile5(filePath, JSON.stringify(existing, null, 2), "utf-8");
|
|
1840
|
+
await chmod2(filePath, 384);
|
|
848
1841
|
}
|
|
849
1842
|
/**
|
|
850
1843
|
* Syncs MCP server config to all registered backend adapters.
|
|
@@ -852,7 +1845,12 @@ var ConfigManager = class {
|
|
|
852
1845
|
async syncMCPConfig(registry2) {
|
|
853
1846
|
const config = await this.getConfig();
|
|
854
1847
|
const mcpServers = config.mcpServers ?? {};
|
|
855
|
-
const servers = Object.
|
|
1848
|
+
const servers = Object.entries(mcpServers).map(
|
|
1849
|
+
([key, server]) => ({
|
|
1850
|
+
...server,
|
|
1851
|
+
name: server.name || key
|
|
1852
|
+
})
|
|
1853
|
+
);
|
|
856
1854
|
const adapters = registry2.list();
|
|
857
1855
|
for (const adapter of adapters) {
|
|
858
1856
|
try {
|
|
@@ -869,11 +1867,11 @@ var ConfigManager = class {
|
|
|
869
1867
|
getFilePath(scope) {
|
|
870
1868
|
switch (scope) {
|
|
871
1869
|
case "global":
|
|
872
|
-
return
|
|
1870
|
+
return join5(this.globalDir, "config.json");
|
|
873
1871
|
case "project":
|
|
874
|
-
return
|
|
1872
|
+
return join5(this.projectDir ?? "", "config.json");
|
|
875
1873
|
case "local":
|
|
876
|
-
return
|
|
1874
|
+
return join5(this.projectDir ?? "", "config.local.json");
|
|
877
1875
|
}
|
|
878
1876
|
}
|
|
879
1877
|
mapConfigScope(scope) {
|
|
@@ -890,7 +1888,7 @@ var ConfigManager = class {
|
|
|
890
1888
|
}
|
|
891
1889
|
async readConfigFile(filePath) {
|
|
892
1890
|
try {
|
|
893
|
-
const raw = await
|
|
1891
|
+
const raw = await readFile5(filePath, "utf-8");
|
|
894
1892
|
const parsed = JSON.parse(raw);
|
|
895
1893
|
if (!isPlainObject(parsed)) return {};
|
|
896
1894
|
return parsed;
|
|
@@ -919,6 +1917,7 @@ var ConfigManager = class {
|
|
|
919
1917
|
};
|
|
920
1918
|
|
|
921
1919
|
// src/core/auth-manager.ts
|
|
1920
|
+
init_logger();
|
|
922
1921
|
var AuthManager = class {
|
|
923
1922
|
constructor(registry2) {
|
|
924
1923
|
this.registry = registry2;
|
|
@@ -992,24 +1991,61 @@ var EventBus = class {
|
|
|
992
1991
|
};
|
|
993
1992
|
|
|
994
1993
|
// src/core/hooks-engine.ts
|
|
1994
|
+
init_logger();
|
|
995
1995
|
var DEFAULT_TIMEOUT_MS = 1e4;
|
|
996
1996
|
var DEFAULT_HOOK_OUTPUT = {
|
|
997
1997
|
allow: true,
|
|
998
1998
|
message: "",
|
|
999
1999
|
metadata: {}
|
|
1000
2000
|
};
|
|
1001
|
-
var HooksEngine = class {
|
|
2001
|
+
var HooksEngine = class _HooksEngine {
|
|
1002
2002
|
constructor(eventBus2, processManager2) {
|
|
1003
2003
|
this.eventBus = eventBus2;
|
|
1004
2004
|
this.processManager = processManager2;
|
|
1005
2005
|
}
|
|
2006
|
+
static COMMAND_PATTERN = /^[a-zA-Z0-9_./-]+$/;
|
|
2007
|
+
static ARG_PATTERN = /^[a-zA-Z0-9_.=:/-]+$/;
|
|
1006
2008
|
definitions = [];
|
|
1007
2009
|
registered = false;
|
|
2010
|
+
validateCommand(command) {
|
|
2011
|
+
if (!command || command.trim().length === 0) {
|
|
2012
|
+
throw new Error("Hook command cannot be empty");
|
|
2013
|
+
}
|
|
2014
|
+
if (!_HooksEngine.COMMAND_PATTERN.test(command)) {
|
|
2015
|
+
throw new Error(
|
|
2016
|
+
`Hook command contains invalid characters: "${command}". Only alphanumeric characters, dots, underscores, hyphens, and slashes are allowed.`
|
|
2017
|
+
);
|
|
2018
|
+
}
|
|
2019
|
+
if (command.includes("..")) {
|
|
2020
|
+
throw new Error(
|
|
2021
|
+
`Hook command contains path traversal: "${command}"`
|
|
2022
|
+
);
|
|
2023
|
+
}
|
|
2024
|
+
}
|
|
2025
|
+
validateArgs(args) {
|
|
2026
|
+
for (const arg of args) {
|
|
2027
|
+
if (!_HooksEngine.ARG_PATTERN.test(arg)) {
|
|
2028
|
+
throw new Error(
|
|
2029
|
+
`Hook argument contains invalid characters: "${arg}". Only alphanumeric characters, dots, underscores, equals, colons, hyphens, and slashes are allowed.`
|
|
2030
|
+
);
|
|
2031
|
+
}
|
|
2032
|
+
}
|
|
2033
|
+
}
|
|
1008
2034
|
/** Load hook definitions from config and register listeners on EventBus */
|
|
1009
2035
|
loadConfig(config) {
|
|
1010
|
-
this.definitions = config.definitions.filter(
|
|
1011
|
-
(def
|
|
1012
|
-
|
|
2036
|
+
this.definitions = config.definitions.filter((def) => {
|
|
2037
|
+
if (def.enabled === false) return false;
|
|
2038
|
+
try {
|
|
2039
|
+
this.validateCommand(def.command);
|
|
2040
|
+
this.validateArgs(def.args ?? []);
|
|
2041
|
+
return true;
|
|
2042
|
+
} catch (error) {
|
|
2043
|
+
logger.warn(
|
|
2044
|
+
`Skipping hook with invalid command: ${error instanceof Error ? error.message : String(error)}`
|
|
2045
|
+
);
|
|
2046
|
+
return false;
|
|
2047
|
+
}
|
|
2048
|
+
});
|
|
1013
2049
|
if (!this.registered) {
|
|
1014
2050
|
for (const event of this.getUniqueEvents()) {
|
|
1015
2051
|
this.eventBus.on(event, async () => {
|
|
@@ -1056,6 +2092,8 @@ var HooksEngine = class {
|
|
|
1056
2092
|
return this.definitions.filter((def) => def.event === event).length;
|
|
1057
2093
|
}
|
|
1058
2094
|
async executeHook(def, input) {
|
|
2095
|
+
this.validateCommand(def.command);
|
|
2096
|
+
this.validateArgs(def.args ?? []);
|
|
1059
2097
|
const timeoutMs = def.timeout ?? DEFAULT_TIMEOUT_MS;
|
|
1060
2098
|
const stdinData = JSON.stringify(input);
|
|
1061
2099
|
const startTime = Date.now();
|
|
@@ -1157,6 +2195,7 @@ var ContextMonitor = class {
|
|
|
1157
2195
|
};
|
|
1158
2196
|
|
|
1159
2197
|
// src/commands/backend.ts
|
|
2198
|
+
init_logger();
|
|
1160
2199
|
import { defineCommand } from "citty";
|
|
1161
2200
|
|
|
1162
2201
|
// src/adapters/install-guides.ts
|
|
@@ -1167,7 +2206,7 @@ var INSTALL_GUIDES = {
|
|
|
1167
2206
|
};
|
|
1168
2207
|
|
|
1169
2208
|
// src/commands/backend.ts
|
|
1170
|
-
function createBackendCommand(backendId, registry2, sessionManager2, hooksEngine2) {
|
|
2209
|
+
function createBackendCommand(backendId, registry2, sessionManager2, hooksEngine2, contextMonitor2) {
|
|
1171
2210
|
return defineCommand({
|
|
1172
2211
|
meta: {
|
|
1173
2212
|
name: backendId,
|
|
@@ -1208,6 +2247,11 @@ function createBackendCommand(backendId, registry2, sessionManager2, hooksEngine
|
|
|
1208
2247
|
verbose: {
|
|
1209
2248
|
type: "boolean",
|
|
1210
2249
|
description: "Enable verbose output"
|
|
2250
|
+
},
|
|
2251
|
+
dryRun: {
|
|
2252
|
+
type: "boolean",
|
|
2253
|
+
description: "Show what would be executed without running it",
|
|
2254
|
+
default: false
|
|
1211
2255
|
}
|
|
1212
2256
|
},
|
|
1213
2257
|
async run({ args }) {
|
|
@@ -1231,6 +2275,23 @@ function createBackendCommand(backendId, registry2, sessionManager2, hooksEngine
|
|
|
1231
2275
|
flags.outputFormat = args.outputFormat;
|
|
1232
2276
|
}
|
|
1233
2277
|
if (args.verbose) flags.verbose = true;
|
|
2278
|
+
if (args.dryRun) {
|
|
2279
|
+
const nativeFlags = adapter.mapFlags(flags);
|
|
2280
|
+
const dryRunOutput = {
|
|
2281
|
+
backend: backendId,
|
|
2282
|
+
installed: await adapter.isInstalled(),
|
|
2283
|
+
nativeArgs: nativeFlags.args,
|
|
2284
|
+
relayEnv: {
|
|
2285
|
+
RELAY_HOME: process.env["RELAY_HOME"] ?? "~/.relay",
|
|
2286
|
+
RELAY_LOG_LEVEL: process.env["RELAY_LOG_LEVEL"] ?? "info"
|
|
2287
|
+
},
|
|
2288
|
+
sessionTracking: !!sessionManager2,
|
|
2289
|
+
hooksEnabled: !!hooksEngine2,
|
|
2290
|
+
contextMonitorEnabled: !!contextMonitor2
|
|
2291
|
+
};
|
|
2292
|
+
console.log(JSON.stringify(dryRunOutput, null, 2));
|
|
2293
|
+
return;
|
|
2294
|
+
}
|
|
1234
2295
|
let relaySessionId;
|
|
1235
2296
|
if (sessionManager2) {
|
|
1236
2297
|
try {
|
|
@@ -1262,10 +2323,73 @@ function createBackendCommand(backendId, registry2, sessionManager2, hooksEngine
|
|
|
1262
2323
|
try {
|
|
1263
2324
|
if (flags.prompt) {
|
|
1264
2325
|
logger.debug(`Executing prompt on ${backendId}`);
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
2326
|
+
if (adapter.executeStreaming) {
|
|
2327
|
+
for await (const event of adapter.executeStreaming(flags)) {
|
|
2328
|
+
switch (event.type) {
|
|
2329
|
+
case "text":
|
|
2330
|
+
process.stdout.write(event.text);
|
|
2331
|
+
break;
|
|
2332
|
+
case "tool_start":
|
|
2333
|
+
if (flags.verbose) {
|
|
2334
|
+
process.stderr.write(`
|
|
2335
|
+
[${event.tool}] started
|
|
2336
|
+
`);
|
|
2337
|
+
}
|
|
2338
|
+
break;
|
|
2339
|
+
case "tool_end":
|
|
2340
|
+
if (flags.verbose) {
|
|
2341
|
+
process.stderr.write(`[${event.tool}] completed
|
|
2342
|
+
`);
|
|
2343
|
+
}
|
|
2344
|
+
break;
|
|
2345
|
+
case "status":
|
|
2346
|
+
if (flags.verbose) {
|
|
2347
|
+
process.stderr.write(`[status] ${event.message}
|
|
2348
|
+
`);
|
|
2349
|
+
}
|
|
2350
|
+
break;
|
|
2351
|
+
case "error":
|
|
2352
|
+
process.stderr.write(event.message);
|
|
2353
|
+
break;
|
|
2354
|
+
case "usage": {
|
|
2355
|
+
if (contextMonitor2 && relaySessionId) {
|
|
2356
|
+
const maxTokens = backendId === "gemini" ? 128e3 : 2e5;
|
|
2357
|
+
contextMonitor2.updateUsage(
|
|
2358
|
+
relaySessionId,
|
|
2359
|
+
backendId,
|
|
2360
|
+
event.inputTokens + event.outputTokens,
|
|
2361
|
+
maxTokens
|
|
2362
|
+
);
|
|
2363
|
+
}
|
|
2364
|
+
break;
|
|
2365
|
+
}
|
|
2366
|
+
case "done":
|
|
2367
|
+
process.exitCode = event.result.exitCode;
|
|
2368
|
+
if (event.nativeSessionId && sessionManager2 && relaySessionId) {
|
|
2369
|
+
try {
|
|
2370
|
+
await sessionManager2.update(relaySessionId, {
|
|
2371
|
+
nativeSessionId: event.nativeSessionId
|
|
2372
|
+
});
|
|
2373
|
+
} catch {
|
|
2374
|
+
}
|
|
2375
|
+
}
|
|
2376
|
+
break;
|
|
2377
|
+
}
|
|
2378
|
+
}
|
|
2379
|
+
} else {
|
|
2380
|
+
const result = await adapter.execute(flags);
|
|
2381
|
+
if (result.stdout) process.stdout.write(result.stdout);
|
|
2382
|
+
if (result.stderr) process.stderr.write(result.stderr);
|
|
2383
|
+
process.exitCode = result.exitCode;
|
|
2384
|
+
if (result.nativeSessionId && sessionManager2 && relaySessionId) {
|
|
2385
|
+
try {
|
|
2386
|
+
await sessionManager2.update(relaySessionId, {
|
|
2387
|
+
nativeSessionId: result.nativeSessionId
|
|
2388
|
+
});
|
|
2389
|
+
} catch {
|
|
2390
|
+
}
|
|
2391
|
+
}
|
|
2392
|
+
}
|
|
1269
2393
|
} else if (flags.continue) {
|
|
1270
2394
|
logger.debug(`Continuing latest session on ${backendId}`);
|
|
1271
2395
|
if (sessionManager2) {
|
|
@@ -1332,502 +2456,193 @@ function createBackendCommand(backendId, registry2, sessionManager2, hooksEngine
|
|
|
1332
2456
|
data: {
|
|
1333
2457
|
error: error instanceof Error ? error.message : String(error)
|
|
1334
2458
|
}
|
|
1335
|
-
};
|
|
1336
|
-
await hooksEngine2.emit("on-error", hookInput);
|
|
1337
|
-
} catch {
|
|
1338
|
-
}
|
|
1339
|
-
}
|
|
1340
|
-
throw error;
|
|
1341
|
-
}
|
|
1342
|
-
}
|
|
1343
|
-
});
|
|
1344
|
-
}
|
|
1345
|
-
|
|
1346
|
-
// src/commands/update.ts
|
|
1347
|
-
import { defineCommand as defineCommand2 } from "citty";
|
|
1348
|
-
function createUpdateCommand(registry2) {
|
|
1349
|
-
return defineCommand2({
|
|
1350
|
-
meta: {
|
|
1351
|
-
name: "update",
|
|
1352
|
-
description: "Update all installed backend CLI tools"
|
|
1353
|
-
},
|
|
1354
|
-
async run() {
|
|
1355
|
-
const adapters = registry2.list();
|
|
1356
|
-
if (adapters.length === 0) {
|
|
1357
|
-
logger.warn("No backends registered");
|
|
1358
|
-
return;
|
|
1359
|
-
}
|
|
1360
|
-
for (const adapter of adapters) {
|
|
1361
|
-
const installed = await adapter.isInstalled();
|
|
1362
|
-
if (!installed) {
|
|
1363
|
-
logger.info(`Skipping ${adapter.id}: not installed`);
|
|
1364
|
-
continue;
|
|
1365
|
-
}
|
|
1366
|
-
logger.info(`Updating ${adapter.id}...`);
|
|
1367
|
-
try {
|
|
1368
|
-
await adapter.update();
|
|
1369
|
-
} catch (error) {
|
|
1370
|
-
logger.error(
|
|
1371
|
-
`Failed to update ${adapter.id}: ${error instanceof Error ? error.message : String(error)}`
|
|
1372
|
-
);
|
|
1373
|
-
}
|
|
1374
|
-
}
|
|
1375
|
-
}
|
|
1376
|
-
});
|
|
1377
|
-
}
|
|
1378
|
-
|
|
1379
|
-
// src/commands/config.ts
|
|
1380
|
-
import { defineCommand as defineCommand3 } from "citty";
|
|
1381
|
-
function createConfigCommand(configManager2) {
|
|
1382
|
-
return defineCommand3({
|
|
1383
|
-
meta: {
|
|
1384
|
-
name: "config",
|
|
1385
|
-
description: "Manage relay configuration"
|
|
1386
|
-
},
|
|
1387
|
-
subCommands: {
|
|
1388
|
-
get: defineCommand3({
|
|
1389
|
-
meta: {
|
|
1390
|
-
name: "get",
|
|
1391
|
-
description: "Get a config value by key (dot-path notation)"
|
|
1392
|
-
},
|
|
1393
|
-
args: {
|
|
1394
|
-
key: {
|
|
1395
|
-
type: "positional",
|
|
1396
|
-
description: "Config key (e.g. mcpServerMode.maxDepth)",
|
|
1397
|
-
required: true
|
|
1398
|
-
}
|
|
1399
|
-
},
|
|
1400
|
-
async run({ args }) {
|
|
1401
|
-
const key = String(args.key);
|
|
1402
|
-
const value = await configManager2.get(key);
|
|
1403
|
-
if (value === void 0) {
|
|
1404
|
-
logger.info(`Key "${key}" is not set`);
|
|
1405
|
-
} else {
|
|
1406
|
-
const output = typeof value === "object" ? JSON.stringify(value, null, 2) : String(value);
|
|
1407
|
-
console.log(output);
|
|
1408
|
-
}
|
|
1409
|
-
}
|
|
1410
|
-
}),
|
|
1411
|
-
set: defineCommand3({
|
|
1412
|
-
meta: {
|
|
1413
|
-
name: "set",
|
|
1414
|
-
description: "Set a config value (default: global scope)"
|
|
1415
|
-
},
|
|
1416
|
-
args: {
|
|
1417
|
-
project: {
|
|
1418
|
-
type: "boolean",
|
|
1419
|
-
description: "Write to project scope instead of global"
|
|
1420
|
-
},
|
|
1421
|
-
key: {
|
|
1422
|
-
type: "positional",
|
|
1423
|
-
description: "Config key (e.g. defaultBackend)",
|
|
1424
|
-
required: true
|
|
1425
|
-
},
|
|
1426
|
-
value: {
|
|
1427
|
-
type: "positional",
|
|
1428
|
-
description: "Config value",
|
|
1429
|
-
required: true
|
|
1430
|
-
}
|
|
1431
|
-
},
|
|
1432
|
-
async run({ args }) {
|
|
1433
|
-
const scope = args.project ? "project" : "global";
|
|
1434
|
-
const key = String(args.key);
|
|
1435
|
-
const rawValue = String(args.value);
|
|
1436
|
-
const parsed = parseValue(rawValue);
|
|
1437
|
-
await configManager2.set(scope, key, parsed);
|
|
1438
|
-
logger.success(`Set ${key} = ${JSON.stringify(parsed)} (${scope})`);
|
|
1439
|
-
}
|
|
1440
|
-
})
|
|
1441
|
-
},
|
|
1442
|
-
async run() {
|
|
1443
|
-
const config = await configManager2.getConfig();
|
|
1444
|
-
console.log(JSON.stringify(config, null, 2));
|
|
1445
|
-
}
|
|
1446
|
-
});
|
|
1447
|
-
}
|
|
1448
|
-
function parseValue(raw) {
|
|
1449
|
-
if (raw === "true") return true;
|
|
1450
|
-
if (raw === "false") return false;
|
|
1451
|
-
if (raw === "null") return null;
|
|
1452
|
-
const num = Number(raw);
|
|
1453
|
-
if (!Number.isNaN(num) && raw.trim() !== "") return num;
|
|
1454
|
-
try {
|
|
1455
|
-
const parsed = JSON.parse(raw);
|
|
1456
|
-
if (typeof parsed === "object") return parsed;
|
|
1457
|
-
} catch {
|
|
1458
|
-
}
|
|
1459
|
-
return raw;
|
|
1460
|
-
}
|
|
1461
|
-
|
|
1462
|
-
// src/commands/mcp.ts
|
|
1463
|
-
import { defineCommand as defineCommand4 } from "citty";
|
|
1464
|
-
|
|
1465
|
-
// src/mcp-server/server.ts
|
|
1466
|
-
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
1467
|
-
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
1468
|
-
import { z as z5 } from "zod";
|
|
1469
|
-
|
|
1470
|
-
// src/mcp-server/recursion-guard.ts
|
|
1471
|
-
import { createHash } from "crypto";
|
|
1472
|
-
var RecursionGuard = class {
|
|
1473
|
-
constructor(config = {
|
|
1474
|
-
maxDepth: 5,
|
|
1475
|
-
maxCallsPerSession: 20,
|
|
1476
|
-
timeoutSec: 300
|
|
1477
|
-
}) {
|
|
1478
|
-
this.config = config;
|
|
1479
|
-
}
|
|
1480
|
-
callCounts = /* @__PURE__ */ new Map();
|
|
1481
|
-
promptHashes = /* @__PURE__ */ new Map();
|
|
1482
|
-
/** Check if a spawn is allowed */
|
|
1483
|
-
canSpawn(context) {
|
|
1484
|
-
if (context.depth >= this.config.maxDepth) {
|
|
1485
|
-
return {
|
|
1486
|
-
allowed: false,
|
|
1487
|
-
reason: `Max depth exceeded: ${context.depth} >= ${this.config.maxDepth}`
|
|
1488
|
-
};
|
|
1489
|
-
}
|
|
1490
|
-
const currentCount = this.callCounts.get(context.traceId) ?? 0;
|
|
1491
|
-
if (currentCount >= this.config.maxCallsPerSession) {
|
|
1492
|
-
return {
|
|
1493
|
-
allowed: false,
|
|
1494
|
-
reason: `Max calls per session exceeded: ${currentCount} >= ${this.config.maxCallsPerSession}`
|
|
1495
|
-
};
|
|
1496
|
-
}
|
|
1497
|
-
if (this.detectLoop(context.traceId, context.backend, context.promptHash)) {
|
|
1498
|
-
return {
|
|
1499
|
-
allowed: false,
|
|
1500
|
-
reason: `Loop detected: same (backend=${context.backend}, promptHash=${context.promptHash}) appeared 3+ times in trace ${context.traceId}`
|
|
1501
|
-
};
|
|
1502
|
-
}
|
|
1503
|
-
return { allowed: true };
|
|
1504
|
-
}
|
|
1505
|
-
/** Record a spawn invocation */
|
|
1506
|
-
recordSpawn(context) {
|
|
1507
|
-
const currentCount = this.callCounts.get(context.traceId) ?? 0;
|
|
1508
|
-
this.callCounts.set(context.traceId, currentCount + 1);
|
|
1509
|
-
const key = `${context.backend}:${context.promptHash}`;
|
|
1510
|
-
const existing = this.promptHashes.get(context.traceId) ?? [];
|
|
1511
|
-
existing.push(key);
|
|
1512
|
-
this.promptHashes.set(context.traceId, existing);
|
|
1513
|
-
}
|
|
1514
|
-
/** Detect if the same (backend + promptHash) combination has appeared 3+ times */
|
|
1515
|
-
detectLoop(traceId, backend, promptHash) {
|
|
1516
|
-
const key = `${backend}:${promptHash}`;
|
|
1517
|
-
const hashes = this.promptHashes.get(traceId) ?? [];
|
|
1518
|
-
const count = hashes.filter((h) => h === key).length;
|
|
1519
|
-
return count >= 3;
|
|
1520
|
-
}
|
|
1521
|
-
/** Get current config (for testing/inspection) */
|
|
1522
|
-
getConfig() {
|
|
1523
|
-
return { ...this.config };
|
|
1524
|
-
}
|
|
1525
|
-
/** Get call count for a trace */
|
|
1526
|
-
getCallCount(traceId) {
|
|
1527
|
-
return this.callCounts.get(traceId) ?? 0;
|
|
1528
|
-
}
|
|
1529
|
-
/** Utility: compute a prompt hash */
|
|
1530
|
-
static hashPrompt(prompt) {
|
|
1531
|
-
return createHash("sha256").update(prompt).digest("hex").slice(0, 16);
|
|
1532
|
-
}
|
|
1533
|
-
};
|
|
1534
|
-
|
|
1535
|
-
// src/mcp-server/tools/spawn-agent.ts
|
|
1536
|
-
import { z as z2 } from "zod";
|
|
1537
|
-
import { nanoid as nanoid2 } from "nanoid";
|
|
1538
|
-
var spawnAgentInputSchema = z2.object({
|
|
1539
|
-
backend: z2.enum(["claude", "codex", "gemini"]),
|
|
1540
|
-
prompt: z2.string(),
|
|
1541
|
-
agent: z2.string().optional(),
|
|
1542
|
-
resumeSessionId: z2.string().optional(),
|
|
1543
|
-
model: z2.string().optional(),
|
|
1544
|
-
maxTurns: z2.number().optional()
|
|
1545
|
-
});
|
|
1546
|
-
function buildContextFromEnv() {
|
|
1547
|
-
const traceId = process.env["RELAY_TRACE_ID"] ?? `trace-${nanoid2()}`;
|
|
1548
|
-
const parentSessionId = process.env["RELAY_PARENT_SESSION_ID"] ?? null;
|
|
1549
|
-
const depth = Number(process.env["RELAY_DEPTH"] ?? "0");
|
|
1550
|
-
return { traceId, parentSessionId, depth };
|
|
1551
|
-
}
|
|
1552
|
-
async function executeSpawnAgent(input, registry2, sessionManager2, guard, hooksEngine2) {
|
|
1553
|
-
const envContext = buildContextFromEnv();
|
|
1554
|
-
const promptHash = RecursionGuard.hashPrompt(input.prompt);
|
|
1555
|
-
const context = {
|
|
1556
|
-
traceId: envContext.traceId,
|
|
1557
|
-
depth: envContext.depth,
|
|
1558
|
-
backend: input.backend,
|
|
1559
|
-
promptHash
|
|
1560
|
-
};
|
|
1561
|
-
const guardResult = guard.canSpawn(context);
|
|
1562
|
-
if (!guardResult.allowed) {
|
|
1563
|
-
logger.warn(`Spawn blocked by RecursionGuard: ${guardResult.reason}`);
|
|
1564
|
-
return {
|
|
1565
|
-
sessionId: "",
|
|
1566
|
-
exitCode: 1,
|
|
1567
|
-
stdout: "",
|
|
1568
|
-
stderr: `Spawn blocked: ${guardResult.reason}`
|
|
1569
|
-
};
|
|
1570
|
-
}
|
|
1571
|
-
const adapter = registry2.get(input.backend);
|
|
1572
|
-
const installed = await adapter.isInstalled();
|
|
1573
|
-
if (!installed) {
|
|
1574
|
-
return {
|
|
1575
|
-
sessionId: "",
|
|
1576
|
-
exitCode: 1,
|
|
1577
|
-
stdout: "",
|
|
1578
|
-
stderr: `Backend "${input.backend}" is not installed`
|
|
1579
|
-
};
|
|
1580
|
-
}
|
|
1581
|
-
const session = await sessionManager2.create({
|
|
1582
|
-
backendId: input.backend,
|
|
1583
|
-
parentSessionId: envContext.parentSessionId ?? void 0,
|
|
1584
|
-
depth: envContext.depth + 1
|
|
1585
|
-
});
|
|
1586
|
-
if (hooksEngine2) {
|
|
1587
|
-
try {
|
|
1588
|
-
const hookInput = {
|
|
1589
|
-
event: "pre-spawn",
|
|
1590
|
-
sessionId: session.relaySessionId,
|
|
1591
|
-
backendId: input.backend,
|
|
1592
|
-
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1593
|
-
data: {
|
|
1594
|
-
prompt: input.prompt,
|
|
1595
|
-
agent: input.agent,
|
|
1596
|
-
model: input.model
|
|
1597
|
-
}
|
|
1598
|
-
};
|
|
1599
|
-
await hooksEngine2.emit("pre-spawn", hookInput);
|
|
1600
|
-
} catch (error) {
|
|
1601
|
-
logger.debug(
|
|
1602
|
-
`pre-spawn hook error: ${error instanceof Error ? error.message : String(error)}`
|
|
1603
|
-
);
|
|
1604
|
-
}
|
|
1605
|
-
}
|
|
1606
|
-
try {
|
|
1607
|
-
const result = await adapter.execute({
|
|
1608
|
-
prompt: input.prompt,
|
|
1609
|
-
agent: input.agent,
|
|
1610
|
-
model: input.model,
|
|
1611
|
-
maxTurns: input.maxTurns,
|
|
1612
|
-
resume: input.resumeSessionId,
|
|
1613
|
-
mcpContext: {
|
|
1614
|
-
parentSessionId: session.relaySessionId,
|
|
1615
|
-
depth: envContext.depth + 1,
|
|
1616
|
-
maxDepth: guard.getConfig().maxDepth,
|
|
1617
|
-
traceId: envContext.traceId
|
|
1618
|
-
}
|
|
1619
|
-
});
|
|
1620
|
-
guard.recordSpawn(context);
|
|
1621
|
-
const status = result.exitCode === 0 ? "completed" : "error";
|
|
1622
|
-
await sessionManager2.update(session.relaySessionId, { status });
|
|
1623
|
-
if (hooksEngine2) {
|
|
1624
|
-
try {
|
|
1625
|
-
const hookInput = {
|
|
1626
|
-
event: "post-spawn",
|
|
1627
|
-
sessionId: session.relaySessionId,
|
|
1628
|
-
backendId: input.backend,
|
|
1629
|
-
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1630
|
-
data: {
|
|
1631
|
-
exitCode: result.exitCode,
|
|
1632
|
-
status
|
|
1633
|
-
}
|
|
1634
|
-
};
|
|
1635
|
-
await hooksEngine2.emit("post-spawn", hookInput);
|
|
1636
|
-
} catch (hookError) {
|
|
1637
|
-
logger.debug(
|
|
1638
|
-
`post-spawn hook error: ${hookError instanceof Error ? hookError.message : String(hookError)}`
|
|
1639
|
-
);
|
|
1640
|
-
}
|
|
1641
|
-
}
|
|
1642
|
-
return {
|
|
1643
|
-
sessionId: session.relaySessionId,
|
|
1644
|
-
exitCode: result.exitCode,
|
|
1645
|
-
stdout: result.stdout,
|
|
1646
|
-
stderr: result.stderr
|
|
1647
|
-
};
|
|
1648
|
-
} catch (error) {
|
|
1649
|
-
await sessionManager2.update(session.relaySessionId, { status: "error" });
|
|
1650
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
1651
|
-
return {
|
|
1652
|
-
sessionId: session.relaySessionId,
|
|
1653
|
-
exitCode: 1,
|
|
1654
|
-
stdout: "",
|
|
1655
|
-
stderr: message
|
|
1656
|
-
};
|
|
1657
|
-
}
|
|
1658
|
-
}
|
|
1659
|
-
|
|
1660
|
-
// src/mcp-server/tools/list-sessions.ts
|
|
1661
|
-
import { z as z3 } from "zod";
|
|
1662
|
-
var listSessionsInputSchema = z3.object({
|
|
1663
|
-
backend: z3.enum(["claude", "codex", "gemini"]).optional(),
|
|
1664
|
-
limit: z3.number().optional().default(10)
|
|
1665
|
-
});
|
|
1666
|
-
async function executeListSessions(input, sessionManager2) {
|
|
1667
|
-
const sessions = await sessionManager2.list({
|
|
1668
|
-
backendId: input.backend,
|
|
1669
|
-
limit: input.limit
|
|
2459
|
+
};
|
|
2460
|
+
await hooksEngine2.emit("on-error", hookInput);
|
|
2461
|
+
} catch {
|
|
2462
|
+
}
|
|
2463
|
+
}
|
|
2464
|
+
throw error;
|
|
2465
|
+
}
|
|
2466
|
+
}
|
|
1670
2467
|
});
|
|
1671
|
-
return {
|
|
1672
|
-
sessions: sessions.map((s) => ({
|
|
1673
|
-
relaySessionId: s.relaySessionId,
|
|
1674
|
-
backendId: s.backendId,
|
|
1675
|
-
status: s.status,
|
|
1676
|
-
createdAt: s.createdAt.toISOString()
|
|
1677
|
-
}))
|
|
1678
|
-
};
|
|
1679
2468
|
}
|
|
1680
2469
|
|
|
1681
|
-
// src/
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
2470
|
+
// src/commands/update.ts
|
|
2471
|
+
init_logger();
|
|
2472
|
+
import { defineCommand as defineCommand2 } from "citty";
|
|
2473
|
+
|
|
2474
|
+
// src/infrastructure/version-check.ts
|
|
2475
|
+
init_logger();
|
|
2476
|
+
async function getLatestNpmVersion(packageName) {
|
|
2477
|
+
try {
|
|
2478
|
+
const response = await fetch(
|
|
2479
|
+
`https://registry.npmjs.org/${packageName}/latest`,
|
|
2480
|
+
{
|
|
2481
|
+
headers: { Accept: "application/json" },
|
|
2482
|
+
signal: AbortSignal.timeout(5e3)
|
|
2483
|
+
}
|
|
2484
|
+
);
|
|
2485
|
+
if (!response.ok) {
|
|
2486
|
+
logger.debug(
|
|
2487
|
+
`npm registry returned ${response.status} for ${packageName}`
|
|
2488
|
+
);
|
|
2489
|
+
return null;
|
|
1699
2490
|
}
|
|
2491
|
+
const data = await response.json();
|
|
2492
|
+
return data.version ?? null;
|
|
2493
|
+
} catch (error) {
|
|
2494
|
+
logger.debug(
|
|
2495
|
+
`Failed to check npm registry: ${error instanceof Error ? error.message : String(error)}`
|
|
2496
|
+
);
|
|
2497
|
+
return null;
|
|
1700
2498
|
}
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
2499
|
+
}
|
|
2500
|
+
function compareSemver(a, b) {
|
|
2501
|
+
const partsA = a.replace(/^v/, "").split(".").map(Number);
|
|
2502
|
+
const partsB = b.replace(/^v/, "").split(".").map(Number);
|
|
2503
|
+
for (let i = 0; i < 3; i++) {
|
|
2504
|
+
const diff = (partsA[i] ?? 0) - (partsB[i] ?? 0);
|
|
2505
|
+
if (diff !== 0) return diff;
|
|
2506
|
+
}
|
|
2507
|
+
return 0;
|
|
1706
2508
|
}
|
|
1707
2509
|
|
|
1708
|
-
// src/
|
|
1709
|
-
var
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
registerTools() {
|
|
1725
|
-
this.server.tool(
|
|
1726
|
-
"spawn_agent",
|
|
1727
|
-
"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.",
|
|
1728
|
-
{
|
|
1729
|
-
backend: z5.enum(["claude", "codex", "gemini"]),
|
|
1730
|
-
prompt: z5.string(),
|
|
1731
|
-
agent: z5.string().optional(),
|
|
1732
|
-
resumeSessionId: z5.string().optional(),
|
|
1733
|
-
model: z5.string().optional(),
|
|
1734
|
-
maxTurns: z5.number().optional()
|
|
1735
|
-
},
|
|
1736
|
-
async (params) => {
|
|
1737
|
-
try {
|
|
1738
|
-
const result = await executeSpawnAgent(
|
|
1739
|
-
params,
|
|
1740
|
-
this.registry,
|
|
1741
|
-
this.sessionManager,
|
|
1742
|
-
this.guard,
|
|
1743
|
-
this.hooksEngine
|
|
2510
|
+
// src/commands/update.ts
|
|
2511
|
+
var PACKAGE_NAME = "@rk0429/agentic-relay";
|
|
2512
|
+
var CURRENT_VERSION = "0.4.0";
|
|
2513
|
+
function createUpdateCommand(registry2) {
|
|
2514
|
+
return defineCommand2({
|
|
2515
|
+
meta: {
|
|
2516
|
+
name: "update",
|
|
2517
|
+
description: "Update relay and all installed backend CLI tools"
|
|
2518
|
+
},
|
|
2519
|
+
async run() {
|
|
2520
|
+
logger.info("Checking for relay updates...");
|
|
2521
|
+
const latestVersion = await getLatestNpmVersion(PACKAGE_NAME);
|
|
2522
|
+
if (latestVersion) {
|
|
2523
|
+
if (compareSemver(latestVersion, CURRENT_VERSION) > 0) {
|
|
2524
|
+
logger.warn(
|
|
2525
|
+
`A newer version of relay is available: ${latestVersion} (current: ${CURRENT_VERSION})`
|
|
1744
2526
|
);
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
${result.stdout}`;
|
|
1749
|
-
return {
|
|
1750
|
-
content: [{ type: "text", text }],
|
|
1751
|
-
isError
|
|
1752
|
-
};
|
|
1753
|
-
} catch (error) {
|
|
1754
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
1755
|
-
return {
|
|
1756
|
-
content: [{ type: "text", text: `Error: ${message}` }],
|
|
1757
|
-
isError: true
|
|
1758
|
-
};
|
|
2527
|
+
logger.info(` Run: npm install -g ${PACKAGE_NAME}@latest`);
|
|
2528
|
+
} else {
|
|
2529
|
+
logger.success(`relay is up to date (${CURRENT_VERSION})`);
|
|
1759
2530
|
}
|
|
2531
|
+
} else {
|
|
2532
|
+
logger.debug("Could not check for relay updates");
|
|
1760
2533
|
}
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
{
|
|
1766
|
-
backend: z5.enum(["claude", "codex", "gemini"]).optional(),
|
|
1767
|
-
limit: z5.number().optional()
|
|
1768
|
-
},
|
|
1769
|
-
async (params) => {
|
|
1770
|
-
try {
|
|
1771
|
-
const result = await executeListSessions(
|
|
1772
|
-
{ backend: params.backend, limit: params.limit ?? 10 },
|
|
1773
|
-
this.sessionManager
|
|
1774
|
-
);
|
|
1775
|
-
return {
|
|
1776
|
-
content: [
|
|
1777
|
-
{
|
|
1778
|
-
type: "text",
|
|
1779
|
-
text: JSON.stringify(result, null, 2)
|
|
1780
|
-
}
|
|
1781
|
-
]
|
|
1782
|
-
};
|
|
1783
|
-
} catch (error) {
|
|
1784
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
1785
|
-
return {
|
|
1786
|
-
content: [{ type: "text", text: `Error: ${message}` }],
|
|
1787
|
-
isError: true
|
|
1788
|
-
};
|
|
1789
|
-
}
|
|
2534
|
+
const adapters = registry2.list();
|
|
2535
|
+
if (adapters.length === 0) {
|
|
2536
|
+
logger.warn("No backends registered");
|
|
2537
|
+
return;
|
|
1790
2538
|
}
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
async (params) => {
|
|
2539
|
+
for (const adapter of adapters) {
|
|
2540
|
+
const installed = await adapter.isInstalled();
|
|
2541
|
+
if (!installed) {
|
|
2542
|
+
logger.info(`Skipping ${adapter.id}: not installed`);
|
|
2543
|
+
continue;
|
|
2544
|
+
}
|
|
2545
|
+
logger.info(`Updating ${adapter.id}...`);
|
|
1799
2546
|
try {
|
|
1800
|
-
|
|
1801
|
-
params,
|
|
1802
|
-
this.sessionManager,
|
|
1803
|
-
this.contextMonitor
|
|
1804
|
-
);
|
|
1805
|
-
return {
|
|
1806
|
-
content: [
|
|
1807
|
-
{
|
|
1808
|
-
type: "text",
|
|
1809
|
-
text: JSON.stringify(result, null, 2)
|
|
1810
|
-
}
|
|
1811
|
-
]
|
|
1812
|
-
};
|
|
2547
|
+
await adapter.update();
|
|
1813
2548
|
} catch (error) {
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
isError: true
|
|
1818
|
-
};
|
|
2549
|
+
logger.error(
|
|
2550
|
+
`Failed to update ${adapter.id}: ${error instanceof Error ? error.message : String(error)}`
|
|
2551
|
+
);
|
|
1819
2552
|
}
|
|
1820
2553
|
}
|
|
1821
|
-
|
|
1822
|
-
}
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
2554
|
+
}
|
|
2555
|
+
});
|
|
2556
|
+
}
|
|
2557
|
+
|
|
2558
|
+
// src/commands/config.ts
|
|
2559
|
+
init_logger();
|
|
2560
|
+
import { defineCommand as defineCommand3 } from "citty";
|
|
2561
|
+
function createConfigCommand(configManager2) {
|
|
2562
|
+
return defineCommand3({
|
|
2563
|
+
meta: {
|
|
2564
|
+
name: "config",
|
|
2565
|
+
description: "Manage relay configuration"
|
|
2566
|
+
},
|
|
2567
|
+
subCommands: {
|
|
2568
|
+
get: defineCommand3({
|
|
2569
|
+
meta: {
|
|
2570
|
+
name: "get",
|
|
2571
|
+
description: "Get a config value by key (dot-path notation)"
|
|
2572
|
+
},
|
|
2573
|
+
args: {
|
|
2574
|
+
key: {
|
|
2575
|
+
type: "positional",
|
|
2576
|
+
description: "Config key (e.g. mcpServerMode.maxDepth)",
|
|
2577
|
+
required: true
|
|
2578
|
+
}
|
|
2579
|
+
},
|
|
2580
|
+
async run({ args }) {
|
|
2581
|
+
const key = String(args.key);
|
|
2582
|
+
const value = await configManager2.get(key);
|
|
2583
|
+
if (value === void 0) {
|
|
2584
|
+
logger.info(`Key "${key}" is not set`);
|
|
2585
|
+
} else {
|
|
2586
|
+
const output = typeof value === "object" ? JSON.stringify(value, null, 2) : String(value);
|
|
2587
|
+
console.log(output);
|
|
2588
|
+
}
|
|
2589
|
+
}
|
|
2590
|
+
}),
|
|
2591
|
+
set: defineCommand3({
|
|
2592
|
+
meta: {
|
|
2593
|
+
name: "set",
|
|
2594
|
+
description: "Set a config value (default: global scope)"
|
|
2595
|
+
},
|
|
2596
|
+
args: {
|
|
2597
|
+
project: {
|
|
2598
|
+
type: "boolean",
|
|
2599
|
+
description: "Write to project scope instead of global"
|
|
2600
|
+
},
|
|
2601
|
+
key: {
|
|
2602
|
+
type: "positional",
|
|
2603
|
+
description: "Config key (e.g. defaultBackend)",
|
|
2604
|
+
required: true
|
|
2605
|
+
},
|
|
2606
|
+
value: {
|
|
2607
|
+
type: "positional",
|
|
2608
|
+
description: "Config value",
|
|
2609
|
+
required: true
|
|
2610
|
+
}
|
|
2611
|
+
},
|
|
2612
|
+
async run({ args }) {
|
|
2613
|
+
const scope = args.project ? "project" : "global";
|
|
2614
|
+
const key = String(args.key);
|
|
2615
|
+
const rawValue = String(args.value);
|
|
2616
|
+
const parsed = parseValue(rawValue);
|
|
2617
|
+
await configManager2.set(scope, key, parsed);
|
|
2618
|
+
logger.success(`Set ${key} = ${JSON.stringify(parsed)} (${scope})`);
|
|
2619
|
+
}
|
|
2620
|
+
})
|
|
2621
|
+
},
|
|
2622
|
+
async run() {
|
|
2623
|
+
const config = await configManager2.getConfig();
|
|
2624
|
+
console.log(JSON.stringify(config, null, 2));
|
|
2625
|
+
}
|
|
2626
|
+
});
|
|
2627
|
+
}
|
|
2628
|
+
function parseValue(raw) {
|
|
2629
|
+
if (raw === "true") return true;
|
|
2630
|
+
if (raw === "false") return false;
|
|
2631
|
+
if (raw === "null") return null;
|
|
2632
|
+
const num = Number(raw);
|
|
2633
|
+
if (!Number.isNaN(num) && raw.trim() !== "") return num;
|
|
2634
|
+
try {
|
|
2635
|
+
const parsed = JSON.parse(raw);
|
|
2636
|
+
if (typeof parsed === "object") return parsed;
|
|
2637
|
+
} catch {
|
|
1827
2638
|
}
|
|
1828
|
-
|
|
2639
|
+
return raw;
|
|
2640
|
+
}
|
|
1829
2641
|
|
|
1830
2642
|
// src/commands/mcp.ts
|
|
2643
|
+
init_server();
|
|
2644
|
+
init_logger();
|
|
2645
|
+
import { defineCommand as defineCommand4 } from "citty";
|
|
1831
2646
|
function createMCPCommand(configManager2, registry2, sessionManager2, hooksEngine2, contextMonitor2) {
|
|
1832
2647
|
return defineCommand4({
|
|
1833
2648
|
meta: {
|
|
@@ -1948,9 +2763,21 @@ function createMCPCommand(configManager2, registry2, sessionManager2, hooksEngin
|
|
|
1948
2763
|
serve: defineCommand4({
|
|
1949
2764
|
meta: {
|
|
1950
2765
|
name: "serve",
|
|
1951
|
-
description: "Start relay as an MCP server
|
|
2766
|
+
description: "Start relay as an MCP server"
|
|
1952
2767
|
},
|
|
1953
|
-
|
|
2768
|
+
args: {
|
|
2769
|
+
transport: {
|
|
2770
|
+
type: "string",
|
|
2771
|
+
description: "Transport type: stdio or http (default: stdio)",
|
|
2772
|
+
default: "stdio"
|
|
2773
|
+
},
|
|
2774
|
+
port: {
|
|
2775
|
+
type: "string",
|
|
2776
|
+
description: "Port for HTTP transport (default: 3100)",
|
|
2777
|
+
default: "3100"
|
|
2778
|
+
}
|
|
2779
|
+
},
|
|
2780
|
+
async run({ args }) {
|
|
1954
2781
|
if (!sessionManager2) {
|
|
1955
2782
|
logger.error("SessionManager is required for MCP server mode");
|
|
1956
2783
|
process.exitCode = 1;
|
|
@@ -1968,6 +2795,8 @@ function createMCPCommand(configManager2, registry2, sessionManager2, hooksEngin
|
|
|
1968
2795
|
}
|
|
1969
2796
|
} catch {
|
|
1970
2797
|
}
|
|
2798
|
+
const transport = args.transport === "http" ? "http" : "stdio";
|
|
2799
|
+
const port = parseInt(String(args.port), 10) || 3100;
|
|
1971
2800
|
const server = new RelayMCPServer(
|
|
1972
2801
|
registry2,
|
|
1973
2802
|
sessionManager2,
|
|
@@ -1975,7 +2804,7 @@ function createMCPCommand(configManager2, registry2, sessionManager2, hooksEngin
|
|
|
1975
2804
|
hooksEngine2,
|
|
1976
2805
|
contextMonitor2
|
|
1977
2806
|
);
|
|
1978
|
-
await server.start();
|
|
2807
|
+
await server.start({ transport, port });
|
|
1979
2808
|
}
|
|
1980
2809
|
})
|
|
1981
2810
|
},
|
|
@@ -1988,6 +2817,7 @@ function createMCPCommand(configManager2, registry2, sessionManager2, hooksEngin
|
|
|
1988
2817
|
}
|
|
1989
2818
|
|
|
1990
2819
|
// src/commands/auth.ts
|
|
2820
|
+
init_logger();
|
|
1991
2821
|
import { defineCommand as defineCommand5 } from "citty";
|
|
1992
2822
|
function createBackendAuthCommand(backendId, authManager2) {
|
|
1993
2823
|
return defineCommand5({
|
|
@@ -2042,6 +2872,7 @@ function createAuthCommand(authManager2) {
|
|
|
2042
2872
|
}
|
|
2043
2873
|
|
|
2044
2874
|
// src/commands/sessions.ts
|
|
2875
|
+
init_logger();
|
|
2045
2876
|
import { defineCommand as defineCommand6 } from "citty";
|
|
2046
2877
|
function formatDate(date) {
|
|
2047
2878
|
const y = date.getFullYear();
|
|
@@ -2157,9 +2988,12 @@ function createVersionCommand(registry2) {
|
|
|
2157
2988
|
|
|
2158
2989
|
// src/commands/doctor.ts
|
|
2159
2990
|
import { defineCommand as defineCommand8 } from "citty";
|
|
2160
|
-
import { access, constants } from "fs/promises";
|
|
2161
|
-
import { join as
|
|
2162
|
-
import { homedir as
|
|
2991
|
+
import { access, constants, readdir as readdir2 } from "fs/promises";
|
|
2992
|
+
import { join as join6 } from "path";
|
|
2993
|
+
import { homedir as homedir5 } from "os";
|
|
2994
|
+
import { execFile } from "child_process";
|
|
2995
|
+
import { promisify } from "util";
|
|
2996
|
+
var execFileAsync = promisify(execFile);
|
|
2163
2997
|
async function checkNodeVersion() {
|
|
2164
2998
|
const version = process.version;
|
|
2165
2999
|
const major = Number(version.slice(1).split(".")[0]);
|
|
@@ -2216,8 +3050,8 @@ async function checkConfig(configManager2) {
|
|
|
2216
3050
|
}
|
|
2217
3051
|
}
|
|
2218
3052
|
async function checkSessionsDir() {
|
|
2219
|
-
const relayHome2 = process.env["RELAY_HOME"] ??
|
|
2220
|
-
const sessionsDir =
|
|
3053
|
+
const relayHome2 = process.env["RELAY_HOME"] ?? join6(homedir5(), ".relay");
|
|
3054
|
+
const sessionsDir = join6(relayHome2, "sessions");
|
|
2221
3055
|
try {
|
|
2222
3056
|
await access(sessionsDir, constants.W_OK);
|
|
2223
3057
|
return {
|
|
@@ -2251,6 +3085,107 @@ async function checkMCPSDK() {
|
|
|
2251
3085
|
};
|
|
2252
3086
|
}
|
|
2253
3087
|
}
|
|
3088
|
+
async function checkMCPServerCommands(configManager2) {
|
|
3089
|
+
const results = [];
|
|
3090
|
+
try {
|
|
3091
|
+
const config = await configManager2.getConfig();
|
|
3092
|
+
const mcpServers = config.mcpServers ?? {};
|
|
3093
|
+
for (const [name, server] of Object.entries(mcpServers)) {
|
|
3094
|
+
const command = server.command;
|
|
3095
|
+
try {
|
|
3096
|
+
await execFileAsync("which", [command]);
|
|
3097
|
+
results.push({
|
|
3098
|
+
label: `MCP server: ${name}`,
|
|
3099
|
+
ok: true,
|
|
3100
|
+
detail: `MCP server "${name}": command "${command}" found`
|
|
3101
|
+
});
|
|
3102
|
+
} catch {
|
|
3103
|
+
results.push({
|
|
3104
|
+
label: `MCP server: ${name}`,
|
|
3105
|
+
ok: true,
|
|
3106
|
+
detail: `MCP server "${name}": command "${command}" not found in PATH`,
|
|
3107
|
+
hint: `Ensure "${command}" is installed and available in your PATH`
|
|
3108
|
+
});
|
|
3109
|
+
}
|
|
3110
|
+
}
|
|
3111
|
+
} catch {
|
|
3112
|
+
}
|
|
3113
|
+
return results;
|
|
3114
|
+
}
|
|
3115
|
+
async function checkMCPServerInstantiation() {
|
|
3116
|
+
try {
|
|
3117
|
+
const { RelayMCPServer: RelayMCPServer2 } = await Promise.resolve().then(() => (init_server(), server_exports));
|
|
3118
|
+
if (typeof RelayMCPServer2 !== "function") {
|
|
3119
|
+
return {
|
|
3120
|
+
label: "MCP server",
|
|
3121
|
+
ok: false,
|
|
3122
|
+
detail: "MCP server module is not a constructor",
|
|
3123
|
+
hint: "Reinstall dependencies: pnpm install"
|
|
3124
|
+
};
|
|
3125
|
+
}
|
|
3126
|
+
return {
|
|
3127
|
+
label: "MCP server",
|
|
3128
|
+
ok: true,
|
|
3129
|
+
detail: "MCP server module loadable"
|
|
3130
|
+
};
|
|
3131
|
+
} catch (error) {
|
|
3132
|
+
return {
|
|
3133
|
+
label: "MCP server",
|
|
3134
|
+
ok: false,
|
|
3135
|
+
detail: `MCP server module not loadable: ${error instanceof Error ? error.message : String(error)}`,
|
|
3136
|
+
hint: "Reinstall dependencies: pnpm install"
|
|
3137
|
+
};
|
|
3138
|
+
}
|
|
3139
|
+
}
|
|
3140
|
+
async function checkBackendAuthEnv() {
|
|
3141
|
+
const envVars = {
|
|
3142
|
+
claude: "ANTHROPIC_API_KEY",
|
|
3143
|
+
codex: "OPENAI_API_KEY",
|
|
3144
|
+
gemini: "GEMINI_API_KEY"
|
|
3145
|
+
};
|
|
3146
|
+
const results = [];
|
|
3147
|
+
for (const [backend, envVar] of Object.entries(envVars)) {
|
|
3148
|
+
const value = process.env[envVar];
|
|
3149
|
+
if (value) {
|
|
3150
|
+
results.push({
|
|
3151
|
+
label: `Auth: ${backend}`,
|
|
3152
|
+
ok: true,
|
|
3153
|
+
detail: `${envVar} is set`
|
|
3154
|
+
});
|
|
3155
|
+
} else {
|
|
3156
|
+
results.push({
|
|
3157
|
+
label: `Auth: ${backend}`,
|
|
3158
|
+
ok: true,
|
|
3159
|
+
detail: `${envVar} not set`,
|
|
3160
|
+
hint: "API key not set; may use subscription auth"
|
|
3161
|
+
});
|
|
3162
|
+
}
|
|
3163
|
+
}
|
|
3164
|
+
return results;
|
|
3165
|
+
}
|
|
3166
|
+
async function checkSessionsDiskUsage() {
|
|
3167
|
+
const relayHome2 = process.env["RELAY_HOME"] ?? join6(homedir5(), ".relay");
|
|
3168
|
+
const sessionsDir = join6(relayHome2, "sessions");
|
|
3169
|
+
try {
|
|
3170
|
+
const entries = await readdir2(sessionsDir);
|
|
3171
|
+
const fileCount = entries.length;
|
|
3172
|
+
if (fileCount >= 100) {
|
|
3173
|
+
return {
|
|
3174
|
+
label: "Sessions disk usage",
|
|
3175
|
+
ok: true,
|
|
3176
|
+
detail: `${fileCount} session files in ${sessionsDir}`,
|
|
3177
|
+
hint: "Consider cleaning up old sessions: relay sessions list --limit 10"
|
|
3178
|
+
};
|
|
3179
|
+
}
|
|
3180
|
+
return {
|
|
3181
|
+
label: "Sessions disk usage",
|
|
3182
|
+
ok: true,
|
|
3183
|
+
detail: `${fileCount} session files in ${sessionsDir}`
|
|
3184
|
+
};
|
|
3185
|
+
} catch {
|
|
3186
|
+
return null;
|
|
3187
|
+
}
|
|
3188
|
+
}
|
|
2254
3189
|
function createDoctorCommand(registry2, configManager2) {
|
|
2255
3190
|
return defineCommand8({
|
|
2256
3191
|
meta: {
|
|
@@ -2268,15 +3203,24 @@ function createDoctorCommand(registry2, configManager2) {
|
|
|
2268
3203
|
checks.push(await checkConfig(configManager2));
|
|
2269
3204
|
checks.push(await checkSessionsDir());
|
|
2270
3205
|
checks.push(await checkMCPSDK());
|
|
3206
|
+
const mcpCommandChecks = await checkMCPServerCommands(configManager2);
|
|
3207
|
+
checks.push(...mcpCommandChecks);
|
|
3208
|
+
checks.push(await checkMCPServerInstantiation());
|
|
3209
|
+
const authChecks = await checkBackendAuthEnv();
|
|
3210
|
+
checks.push(...authChecks);
|
|
3211
|
+
const diskCheck = await checkSessionsDiskUsage();
|
|
3212
|
+
if (diskCheck) {
|
|
3213
|
+
checks.push(diskCheck);
|
|
3214
|
+
}
|
|
2271
3215
|
let hasFailures = false;
|
|
2272
3216
|
for (const check of checks) {
|
|
2273
3217
|
const icon = check.ok ? "\u2713" : "\u2717";
|
|
2274
3218
|
console.log(` ${icon} ${check.detail}`);
|
|
2275
3219
|
if (!check.ok) {
|
|
2276
3220
|
hasFailures = true;
|
|
2277
|
-
|
|
2278
|
-
|
|
2279
|
-
}
|
|
3221
|
+
}
|
|
3222
|
+
if (check.hint) {
|
|
3223
|
+
console.log(` ${check.hint}`);
|
|
2280
3224
|
}
|
|
2281
3225
|
}
|
|
2282
3226
|
if (hasFailures) {
|
|
@@ -2292,9 +3236,10 @@ function createDoctorCommand(registry2, configManager2) {
|
|
|
2292
3236
|
}
|
|
2293
3237
|
|
|
2294
3238
|
// src/commands/init.ts
|
|
3239
|
+
init_logger();
|
|
2295
3240
|
import { defineCommand as defineCommand9 } from "citty";
|
|
2296
|
-
import { mkdir as
|
|
2297
|
-
import { join as
|
|
3241
|
+
import { mkdir as mkdir6, writeFile as writeFile6, access as access2, readFile as readFile6 } from "fs/promises";
|
|
3242
|
+
import { join as join7 } from "path";
|
|
2298
3243
|
var DEFAULT_CONFIG2 = {
|
|
2299
3244
|
defaultBackend: "claude",
|
|
2300
3245
|
backends: {},
|
|
@@ -2308,8 +3253,8 @@ function createInitCommand() {
|
|
|
2308
3253
|
},
|
|
2309
3254
|
async run() {
|
|
2310
3255
|
const projectDir = process.cwd();
|
|
2311
|
-
const relayDir =
|
|
2312
|
-
const configPath =
|
|
3256
|
+
const relayDir = join7(projectDir, ".relay");
|
|
3257
|
+
const configPath = join7(relayDir, "config.json");
|
|
2313
3258
|
try {
|
|
2314
3259
|
await access2(relayDir);
|
|
2315
3260
|
logger.info(
|
|
@@ -2318,16 +3263,16 @@ function createInitCommand() {
|
|
|
2318
3263
|
return;
|
|
2319
3264
|
} catch {
|
|
2320
3265
|
}
|
|
2321
|
-
await
|
|
2322
|
-
await
|
|
3266
|
+
await mkdir6(relayDir, { recursive: true });
|
|
3267
|
+
await writeFile6(
|
|
2323
3268
|
configPath,
|
|
2324
3269
|
JSON.stringify(DEFAULT_CONFIG2, null, 2) + "\n",
|
|
2325
3270
|
"utf-8"
|
|
2326
3271
|
);
|
|
2327
3272
|
logger.success(`Created ${configPath}`);
|
|
2328
|
-
const gitignorePath =
|
|
3273
|
+
const gitignorePath = join7(projectDir, ".gitignore");
|
|
2329
3274
|
try {
|
|
2330
|
-
const gitignoreContent = await
|
|
3275
|
+
const gitignoreContent = await readFile6(gitignorePath, "utf-8");
|
|
2331
3276
|
if (!gitignoreContent.includes(".relay/config.local.json")) {
|
|
2332
3277
|
logger.info(
|
|
2333
3278
|
'Tip: Add ".relay/config.local.json" to your .gitignore to keep local config out of version control.'
|
|
@@ -2346,25 +3291,23 @@ function createInitCommand() {
|
|
|
2346
3291
|
// src/bin/relay.ts
|
|
2347
3292
|
var processManager = new ProcessManager();
|
|
2348
3293
|
var registry = new AdapterRegistry();
|
|
2349
|
-
registry.
|
|
2350
|
-
registry.
|
|
2351
|
-
registry.
|
|
3294
|
+
registry.registerLazy("claude", () => new ClaudeAdapter(processManager));
|
|
3295
|
+
registry.registerLazy("codex", () => new CodexAdapter(processManager));
|
|
3296
|
+
registry.registerLazy("gemini", () => new GeminiAdapter(processManager));
|
|
2352
3297
|
var sessionManager = new SessionManager();
|
|
2353
|
-
var relayHome = process.env["RELAY_HOME"] ??
|
|
2354
|
-
var projectRelayDir =
|
|
3298
|
+
var relayHome = process.env["RELAY_HOME"] ?? join8(homedir6(), ".relay");
|
|
3299
|
+
var projectRelayDir = join8(process.cwd(), ".relay");
|
|
2355
3300
|
var configManager = new ConfigManager(relayHome, projectRelayDir);
|
|
2356
3301
|
var authManager = new AuthManager(registry);
|
|
2357
3302
|
var eventBus = new EventBus();
|
|
2358
3303
|
var hooksEngine = new HooksEngine(eventBus, processManager);
|
|
2359
|
-
var contextMonitor = new ContextMonitor(hooksEngine
|
|
2360
|
-
enabled: false
|
|
2361
|
-
// Will be enabled from config at runtime
|
|
2362
|
-
});
|
|
3304
|
+
var contextMonitor = new ContextMonitor(hooksEngine);
|
|
2363
3305
|
void configManager.getConfig().then((config) => {
|
|
2364
3306
|
if (config.hooks) {
|
|
2365
3307
|
hooksEngine.loadConfig(config.hooks);
|
|
2366
3308
|
}
|
|
2367
3309
|
if (config.contextMonitor) {
|
|
3310
|
+
contextMonitor = new ContextMonitor(hooksEngine, config.contextMonitor);
|
|
2368
3311
|
}
|
|
2369
3312
|
}).catch(() => {
|
|
2370
3313
|
});
|
|
@@ -2375,9 +3318,9 @@ var main = defineCommand10({
|
|
|
2375
3318
|
description: "Unified CLI proxy for Claude Code, Codex CLI, and Gemini CLI"
|
|
2376
3319
|
},
|
|
2377
3320
|
subCommands: {
|
|
2378
|
-
claude: createBackendCommand("claude", registry, sessionManager, hooksEngine),
|
|
2379
|
-
codex: createBackendCommand("codex", registry, sessionManager, hooksEngine),
|
|
2380
|
-
gemini: createBackendCommand("gemini", registry, sessionManager, hooksEngine),
|
|
3321
|
+
claude: createBackendCommand("claude", registry, sessionManager, hooksEngine, contextMonitor),
|
|
3322
|
+
codex: createBackendCommand("codex", registry, sessionManager, hooksEngine, contextMonitor),
|
|
3323
|
+
gemini: createBackendCommand("gemini", registry, sessionManager, hooksEngine, contextMonitor),
|
|
2381
3324
|
update: createUpdateCommand(registry),
|
|
2382
3325
|
config: createConfigCommand(configManager),
|
|
2383
3326
|
mcp: createMCPCommand(configManager, registry, sessionManager, hooksEngine, contextMonitor),
|