@rk0429/agentic-relay 1.1.1 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/relay.mjs +1658 -149
- package/package.json +1 -1
package/dist/relay.mjs
CHANGED
|
@@ -169,6 +169,424 @@ var init_deferred_cleanup_task_store = __esm({
|
|
|
169
169
|
}
|
|
170
170
|
});
|
|
171
171
|
|
|
172
|
+
// src/core/agent-event-store.ts
|
|
173
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
|
|
174
|
+
import { join as join6 } from "path";
|
|
175
|
+
import { homedir as homedir5 } from "os";
|
|
176
|
+
import { nanoid as nanoid2 } from "nanoid";
|
|
177
|
+
function getRelayHome2() {
|
|
178
|
+
return process.env["RELAY_HOME"] ?? join6(homedir5(), ".relay");
|
|
179
|
+
}
|
|
180
|
+
function isValidEventType(type) {
|
|
181
|
+
return type === "session-start" || type === "session-complete" || type === "session-error" || type === "session-stale" || type === "context-threshold";
|
|
182
|
+
}
|
|
183
|
+
var DEFAULT_CONFIG2, AgentEventStore;
|
|
184
|
+
var init_agent_event_store = __esm({
|
|
185
|
+
"src/core/agent-event-store.ts"() {
|
|
186
|
+
"use strict";
|
|
187
|
+
init_logger();
|
|
188
|
+
DEFAULT_CONFIG2 = {
|
|
189
|
+
maxEvents: 1e3,
|
|
190
|
+
ttlMs: 36e5,
|
|
191
|
+
backend: "jsonl",
|
|
192
|
+
sessionDir: join6(getRelayHome2(), "sessions"),
|
|
193
|
+
eventsFileName: "events.jsonl"
|
|
194
|
+
};
|
|
195
|
+
AgentEventStore = class {
|
|
196
|
+
config;
|
|
197
|
+
eventsFilePath;
|
|
198
|
+
cleanupTimer = null;
|
|
199
|
+
events = [];
|
|
200
|
+
constructor(config) {
|
|
201
|
+
const ttlMs = config?.ttlMs ?? (config?.ttlSec ?? 3600) * 1e3;
|
|
202
|
+
this.config = {
|
|
203
|
+
...DEFAULT_CONFIG2,
|
|
204
|
+
...config,
|
|
205
|
+
ttlMs
|
|
206
|
+
};
|
|
207
|
+
this.eventsFilePath = this.config.backend === "jsonl" ? join6(this.config.sessionDir, this.config.eventsFileName) : null;
|
|
208
|
+
if (this.eventsFilePath) {
|
|
209
|
+
mkdirSync(this.config.sessionDir, { recursive: true });
|
|
210
|
+
this.restoreFromJsonl();
|
|
211
|
+
}
|
|
212
|
+
this.cleanupTimer = setInterval(() => {
|
|
213
|
+
this.prune();
|
|
214
|
+
}, 6e4);
|
|
215
|
+
this.cleanupTimer.unref();
|
|
216
|
+
}
|
|
217
|
+
restoreFromJsonl() {
|
|
218
|
+
if (!this.eventsFilePath || !existsSync(this.eventsFilePath)) {
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
let malformedCount = 0;
|
|
222
|
+
try {
|
|
223
|
+
const raw = readFileSync(this.eventsFilePath, "utf-8");
|
|
224
|
+
const lines = raw.split("\n").filter((line) => line.trim().length > 0);
|
|
225
|
+
for (const line of lines) {
|
|
226
|
+
try {
|
|
227
|
+
const parsed = JSON.parse(line);
|
|
228
|
+
if (typeof parsed.eventId !== "string" || typeof parsed.timestamp !== "string" || !isValidEventType(parsed.type) || typeof parsed.sessionId !== "string" || typeof parsed.backendId !== "string" || typeof parsed.metadata !== "object" || parsed.metadata === null || typeof parsed.data !== "object" || parsed.data === null) {
|
|
229
|
+
malformedCount += 1;
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
this.events.push({
|
|
233
|
+
eventId: parsed.eventId,
|
|
234
|
+
timestamp: parsed.timestamp,
|
|
235
|
+
type: parsed.type,
|
|
236
|
+
sessionId: parsed.sessionId,
|
|
237
|
+
parentSessionId: parsed.parentSessionId,
|
|
238
|
+
backendId: parsed.backendId,
|
|
239
|
+
metadata: parsed.metadata,
|
|
240
|
+
data: parsed.data
|
|
241
|
+
});
|
|
242
|
+
} catch {
|
|
243
|
+
malformedCount += 1;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
this.events.sort((a, b) => {
|
|
247
|
+
const diff = new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime();
|
|
248
|
+
return diff !== 0 ? diff : 0;
|
|
249
|
+
});
|
|
250
|
+
this.prune();
|
|
251
|
+
} catch (error) {
|
|
252
|
+
logger.warn(
|
|
253
|
+
`Failed to restore event store from JSONL: ${error instanceof Error ? error.message : String(error)}`
|
|
254
|
+
);
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
if (malformedCount > 0) {
|
|
258
|
+
logger.warn(
|
|
259
|
+
`Skipped ${malformedCount} malformed event line(s) while restoring ${this.eventsFilePath}`
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
record(event) {
|
|
264
|
+
const fullEvent = {
|
|
265
|
+
...event,
|
|
266
|
+
eventId: `evt-${nanoid2()}`,
|
|
267
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
268
|
+
metadata: event.metadata ?? {},
|
|
269
|
+
data: event.data ?? {}
|
|
270
|
+
};
|
|
271
|
+
this.events.push(fullEvent);
|
|
272
|
+
if (this.eventsFilePath) {
|
|
273
|
+
try {
|
|
274
|
+
appendFileSync(
|
|
275
|
+
this.eventsFilePath,
|
|
276
|
+
`${JSON.stringify(fullEvent)}
|
|
277
|
+
`,
|
|
278
|
+
"utf-8"
|
|
279
|
+
);
|
|
280
|
+
} catch (error) {
|
|
281
|
+
logger.warn(
|
|
282
|
+
`Failed to append event JSONL: ${error instanceof Error ? error.message : String(error)}`
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
this.prune();
|
|
287
|
+
return fullEvent;
|
|
288
|
+
}
|
|
289
|
+
query(params) {
|
|
290
|
+
this.prune();
|
|
291
|
+
const limit = Math.max(1, Math.min(200, params.limit));
|
|
292
|
+
let candidates = this.events;
|
|
293
|
+
if (params.afterEventId) {
|
|
294
|
+
const index = candidates.findIndex((e) => e.eventId === params.afterEventId);
|
|
295
|
+
if (index < 0) {
|
|
296
|
+
throw new Error(
|
|
297
|
+
`RELAY_INVALID_CURSOR: cursor "${params.afterEventId}" was not found`
|
|
298
|
+
);
|
|
299
|
+
}
|
|
300
|
+
candidates = candidates.slice(index + 1);
|
|
301
|
+
}
|
|
302
|
+
if (params.types && params.types.length > 0) {
|
|
303
|
+
const wanted = new Set(params.types);
|
|
304
|
+
candidates = candidates.filter((event) => wanted.has(event.type));
|
|
305
|
+
}
|
|
306
|
+
if (params.sessionId) {
|
|
307
|
+
candidates = candidates.filter((event) => event.sessionId === params.sessionId);
|
|
308
|
+
}
|
|
309
|
+
if (params.parentSessionId) {
|
|
310
|
+
if (params.recursive) {
|
|
311
|
+
const descendants = /* @__PURE__ */ new Set();
|
|
312
|
+
const queue = [params.parentSessionId];
|
|
313
|
+
while (queue.length > 0) {
|
|
314
|
+
const parent = queue.shift();
|
|
315
|
+
const children = this.events.filter((event) => event.parentSessionId === parent).map((event) => event.sessionId);
|
|
316
|
+
for (const child of children) {
|
|
317
|
+
if (!descendants.has(child)) {
|
|
318
|
+
descendants.add(child);
|
|
319
|
+
queue.push(child);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
candidates = candidates.filter((event) => descendants.has(event.sessionId));
|
|
324
|
+
} else {
|
|
325
|
+
candidates = candidates.filter(
|
|
326
|
+
(event) => event.parentSessionId === params.parentSessionId
|
|
327
|
+
);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
const events = candidates.slice(0, limit);
|
|
331
|
+
return {
|
|
332
|
+
events,
|
|
333
|
+
hasMore: candidates.length > events.length
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
prune() {
|
|
337
|
+
const now = Date.now();
|
|
338
|
+
const ttlCutoff = now - this.config.ttlMs;
|
|
339
|
+
let nextEvents = this.events.filter((event) => {
|
|
340
|
+
const ts = new Date(event.timestamp).getTime();
|
|
341
|
+
return !Number.isNaN(ts) && ts >= ttlCutoff;
|
|
342
|
+
});
|
|
343
|
+
if (nextEvents.length > this.config.maxEvents) {
|
|
344
|
+
nextEvents = nextEvents.slice(nextEvents.length - this.config.maxEvents);
|
|
345
|
+
}
|
|
346
|
+
if (nextEvents.length !== this.events.length) {
|
|
347
|
+
this.events = nextEvents;
|
|
348
|
+
if (this.eventsFilePath) {
|
|
349
|
+
try {
|
|
350
|
+
const serialized = this.events.length > 0 ? `${this.events.map((event) => JSON.stringify(event)).join("\n")}
|
|
351
|
+
` : "";
|
|
352
|
+
writeFileSync(this.eventsFilePath, serialized, "utf-8");
|
|
353
|
+
} catch (error) {
|
|
354
|
+
logger.warn(
|
|
355
|
+
`Failed to prune event JSONL file: ${error instanceof Error ? error.message : String(error)}`
|
|
356
|
+
);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
cleanup() {
|
|
362
|
+
if (this.cleanupTimer) {
|
|
363
|
+
clearInterval(this.cleanupTimer);
|
|
364
|
+
this.cleanupTimer = null;
|
|
365
|
+
}
|
|
366
|
+
this.events = [];
|
|
367
|
+
}
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
// src/core/session-health-monitor.ts
|
|
373
|
+
var DEFAULT_CONFIG3, SessionHealthMonitor;
|
|
374
|
+
var init_session_health_monitor = __esm({
|
|
375
|
+
"src/core/session-health-monitor.ts"() {
|
|
376
|
+
"use strict";
|
|
377
|
+
init_logger();
|
|
378
|
+
DEFAULT_CONFIG3 = {
|
|
379
|
+
enabled: true,
|
|
380
|
+
heartbeatIntervalSec: 300,
|
|
381
|
+
staleThresholdSec: 300,
|
|
382
|
+
cleanupAfterSec: 3600,
|
|
383
|
+
maxActiveSessions: 20,
|
|
384
|
+
checkIntervalSec: 60,
|
|
385
|
+
memoryDir: "./memory"
|
|
386
|
+
};
|
|
387
|
+
SessionHealthMonitor = class {
|
|
388
|
+
constructor(config, sessionManager2, hooksEngine2, contextMonitor2, agentEventStore) {
|
|
389
|
+
this.sessionManager = sessionManager2;
|
|
390
|
+
this.hooksEngine = hooksEngine2;
|
|
391
|
+
this.contextMonitor = contextMonitor2;
|
|
392
|
+
this.agentEventStore = agentEventStore;
|
|
393
|
+
this.config = { ...DEFAULT_CONFIG3, ...config };
|
|
394
|
+
this.staleThresholdMs = this.config.staleThresholdSec * 1e3;
|
|
395
|
+
this.cleanupAfterMs = this.config.cleanupAfterSec * 1e3;
|
|
396
|
+
this.checkIntervalMs = this.config.checkIntervalSec * 1e3;
|
|
397
|
+
}
|
|
398
|
+
config;
|
|
399
|
+
staleThresholdMs;
|
|
400
|
+
cleanupAfterMs;
|
|
401
|
+
checkIntervalMs;
|
|
402
|
+
timer = null;
|
|
403
|
+
isChecking = false;
|
|
404
|
+
start() {
|
|
405
|
+
if (!this.config.enabled || this.timer) {
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
this.timer = setInterval(() => {
|
|
409
|
+
void this.checkHealthLoop();
|
|
410
|
+
}, this.checkIntervalMs);
|
|
411
|
+
this.timer.unref();
|
|
412
|
+
}
|
|
413
|
+
stop() {
|
|
414
|
+
if (this.timer) {
|
|
415
|
+
clearInterval(this.timer);
|
|
416
|
+
this.timer = null;
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
async checkHealth(options) {
|
|
420
|
+
let sessions;
|
|
421
|
+
if (options?.sessionId) {
|
|
422
|
+
const session = await this.sessionManager.get(options.sessionId);
|
|
423
|
+
if (!session) {
|
|
424
|
+
throw new Error(`Session not found: ${options.sessionId}`);
|
|
425
|
+
}
|
|
426
|
+
sessions = [session];
|
|
427
|
+
} else if (options?.includeCompleted) {
|
|
428
|
+
sessions = await this.sessionManager.list();
|
|
429
|
+
} else {
|
|
430
|
+
sessions = await this.sessionManager.list({ status: "active" });
|
|
431
|
+
}
|
|
432
|
+
const now = Date.now();
|
|
433
|
+
const statuses = sessions.map(
|
|
434
|
+
(session) => this.buildHealthStatus(session, now)
|
|
435
|
+
);
|
|
436
|
+
const staleSessions = statuses.filter((status) => status.isStale).map((status) => status.relaySessionId);
|
|
437
|
+
return {
|
|
438
|
+
sessions: statuses,
|
|
439
|
+
staleSessions,
|
|
440
|
+
summary: {
|
|
441
|
+
total: statuses.length,
|
|
442
|
+
active: statuses.filter((s) => s.status === "active").length,
|
|
443
|
+
stale: staleSessions.length,
|
|
444
|
+
completed: statuses.filter((s) => s.status === "completed").length,
|
|
445
|
+
error: statuses.filter((s) => s.status === "error").length
|
|
446
|
+
}
|
|
447
|
+
};
|
|
448
|
+
}
|
|
449
|
+
isStale(session) {
|
|
450
|
+
if (session.status !== "active") {
|
|
451
|
+
return false;
|
|
452
|
+
}
|
|
453
|
+
return this.getLastActivityAtMs(session) + this.staleThresholdMs < Date.now();
|
|
454
|
+
}
|
|
455
|
+
async handleStaleSession(session) {
|
|
456
|
+
if (session.status !== "active") {
|
|
457
|
+
return;
|
|
458
|
+
}
|
|
459
|
+
if (session.staleNotifiedAt) {
|
|
460
|
+
return;
|
|
461
|
+
}
|
|
462
|
+
const now = /* @__PURE__ */ new Date();
|
|
463
|
+
const staleSinceMs = now.getTime() - this.getLastActivityAtMs(session);
|
|
464
|
+
const errorMessage = `Session stale: no activity for ${staleSinceMs}ms`;
|
|
465
|
+
const contextUsage = this.contextMonitor?.getUsage(session.relaySessionId);
|
|
466
|
+
await this.sessionManager.update(session.relaySessionId, {
|
|
467
|
+
status: "error",
|
|
468
|
+
errorCode: "RELAY_SESSION_STALE",
|
|
469
|
+
errorMessage,
|
|
470
|
+
staleNotifiedAt: now
|
|
471
|
+
});
|
|
472
|
+
this.agentEventStore.record({
|
|
473
|
+
type: "session-stale",
|
|
474
|
+
sessionId: session.relaySessionId,
|
|
475
|
+
parentSessionId: session.parentSessionId ?? void 0,
|
|
476
|
+
backendId: session.backendId,
|
|
477
|
+
metadata: session.metadata,
|
|
478
|
+
data: {
|
|
479
|
+
lastHeartbeatAt: session.lastHeartbeatAt.toISOString(),
|
|
480
|
+
staleSinceMs,
|
|
481
|
+
taskId: session.metadata.taskId,
|
|
482
|
+
label: session.metadata.label,
|
|
483
|
+
agentType: session.metadata.agentType,
|
|
484
|
+
contextUsage: contextUsage ? {
|
|
485
|
+
usagePercent: contextUsage.usagePercent,
|
|
486
|
+
estimatedTokens: contextUsage.estimatedTokens,
|
|
487
|
+
contextWindow: contextUsage.contextWindow
|
|
488
|
+
} : void 0
|
|
489
|
+
}
|
|
490
|
+
});
|
|
491
|
+
if (this.hooksEngine) {
|
|
492
|
+
await this.hooksEngine.emit("on-session-stale", {
|
|
493
|
+
event: "on-session-stale",
|
|
494
|
+
sessionId: session.relaySessionId,
|
|
495
|
+
backendId: session.backendId,
|
|
496
|
+
timestamp: now.toISOString(),
|
|
497
|
+
data: {
|
|
498
|
+
lastHeartbeatAt: session.lastHeartbeatAt.toISOString(),
|
|
499
|
+
staleSinceMs,
|
|
500
|
+
taskId: session.metadata.taskId,
|
|
501
|
+
label: session.metadata.label,
|
|
502
|
+
agentType: session.metadata.agentType,
|
|
503
|
+
contextUsage: contextUsage ? {
|
|
504
|
+
usagePercent: contextUsage.usagePercent,
|
|
505
|
+
estimatedTokens: contextUsage.estimatedTokens
|
|
506
|
+
} : void 0,
|
|
507
|
+
memoryDir: this.config.memoryDir ?? "./memory"
|
|
508
|
+
}
|
|
509
|
+
});
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
async cleanupOldSessions() {
|
|
513
|
+
const { deletedSessionIds } = await this.sessionManager.cleanup(
|
|
514
|
+
this.cleanupAfterMs
|
|
515
|
+
);
|
|
516
|
+
if (this.contextMonitor) {
|
|
517
|
+
for (const sessionId of deletedSessionIds) {
|
|
518
|
+
this.contextMonitor.removeSession(sessionId);
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
async checkHealthLoop() {
|
|
523
|
+
if (this.isChecking) return;
|
|
524
|
+
this.isChecking = true;
|
|
525
|
+
try {
|
|
526
|
+
const health = await this.checkHealth({ includeCompleted: false });
|
|
527
|
+
for (const staleId of health.staleSessions) {
|
|
528
|
+
const session = await this.sessionManager.get(staleId);
|
|
529
|
+
if (!session || !this.isStale(session)) {
|
|
530
|
+
continue;
|
|
531
|
+
}
|
|
532
|
+
await this.handleStaleSession(session);
|
|
533
|
+
}
|
|
534
|
+
await this.cleanupOldSessions();
|
|
535
|
+
const activeHealthyCount = health.sessions.filter(
|
|
536
|
+
(session) => session.status === "active" && !session.isStale
|
|
537
|
+
).length;
|
|
538
|
+
const warnThreshold = Math.ceil(this.config.maxActiveSessions * 0.8);
|
|
539
|
+
if (activeHealthyCount >= warnThreshold) {
|
|
540
|
+
logger.warn(
|
|
541
|
+
`Active session usage high: ${activeHealthyCount}/${this.config.maxActiveSessions}`
|
|
542
|
+
);
|
|
543
|
+
}
|
|
544
|
+
} catch (error) {
|
|
545
|
+
logger.warn(
|
|
546
|
+
`Session health check failed: ${error instanceof Error ? error.message : String(error)}`
|
|
547
|
+
);
|
|
548
|
+
} finally {
|
|
549
|
+
this.isChecking = false;
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
buildHealthStatus(session, now) {
|
|
553
|
+
const lastActivityAtMs = this.getLastActivityAtMs(session);
|
|
554
|
+
const isStale = session.status === "active" && lastActivityAtMs + this.staleThresholdMs < now;
|
|
555
|
+
const issues = [];
|
|
556
|
+
if (isStale) {
|
|
557
|
+
issues.push("stale");
|
|
558
|
+
}
|
|
559
|
+
const usage = this.contextMonitor?.getUsage(session.relaySessionId);
|
|
560
|
+
if (usage && usage.usagePercent >= 95) {
|
|
561
|
+
issues.push("high_context_usage");
|
|
562
|
+
}
|
|
563
|
+
return {
|
|
564
|
+
relaySessionId: session.relaySessionId,
|
|
565
|
+
status: session.status,
|
|
566
|
+
backendId: session.backendId,
|
|
567
|
+
healthy: issues.length === 0,
|
|
568
|
+
issues,
|
|
569
|
+
staleSince: isStale ? new Date(lastActivityAtMs + this.staleThresholdMs).toISOString() : void 0,
|
|
570
|
+
isStale,
|
|
571
|
+
lastHeartbeatAt: session.lastHeartbeatAt.toISOString(),
|
|
572
|
+
metadata: session.metadata,
|
|
573
|
+
contextUsage: usage ? {
|
|
574
|
+
usagePercent: usage.usagePercent,
|
|
575
|
+
estimatedTokens: usage.estimatedTokens,
|
|
576
|
+
contextWindow: usage.contextWindow
|
|
577
|
+
} : void 0
|
|
578
|
+
};
|
|
579
|
+
}
|
|
580
|
+
getLastActivityAtMs(session) {
|
|
581
|
+
return Math.max(
|
|
582
|
+
session.lastHeartbeatAt.getTime(),
|
|
583
|
+
session.updatedAt.getTime()
|
|
584
|
+
);
|
|
585
|
+
}
|
|
586
|
+
};
|
|
587
|
+
}
|
|
588
|
+
});
|
|
589
|
+
|
|
172
590
|
// src/mcp-server/recursion-guard.ts
|
|
173
591
|
import { createHash } from "crypto";
|
|
174
592
|
var RecursionGuard;
|
|
@@ -288,9 +706,9 @@ var init_recursion_guard = __esm({
|
|
|
288
706
|
|
|
289
707
|
// src/mcp-server/tools/spawn-agent.ts
|
|
290
708
|
import { z as z2 } from "zod";
|
|
291
|
-
import { nanoid as
|
|
292
|
-
import { existsSync, readFileSync } from "fs";
|
|
293
|
-
import { join as
|
|
709
|
+
import { nanoid as nanoid3 } from "nanoid";
|
|
710
|
+
import { existsSync as existsSync2, readFileSync as readFileSync2 } from "fs";
|
|
711
|
+
import { join as join7, normalize, resolve, sep } from "path";
|
|
294
712
|
function buildContextInjection(metadata) {
|
|
295
713
|
const parts = [];
|
|
296
714
|
if (metadata.stateContent && typeof metadata.stateContent === "string") {
|
|
@@ -309,9 +727,9 @@ ${formatted}
|
|
|
309
727
|
}
|
|
310
728
|
function readPreviousState(dailynoteDir) {
|
|
311
729
|
try {
|
|
312
|
-
const statePath =
|
|
313
|
-
if (
|
|
314
|
-
return
|
|
730
|
+
const statePath = join7(dailynoteDir, "_state.md");
|
|
731
|
+
if (existsSync2(statePath)) {
|
|
732
|
+
return readFileSync2(statePath, "utf-8");
|
|
315
733
|
}
|
|
316
734
|
return null;
|
|
317
735
|
} catch (error) {
|
|
@@ -336,11 +754,11 @@ function validatePathWithinProject(filePath, projectRoot) {
|
|
|
336
754
|
function readAgentDefinition(definitionPath, projectRoot = process.cwd()) {
|
|
337
755
|
try {
|
|
338
756
|
const safeDefinitionPath = validatePathWithinProject(definitionPath, projectRoot);
|
|
339
|
-
if (!
|
|
757
|
+
if (!existsSync2(safeDefinitionPath)) {
|
|
340
758
|
logger.warn(`Agent definition file not found at ${safeDefinitionPath}`);
|
|
341
759
|
return null;
|
|
342
760
|
}
|
|
343
|
-
const content =
|
|
761
|
+
const content = readFileSync2(safeDefinitionPath, "utf-8");
|
|
344
762
|
if (content.trim().length === 0) {
|
|
345
763
|
logger.warn(`Agent definition file is empty at ${safeDefinitionPath}`);
|
|
346
764
|
return null;
|
|
@@ -356,25 +774,25 @@ function readAgentDefinition(definitionPath, projectRoot = process.cwd()) {
|
|
|
356
774
|
function readSkillContext(skillContext, projectRoot = process.cwd()) {
|
|
357
775
|
try {
|
|
358
776
|
const safeSkillPath = validatePathWithinProject(skillContext.skillPath, projectRoot);
|
|
359
|
-
const skillMdPath = validatePathWithinProject(
|
|
360
|
-
if (!
|
|
777
|
+
const skillMdPath = validatePathWithinProject(join7(safeSkillPath, "SKILL.md"), projectRoot);
|
|
778
|
+
if (!existsSync2(skillMdPath)) {
|
|
361
779
|
logger.warn(
|
|
362
780
|
`SKILL.md not found at ${skillMdPath}`
|
|
363
781
|
);
|
|
364
782
|
return null;
|
|
365
783
|
}
|
|
366
784
|
const parts = [];
|
|
367
|
-
const skillContent =
|
|
785
|
+
const skillContent = readFileSync2(skillMdPath, "utf-8");
|
|
368
786
|
parts.push(skillContent);
|
|
369
787
|
if (skillContext.subskill) {
|
|
370
|
-
const subskillPath = validatePathWithinProject(
|
|
788
|
+
const subskillPath = validatePathWithinProject(join7(
|
|
371
789
|
safeSkillPath,
|
|
372
790
|
"subskills",
|
|
373
791
|
skillContext.subskill,
|
|
374
792
|
"SUBSKILL.md"
|
|
375
793
|
), projectRoot);
|
|
376
|
-
if (
|
|
377
|
-
const subskillContent =
|
|
794
|
+
if (existsSync2(subskillPath)) {
|
|
795
|
+
const subskillContent = readFileSync2(subskillPath, "utf-8");
|
|
378
796
|
parts.push(subskillContent);
|
|
379
797
|
} else {
|
|
380
798
|
logger.warn(
|
|
@@ -393,11 +811,11 @@ function readSkillContext(skillContext, projectRoot = process.cwd()) {
|
|
|
393
811
|
function readProjectMcpJson(cwd) {
|
|
394
812
|
try {
|
|
395
813
|
const dir = cwd ?? process.cwd();
|
|
396
|
-
const mcpJsonPath =
|
|
397
|
-
if (!
|
|
814
|
+
const mcpJsonPath = join7(dir, ".mcp.json");
|
|
815
|
+
if (!existsSync2(mcpJsonPath)) {
|
|
398
816
|
return {};
|
|
399
817
|
}
|
|
400
|
-
const raw =
|
|
818
|
+
const raw = readFileSync2(mcpJsonPath, "utf-8");
|
|
401
819
|
const parsed = JSON.parse(raw);
|
|
402
820
|
const servers = parsed.mcpServers;
|
|
403
821
|
if (!servers || typeof servers !== "object") {
|
|
@@ -438,6 +856,117 @@ function buildChildMcpServers(parentMcpServers, childHttpUrl) {
|
|
|
438
856
|
}
|
|
439
857
|
return result;
|
|
440
858
|
}
|
|
859
|
+
function isPlainObject2(value) {
|
|
860
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
861
|
+
return false;
|
|
862
|
+
}
|
|
863
|
+
const prototype = Object.getPrototypeOf(value);
|
|
864
|
+
return prototype === Object.prototype || prototype === null;
|
|
865
|
+
}
|
|
866
|
+
function utf8Size(value) {
|
|
867
|
+
return new TextEncoder().encode(value).length;
|
|
868
|
+
}
|
|
869
|
+
function validateMetadataKey(key) {
|
|
870
|
+
if (DANGEROUS_METADATA_KEYS.has(key)) {
|
|
871
|
+
throw new Error(`metadata key "${key}" is not allowed`);
|
|
872
|
+
}
|
|
873
|
+
if (key.length > MAX_METADATA_KEY_LENGTH) {
|
|
874
|
+
throw new Error(
|
|
875
|
+
`metadata key "${key}" exceeds ${MAX_METADATA_KEY_LENGTH} chars`
|
|
876
|
+
);
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
function validateMetadataValue(value, path2, depth) {
|
|
880
|
+
if (depth > MAX_METADATA_NESTING_DEPTH) {
|
|
881
|
+
throw new Error(
|
|
882
|
+
`metadata nesting depth exceeds ${MAX_METADATA_NESTING_DEPTH} at "${path2}"`
|
|
883
|
+
);
|
|
884
|
+
}
|
|
885
|
+
if (typeof value === "string") {
|
|
886
|
+
if (value.length > MAX_METADATA_STRING_VALUE_LENGTH) {
|
|
887
|
+
throw new Error(
|
|
888
|
+
`metadata string at "${path2}" exceeds ${MAX_METADATA_STRING_VALUE_LENGTH} chars`
|
|
889
|
+
);
|
|
890
|
+
}
|
|
891
|
+
return;
|
|
892
|
+
}
|
|
893
|
+
if (value === null || value === void 0 || typeof value === "number" || typeof value === "boolean") {
|
|
894
|
+
return;
|
|
895
|
+
}
|
|
896
|
+
if (Array.isArray(value)) {
|
|
897
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
898
|
+
validateMetadataValue(value[index], `${path2}[${index}]`, depth + 1);
|
|
899
|
+
}
|
|
900
|
+
return;
|
|
901
|
+
}
|
|
902
|
+
if (typeof value === "object") {
|
|
903
|
+
if (!isPlainObject2(value)) {
|
|
904
|
+
throw new Error(`metadata value at "${path2}" must be a plain object`);
|
|
905
|
+
}
|
|
906
|
+
for (const [key, nestedValue] of Object.entries(value)) {
|
|
907
|
+
validateMetadataKey(key);
|
|
908
|
+
validateMetadataValue(nestedValue, `${path2}.${key}`, depth + 1);
|
|
909
|
+
}
|
|
910
|
+
return;
|
|
911
|
+
}
|
|
912
|
+
throw new Error(`metadata value at "${path2}" has unsupported type`);
|
|
913
|
+
}
|
|
914
|
+
function validateMetadata(raw) {
|
|
915
|
+
if (raw === void 0 || raw === null) {
|
|
916
|
+
return {};
|
|
917
|
+
}
|
|
918
|
+
if (!isPlainObject2(raw)) {
|
|
919
|
+
throw new Error("metadata must be a plain object");
|
|
920
|
+
}
|
|
921
|
+
let serialized;
|
|
922
|
+
try {
|
|
923
|
+
serialized = JSON.stringify(raw);
|
|
924
|
+
} catch {
|
|
925
|
+
throw new Error("metadata must be JSON-serializable");
|
|
926
|
+
}
|
|
927
|
+
if (utf8Size(serialized) > MAX_METADATA_SIZE_BYTES) {
|
|
928
|
+
throw new Error(`metadata exceeds ${MAX_METADATA_SIZE_BYTES} bytes`);
|
|
929
|
+
}
|
|
930
|
+
const entries = Object.entries(raw);
|
|
931
|
+
if (entries.length > MAX_METADATA_KEY_COUNT) {
|
|
932
|
+
throw new Error(`metadata has ${entries.length} keys, max is ${MAX_METADATA_KEY_COUNT}`);
|
|
933
|
+
}
|
|
934
|
+
for (const [key, value] of entries) {
|
|
935
|
+
validateMetadataKey(key);
|
|
936
|
+
validateMetadataValue(value, key, 1);
|
|
937
|
+
}
|
|
938
|
+
const typed = raw;
|
|
939
|
+
if (typed.taskId !== void 0 && typeof typed.taskId !== "string") {
|
|
940
|
+
throw new Error("metadata.taskId must be a string");
|
|
941
|
+
}
|
|
942
|
+
if (typed.taskId !== void 0 && typeof typed.taskId === "string" && !TASK_ID_PATTERN.test(typed.taskId)) {
|
|
943
|
+
throw new Error("metadata.taskId must match ^(TASK|GOAL)-\\d{3,}$");
|
|
944
|
+
}
|
|
945
|
+
if (typed.agentType !== void 0 && typeof typed.agentType !== "string") {
|
|
946
|
+
throw new Error("metadata.agentType must be a string");
|
|
947
|
+
}
|
|
948
|
+
if (typed.label !== void 0 && typeof typed.label !== "string") {
|
|
949
|
+
throw new Error("metadata.label must be a string");
|
|
950
|
+
}
|
|
951
|
+
if (typed.tags !== void 0) {
|
|
952
|
+
if (!Array.isArray(typed.tags) || !typed.tags.every((tag) => typeof tag === "string")) {
|
|
953
|
+
throw new Error("metadata.tags must be string[]");
|
|
954
|
+
}
|
|
955
|
+
}
|
|
956
|
+
return typed;
|
|
957
|
+
}
|
|
958
|
+
function getLastActivityAtMs(session) {
|
|
959
|
+
return Math.max(
|
|
960
|
+
session.lastHeartbeatAt.getTime(),
|
|
961
|
+
session.updatedAt.getTime()
|
|
962
|
+
);
|
|
963
|
+
}
|
|
964
|
+
function isActiveSessionStale(session, staleThresholdMs, now) {
|
|
965
|
+
if (session.status !== "active") {
|
|
966
|
+
return false;
|
|
967
|
+
}
|
|
968
|
+
return getLastActivityAtMs(session) + staleThresholdMs < now;
|
|
969
|
+
}
|
|
441
970
|
function inferFailureReason(stderr, stdout, sdkErrorMetadata) {
|
|
442
971
|
if (sdkErrorMetadata) {
|
|
443
972
|
if (sdkErrorMetadata.subtype === "error_max_turns") return "max_turns_exhausted";
|
|
@@ -449,13 +978,46 @@ function inferFailureReason(stderr, stdout, sdkErrorMetadata) {
|
|
|
449
978
|
if (combined.includes("429") || combined.includes("capacity_exhausted") || combined.includes("model_capacity_exhausted") || combined.includes("ratelimitexceeded") || combined.includes("resource_exhausted")) return "rate_limit";
|
|
450
979
|
return "adapter_error";
|
|
451
980
|
}
|
|
981
|
+
function failureReasonToErrorCode(reason) {
|
|
982
|
+
switch (reason) {
|
|
983
|
+
case "recursion_blocked":
|
|
984
|
+
return "RELAY_RECURSION_BLOCKED";
|
|
985
|
+
case "metadata_validation":
|
|
986
|
+
return "RELAY_METADATA_VALIDATION";
|
|
987
|
+
case "max_sessions_exceeded":
|
|
988
|
+
return "RELAY_MAX_SESSIONS_EXCEEDED";
|
|
989
|
+
case "concurrent_limit_race":
|
|
990
|
+
return "RELAY_CONCURRENT_LIMIT_RACE";
|
|
991
|
+
case "backend_unavailable":
|
|
992
|
+
return "RELAY_BACKEND_UNAVAILABLE";
|
|
993
|
+
case "instruction_file_error":
|
|
994
|
+
return "RELAY_INSTRUCTION_FILE_ERROR";
|
|
995
|
+
case "session_continuation_unsupported":
|
|
996
|
+
return "RELAY_SESSION_CONTINUATION_UNSUPPORTED";
|
|
997
|
+
case "session_not_found":
|
|
998
|
+
return "RELAY_SESSION_NOT_FOUND";
|
|
999
|
+
case "timeout":
|
|
1000
|
+
return "RELAY_TIMEOUT";
|
|
1001
|
+
case "max_turns_exhausted":
|
|
1002
|
+
return "RELAY_MAX_TURNS_EXHAUSTED";
|
|
1003
|
+
case "rate_limit":
|
|
1004
|
+
return "RELAY_RATE_LIMIT";
|
|
1005
|
+
case "adapter_error":
|
|
1006
|
+
return "RELAY_ADAPTER_ERROR";
|
|
1007
|
+
case "unknown":
|
|
1008
|
+
return "RELAY_UNKNOWN_ERROR";
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
function isShortCircuitSpawnResult(value) {
|
|
1012
|
+
return "_noSession" in value;
|
|
1013
|
+
}
|
|
452
1014
|
function buildContextFromEnv() {
|
|
453
|
-
const traceId = process.env["RELAY_TRACE_ID"] ?? `trace-${
|
|
1015
|
+
const traceId = process.env["RELAY_TRACE_ID"] ?? `trace-${nanoid3()}`;
|
|
454
1016
|
const parentSessionId = process.env["RELAY_PARENT_SESSION_ID"] ?? null;
|
|
455
1017
|
const depth = Number(process.env["RELAY_DEPTH"] ?? "0");
|
|
456
1018
|
return { traceId, parentSessionId, depth };
|
|
457
1019
|
}
|
|
458
|
-
async function executeSpawnAgent(input, registry2, sessionManager2, guard, hooksEngine2, contextMonitor2, backendSelector, childHttpUrl, onProgress) {
|
|
1020
|
+
async function executeSpawnAgent(input, registry2, sessionManager2, guard, hooksEngine2, contextMonitor2, backendSelector, childHttpUrl, onProgress, agentEventStore, hookMemoryDir = "./memory", sessionHealthConfig) {
|
|
459
1021
|
onProgress?.({ stage: "initializing", percent: 0 });
|
|
460
1022
|
let effectiveBackend;
|
|
461
1023
|
let selectionReason;
|
|
@@ -509,16 +1071,120 @@ async function executeSpawnAgent(input, registry2, sessionManager2, guard, hooks
|
|
|
509
1071
|
};
|
|
510
1072
|
}
|
|
511
1073
|
const spawnStartedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
512
|
-
|
|
1074
|
+
let validatedMetadata;
|
|
1075
|
+
try {
|
|
1076
|
+
validatedMetadata = validateMetadata(input.metadata);
|
|
1077
|
+
const mergedMetadata = { ...validatedMetadata };
|
|
1078
|
+
if (mergedMetadata.agentType === void 0 && input.agent !== void 0) {
|
|
1079
|
+
mergedMetadata.agentType = input.agent;
|
|
1080
|
+
}
|
|
1081
|
+
if (mergedMetadata.label === void 0 && input.label !== void 0) {
|
|
1082
|
+
mergedMetadata.label = input.label;
|
|
1083
|
+
}
|
|
1084
|
+
validatedMetadata = validateMetadata(mergedMetadata);
|
|
1085
|
+
} catch (error) {
|
|
1086
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1087
|
+
return {
|
|
1088
|
+
sessionId: "",
|
|
1089
|
+
exitCode: 1,
|
|
1090
|
+
stdout: "",
|
|
1091
|
+
stderr: `RELAY_METADATA_VALIDATION: ${message}`,
|
|
1092
|
+
failureReason: "metadata_validation"
|
|
1093
|
+
};
|
|
1094
|
+
}
|
|
1095
|
+
const maxActiveSessions = Math.max(
|
|
1096
|
+
1,
|
|
1097
|
+
sessionHealthConfig?.maxActiveSessions ?? DEFAULT_MAX_ACTIVE_SESSIONS
|
|
1098
|
+
);
|
|
1099
|
+
const staleThresholdMs = Math.max(
|
|
1100
|
+
1,
|
|
1101
|
+
sessionHealthConfig?.staleThresholdMs ?? DEFAULT_STALE_THRESHOLD_MS
|
|
1102
|
+
);
|
|
1103
|
+
const warnThreshold = Math.ceil(maxActiveSessions * 0.8);
|
|
1104
|
+
const maxCreateAttempts = 3;
|
|
1105
|
+
let session = null;
|
|
1106
|
+
let lastCreateError = "";
|
|
1107
|
+
for (let attempt = 0; attempt < maxCreateAttempts; attempt += 1) {
|
|
1108
|
+
const activeSessions = await sessionManager2.list({ status: "active" });
|
|
1109
|
+
const now = Date.now();
|
|
1110
|
+
const activeHealthyCount = activeSessions.filter(
|
|
1111
|
+
(activeSession) => !isActiveSessionStale(activeSession, staleThresholdMs, now)
|
|
1112
|
+
).length;
|
|
1113
|
+
if (activeHealthyCount >= maxActiveSessions) {
|
|
1114
|
+
return {
|
|
1115
|
+
sessionId: "",
|
|
1116
|
+
exitCode: 1,
|
|
1117
|
+
stdout: "",
|
|
1118
|
+
stderr: `RELAY_MAX_SESSIONS_EXCEEDED: active sessions ${activeHealthyCount}/${maxActiveSessions}`,
|
|
1119
|
+
failureReason: "max_sessions_exceeded"
|
|
1120
|
+
};
|
|
1121
|
+
}
|
|
1122
|
+
if (activeHealthyCount >= warnThreshold) {
|
|
1123
|
+
logger.warn(
|
|
1124
|
+
`Active session usage high during spawn: ${activeHealthyCount}/${maxActiveSessions}`
|
|
1125
|
+
);
|
|
1126
|
+
}
|
|
1127
|
+
try {
|
|
1128
|
+
session = await sessionManager2.create({
|
|
1129
|
+
backendId: effectiveBackend,
|
|
1130
|
+
parentSessionId: envContext.parentSessionId ?? void 0,
|
|
1131
|
+
depth: envContext.depth + 1,
|
|
1132
|
+
metadata: validatedMetadata,
|
|
1133
|
+
expectedActiveCount: activeHealthyCount,
|
|
1134
|
+
expectedActiveStaleThresholdMs: staleThresholdMs
|
|
1135
|
+
});
|
|
1136
|
+
break;
|
|
1137
|
+
} catch (error) {
|
|
1138
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1139
|
+
lastCreateError = message;
|
|
1140
|
+
if (message.includes("RELAY_CONCURRENT_LIMIT_RACE") && attempt < maxCreateAttempts - 1) {
|
|
1141
|
+
continue;
|
|
1142
|
+
}
|
|
1143
|
+
if (message.includes("RELAY_CONCURRENT_LIMIT_RACE")) {
|
|
1144
|
+
return {
|
|
1145
|
+
sessionId: "",
|
|
1146
|
+
exitCode: 1,
|
|
1147
|
+
stdout: "",
|
|
1148
|
+
stderr: message,
|
|
1149
|
+
failureReason: "concurrent_limit_race"
|
|
1150
|
+
};
|
|
1151
|
+
}
|
|
1152
|
+
return {
|
|
1153
|
+
sessionId: "",
|
|
1154
|
+
exitCode: 1,
|
|
1155
|
+
stdout: "",
|
|
1156
|
+
stderr: message,
|
|
1157
|
+
failureReason: "unknown"
|
|
1158
|
+
};
|
|
1159
|
+
}
|
|
1160
|
+
}
|
|
1161
|
+
if (!session) {
|
|
1162
|
+
return {
|
|
1163
|
+
sessionId: "",
|
|
1164
|
+
exitCode: 1,
|
|
1165
|
+
stdout: "",
|
|
1166
|
+
stderr: lastCreateError || "RELAY_CONCURRENT_LIMIT_RACE: failed to create session",
|
|
1167
|
+
failureReason: "concurrent_limit_race"
|
|
1168
|
+
};
|
|
1169
|
+
}
|
|
1170
|
+
agentEventStore?.record({
|
|
1171
|
+
type: "session-start",
|
|
1172
|
+
sessionId: session.relaySessionId,
|
|
1173
|
+
parentSessionId: session.parentSessionId ?? void 0,
|
|
513
1174
|
backendId: effectiveBackend,
|
|
514
|
-
|
|
515
|
-
|
|
1175
|
+
metadata: session.metadata,
|
|
1176
|
+
data: {
|
|
1177
|
+
taskId: session.metadata.taskId,
|
|
1178
|
+
label: session.metadata.label,
|
|
1179
|
+
agentType: session.metadata.agentType,
|
|
1180
|
+
selectedBackend: effectiveBackend
|
|
1181
|
+
}
|
|
516
1182
|
});
|
|
517
1183
|
let collectedMetadata = {};
|
|
518
1184
|
if (hooksEngine2 && !input.resumeSessionId) {
|
|
519
1185
|
try {
|
|
520
1186
|
const cwd = process.cwd();
|
|
521
|
-
const dailynoteDir =
|
|
1187
|
+
const dailynoteDir = join7(cwd, "daily_note");
|
|
522
1188
|
const hookInput = {
|
|
523
1189
|
schemaVersion: "1.0",
|
|
524
1190
|
event: "session-init",
|
|
@@ -528,7 +1194,7 @@ async function executeSpawnAgent(input, registry2, sessionManager2, guard, hooks
|
|
|
528
1194
|
data: {
|
|
529
1195
|
workingDirectory: cwd,
|
|
530
1196
|
dailynoteDir,
|
|
531
|
-
isFirstSession: !
|
|
1197
|
+
isFirstSession: !existsSync2(join7(dailynoteDir, "_state.md")),
|
|
532
1198
|
previousState: readPreviousState(dailynoteDir)
|
|
533
1199
|
}
|
|
534
1200
|
};
|
|
@@ -592,7 +1258,7 @@ ${wrapped}` : wrapped;
|
|
|
592
1258
|
try {
|
|
593
1259
|
const projectRoot = process.cwd();
|
|
594
1260
|
const safePath = validatePathWithinProject(input.taskInstructionPath, projectRoot);
|
|
595
|
-
if (!
|
|
1261
|
+
if (!existsSync2(safePath)) {
|
|
596
1262
|
return {
|
|
597
1263
|
sessionId: "",
|
|
598
1264
|
exitCode: 1,
|
|
@@ -601,7 +1267,7 @@ ${wrapped}` : wrapped;
|
|
|
601
1267
|
failureReason: "instruction_file_error"
|
|
602
1268
|
};
|
|
603
1269
|
}
|
|
604
|
-
const instructionContent =
|
|
1270
|
+
const instructionContent = readFileSync2(safePath, "utf-8");
|
|
605
1271
|
effectivePrompt = `${instructionContent}
|
|
606
1272
|
|
|
607
1273
|
${input.prompt}`;
|
|
@@ -617,6 +1283,14 @@ ${input.prompt}`;
|
|
|
617
1283
|
}
|
|
618
1284
|
}
|
|
619
1285
|
onProgress?.({ stage: "spawning", percent: 10 });
|
|
1286
|
+
const heartbeatTimer = setInterval(() => {
|
|
1287
|
+
void sessionManager2.updateHeartbeat(session.relaySessionId).catch((heartbeatError) => {
|
|
1288
|
+
logger.warn(
|
|
1289
|
+
`Internal heartbeat update failed: ${heartbeatError instanceof Error ? heartbeatError.message : String(heartbeatError)}`
|
|
1290
|
+
);
|
|
1291
|
+
});
|
|
1292
|
+
}, 3e4);
|
|
1293
|
+
heartbeatTimer.unref();
|
|
620
1294
|
try {
|
|
621
1295
|
const executePromise = (async () => {
|
|
622
1296
|
if (input.resumeSessionId) {
|
|
@@ -678,17 +1352,30 @@ ${input.prompt}`;
|
|
|
678
1352
|
});
|
|
679
1353
|
}
|
|
680
1354
|
})();
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
stdout: result.stdout,
|
|
687
|
-
stderr: result.stderr,
|
|
688
|
-
..."_failureReason" in result ? { failureReason: result._failureReason } : {}
|
|
689
|
-
};
|
|
1355
|
+
let rawResult;
|
|
1356
|
+
try {
|
|
1357
|
+
rawResult = await executePromise;
|
|
1358
|
+
} finally {
|
|
1359
|
+
clearInterval(heartbeatTimer);
|
|
690
1360
|
}
|
|
691
1361
|
onProgress?.({ stage: "executing", percent: 50 });
|
|
1362
|
+
let isShortCircuit = false;
|
|
1363
|
+
let failureReasonFromShortCircuit;
|
|
1364
|
+
let result;
|
|
1365
|
+
if (isShortCircuitSpawnResult(rawResult)) {
|
|
1366
|
+
isShortCircuit = true;
|
|
1367
|
+
failureReasonFromShortCircuit = rawResult._failureReason;
|
|
1368
|
+
result = {
|
|
1369
|
+
exitCode: rawResult.exitCode,
|
|
1370
|
+
stdout: rawResult.stdout,
|
|
1371
|
+
stderr: rawResult.stderr,
|
|
1372
|
+
nativeSessionId: void 0,
|
|
1373
|
+
tokenUsage: void 0,
|
|
1374
|
+
sdkErrorMetadata: void 0
|
|
1375
|
+
};
|
|
1376
|
+
} else {
|
|
1377
|
+
result = rawResult;
|
|
1378
|
+
}
|
|
692
1379
|
if (contextMonitor2) {
|
|
693
1380
|
const estimatedTokens = Math.ceil(
|
|
694
1381
|
(result.stdout.length + result.stderr.length) / 4
|
|
@@ -696,16 +1383,28 @@ ${input.prompt}`;
|
|
|
696
1383
|
contextMonitor2.updateUsage(
|
|
697
1384
|
session.relaySessionId,
|
|
698
1385
|
effectiveBackend,
|
|
699
|
-
estimatedTokens
|
|
1386
|
+
estimatedTokens,
|
|
1387
|
+
session.metadata
|
|
700
1388
|
);
|
|
1389
|
+
await sessionManager2.updateHeartbeat(session.relaySessionId).catch(() => void 0);
|
|
1390
|
+
}
|
|
1391
|
+
if (!isShortCircuit) {
|
|
1392
|
+
guard.recordSpawn(context);
|
|
701
1393
|
}
|
|
702
|
-
guard.recordSpawn(context);
|
|
703
1394
|
const status = result.exitCode === 0 ? "completed" : "error";
|
|
704
|
-
const failureReason = result.exitCode !== 0 ? inferFailureReason(result.stderr, result.stdout, result.sdkErrorMetadata) : void 0;
|
|
1395
|
+
const failureReason = result.exitCode !== 0 ? failureReasonFromShortCircuit ?? inferFailureReason(result.stderr, result.stdout, result.sdkErrorMetadata) : void 0;
|
|
705
1396
|
await sessionManager2.update(session.relaySessionId, {
|
|
706
1397
|
status,
|
|
707
|
-
...result.nativeSessionId ? { nativeSessionId: result.nativeSessionId } : {}
|
|
1398
|
+
...result.nativeSessionId ? { nativeSessionId: result.nativeSessionId } : {},
|
|
1399
|
+
...status === "error" ? {
|
|
1400
|
+
errorMessage: result.stderr.slice(0, 500),
|
|
1401
|
+
errorCode: failureReason ? failureReasonToErrorCode(failureReason) : "RELAY_UNKNOWN_ERROR"
|
|
1402
|
+
} : {
|
|
1403
|
+
errorMessage: void 0,
|
|
1404
|
+
errorCode: void 0
|
|
1405
|
+
}
|
|
708
1406
|
});
|
|
1407
|
+
contextMonitor2?.removeSession(session.relaySessionId);
|
|
709
1408
|
const completedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
710
1409
|
const metadata = {
|
|
711
1410
|
durationMs: new Date(completedAt).getTime() - new Date(spawnStartedAt).getTime(),
|
|
@@ -716,6 +1415,103 @@ ${input.prompt}`;
|
|
|
716
1415
|
completedAt,
|
|
717
1416
|
...result.tokenUsage ? { tokenUsage: result.tokenUsage } : {}
|
|
718
1417
|
};
|
|
1418
|
+
if (status === "completed") {
|
|
1419
|
+
agentEventStore?.record({
|
|
1420
|
+
type: "session-complete",
|
|
1421
|
+
sessionId: session.relaySessionId,
|
|
1422
|
+
parentSessionId: session.parentSessionId ?? void 0,
|
|
1423
|
+
backendId: effectiveBackend,
|
|
1424
|
+
metadata: session.metadata,
|
|
1425
|
+
data: {
|
|
1426
|
+
exitCode: result.exitCode,
|
|
1427
|
+
durationMs: metadata.durationMs,
|
|
1428
|
+
taskId: session.metadata.taskId,
|
|
1429
|
+
label: session.metadata.label,
|
|
1430
|
+
agentType: session.metadata.agentType,
|
|
1431
|
+
nativeSessionId: result.nativeSessionId,
|
|
1432
|
+
selectedBackend: effectiveBackend,
|
|
1433
|
+
tokenUsage: result.tokenUsage
|
|
1434
|
+
}
|
|
1435
|
+
});
|
|
1436
|
+
if (hooksEngine2) {
|
|
1437
|
+
try {
|
|
1438
|
+
await hooksEngine2.emit("on-session-complete", {
|
|
1439
|
+
event: "on-session-complete",
|
|
1440
|
+
sessionId: session.relaySessionId,
|
|
1441
|
+
backendId: effectiveBackend,
|
|
1442
|
+
timestamp: completedAt,
|
|
1443
|
+
data: {
|
|
1444
|
+
exitCode: result.exitCode,
|
|
1445
|
+
durationMs: metadata.durationMs,
|
|
1446
|
+
taskId: session.metadata.taskId,
|
|
1447
|
+
label: session.metadata.label,
|
|
1448
|
+
agentType: session.metadata.agentType,
|
|
1449
|
+
nativeSessionId: result.nativeSessionId,
|
|
1450
|
+
selectedBackend: effectiveBackend,
|
|
1451
|
+
tokenUsage: result.tokenUsage,
|
|
1452
|
+
memoryDir: hookMemoryDir
|
|
1453
|
+
}
|
|
1454
|
+
});
|
|
1455
|
+
} catch (hookError) {
|
|
1456
|
+
logger.debug(
|
|
1457
|
+
`on-session-complete hook error: ${hookError instanceof Error ? hookError.message : String(hookError)}`
|
|
1458
|
+
);
|
|
1459
|
+
}
|
|
1460
|
+
}
|
|
1461
|
+
} else if (failureReason) {
|
|
1462
|
+
const retryableReasons = [
|
|
1463
|
+
"timeout",
|
|
1464
|
+
"rate_limit",
|
|
1465
|
+
"adapter_error",
|
|
1466
|
+
"unknown"
|
|
1467
|
+
];
|
|
1468
|
+
const isRetryable = retryableReasons.includes(failureReason);
|
|
1469
|
+
const errorMessage = result.stderr.slice(0, 500);
|
|
1470
|
+
agentEventStore?.record({
|
|
1471
|
+
type: "session-error",
|
|
1472
|
+
sessionId: session.relaySessionId,
|
|
1473
|
+
parentSessionId: session.parentSessionId ?? void 0,
|
|
1474
|
+
backendId: effectiveBackend,
|
|
1475
|
+
metadata: session.metadata,
|
|
1476
|
+
data: {
|
|
1477
|
+
exitCode: result.exitCode,
|
|
1478
|
+
failureReason,
|
|
1479
|
+
errorMessage,
|
|
1480
|
+
durationMs: metadata.durationMs,
|
|
1481
|
+
taskId: session.metadata.taskId,
|
|
1482
|
+
label: session.metadata.label,
|
|
1483
|
+
agentType: session.metadata.agentType,
|
|
1484
|
+
selectedBackend: effectiveBackend,
|
|
1485
|
+
isRetryable
|
|
1486
|
+
}
|
|
1487
|
+
});
|
|
1488
|
+
if (hooksEngine2) {
|
|
1489
|
+
try {
|
|
1490
|
+
await hooksEngine2.emit("on-session-error", {
|
|
1491
|
+
event: "on-session-error",
|
|
1492
|
+
sessionId: session.relaySessionId,
|
|
1493
|
+
backendId: effectiveBackend,
|
|
1494
|
+
timestamp: completedAt,
|
|
1495
|
+
data: {
|
|
1496
|
+
exitCode: result.exitCode,
|
|
1497
|
+
failureReason,
|
|
1498
|
+
errorMessage,
|
|
1499
|
+
durationMs: metadata.durationMs,
|
|
1500
|
+
taskId: session.metadata.taskId,
|
|
1501
|
+
label: session.metadata.label,
|
|
1502
|
+
agentType: session.metadata.agentType,
|
|
1503
|
+
selectedBackend: effectiveBackend,
|
|
1504
|
+
isRetryable,
|
|
1505
|
+
memoryDir: hookMemoryDir
|
|
1506
|
+
}
|
|
1507
|
+
});
|
|
1508
|
+
} catch (hookError) {
|
|
1509
|
+
logger.debug(
|
|
1510
|
+
`on-session-error hook error: ${hookError instanceof Error ? hookError.message : String(hookError)}`
|
|
1511
|
+
);
|
|
1512
|
+
}
|
|
1513
|
+
}
|
|
1514
|
+
}
|
|
719
1515
|
onProgress?.({ stage: "completed", percent: 100 });
|
|
720
1516
|
if (hooksEngine2) {
|
|
721
1517
|
try {
|
|
@@ -758,9 +1554,69 @@ ${input.prompt}`;
|
|
|
758
1554
|
...failureReason ? { failureReason } : {}
|
|
759
1555
|
};
|
|
760
1556
|
} catch (error) {
|
|
761
|
-
|
|
1557
|
+
clearInterval(heartbeatTimer);
|
|
762
1558
|
const message = error instanceof Error ? error.message : String(error);
|
|
763
1559
|
const catchFailureReason = message.toLowerCase().includes("timed out") || message.toLowerCase().includes("timeout") ? "timeout" : "unknown";
|
|
1560
|
+
await sessionManager2.update(session.relaySessionId, {
|
|
1561
|
+
status: "error",
|
|
1562
|
+
errorMessage: message.slice(0, 500),
|
|
1563
|
+
errorCode: failureReasonToErrorCode(catchFailureReason)
|
|
1564
|
+
});
|
|
1565
|
+
contextMonitor2?.removeSession(session.relaySessionId);
|
|
1566
|
+
const completedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1567
|
+
const durationMs = new Date(completedAt).getTime() - new Date(spawnStartedAt).getTime();
|
|
1568
|
+
const errorMessage = message.slice(0, 500);
|
|
1569
|
+
const retryableReasons = [
|
|
1570
|
+
"timeout",
|
|
1571
|
+
"rate_limit",
|
|
1572
|
+
"adapter_error",
|
|
1573
|
+
"unknown"
|
|
1574
|
+
];
|
|
1575
|
+
const isRetryable = retryableReasons.includes(catchFailureReason);
|
|
1576
|
+
agentEventStore?.record({
|
|
1577
|
+
type: "session-error",
|
|
1578
|
+
sessionId: session.relaySessionId,
|
|
1579
|
+
parentSessionId: session.parentSessionId ?? void 0,
|
|
1580
|
+
backendId: effectiveBackend,
|
|
1581
|
+
metadata: session.metadata,
|
|
1582
|
+
data: {
|
|
1583
|
+
exitCode: 1,
|
|
1584
|
+
failureReason: catchFailureReason,
|
|
1585
|
+
errorMessage,
|
|
1586
|
+
durationMs,
|
|
1587
|
+
taskId: session.metadata.taskId,
|
|
1588
|
+
label: session.metadata.label,
|
|
1589
|
+
agentType: session.metadata.agentType,
|
|
1590
|
+
selectedBackend: effectiveBackend,
|
|
1591
|
+
isRetryable
|
|
1592
|
+
}
|
|
1593
|
+
});
|
|
1594
|
+
if (hooksEngine2) {
|
|
1595
|
+
try {
|
|
1596
|
+
await hooksEngine2.emit("on-session-error", {
|
|
1597
|
+
event: "on-session-error",
|
|
1598
|
+
sessionId: session.relaySessionId,
|
|
1599
|
+
backendId: effectiveBackend,
|
|
1600
|
+
timestamp: completedAt,
|
|
1601
|
+
data: {
|
|
1602
|
+
exitCode: 1,
|
|
1603
|
+
failureReason: catchFailureReason,
|
|
1604
|
+
errorMessage,
|
|
1605
|
+
durationMs,
|
|
1606
|
+
taskId: session.metadata.taskId,
|
|
1607
|
+
label: session.metadata.label,
|
|
1608
|
+
agentType: session.metadata.agentType,
|
|
1609
|
+
selectedBackend: effectiveBackend,
|
|
1610
|
+
isRetryable,
|
|
1611
|
+
memoryDir: hookMemoryDir
|
|
1612
|
+
}
|
|
1613
|
+
});
|
|
1614
|
+
} catch (hookError) {
|
|
1615
|
+
logger.debug(
|
|
1616
|
+
`on-session-error hook error: ${hookError instanceof Error ? hookError.message : String(hookError)}`
|
|
1617
|
+
);
|
|
1618
|
+
}
|
|
1619
|
+
}
|
|
764
1620
|
return {
|
|
765
1621
|
sessionId: session.relaySessionId,
|
|
766
1622
|
exitCode: 1,
|
|
@@ -770,12 +1626,21 @@ ${input.prompt}`;
|
|
|
770
1626
|
};
|
|
771
1627
|
}
|
|
772
1628
|
}
|
|
773
|
-
var spawnAgentInputSchema;
|
|
1629
|
+
var DANGEROUS_METADATA_KEYS, TASK_ID_PATTERN, MAX_METADATA_SIZE_BYTES, MAX_METADATA_KEY_COUNT, MAX_METADATA_KEY_LENGTH, MAX_METADATA_STRING_VALUE_LENGTH, MAX_METADATA_NESTING_DEPTH, DEFAULT_MAX_ACTIVE_SESSIONS, DEFAULT_STALE_THRESHOLD_MS, spawnAgentInputSchema;
|
|
774
1630
|
var init_spawn_agent = __esm({
|
|
775
1631
|
"src/mcp-server/tools/spawn-agent.ts"() {
|
|
776
1632
|
"use strict";
|
|
777
1633
|
init_recursion_guard();
|
|
778
1634
|
init_logger();
|
|
1635
|
+
DANGEROUS_METADATA_KEYS = /* @__PURE__ */ new Set(["__proto__", "constructor", "prototype"]);
|
|
1636
|
+
TASK_ID_PATTERN = /^(TASK|GOAL)-\d{3,}$/;
|
|
1637
|
+
MAX_METADATA_SIZE_BYTES = 8 * 1024;
|
|
1638
|
+
MAX_METADATA_KEY_COUNT = 20;
|
|
1639
|
+
MAX_METADATA_KEY_LENGTH = 64;
|
|
1640
|
+
MAX_METADATA_STRING_VALUE_LENGTH = 1024;
|
|
1641
|
+
MAX_METADATA_NESTING_DEPTH = 3;
|
|
1642
|
+
DEFAULT_MAX_ACTIVE_SESSIONS = 20;
|
|
1643
|
+
DEFAULT_STALE_THRESHOLD_MS = 3e5;
|
|
779
1644
|
spawnAgentInputSchema = z2.object({
|
|
780
1645
|
fallbackBackend: z2.enum(["claude", "codex", "gemini"]).optional().describe(
|
|
781
1646
|
"Optional fallback backend. Used only when BackendSelector is not active or cannot determine a backend. When BackendSelector is active, backend is auto-selected by priority: preferredBackend > agentType mapping > taskType mapping > default (claude)."
|
|
@@ -811,7 +1676,14 @@ var init_spawn_agent = __esm({
|
|
|
811
1676
|
taskInstructionPath: z2.string().optional().describe(
|
|
812
1677
|
"Path to a file containing task instructions. Content is prepended to the prompt. Path is resolved relative to the project root and validated against path traversal."
|
|
813
1678
|
),
|
|
814
|
-
label: z2.string().optional().describe("Human-readable label for identifying this agent in parallel results and logs.")
|
|
1679
|
+
label: z2.string().optional().describe("Human-readable label for identifying this agent in parallel results and logs."),
|
|
1680
|
+
metadata: z2.object({
|
|
1681
|
+
taskId: z2.string().regex(TASK_ID_PATTERN).optional(),
|
|
1682
|
+
agentType: z2.string().optional(),
|
|
1683
|
+
label: z2.string().optional(),
|
|
1684
|
+
parentTaskId: z2.string().optional(),
|
|
1685
|
+
tags: z2.array(z2.string()).optional()
|
|
1686
|
+
}).catchall(z2.unknown()).optional().describe("Session metadata for task linkage and orchestration context.")
|
|
815
1687
|
});
|
|
816
1688
|
}
|
|
817
1689
|
});
|
|
@@ -908,7 +1780,7 @@ var init_conflict_detector = __esm({
|
|
|
908
1780
|
});
|
|
909
1781
|
|
|
910
1782
|
// src/mcp-server/tools/spawn-agents-parallel.ts
|
|
911
|
-
async function executeSpawnAgentsParallel(agents, registry2, sessionManager2, guard, hooksEngine2, contextMonitor2, backendSelector, childHttpUrl, onProgress) {
|
|
1783
|
+
async function executeSpawnAgentsParallel(agents, registry2, sessionManager2, guard, hooksEngine2, contextMonitor2, backendSelector, childHttpUrl, onProgress, agentEventStore, hookMemoryDir = "./memory", sessionHealthConfig) {
|
|
912
1784
|
const envContext = buildContextFromEnv();
|
|
913
1785
|
if (envContext.depth >= guard.getConfig().maxDepth) {
|
|
914
1786
|
const reason = `Max depth exceeded: ${envContext.depth} >= ${guard.getConfig().maxDepth}`;
|
|
@@ -964,7 +1836,11 @@ async function executeSpawnAgentsParallel(agents, registry2, sessionManager2, gu
|
|
|
964
1836
|
hooksEngine2,
|
|
965
1837
|
contextMonitor2,
|
|
966
1838
|
backendSelector,
|
|
967
|
-
childHttpUrl
|
|
1839
|
+
childHttpUrl,
|
|
1840
|
+
void 0,
|
|
1841
|
+
agentEventStore,
|
|
1842
|
+
hookMemoryDir,
|
|
1843
|
+
sessionHealthConfig
|
|
968
1844
|
).then((result) => {
|
|
969
1845
|
completedCount++;
|
|
970
1846
|
onProgress?.({
|
|
@@ -1034,17 +1910,34 @@ var init_spawn_agents_parallel = __esm({
|
|
|
1034
1910
|
|
|
1035
1911
|
// src/mcp-server/tools/list-sessions.ts
|
|
1036
1912
|
import { z as z3 } from "zod";
|
|
1037
|
-
async function executeListSessions(input, sessionManager2) {
|
|
1913
|
+
async function executeListSessions(input, sessionManager2, options) {
|
|
1914
|
+
const normalizedBackendId = input.backendId ?? input.backend;
|
|
1915
|
+
if (input.backendId && input.backend && input.backendId !== input.backend) {
|
|
1916
|
+
logger.warn(
|
|
1917
|
+
`list_sessions: both backendId and backend were provided; backendId="${input.backendId}" is used`
|
|
1918
|
+
);
|
|
1919
|
+
}
|
|
1920
|
+
const staleThresholdMs = (options?.staleThresholdSec ?? 300) * 1e3;
|
|
1038
1921
|
const sessions = await sessionManager2.list({
|
|
1039
|
-
backendId:
|
|
1040
|
-
limit: input.limit
|
|
1922
|
+
backendId: normalizedBackendId,
|
|
1923
|
+
limit: input.limit,
|
|
1924
|
+
status: input.staleOnly ? "active" : input.status,
|
|
1925
|
+
taskId: input.taskId,
|
|
1926
|
+
label: input.label,
|
|
1927
|
+
tags: input.tags,
|
|
1928
|
+
staleOnly: input.staleOnly ?? false,
|
|
1929
|
+
staleThresholdMs
|
|
1041
1930
|
});
|
|
1042
1931
|
return {
|
|
1043
1932
|
sessions: sessions.map((s) => ({
|
|
1044
1933
|
relaySessionId: s.relaySessionId,
|
|
1045
1934
|
backendId: s.backendId,
|
|
1046
1935
|
status: s.status,
|
|
1047
|
-
createdAt: s.createdAt.toISOString()
|
|
1936
|
+
createdAt: s.createdAt.toISOString(),
|
|
1937
|
+
updatedAt: s.updatedAt.toISOString(),
|
|
1938
|
+
lastHeartbeatAt: s.lastHeartbeatAt.toISOString(),
|
|
1939
|
+
isStale: s.status === "active" && s.lastHeartbeatAt.getTime() + staleThresholdMs < Date.now(),
|
|
1940
|
+
metadata: s.metadata
|
|
1048
1941
|
}))
|
|
1049
1942
|
};
|
|
1050
1943
|
}
|
|
@@ -1052,15 +1945,81 @@ var listSessionsInputSchema;
|
|
|
1052
1945
|
var init_list_sessions = __esm({
|
|
1053
1946
|
"src/mcp-server/tools/list-sessions.ts"() {
|
|
1054
1947
|
"use strict";
|
|
1948
|
+
init_logger();
|
|
1055
1949
|
listSessionsInputSchema = z3.object({
|
|
1950
|
+
backendId: z3.enum(["claude", "codex", "gemini"]).optional(),
|
|
1056
1951
|
backend: z3.enum(["claude", "codex", "gemini"]).optional(),
|
|
1057
|
-
limit: z3.number().optional().default(10)
|
|
1952
|
+
limit: z3.number().int().min(1).max(100).optional().default(10),
|
|
1953
|
+
status: z3.enum(["active", "completed", "error"]).optional(),
|
|
1954
|
+
taskId: z3.string().optional(),
|
|
1955
|
+
label: z3.string().optional(),
|
|
1956
|
+
tags: z3.array(z3.string()).optional(),
|
|
1957
|
+
staleOnly: z3.boolean().optional().default(false)
|
|
1058
1958
|
});
|
|
1059
1959
|
}
|
|
1060
1960
|
});
|
|
1061
1961
|
|
|
1062
|
-
// src/mcp-server/tools/
|
|
1962
|
+
// src/mcp-server/tools/check-session-health.ts
|
|
1063
1963
|
import { z as z4 } from "zod";
|
|
1964
|
+
async function executeCheckSessionHealth(input, sessionHealthMonitor) {
|
|
1965
|
+
return sessionHealthMonitor.checkHealth({
|
|
1966
|
+
sessionId: input.sessionId,
|
|
1967
|
+
includeCompleted: input.includeCompleted ?? false
|
|
1968
|
+
});
|
|
1969
|
+
}
|
|
1970
|
+
var checkSessionHealthInputSchema;
|
|
1971
|
+
var init_check_session_health = __esm({
|
|
1972
|
+
"src/mcp-server/tools/check-session-health.ts"() {
|
|
1973
|
+
"use strict";
|
|
1974
|
+
checkSessionHealthInputSchema = z4.object({
|
|
1975
|
+
sessionId: z4.string().optional().describe("Specific session to inspect. Omit to check all sessions."),
|
|
1976
|
+
includeCompleted: z4.boolean().optional().default(false).describe("When true, include completed/error sessions in addition to active ones.")
|
|
1977
|
+
});
|
|
1978
|
+
}
|
|
1979
|
+
});
|
|
1980
|
+
|
|
1981
|
+
// src/mcp-server/tools/poll-agent-events.ts
|
|
1982
|
+
import { z as z5 } from "zod";
|
|
1983
|
+
function executePollAgentEvents(input, agentEventStore) {
|
|
1984
|
+
const limit = input.limit ?? 50;
|
|
1985
|
+
const result = agentEventStore.query({
|
|
1986
|
+
afterEventId: input.cursor,
|
|
1987
|
+
types: input.types,
|
|
1988
|
+
sessionId: input.sessionId,
|
|
1989
|
+
parentSessionId: input.parentSessionId,
|
|
1990
|
+
recursive: false,
|
|
1991
|
+
limit
|
|
1992
|
+
});
|
|
1993
|
+
const lastEventId = result.events.length > 0 ? result.events[result.events.length - 1].eventId : input.cursor ?? null;
|
|
1994
|
+
return {
|
|
1995
|
+
events: result.events,
|
|
1996
|
+
lastEventId,
|
|
1997
|
+
hasMore: result.hasMore
|
|
1998
|
+
};
|
|
1999
|
+
}
|
|
2000
|
+
var EVENT_TYPE_VALUES, pollAgentEventsInputSchema;
|
|
2001
|
+
var init_poll_agent_events = __esm({
|
|
2002
|
+
"src/mcp-server/tools/poll-agent-events.ts"() {
|
|
2003
|
+
"use strict";
|
|
2004
|
+
EVENT_TYPE_VALUES = [
|
|
2005
|
+
"session-start",
|
|
2006
|
+
"session-complete",
|
|
2007
|
+
"session-error",
|
|
2008
|
+
"session-stale",
|
|
2009
|
+
"context-threshold"
|
|
2010
|
+
];
|
|
2011
|
+
pollAgentEventsInputSchema = z5.object({
|
|
2012
|
+
cursor: z5.string().optional().describe("Exclusive cursor (previous lastEventId). Omit to start from oldest."),
|
|
2013
|
+
types: z5.array(z5.enum(EVENT_TYPE_VALUES)).optional().describe("Filter by event types."),
|
|
2014
|
+
sessionId: z5.string().optional().describe("Filter by session ID."),
|
|
2015
|
+
parentSessionId: z5.string().optional().describe("Filter to direct child sessions of this parent session."),
|
|
2016
|
+
limit: z5.number().int().min(1).max(200).optional().default(50).describe("Maximum number of events to return.")
|
|
2017
|
+
});
|
|
2018
|
+
}
|
|
2019
|
+
});
|
|
2020
|
+
|
|
2021
|
+
// src/mcp-server/tools/get-context-status.ts
|
|
2022
|
+
import { z as z6 } from "zod";
|
|
1064
2023
|
async function executeGetContextStatus(input, sessionManager2, contextMonitor2) {
|
|
1065
2024
|
const session = await sessionManager2.get(input.sessionId);
|
|
1066
2025
|
if (!session) {
|
|
@@ -1092,8 +2051,8 @@ var getContextStatusInputSchema;
|
|
|
1092
2051
|
var init_get_context_status = __esm({
|
|
1093
2052
|
"src/mcp-server/tools/get-context-status.ts"() {
|
|
1094
2053
|
"use strict";
|
|
1095
|
-
getContextStatusInputSchema =
|
|
1096
|
-
sessionId:
|
|
2054
|
+
getContextStatusInputSchema = z6.object({
|
|
2055
|
+
sessionId: z6.string()
|
|
1097
2056
|
});
|
|
1098
2057
|
}
|
|
1099
2058
|
});
|
|
@@ -1366,7 +2325,7 @@ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/
|
|
|
1366
2325
|
import { InMemoryTaskMessageQueue } from "@modelcontextprotocol/sdk/experimental/tasks/stores/in-memory.js";
|
|
1367
2326
|
import { createServer } from "http";
|
|
1368
2327
|
import { randomUUID } from "crypto";
|
|
1369
|
-
import { z as
|
|
2328
|
+
import { z as z7 } from "zod";
|
|
1370
2329
|
function createMcpServerOptions() {
|
|
1371
2330
|
const taskStore = new DeferredCleanupTaskStore();
|
|
1372
2331
|
return {
|
|
@@ -1381,10 +2340,14 @@ var init_server = __esm({
|
|
|
1381
2340
|
"src/mcp-server/server.ts"() {
|
|
1382
2341
|
"use strict";
|
|
1383
2342
|
init_deferred_cleanup_task_store();
|
|
2343
|
+
init_agent_event_store();
|
|
2344
|
+
init_session_health_monitor();
|
|
1384
2345
|
init_recursion_guard();
|
|
1385
2346
|
init_spawn_agent();
|
|
1386
2347
|
init_spawn_agents_parallel();
|
|
1387
2348
|
init_list_sessions();
|
|
2349
|
+
init_check_session_health();
|
|
2350
|
+
init_poll_agent_events();
|
|
1388
2351
|
init_get_context_status();
|
|
1389
2352
|
init_list_available_backends();
|
|
1390
2353
|
init_backend_selector();
|
|
@@ -1392,30 +2355,65 @@ var init_server = __esm({
|
|
|
1392
2355
|
init_types();
|
|
1393
2356
|
init_response_formatter();
|
|
1394
2357
|
spawnAgentsParallelInputShape = {
|
|
1395
|
-
agents:
|
|
2358
|
+
agents: z7.array(spawnAgentInputSchema).min(1).max(10).describe(
|
|
1396
2359
|
"Array of agent configurations to execute in parallel (1-10 agents)"
|
|
1397
2360
|
)
|
|
1398
2361
|
};
|
|
1399
2362
|
MAX_CHILD_HTTP_SESSIONS = 100;
|
|
1400
2363
|
RelayMCPServer = class {
|
|
1401
|
-
constructor(registry2, sessionManager2, guardConfig, hooksEngine2, contextMonitor2, inlineSummaryLength, responseOutputDir) {
|
|
2364
|
+
constructor(registry2, sessionManager2, guardConfig, hooksEngine2, contextMonitor2, inlineSummaryLength, responseOutputDir, relayConfig) {
|
|
1402
2365
|
this.registry = registry2;
|
|
1403
2366
|
this.sessionManager = sessionManager2;
|
|
1404
2367
|
this.hooksEngine = hooksEngine2;
|
|
1405
2368
|
this.contextMonitor = contextMonitor2;
|
|
1406
2369
|
this.inlineSummaryLength = inlineSummaryLength;
|
|
1407
2370
|
this.responseOutputDir = responseOutputDir;
|
|
2371
|
+
this.relayConfig = relayConfig;
|
|
1408
2372
|
this.guard = new RecursionGuard(guardConfig);
|
|
1409
2373
|
this.backendSelector = new BackendSelector();
|
|
2374
|
+
this.staleThresholdSec = relayConfig?.sessionHealth?.staleThresholdSec ?? 300;
|
|
2375
|
+
this.maxActiveSessions = relayConfig?.sessionHealth?.maxActiveSessions ?? 20;
|
|
2376
|
+
this.hookMemoryDir = relayConfig?.hooks?.memoryDir ?? "./memory";
|
|
2377
|
+
this.agentEventStore = new AgentEventStore({
|
|
2378
|
+
backend: relayConfig?.eventStore?.backend,
|
|
2379
|
+
maxEvents: relayConfig?.eventStore?.maxEvents,
|
|
2380
|
+
ttlSec: relayConfig?.eventStore?.ttlSec,
|
|
2381
|
+
sessionDir: relayConfig?.eventStore?.sessionDir ?? this.sessionManager.getSessionsDir(),
|
|
2382
|
+
eventsFileName: relayConfig?.eventStore?.eventsFileName
|
|
2383
|
+
});
|
|
2384
|
+
if (this.contextMonitor) {
|
|
2385
|
+
this.contextMonitor.setAgentEventStore(this.agentEventStore);
|
|
2386
|
+
}
|
|
2387
|
+
this.sessionHealthMonitor = new SessionHealthMonitor(
|
|
2388
|
+
{
|
|
2389
|
+
enabled: relayConfig?.sessionHealth?.enabled,
|
|
2390
|
+
heartbeatIntervalSec: relayConfig?.sessionHealth?.heartbeatIntervalSec,
|
|
2391
|
+
staleThresholdSec: relayConfig?.sessionHealth?.staleThresholdSec,
|
|
2392
|
+
cleanupAfterSec: relayConfig?.sessionHealth?.cleanupAfterSec,
|
|
2393
|
+
maxActiveSessions: relayConfig?.sessionHealth?.maxActiveSessions,
|
|
2394
|
+
checkIntervalSec: relayConfig?.sessionHealth?.checkIntervalSec,
|
|
2395
|
+
memoryDir: this.hookMemoryDir
|
|
2396
|
+
},
|
|
2397
|
+
this.sessionManager,
|
|
2398
|
+
this.hooksEngine ?? null,
|
|
2399
|
+
this.contextMonitor ?? null,
|
|
2400
|
+
this.agentEventStore
|
|
2401
|
+
);
|
|
1410
2402
|
this.server = new McpServer(
|
|
1411
|
-
{ name: "agentic-relay", version: "1.
|
|
2403
|
+
{ name: "agentic-relay", version: "1.2.0" },
|
|
1412
2404
|
createMcpServerOptions()
|
|
1413
2405
|
);
|
|
1414
2406
|
this.registerTools(this.server);
|
|
2407
|
+
this.sessionHealthMonitor.start();
|
|
1415
2408
|
}
|
|
1416
2409
|
server;
|
|
1417
2410
|
guard;
|
|
1418
2411
|
backendSelector;
|
|
2412
|
+
agentEventStore;
|
|
2413
|
+
sessionHealthMonitor;
|
|
2414
|
+
staleThresholdSec;
|
|
2415
|
+
maxActiveSessions;
|
|
2416
|
+
hookMemoryDir;
|
|
1419
2417
|
_childHttpServer;
|
|
1420
2418
|
_childHttpUrl;
|
|
1421
2419
|
/** URL for child agents to connect via HTTP. Available after start() in stdio mode. */
|
|
@@ -1441,7 +2439,14 @@ var init_server = __esm({
|
|
|
1441
2439
|
this.hooksEngine,
|
|
1442
2440
|
this.contextMonitor,
|
|
1443
2441
|
this.backendSelector,
|
|
1444
|
-
this._childHttpUrl
|
|
2442
|
+
this._childHttpUrl,
|
|
2443
|
+
void 0,
|
|
2444
|
+
this.agentEventStore,
|
|
2445
|
+
this.hookMemoryDir,
|
|
2446
|
+
{
|
|
2447
|
+
maxActiveSessions: this.maxActiveSessions,
|
|
2448
|
+
staleThresholdMs: this.staleThresholdSec * 1e3
|
|
2449
|
+
}
|
|
1445
2450
|
);
|
|
1446
2451
|
const controlOptions = {
|
|
1447
2452
|
inlineSummaryLength: this.inlineSummaryLength ?? DEFAULT_INLINE_SUMMARY_LENGTH,
|
|
@@ -1493,7 +2498,14 @@ var init_server = __esm({
|
|
|
1493
2498
|
this.hooksEngine,
|
|
1494
2499
|
this.contextMonitor,
|
|
1495
2500
|
this.backendSelector,
|
|
1496
|
-
this._childHttpUrl
|
|
2501
|
+
this._childHttpUrl,
|
|
2502
|
+
void 0,
|
|
2503
|
+
this.agentEventStore,
|
|
2504
|
+
this.hookMemoryDir,
|
|
2505
|
+
{
|
|
2506
|
+
maxActiveSessions: this.maxActiveSessions,
|
|
2507
|
+
staleThresholdMs: this.staleThresholdSec * 1e3
|
|
2508
|
+
}
|
|
1497
2509
|
);
|
|
1498
2510
|
const controlOptions = {
|
|
1499
2511
|
inlineSummaryLength: this.inlineSummaryLength ?? DEFAULT_INLINE_SUMMARY_LENGTH,
|
|
@@ -1586,12 +2598,12 @@ var init_server = __esm({
|
|
|
1586
2598
|
"retry_failed_agents",
|
|
1587
2599
|
"Retry only the failed agents from a previous spawn_agents_parallel call. Pass the failed results array (with originalInput) directly.",
|
|
1588
2600
|
{
|
|
1589
|
-
failedResults:
|
|
1590
|
-
index:
|
|
2601
|
+
failedResults: z7.array(z7.object({
|
|
2602
|
+
index: z7.number(),
|
|
1591
2603
|
originalInput: spawnAgentInputSchema
|
|
1592
2604
|
})).min(1).describe("Array of failed results with their original input configurations"),
|
|
1593
|
-
overrides:
|
|
1594
|
-
preferredBackend:
|
|
2605
|
+
overrides: z7.object({
|
|
2606
|
+
preferredBackend: z7.enum(["claude", "codex", "gemini"]).optional()
|
|
1595
2607
|
}).optional().describe("Parameter overrides applied to all retried agents")
|
|
1596
2608
|
},
|
|
1597
2609
|
async (params) => {
|
|
@@ -1611,7 +2623,14 @@ var init_server = __esm({
|
|
|
1611
2623
|
this.hooksEngine,
|
|
1612
2624
|
this.contextMonitor,
|
|
1613
2625
|
this.backendSelector,
|
|
1614
|
-
this._childHttpUrl
|
|
2626
|
+
this._childHttpUrl,
|
|
2627
|
+
void 0,
|
|
2628
|
+
this.agentEventStore,
|
|
2629
|
+
this.hookMemoryDir,
|
|
2630
|
+
{
|
|
2631
|
+
maxActiveSessions: this.maxActiveSessions,
|
|
2632
|
+
staleThresholdMs: this.staleThresholdSec * 1e3
|
|
2633
|
+
}
|
|
1615
2634
|
);
|
|
1616
2635
|
const controlOptions = {
|
|
1617
2636
|
inlineSummaryLength: this.inlineSummaryLength ?? DEFAULT_INLINE_SUMMARY_LENGTH,
|
|
@@ -1635,14 +2654,93 @@ var init_server = __esm({
|
|
|
1635
2654
|
"list_sessions",
|
|
1636
2655
|
"List relay sessions, optionally filtered by backend.",
|
|
1637
2656
|
{
|
|
1638
|
-
|
|
1639
|
-
|
|
2657
|
+
backendId: z7.enum(["claude", "codex", "gemini"]).optional().describe("Filter sessions by backend type."),
|
|
2658
|
+
backend: z7.enum(["claude", "codex", "gemini"]).optional().describe("Filter sessions by backend type."),
|
|
2659
|
+
limit: z7.number().int().min(1).max(100).optional().describe("Maximum number of sessions to return. Default: 10."),
|
|
2660
|
+
status: z7.enum(["active", "completed", "error"]).optional().describe("Filter sessions by status."),
|
|
2661
|
+
taskId: z7.string().optional().describe("Filter by metadata.taskId."),
|
|
2662
|
+
label: z7.string().optional().describe("Case-insensitive partial match for metadata.label."),
|
|
2663
|
+
tags: z7.array(z7.string()).optional().describe("Filter sessions containing all provided tags."),
|
|
2664
|
+
staleOnly: z7.boolean().optional().describe("When true, return only stale active sessions.")
|
|
1640
2665
|
},
|
|
1641
2666
|
async (params) => {
|
|
1642
2667
|
try {
|
|
1643
2668
|
const result = await executeListSessions(
|
|
1644
|
-
{
|
|
1645
|
-
|
|
2669
|
+
{
|
|
2670
|
+
backendId: params.backendId,
|
|
2671
|
+
backend: params.backend,
|
|
2672
|
+
limit: params.limit ?? 10,
|
|
2673
|
+
status: params.status,
|
|
2674
|
+
taskId: params.taskId,
|
|
2675
|
+
label: params.label,
|
|
2676
|
+
tags: params.tags,
|
|
2677
|
+
staleOnly: params.staleOnly ?? false
|
|
2678
|
+
},
|
|
2679
|
+
this.sessionManager,
|
|
2680
|
+
{ staleThresholdSec: this.staleThresholdSec }
|
|
2681
|
+
);
|
|
2682
|
+
return {
|
|
2683
|
+
content: [
|
|
2684
|
+
{
|
|
2685
|
+
type: "text",
|
|
2686
|
+
text: JSON.stringify(result, null, 2)
|
|
2687
|
+
}
|
|
2688
|
+
]
|
|
2689
|
+
};
|
|
2690
|
+
} catch (error) {
|
|
2691
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
2692
|
+
return {
|
|
2693
|
+
content: [{ type: "text", text: `Error: ${message}` }],
|
|
2694
|
+
isError: true
|
|
2695
|
+
};
|
|
2696
|
+
}
|
|
2697
|
+
}
|
|
2698
|
+
);
|
|
2699
|
+
server.tool(
|
|
2700
|
+
"check_session_health",
|
|
2701
|
+
"Check relay session health without side effects.",
|
|
2702
|
+
checkSessionHealthInputSchema.shape,
|
|
2703
|
+
async (params) => {
|
|
2704
|
+
try {
|
|
2705
|
+
const result = await executeCheckSessionHealth(
|
|
2706
|
+
{
|
|
2707
|
+
sessionId: params.sessionId,
|
|
2708
|
+
includeCompleted: params.includeCompleted ?? false
|
|
2709
|
+
},
|
|
2710
|
+
this.sessionHealthMonitor
|
|
2711
|
+
);
|
|
2712
|
+
return {
|
|
2713
|
+
content: [
|
|
2714
|
+
{
|
|
2715
|
+
type: "text",
|
|
2716
|
+
text: JSON.stringify(result, null, 2)
|
|
2717
|
+
}
|
|
2718
|
+
]
|
|
2719
|
+
};
|
|
2720
|
+
} catch (error) {
|
|
2721
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
2722
|
+
return {
|
|
2723
|
+
content: [{ type: "text", text: `Error: ${message}` }],
|
|
2724
|
+
isError: true
|
|
2725
|
+
};
|
|
2726
|
+
}
|
|
2727
|
+
}
|
|
2728
|
+
);
|
|
2729
|
+
server.tool(
|
|
2730
|
+
"poll_agent_events",
|
|
2731
|
+
"Poll agent lifecycle events using a cursor.",
|
|
2732
|
+
pollAgentEventsInputSchema.shape,
|
|
2733
|
+
async (params) => {
|
|
2734
|
+
try {
|
|
2735
|
+
const result = executePollAgentEvents(
|
|
2736
|
+
{
|
|
2737
|
+
cursor: params.cursor,
|
|
2738
|
+
types: params.types,
|
|
2739
|
+
sessionId: params.sessionId,
|
|
2740
|
+
parentSessionId: params.parentSessionId,
|
|
2741
|
+
limit: params.limit ?? 50
|
|
2742
|
+
},
|
|
2743
|
+
this.agentEventStore
|
|
1646
2744
|
);
|
|
1647
2745
|
return {
|
|
1648
2746
|
content: [
|
|
@@ -1665,7 +2763,7 @@ var init_server = __esm({
|
|
|
1665
2763
|
"get_context_status",
|
|
1666
2764
|
"Get the context usage status of a relay session. Returns usage data from ContextMonitor when available, otherwise estimated values.",
|
|
1667
2765
|
{
|
|
1668
|
-
sessionId:
|
|
2766
|
+
sessionId: z7.string().describe("Relay session ID to query context usage for.")
|
|
1669
2767
|
},
|
|
1670
2768
|
async (params) => {
|
|
1671
2769
|
try {
|
|
@@ -1753,6 +2851,8 @@ var init_server = __esm({
|
|
|
1753
2851
|
await new Promise((resolve3) => {
|
|
1754
2852
|
httpServer.on("close", resolve3);
|
|
1755
2853
|
});
|
|
2854
|
+
this._httpServer = void 0;
|
|
2855
|
+
await this.close();
|
|
1756
2856
|
}
|
|
1757
2857
|
/**
|
|
1758
2858
|
* Start an HTTP server for child agents.
|
|
@@ -1781,7 +2881,7 @@ var init_server = __esm({
|
|
|
1781
2881
|
sessionIdGenerator: () => randomUUID()
|
|
1782
2882
|
});
|
|
1783
2883
|
const server = new McpServer(
|
|
1784
|
-
{ name: "agentic-relay", version: "1.
|
|
2884
|
+
{ name: "agentic-relay", version: "1.2.0" },
|
|
1785
2885
|
createMcpServerOptions()
|
|
1786
2886
|
);
|
|
1787
2887
|
this.registerTools(server);
|
|
@@ -1826,6 +2926,17 @@ var init_server = __esm({
|
|
|
1826
2926
|
});
|
|
1827
2927
|
});
|
|
1828
2928
|
}
|
|
2929
|
+
async close() {
|
|
2930
|
+
this.sessionHealthMonitor.stop();
|
|
2931
|
+
this.agentEventStore.cleanup();
|
|
2932
|
+
if (this._childHttpServer) {
|
|
2933
|
+
await new Promise((resolve3) => {
|
|
2934
|
+
this._childHttpServer.close(() => resolve3());
|
|
2935
|
+
});
|
|
2936
|
+
this._childHttpServer = void 0;
|
|
2937
|
+
this._childHttpUrl = void 0;
|
|
2938
|
+
}
|
|
2939
|
+
}
|
|
1829
2940
|
/** Exposed for testing and graceful shutdown */
|
|
1830
2941
|
get httpServer() {
|
|
1831
2942
|
return this._httpServer;
|
|
@@ -1837,8 +2948,8 @@ var init_server = __esm({
|
|
|
1837
2948
|
|
|
1838
2949
|
// src/bin/relay.ts
|
|
1839
2950
|
import { defineCommand as defineCommand10, runMain } from "citty";
|
|
1840
|
-
import { join as
|
|
1841
|
-
import { homedir as
|
|
2951
|
+
import { join as join11 } from "path";
|
|
2952
|
+
import { homedir as homedir7 } from "os";
|
|
1842
2953
|
|
|
1843
2954
|
// src/infrastructure/process-manager.ts
|
|
1844
2955
|
init_logger();
|
|
@@ -3179,7 +4290,8 @@ ${prompt}`;
|
|
|
3179
4290
|
};
|
|
3180
4291
|
|
|
3181
4292
|
// src/core/session-manager.ts
|
|
3182
|
-
|
|
4293
|
+
init_logger();
|
|
4294
|
+
import { readFile as readFile4, writeFile as writeFile4, readdir, mkdir as mkdir4, chmod, rename, unlink, open } from "fs/promises";
|
|
3183
4295
|
import { join as join4 } from "path";
|
|
3184
4296
|
import { homedir as homedir4 } from "os";
|
|
3185
4297
|
import { nanoid } from "nanoid";
|
|
@@ -3193,22 +4305,44 @@ function toSessionData(session) {
|
|
|
3193
4305
|
return {
|
|
3194
4306
|
...session,
|
|
3195
4307
|
createdAt: session.createdAt.toISOString(),
|
|
3196
|
-
updatedAt: session.updatedAt.toISOString()
|
|
4308
|
+
updatedAt: session.updatedAt.toISOString(),
|
|
4309
|
+
lastHeartbeatAt: session.lastHeartbeatAt.toISOString(),
|
|
4310
|
+
staleNotifiedAt: session.staleNotifiedAt?.toISOString() ?? null
|
|
3197
4311
|
};
|
|
3198
4312
|
}
|
|
3199
4313
|
function fromSessionData(data) {
|
|
4314
|
+
const createdAt = new Date(data.createdAt);
|
|
4315
|
+
const updatedAt = new Date(data.updatedAt);
|
|
4316
|
+
const fallbackHeartbeat = data.lastHeartbeatAt ?? data.createdAt ?? data.updatedAt;
|
|
4317
|
+
const lastHeartbeatAt = new Date(fallbackHeartbeat);
|
|
4318
|
+
const staleNotifiedAt = data.staleNotifiedAt ? new Date(data.staleNotifiedAt) : null;
|
|
3200
4319
|
return {
|
|
3201
4320
|
...data,
|
|
3202
|
-
createdAt
|
|
3203
|
-
updatedAt
|
|
4321
|
+
createdAt,
|
|
4322
|
+
updatedAt,
|
|
4323
|
+
lastHeartbeatAt: Number.isNaN(lastHeartbeatAt.getTime()) ? updatedAt : lastHeartbeatAt,
|
|
4324
|
+
staleNotifiedAt: staleNotifiedAt && !Number.isNaN(staleNotifiedAt.getTime()) ? staleNotifiedAt : null,
|
|
4325
|
+
metadata: data.metadata && typeof data.metadata === "object" ? data.metadata : {}
|
|
3204
4326
|
};
|
|
3205
4327
|
}
|
|
3206
4328
|
var SessionManager = class _SessionManager {
|
|
3207
4329
|
static SESSION_ID_PATTERN = /^relay-[A-Za-z0-9_-]+$/;
|
|
4330
|
+
static DEFAULT_STALE_THRESHOLD_MS = 3e5;
|
|
4331
|
+
static CREATE_LOCK_FILE = ".create.lock";
|
|
4332
|
+
static CREATE_LOCK_RETRY_DELAY_MS = 10;
|
|
4333
|
+
static CREATE_LOCK_MAX_RETRIES = 100;
|
|
4334
|
+
static PROTECTED_METADATA_KEYS = /* @__PURE__ */ new Set([
|
|
4335
|
+
"taskId",
|
|
4336
|
+
"parentTaskId",
|
|
4337
|
+
"agentType"
|
|
4338
|
+
]);
|
|
3208
4339
|
sessionsDir;
|
|
3209
4340
|
constructor(sessionsDir) {
|
|
3210
4341
|
this.sessionsDir = sessionsDir ?? getSessionsDir(getRelayHome());
|
|
3211
4342
|
}
|
|
4343
|
+
getSessionsDir() {
|
|
4344
|
+
return this.sessionsDir;
|
|
4345
|
+
}
|
|
3212
4346
|
/** Ensure the sessions directory exists. */
|
|
3213
4347
|
async ensureDir() {
|
|
3214
4348
|
await mkdir4(this.sessionsDir, { recursive: true });
|
|
@@ -3219,28 +4353,115 @@ var SessionManager = class _SessionManager {
|
|
|
3219
4353
|
}
|
|
3220
4354
|
return join4(this.sessionsDir, `${relaySessionId}.json`);
|
|
3221
4355
|
}
|
|
4356
|
+
async writeSession(session) {
|
|
4357
|
+
const filePath = this.sessionPath(session.relaySessionId);
|
|
4358
|
+
const tempPath = `${filePath}.${nanoid()}.tmp`;
|
|
4359
|
+
try {
|
|
4360
|
+
await writeFile4(tempPath, JSON.stringify(toSessionData(session), null, 2), "utf-8");
|
|
4361
|
+
await chmod(tempPath, 384);
|
|
4362
|
+
await rename(tempPath, filePath);
|
|
4363
|
+
} catch (error) {
|
|
4364
|
+
await unlink(tempPath).catch(() => void 0);
|
|
4365
|
+
throw error;
|
|
4366
|
+
}
|
|
4367
|
+
}
|
|
4368
|
+
ensureValidTransition(currentStatus, nextStatus) {
|
|
4369
|
+
if (currentStatus === nextStatus) {
|
|
4370
|
+
return;
|
|
4371
|
+
}
|
|
4372
|
+
if (currentStatus === "completed" || currentStatus === "error") {
|
|
4373
|
+
throw new Error(
|
|
4374
|
+
`RELAY_INVALID_TRANSITION: ${currentStatus} -> ${nextStatus}`
|
|
4375
|
+
);
|
|
4376
|
+
}
|
|
4377
|
+
if (currentStatus === "active" && nextStatus !== "completed" && nextStatus !== "error") {
|
|
4378
|
+
throw new Error(
|
|
4379
|
+
`RELAY_INVALID_TRANSITION: ${currentStatus} -> ${nextStatus}`
|
|
4380
|
+
);
|
|
4381
|
+
}
|
|
4382
|
+
}
|
|
4383
|
+
getLastActivityAtMs(session) {
|
|
4384
|
+
return Math.max(
|
|
4385
|
+
session.lastHeartbeatAt.getTime(),
|
|
4386
|
+
session.updatedAt.getTime()
|
|
4387
|
+
);
|
|
4388
|
+
}
|
|
4389
|
+
isStale(session, staleThresholdMs, now = Date.now()) {
|
|
4390
|
+
if (session.status !== "active") return false;
|
|
4391
|
+
return this.getLastActivityAtMs(session) + staleThresholdMs < now;
|
|
4392
|
+
}
|
|
4393
|
+
async countHealthyActiveSessions(staleThresholdMs) {
|
|
4394
|
+
const activeSessions = await this.list({ status: "active" });
|
|
4395
|
+
const now = Date.now();
|
|
4396
|
+
return activeSessions.filter(
|
|
4397
|
+
(session) => !this.isStale(session, staleThresholdMs, now)
|
|
4398
|
+
).length;
|
|
4399
|
+
}
|
|
4400
|
+
async acquireCreateLock() {
|
|
4401
|
+
await this.ensureDir();
|
|
4402
|
+
const lockPath = join4(this.sessionsDir, _SessionManager.CREATE_LOCK_FILE);
|
|
4403
|
+
for (let attempt = 0; attempt < _SessionManager.CREATE_LOCK_MAX_RETRIES; attempt += 1) {
|
|
4404
|
+
try {
|
|
4405
|
+
const handle = await open(lockPath, "wx", 384);
|
|
4406
|
+
return async () => {
|
|
4407
|
+
await handle.close().catch(() => void 0);
|
|
4408
|
+
await unlink(lockPath).catch(() => void 0);
|
|
4409
|
+
};
|
|
4410
|
+
} catch (error) {
|
|
4411
|
+
const code = error.code;
|
|
4412
|
+
if (code !== "EEXIST") {
|
|
4413
|
+
throw error;
|
|
4414
|
+
}
|
|
4415
|
+
}
|
|
4416
|
+
await new Promise(
|
|
4417
|
+
(resolve3) => setTimeout(resolve3, _SessionManager.CREATE_LOCK_RETRY_DELAY_MS)
|
|
4418
|
+
);
|
|
4419
|
+
}
|
|
4420
|
+
throw new Error("RELAY_CONCURRENT_LIMIT_RACE: failed to acquire create lock");
|
|
4421
|
+
}
|
|
4422
|
+
mergeMetadata(existing, updates) {
|
|
4423
|
+
const merged = { ...existing };
|
|
4424
|
+
for (const [key, value] of Object.entries(updates)) {
|
|
4425
|
+
if (_SessionManager.PROTECTED_METADATA_KEYS.has(key) && Object.prototype.hasOwnProperty.call(existing, key)) {
|
|
4426
|
+
logger.warn(`Attempted to overwrite protected metadata key: ${key}`);
|
|
4427
|
+
continue;
|
|
4428
|
+
}
|
|
4429
|
+
merged[key] = value;
|
|
4430
|
+
}
|
|
4431
|
+
return merged;
|
|
4432
|
+
}
|
|
3222
4433
|
/** Create a new relay session. */
|
|
3223
4434
|
async create(params) {
|
|
3224
|
-
await this.
|
|
3225
|
-
|
|
3226
|
-
|
|
3227
|
-
|
|
3228
|
-
|
|
3229
|
-
|
|
3230
|
-
|
|
3231
|
-
|
|
3232
|
-
|
|
3233
|
-
|
|
3234
|
-
|
|
3235
|
-
|
|
3236
|
-
|
|
3237
|
-
|
|
3238
|
-
|
|
3239
|
-
|
|
3240
|
-
|
|
3241
|
-
|
|
3242
|
-
|
|
3243
|
-
|
|
4435
|
+
const releaseCreateLock = await this.acquireCreateLock();
|
|
4436
|
+
try {
|
|
4437
|
+
if (typeof params.expectedActiveCount === "number") {
|
|
4438
|
+
const staleThresholdMs = params.expectedActiveStaleThresholdMs ?? _SessionManager.DEFAULT_STALE_THRESHOLD_MS;
|
|
4439
|
+
const activeCount = await this.countHealthyActiveSessions(staleThresholdMs);
|
|
4440
|
+
if (activeCount !== params.expectedActiveCount) {
|
|
4441
|
+
throw new Error(
|
|
4442
|
+
`RELAY_CONCURRENT_LIMIT_RACE: expected=${params.expectedActiveCount}, actual=${activeCount}`
|
|
4443
|
+
);
|
|
4444
|
+
}
|
|
4445
|
+
}
|
|
4446
|
+
const now = /* @__PURE__ */ new Date();
|
|
4447
|
+
const session = {
|
|
4448
|
+
relaySessionId: `relay-${nanoid()}`,
|
|
4449
|
+
nativeSessionId: params.nativeSessionId ?? null,
|
|
4450
|
+
backendId: params.backendId,
|
|
4451
|
+
parentSessionId: params.parentSessionId ?? null,
|
|
4452
|
+
depth: params.depth ?? 0,
|
|
4453
|
+
createdAt: now,
|
|
4454
|
+
updatedAt: now,
|
|
4455
|
+
status: "active",
|
|
4456
|
+
lastHeartbeatAt: now,
|
|
4457
|
+
staleNotifiedAt: null,
|
|
4458
|
+
metadata: params.metadata ?? {}
|
|
4459
|
+
};
|
|
4460
|
+
await this.writeSession(session);
|
|
4461
|
+
return session;
|
|
4462
|
+
} finally {
|
|
4463
|
+
await releaseCreateLock();
|
|
4464
|
+
}
|
|
3244
4465
|
}
|
|
3245
4466
|
/** Update an existing session. */
|
|
3246
4467
|
async update(relaySessionId, updates) {
|
|
@@ -3248,18 +4469,36 @@ var SessionManager = class _SessionManager {
|
|
|
3248
4469
|
if (!session) {
|
|
3249
4470
|
throw new Error(`Session not found: ${relaySessionId}`);
|
|
3250
4471
|
}
|
|
4472
|
+
if (updates.status) {
|
|
4473
|
+
this.ensureValidTransition(session.status, updates.status);
|
|
4474
|
+
}
|
|
3251
4475
|
const updated = {
|
|
3252
4476
|
...session,
|
|
3253
4477
|
...updates,
|
|
4478
|
+
metadata: updates.metadata ? this.mergeMetadata(session.metadata, updates.metadata) : session.metadata,
|
|
3254
4479
|
updatedAt: /* @__PURE__ */ new Date()
|
|
3255
4480
|
};
|
|
3256
|
-
|
|
3257
|
-
|
|
3258
|
-
|
|
3259
|
-
|
|
3260
|
-
|
|
3261
|
-
)
|
|
3262
|
-
|
|
4481
|
+
await this.writeSession(updated);
|
|
4482
|
+
}
|
|
4483
|
+
/** Update heartbeat timestamp for an active session. */
|
|
4484
|
+
async updateHeartbeat(relaySessionId) {
|
|
4485
|
+
const session = await this.get(relaySessionId);
|
|
4486
|
+
if (!session) {
|
|
4487
|
+
throw new Error(`Session not found: ${relaySessionId}`);
|
|
4488
|
+
}
|
|
4489
|
+
if (session.status !== "active") {
|
|
4490
|
+
throw new Error(
|
|
4491
|
+
`RELAY_INVALID_TRANSITION: ${session.status} -> active`
|
|
4492
|
+
);
|
|
4493
|
+
}
|
|
4494
|
+
const now = /* @__PURE__ */ new Date();
|
|
4495
|
+
const updated = {
|
|
4496
|
+
...session,
|
|
4497
|
+
lastHeartbeatAt: now,
|
|
4498
|
+
staleNotifiedAt: null,
|
|
4499
|
+
updatedAt: now
|
|
4500
|
+
};
|
|
4501
|
+
await this.writeSession(updated);
|
|
3263
4502
|
}
|
|
3264
4503
|
/** Get a session by relay session ID. */
|
|
3265
4504
|
async get(relaySessionId) {
|
|
@@ -3294,6 +4533,31 @@ var SessionManager = class _SessionManager {
|
|
|
3294
4533
|
if (filter?.backendId && session.backendId !== filter.backendId) {
|
|
3295
4534
|
continue;
|
|
3296
4535
|
}
|
|
4536
|
+
if (filter?.status && session.status !== filter.status) {
|
|
4537
|
+
continue;
|
|
4538
|
+
}
|
|
4539
|
+
if (filter?.taskId && session.metadata.taskId !== filter.taskId) {
|
|
4540
|
+
continue;
|
|
4541
|
+
}
|
|
4542
|
+
if (filter?.label && filter.label.trim().length > 0) {
|
|
4543
|
+
const target = (session.metadata.label ?? "").toLowerCase();
|
|
4544
|
+
if (!target.includes(filter.label.toLowerCase())) {
|
|
4545
|
+
continue;
|
|
4546
|
+
}
|
|
4547
|
+
}
|
|
4548
|
+
if (filter?.tags && filter.tags.length > 0) {
|
|
4549
|
+
const sessionTags = Array.isArray(session.metadata.tags) ? session.metadata.tags : [];
|
|
4550
|
+
const hasAllTags = filter.tags.every((tag) => sessionTags.includes(tag));
|
|
4551
|
+
if (!hasAllTags) {
|
|
4552
|
+
continue;
|
|
4553
|
+
}
|
|
4554
|
+
}
|
|
4555
|
+
if (filter?.staleOnly) {
|
|
4556
|
+
const threshold = filter.staleThresholdMs ?? _SessionManager.DEFAULT_STALE_THRESHOLD_MS;
|
|
4557
|
+
if (!this.isStale(session, threshold)) {
|
|
4558
|
+
continue;
|
|
4559
|
+
}
|
|
4560
|
+
}
|
|
3297
4561
|
sessions.push(session);
|
|
3298
4562
|
} catch {
|
|
3299
4563
|
}
|
|
@@ -3306,6 +4570,41 @@ var SessionManager = class _SessionManager {
|
|
|
3306
4570
|
}
|
|
3307
4571
|
return sessions;
|
|
3308
4572
|
}
|
|
4573
|
+
async listStale(thresholdMs) {
|
|
4574
|
+
const activeSessions = await this.list({ status: "active" });
|
|
4575
|
+
const now = Date.now();
|
|
4576
|
+
return activeSessions.filter((session) => this.isStale(session, thresholdMs, now));
|
|
4577
|
+
}
|
|
4578
|
+
async delete(relaySessionId) {
|
|
4579
|
+
const filePath = this.sessionPath(relaySessionId);
|
|
4580
|
+
try {
|
|
4581
|
+
await unlink(filePath);
|
|
4582
|
+
} catch (error) {
|
|
4583
|
+
if (error.code === "ENOENT") {
|
|
4584
|
+
return;
|
|
4585
|
+
}
|
|
4586
|
+
throw error;
|
|
4587
|
+
}
|
|
4588
|
+
}
|
|
4589
|
+
async cleanup(olderThanMs) {
|
|
4590
|
+
const sessions = await this.list();
|
|
4591
|
+
const now = Date.now();
|
|
4592
|
+
const deletedSessionIds = [];
|
|
4593
|
+
for (const session of sessions) {
|
|
4594
|
+
if (session.status !== "completed" && session.status !== "error") {
|
|
4595
|
+
continue;
|
|
4596
|
+
}
|
|
4597
|
+
if (session.updatedAt.getTime() + olderThanMs >= now) {
|
|
4598
|
+
continue;
|
|
4599
|
+
}
|
|
4600
|
+
await this.delete(session.relaySessionId);
|
|
4601
|
+
deletedSessionIds.push(session.relaySessionId);
|
|
4602
|
+
}
|
|
4603
|
+
return {
|
|
4604
|
+
deletedCount: deletedSessionIds.length,
|
|
4605
|
+
deletedSessionIds
|
|
4606
|
+
};
|
|
4607
|
+
}
|
|
3309
4608
|
};
|
|
3310
4609
|
|
|
3311
4610
|
// src/core/config-manager.ts
|
|
@@ -3328,7 +4627,10 @@ var hookEventSchema = z.enum([
|
|
|
3328
4627
|
"on-error",
|
|
3329
4628
|
"on-context-threshold",
|
|
3330
4629
|
"pre-spawn",
|
|
3331
|
-
"post-spawn"
|
|
4630
|
+
"post-spawn",
|
|
4631
|
+
"on-session-complete",
|
|
4632
|
+
"on-session-error",
|
|
4633
|
+
"on-session-stale"
|
|
3332
4634
|
]);
|
|
3333
4635
|
var hookDefinitionSchema = z.object({
|
|
3334
4636
|
event: hookEventSchema,
|
|
@@ -3350,7 +4652,8 @@ var hookChainSchema = z.object({
|
|
|
3350
4652
|
});
|
|
3351
4653
|
var hooksConfigSchema = z.object({
|
|
3352
4654
|
definitions: z.array(hookDefinitionSchema),
|
|
3353
|
-
chains: z.array(hookChainSchema).optional()
|
|
4655
|
+
chains: z.array(hookChainSchema).optional(),
|
|
4656
|
+
memoryDir: z.string().optional()
|
|
3354
4657
|
});
|
|
3355
4658
|
var backendContextConfigSchema = z.object({
|
|
3356
4659
|
contextWindow: z.number().positive().optional(),
|
|
@@ -3365,18 +4668,38 @@ var relayConfigSchema = z.object({
|
|
|
3365
4668
|
gemini: z.record(z.unknown()).optional()
|
|
3366
4669
|
}).optional(),
|
|
3367
4670
|
hooks: hooksConfigSchema.optional(),
|
|
4671
|
+
sessionHealth: z.object({
|
|
4672
|
+
enabled: z.boolean().optional(),
|
|
4673
|
+
heartbeatIntervalSec: z.number().positive().optional(),
|
|
4674
|
+
staleThresholdSec: z.number().positive().optional(),
|
|
4675
|
+
cleanupAfterSec: z.number().positive().optional(),
|
|
4676
|
+
maxActiveSessions: z.number().int().positive().optional(),
|
|
4677
|
+
checkIntervalSec: z.number().positive().optional()
|
|
4678
|
+
}).optional(),
|
|
3368
4679
|
contextMonitor: z.object({
|
|
3369
4680
|
enabled: z.boolean().optional(),
|
|
3370
4681
|
thresholdPercent: z.number().min(0).max(100).optional(),
|
|
3371
4682
|
notifyThreshold: z.number().positive().optional(),
|
|
3372
4683
|
notifyPercent: z.number().min(0).max(100).optional(),
|
|
3373
4684
|
notifyMethod: z.enum(["stderr", "hook"]).optional(),
|
|
4685
|
+
thresholdLevels: z.object({
|
|
4686
|
+
warning: z.number().min(0).max(100).optional(),
|
|
4687
|
+
critical: z.number().min(0).max(100).optional(),
|
|
4688
|
+
emergency: z.number().min(0).max(100).optional()
|
|
4689
|
+
}).optional(),
|
|
3374
4690
|
backends: z.object({
|
|
3375
4691
|
claude: backendContextConfigSchema,
|
|
3376
4692
|
codex: backendContextConfigSchema,
|
|
3377
4693
|
gemini: backendContextConfigSchema
|
|
3378
4694
|
}).optional()
|
|
3379
4695
|
}).optional(),
|
|
4696
|
+
eventStore: z.object({
|
|
4697
|
+
backend: z.enum(["memory", "jsonl"]).optional(),
|
|
4698
|
+
maxEvents: z.number().int().positive().optional(),
|
|
4699
|
+
ttlSec: z.number().positive().optional(),
|
|
4700
|
+
sessionDir: z.string().optional(),
|
|
4701
|
+
eventsFileName: z.string().optional()
|
|
4702
|
+
}).optional(),
|
|
3380
4703
|
mcpServerMode: z.object({
|
|
3381
4704
|
maxDepth: z.number().int().positive(),
|
|
3382
4705
|
maxCallsPerSession: z.number().int().positive(),
|
|
@@ -3678,7 +5001,7 @@ var HooksEngine = class _HooksEngine {
|
|
|
3678
5001
|
}
|
|
3679
5002
|
/** Load hook definitions from config and register listeners on EventBus */
|
|
3680
5003
|
loadConfig(config) {
|
|
3681
|
-
this.definitions = config.definitions.filter((def) => {
|
|
5004
|
+
this.definitions = (config.definitions ?? []).filter((def) => {
|
|
3682
5005
|
if (def.enabled === false) return false;
|
|
3683
5006
|
try {
|
|
3684
5007
|
this.validateCommand(def.command);
|
|
@@ -3906,13 +5229,17 @@ var DEFAULT_BACKEND_CONTEXT = {
|
|
|
3906
5229
|
gemini: { contextWindow: 1048576, compactThreshold: 524288 }
|
|
3907
5230
|
};
|
|
3908
5231
|
var DEFAULT_NOTIFY_PERCENT = 70;
|
|
5232
|
+
var DEFAULT_CRITICAL_PERCENT = 85;
|
|
5233
|
+
var DEFAULT_EMERGENCY_PERCENT = 95;
|
|
3909
5234
|
var DEFAULT_CONFIG = {
|
|
3910
5235
|
enabled: true,
|
|
3911
|
-
notifyMethod: "hook"
|
|
5236
|
+
notifyMethod: "hook",
|
|
5237
|
+
memoryDir: "./memory"
|
|
3912
5238
|
};
|
|
3913
5239
|
var ContextMonitor = class {
|
|
3914
|
-
constructor(hooksEngine2, config) {
|
|
5240
|
+
constructor(hooksEngine2, config, agentEventStore) {
|
|
3915
5241
|
this.hooksEngine = hooksEngine2;
|
|
5242
|
+
this.agentEventStore = agentEventStore;
|
|
3916
5243
|
this.config = { ...DEFAULT_CONFIG, ...config };
|
|
3917
5244
|
if (this.config.thresholdPercent !== void 0 && this.config.notifyPercent === void 0 && this.config.notifyThreshold === void 0) {
|
|
3918
5245
|
this.config.notifyPercent = this.config.thresholdPercent;
|
|
@@ -3920,6 +5247,7 @@ var ContextMonitor = class {
|
|
|
3920
5247
|
}
|
|
3921
5248
|
config;
|
|
3922
5249
|
usageMap = /* @__PURE__ */ new Map();
|
|
5250
|
+
agentEventStore;
|
|
3923
5251
|
/** Get backend context config, merging user overrides with defaults */
|
|
3924
5252
|
getBackendConfig(backendId) {
|
|
3925
5253
|
const defaults = DEFAULT_BACKEND_CONTEXT[backendId];
|
|
@@ -3935,19 +5263,60 @@ var ContextMonitor = class {
|
|
|
3935
5263
|
return this.config.notifyThreshold;
|
|
3936
5264
|
}
|
|
3937
5265
|
const backendConfig = this.getBackendConfig(backendId);
|
|
3938
|
-
const notifyPercent = this.config.notifyPercent ?? DEFAULT_NOTIFY_PERCENT;
|
|
5266
|
+
const notifyPercent = this.config.thresholdLevels?.warning ?? this.config.notifyPercent ?? DEFAULT_NOTIFY_PERCENT;
|
|
3939
5267
|
return Math.round(backendConfig.contextWindow * notifyPercent / 100);
|
|
3940
5268
|
}
|
|
5269
|
+
getWarningPercent(backendId) {
|
|
5270
|
+
if (this.config.thresholdLevels?.warning !== void 0) {
|
|
5271
|
+
return this.config.thresholdLevels.warning;
|
|
5272
|
+
}
|
|
5273
|
+
if (this.config.notifyThreshold !== void 0) {
|
|
5274
|
+
const backendConfig = this.getBackendConfig(backendId);
|
|
5275
|
+
if (backendConfig.contextWindow <= 0) return DEFAULT_NOTIFY_PERCENT;
|
|
5276
|
+
return Math.round(
|
|
5277
|
+
this.config.notifyThreshold / backendConfig.contextWindow * 100
|
|
5278
|
+
);
|
|
5279
|
+
}
|
|
5280
|
+
return this.config.notifyPercent ?? DEFAULT_NOTIFY_PERCENT;
|
|
5281
|
+
}
|
|
5282
|
+
getThresholdLevels(backendId, contextWindow) {
|
|
5283
|
+
const warningPercent = this.getWarningPercent(backendId);
|
|
5284
|
+
const warningTokens = this.getNotifyThreshold(backendId);
|
|
5285
|
+
const criticalPercent = this.config.thresholdLevels?.critical ?? DEFAULT_CRITICAL_PERCENT;
|
|
5286
|
+
const emergencyPercent = this.config.thresholdLevels?.emergency ?? DEFAULT_EMERGENCY_PERCENT;
|
|
5287
|
+
return [
|
|
5288
|
+
["warning", warningPercent, warningTokens],
|
|
5289
|
+
[
|
|
5290
|
+
"critical",
|
|
5291
|
+
criticalPercent,
|
|
5292
|
+
Math.round(contextWindow * criticalPercent / 100)
|
|
5293
|
+
],
|
|
5294
|
+
[
|
|
5295
|
+
"emergency",
|
|
5296
|
+
emergencyPercent,
|
|
5297
|
+
Math.round(contextWindow * emergencyPercent / 100)
|
|
5298
|
+
]
|
|
5299
|
+
];
|
|
5300
|
+
}
|
|
5301
|
+
isNotified(sessionId, level) {
|
|
5302
|
+
const entry = this.usageMap.get(sessionId);
|
|
5303
|
+
return entry?.notifiedLevels.has(level) ?? false;
|
|
5304
|
+
}
|
|
5305
|
+
markNotified(sessionId, level) {
|
|
5306
|
+
const entry = this.usageMap.get(sessionId);
|
|
5307
|
+
if (!entry) return;
|
|
5308
|
+
entry.notifiedLevels.add(level);
|
|
5309
|
+
}
|
|
3941
5310
|
/** Update token usage for a session and check threshold */
|
|
3942
|
-
updateUsage(sessionId, backendId, estimatedTokens) {
|
|
5311
|
+
updateUsage(sessionId, backendId, estimatedTokens, sessionMetadata) {
|
|
3943
5312
|
if (!this.config.enabled) return;
|
|
3944
5313
|
const backendConfig = this.getBackendConfig(backendId);
|
|
3945
5314
|
const contextWindow = backendConfig.contextWindow;
|
|
3946
5315
|
const usagePercent = contextWindow > 0 ? Math.round(estimatedTokens / contextWindow * 100) : 0;
|
|
3947
5316
|
const existing = this.usageMap.get(sessionId);
|
|
3948
|
-
let
|
|
5317
|
+
let notifiedLevels = existing?.notifiedLevels ?? /* @__PURE__ */ new Set();
|
|
3949
5318
|
if (existing && estimatedTokens < existing.estimatedTokens * 0.7) {
|
|
3950
|
-
|
|
5319
|
+
notifiedLevels = /* @__PURE__ */ new Set();
|
|
3951
5320
|
}
|
|
3952
5321
|
this.usageMap.set(sessionId, {
|
|
3953
5322
|
estimatedTokens,
|
|
@@ -3955,19 +5324,27 @@ var ContextMonitor = class {
|
|
|
3955
5324
|
compactThreshold: backendConfig.compactThreshold,
|
|
3956
5325
|
usagePercent,
|
|
3957
5326
|
backendId,
|
|
3958
|
-
|
|
5327
|
+
notifiedLevels,
|
|
5328
|
+
sessionMetadata: sessionMetadata ?? existing?.sessionMetadata
|
|
3959
5329
|
});
|
|
3960
|
-
const
|
|
3961
|
-
|
|
3962
|
-
|
|
3963
|
-
|
|
3964
|
-
|
|
5330
|
+
const levels = this.getThresholdLevels(backendId, contextWindow);
|
|
5331
|
+
for (const [level, _thresholdPercent, thresholdTokens] of levels) {
|
|
5332
|
+
if (estimatedTokens < thresholdTokens) {
|
|
5333
|
+
continue;
|
|
5334
|
+
}
|
|
5335
|
+
if (this.isNotified(sessionId, level)) {
|
|
5336
|
+
continue;
|
|
5337
|
+
}
|
|
5338
|
+
this.markNotified(sessionId, level);
|
|
5339
|
+
this.notifyLevel(
|
|
3965
5340
|
sessionId,
|
|
3966
5341
|
backendId,
|
|
5342
|
+
level,
|
|
3967
5343
|
usagePercent,
|
|
3968
5344
|
estimatedTokens,
|
|
3969
5345
|
contextWindow,
|
|
3970
|
-
backendConfig.compactThreshold
|
|
5346
|
+
backendConfig.compactThreshold,
|
|
5347
|
+
this.usageMap.get(sessionId)?.sessionMetadata
|
|
3971
5348
|
);
|
|
3972
5349
|
}
|
|
3973
5350
|
}
|
|
@@ -3993,15 +5370,93 @@ var ContextMonitor = class {
|
|
|
3993
5370
|
removeSession(sessionId) {
|
|
3994
5371
|
this.usageMap.delete(sessionId);
|
|
3995
5372
|
}
|
|
3996
|
-
|
|
5373
|
+
setAgentEventStore(agentEventStore) {
|
|
5374
|
+
this.agentEventStore = agentEventStore;
|
|
5375
|
+
}
|
|
5376
|
+
parseSaveResult(value) {
|
|
5377
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
5378
|
+
return null;
|
|
5379
|
+
}
|
|
5380
|
+
const candidate = value;
|
|
5381
|
+
if (typeof candidate.saved !== "boolean") {
|
|
5382
|
+
return null;
|
|
5383
|
+
}
|
|
5384
|
+
const saveResult = { saved: candidate.saved };
|
|
5385
|
+
if (typeof candidate.snapshotPath === "string") {
|
|
5386
|
+
saveResult.snapshotPath = candidate.snapshotPath;
|
|
5387
|
+
}
|
|
5388
|
+
if (typeof candidate.error === "string") {
|
|
5389
|
+
saveResult.error = candidate.error;
|
|
5390
|
+
}
|
|
5391
|
+
return saveResult;
|
|
5392
|
+
}
|
|
5393
|
+
extractSaveResult(results) {
|
|
5394
|
+
for (let index = results.length - 1; index >= 0; index -= 1) {
|
|
5395
|
+
const metadata = results[index]?.output.metadata;
|
|
5396
|
+
const nested = this.parseSaveResult(
|
|
5397
|
+
metadata?.saveResult
|
|
5398
|
+
);
|
|
5399
|
+
if (nested) {
|
|
5400
|
+
return nested;
|
|
5401
|
+
}
|
|
5402
|
+
const topLevel = this.parseSaveResult(metadata);
|
|
5403
|
+
if (topLevel) {
|
|
5404
|
+
return topLevel;
|
|
5405
|
+
}
|
|
5406
|
+
}
|
|
5407
|
+
const failed = results.find(
|
|
5408
|
+
(result) => result.exitCode !== 0 || (result.stderr?.trim().length ?? 0) > 0
|
|
5409
|
+
);
|
|
5410
|
+
if (failed) {
|
|
5411
|
+
return {
|
|
5412
|
+
saved: false,
|
|
5413
|
+
error: failed.stderr || `hook exited with code ${failed.exitCode}`
|
|
5414
|
+
};
|
|
5415
|
+
}
|
|
5416
|
+
return { saved: false };
|
|
5417
|
+
}
|
|
5418
|
+
recordThresholdEvent(sessionId, backendId, level, usagePercent, currentTokens, contextWindow, compactThreshold, remainingBeforeCompact, saveResult, sessionMetadata) {
|
|
5419
|
+
this.agentEventStore?.record({
|
|
5420
|
+
type: "context-threshold",
|
|
5421
|
+
sessionId,
|
|
5422
|
+
backendId,
|
|
5423
|
+
parentSessionId: void 0,
|
|
5424
|
+
metadata: sessionMetadata ?? {},
|
|
5425
|
+
data: {
|
|
5426
|
+
usagePercent,
|
|
5427
|
+
currentTokens,
|
|
5428
|
+
contextWindow,
|
|
5429
|
+
compactThreshold,
|
|
5430
|
+
remainingBeforeCompact,
|
|
5431
|
+
level,
|
|
5432
|
+
thresholdLevel: level,
|
|
5433
|
+
saveResult,
|
|
5434
|
+
taskId: sessionMetadata?.taskId
|
|
5435
|
+
}
|
|
5436
|
+
});
|
|
5437
|
+
}
|
|
5438
|
+
notifyLevel(sessionId, backendId, level, usagePercent, currentTokens, contextWindow, compactThreshold, sessionMetadata) {
|
|
3997
5439
|
const remainingBeforeCompact = Math.max(
|
|
3998
5440
|
0,
|
|
3999
5441
|
compactThreshold - currentTokens
|
|
4000
5442
|
);
|
|
4001
|
-
const
|
|
5443
|
+
const initialSaveResult = { saved: false };
|
|
5444
|
+
const warningMessage = `${backendId} session ${sessionId} level=${level} at ${usagePercent}% (${currentTokens}/${contextWindow} tokens). Compact in ~${remainingBeforeCompact} tokens. Save your work state now.`;
|
|
4002
5445
|
if (this.config.notifyMethod === "stderr") {
|
|
5446
|
+
this.recordThresholdEvent(
|
|
5447
|
+
sessionId,
|
|
5448
|
+
backendId,
|
|
5449
|
+
level,
|
|
5450
|
+
usagePercent,
|
|
5451
|
+
currentTokens,
|
|
5452
|
+
contextWindow,
|
|
5453
|
+
compactThreshold,
|
|
5454
|
+
remainingBeforeCompact,
|
|
5455
|
+
initialSaveResult,
|
|
5456
|
+
sessionMetadata
|
|
5457
|
+
);
|
|
4003
5458
|
process.stderr.write(
|
|
4004
|
-
`[relay] Context
|
|
5459
|
+
`[relay] Context ${level}: ${warningMessage}
|
|
4005
5460
|
`
|
|
4006
5461
|
);
|
|
4007
5462
|
} else if (this.config.notifyMethod === "hook" && this.hooksEngine) {
|
|
@@ -4015,11 +5470,60 @@ var ContextMonitor = class {
|
|
|
4015
5470
|
currentTokens,
|
|
4016
5471
|
contextWindow,
|
|
4017
5472
|
compactThreshold,
|
|
4018
|
-
remainingBeforeCompact
|
|
5473
|
+
remainingBeforeCompact,
|
|
5474
|
+
level,
|
|
5475
|
+
thresholdLevel: level,
|
|
5476
|
+
taskId: sessionMetadata?.taskId,
|
|
5477
|
+
label: sessionMetadata?.label,
|
|
5478
|
+
agentType: sessionMetadata?.agentType,
|
|
5479
|
+
saveResult: initialSaveResult,
|
|
5480
|
+
memoryDir: this.config.memoryDir ?? "./memory"
|
|
4019
5481
|
}
|
|
4020
5482
|
};
|
|
4021
|
-
void this.hooksEngine.emit("on-context-threshold", hookInput).
|
|
4022
|
-
|
|
5483
|
+
void this.hooksEngine.emit("on-context-threshold", hookInput).then((results) => {
|
|
5484
|
+
const saveResult = this.extractSaveResult(results);
|
|
5485
|
+
this.recordThresholdEvent(
|
|
5486
|
+
sessionId,
|
|
5487
|
+
backendId,
|
|
5488
|
+
level,
|
|
5489
|
+
usagePercent,
|
|
5490
|
+
currentTokens,
|
|
5491
|
+
contextWindow,
|
|
5492
|
+
compactThreshold,
|
|
5493
|
+
remainingBeforeCompact,
|
|
5494
|
+
saveResult,
|
|
5495
|
+
sessionMetadata
|
|
5496
|
+
);
|
|
5497
|
+
}).catch(
|
|
5498
|
+
(e) => {
|
|
5499
|
+
const errorMessage = e instanceof Error ? e.message : String(e);
|
|
5500
|
+
this.recordThresholdEvent(
|
|
5501
|
+
sessionId,
|
|
5502
|
+
backendId,
|
|
5503
|
+
level,
|
|
5504
|
+
usagePercent,
|
|
5505
|
+
currentTokens,
|
|
5506
|
+
contextWindow,
|
|
5507
|
+
compactThreshold,
|
|
5508
|
+
remainingBeforeCompact,
|
|
5509
|
+
{ saved: false, error: errorMessage },
|
|
5510
|
+
sessionMetadata
|
|
5511
|
+
);
|
|
5512
|
+
logger.debug("Context threshold hook error:", e);
|
|
5513
|
+
}
|
|
5514
|
+
);
|
|
5515
|
+
} else {
|
|
5516
|
+
this.recordThresholdEvent(
|
|
5517
|
+
sessionId,
|
|
5518
|
+
backendId,
|
|
5519
|
+
level,
|
|
5520
|
+
usagePercent,
|
|
5521
|
+
currentTokens,
|
|
5522
|
+
contextWindow,
|
|
5523
|
+
compactThreshold,
|
|
5524
|
+
remainingBeforeCompact,
|
|
5525
|
+
initialSaveResult,
|
|
5526
|
+
sessionMetadata
|
|
4023
5527
|
);
|
|
4024
5528
|
}
|
|
4025
5529
|
}
|
|
@@ -4615,16 +6119,17 @@ function createMCPCommand(configManager2, registry2, sessionManager2, hooksEngin
|
|
|
4615
6119
|
let guardConfig;
|
|
4616
6120
|
let inlineSummaryLength;
|
|
4617
6121
|
let responseOutputDir;
|
|
6122
|
+
let relayConfig;
|
|
4618
6123
|
try {
|
|
4619
|
-
|
|
4620
|
-
if (
|
|
6124
|
+
relayConfig = await configManager2.getConfig();
|
|
6125
|
+
if (relayConfig.mcpServerMode) {
|
|
4621
6126
|
guardConfig = {
|
|
4622
|
-
maxDepth:
|
|
4623
|
-
maxCallsPerSession:
|
|
4624
|
-
timeoutSec:
|
|
6127
|
+
maxDepth: relayConfig.mcpServerMode.maxDepth ?? 5,
|
|
6128
|
+
maxCallsPerSession: relayConfig.mcpServerMode.maxCallsPerSession ?? 20,
|
|
6129
|
+
timeoutSec: relayConfig.mcpServerMode.timeoutSec ?? 86400
|
|
4625
6130
|
};
|
|
4626
|
-
inlineSummaryLength =
|
|
4627
|
-
responseOutputDir =
|
|
6131
|
+
inlineSummaryLength = relayConfig.mcpServerMode.inlineSummaryLength;
|
|
6132
|
+
responseOutputDir = relayConfig.mcpServerMode.responseOutputDir;
|
|
4628
6133
|
}
|
|
4629
6134
|
} catch {
|
|
4630
6135
|
}
|
|
@@ -4637,7 +6142,8 @@ function createMCPCommand(configManager2, registry2, sessionManager2, hooksEngin
|
|
|
4637
6142
|
hooksEngine2,
|
|
4638
6143
|
contextMonitor2,
|
|
4639
6144
|
inlineSummaryLength,
|
|
4640
|
-
responseOutputDir
|
|
6145
|
+
responseOutputDir,
|
|
6146
|
+
relayConfig
|
|
4641
6147
|
);
|
|
4642
6148
|
await server.start({ transport, port });
|
|
4643
6149
|
}
|
|
@@ -4799,7 +6305,7 @@ function createVersionCommand(registry2) {
|
|
|
4799
6305
|
description: "Show relay and backend versions"
|
|
4800
6306
|
},
|
|
4801
6307
|
async run() {
|
|
4802
|
-
const relayVersion = "1.
|
|
6308
|
+
const relayVersion = "1.2.0";
|
|
4803
6309
|
console.log(`agentic-relay v${relayVersion}`);
|
|
4804
6310
|
console.log("");
|
|
4805
6311
|
console.log("Backends:");
|
|
@@ -4824,8 +6330,8 @@ function createVersionCommand(registry2) {
|
|
|
4824
6330
|
// src/commands/doctor.ts
|
|
4825
6331
|
import { defineCommand as defineCommand8 } from "citty";
|
|
4826
6332
|
import { access, constants, readdir as readdir2 } from "fs/promises";
|
|
4827
|
-
import { join as
|
|
4828
|
-
import { homedir as
|
|
6333
|
+
import { join as join9 } from "path";
|
|
6334
|
+
import { homedir as homedir6 } from "os";
|
|
4829
6335
|
import { execFile as execFile2 } from "child_process";
|
|
4830
6336
|
import { promisify as promisify2 } from "util";
|
|
4831
6337
|
var execFileAsync2 = promisify2(execFile2);
|
|
@@ -4885,8 +6391,8 @@ async function checkConfig(configManager2) {
|
|
|
4885
6391
|
}
|
|
4886
6392
|
}
|
|
4887
6393
|
async function checkSessionsDir() {
|
|
4888
|
-
const relayHome2 = process.env["RELAY_HOME"] ??
|
|
4889
|
-
const sessionsDir =
|
|
6394
|
+
const relayHome2 = process.env["RELAY_HOME"] ?? join9(homedir6(), ".relay");
|
|
6395
|
+
const sessionsDir = join9(relayHome2, "sessions");
|
|
4890
6396
|
try {
|
|
4891
6397
|
await access(sessionsDir, constants.W_OK);
|
|
4892
6398
|
return {
|
|
@@ -4999,8 +6505,8 @@ async function checkBackendAuthEnv() {
|
|
|
4999
6505
|
return results;
|
|
5000
6506
|
}
|
|
5001
6507
|
async function checkSessionsDiskUsage() {
|
|
5002
|
-
const relayHome2 = process.env["RELAY_HOME"] ??
|
|
5003
|
-
const sessionsDir =
|
|
6508
|
+
const relayHome2 = process.env["RELAY_HOME"] ?? join9(homedir6(), ".relay");
|
|
6509
|
+
const sessionsDir = join9(relayHome2, "sessions");
|
|
5004
6510
|
try {
|
|
5005
6511
|
const entries = await readdir2(sessionsDir);
|
|
5006
6512
|
const fileCount = entries.length;
|
|
@@ -5074,8 +6580,8 @@ function createDoctorCommand(registry2, configManager2) {
|
|
|
5074
6580
|
init_logger();
|
|
5075
6581
|
import { defineCommand as defineCommand9 } from "citty";
|
|
5076
6582
|
import { mkdir as mkdir6, writeFile as writeFile6, access as access2, readFile as readFile6 } from "fs/promises";
|
|
5077
|
-
import { join as
|
|
5078
|
-
var
|
|
6583
|
+
import { join as join10 } from "path";
|
|
6584
|
+
var DEFAULT_CONFIG4 = {
|
|
5079
6585
|
defaultBackend: "claude",
|
|
5080
6586
|
backends: {},
|
|
5081
6587
|
mcpServers: {}
|
|
@@ -5088,8 +6594,8 @@ function createInitCommand() {
|
|
|
5088
6594
|
},
|
|
5089
6595
|
async run() {
|
|
5090
6596
|
const projectDir = process.cwd();
|
|
5091
|
-
const relayDir =
|
|
5092
|
-
const configPath =
|
|
6597
|
+
const relayDir = join10(projectDir, ".relay");
|
|
6598
|
+
const configPath = join10(relayDir, "config.json");
|
|
5093
6599
|
try {
|
|
5094
6600
|
await access2(relayDir);
|
|
5095
6601
|
logger.info(
|
|
@@ -5101,11 +6607,11 @@ function createInitCommand() {
|
|
|
5101
6607
|
await mkdir6(relayDir, { recursive: true });
|
|
5102
6608
|
await writeFile6(
|
|
5103
6609
|
configPath,
|
|
5104
|
-
JSON.stringify(
|
|
6610
|
+
JSON.stringify(DEFAULT_CONFIG4, null, 2) + "\n",
|
|
5105
6611
|
"utf-8"
|
|
5106
6612
|
);
|
|
5107
6613
|
logger.success(`Created ${configPath}`);
|
|
5108
|
-
const gitignorePath =
|
|
6614
|
+
const gitignorePath = join10(projectDir, ".gitignore");
|
|
5109
6615
|
try {
|
|
5110
6616
|
const gitignoreContent = await readFile6(gitignorePath, "utf-8");
|
|
5111
6617
|
if (!gitignoreContent.includes(".relay/config.local.json")) {
|
|
@@ -5131,8 +6637,8 @@ registry.registerLazy("claude", () => new ClaudeAdapter(processManager));
|
|
|
5131
6637
|
registry.registerLazy("codex", () => new CodexAdapter(processManager));
|
|
5132
6638
|
registry.registerLazy("gemini", () => new GeminiAdapter(processManager));
|
|
5133
6639
|
var sessionManager = new SessionManager();
|
|
5134
|
-
var relayHome = process.env["RELAY_HOME"] ??
|
|
5135
|
-
var projectRelayDir =
|
|
6640
|
+
var relayHome = process.env["RELAY_HOME"] ?? join11(homedir7(), ".relay");
|
|
6641
|
+
var projectRelayDir = join11(process.cwd(), ".relay");
|
|
5136
6642
|
var configManager = new ConfigManager(relayHome, projectRelayDir);
|
|
5137
6643
|
var authManager = new AuthManager(registry);
|
|
5138
6644
|
var eventBus = new EventBus();
|
|
@@ -5143,13 +6649,16 @@ void configManager.getConfig().then((config) => {
|
|
|
5143
6649
|
hooksEngine.loadConfig(config.hooks);
|
|
5144
6650
|
}
|
|
5145
6651
|
if (config.contextMonitor) {
|
|
5146
|
-
contextMonitor = new ContextMonitor(hooksEngine,
|
|
6652
|
+
contextMonitor = new ContextMonitor(hooksEngine, {
|
|
6653
|
+
...config.contextMonitor,
|
|
6654
|
+
memoryDir: config.hooks?.memoryDir
|
|
6655
|
+
});
|
|
5147
6656
|
}
|
|
5148
6657
|
}).catch((e) => logger.debug("Config load failed:", e));
|
|
5149
6658
|
var main = defineCommand10({
|
|
5150
6659
|
meta: {
|
|
5151
6660
|
name: "relay",
|
|
5152
|
-
version: "1.
|
|
6661
|
+
version: "1.2.0",
|
|
5153
6662
|
description: "Unified CLI proxy for Claude Code, Codex CLI, and Gemini CLI"
|
|
5154
6663
|
},
|
|
5155
6664
|
subCommands: {
|