@rk0429/agentic-relay 0.1.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/LICENSE +201 -0
- package/README.md +307 -0
- package/dist/relay.mjs +2292 -0
- package/package.json +68 -0
package/dist/relay.mjs
ADDED
|
@@ -0,0 +1,2292 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/bin/relay.ts
|
|
4
|
+
import { defineCommand as defineCommand10, runMain } from "citty";
|
|
5
|
+
import { join as join5 } from "path";
|
|
6
|
+
import { homedir as homedir3 } from "os";
|
|
7
|
+
|
|
8
|
+
// src/infrastructure/process-manager.ts
|
|
9
|
+
import { execa } from "execa";
|
|
10
|
+
|
|
11
|
+
// src/infrastructure/logger.ts
|
|
12
|
+
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
|
+
function resolveLogLevel() {
|
|
26
|
+
const envLevel = process.env["RELAY_LOG_LEVEL"]?.toLowerCase();
|
|
27
|
+
if (envLevel && envLevel in LOG_LEVEL_MAP) {
|
|
28
|
+
return LOG_LEVEL_MAP[envLevel];
|
|
29
|
+
}
|
|
30
|
+
return 3;
|
|
31
|
+
}
|
|
32
|
+
var logger = createConsola({
|
|
33
|
+
level: resolveLogLevel(),
|
|
34
|
+
defaults: {
|
|
35
|
+
tag: "relay"
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
// src/infrastructure/process-manager.ts
|
|
40
|
+
var ProcessManager = class {
|
|
41
|
+
activeProcesses = /* @__PURE__ */ new Set();
|
|
42
|
+
constructor() {
|
|
43
|
+
this.setupSignalHandlers();
|
|
44
|
+
}
|
|
45
|
+
async spawnInteractive(command, args, options) {
|
|
46
|
+
logger.debug(`Spawning interactive: ${command} ${args.join(" ")}`);
|
|
47
|
+
const execaOptions = {
|
|
48
|
+
stdio: "inherit",
|
|
49
|
+
cwd: options?.cwd,
|
|
50
|
+
env: options?.env,
|
|
51
|
+
timeout: options?.timeout,
|
|
52
|
+
reject: false
|
|
53
|
+
};
|
|
54
|
+
const proc = execa(command, args, execaOptions);
|
|
55
|
+
this.activeProcesses.add(proc);
|
|
56
|
+
try {
|
|
57
|
+
await proc;
|
|
58
|
+
} finally {
|
|
59
|
+
this.activeProcesses.delete(proc);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
async execute(command, args, options) {
|
|
63
|
+
logger.debug(`Executing: ${command} ${args.join(" ")}`);
|
|
64
|
+
const execaOptions = {
|
|
65
|
+
stdio: "pipe",
|
|
66
|
+
cwd: options?.cwd,
|
|
67
|
+
env: options?.env,
|
|
68
|
+
timeout: options?.timeout,
|
|
69
|
+
reject: false
|
|
70
|
+
};
|
|
71
|
+
const proc = execa(command, args, execaOptions);
|
|
72
|
+
this.activeProcesses.add(proc);
|
|
73
|
+
try {
|
|
74
|
+
const result = await proc;
|
|
75
|
+
return {
|
|
76
|
+
exitCode: result.exitCode ?? 1,
|
|
77
|
+
stdout: result.stdout?.toString() ?? "",
|
|
78
|
+
stderr: result.stderr?.toString() ?? ""
|
|
79
|
+
};
|
|
80
|
+
} finally {
|
|
81
|
+
this.activeProcesses.delete(proc);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
async executeWithInput(command, args, stdinData, options) {
|
|
85
|
+
logger.debug(`Executing with stdin: ${command} ${args.join(" ")}`);
|
|
86
|
+
const execaOptions = {
|
|
87
|
+
stdio: "pipe",
|
|
88
|
+
input: stdinData,
|
|
89
|
+
cwd: options?.cwd,
|
|
90
|
+
env: options?.env,
|
|
91
|
+
timeout: options?.timeout,
|
|
92
|
+
reject: false
|
|
93
|
+
};
|
|
94
|
+
const proc = execa(command, args, execaOptions);
|
|
95
|
+
this.activeProcesses.add(proc);
|
|
96
|
+
try {
|
|
97
|
+
const result = await proc;
|
|
98
|
+
return {
|
|
99
|
+
exitCode: result.exitCode ?? 1,
|
|
100
|
+
stdout: result.stdout?.toString() ?? "",
|
|
101
|
+
stderr: result.stderr?.toString() ?? ""
|
|
102
|
+
};
|
|
103
|
+
} finally {
|
|
104
|
+
this.activeProcesses.delete(proc);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
setupSignalHandlers() {
|
|
108
|
+
const handleSignal = (signal) => {
|
|
109
|
+
logger.debug(`Received ${signal}, forwarding to child processes`);
|
|
110
|
+
for (const proc of this.activeProcesses) {
|
|
111
|
+
proc.kill(signal);
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
process.on("SIGINT", () => handleSignal("SIGINT"));
|
|
115
|
+
process.on("SIGTERM", () => handleSignal("SIGTERM"));
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
// src/adapters/adapter-registry.ts
|
|
120
|
+
var AdapterRegistry = class {
|
|
121
|
+
adapters = /* @__PURE__ */ new Map();
|
|
122
|
+
register(adapter) {
|
|
123
|
+
this.adapters.set(adapter.id, adapter);
|
|
124
|
+
}
|
|
125
|
+
get(id) {
|
|
126
|
+
const adapter = this.adapters.get(id);
|
|
127
|
+
if (!adapter) {
|
|
128
|
+
throw new Error(
|
|
129
|
+
`Backend "${id}" is not registered. Available: ${[...this.adapters.keys()].join(", ")}`
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
return adapter;
|
|
133
|
+
}
|
|
134
|
+
has(id) {
|
|
135
|
+
return this.adapters.has(id);
|
|
136
|
+
}
|
|
137
|
+
list() {
|
|
138
|
+
return [...this.adapters.values()];
|
|
139
|
+
}
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
// src/adapters/base-adapter.ts
|
|
143
|
+
var BaseAdapter = class {
|
|
144
|
+
constructor(processManager2) {
|
|
145
|
+
this.processManager = processManager2;
|
|
146
|
+
}
|
|
147
|
+
async isInstalled() {
|
|
148
|
+
try {
|
|
149
|
+
const result = await this.processManager.execute("which", [this.command]);
|
|
150
|
+
return result.exitCode === 0;
|
|
151
|
+
} catch {
|
|
152
|
+
return false;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
async getVersion() {
|
|
156
|
+
const result = await this.processManager.execute(this.command, [
|
|
157
|
+
"--version"
|
|
158
|
+
]);
|
|
159
|
+
if (result.exitCode !== 0) {
|
|
160
|
+
throw new Error(
|
|
161
|
+
`Failed to get version for ${this.id}: ${result.stderr}`
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
return result.stdout.trim();
|
|
165
|
+
}
|
|
166
|
+
async getMCPConfig() {
|
|
167
|
+
logger.warn(`getMCPConfig not implemented for ${this.id}`);
|
|
168
|
+
return [];
|
|
169
|
+
}
|
|
170
|
+
async setMCPConfig(_servers) {
|
|
171
|
+
logger.warn(`setMCPConfig not implemented for ${this.id}`);
|
|
172
|
+
}
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
// src/adapters/flag-mapper.ts
|
|
176
|
+
var FLAG_MAP = {
|
|
177
|
+
prompt: {
|
|
178
|
+
claude: { flag: "-p" },
|
|
179
|
+
codex: null,
|
|
180
|
+
// Codex uses `exec` subcommand, handled by adapter
|
|
181
|
+
gemini: { flag: "-p" }
|
|
182
|
+
},
|
|
183
|
+
continue: {
|
|
184
|
+
claude: { flag: "-c" },
|
|
185
|
+
codex: null,
|
|
186
|
+
// Codex uses `resume --last`, handled by adapter
|
|
187
|
+
gemini: null
|
|
188
|
+
// Gemini uses `--resume` (no arg), handled by adapter
|
|
189
|
+
},
|
|
190
|
+
resume: {
|
|
191
|
+
claude: { flag: "-r" },
|
|
192
|
+
codex: null,
|
|
193
|
+
// Codex uses `resume SESSION_ID`, handled by adapter
|
|
194
|
+
gemini: null
|
|
195
|
+
// Gemini uses `--resume UUID`, handled by adapter
|
|
196
|
+
},
|
|
197
|
+
agent: {
|
|
198
|
+
claude: { flag: "--agent" },
|
|
199
|
+
codex: null,
|
|
200
|
+
// Not supported
|
|
201
|
+
gemini: null
|
|
202
|
+
// Not supported
|
|
203
|
+
},
|
|
204
|
+
model: {
|
|
205
|
+
claude: { flag: "--model" },
|
|
206
|
+
codex: { flag: "--model" },
|
|
207
|
+
gemini: { flag: "--model" }
|
|
208
|
+
},
|
|
209
|
+
maxTurns: {
|
|
210
|
+
claude: { flag: "--max-turns" },
|
|
211
|
+
codex: null,
|
|
212
|
+
gemini: null
|
|
213
|
+
},
|
|
214
|
+
outputFormat: {
|
|
215
|
+
claude: { flag: "--output-format" },
|
|
216
|
+
codex: null,
|
|
217
|
+
// Codex uses `--json` (boolean), handled by adapter
|
|
218
|
+
gemini: null
|
|
219
|
+
// Gemini uses `--output-format`, handled by adapter
|
|
220
|
+
},
|
|
221
|
+
verbose: {
|
|
222
|
+
claude: { flag: "--verbose" },
|
|
223
|
+
codex: null,
|
|
224
|
+
gemini: { flag: "--verbose" }
|
|
225
|
+
}
|
|
226
|
+
};
|
|
227
|
+
function mapCommonToNative(backendId, flags) {
|
|
228
|
+
const args = [];
|
|
229
|
+
for (const [key, mappings] of Object.entries(FLAG_MAP)) {
|
|
230
|
+
const value = flags[key];
|
|
231
|
+
if (value === void 0 || value === false) continue;
|
|
232
|
+
const mapping = mappings[backendId];
|
|
233
|
+
if (!mapping) continue;
|
|
234
|
+
if (mapping.transform) {
|
|
235
|
+
const transformed = mapping.transform(value);
|
|
236
|
+
if (transformed !== void 0) {
|
|
237
|
+
args.push(mapping.flag, transformed);
|
|
238
|
+
}
|
|
239
|
+
} else if (typeof value === "boolean") {
|
|
240
|
+
args.push(mapping.flag);
|
|
241
|
+
} else {
|
|
242
|
+
args.push(mapping.flag, String(value));
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
return args;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// src/adapters/claude-adapter.ts
|
|
249
|
+
var ClaudeAdapter = class extends BaseAdapter {
|
|
250
|
+
id = "claude";
|
|
251
|
+
command = "claude";
|
|
252
|
+
mapFlags(flags) {
|
|
253
|
+
return {
|
|
254
|
+
args: mapCommonToNative("claude", flags)
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
async startInteractive(flags) {
|
|
258
|
+
const { args } = this.mapFlags(flags);
|
|
259
|
+
await this.processManager.spawnInteractive(this.command, args);
|
|
260
|
+
}
|
|
261
|
+
async execute(flags) {
|
|
262
|
+
if (!flags.prompt) {
|
|
263
|
+
throw new Error("execute requires a prompt (-p flag)");
|
|
264
|
+
}
|
|
265
|
+
const { args } = this.mapFlags(flags);
|
|
266
|
+
return this.processManager.execute(this.command, args);
|
|
267
|
+
}
|
|
268
|
+
async resumeSession(sessionId, _flags) {
|
|
269
|
+
await this.processManager.spawnInteractive(this.command, [
|
|
270
|
+
"-r",
|
|
271
|
+
sessionId
|
|
272
|
+
]);
|
|
273
|
+
}
|
|
274
|
+
async listNativeSessions() {
|
|
275
|
+
logger.debug(
|
|
276
|
+
"listNativeSessions: claude --resume is interactive, returning empty array"
|
|
277
|
+
);
|
|
278
|
+
return [];
|
|
279
|
+
}
|
|
280
|
+
async auth(action) {
|
|
281
|
+
await this.processManager.spawnInteractive(this.command, ["auth", action]);
|
|
282
|
+
}
|
|
283
|
+
async update() {
|
|
284
|
+
logger.info("Updating Claude CLI...");
|
|
285
|
+
const result = await this.processManager.execute(this.command, ["update"]);
|
|
286
|
+
if (result.exitCode !== 0) {
|
|
287
|
+
logger.error(`Claude update failed: ${result.stderr}`);
|
|
288
|
+
} else {
|
|
289
|
+
logger.success(`Claude CLI updated: ${result.stdout.trim()}`);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
};
|
|
293
|
+
|
|
294
|
+
// src/adapters/codex-adapter.ts
|
|
295
|
+
var CodexAdapter = class extends BaseAdapter {
|
|
296
|
+
id = "codex";
|
|
297
|
+
command = "codex";
|
|
298
|
+
mapFlags(flags) {
|
|
299
|
+
const args = mapCommonToNative("codex", flags);
|
|
300
|
+
if (flags.outputFormat === "json") {
|
|
301
|
+
args.push("--json");
|
|
302
|
+
}
|
|
303
|
+
return { args };
|
|
304
|
+
}
|
|
305
|
+
async startInteractive(flags) {
|
|
306
|
+
const args = [];
|
|
307
|
+
if (flags.model) {
|
|
308
|
+
args.push("--model", flags.model);
|
|
309
|
+
}
|
|
310
|
+
if (flags.continue) {
|
|
311
|
+
args.push("resume", "--last");
|
|
312
|
+
}
|
|
313
|
+
await this.processManager.spawnInteractive(this.command, args);
|
|
314
|
+
}
|
|
315
|
+
async execute(flags) {
|
|
316
|
+
if (!flags.prompt) {
|
|
317
|
+
throw new Error("execute requires a prompt (-p flag)");
|
|
318
|
+
}
|
|
319
|
+
if (flags.agent) {
|
|
320
|
+
logger.warn(
|
|
321
|
+
`Codex CLI does not support --agent flag. Ignoring agent "${flags.agent}".`
|
|
322
|
+
);
|
|
323
|
+
}
|
|
324
|
+
const args = [];
|
|
325
|
+
if (flags.model) {
|
|
326
|
+
args.push("--model", flags.model);
|
|
327
|
+
}
|
|
328
|
+
if (flags.outputFormat === "json") {
|
|
329
|
+
args.push("--json");
|
|
330
|
+
}
|
|
331
|
+
args.push("exec", flags.prompt);
|
|
332
|
+
return this.processManager.execute(this.command, args);
|
|
333
|
+
}
|
|
334
|
+
async resumeSession(sessionId, flags) {
|
|
335
|
+
const args = [];
|
|
336
|
+
if (flags.model) {
|
|
337
|
+
args.push("--model", flags.model);
|
|
338
|
+
}
|
|
339
|
+
args.push("resume", sessionId);
|
|
340
|
+
await this.processManager.spawnInteractive(this.command, args);
|
|
341
|
+
}
|
|
342
|
+
async listNativeSessions() {
|
|
343
|
+
logger.debug(
|
|
344
|
+
"listNativeSessions: Codex CLI does not provide a session list command"
|
|
345
|
+
);
|
|
346
|
+
return [];
|
|
347
|
+
}
|
|
348
|
+
async auth(action) {
|
|
349
|
+
if (action === "status") {
|
|
350
|
+
await this.processManager.spawnInteractive(this.command, [
|
|
351
|
+
"login",
|
|
352
|
+
"status"
|
|
353
|
+
]);
|
|
354
|
+
} else {
|
|
355
|
+
await this.processManager.spawnInteractive(this.command, [action]);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
async update() {
|
|
359
|
+
logger.info("Updating Codex CLI...");
|
|
360
|
+
const result = await this.processManager.execute("npm", [
|
|
361
|
+
"update",
|
|
362
|
+
"-g",
|
|
363
|
+
"@openai/codex"
|
|
364
|
+
]);
|
|
365
|
+
if (result.exitCode !== 0) {
|
|
366
|
+
logger.error(`Codex update failed: ${result.stderr}`);
|
|
367
|
+
} else {
|
|
368
|
+
logger.success(`Codex CLI updated: ${result.stdout.trim()}`);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
};
|
|
372
|
+
|
|
373
|
+
// src/adapters/gemini-adapter.ts
|
|
374
|
+
var GeminiAdapter = class extends BaseAdapter {
|
|
375
|
+
id = "gemini";
|
|
376
|
+
command = "gemini";
|
|
377
|
+
mapFlags(flags) {
|
|
378
|
+
const args = mapCommonToNative("gemini", flags);
|
|
379
|
+
if (flags.outputFormat) {
|
|
380
|
+
args.push("--output-format", flags.outputFormat);
|
|
381
|
+
}
|
|
382
|
+
return { args };
|
|
383
|
+
}
|
|
384
|
+
async startInteractive(flags) {
|
|
385
|
+
const args = [];
|
|
386
|
+
if (flags.model) {
|
|
387
|
+
args.push("--model", flags.model);
|
|
388
|
+
}
|
|
389
|
+
if (flags.verbose) {
|
|
390
|
+
args.push("--verbose");
|
|
391
|
+
}
|
|
392
|
+
if (flags.continue) {
|
|
393
|
+
args.push("--resume");
|
|
394
|
+
}
|
|
395
|
+
await this.processManager.spawnInteractive(this.command, args);
|
|
396
|
+
}
|
|
397
|
+
async execute(flags) {
|
|
398
|
+
if (!flags.prompt) {
|
|
399
|
+
throw new Error("execute requires a prompt (-p flag)");
|
|
400
|
+
}
|
|
401
|
+
if (flags.agent) {
|
|
402
|
+
logger.warn(
|
|
403
|
+
`Gemini CLI does not support --agent flag. Ignoring agent "${flags.agent}".`
|
|
404
|
+
);
|
|
405
|
+
}
|
|
406
|
+
const args = [];
|
|
407
|
+
if (flags.model) {
|
|
408
|
+
args.push("--model", flags.model);
|
|
409
|
+
}
|
|
410
|
+
if (flags.outputFormat) {
|
|
411
|
+
args.push("--output-format", flags.outputFormat);
|
|
412
|
+
}
|
|
413
|
+
if (flags.verbose) {
|
|
414
|
+
args.push("--verbose");
|
|
415
|
+
}
|
|
416
|
+
args.push("-p", flags.prompt);
|
|
417
|
+
return this.processManager.execute(this.command, args);
|
|
418
|
+
}
|
|
419
|
+
async resumeSession(sessionId, flags) {
|
|
420
|
+
const args = [];
|
|
421
|
+
if (flags.model) {
|
|
422
|
+
args.push("--model", flags.model);
|
|
423
|
+
}
|
|
424
|
+
if (flags.verbose) {
|
|
425
|
+
args.push("--verbose");
|
|
426
|
+
}
|
|
427
|
+
args.push("--resume", sessionId);
|
|
428
|
+
await this.processManager.spawnInteractive(this.command, args);
|
|
429
|
+
}
|
|
430
|
+
async listNativeSessions() {
|
|
431
|
+
try {
|
|
432
|
+
const result = await this.processManager.execute(this.command, [
|
|
433
|
+
"--list-sessions"
|
|
434
|
+
]);
|
|
435
|
+
if (result.exitCode !== 0) {
|
|
436
|
+
logger.debug(
|
|
437
|
+
`listNativeSessions failed for gemini: ${result.stderr}`
|
|
438
|
+
);
|
|
439
|
+
return [];
|
|
440
|
+
}
|
|
441
|
+
try {
|
|
442
|
+
const parsed = JSON.parse(result.stdout);
|
|
443
|
+
if (!Array.isArray(parsed)) return [];
|
|
444
|
+
return parsed.map(
|
|
445
|
+
(entry) => ({
|
|
446
|
+
nativeId: entry.id ?? "",
|
|
447
|
+
startedAt: entry.startedAt ? new Date(entry.startedAt) : /* @__PURE__ */ new Date(),
|
|
448
|
+
lastMessage: entry.lastMessage
|
|
449
|
+
})
|
|
450
|
+
);
|
|
451
|
+
} catch {
|
|
452
|
+
logger.debug(
|
|
453
|
+
"listNativeSessions: failed to parse gemini session list output"
|
|
454
|
+
);
|
|
455
|
+
return [];
|
|
456
|
+
}
|
|
457
|
+
} catch {
|
|
458
|
+
return [];
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
async auth(_action) {
|
|
462
|
+
logger.info(
|
|
463
|
+
"Gemini CLI does not have explicit auth commands. Launching gemini for initial authentication flow..."
|
|
464
|
+
);
|
|
465
|
+
await this.processManager.spawnInteractive(this.command, []);
|
|
466
|
+
}
|
|
467
|
+
async update() {
|
|
468
|
+
logger.info("Updating Gemini CLI...");
|
|
469
|
+
const result = await this.processManager.execute("npm", [
|
|
470
|
+
"update",
|
|
471
|
+
"-g",
|
|
472
|
+
"@google/gemini-cli"
|
|
473
|
+
]);
|
|
474
|
+
if (result.exitCode !== 0) {
|
|
475
|
+
logger.error(`Gemini update failed: ${result.stderr}`);
|
|
476
|
+
} else {
|
|
477
|
+
logger.success(`Gemini CLI updated: ${result.stdout.trim()}`);
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
};
|
|
481
|
+
|
|
482
|
+
// src/core/session-manager.ts
|
|
483
|
+
import { readFile, writeFile, readdir, mkdir } from "fs/promises";
|
|
484
|
+
import { join } from "path";
|
|
485
|
+
import { homedir } from "os";
|
|
486
|
+
import { nanoid } from "nanoid";
|
|
487
|
+
function getRelayHome() {
|
|
488
|
+
return process.env["RELAY_HOME"] ?? join(homedir(), ".relay");
|
|
489
|
+
}
|
|
490
|
+
function getSessionsDir(relayHome2) {
|
|
491
|
+
return join(relayHome2, "sessions");
|
|
492
|
+
}
|
|
493
|
+
function toSessionData(session) {
|
|
494
|
+
return {
|
|
495
|
+
...session,
|
|
496
|
+
createdAt: session.createdAt.toISOString(),
|
|
497
|
+
updatedAt: session.updatedAt.toISOString()
|
|
498
|
+
};
|
|
499
|
+
}
|
|
500
|
+
function fromSessionData(data) {
|
|
501
|
+
return {
|
|
502
|
+
...data,
|
|
503
|
+
createdAt: new Date(data.createdAt),
|
|
504
|
+
updatedAt: new Date(data.updatedAt)
|
|
505
|
+
};
|
|
506
|
+
}
|
|
507
|
+
var SessionManager = class {
|
|
508
|
+
sessionsDir;
|
|
509
|
+
constructor(sessionsDir) {
|
|
510
|
+
this.sessionsDir = sessionsDir ?? getSessionsDir(getRelayHome());
|
|
511
|
+
}
|
|
512
|
+
/** Ensure the sessions directory exists. */
|
|
513
|
+
async ensureDir() {
|
|
514
|
+
await mkdir(this.sessionsDir, { recursive: true });
|
|
515
|
+
}
|
|
516
|
+
sessionPath(relaySessionId) {
|
|
517
|
+
return join(this.sessionsDir, `${relaySessionId}.json`);
|
|
518
|
+
}
|
|
519
|
+
/** Create a new relay session. */
|
|
520
|
+
async create(params) {
|
|
521
|
+
await this.ensureDir();
|
|
522
|
+
const now = /* @__PURE__ */ new Date();
|
|
523
|
+
const session = {
|
|
524
|
+
relaySessionId: `relay-${nanoid()}`,
|
|
525
|
+
nativeSessionId: params.nativeSessionId ?? null,
|
|
526
|
+
backendId: params.backendId,
|
|
527
|
+
parentSessionId: params.parentSessionId ?? null,
|
|
528
|
+
depth: params.depth ?? 0,
|
|
529
|
+
createdAt: now,
|
|
530
|
+
updatedAt: now,
|
|
531
|
+
status: "active"
|
|
532
|
+
};
|
|
533
|
+
await writeFile(
|
|
534
|
+
this.sessionPath(session.relaySessionId),
|
|
535
|
+
JSON.stringify(toSessionData(session), null, 2),
|
|
536
|
+
"utf-8"
|
|
537
|
+
);
|
|
538
|
+
return session;
|
|
539
|
+
}
|
|
540
|
+
/** Update an existing session. */
|
|
541
|
+
async update(relaySessionId, updates) {
|
|
542
|
+
const session = await this.get(relaySessionId);
|
|
543
|
+
if (!session) {
|
|
544
|
+
throw new Error(`Session not found: ${relaySessionId}`);
|
|
545
|
+
}
|
|
546
|
+
const updated = {
|
|
547
|
+
...session,
|
|
548
|
+
...updates,
|
|
549
|
+
updatedAt: /* @__PURE__ */ new Date()
|
|
550
|
+
};
|
|
551
|
+
await writeFile(
|
|
552
|
+
this.sessionPath(relaySessionId),
|
|
553
|
+
JSON.stringify(toSessionData(updated), null, 2),
|
|
554
|
+
"utf-8"
|
|
555
|
+
);
|
|
556
|
+
}
|
|
557
|
+
/** Get a session by relay session ID. */
|
|
558
|
+
async get(relaySessionId) {
|
|
559
|
+
try {
|
|
560
|
+
const raw = await readFile(this.sessionPath(relaySessionId), "utf-8");
|
|
561
|
+
return fromSessionData(JSON.parse(raw));
|
|
562
|
+
} catch {
|
|
563
|
+
return null;
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
/** Get the most recent session for a given backend. */
|
|
567
|
+
async getLatest(backendId) {
|
|
568
|
+
const sessions = await this.list({ backendId, limit: 1 });
|
|
569
|
+
return sessions[0] ?? null;
|
|
570
|
+
}
|
|
571
|
+
/** List sessions, optionally filtered by backend and limited. */
|
|
572
|
+
async list(filter) {
|
|
573
|
+
await this.ensureDir();
|
|
574
|
+
let files;
|
|
575
|
+
try {
|
|
576
|
+
files = await readdir(this.sessionsDir);
|
|
577
|
+
} catch {
|
|
578
|
+
return [];
|
|
579
|
+
}
|
|
580
|
+
const jsonFiles = files.filter((f) => f.endsWith(".json"));
|
|
581
|
+
const sessions = [];
|
|
582
|
+
for (const file of jsonFiles) {
|
|
583
|
+
try {
|
|
584
|
+
const raw = await readFile(join(this.sessionsDir, file), "utf-8");
|
|
585
|
+
const session = fromSessionData(JSON.parse(raw));
|
|
586
|
+
if (filter?.backendId && session.backendId !== filter.backendId) {
|
|
587
|
+
continue;
|
|
588
|
+
}
|
|
589
|
+
sessions.push(session);
|
|
590
|
+
} catch {
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
sessions.sort(
|
|
594
|
+
(a, b) => b.createdAt.getTime() - a.createdAt.getTime()
|
|
595
|
+
);
|
|
596
|
+
if (filter?.limit && filter.limit > 0) {
|
|
597
|
+
return sessions.slice(0, filter.limit);
|
|
598
|
+
}
|
|
599
|
+
return sessions;
|
|
600
|
+
}
|
|
601
|
+
};
|
|
602
|
+
|
|
603
|
+
// src/core/config-manager.ts
|
|
604
|
+
import { readFile as readFile2, writeFile as writeFile2, mkdir as mkdir2 } from "fs/promises";
|
|
605
|
+
import { join as join2 } from "path";
|
|
606
|
+
|
|
607
|
+
// src/schemas/config.schema.ts
|
|
608
|
+
import { z } from "zod";
|
|
609
|
+
var backendIdSchema = z.enum(["claude", "codex", "gemini"]);
|
|
610
|
+
var mcpServerConfigSchema = z.object({
|
|
611
|
+
name: z.string(),
|
|
612
|
+
command: z.string(),
|
|
613
|
+
args: z.array(z.string()).optional(),
|
|
614
|
+
env: z.record(z.string()).optional()
|
|
615
|
+
});
|
|
616
|
+
var hookEventSchema = z.enum([
|
|
617
|
+
"pre-prompt",
|
|
618
|
+
"post-response",
|
|
619
|
+
"on-error",
|
|
620
|
+
"on-context-threshold",
|
|
621
|
+
"pre-spawn",
|
|
622
|
+
"post-spawn"
|
|
623
|
+
]);
|
|
624
|
+
var hookDefinitionSchema = z.object({
|
|
625
|
+
event: hookEventSchema,
|
|
626
|
+
command: z.string(),
|
|
627
|
+
args: z.array(z.string()).optional(),
|
|
628
|
+
timeout: z.number().positive().optional(),
|
|
629
|
+
enabled: z.boolean().optional(),
|
|
630
|
+
onError: z.enum(["ignore", "warn", "abort"]).optional()
|
|
631
|
+
});
|
|
632
|
+
var hooksConfigSchema = z.object({
|
|
633
|
+
definitions: z.array(hookDefinitionSchema)
|
|
634
|
+
});
|
|
635
|
+
var relayConfigSchema = z.object({
|
|
636
|
+
defaultBackend: backendIdSchema.optional(),
|
|
637
|
+
mcpServers: z.record(mcpServerConfigSchema).optional(),
|
|
638
|
+
backends: z.object({
|
|
639
|
+
claude: z.record(z.unknown()).optional(),
|
|
640
|
+
codex: z.record(z.unknown()).optional(),
|
|
641
|
+
gemini: z.record(z.unknown()).optional()
|
|
642
|
+
}).optional(),
|
|
643
|
+
hooks: hooksConfigSchema.optional(),
|
|
644
|
+
contextMonitor: z.object({
|
|
645
|
+
enabled: z.boolean(),
|
|
646
|
+
thresholdPercent: z.number().min(0).max(100),
|
|
647
|
+
notifyMethod: z.enum(["stderr", "hook"])
|
|
648
|
+
}).optional(),
|
|
649
|
+
mcpServerMode: z.object({
|
|
650
|
+
maxDepth: z.number().int().positive(),
|
|
651
|
+
maxCallsPerSession: z.number().int().positive(),
|
|
652
|
+
timeoutSec: z.number().positive()
|
|
653
|
+
}).optional(),
|
|
654
|
+
telemetry: z.object({
|
|
655
|
+
enabled: z.boolean()
|
|
656
|
+
}).optional()
|
|
657
|
+
});
|
|
658
|
+
|
|
659
|
+
// src/core/config-manager.ts
|
|
660
|
+
function deepMerge(target, source) {
|
|
661
|
+
const result = { ...target };
|
|
662
|
+
for (const key of Object.keys(source)) {
|
|
663
|
+
const sourceVal = source[key];
|
|
664
|
+
const targetVal = result[key];
|
|
665
|
+
if (isPlainObject(sourceVal) && isPlainObject(targetVal)) {
|
|
666
|
+
result[key] = deepMerge(
|
|
667
|
+
targetVal,
|
|
668
|
+
sourceVal
|
|
669
|
+
);
|
|
670
|
+
} else {
|
|
671
|
+
result[key] = sourceVal;
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
return result;
|
|
675
|
+
}
|
|
676
|
+
function isPlainObject(value) {
|
|
677
|
+
return typeof value === "object" && value !== null && !Array.isArray(value) && Object.getPrototypeOf(value) === Object.prototype;
|
|
678
|
+
}
|
|
679
|
+
function getByPath(obj, path) {
|
|
680
|
+
const parts = path.split(".");
|
|
681
|
+
let current = obj;
|
|
682
|
+
for (const part of parts) {
|
|
683
|
+
if (!isPlainObject(current)) return void 0;
|
|
684
|
+
current = current[part];
|
|
685
|
+
}
|
|
686
|
+
return current;
|
|
687
|
+
}
|
|
688
|
+
function setByPath(obj, path, value) {
|
|
689
|
+
const parts = path.split(".");
|
|
690
|
+
let current = obj;
|
|
691
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
692
|
+
const part = parts[i];
|
|
693
|
+
if (!isPlainObject(current[part])) {
|
|
694
|
+
current[part] = {};
|
|
695
|
+
}
|
|
696
|
+
current = current[part];
|
|
697
|
+
}
|
|
698
|
+
const lastPart = parts[parts.length - 1];
|
|
699
|
+
current[lastPart] = value;
|
|
700
|
+
}
|
|
701
|
+
var ConfigManager = class {
|
|
702
|
+
constructor(globalDir, projectDir) {
|
|
703
|
+
this.globalDir = globalDir;
|
|
704
|
+
this.projectDir = projectDir;
|
|
705
|
+
}
|
|
706
|
+
/**
|
|
707
|
+
* Returns the merged config from all scopes (global < project < local).
|
|
708
|
+
*/
|
|
709
|
+
async getConfig() {
|
|
710
|
+
const globalCfg = await this.readConfigFile(this.getFilePath("global"));
|
|
711
|
+
const projectCfg = this.projectDir ? await this.readConfigFile(this.getFilePath("project")) : {};
|
|
712
|
+
const localCfg = this.projectDir ? await this.readConfigFile(this.getFilePath("local")) : {};
|
|
713
|
+
const merged = deepMerge(
|
|
714
|
+
deepMerge(globalCfg, projectCfg),
|
|
715
|
+
localCfg
|
|
716
|
+
);
|
|
717
|
+
return this.validate(merged);
|
|
718
|
+
}
|
|
719
|
+
/**
|
|
720
|
+
* Returns config for a specific scope without merging.
|
|
721
|
+
*/
|
|
722
|
+
async getConfigByScope(scope) {
|
|
723
|
+
const scopeFile = this.mapConfigScope(scope);
|
|
724
|
+
return this.readConfigFile(this.getFilePath(scopeFile));
|
|
725
|
+
}
|
|
726
|
+
/**
|
|
727
|
+
* Gets a value from the merged config using dot-path notation.
|
|
728
|
+
*/
|
|
729
|
+
async get(key) {
|
|
730
|
+
const config = await this.getConfig();
|
|
731
|
+
return getByPath(config, key);
|
|
732
|
+
}
|
|
733
|
+
/**
|
|
734
|
+
* Sets a value in the specified scope's config file.
|
|
735
|
+
*/
|
|
736
|
+
async set(scope, key, value) {
|
|
737
|
+
const scopeFile = this.mapConfigScope(scope);
|
|
738
|
+
const filePath = this.getFilePath(scopeFile);
|
|
739
|
+
if (scopeFile !== "global" && !this.projectDir) {
|
|
740
|
+
throw new Error(
|
|
741
|
+
`Cannot write to "${scope}" scope: no project directory configured`
|
|
742
|
+
);
|
|
743
|
+
}
|
|
744
|
+
const existing = await this.readConfigFile(filePath);
|
|
745
|
+
setByPath(existing, key, value);
|
|
746
|
+
const dir = filePath.substring(0, filePath.lastIndexOf("/"));
|
|
747
|
+
await mkdir2(dir, { recursive: true });
|
|
748
|
+
await writeFile2(filePath, JSON.stringify(existing, null, 2), "utf-8");
|
|
749
|
+
}
|
|
750
|
+
/**
|
|
751
|
+
* Syncs MCP server config to all registered backend adapters.
|
|
752
|
+
*/
|
|
753
|
+
async syncMCPConfig(registry2) {
|
|
754
|
+
const config = await this.getConfig();
|
|
755
|
+
const mcpServers = config.mcpServers ?? {};
|
|
756
|
+
const servers = Object.values(mcpServers);
|
|
757
|
+
const adapters = registry2.list();
|
|
758
|
+
for (const adapter of adapters) {
|
|
759
|
+
try {
|
|
760
|
+
await adapter.setMCPConfig(servers);
|
|
761
|
+
logger.success(`Synced MCP config to ${adapter.id}`);
|
|
762
|
+
} catch (error) {
|
|
763
|
+
logger.error(
|
|
764
|
+
`Failed to sync MCP config to ${adapter.id}: ${error instanceof Error ? error.message : String(error)}`
|
|
765
|
+
);
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
// --- Private helpers ---
|
|
770
|
+
getFilePath(scope) {
|
|
771
|
+
switch (scope) {
|
|
772
|
+
case "global":
|
|
773
|
+
return join2(this.globalDir, "config.json");
|
|
774
|
+
case "project":
|
|
775
|
+
return join2(this.projectDir ?? "", "config.json");
|
|
776
|
+
case "local":
|
|
777
|
+
return join2(this.projectDir ?? "", "config.local.json");
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
mapConfigScope(scope) {
|
|
781
|
+
switch (scope) {
|
|
782
|
+
case "global":
|
|
783
|
+
return "global";
|
|
784
|
+
case "project":
|
|
785
|
+
return "project";
|
|
786
|
+
case "session":
|
|
787
|
+
return "local";
|
|
788
|
+
default:
|
|
789
|
+
return "global";
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
async readConfigFile(filePath) {
|
|
793
|
+
try {
|
|
794
|
+
const raw = await readFile2(filePath, "utf-8");
|
|
795
|
+
const parsed = JSON.parse(raw);
|
|
796
|
+
if (!isPlainObject(parsed)) return {};
|
|
797
|
+
return parsed;
|
|
798
|
+
} catch {
|
|
799
|
+
return {};
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
validate(raw) {
|
|
803
|
+
const result = relayConfigSchema.safeParse(raw);
|
|
804
|
+
if (result.success) {
|
|
805
|
+
return result.data;
|
|
806
|
+
}
|
|
807
|
+
logger.warn(
|
|
808
|
+
`Config validation warnings: ${result.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join(", ")}`
|
|
809
|
+
);
|
|
810
|
+
const partial = {};
|
|
811
|
+
for (const key of Object.keys(raw)) {
|
|
812
|
+
const singleField = { [key]: raw[key] };
|
|
813
|
+
const fieldResult = relayConfigSchema.partial().safeParse(singleField);
|
|
814
|
+
if (fieldResult.success) {
|
|
815
|
+
Object.assign(partial, fieldResult.data);
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
return partial;
|
|
819
|
+
}
|
|
820
|
+
};
|
|
821
|
+
|
|
822
|
+
// src/core/auth-manager.ts
|
|
823
|
+
var AuthManager = class {
|
|
824
|
+
constructor(registry2) {
|
|
825
|
+
this.registry = registry2;
|
|
826
|
+
}
|
|
827
|
+
/**
|
|
828
|
+
* Delegates auth action to the specified backend adapter.
|
|
829
|
+
*/
|
|
830
|
+
async auth(backendId, action) {
|
|
831
|
+
const adapter = this.registry.get(backendId);
|
|
832
|
+
await adapter.auth(action);
|
|
833
|
+
}
|
|
834
|
+
/**
|
|
835
|
+
* Shows auth status for all registered backends.
|
|
836
|
+
*/
|
|
837
|
+
async statusAll() {
|
|
838
|
+
const adapters = this.registry.list();
|
|
839
|
+
if (adapters.length === 0) {
|
|
840
|
+
logger.info("No backends registered");
|
|
841
|
+
return;
|
|
842
|
+
}
|
|
843
|
+
for (const adapter of adapters) {
|
|
844
|
+
try {
|
|
845
|
+
logger.info(`--- ${adapter.id} ---`);
|
|
846
|
+
await adapter.auth("status");
|
|
847
|
+
} catch (error) {
|
|
848
|
+
logger.error(
|
|
849
|
+
`Failed to get auth status for ${adapter.id}: ${error instanceof Error ? error.message : String(error)}`
|
|
850
|
+
);
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
};
|
|
855
|
+
|
|
856
|
+
// src/core/event-bus.ts
|
|
857
|
+
var EventBus = class {
|
|
858
|
+
handlers = /* @__PURE__ */ new Map();
|
|
859
|
+
/** Register an event listener */
|
|
860
|
+
on(event, handler) {
|
|
861
|
+
const existing = this.handlers.get(event) ?? [];
|
|
862
|
+
existing.push(handler);
|
|
863
|
+
this.handlers.set(event, existing);
|
|
864
|
+
}
|
|
865
|
+
/** Remove an event listener */
|
|
866
|
+
off(event, handler) {
|
|
867
|
+
const existing = this.handlers.get(event);
|
|
868
|
+
if (!existing) return;
|
|
869
|
+
const index = existing.indexOf(handler);
|
|
870
|
+
if (index !== -1) {
|
|
871
|
+
existing.splice(index, 1);
|
|
872
|
+
}
|
|
873
|
+
if (existing.length === 0) {
|
|
874
|
+
this.handlers.delete(event);
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
/** Emit an event, executing all handlers sequentially */
|
|
878
|
+
async emit(event, data) {
|
|
879
|
+
const handlers = this.handlers.get(event);
|
|
880
|
+
if (!handlers) return;
|
|
881
|
+
for (const handler of handlers) {
|
|
882
|
+
await handler(data);
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
/** Get the number of handlers for a given event */
|
|
886
|
+
listenerCount(event) {
|
|
887
|
+
return this.handlers.get(event)?.length ?? 0;
|
|
888
|
+
}
|
|
889
|
+
/** Remove all handlers */
|
|
890
|
+
clear() {
|
|
891
|
+
this.handlers.clear();
|
|
892
|
+
}
|
|
893
|
+
};
|
|
894
|
+
|
|
895
|
+
// src/core/hooks-engine.ts
|
|
896
|
+
var DEFAULT_TIMEOUT_MS = 1e4;
|
|
897
|
+
var DEFAULT_HOOK_OUTPUT = {
|
|
898
|
+
allow: true,
|
|
899
|
+
message: "",
|
|
900
|
+
metadata: {}
|
|
901
|
+
};
|
|
902
|
+
var HooksEngine = class {
|
|
903
|
+
constructor(eventBus2, processManager2) {
|
|
904
|
+
this.eventBus = eventBus2;
|
|
905
|
+
this.processManager = processManager2;
|
|
906
|
+
}
|
|
907
|
+
definitions = [];
|
|
908
|
+
registered = false;
|
|
909
|
+
/** Load hook definitions from config and register listeners on EventBus */
|
|
910
|
+
loadConfig(config) {
|
|
911
|
+
this.definitions = config.definitions.filter(
|
|
912
|
+
(def) => def.enabled !== false
|
|
913
|
+
);
|
|
914
|
+
if (!this.registered) {
|
|
915
|
+
for (const event of this.getUniqueEvents()) {
|
|
916
|
+
this.eventBus.on(event, async () => {
|
|
917
|
+
});
|
|
918
|
+
}
|
|
919
|
+
this.registered = true;
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
/** Emit a hook event and execute all matching hook definitions sequentially */
|
|
923
|
+
async emit(event, input) {
|
|
924
|
+
const matchingDefs = this.definitions.filter((def) => def.event === event);
|
|
925
|
+
const results = [];
|
|
926
|
+
for (const def of matchingDefs) {
|
|
927
|
+
try {
|
|
928
|
+
const result = await this.executeHook(def, input);
|
|
929
|
+
results.push(result);
|
|
930
|
+
} catch (error) {
|
|
931
|
+
const strategy = def.onError ?? "warn";
|
|
932
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
933
|
+
if (strategy === "abort") {
|
|
934
|
+
throw new Error(
|
|
935
|
+
`Hook "${def.command}" failed (abort): ${message}`
|
|
936
|
+
);
|
|
937
|
+
}
|
|
938
|
+
if (strategy === "warn") {
|
|
939
|
+
logger.warn(
|
|
940
|
+
`Hook "${def.command}" failed (warn): ${message}`
|
|
941
|
+
);
|
|
942
|
+
}
|
|
943
|
+
results.push({
|
|
944
|
+
exitCode: 1,
|
|
945
|
+
stdout: "",
|
|
946
|
+
stderr: message,
|
|
947
|
+
durationMs: 0,
|
|
948
|
+
output: { ...DEFAULT_HOOK_OUTPUT }
|
|
949
|
+
});
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
await this.eventBus.emit(event, input);
|
|
953
|
+
return results;
|
|
954
|
+
}
|
|
955
|
+
/** Get the count of enabled definitions for a given event */
|
|
956
|
+
getDefinitionCount(event) {
|
|
957
|
+
return this.definitions.filter((def) => def.event === event).length;
|
|
958
|
+
}
|
|
959
|
+
async executeHook(def, input) {
|
|
960
|
+
const timeoutMs = def.timeout ?? DEFAULT_TIMEOUT_MS;
|
|
961
|
+
const stdinData = JSON.stringify(input);
|
|
962
|
+
const startTime = Date.now();
|
|
963
|
+
const result = await this.processManager.executeWithInput(
|
|
964
|
+
def.command,
|
|
965
|
+
def.args ?? [],
|
|
966
|
+
stdinData,
|
|
967
|
+
{ timeout: timeoutMs }
|
|
968
|
+
);
|
|
969
|
+
const durationMs = Date.now() - startTime;
|
|
970
|
+
let output;
|
|
971
|
+
try {
|
|
972
|
+
const parsed = JSON.parse(result.stdout);
|
|
973
|
+
output = {
|
|
974
|
+
allow: typeof parsed.allow === "boolean" ? parsed.allow : true,
|
|
975
|
+
message: typeof parsed.message === "string" ? parsed.message : "",
|
|
976
|
+
metadata: typeof parsed.metadata === "object" && parsed.metadata !== null && !Array.isArray(parsed.metadata) ? parsed.metadata : {}
|
|
977
|
+
};
|
|
978
|
+
} catch {
|
|
979
|
+
output = { ...DEFAULT_HOOK_OUTPUT };
|
|
980
|
+
}
|
|
981
|
+
return {
|
|
982
|
+
exitCode: result.exitCode,
|
|
983
|
+
stdout: result.stdout,
|
|
984
|
+
stderr: result.stderr,
|
|
985
|
+
durationMs,
|
|
986
|
+
output
|
|
987
|
+
};
|
|
988
|
+
}
|
|
989
|
+
getUniqueEvents() {
|
|
990
|
+
return [...new Set(this.definitions.map((def) => def.event))];
|
|
991
|
+
}
|
|
992
|
+
};
|
|
993
|
+
|
|
994
|
+
// src/core/context-monitor.ts
|
|
995
|
+
var DEFAULT_CONFIG = {
|
|
996
|
+
enabled: true,
|
|
997
|
+
thresholdPercent: 75,
|
|
998
|
+
notifyMethod: "stderr"
|
|
999
|
+
};
|
|
1000
|
+
var ContextMonitor = class {
|
|
1001
|
+
constructor(hooksEngine2, config) {
|
|
1002
|
+
this.hooksEngine = hooksEngine2;
|
|
1003
|
+
this.config = { ...DEFAULT_CONFIG, ...config };
|
|
1004
|
+
}
|
|
1005
|
+
config;
|
|
1006
|
+
usageMap = /* @__PURE__ */ new Map();
|
|
1007
|
+
/** Update token usage for a session and check threshold */
|
|
1008
|
+
updateUsage(sessionId, backendId, estimatedTokens, maxTokens) {
|
|
1009
|
+
if (!this.config.enabled) return;
|
|
1010
|
+
const usagePercent = maxTokens > 0 ? Math.round(estimatedTokens / maxTokens * 100) : 0;
|
|
1011
|
+
const existing = this.usageMap.get(sessionId);
|
|
1012
|
+
const wasNotified = existing?.notified ?? false;
|
|
1013
|
+
this.usageMap.set(sessionId, {
|
|
1014
|
+
estimatedTokens,
|
|
1015
|
+
maxTokens,
|
|
1016
|
+
usagePercent,
|
|
1017
|
+
backendId,
|
|
1018
|
+
notified: wasNotified
|
|
1019
|
+
});
|
|
1020
|
+
if (usagePercent >= this.config.thresholdPercent && !wasNotified) {
|
|
1021
|
+
this.usageMap.get(sessionId).notified = true;
|
|
1022
|
+
this.notify(sessionId, backendId, usagePercent);
|
|
1023
|
+
}
|
|
1024
|
+
}
|
|
1025
|
+
/** Get usage info for a session */
|
|
1026
|
+
getUsage(sessionId) {
|
|
1027
|
+
const entry = this.usageMap.get(sessionId);
|
|
1028
|
+
if (!entry) return null;
|
|
1029
|
+
return {
|
|
1030
|
+
usagePercent: entry.usagePercent,
|
|
1031
|
+
isEstimated: true
|
|
1032
|
+
};
|
|
1033
|
+
}
|
|
1034
|
+
/** Remove usage tracking for a session */
|
|
1035
|
+
removeSession(sessionId) {
|
|
1036
|
+
this.usageMap.delete(sessionId);
|
|
1037
|
+
}
|
|
1038
|
+
notify(sessionId, backendId, usagePercent) {
|
|
1039
|
+
if (this.config.notifyMethod === "stderr") {
|
|
1040
|
+
process.stderr.write(
|
|
1041
|
+
`[relay] Context usage warning: session ${sessionId} is at ${usagePercent}% (threshold: ${this.config.thresholdPercent}%)
|
|
1042
|
+
`
|
|
1043
|
+
);
|
|
1044
|
+
} else if (this.config.notifyMethod === "hook" && this.hooksEngine) {
|
|
1045
|
+
const hookInput = {
|
|
1046
|
+
event: "on-context-threshold",
|
|
1047
|
+
sessionId,
|
|
1048
|
+
backendId,
|
|
1049
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1050
|
+
data: {
|
|
1051
|
+
usagePercent,
|
|
1052
|
+
thresholdPercent: this.config.thresholdPercent
|
|
1053
|
+
}
|
|
1054
|
+
};
|
|
1055
|
+
void this.hooksEngine.emit("on-context-threshold", hookInput);
|
|
1056
|
+
}
|
|
1057
|
+
}
|
|
1058
|
+
};
|
|
1059
|
+
|
|
1060
|
+
// src/commands/backend.ts
|
|
1061
|
+
import { defineCommand } from "citty";
|
|
1062
|
+
|
|
1063
|
+
// src/adapters/install-guides.ts
|
|
1064
|
+
var INSTALL_GUIDES = {
|
|
1065
|
+
claude: "Install: curl -fsSL https://claude.ai/install.sh | bash\n Or: brew install --cask claude-code",
|
|
1066
|
+
codex: "Install: npm install -g @openai/codex\n Or: brew install --cask codex",
|
|
1067
|
+
gemini: "Install: npm install -g @google/gemini-cli\n Or: brew install gemini-cli"
|
|
1068
|
+
};
|
|
1069
|
+
|
|
1070
|
+
// src/commands/backend.ts
|
|
1071
|
+
function createBackendCommand(backendId, registry2, sessionManager2, hooksEngine2) {
|
|
1072
|
+
return defineCommand({
|
|
1073
|
+
meta: {
|
|
1074
|
+
name: backendId,
|
|
1075
|
+
description: `Run ${backendId} CLI`
|
|
1076
|
+
},
|
|
1077
|
+
args: {
|
|
1078
|
+
prompt: {
|
|
1079
|
+
type: "string",
|
|
1080
|
+
alias: "p",
|
|
1081
|
+
description: "Execute a single prompt"
|
|
1082
|
+
},
|
|
1083
|
+
continue: {
|
|
1084
|
+
type: "boolean",
|
|
1085
|
+
alias: "c",
|
|
1086
|
+
description: "Continue the most recent session"
|
|
1087
|
+
},
|
|
1088
|
+
resume: {
|
|
1089
|
+
type: "string",
|
|
1090
|
+
alias: "r",
|
|
1091
|
+
description: "Resume a specific session by ID"
|
|
1092
|
+
},
|
|
1093
|
+
agent: {
|
|
1094
|
+
type: "string",
|
|
1095
|
+
description: "Agent configuration to use"
|
|
1096
|
+
},
|
|
1097
|
+
model: {
|
|
1098
|
+
type: "string",
|
|
1099
|
+
description: "Model to use"
|
|
1100
|
+
},
|
|
1101
|
+
maxTurns: {
|
|
1102
|
+
type: "string",
|
|
1103
|
+
description: "Maximum number of turns"
|
|
1104
|
+
},
|
|
1105
|
+
outputFormat: {
|
|
1106
|
+
type: "string",
|
|
1107
|
+
description: "Output format (text, json, stream-json)"
|
|
1108
|
+
},
|
|
1109
|
+
verbose: {
|
|
1110
|
+
type: "boolean",
|
|
1111
|
+
description: "Enable verbose output"
|
|
1112
|
+
}
|
|
1113
|
+
},
|
|
1114
|
+
async run({ args }) {
|
|
1115
|
+
const adapter = registry2.get(backendId);
|
|
1116
|
+
const installed = await adapter.isInstalled();
|
|
1117
|
+
if (!installed) {
|
|
1118
|
+
logger.error(
|
|
1119
|
+
`Backend "${backendId}" is not installed.
|
|
1120
|
+
${INSTALL_GUIDES[backendId]}`
|
|
1121
|
+
);
|
|
1122
|
+
process.exit(1);
|
|
1123
|
+
}
|
|
1124
|
+
const flags = {};
|
|
1125
|
+
if (args.prompt) flags.prompt = args.prompt;
|
|
1126
|
+
if (args.continue) flags.continue = true;
|
|
1127
|
+
if (args.resume) flags.resume = args.resume;
|
|
1128
|
+
if (args.agent) flags.agent = args.agent;
|
|
1129
|
+
if (args.model) flags.model = args.model;
|
|
1130
|
+
if (args.maxTurns) flags.maxTurns = Number(args.maxTurns);
|
|
1131
|
+
if (args.outputFormat) {
|
|
1132
|
+
flags.outputFormat = args.outputFormat;
|
|
1133
|
+
}
|
|
1134
|
+
if (args.verbose) flags.verbose = true;
|
|
1135
|
+
let relaySessionId;
|
|
1136
|
+
if (sessionManager2) {
|
|
1137
|
+
try {
|
|
1138
|
+
const session = await sessionManager2.create({ backendId });
|
|
1139
|
+
relaySessionId = session.relaySessionId;
|
|
1140
|
+
logger.debug(`Created relay session: ${relaySessionId}`);
|
|
1141
|
+
} catch (error) {
|
|
1142
|
+
logger.debug(
|
|
1143
|
+
`Failed to create session: ${error instanceof Error ? error.message : String(error)}`
|
|
1144
|
+
);
|
|
1145
|
+
}
|
|
1146
|
+
}
|
|
1147
|
+
if (hooksEngine2 && relaySessionId) {
|
|
1148
|
+
try {
|
|
1149
|
+
const hookInput = {
|
|
1150
|
+
event: "pre-prompt",
|
|
1151
|
+
sessionId: relaySessionId,
|
|
1152
|
+
backendId,
|
|
1153
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1154
|
+
data: { prompt: flags.prompt }
|
|
1155
|
+
};
|
|
1156
|
+
await hooksEngine2.emit("pre-prompt", hookInput);
|
|
1157
|
+
} catch (error) {
|
|
1158
|
+
logger.debug(
|
|
1159
|
+
`pre-prompt hook error: ${error instanceof Error ? error.message : String(error)}`
|
|
1160
|
+
);
|
|
1161
|
+
}
|
|
1162
|
+
}
|
|
1163
|
+
try {
|
|
1164
|
+
if (flags.prompt) {
|
|
1165
|
+
logger.debug(`Executing prompt on ${backendId}`);
|
|
1166
|
+
const result = await adapter.execute(flags);
|
|
1167
|
+
if (result.stdout) process.stdout.write(result.stdout);
|
|
1168
|
+
if (result.stderr) process.stderr.write(result.stderr);
|
|
1169
|
+
process.exitCode = result.exitCode;
|
|
1170
|
+
} else if (flags.continue) {
|
|
1171
|
+
logger.debug(`Continuing latest session on ${backendId}`);
|
|
1172
|
+
if (sessionManager2) {
|
|
1173
|
+
const latestSession = await sessionManager2.getLatest(backendId);
|
|
1174
|
+
if (latestSession?.nativeSessionId) {
|
|
1175
|
+
logger.debug(
|
|
1176
|
+
`Found latest relay session: ${latestSession.relaySessionId} (native: ${latestSession.nativeSessionId})`
|
|
1177
|
+
);
|
|
1178
|
+
}
|
|
1179
|
+
}
|
|
1180
|
+
const sessions = await adapter.listNativeSessions();
|
|
1181
|
+
if (sessions.length > 0) {
|
|
1182
|
+
await adapter.resumeSession(sessions[0].nativeId, flags);
|
|
1183
|
+
} else {
|
|
1184
|
+
await adapter.startInteractive({ ...flags, continue: true });
|
|
1185
|
+
}
|
|
1186
|
+
} else if (flags.resume) {
|
|
1187
|
+
logger.debug(`Resuming session ${flags.resume} on ${backendId}`);
|
|
1188
|
+
await adapter.resumeSession(flags.resume, flags);
|
|
1189
|
+
} else {
|
|
1190
|
+
logger.debug(`Starting interactive session on ${backendId}`);
|
|
1191
|
+
await adapter.startInteractive(flags);
|
|
1192
|
+
}
|
|
1193
|
+
if (sessionManager2 && relaySessionId) {
|
|
1194
|
+
try {
|
|
1195
|
+
await sessionManager2.update(relaySessionId, {
|
|
1196
|
+
status: "completed"
|
|
1197
|
+
});
|
|
1198
|
+
} catch {
|
|
1199
|
+
}
|
|
1200
|
+
}
|
|
1201
|
+
if (hooksEngine2 && relaySessionId) {
|
|
1202
|
+
try {
|
|
1203
|
+
const hookInput = {
|
|
1204
|
+
event: "post-response",
|
|
1205
|
+
sessionId: relaySessionId,
|
|
1206
|
+
backendId,
|
|
1207
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1208
|
+
data: { status: "completed" }
|
|
1209
|
+
};
|
|
1210
|
+
await hooksEngine2.emit("post-response", hookInput);
|
|
1211
|
+
} catch (error) {
|
|
1212
|
+
logger.debug(
|
|
1213
|
+
`post-response hook error: ${error instanceof Error ? error.message : String(error)}`
|
|
1214
|
+
);
|
|
1215
|
+
}
|
|
1216
|
+
}
|
|
1217
|
+
} catch (error) {
|
|
1218
|
+
if (sessionManager2 && relaySessionId) {
|
|
1219
|
+
try {
|
|
1220
|
+
await sessionManager2.update(relaySessionId, {
|
|
1221
|
+
status: "error"
|
|
1222
|
+
});
|
|
1223
|
+
} catch {
|
|
1224
|
+
}
|
|
1225
|
+
}
|
|
1226
|
+
if (hooksEngine2 && relaySessionId) {
|
|
1227
|
+
try {
|
|
1228
|
+
const hookInput = {
|
|
1229
|
+
event: "on-error",
|
|
1230
|
+
sessionId: relaySessionId,
|
|
1231
|
+
backendId,
|
|
1232
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1233
|
+
data: {
|
|
1234
|
+
error: error instanceof Error ? error.message : String(error)
|
|
1235
|
+
}
|
|
1236
|
+
};
|
|
1237
|
+
await hooksEngine2.emit("on-error", hookInput);
|
|
1238
|
+
} catch {
|
|
1239
|
+
}
|
|
1240
|
+
}
|
|
1241
|
+
throw error;
|
|
1242
|
+
}
|
|
1243
|
+
}
|
|
1244
|
+
});
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
// src/commands/update.ts
|
|
1248
|
+
import { defineCommand as defineCommand2 } from "citty";
|
|
1249
|
+
function createUpdateCommand(registry2) {
|
|
1250
|
+
return defineCommand2({
|
|
1251
|
+
meta: {
|
|
1252
|
+
name: "update",
|
|
1253
|
+
description: "Update all installed backend CLI tools"
|
|
1254
|
+
},
|
|
1255
|
+
async run() {
|
|
1256
|
+
const adapters = registry2.list();
|
|
1257
|
+
if (adapters.length === 0) {
|
|
1258
|
+
logger.warn("No backends registered");
|
|
1259
|
+
return;
|
|
1260
|
+
}
|
|
1261
|
+
for (const adapter of adapters) {
|
|
1262
|
+
const installed = await adapter.isInstalled();
|
|
1263
|
+
if (!installed) {
|
|
1264
|
+
logger.info(`Skipping ${adapter.id}: not installed`);
|
|
1265
|
+
continue;
|
|
1266
|
+
}
|
|
1267
|
+
logger.info(`Updating ${adapter.id}...`);
|
|
1268
|
+
try {
|
|
1269
|
+
await adapter.update();
|
|
1270
|
+
} catch (error) {
|
|
1271
|
+
logger.error(
|
|
1272
|
+
`Failed to update ${adapter.id}: ${error instanceof Error ? error.message : String(error)}`
|
|
1273
|
+
);
|
|
1274
|
+
}
|
|
1275
|
+
}
|
|
1276
|
+
}
|
|
1277
|
+
});
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1280
|
+
// src/commands/config.ts
|
|
1281
|
+
import { defineCommand as defineCommand3 } from "citty";
|
|
1282
|
+
function createConfigCommand(configManager2) {
|
|
1283
|
+
return defineCommand3({
|
|
1284
|
+
meta: {
|
|
1285
|
+
name: "config",
|
|
1286
|
+
description: "Manage relay configuration"
|
|
1287
|
+
},
|
|
1288
|
+
subCommands: {
|
|
1289
|
+
get: defineCommand3({
|
|
1290
|
+
meta: {
|
|
1291
|
+
name: "get",
|
|
1292
|
+
description: "Get a config value by key (dot-path notation)"
|
|
1293
|
+
},
|
|
1294
|
+
args: {
|
|
1295
|
+
key: {
|
|
1296
|
+
type: "positional",
|
|
1297
|
+
description: "Config key (e.g. mcpServerMode.maxDepth)",
|
|
1298
|
+
required: true
|
|
1299
|
+
}
|
|
1300
|
+
},
|
|
1301
|
+
async run({ args }) {
|
|
1302
|
+
const key = String(args.key);
|
|
1303
|
+
const value = await configManager2.get(key);
|
|
1304
|
+
if (value === void 0) {
|
|
1305
|
+
logger.info(`Key "${key}" is not set`);
|
|
1306
|
+
} else {
|
|
1307
|
+
const output = typeof value === "object" ? JSON.stringify(value, null, 2) : String(value);
|
|
1308
|
+
console.log(output);
|
|
1309
|
+
}
|
|
1310
|
+
}
|
|
1311
|
+
}),
|
|
1312
|
+
set: defineCommand3({
|
|
1313
|
+
meta: {
|
|
1314
|
+
name: "set",
|
|
1315
|
+
description: "Set a config value (default: global scope)"
|
|
1316
|
+
},
|
|
1317
|
+
args: {
|
|
1318
|
+
project: {
|
|
1319
|
+
type: "boolean",
|
|
1320
|
+
description: "Write to project scope instead of global"
|
|
1321
|
+
},
|
|
1322
|
+
key: {
|
|
1323
|
+
type: "positional",
|
|
1324
|
+
description: "Config key (e.g. defaultBackend)",
|
|
1325
|
+
required: true
|
|
1326
|
+
},
|
|
1327
|
+
value: {
|
|
1328
|
+
type: "positional",
|
|
1329
|
+
description: "Config value",
|
|
1330
|
+
required: true
|
|
1331
|
+
}
|
|
1332
|
+
},
|
|
1333
|
+
async run({ args }) {
|
|
1334
|
+
const scope = args.project ? "project" : "global";
|
|
1335
|
+
const key = String(args.key);
|
|
1336
|
+
const rawValue = String(args.value);
|
|
1337
|
+
const parsed = parseValue(rawValue);
|
|
1338
|
+
await configManager2.set(scope, key, parsed);
|
|
1339
|
+
logger.success(`Set ${key} = ${JSON.stringify(parsed)} (${scope})`);
|
|
1340
|
+
}
|
|
1341
|
+
})
|
|
1342
|
+
},
|
|
1343
|
+
async run() {
|
|
1344
|
+
const config = await configManager2.getConfig();
|
|
1345
|
+
console.log(JSON.stringify(config, null, 2));
|
|
1346
|
+
}
|
|
1347
|
+
});
|
|
1348
|
+
}
|
|
1349
|
+
function parseValue(raw) {
|
|
1350
|
+
if (raw === "true") return true;
|
|
1351
|
+
if (raw === "false") return false;
|
|
1352
|
+
if (raw === "null") return null;
|
|
1353
|
+
const num = Number(raw);
|
|
1354
|
+
if (!Number.isNaN(num) && raw.trim() !== "") return num;
|
|
1355
|
+
try {
|
|
1356
|
+
const parsed = JSON.parse(raw);
|
|
1357
|
+
if (typeof parsed === "object") return parsed;
|
|
1358
|
+
} catch {
|
|
1359
|
+
}
|
|
1360
|
+
return raw;
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1363
|
+
// src/commands/mcp.ts
|
|
1364
|
+
import { defineCommand as defineCommand4 } from "citty";
|
|
1365
|
+
|
|
1366
|
+
// src/mcp-server/server.ts
|
|
1367
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
1368
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
1369
|
+
import { z as z5 } from "zod";
|
|
1370
|
+
|
|
1371
|
+
// src/mcp-server/recursion-guard.ts
|
|
1372
|
+
import { createHash } from "crypto";
|
|
1373
|
+
var RecursionGuard = class {
|
|
1374
|
+
constructor(config = {
|
|
1375
|
+
maxDepth: 5,
|
|
1376
|
+
maxCallsPerSession: 20,
|
|
1377
|
+
timeoutSec: 300
|
|
1378
|
+
}) {
|
|
1379
|
+
this.config = config;
|
|
1380
|
+
}
|
|
1381
|
+
callCounts = /* @__PURE__ */ new Map();
|
|
1382
|
+
promptHashes = /* @__PURE__ */ new Map();
|
|
1383
|
+
/** Check if a spawn is allowed */
|
|
1384
|
+
canSpawn(context) {
|
|
1385
|
+
if (context.depth >= this.config.maxDepth) {
|
|
1386
|
+
return {
|
|
1387
|
+
allowed: false,
|
|
1388
|
+
reason: `Max depth exceeded: ${context.depth} >= ${this.config.maxDepth}`
|
|
1389
|
+
};
|
|
1390
|
+
}
|
|
1391
|
+
const currentCount = this.callCounts.get(context.traceId) ?? 0;
|
|
1392
|
+
if (currentCount >= this.config.maxCallsPerSession) {
|
|
1393
|
+
return {
|
|
1394
|
+
allowed: false,
|
|
1395
|
+
reason: `Max calls per session exceeded: ${currentCount} >= ${this.config.maxCallsPerSession}`
|
|
1396
|
+
};
|
|
1397
|
+
}
|
|
1398
|
+
if (this.detectLoop(context.traceId, context.backend, context.promptHash)) {
|
|
1399
|
+
return {
|
|
1400
|
+
allowed: false,
|
|
1401
|
+
reason: `Loop detected: same (backend=${context.backend}, promptHash=${context.promptHash}) appeared 3+ times in trace ${context.traceId}`
|
|
1402
|
+
};
|
|
1403
|
+
}
|
|
1404
|
+
return { allowed: true };
|
|
1405
|
+
}
|
|
1406
|
+
/** Record a spawn invocation */
|
|
1407
|
+
recordSpawn(context) {
|
|
1408
|
+
const currentCount = this.callCounts.get(context.traceId) ?? 0;
|
|
1409
|
+
this.callCounts.set(context.traceId, currentCount + 1);
|
|
1410
|
+
const key = `${context.backend}:${context.promptHash}`;
|
|
1411
|
+
const existing = this.promptHashes.get(context.traceId) ?? [];
|
|
1412
|
+
existing.push(key);
|
|
1413
|
+
this.promptHashes.set(context.traceId, existing);
|
|
1414
|
+
}
|
|
1415
|
+
/** Detect if the same (backend + promptHash) combination has appeared 3+ times */
|
|
1416
|
+
detectLoop(traceId, backend, promptHash) {
|
|
1417
|
+
const key = `${backend}:${promptHash}`;
|
|
1418
|
+
const hashes = this.promptHashes.get(traceId) ?? [];
|
|
1419
|
+
const count = hashes.filter((h) => h === key).length;
|
|
1420
|
+
return count >= 3;
|
|
1421
|
+
}
|
|
1422
|
+
/** Get current config (for testing/inspection) */
|
|
1423
|
+
getConfig() {
|
|
1424
|
+
return { ...this.config };
|
|
1425
|
+
}
|
|
1426
|
+
/** Get call count for a trace */
|
|
1427
|
+
getCallCount(traceId) {
|
|
1428
|
+
return this.callCounts.get(traceId) ?? 0;
|
|
1429
|
+
}
|
|
1430
|
+
/** Utility: compute a prompt hash */
|
|
1431
|
+
static hashPrompt(prompt) {
|
|
1432
|
+
return createHash("sha256").update(prompt).digest("hex").slice(0, 16);
|
|
1433
|
+
}
|
|
1434
|
+
};
|
|
1435
|
+
|
|
1436
|
+
// src/mcp-server/tools/spawn-agent.ts
|
|
1437
|
+
import { z as z2 } from "zod";
|
|
1438
|
+
import { nanoid as nanoid2 } from "nanoid";
|
|
1439
|
+
var spawnAgentInputSchema = z2.object({
|
|
1440
|
+
backend: z2.enum(["claude", "codex", "gemini"]),
|
|
1441
|
+
prompt: z2.string(),
|
|
1442
|
+
agent: z2.string().optional(),
|
|
1443
|
+
resumeSessionId: z2.string().optional(),
|
|
1444
|
+
model: z2.string().optional(),
|
|
1445
|
+
maxTurns: z2.number().optional()
|
|
1446
|
+
});
|
|
1447
|
+
function buildContextFromEnv() {
|
|
1448
|
+
const traceId = process.env["RELAY_TRACE_ID"] ?? `trace-${nanoid2()}`;
|
|
1449
|
+
const parentSessionId = process.env["RELAY_PARENT_SESSION_ID"] ?? null;
|
|
1450
|
+
const depth = Number(process.env["RELAY_DEPTH"] ?? "0");
|
|
1451
|
+
return { traceId, parentSessionId, depth };
|
|
1452
|
+
}
|
|
1453
|
+
async function executeSpawnAgent(input, registry2, sessionManager2, guard, hooksEngine2) {
|
|
1454
|
+
const envContext = buildContextFromEnv();
|
|
1455
|
+
const promptHash = RecursionGuard.hashPrompt(input.prompt);
|
|
1456
|
+
const context = {
|
|
1457
|
+
traceId: envContext.traceId,
|
|
1458
|
+
depth: envContext.depth,
|
|
1459
|
+
backend: input.backend,
|
|
1460
|
+
promptHash
|
|
1461
|
+
};
|
|
1462
|
+
const guardResult = guard.canSpawn(context);
|
|
1463
|
+
if (!guardResult.allowed) {
|
|
1464
|
+
logger.warn(`Spawn blocked by RecursionGuard: ${guardResult.reason}`);
|
|
1465
|
+
return {
|
|
1466
|
+
sessionId: "",
|
|
1467
|
+
exitCode: 1,
|
|
1468
|
+
stdout: "",
|
|
1469
|
+
stderr: `Spawn blocked: ${guardResult.reason}`
|
|
1470
|
+
};
|
|
1471
|
+
}
|
|
1472
|
+
const adapter = registry2.get(input.backend);
|
|
1473
|
+
const installed = await adapter.isInstalled();
|
|
1474
|
+
if (!installed) {
|
|
1475
|
+
return {
|
|
1476
|
+
sessionId: "",
|
|
1477
|
+
exitCode: 1,
|
|
1478
|
+
stdout: "",
|
|
1479
|
+
stderr: `Backend "${input.backend}" is not installed`
|
|
1480
|
+
};
|
|
1481
|
+
}
|
|
1482
|
+
const session = await sessionManager2.create({
|
|
1483
|
+
backendId: input.backend,
|
|
1484
|
+
parentSessionId: envContext.parentSessionId ?? void 0,
|
|
1485
|
+
depth: envContext.depth + 1
|
|
1486
|
+
});
|
|
1487
|
+
if (hooksEngine2) {
|
|
1488
|
+
try {
|
|
1489
|
+
const hookInput = {
|
|
1490
|
+
event: "pre-spawn",
|
|
1491
|
+
sessionId: session.relaySessionId,
|
|
1492
|
+
backendId: input.backend,
|
|
1493
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1494
|
+
data: {
|
|
1495
|
+
prompt: input.prompt,
|
|
1496
|
+
agent: input.agent,
|
|
1497
|
+
model: input.model
|
|
1498
|
+
}
|
|
1499
|
+
};
|
|
1500
|
+
await hooksEngine2.emit("pre-spawn", hookInput);
|
|
1501
|
+
} catch (error) {
|
|
1502
|
+
logger.debug(
|
|
1503
|
+
`pre-spawn hook error: ${error instanceof Error ? error.message : String(error)}`
|
|
1504
|
+
);
|
|
1505
|
+
}
|
|
1506
|
+
}
|
|
1507
|
+
try {
|
|
1508
|
+
const result = await adapter.execute({
|
|
1509
|
+
prompt: input.prompt,
|
|
1510
|
+
agent: input.agent,
|
|
1511
|
+
model: input.model,
|
|
1512
|
+
maxTurns: input.maxTurns,
|
|
1513
|
+
resume: input.resumeSessionId,
|
|
1514
|
+
mcpContext: {
|
|
1515
|
+
parentSessionId: session.relaySessionId,
|
|
1516
|
+
depth: envContext.depth + 1,
|
|
1517
|
+
maxDepth: guard.getConfig().maxDepth,
|
|
1518
|
+
traceId: envContext.traceId
|
|
1519
|
+
}
|
|
1520
|
+
});
|
|
1521
|
+
guard.recordSpawn(context);
|
|
1522
|
+
const status = result.exitCode === 0 ? "completed" : "error";
|
|
1523
|
+
await sessionManager2.update(session.relaySessionId, { status });
|
|
1524
|
+
if (hooksEngine2) {
|
|
1525
|
+
try {
|
|
1526
|
+
const hookInput = {
|
|
1527
|
+
event: "post-spawn",
|
|
1528
|
+
sessionId: session.relaySessionId,
|
|
1529
|
+
backendId: input.backend,
|
|
1530
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1531
|
+
data: {
|
|
1532
|
+
exitCode: result.exitCode,
|
|
1533
|
+
status
|
|
1534
|
+
}
|
|
1535
|
+
};
|
|
1536
|
+
await hooksEngine2.emit("post-spawn", hookInput);
|
|
1537
|
+
} catch (hookError) {
|
|
1538
|
+
logger.debug(
|
|
1539
|
+
`post-spawn hook error: ${hookError instanceof Error ? hookError.message : String(hookError)}`
|
|
1540
|
+
);
|
|
1541
|
+
}
|
|
1542
|
+
}
|
|
1543
|
+
return {
|
|
1544
|
+
sessionId: session.relaySessionId,
|
|
1545
|
+
exitCode: result.exitCode,
|
|
1546
|
+
stdout: result.stdout,
|
|
1547
|
+
stderr: result.stderr
|
|
1548
|
+
};
|
|
1549
|
+
} catch (error) {
|
|
1550
|
+
await sessionManager2.update(session.relaySessionId, { status: "error" });
|
|
1551
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1552
|
+
return {
|
|
1553
|
+
sessionId: session.relaySessionId,
|
|
1554
|
+
exitCode: 1,
|
|
1555
|
+
stdout: "",
|
|
1556
|
+
stderr: message
|
|
1557
|
+
};
|
|
1558
|
+
}
|
|
1559
|
+
}
|
|
1560
|
+
|
|
1561
|
+
// src/mcp-server/tools/list-sessions.ts
|
|
1562
|
+
import { z as z3 } from "zod";
|
|
1563
|
+
var listSessionsInputSchema = z3.object({
|
|
1564
|
+
backend: z3.enum(["claude", "codex", "gemini"]).optional(),
|
|
1565
|
+
limit: z3.number().optional().default(10)
|
|
1566
|
+
});
|
|
1567
|
+
async function executeListSessions(input, sessionManager2) {
|
|
1568
|
+
const sessions = await sessionManager2.list({
|
|
1569
|
+
backendId: input.backend,
|
|
1570
|
+
limit: input.limit
|
|
1571
|
+
});
|
|
1572
|
+
return {
|
|
1573
|
+
sessions: sessions.map((s) => ({
|
|
1574
|
+
relaySessionId: s.relaySessionId,
|
|
1575
|
+
backendId: s.backendId,
|
|
1576
|
+
status: s.status,
|
|
1577
|
+
createdAt: s.createdAt.toISOString()
|
|
1578
|
+
}))
|
|
1579
|
+
};
|
|
1580
|
+
}
|
|
1581
|
+
|
|
1582
|
+
// src/mcp-server/tools/get-context-status.ts
|
|
1583
|
+
import { z as z4 } from "zod";
|
|
1584
|
+
var getContextStatusInputSchema = z4.object({
|
|
1585
|
+
sessionId: z4.string()
|
|
1586
|
+
});
|
|
1587
|
+
async function executeGetContextStatus(input, sessionManager2, contextMonitor2) {
|
|
1588
|
+
const session = await sessionManager2.get(input.sessionId);
|
|
1589
|
+
if (!session) {
|
|
1590
|
+
throw new Error(`Session not found: ${input.sessionId}`);
|
|
1591
|
+
}
|
|
1592
|
+
if (contextMonitor2) {
|
|
1593
|
+
const usage = contextMonitor2.getUsage(input.sessionId);
|
|
1594
|
+
if (usage) {
|
|
1595
|
+
return {
|
|
1596
|
+
sessionId: input.sessionId,
|
|
1597
|
+
usagePercent: usage.usagePercent,
|
|
1598
|
+
isEstimated: usage.isEstimated
|
|
1599
|
+
};
|
|
1600
|
+
}
|
|
1601
|
+
}
|
|
1602
|
+
return {
|
|
1603
|
+
sessionId: input.sessionId,
|
|
1604
|
+
usagePercent: 0,
|
|
1605
|
+
isEstimated: true
|
|
1606
|
+
};
|
|
1607
|
+
}
|
|
1608
|
+
|
|
1609
|
+
// src/mcp-server/server.ts
|
|
1610
|
+
var RelayMCPServer = class {
|
|
1611
|
+
constructor(registry2, sessionManager2, guardConfig, hooksEngine2, contextMonitor2) {
|
|
1612
|
+
this.registry = registry2;
|
|
1613
|
+
this.sessionManager = sessionManager2;
|
|
1614
|
+
this.hooksEngine = hooksEngine2;
|
|
1615
|
+
this.contextMonitor = contextMonitor2;
|
|
1616
|
+
this.guard = new RecursionGuard(guardConfig);
|
|
1617
|
+
this.server = new McpServer({
|
|
1618
|
+
name: "agentic-relay",
|
|
1619
|
+
version: "0.1.0"
|
|
1620
|
+
});
|
|
1621
|
+
this.registerTools();
|
|
1622
|
+
}
|
|
1623
|
+
server;
|
|
1624
|
+
guard;
|
|
1625
|
+
registerTools() {
|
|
1626
|
+
this.server.tool(
|
|
1627
|
+
"spawn_agent",
|
|
1628
|
+
"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.",
|
|
1629
|
+
{
|
|
1630
|
+
backend: z5.enum(["claude", "codex", "gemini"]),
|
|
1631
|
+
prompt: z5.string(),
|
|
1632
|
+
agent: z5.string().optional(),
|
|
1633
|
+
resumeSessionId: z5.string().optional(),
|
|
1634
|
+
model: z5.string().optional(),
|
|
1635
|
+
maxTurns: z5.number().optional()
|
|
1636
|
+
},
|
|
1637
|
+
async (params) => {
|
|
1638
|
+
try {
|
|
1639
|
+
const result = await executeSpawnAgent(
|
|
1640
|
+
params,
|
|
1641
|
+
this.registry,
|
|
1642
|
+
this.sessionManager,
|
|
1643
|
+
this.guard,
|
|
1644
|
+
this.hooksEngine
|
|
1645
|
+
);
|
|
1646
|
+
const isError = result.exitCode !== 0;
|
|
1647
|
+
const text = isError ? `Error (exit ${result.exitCode}): ${result.stderr || result.stdout}` : `Session: ${result.sessionId}
|
|
1648
|
+
|
|
1649
|
+
${result.stdout}`;
|
|
1650
|
+
return {
|
|
1651
|
+
content: [{ type: "text", text }],
|
|
1652
|
+
isError
|
|
1653
|
+
};
|
|
1654
|
+
} catch (error) {
|
|
1655
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1656
|
+
return {
|
|
1657
|
+
content: [{ type: "text", text: `Error: ${message}` }],
|
|
1658
|
+
isError: true
|
|
1659
|
+
};
|
|
1660
|
+
}
|
|
1661
|
+
}
|
|
1662
|
+
);
|
|
1663
|
+
this.server.tool(
|
|
1664
|
+
"list_sessions",
|
|
1665
|
+
"List relay sessions, optionally filtered by backend.",
|
|
1666
|
+
{
|
|
1667
|
+
backend: z5.enum(["claude", "codex", "gemini"]).optional(),
|
|
1668
|
+
limit: z5.number().optional()
|
|
1669
|
+
},
|
|
1670
|
+
async (params) => {
|
|
1671
|
+
try {
|
|
1672
|
+
const result = await executeListSessions(
|
|
1673
|
+
{ backend: params.backend, limit: params.limit ?? 10 },
|
|
1674
|
+
this.sessionManager
|
|
1675
|
+
);
|
|
1676
|
+
return {
|
|
1677
|
+
content: [
|
|
1678
|
+
{
|
|
1679
|
+
type: "text",
|
|
1680
|
+
text: JSON.stringify(result, null, 2)
|
|
1681
|
+
}
|
|
1682
|
+
]
|
|
1683
|
+
};
|
|
1684
|
+
} catch (error) {
|
|
1685
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1686
|
+
return {
|
|
1687
|
+
content: [{ type: "text", text: `Error: ${message}` }],
|
|
1688
|
+
isError: true
|
|
1689
|
+
};
|
|
1690
|
+
}
|
|
1691
|
+
}
|
|
1692
|
+
);
|
|
1693
|
+
this.server.tool(
|
|
1694
|
+
"get_context_status",
|
|
1695
|
+
"Get the context usage status of a relay session. Returns usage data from ContextMonitor when available, otherwise estimated values.",
|
|
1696
|
+
{
|
|
1697
|
+
sessionId: z5.string()
|
|
1698
|
+
},
|
|
1699
|
+
async (params) => {
|
|
1700
|
+
try {
|
|
1701
|
+
const result = await executeGetContextStatus(
|
|
1702
|
+
params,
|
|
1703
|
+
this.sessionManager,
|
|
1704
|
+
this.contextMonitor
|
|
1705
|
+
);
|
|
1706
|
+
return {
|
|
1707
|
+
content: [
|
|
1708
|
+
{
|
|
1709
|
+
type: "text",
|
|
1710
|
+
text: JSON.stringify(result, null, 2)
|
|
1711
|
+
}
|
|
1712
|
+
]
|
|
1713
|
+
};
|
|
1714
|
+
} catch (error) {
|
|
1715
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1716
|
+
return {
|
|
1717
|
+
content: [{ type: "text", text: `Error: ${message}` }],
|
|
1718
|
+
isError: true
|
|
1719
|
+
};
|
|
1720
|
+
}
|
|
1721
|
+
}
|
|
1722
|
+
);
|
|
1723
|
+
}
|
|
1724
|
+
async start() {
|
|
1725
|
+
logger.info("Starting agentic-relay MCP server (stdio transport)...");
|
|
1726
|
+
const transport = new StdioServerTransport();
|
|
1727
|
+
await this.server.connect(transport);
|
|
1728
|
+
}
|
|
1729
|
+
};
|
|
1730
|
+
|
|
1731
|
+
// src/commands/mcp.ts
|
|
1732
|
+
function createMCPCommand(configManager2, registry2, sessionManager2, hooksEngine2, contextMonitor2) {
|
|
1733
|
+
return defineCommand4({
|
|
1734
|
+
meta: {
|
|
1735
|
+
name: "mcp",
|
|
1736
|
+
description: "Manage MCP server configuration"
|
|
1737
|
+
},
|
|
1738
|
+
subCommands: {
|
|
1739
|
+
list: defineCommand4({
|
|
1740
|
+
meta: {
|
|
1741
|
+
name: "list",
|
|
1742
|
+
description: "List configured MCP servers"
|
|
1743
|
+
},
|
|
1744
|
+
async run() {
|
|
1745
|
+
const config = await configManager2.getConfig();
|
|
1746
|
+
const servers = config.mcpServers ?? {};
|
|
1747
|
+
const entries = Object.entries(servers);
|
|
1748
|
+
if (entries.length === 0) {
|
|
1749
|
+
logger.info("No MCP servers configured");
|
|
1750
|
+
return;
|
|
1751
|
+
}
|
|
1752
|
+
console.log("MCP Servers:");
|
|
1753
|
+
console.log(
|
|
1754
|
+
"%-20s %-20s %s",
|
|
1755
|
+
"NAME",
|
|
1756
|
+
"COMMAND",
|
|
1757
|
+
"ARGS"
|
|
1758
|
+
);
|
|
1759
|
+
console.log("-".repeat(60));
|
|
1760
|
+
for (const [name, server] of entries) {
|
|
1761
|
+
const args = server.args?.join(" ") ?? "";
|
|
1762
|
+
console.log(
|
|
1763
|
+
"%-20s %-20s %s",
|
|
1764
|
+
name,
|
|
1765
|
+
server.command,
|
|
1766
|
+
args
|
|
1767
|
+
);
|
|
1768
|
+
}
|
|
1769
|
+
}
|
|
1770
|
+
}),
|
|
1771
|
+
add: defineCommand4({
|
|
1772
|
+
meta: {
|
|
1773
|
+
name: "add",
|
|
1774
|
+
description: "Add an MCP server (usage: relay mcp add <name> -- <command> [args...])"
|
|
1775
|
+
},
|
|
1776
|
+
args: {
|
|
1777
|
+
name: {
|
|
1778
|
+
type: "positional",
|
|
1779
|
+
description: "Server name",
|
|
1780
|
+
required: true
|
|
1781
|
+
},
|
|
1782
|
+
_: {
|
|
1783
|
+
type: "positional",
|
|
1784
|
+
description: "Command and args (after --)",
|
|
1785
|
+
required: false
|
|
1786
|
+
}
|
|
1787
|
+
},
|
|
1788
|
+
async run({ args }) {
|
|
1789
|
+
const name = String(args.name);
|
|
1790
|
+
const rawArgs = process.argv;
|
|
1791
|
+
const doubleDashIndex = rawArgs.indexOf("--");
|
|
1792
|
+
if (doubleDashIndex === -1 || doubleDashIndex >= rawArgs.length - 1) {
|
|
1793
|
+
logger.error(
|
|
1794
|
+
"Usage: relay mcp add <name> -- <command> [args...]"
|
|
1795
|
+
);
|
|
1796
|
+
process.exitCode = 1;
|
|
1797
|
+
return;
|
|
1798
|
+
}
|
|
1799
|
+
const commandArgs = rawArgs.slice(doubleDashIndex + 1);
|
|
1800
|
+
const command = commandArgs[0];
|
|
1801
|
+
const serverArgs = commandArgs.slice(1);
|
|
1802
|
+
const server = {
|
|
1803
|
+
name,
|
|
1804
|
+
command,
|
|
1805
|
+
...serverArgs.length > 0 ? { args: serverArgs } : {}
|
|
1806
|
+
};
|
|
1807
|
+
await configManager2.set("global", `mcpServers.${name}`, server);
|
|
1808
|
+
logger.success(`Added MCP server "${name}"`);
|
|
1809
|
+
}
|
|
1810
|
+
}),
|
|
1811
|
+
remove: defineCommand4({
|
|
1812
|
+
meta: {
|
|
1813
|
+
name: "remove",
|
|
1814
|
+
description: "Remove an MCP server"
|
|
1815
|
+
},
|
|
1816
|
+
args: {
|
|
1817
|
+
name: {
|
|
1818
|
+
type: "positional",
|
|
1819
|
+
description: "Server name to remove",
|
|
1820
|
+
required: true
|
|
1821
|
+
}
|
|
1822
|
+
},
|
|
1823
|
+
async run({ args }) {
|
|
1824
|
+
const name = String(args.name);
|
|
1825
|
+
const config = await configManager2.getConfig();
|
|
1826
|
+
const servers = config.mcpServers ?? {};
|
|
1827
|
+
if (!(name in servers)) {
|
|
1828
|
+
logger.error(`MCP server "${name}" not found`);
|
|
1829
|
+
process.exitCode = 1;
|
|
1830
|
+
return;
|
|
1831
|
+
}
|
|
1832
|
+
const globalConfig = await configManager2.getConfigByScope("global");
|
|
1833
|
+
const mcpServers = globalConfig.mcpServers ?? {};
|
|
1834
|
+
delete mcpServers[name];
|
|
1835
|
+
await configManager2.set("global", "mcpServers", mcpServers);
|
|
1836
|
+
logger.success(`Removed MCP server "${name}"`);
|
|
1837
|
+
}
|
|
1838
|
+
}),
|
|
1839
|
+
sync: defineCommand4({
|
|
1840
|
+
meta: {
|
|
1841
|
+
name: "sync",
|
|
1842
|
+
description: "Sync MCP config to all backend CLI tools"
|
|
1843
|
+
},
|
|
1844
|
+
async run() {
|
|
1845
|
+
await configManager2.syncMCPConfig(registry2);
|
|
1846
|
+
logger.success("MCP config sync complete");
|
|
1847
|
+
}
|
|
1848
|
+
}),
|
|
1849
|
+
serve: defineCommand4({
|
|
1850
|
+
meta: {
|
|
1851
|
+
name: "serve",
|
|
1852
|
+
description: "Start relay as an MCP server (stdio transport)"
|
|
1853
|
+
},
|
|
1854
|
+
async run() {
|
|
1855
|
+
if (!sessionManager2) {
|
|
1856
|
+
logger.error("SessionManager is required for MCP server mode");
|
|
1857
|
+
process.exitCode = 1;
|
|
1858
|
+
return;
|
|
1859
|
+
}
|
|
1860
|
+
let guardConfig;
|
|
1861
|
+
try {
|
|
1862
|
+
const config = await configManager2.getConfig();
|
|
1863
|
+
if (config.mcpServerMode) {
|
|
1864
|
+
guardConfig = {
|
|
1865
|
+
maxDepth: config.mcpServerMode.maxDepth ?? 5,
|
|
1866
|
+
maxCallsPerSession: config.mcpServerMode.maxCallsPerSession ?? 20,
|
|
1867
|
+
timeoutSec: config.mcpServerMode.timeoutSec ?? 300
|
|
1868
|
+
};
|
|
1869
|
+
}
|
|
1870
|
+
} catch {
|
|
1871
|
+
}
|
|
1872
|
+
const server = new RelayMCPServer(
|
|
1873
|
+
registry2,
|
|
1874
|
+
sessionManager2,
|
|
1875
|
+
guardConfig,
|
|
1876
|
+
hooksEngine2,
|
|
1877
|
+
contextMonitor2
|
|
1878
|
+
);
|
|
1879
|
+
await server.start();
|
|
1880
|
+
}
|
|
1881
|
+
})
|
|
1882
|
+
},
|
|
1883
|
+
async run() {
|
|
1884
|
+
logger.info(
|
|
1885
|
+
"Usage: relay mcp <list|add|remove|sync|serve>. Run relay mcp --help for details."
|
|
1886
|
+
);
|
|
1887
|
+
}
|
|
1888
|
+
});
|
|
1889
|
+
}
|
|
1890
|
+
|
|
1891
|
+
// src/commands/auth.ts
|
|
1892
|
+
import { defineCommand as defineCommand5 } from "citty";
|
|
1893
|
+
function createBackendAuthCommand(backendId, authManager2) {
|
|
1894
|
+
return defineCommand5({
|
|
1895
|
+
meta: {
|
|
1896
|
+
name: backendId,
|
|
1897
|
+
description: `Manage ${backendId} authentication`
|
|
1898
|
+
},
|
|
1899
|
+
args: {
|
|
1900
|
+
action: {
|
|
1901
|
+
type: "string",
|
|
1902
|
+
description: "Auth action: login, logout, or status",
|
|
1903
|
+
required: true
|
|
1904
|
+
}
|
|
1905
|
+
},
|
|
1906
|
+
async run({ args }) {
|
|
1907
|
+
const action = args.action;
|
|
1908
|
+
if (!["login", "logout", "status"].includes(action)) {
|
|
1909
|
+
logger.error(
|
|
1910
|
+
`Invalid auth action "${action}". Use login, logout, or status.`
|
|
1911
|
+
);
|
|
1912
|
+
process.exitCode = 1;
|
|
1913
|
+
return;
|
|
1914
|
+
}
|
|
1915
|
+
await authManager2.auth(backendId, action);
|
|
1916
|
+
}
|
|
1917
|
+
});
|
|
1918
|
+
}
|
|
1919
|
+
function createAuthCommand(authManager2) {
|
|
1920
|
+
return defineCommand5({
|
|
1921
|
+
meta: {
|
|
1922
|
+
name: "auth",
|
|
1923
|
+
description: "Manage backend authentication"
|
|
1924
|
+
},
|
|
1925
|
+
subCommands: {
|
|
1926
|
+
claude: createBackendAuthCommand("claude", authManager2),
|
|
1927
|
+
codex: createBackendAuthCommand("codex", authManager2),
|
|
1928
|
+
gemini: createBackendAuthCommand("gemini", authManager2),
|
|
1929
|
+
status: defineCommand5({
|
|
1930
|
+
meta: {
|
|
1931
|
+
name: "status",
|
|
1932
|
+
description: "Show auth status for all backends"
|
|
1933
|
+
},
|
|
1934
|
+
async run() {
|
|
1935
|
+
await authManager2.statusAll();
|
|
1936
|
+
}
|
|
1937
|
+
})
|
|
1938
|
+
},
|
|
1939
|
+
async run() {
|
|
1940
|
+
await authManager2.statusAll();
|
|
1941
|
+
}
|
|
1942
|
+
});
|
|
1943
|
+
}
|
|
1944
|
+
|
|
1945
|
+
// src/commands/sessions.ts
|
|
1946
|
+
import { defineCommand as defineCommand6 } from "citty";
|
|
1947
|
+
function formatDate(date) {
|
|
1948
|
+
const y = date.getFullYear();
|
|
1949
|
+
const mo = String(date.getMonth() + 1).padStart(2, "0");
|
|
1950
|
+
const d = String(date.getDate()).padStart(2, "0");
|
|
1951
|
+
const h = String(date.getHours()).padStart(2, "0");
|
|
1952
|
+
const mi = String(date.getMinutes()).padStart(2, "0");
|
|
1953
|
+
const s = String(date.getSeconds()).padStart(2, "0");
|
|
1954
|
+
return `${y}-${mo}-${d} ${h}:${mi}:${s}`;
|
|
1955
|
+
}
|
|
1956
|
+
function padRight(str, len) {
|
|
1957
|
+
return str.length >= len ? str : str + " ".repeat(len - str.length);
|
|
1958
|
+
}
|
|
1959
|
+
function createSessionsCommand(sessionManager2) {
|
|
1960
|
+
return defineCommand6({
|
|
1961
|
+
meta: {
|
|
1962
|
+
name: "sessions",
|
|
1963
|
+
description: "List and search relay sessions"
|
|
1964
|
+
},
|
|
1965
|
+
args: {
|
|
1966
|
+
backend: {
|
|
1967
|
+
type: "string",
|
|
1968
|
+
description: "Filter by backend (claude, codex, gemini)"
|
|
1969
|
+
},
|
|
1970
|
+
limit: {
|
|
1971
|
+
type: "string",
|
|
1972
|
+
description: "Maximum number of sessions to display (default: 10)"
|
|
1973
|
+
},
|
|
1974
|
+
search: {
|
|
1975
|
+
type: "string",
|
|
1976
|
+
description: "Search sessions by keyword"
|
|
1977
|
+
}
|
|
1978
|
+
},
|
|
1979
|
+
async run({ args }) {
|
|
1980
|
+
const backendId = args.backend;
|
|
1981
|
+
const limit = args.limit ? Number(args.limit) : 10;
|
|
1982
|
+
const search = args.search;
|
|
1983
|
+
if (backendId && !["claude", "codex", "gemini"].includes(backendId)) {
|
|
1984
|
+
logger.error(
|
|
1985
|
+
`Invalid backend "${backendId}". Available: claude, codex, gemini`
|
|
1986
|
+
);
|
|
1987
|
+
process.exit(1);
|
|
1988
|
+
}
|
|
1989
|
+
if (args.limit && (isNaN(limit) || limit <= 0)) {
|
|
1990
|
+
logger.error("--limit must be a positive number");
|
|
1991
|
+
process.exit(1);
|
|
1992
|
+
}
|
|
1993
|
+
try {
|
|
1994
|
+
let sessions = await sessionManager2.list({
|
|
1995
|
+
backendId
|
|
1996
|
+
});
|
|
1997
|
+
if (search) {
|
|
1998
|
+
const keyword = search.toLowerCase();
|
|
1999
|
+
sessions = sessions.filter((s) => {
|
|
2000
|
+
return s.relaySessionId.toLowerCase().includes(keyword) || s.backendId.toLowerCase().includes(keyword) || s.status.toLowerCase().includes(keyword) || (s.parentSessionId?.toLowerCase().includes(keyword) ?? false);
|
|
2001
|
+
});
|
|
2002
|
+
}
|
|
2003
|
+
sessions = sessions.slice(0, limit);
|
|
2004
|
+
if (sessions.length === 0) {
|
|
2005
|
+
logger.info("No sessions found.");
|
|
2006
|
+
return;
|
|
2007
|
+
}
|
|
2008
|
+
const header = `${padRight("ID", 24)}${padRight("Backend", 10)}${padRight("Status", 12)}${padRight("Created", 21)}Parent`;
|
|
2009
|
+
console.log(header);
|
|
2010
|
+
for (const session of sessions) {
|
|
2011
|
+
const id = padRight(session.relaySessionId, 24);
|
|
2012
|
+
const backend = padRight(session.backendId, 10);
|
|
2013
|
+
const status = padRight(session.status, 12);
|
|
2014
|
+
const created = padRight(formatDate(session.createdAt), 21);
|
|
2015
|
+
const parent = session.parentSessionId ?? "-";
|
|
2016
|
+
console.log(`${id}${backend}${status}${created}${parent}`);
|
|
2017
|
+
}
|
|
2018
|
+
} catch (error) {
|
|
2019
|
+
logger.error(
|
|
2020
|
+
`Failed to list sessions: ${error instanceof Error ? error.message : String(error)}`
|
|
2021
|
+
);
|
|
2022
|
+
process.exit(1);
|
|
2023
|
+
}
|
|
2024
|
+
}
|
|
2025
|
+
});
|
|
2026
|
+
}
|
|
2027
|
+
|
|
2028
|
+
// src/commands/version.ts
|
|
2029
|
+
import { defineCommand as defineCommand7 } from "citty";
|
|
2030
|
+
function createVersionCommand(registry2) {
|
|
2031
|
+
return defineCommand7({
|
|
2032
|
+
meta: {
|
|
2033
|
+
name: "version",
|
|
2034
|
+
description: "Show relay and backend versions"
|
|
2035
|
+
},
|
|
2036
|
+
async run() {
|
|
2037
|
+
const relayVersion = "0.1.0";
|
|
2038
|
+
console.log(`agentic-relay v${relayVersion}`);
|
|
2039
|
+
console.log("");
|
|
2040
|
+
console.log("Backends:");
|
|
2041
|
+
const adapters = registry2.list();
|
|
2042
|
+
for (const adapter of adapters) {
|
|
2043
|
+
const installed = await adapter.isInstalled();
|
|
2044
|
+
if (installed) {
|
|
2045
|
+
try {
|
|
2046
|
+
const version = await adapter.getVersion();
|
|
2047
|
+
console.log(` ${adapter.id} ${version} installed`);
|
|
2048
|
+
} catch {
|
|
2049
|
+
console.log(` ${adapter.id} (version unknown) installed`);
|
|
2050
|
+
}
|
|
2051
|
+
} else {
|
|
2052
|
+
console.log(` ${adapter.id} - not installed`);
|
|
2053
|
+
}
|
|
2054
|
+
}
|
|
2055
|
+
}
|
|
2056
|
+
});
|
|
2057
|
+
}
|
|
2058
|
+
|
|
2059
|
+
// src/commands/doctor.ts
|
|
2060
|
+
import { defineCommand as defineCommand8 } from "citty";
|
|
2061
|
+
import { access, constants } from "fs/promises";
|
|
2062
|
+
import { join as join3 } from "path";
|
|
2063
|
+
import { homedir as homedir2 } from "os";
|
|
2064
|
+
async function checkNodeVersion() {
|
|
2065
|
+
const version = process.version;
|
|
2066
|
+
const major = Number(version.slice(1).split(".")[0]);
|
|
2067
|
+
const ok = major >= 22;
|
|
2068
|
+
return {
|
|
2069
|
+
label: "Node.js",
|
|
2070
|
+
ok,
|
|
2071
|
+
detail: ok ? `Node.js ${version}` : `Node.js ${version} (requires >= 22)`,
|
|
2072
|
+
hint: ok ? void 0 : "Upgrade Node.js to version 22 or later"
|
|
2073
|
+
};
|
|
2074
|
+
}
|
|
2075
|
+
async function checkBackend(registry2, backendId) {
|
|
2076
|
+
const adapter = registry2.get(backendId);
|
|
2077
|
+
const installed = await adapter.isInstalled();
|
|
2078
|
+
if (!installed) {
|
|
2079
|
+
return {
|
|
2080
|
+
label: backendId,
|
|
2081
|
+
ok: false,
|
|
2082
|
+
detail: `${backendId} not installed`,
|
|
2083
|
+
hint: INSTALL_GUIDES[backendId]
|
|
2084
|
+
};
|
|
2085
|
+
}
|
|
2086
|
+
try {
|
|
2087
|
+
const version = await adapter.getVersion();
|
|
2088
|
+
return {
|
|
2089
|
+
label: backendId,
|
|
2090
|
+
ok: true,
|
|
2091
|
+
detail: `${backendId} ${version}`
|
|
2092
|
+
};
|
|
2093
|
+
} catch {
|
|
2094
|
+
return {
|
|
2095
|
+
label: backendId,
|
|
2096
|
+
ok: true,
|
|
2097
|
+
detail: `${backendId} installed (version unknown)`
|
|
2098
|
+
};
|
|
2099
|
+
}
|
|
2100
|
+
}
|
|
2101
|
+
async function checkConfig(configManager2) {
|
|
2102
|
+
try {
|
|
2103
|
+
const config = await configManager2.getConfig();
|
|
2104
|
+
const hasContent = Object.keys(config).length > 0;
|
|
2105
|
+
return {
|
|
2106
|
+
label: "Config",
|
|
2107
|
+
ok: true,
|
|
2108
|
+
detail: hasContent ? "Config loaded (valid)" : "Config: default (no config file)"
|
|
2109
|
+
};
|
|
2110
|
+
} catch (error) {
|
|
2111
|
+
return {
|
|
2112
|
+
label: "Config",
|
|
2113
|
+
ok: false,
|
|
2114
|
+
detail: `Config error: ${error instanceof Error ? error.message : String(error)}`,
|
|
2115
|
+
hint: 'Run "relay init" to create a default config'
|
|
2116
|
+
};
|
|
2117
|
+
}
|
|
2118
|
+
}
|
|
2119
|
+
async function checkSessionsDir() {
|
|
2120
|
+
const relayHome2 = process.env["RELAY_HOME"] ?? join3(homedir2(), ".relay");
|
|
2121
|
+
const sessionsDir = join3(relayHome2, "sessions");
|
|
2122
|
+
try {
|
|
2123
|
+
await access(sessionsDir, constants.W_OK);
|
|
2124
|
+
return {
|
|
2125
|
+
label: "Sessions directory",
|
|
2126
|
+
ok: true,
|
|
2127
|
+
detail: `${sessionsDir} (writable)`
|
|
2128
|
+
};
|
|
2129
|
+
} catch {
|
|
2130
|
+
return {
|
|
2131
|
+
label: "Sessions directory",
|
|
2132
|
+
ok: false,
|
|
2133
|
+
detail: `${sessionsDir} (not writable or missing)`,
|
|
2134
|
+
hint: `Create directory: mkdir -p ${sessionsDir}`
|
|
2135
|
+
};
|
|
2136
|
+
}
|
|
2137
|
+
}
|
|
2138
|
+
async function checkMCPSDK() {
|
|
2139
|
+
try {
|
|
2140
|
+
await import("@modelcontextprotocol/sdk/server/mcp.js");
|
|
2141
|
+
return {
|
|
2142
|
+
label: "MCP SDK",
|
|
2143
|
+
ok: true,
|
|
2144
|
+
detail: "MCP SDK available"
|
|
2145
|
+
};
|
|
2146
|
+
} catch {
|
|
2147
|
+
return {
|
|
2148
|
+
label: "MCP SDK",
|
|
2149
|
+
ok: false,
|
|
2150
|
+
detail: "MCP SDK not available",
|
|
2151
|
+
hint: "Install: pnpm add @modelcontextprotocol/sdk"
|
|
2152
|
+
};
|
|
2153
|
+
}
|
|
2154
|
+
}
|
|
2155
|
+
function createDoctorCommand(registry2, configManager2) {
|
|
2156
|
+
return defineCommand8({
|
|
2157
|
+
meta: {
|
|
2158
|
+
name: "doctor",
|
|
2159
|
+
description: "Run environment diagnostics"
|
|
2160
|
+
},
|
|
2161
|
+
async run() {
|
|
2162
|
+
console.log("Environment Check:");
|
|
2163
|
+
const checks = [];
|
|
2164
|
+
checks.push(await checkNodeVersion());
|
|
2165
|
+
const backendIds = ["claude", "codex", "gemini"];
|
|
2166
|
+
for (const id of backendIds) {
|
|
2167
|
+
checks.push(await checkBackend(registry2, id));
|
|
2168
|
+
}
|
|
2169
|
+
checks.push(await checkConfig(configManager2));
|
|
2170
|
+
checks.push(await checkSessionsDir());
|
|
2171
|
+
checks.push(await checkMCPSDK());
|
|
2172
|
+
let hasFailures = false;
|
|
2173
|
+
for (const check of checks) {
|
|
2174
|
+
const icon = check.ok ? "\u2713" : "\u2717";
|
|
2175
|
+
console.log(` ${icon} ${check.detail}`);
|
|
2176
|
+
if (!check.ok) {
|
|
2177
|
+
hasFailures = true;
|
|
2178
|
+
if (check.hint) {
|
|
2179
|
+
console.log(` ${check.hint}`);
|
|
2180
|
+
}
|
|
2181
|
+
}
|
|
2182
|
+
}
|
|
2183
|
+
if (hasFailures) {
|
|
2184
|
+
console.log("");
|
|
2185
|
+
console.log("Some checks failed. Resolve the issues above.");
|
|
2186
|
+
process.exitCode = 1;
|
|
2187
|
+
} else {
|
|
2188
|
+
console.log("");
|
|
2189
|
+
console.log("All checks passed.");
|
|
2190
|
+
}
|
|
2191
|
+
}
|
|
2192
|
+
});
|
|
2193
|
+
}
|
|
2194
|
+
|
|
2195
|
+
// src/commands/init.ts
|
|
2196
|
+
import { defineCommand as defineCommand9 } from "citty";
|
|
2197
|
+
import { mkdir as mkdir3, writeFile as writeFile3, access as access2, readFile as readFile3 } from "fs/promises";
|
|
2198
|
+
import { join as join4 } from "path";
|
|
2199
|
+
var DEFAULT_CONFIG2 = {
|
|
2200
|
+
defaultBackend: "claude",
|
|
2201
|
+
backends: {},
|
|
2202
|
+
mcpServers: {}
|
|
2203
|
+
};
|
|
2204
|
+
function createInitCommand() {
|
|
2205
|
+
return defineCommand9({
|
|
2206
|
+
meta: {
|
|
2207
|
+
name: "init",
|
|
2208
|
+
description: "Initialize relay configuration in the current project"
|
|
2209
|
+
},
|
|
2210
|
+
async run() {
|
|
2211
|
+
const projectDir = process.cwd();
|
|
2212
|
+
const relayDir = join4(projectDir, ".relay");
|
|
2213
|
+
const configPath = join4(relayDir, "config.json");
|
|
2214
|
+
try {
|
|
2215
|
+
await access2(relayDir);
|
|
2216
|
+
logger.info(
|
|
2217
|
+
`.relay/ already exists at ${relayDir}. Skipping initialization.`
|
|
2218
|
+
);
|
|
2219
|
+
return;
|
|
2220
|
+
} catch {
|
|
2221
|
+
}
|
|
2222
|
+
await mkdir3(relayDir, { recursive: true });
|
|
2223
|
+
await writeFile3(
|
|
2224
|
+
configPath,
|
|
2225
|
+
JSON.stringify(DEFAULT_CONFIG2, null, 2) + "\n",
|
|
2226
|
+
"utf-8"
|
|
2227
|
+
);
|
|
2228
|
+
logger.success(`Created ${configPath}`);
|
|
2229
|
+
const gitignorePath = join4(projectDir, ".gitignore");
|
|
2230
|
+
try {
|
|
2231
|
+
const gitignoreContent = await readFile3(gitignorePath, "utf-8");
|
|
2232
|
+
if (!gitignoreContent.includes(".relay/config.local.json")) {
|
|
2233
|
+
logger.info(
|
|
2234
|
+
'Tip: Add ".relay/config.local.json" to your .gitignore to keep local config out of version control.'
|
|
2235
|
+
);
|
|
2236
|
+
}
|
|
2237
|
+
} catch {
|
|
2238
|
+
logger.info(
|
|
2239
|
+
'Tip: Add ".relay/config.local.json" to your .gitignore to keep local config out of version control.'
|
|
2240
|
+
);
|
|
2241
|
+
}
|
|
2242
|
+
logger.success("Relay project initialized.");
|
|
2243
|
+
}
|
|
2244
|
+
});
|
|
2245
|
+
}
|
|
2246
|
+
|
|
2247
|
+
// src/bin/relay.ts
|
|
2248
|
+
var processManager = new ProcessManager();
|
|
2249
|
+
var registry = new AdapterRegistry();
|
|
2250
|
+
registry.register(new ClaudeAdapter(processManager));
|
|
2251
|
+
registry.register(new CodexAdapter(processManager));
|
|
2252
|
+
registry.register(new GeminiAdapter(processManager));
|
|
2253
|
+
var sessionManager = new SessionManager();
|
|
2254
|
+
var relayHome = process.env["RELAY_HOME"] ?? join5(homedir3(), ".relay");
|
|
2255
|
+
var projectRelayDir = join5(process.cwd(), ".relay");
|
|
2256
|
+
var configManager = new ConfigManager(relayHome, projectRelayDir);
|
|
2257
|
+
var authManager = new AuthManager(registry);
|
|
2258
|
+
var eventBus = new EventBus();
|
|
2259
|
+
var hooksEngine = new HooksEngine(eventBus, processManager);
|
|
2260
|
+
var contextMonitor = new ContextMonitor(hooksEngine, {
|
|
2261
|
+
enabled: false
|
|
2262
|
+
// Will be enabled from config at runtime
|
|
2263
|
+
});
|
|
2264
|
+
void configManager.getConfig().then((config) => {
|
|
2265
|
+
if (config.hooks) {
|
|
2266
|
+
hooksEngine.loadConfig(config.hooks);
|
|
2267
|
+
}
|
|
2268
|
+
if (config.contextMonitor) {
|
|
2269
|
+
}
|
|
2270
|
+
}).catch(() => {
|
|
2271
|
+
});
|
|
2272
|
+
var main = defineCommand10({
|
|
2273
|
+
meta: {
|
|
2274
|
+
name: "relay",
|
|
2275
|
+
version: "0.1.0",
|
|
2276
|
+
description: "Unified CLI proxy for Claude Code, Codex CLI, and Gemini CLI"
|
|
2277
|
+
},
|
|
2278
|
+
subCommands: {
|
|
2279
|
+
claude: createBackendCommand("claude", registry, sessionManager, hooksEngine),
|
|
2280
|
+
codex: createBackendCommand("codex", registry, sessionManager, hooksEngine),
|
|
2281
|
+
gemini: createBackendCommand("gemini", registry, sessionManager, hooksEngine),
|
|
2282
|
+
update: createUpdateCommand(registry),
|
|
2283
|
+
config: createConfigCommand(configManager),
|
|
2284
|
+
mcp: createMCPCommand(configManager, registry, sessionManager, hooksEngine, contextMonitor),
|
|
2285
|
+
auth: createAuthCommand(authManager),
|
|
2286
|
+
sessions: createSessionsCommand(sessionManager),
|
|
2287
|
+
version: createVersionCommand(registry),
|
|
2288
|
+
doctor: createDoctorCommand(registry, configManager),
|
|
2289
|
+
init: createInitCommand()
|
|
2290
|
+
}
|
|
2291
|
+
});
|
|
2292
|
+
runMain(main);
|