@rk0429/agentic-relay 1.1.0 → 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 +1662 -162
- 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) {
|
|
@@ -654,9 +1328,7 @@ ${input.prompt}`;
|
|
|
654
1328
|
} else {
|
|
655
1329
|
effectiveNativeSessionId = input.resumeSessionId;
|
|
656
1330
|
}
|
|
657
|
-
return adapter.continueSession(effectiveNativeSessionId, effectivePrompt
|
|
658
|
-
...input.maxTurns !== void 0 ? { maxTurns: input.maxTurns } : {}
|
|
659
|
-
});
|
|
1331
|
+
return adapter.continueSession(effectiveNativeSessionId, effectivePrompt);
|
|
660
1332
|
} else {
|
|
661
1333
|
let mcpServers;
|
|
662
1334
|
if (childHttpUrl) {
|
|
@@ -670,7 +1342,6 @@ ${input.prompt}`;
|
|
|
670
1342
|
agent: input.agent,
|
|
671
1343
|
systemPrompt: enhancedSystemPrompt,
|
|
672
1344
|
model: input.model,
|
|
673
|
-
maxTurns: input.maxTurns,
|
|
674
1345
|
mcpContext: {
|
|
675
1346
|
parentSessionId: session.relaySessionId,
|
|
676
1347
|
depth: envContext.depth + 1,
|
|
@@ -681,17 +1352,30 @@ ${input.prompt}`;
|
|
|
681
1352
|
});
|
|
682
1353
|
}
|
|
683
1354
|
})();
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
stdout: result.stdout,
|
|
690
|
-
stderr: result.stderr,
|
|
691
|
-
..."_failureReason" in result ? { failureReason: result._failureReason } : {}
|
|
692
|
-
};
|
|
1355
|
+
let rawResult;
|
|
1356
|
+
try {
|
|
1357
|
+
rawResult = await executePromise;
|
|
1358
|
+
} finally {
|
|
1359
|
+
clearInterval(heartbeatTimer);
|
|
693
1360
|
}
|
|
694
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
|
+
}
|
|
695
1379
|
if (contextMonitor2) {
|
|
696
1380
|
const estimatedTokens = Math.ceil(
|
|
697
1381
|
(result.stdout.length + result.stderr.length) / 4
|
|
@@ -699,16 +1383,28 @@ ${input.prompt}`;
|
|
|
699
1383
|
contextMonitor2.updateUsage(
|
|
700
1384
|
session.relaySessionId,
|
|
701
1385
|
effectiveBackend,
|
|
702
|
-
estimatedTokens
|
|
1386
|
+
estimatedTokens,
|
|
1387
|
+
session.metadata
|
|
703
1388
|
);
|
|
1389
|
+
await sessionManager2.updateHeartbeat(session.relaySessionId).catch(() => void 0);
|
|
1390
|
+
}
|
|
1391
|
+
if (!isShortCircuit) {
|
|
1392
|
+
guard.recordSpawn(context);
|
|
704
1393
|
}
|
|
705
|
-
guard.recordSpawn(context);
|
|
706
1394
|
const status = result.exitCode === 0 ? "completed" : "error";
|
|
707
|
-
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;
|
|
708
1396
|
await sessionManager2.update(session.relaySessionId, {
|
|
709
1397
|
status,
|
|
710
|
-
...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
|
+
}
|
|
711
1406
|
});
|
|
1407
|
+
contextMonitor2?.removeSession(session.relaySessionId);
|
|
712
1408
|
const completedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
713
1409
|
const metadata = {
|
|
714
1410
|
durationMs: new Date(completedAt).getTime() - new Date(spawnStartedAt).getTime(),
|
|
@@ -719,6 +1415,103 @@ ${input.prompt}`;
|
|
|
719
1415
|
completedAt,
|
|
720
1416
|
...result.tokenUsage ? { tokenUsage: result.tokenUsage } : {}
|
|
721
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
|
+
}
|
|
722
1515
|
onProgress?.({ stage: "completed", percent: 100 });
|
|
723
1516
|
if (hooksEngine2) {
|
|
724
1517
|
try {
|
|
@@ -761,9 +1554,69 @@ ${input.prompt}`;
|
|
|
761
1554
|
...failureReason ? { failureReason } : {}
|
|
762
1555
|
};
|
|
763
1556
|
} catch (error) {
|
|
764
|
-
|
|
1557
|
+
clearInterval(heartbeatTimer);
|
|
765
1558
|
const message = error instanceof Error ? error.message : String(error);
|
|
766
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
|
+
}
|
|
767
1620
|
return {
|
|
768
1621
|
sessionId: session.relaySessionId,
|
|
769
1622
|
exitCode: 1,
|
|
@@ -773,12 +1626,21 @@ ${input.prompt}`;
|
|
|
773
1626
|
};
|
|
774
1627
|
}
|
|
775
1628
|
}
|
|
776
|
-
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;
|
|
777
1630
|
var init_spawn_agent = __esm({
|
|
778
1631
|
"src/mcp-server/tools/spawn-agent.ts"() {
|
|
779
1632
|
"use strict";
|
|
780
1633
|
init_recursion_guard();
|
|
781
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;
|
|
782
1644
|
spawnAgentInputSchema = z2.object({
|
|
783
1645
|
fallbackBackend: z2.enum(["claude", "codex", "gemini"]).optional().describe(
|
|
784
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)."
|
|
@@ -798,9 +1660,6 @@ var init_spawn_agent = __esm({
|
|
|
798
1660
|
model: z2.string().optional().describe(
|
|
799
1661
|
"Model to use (e.g., 'opus', 'sonnet', 'o3'). Passed to the backend CLI. Model availability varies by backend."
|
|
800
1662
|
),
|
|
801
|
-
maxTurns: z2.number().optional().describe(
|
|
802
|
-
"Maximum agentic turns (tool-use cycles). Limits execution depth to prevent runaway agents."
|
|
803
|
-
),
|
|
804
1663
|
skillContext: z2.object({
|
|
805
1664
|
skillPath: z2.string().describe("Path to the skill directory (e.g., '.agents/skills/software-engineer/')"),
|
|
806
1665
|
subskill: z2.string().optional().describe("Specific subskill to activate within the skill")
|
|
@@ -817,7 +1676,14 @@ var init_spawn_agent = __esm({
|
|
|
817
1676
|
taskInstructionPath: z2.string().optional().describe(
|
|
818
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."
|
|
819
1678
|
),
|
|
820
|
-
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.")
|
|
821
1687
|
});
|
|
822
1688
|
}
|
|
823
1689
|
});
|
|
@@ -914,7 +1780,7 @@ var init_conflict_detector = __esm({
|
|
|
914
1780
|
});
|
|
915
1781
|
|
|
916
1782
|
// src/mcp-server/tools/spawn-agents-parallel.ts
|
|
917
|
-
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) {
|
|
918
1784
|
const envContext = buildContextFromEnv();
|
|
919
1785
|
if (envContext.depth >= guard.getConfig().maxDepth) {
|
|
920
1786
|
const reason = `Max depth exceeded: ${envContext.depth} >= ${guard.getConfig().maxDepth}`;
|
|
@@ -970,7 +1836,11 @@ async function executeSpawnAgentsParallel(agents, registry2, sessionManager2, gu
|
|
|
970
1836
|
hooksEngine2,
|
|
971
1837
|
contextMonitor2,
|
|
972
1838
|
backendSelector,
|
|
973
|
-
childHttpUrl
|
|
1839
|
+
childHttpUrl,
|
|
1840
|
+
void 0,
|
|
1841
|
+
agentEventStore,
|
|
1842
|
+
hookMemoryDir,
|
|
1843
|
+
sessionHealthConfig
|
|
974
1844
|
).then((result) => {
|
|
975
1845
|
completedCount++;
|
|
976
1846
|
onProgress?.({
|
|
@@ -1040,17 +1910,34 @@ var init_spawn_agents_parallel = __esm({
|
|
|
1040
1910
|
|
|
1041
1911
|
// src/mcp-server/tools/list-sessions.ts
|
|
1042
1912
|
import { z as z3 } from "zod";
|
|
1043
|
-
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;
|
|
1044
1921
|
const sessions = await sessionManager2.list({
|
|
1045
|
-
backendId:
|
|
1046
|
-
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
|
|
1047
1930
|
});
|
|
1048
1931
|
return {
|
|
1049
1932
|
sessions: sessions.map((s) => ({
|
|
1050
1933
|
relaySessionId: s.relaySessionId,
|
|
1051
1934
|
backendId: s.backendId,
|
|
1052
1935
|
status: s.status,
|
|
1053
|
-
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
|
|
1054
1941
|
}))
|
|
1055
1942
|
};
|
|
1056
1943
|
}
|
|
@@ -1058,15 +1945,81 @@ var listSessionsInputSchema;
|
|
|
1058
1945
|
var init_list_sessions = __esm({
|
|
1059
1946
|
"src/mcp-server/tools/list-sessions.ts"() {
|
|
1060
1947
|
"use strict";
|
|
1948
|
+
init_logger();
|
|
1061
1949
|
listSessionsInputSchema = z3.object({
|
|
1950
|
+
backendId: z3.enum(["claude", "codex", "gemini"]).optional(),
|
|
1062
1951
|
backend: z3.enum(["claude", "codex", "gemini"]).optional(),
|
|
1063
|
-
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)
|
|
1064
1958
|
});
|
|
1065
1959
|
}
|
|
1066
1960
|
});
|
|
1067
1961
|
|
|
1068
|
-
// src/mcp-server/tools/
|
|
1962
|
+
// src/mcp-server/tools/check-session-health.ts
|
|
1069
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";
|
|
1070
2023
|
async function executeGetContextStatus(input, sessionManager2, contextMonitor2) {
|
|
1071
2024
|
const session = await sessionManager2.get(input.sessionId);
|
|
1072
2025
|
if (!session) {
|
|
@@ -1098,8 +2051,8 @@ var getContextStatusInputSchema;
|
|
|
1098
2051
|
var init_get_context_status = __esm({
|
|
1099
2052
|
"src/mcp-server/tools/get-context-status.ts"() {
|
|
1100
2053
|
"use strict";
|
|
1101
|
-
getContextStatusInputSchema =
|
|
1102
|
-
sessionId:
|
|
2054
|
+
getContextStatusInputSchema = z6.object({
|
|
2055
|
+
sessionId: z6.string()
|
|
1103
2056
|
});
|
|
1104
2057
|
}
|
|
1105
2058
|
});
|
|
@@ -1372,7 +2325,7 @@ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/
|
|
|
1372
2325
|
import { InMemoryTaskMessageQueue } from "@modelcontextprotocol/sdk/experimental/tasks/stores/in-memory.js";
|
|
1373
2326
|
import { createServer } from "http";
|
|
1374
2327
|
import { randomUUID } from "crypto";
|
|
1375
|
-
import { z as
|
|
2328
|
+
import { z as z7 } from "zod";
|
|
1376
2329
|
function createMcpServerOptions() {
|
|
1377
2330
|
const taskStore = new DeferredCleanupTaskStore();
|
|
1378
2331
|
return {
|
|
@@ -1387,10 +2340,14 @@ var init_server = __esm({
|
|
|
1387
2340
|
"src/mcp-server/server.ts"() {
|
|
1388
2341
|
"use strict";
|
|
1389
2342
|
init_deferred_cleanup_task_store();
|
|
2343
|
+
init_agent_event_store();
|
|
2344
|
+
init_session_health_monitor();
|
|
1390
2345
|
init_recursion_guard();
|
|
1391
2346
|
init_spawn_agent();
|
|
1392
2347
|
init_spawn_agents_parallel();
|
|
1393
2348
|
init_list_sessions();
|
|
2349
|
+
init_check_session_health();
|
|
2350
|
+
init_poll_agent_events();
|
|
1394
2351
|
init_get_context_status();
|
|
1395
2352
|
init_list_available_backends();
|
|
1396
2353
|
init_backend_selector();
|
|
@@ -1398,30 +2355,65 @@ var init_server = __esm({
|
|
|
1398
2355
|
init_types();
|
|
1399
2356
|
init_response_formatter();
|
|
1400
2357
|
spawnAgentsParallelInputShape = {
|
|
1401
|
-
agents:
|
|
2358
|
+
agents: z7.array(spawnAgentInputSchema).min(1).max(10).describe(
|
|
1402
2359
|
"Array of agent configurations to execute in parallel (1-10 agents)"
|
|
1403
2360
|
)
|
|
1404
2361
|
};
|
|
1405
2362
|
MAX_CHILD_HTTP_SESSIONS = 100;
|
|
1406
2363
|
RelayMCPServer = class {
|
|
1407
|
-
constructor(registry2, sessionManager2, guardConfig, hooksEngine2, contextMonitor2, inlineSummaryLength, responseOutputDir) {
|
|
2364
|
+
constructor(registry2, sessionManager2, guardConfig, hooksEngine2, contextMonitor2, inlineSummaryLength, responseOutputDir, relayConfig) {
|
|
1408
2365
|
this.registry = registry2;
|
|
1409
2366
|
this.sessionManager = sessionManager2;
|
|
1410
2367
|
this.hooksEngine = hooksEngine2;
|
|
1411
2368
|
this.contextMonitor = contextMonitor2;
|
|
1412
2369
|
this.inlineSummaryLength = inlineSummaryLength;
|
|
1413
2370
|
this.responseOutputDir = responseOutputDir;
|
|
2371
|
+
this.relayConfig = relayConfig;
|
|
1414
2372
|
this.guard = new RecursionGuard(guardConfig);
|
|
1415
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
|
+
);
|
|
1416
2402
|
this.server = new McpServer(
|
|
1417
|
-
{ name: "agentic-relay", version: "1.
|
|
2403
|
+
{ name: "agentic-relay", version: "1.2.0" },
|
|
1418
2404
|
createMcpServerOptions()
|
|
1419
2405
|
);
|
|
1420
2406
|
this.registerTools(this.server);
|
|
2407
|
+
this.sessionHealthMonitor.start();
|
|
1421
2408
|
}
|
|
1422
2409
|
server;
|
|
1423
2410
|
guard;
|
|
1424
2411
|
backendSelector;
|
|
2412
|
+
agentEventStore;
|
|
2413
|
+
sessionHealthMonitor;
|
|
2414
|
+
staleThresholdSec;
|
|
2415
|
+
maxActiveSessions;
|
|
2416
|
+
hookMemoryDir;
|
|
1425
2417
|
_childHttpServer;
|
|
1426
2418
|
_childHttpUrl;
|
|
1427
2419
|
/** URL for child agents to connect via HTTP. Available after start() in stdio mode. */
|
|
@@ -1447,7 +2439,14 @@ var init_server = __esm({
|
|
|
1447
2439
|
this.hooksEngine,
|
|
1448
2440
|
this.contextMonitor,
|
|
1449
2441
|
this.backendSelector,
|
|
1450
|
-
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
|
+
}
|
|
1451
2450
|
);
|
|
1452
2451
|
const controlOptions = {
|
|
1453
2452
|
inlineSummaryLength: this.inlineSummaryLength ?? DEFAULT_INLINE_SUMMARY_LENGTH,
|
|
@@ -1499,7 +2498,14 @@ var init_server = __esm({
|
|
|
1499
2498
|
this.hooksEngine,
|
|
1500
2499
|
this.contextMonitor,
|
|
1501
2500
|
this.backendSelector,
|
|
1502
|
-
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
|
+
}
|
|
1503
2509
|
);
|
|
1504
2510
|
const controlOptions = {
|
|
1505
2511
|
inlineSummaryLength: this.inlineSummaryLength ?? DEFAULT_INLINE_SUMMARY_LENGTH,
|
|
@@ -1592,13 +2598,12 @@ var init_server = __esm({
|
|
|
1592
2598
|
"retry_failed_agents",
|
|
1593
2599
|
"Retry only the failed agents from a previous spawn_agents_parallel call. Pass the failed results array (with originalInput) directly.",
|
|
1594
2600
|
{
|
|
1595
|
-
failedResults:
|
|
1596
|
-
index:
|
|
2601
|
+
failedResults: z7.array(z7.object({
|
|
2602
|
+
index: z7.number(),
|
|
1597
2603
|
originalInput: spawnAgentInputSchema
|
|
1598
2604
|
})).min(1).describe("Array of failed results with their original input configurations"),
|
|
1599
|
-
overrides:
|
|
1600
|
-
|
|
1601
|
-
preferredBackend: z5.enum(["claude", "codex", "gemini"]).optional()
|
|
2605
|
+
overrides: z7.object({
|
|
2606
|
+
preferredBackend: z7.enum(["claude", "codex", "gemini"]).optional()
|
|
1602
2607
|
}).optional().describe("Parameter overrides applied to all retried agents")
|
|
1603
2608
|
},
|
|
1604
2609
|
async (params) => {
|
|
@@ -1606,7 +2611,6 @@ var init_server = __esm({
|
|
|
1606
2611
|
const agents = params.failedResults.map((r) => {
|
|
1607
2612
|
const input = { ...r.originalInput };
|
|
1608
2613
|
if (params.overrides) {
|
|
1609
|
-
if (params.overrides.maxTurns !== void 0) input.maxTurns = params.overrides.maxTurns;
|
|
1610
2614
|
if (params.overrides.preferredBackend !== void 0) input.preferredBackend = params.overrides.preferredBackend;
|
|
1611
2615
|
}
|
|
1612
2616
|
return input;
|
|
@@ -1619,7 +2623,14 @@ var init_server = __esm({
|
|
|
1619
2623
|
this.hooksEngine,
|
|
1620
2624
|
this.contextMonitor,
|
|
1621
2625
|
this.backendSelector,
|
|
1622
|
-
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
|
+
}
|
|
1623
2634
|
);
|
|
1624
2635
|
const controlOptions = {
|
|
1625
2636
|
inlineSummaryLength: this.inlineSummaryLength ?? DEFAULT_INLINE_SUMMARY_LENGTH,
|
|
@@ -1643,14 +2654,93 @@ var init_server = __esm({
|
|
|
1643
2654
|
"list_sessions",
|
|
1644
2655
|
"List relay sessions, optionally filtered by backend.",
|
|
1645
2656
|
{
|
|
1646
|
-
|
|
1647
|
-
|
|
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.")
|
|
1648
2665
|
},
|
|
1649
2666
|
async (params) => {
|
|
1650
2667
|
try {
|
|
1651
2668
|
const result = await executeListSessions(
|
|
1652
|
-
{
|
|
1653
|
-
|
|
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
|
|
1654
2744
|
);
|
|
1655
2745
|
return {
|
|
1656
2746
|
content: [
|
|
@@ -1673,7 +2763,7 @@ var init_server = __esm({
|
|
|
1673
2763
|
"get_context_status",
|
|
1674
2764
|
"Get the context usage status of a relay session. Returns usage data from ContextMonitor when available, otherwise estimated values.",
|
|
1675
2765
|
{
|
|
1676
|
-
sessionId:
|
|
2766
|
+
sessionId: z7.string().describe("Relay session ID to query context usage for.")
|
|
1677
2767
|
},
|
|
1678
2768
|
async (params) => {
|
|
1679
2769
|
try {
|
|
@@ -1761,6 +2851,8 @@ var init_server = __esm({
|
|
|
1761
2851
|
await new Promise((resolve3) => {
|
|
1762
2852
|
httpServer.on("close", resolve3);
|
|
1763
2853
|
});
|
|
2854
|
+
this._httpServer = void 0;
|
|
2855
|
+
await this.close();
|
|
1764
2856
|
}
|
|
1765
2857
|
/**
|
|
1766
2858
|
* Start an HTTP server for child agents.
|
|
@@ -1789,7 +2881,7 @@ var init_server = __esm({
|
|
|
1789
2881
|
sessionIdGenerator: () => randomUUID()
|
|
1790
2882
|
});
|
|
1791
2883
|
const server = new McpServer(
|
|
1792
|
-
{ name: "agentic-relay", version: "1.
|
|
2884
|
+
{ name: "agentic-relay", version: "1.2.0" },
|
|
1793
2885
|
createMcpServerOptions()
|
|
1794
2886
|
);
|
|
1795
2887
|
this.registerTools(server);
|
|
@@ -1834,6 +2926,17 @@ var init_server = __esm({
|
|
|
1834
2926
|
});
|
|
1835
2927
|
});
|
|
1836
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
|
+
}
|
|
1837
2940
|
/** Exposed for testing and graceful shutdown */
|
|
1838
2941
|
get httpServer() {
|
|
1839
2942
|
return this._httpServer;
|
|
@@ -1845,8 +2948,8 @@ var init_server = __esm({
|
|
|
1845
2948
|
|
|
1846
2949
|
// src/bin/relay.ts
|
|
1847
2950
|
import { defineCommand as defineCommand10, runMain } from "citty";
|
|
1848
|
-
import { join as
|
|
1849
|
-
import { homedir as
|
|
2951
|
+
import { join as join11 } from "path";
|
|
2952
|
+
import { homedir as homedir7 } from "os";
|
|
1850
2953
|
|
|
1851
2954
|
// src/infrastructure/process-manager.ts
|
|
1852
2955
|
init_logger();
|
|
@@ -2006,7 +3109,7 @@ var BaseAdapter = class _BaseAdapter {
|
|
|
2006
3109
|
}
|
|
2007
3110
|
return result.stdout.trim();
|
|
2008
3111
|
}
|
|
2009
|
-
async continueSession(_nativeSessionId, _prompt
|
|
3112
|
+
async continueSession(_nativeSessionId, _prompt) {
|
|
2010
3113
|
return {
|
|
2011
3114
|
exitCode: 1,
|
|
2012
3115
|
stdout: "",
|
|
@@ -2385,7 +3488,7 @@ var ClaudeAdapter = class extends BaseAdapter {
|
|
|
2385
3488
|
if (timer !== void 0) clearTimeout(timer);
|
|
2386
3489
|
}
|
|
2387
3490
|
}
|
|
2388
|
-
async continueSession(nativeSessionId, prompt
|
|
3491
|
+
async continueSession(nativeSessionId, prompt) {
|
|
2389
3492
|
const timeoutMs = resolveClaudeSdkTimeoutMs();
|
|
2390
3493
|
const abortController = new AbortController();
|
|
2391
3494
|
const timer = timeoutMs !== void 0 ? setTimeout(() => abortController.abort(), timeoutMs) : void 0;
|
|
@@ -2395,7 +3498,6 @@ var ClaudeAdapter = class extends BaseAdapter {
|
|
|
2395
3498
|
const queryOptions = {
|
|
2396
3499
|
abortController,
|
|
2397
3500
|
resume: nativeSessionId,
|
|
2398
|
-
maxTurns: options?.maxTurns ?? 1,
|
|
2399
3501
|
cwd: process.cwd(),
|
|
2400
3502
|
strictMcpConfig: true
|
|
2401
3503
|
};
|
|
@@ -2851,7 +3953,7 @@ ${prompt}`;
|
|
|
2851
3953
|
};
|
|
2852
3954
|
}
|
|
2853
3955
|
}
|
|
2854
|
-
async continueSession(nativeSessionId, prompt
|
|
3956
|
+
async continueSession(nativeSessionId, prompt) {
|
|
2855
3957
|
try {
|
|
2856
3958
|
const { Codex } = await loadCodexSDK();
|
|
2857
3959
|
const codex = new Codex();
|
|
@@ -3188,7 +4290,8 @@ ${prompt}`;
|
|
|
3188
4290
|
};
|
|
3189
4291
|
|
|
3190
4292
|
// src/core/session-manager.ts
|
|
3191
|
-
|
|
4293
|
+
init_logger();
|
|
4294
|
+
import { readFile as readFile4, writeFile as writeFile4, readdir, mkdir as mkdir4, chmod, rename, unlink, open } from "fs/promises";
|
|
3192
4295
|
import { join as join4 } from "path";
|
|
3193
4296
|
import { homedir as homedir4 } from "os";
|
|
3194
4297
|
import { nanoid } from "nanoid";
|
|
@@ -3202,22 +4305,44 @@ function toSessionData(session) {
|
|
|
3202
4305
|
return {
|
|
3203
4306
|
...session,
|
|
3204
4307
|
createdAt: session.createdAt.toISOString(),
|
|
3205
|
-
updatedAt: session.updatedAt.toISOString()
|
|
4308
|
+
updatedAt: session.updatedAt.toISOString(),
|
|
4309
|
+
lastHeartbeatAt: session.lastHeartbeatAt.toISOString(),
|
|
4310
|
+
staleNotifiedAt: session.staleNotifiedAt?.toISOString() ?? null
|
|
3206
4311
|
};
|
|
3207
4312
|
}
|
|
3208
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;
|
|
3209
4319
|
return {
|
|
3210
4320
|
...data,
|
|
3211
|
-
createdAt
|
|
3212
|
-
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 : {}
|
|
3213
4326
|
};
|
|
3214
4327
|
}
|
|
3215
4328
|
var SessionManager = class _SessionManager {
|
|
3216
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
|
+
]);
|
|
3217
4339
|
sessionsDir;
|
|
3218
4340
|
constructor(sessionsDir) {
|
|
3219
4341
|
this.sessionsDir = sessionsDir ?? getSessionsDir(getRelayHome());
|
|
3220
4342
|
}
|
|
4343
|
+
getSessionsDir() {
|
|
4344
|
+
return this.sessionsDir;
|
|
4345
|
+
}
|
|
3221
4346
|
/** Ensure the sessions directory exists. */
|
|
3222
4347
|
async ensureDir() {
|
|
3223
4348
|
await mkdir4(this.sessionsDir, { recursive: true });
|
|
@@ -3228,28 +4353,115 @@ var SessionManager = class _SessionManager {
|
|
|
3228
4353
|
}
|
|
3229
4354
|
return join4(this.sessionsDir, `${relaySessionId}.json`);
|
|
3230
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
|
+
}
|
|
3231
4433
|
/** Create a new relay session. */
|
|
3232
4434
|
async create(params) {
|
|
3233
|
-
await this.
|
|
3234
|
-
|
|
3235
|
-
|
|
3236
|
-
|
|
3237
|
-
|
|
3238
|
-
|
|
3239
|
-
|
|
3240
|
-
|
|
3241
|
-
|
|
3242
|
-
|
|
3243
|
-
|
|
3244
|
-
|
|
3245
|
-
|
|
3246
|
-
|
|
3247
|
-
|
|
3248
|
-
|
|
3249
|
-
|
|
3250
|
-
|
|
3251
|
-
|
|
3252
|
-
|
|
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
|
+
}
|
|
3253
4465
|
}
|
|
3254
4466
|
/** Update an existing session. */
|
|
3255
4467
|
async update(relaySessionId, updates) {
|
|
@@ -3257,18 +4469,36 @@ var SessionManager = class _SessionManager {
|
|
|
3257
4469
|
if (!session) {
|
|
3258
4470
|
throw new Error(`Session not found: ${relaySessionId}`);
|
|
3259
4471
|
}
|
|
4472
|
+
if (updates.status) {
|
|
4473
|
+
this.ensureValidTransition(session.status, updates.status);
|
|
4474
|
+
}
|
|
3260
4475
|
const updated = {
|
|
3261
4476
|
...session,
|
|
3262
4477
|
...updates,
|
|
4478
|
+
metadata: updates.metadata ? this.mergeMetadata(session.metadata, updates.metadata) : session.metadata,
|
|
3263
4479
|
updatedAt: /* @__PURE__ */ new Date()
|
|
3264
4480
|
};
|
|
3265
|
-
|
|
3266
|
-
|
|
3267
|
-
|
|
3268
|
-
|
|
3269
|
-
|
|
3270
|
-
)
|
|
3271
|
-
|
|
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);
|
|
3272
4502
|
}
|
|
3273
4503
|
/** Get a session by relay session ID. */
|
|
3274
4504
|
async get(relaySessionId) {
|
|
@@ -3303,6 +4533,31 @@ var SessionManager = class _SessionManager {
|
|
|
3303
4533
|
if (filter?.backendId && session.backendId !== filter.backendId) {
|
|
3304
4534
|
continue;
|
|
3305
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
|
+
}
|
|
3306
4561
|
sessions.push(session);
|
|
3307
4562
|
} catch {
|
|
3308
4563
|
}
|
|
@@ -3315,6 +4570,41 @@ var SessionManager = class _SessionManager {
|
|
|
3315
4570
|
}
|
|
3316
4571
|
return sessions;
|
|
3317
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
|
+
}
|
|
3318
4608
|
};
|
|
3319
4609
|
|
|
3320
4610
|
// src/core/config-manager.ts
|
|
@@ -3337,7 +4627,10 @@ var hookEventSchema = z.enum([
|
|
|
3337
4627
|
"on-error",
|
|
3338
4628
|
"on-context-threshold",
|
|
3339
4629
|
"pre-spawn",
|
|
3340
|
-
"post-spawn"
|
|
4630
|
+
"post-spawn",
|
|
4631
|
+
"on-session-complete",
|
|
4632
|
+
"on-session-error",
|
|
4633
|
+
"on-session-stale"
|
|
3341
4634
|
]);
|
|
3342
4635
|
var hookDefinitionSchema = z.object({
|
|
3343
4636
|
event: hookEventSchema,
|
|
@@ -3359,7 +4652,8 @@ var hookChainSchema = z.object({
|
|
|
3359
4652
|
});
|
|
3360
4653
|
var hooksConfigSchema = z.object({
|
|
3361
4654
|
definitions: z.array(hookDefinitionSchema),
|
|
3362
|
-
chains: z.array(hookChainSchema).optional()
|
|
4655
|
+
chains: z.array(hookChainSchema).optional(),
|
|
4656
|
+
memoryDir: z.string().optional()
|
|
3363
4657
|
});
|
|
3364
4658
|
var backendContextConfigSchema = z.object({
|
|
3365
4659
|
contextWindow: z.number().positive().optional(),
|
|
@@ -3374,18 +4668,38 @@ var relayConfigSchema = z.object({
|
|
|
3374
4668
|
gemini: z.record(z.unknown()).optional()
|
|
3375
4669
|
}).optional(),
|
|
3376
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(),
|
|
3377
4679
|
contextMonitor: z.object({
|
|
3378
4680
|
enabled: z.boolean().optional(),
|
|
3379
4681
|
thresholdPercent: z.number().min(0).max(100).optional(),
|
|
3380
4682
|
notifyThreshold: z.number().positive().optional(),
|
|
3381
4683
|
notifyPercent: z.number().min(0).max(100).optional(),
|
|
3382
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(),
|
|
3383
4690
|
backends: z.object({
|
|
3384
4691
|
claude: backendContextConfigSchema,
|
|
3385
4692
|
codex: backendContextConfigSchema,
|
|
3386
4693
|
gemini: backendContextConfigSchema
|
|
3387
4694
|
}).optional()
|
|
3388
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(),
|
|
3389
4703
|
mcpServerMode: z.object({
|
|
3390
4704
|
maxDepth: z.number().int().positive(),
|
|
3391
4705
|
maxCallsPerSession: z.number().int().positive(),
|
|
@@ -3687,7 +5001,7 @@ var HooksEngine = class _HooksEngine {
|
|
|
3687
5001
|
}
|
|
3688
5002
|
/** Load hook definitions from config and register listeners on EventBus */
|
|
3689
5003
|
loadConfig(config) {
|
|
3690
|
-
this.definitions = config.definitions.filter((def) => {
|
|
5004
|
+
this.definitions = (config.definitions ?? []).filter((def) => {
|
|
3691
5005
|
if (def.enabled === false) return false;
|
|
3692
5006
|
try {
|
|
3693
5007
|
this.validateCommand(def.command);
|
|
@@ -3915,13 +5229,17 @@ var DEFAULT_BACKEND_CONTEXT = {
|
|
|
3915
5229
|
gemini: { contextWindow: 1048576, compactThreshold: 524288 }
|
|
3916
5230
|
};
|
|
3917
5231
|
var DEFAULT_NOTIFY_PERCENT = 70;
|
|
5232
|
+
var DEFAULT_CRITICAL_PERCENT = 85;
|
|
5233
|
+
var DEFAULT_EMERGENCY_PERCENT = 95;
|
|
3918
5234
|
var DEFAULT_CONFIG = {
|
|
3919
5235
|
enabled: true,
|
|
3920
|
-
notifyMethod: "hook"
|
|
5236
|
+
notifyMethod: "hook",
|
|
5237
|
+
memoryDir: "./memory"
|
|
3921
5238
|
};
|
|
3922
5239
|
var ContextMonitor = class {
|
|
3923
|
-
constructor(hooksEngine2, config) {
|
|
5240
|
+
constructor(hooksEngine2, config, agentEventStore) {
|
|
3924
5241
|
this.hooksEngine = hooksEngine2;
|
|
5242
|
+
this.agentEventStore = agentEventStore;
|
|
3925
5243
|
this.config = { ...DEFAULT_CONFIG, ...config };
|
|
3926
5244
|
if (this.config.thresholdPercent !== void 0 && this.config.notifyPercent === void 0 && this.config.notifyThreshold === void 0) {
|
|
3927
5245
|
this.config.notifyPercent = this.config.thresholdPercent;
|
|
@@ -3929,6 +5247,7 @@ var ContextMonitor = class {
|
|
|
3929
5247
|
}
|
|
3930
5248
|
config;
|
|
3931
5249
|
usageMap = /* @__PURE__ */ new Map();
|
|
5250
|
+
agentEventStore;
|
|
3932
5251
|
/** Get backend context config, merging user overrides with defaults */
|
|
3933
5252
|
getBackendConfig(backendId) {
|
|
3934
5253
|
const defaults = DEFAULT_BACKEND_CONTEXT[backendId];
|
|
@@ -3944,19 +5263,60 @@ var ContextMonitor = class {
|
|
|
3944
5263
|
return this.config.notifyThreshold;
|
|
3945
5264
|
}
|
|
3946
5265
|
const backendConfig = this.getBackendConfig(backendId);
|
|
3947
|
-
const notifyPercent = this.config.notifyPercent ?? DEFAULT_NOTIFY_PERCENT;
|
|
5266
|
+
const notifyPercent = this.config.thresholdLevels?.warning ?? this.config.notifyPercent ?? DEFAULT_NOTIFY_PERCENT;
|
|
3948
5267
|
return Math.round(backendConfig.contextWindow * notifyPercent / 100);
|
|
3949
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
|
+
}
|
|
3950
5310
|
/** Update token usage for a session and check threshold */
|
|
3951
|
-
updateUsage(sessionId, backendId, estimatedTokens) {
|
|
5311
|
+
updateUsage(sessionId, backendId, estimatedTokens, sessionMetadata) {
|
|
3952
5312
|
if (!this.config.enabled) return;
|
|
3953
5313
|
const backendConfig = this.getBackendConfig(backendId);
|
|
3954
5314
|
const contextWindow = backendConfig.contextWindow;
|
|
3955
5315
|
const usagePercent = contextWindow > 0 ? Math.round(estimatedTokens / contextWindow * 100) : 0;
|
|
3956
5316
|
const existing = this.usageMap.get(sessionId);
|
|
3957
|
-
let
|
|
5317
|
+
let notifiedLevels = existing?.notifiedLevels ?? /* @__PURE__ */ new Set();
|
|
3958
5318
|
if (existing && estimatedTokens < existing.estimatedTokens * 0.7) {
|
|
3959
|
-
|
|
5319
|
+
notifiedLevels = /* @__PURE__ */ new Set();
|
|
3960
5320
|
}
|
|
3961
5321
|
this.usageMap.set(sessionId, {
|
|
3962
5322
|
estimatedTokens,
|
|
@@ -3964,19 +5324,27 @@ var ContextMonitor = class {
|
|
|
3964
5324
|
compactThreshold: backendConfig.compactThreshold,
|
|
3965
5325
|
usagePercent,
|
|
3966
5326
|
backendId,
|
|
3967
|
-
|
|
5327
|
+
notifiedLevels,
|
|
5328
|
+
sessionMetadata: sessionMetadata ?? existing?.sessionMetadata
|
|
3968
5329
|
});
|
|
3969
|
-
const
|
|
3970
|
-
|
|
3971
|
-
|
|
3972
|
-
|
|
3973
|
-
|
|
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(
|
|
3974
5340
|
sessionId,
|
|
3975
5341
|
backendId,
|
|
5342
|
+
level,
|
|
3976
5343
|
usagePercent,
|
|
3977
5344
|
estimatedTokens,
|
|
3978
5345
|
contextWindow,
|
|
3979
|
-
backendConfig.compactThreshold
|
|
5346
|
+
backendConfig.compactThreshold,
|
|
5347
|
+
this.usageMap.get(sessionId)?.sessionMetadata
|
|
3980
5348
|
);
|
|
3981
5349
|
}
|
|
3982
5350
|
}
|
|
@@ -4002,15 +5370,93 @@ var ContextMonitor = class {
|
|
|
4002
5370
|
removeSession(sessionId) {
|
|
4003
5371
|
this.usageMap.delete(sessionId);
|
|
4004
5372
|
}
|
|
4005
|
-
|
|
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) {
|
|
4006
5439
|
const remainingBeforeCompact = Math.max(
|
|
4007
5440
|
0,
|
|
4008
5441
|
compactThreshold - currentTokens
|
|
4009
5442
|
);
|
|
4010
|
-
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.`;
|
|
4011
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
|
+
);
|
|
4012
5458
|
process.stderr.write(
|
|
4013
|
-
`[relay] Context
|
|
5459
|
+
`[relay] Context ${level}: ${warningMessage}
|
|
4014
5460
|
`
|
|
4015
5461
|
);
|
|
4016
5462
|
} else if (this.config.notifyMethod === "hook" && this.hooksEngine) {
|
|
@@ -4024,11 +5470,60 @@ var ContextMonitor = class {
|
|
|
4024
5470
|
currentTokens,
|
|
4025
5471
|
contextWindow,
|
|
4026
5472
|
compactThreshold,
|
|
4027
|
-
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"
|
|
4028
5481
|
}
|
|
4029
5482
|
};
|
|
4030
|
-
void this.hooksEngine.emit("on-context-threshold", hookInput).
|
|
4031
|
-
|
|
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
|
|
4032
5527
|
);
|
|
4033
5528
|
}
|
|
4034
5529
|
}
|
|
@@ -4624,16 +6119,17 @@ function createMCPCommand(configManager2, registry2, sessionManager2, hooksEngin
|
|
|
4624
6119
|
let guardConfig;
|
|
4625
6120
|
let inlineSummaryLength;
|
|
4626
6121
|
let responseOutputDir;
|
|
6122
|
+
let relayConfig;
|
|
4627
6123
|
try {
|
|
4628
|
-
|
|
4629
|
-
if (
|
|
6124
|
+
relayConfig = await configManager2.getConfig();
|
|
6125
|
+
if (relayConfig.mcpServerMode) {
|
|
4630
6126
|
guardConfig = {
|
|
4631
|
-
maxDepth:
|
|
4632
|
-
maxCallsPerSession:
|
|
4633
|
-
timeoutSec:
|
|
6127
|
+
maxDepth: relayConfig.mcpServerMode.maxDepth ?? 5,
|
|
6128
|
+
maxCallsPerSession: relayConfig.mcpServerMode.maxCallsPerSession ?? 20,
|
|
6129
|
+
timeoutSec: relayConfig.mcpServerMode.timeoutSec ?? 86400
|
|
4634
6130
|
};
|
|
4635
|
-
inlineSummaryLength =
|
|
4636
|
-
responseOutputDir =
|
|
6131
|
+
inlineSummaryLength = relayConfig.mcpServerMode.inlineSummaryLength;
|
|
6132
|
+
responseOutputDir = relayConfig.mcpServerMode.responseOutputDir;
|
|
4637
6133
|
}
|
|
4638
6134
|
} catch {
|
|
4639
6135
|
}
|
|
@@ -4646,7 +6142,8 @@ function createMCPCommand(configManager2, registry2, sessionManager2, hooksEngin
|
|
|
4646
6142
|
hooksEngine2,
|
|
4647
6143
|
contextMonitor2,
|
|
4648
6144
|
inlineSummaryLength,
|
|
4649
|
-
responseOutputDir
|
|
6145
|
+
responseOutputDir,
|
|
6146
|
+
relayConfig
|
|
4650
6147
|
);
|
|
4651
6148
|
await server.start({ transport, port });
|
|
4652
6149
|
}
|
|
@@ -4808,7 +6305,7 @@ function createVersionCommand(registry2) {
|
|
|
4808
6305
|
description: "Show relay and backend versions"
|
|
4809
6306
|
},
|
|
4810
6307
|
async run() {
|
|
4811
|
-
const relayVersion = "1.
|
|
6308
|
+
const relayVersion = "1.2.0";
|
|
4812
6309
|
console.log(`agentic-relay v${relayVersion}`);
|
|
4813
6310
|
console.log("");
|
|
4814
6311
|
console.log("Backends:");
|
|
@@ -4833,8 +6330,8 @@ function createVersionCommand(registry2) {
|
|
|
4833
6330
|
// src/commands/doctor.ts
|
|
4834
6331
|
import { defineCommand as defineCommand8 } from "citty";
|
|
4835
6332
|
import { access, constants, readdir as readdir2 } from "fs/promises";
|
|
4836
|
-
import { join as
|
|
4837
|
-
import { homedir as
|
|
6333
|
+
import { join as join9 } from "path";
|
|
6334
|
+
import { homedir as homedir6 } from "os";
|
|
4838
6335
|
import { execFile as execFile2 } from "child_process";
|
|
4839
6336
|
import { promisify as promisify2 } from "util";
|
|
4840
6337
|
var execFileAsync2 = promisify2(execFile2);
|
|
@@ -4894,8 +6391,8 @@ async function checkConfig(configManager2) {
|
|
|
4894
6391
|
}
|
|
4895
6392
|
}
|
|
4896
6393
|
async function checkSessionsDir() {
|
|
4897
|
-
const relayHome2 = process.env["RELAY_HOME"] ??
|
|
4898
|
-
const sessionsDir =
|
|
6394
|
+
const relayHome2 = process.env["RELAY_HOME"] ?? join9(homedir6(), ".relay");
|
|
6395
|
+
const sessionsDir = join9(relayHome2, "sessions");
|
|
4899
6396
|
try {
|
|
4900
6397
|
await access(sessionsDir, constants.W_OK);
|
|
4901
6398
|
return {
|
|
@@ -5008,8 +6505,8 @@ async function checkBackendAuthEnv() {
|
|
|
5008
6505
|
return results;
|
|
5009
6506
|
}
|
|
5010
6507
|
async function checkSessionsDiskUsage() {
|
|
5011
|
-
const relayHome2 = process.env["RELAY_HOME"] ??
|
|
5012
|
-
const sessionsDir =
|
|
6508
|
+
const relayHome2 = process.env["RELAY_HOME"] ?? join9(homedir6(), ".relay");
|
|
6509
|
+
const sessionsDir = join9(relayHome2, "sessions");
|
|
5013
6510
|
try {
|
|
5014
6511
|
const entries = await readdir2(sessionsDir);
|
|
5015
6512
|
const fileCount = entries.length;
|
|
@@ -5083,8 +6580,8 @@ function createDoctorCommand(registry2, configManager2) {
|
|
|
5083
6580
|
init_logger();
|
|
5084
6581
|
import { defineCommand as defineCommand9 } from "citty";
|
|
5085
6582
|
import { mkdir as mkdir6, writeFile as writeFile6, access as access2, readFile as readFile6 } from "fs/promises";
|
|
5086
|
-
import { join as
|
|
5087
|
-
var
|
|
6583
|
+
import { join as join10 } from "path";
|
|
6584
|
+
var DEFAULT_CONFIG4 = {
|
|
5088
6585
|
defaultBackend: "claude",
|
|
5089
6586
|
backends: {},
|
|
5090
6587
|
mcpServers: {}
|
|
@@ -5097,8 +6594,8 @@ function createInitCommand() {
|
|
|
5097
6594
|
},
|
|
5098
6595
|
async run() {
|
|
5099
6596
|
const projectDir = process.cwd();
|
|
5100
|
-
const relayDir =
|
|
5101
|
-
const configPath =
|
|
6597
|
+
const relayDir = join10(projectDir, ".relay");
|
|
6598
|
+
const configPath = join10(relayDir, "config.json");
|
|
5102
6599
|
try {
|
|
5103
6600
|
await access2(relayDir);
|
|
5104
6601
|
logger.info(
|
|
@@ -5110,11 +6607,11 @@ function createInitCommand() {
|
|
|
5110
6607
|
await mkdir6(relayDir, { recursive: true });
|
|
5111
6608
|
await writeFile6(
|
|
5112
6609
|
configPath,
|
|
5113
|
-
JSON.stringify(
|
|
6610
|
+
JSON.stringify(DEFAULT_CONFIG4, null, 2) + "\n",
|
|
5114
6611
|
"utf-8"
|
|
5115
6612
|
);
|
|
5116
6613
|
logger.success(`Created ${configPath}`);
|
|
5117
|
-
const gitignorePath =
|
|
6614
|
+
const gitignorePath = join10(projectDir, ".gitignore");
|
|
5118
6615
|
try {
|
|
5119
6616
|
const gitignoreContent = await readFile6(gitignorePath, "utf-8");
|
|
5120
6617
|
if (!gitignoreContent.includes(".relay/config.local.json")) {
|
|
@@ -5140,8 +6637,8 @@ registry.registerLazy("claude", () => new ClaudeAdapter(processManager));
|
|
|
5140
6637
|
registry.registerLazy("codex", () => new CodexAdapter(processManager));
|
|
5141
6638
|
registry.registerLazy("gemini", () => new GeminiAdapter(processManager));
|
|
5142
6639
|
var sessionManager = new SessionManager();
|
|
5143
|
-
var relayHome = process.env["RELAY_HOME"] ??
|
|
5144
|
-
var projectRelayDir =
|
|
6640
|
+
var relayHome = process.env["RELAY_HOME"] ?? join11(homedir7(), ".relay");
|
|
6641
|
+
var projectRelayDir = join11(process.cwd(), ".relay");
|
|
5145
6642
|
var configManager = new ConfigManager(relayHome, projectRelayDir);
|
|
5146
6643
|
var authManager = new AuthManager(registry);
|
|
5147
6644
|
var eventBus = new EventBus();
|
|
@@ -5152,13 +6649,16 @@ void configManager.getConfig().then((config) => {
|
|
|
5152
6649
|
hooksEngine.loadConfig(config.hooks);
|
|
5153
6650
|
}
|
|
5154
6651
|
if (config.contextMonitor) {
|
|
5155
|
-
contextMonitor = new ContextMonitor(hooksEngine,
|
|
6652
|
+
contextMonitor = new ContextMonitor(hooksEngine, {
|
|
6653
|
+
...config.contextMonitor,
|
|
6654
|
+
memoryDir: config.hooks?.memoryDir
|
|
6655
|
+
});
|
|
5156
6656
|
}
|
|
5157
6657
|
}).catch((e) => logger.debug("Config load failed:", e));
|
|
5158
6658
|
var main = defineCommand10({
|
|
5159
6659
|
meta: {
|
|
5160
6660
|
name: "relay",
|
|
5161
|
-
version: "1.
|
|
6661
|
+
version: "1.2.0",
|
|
5162
6662
|
description: "Unified CLI proxy for Claude Code, Codex CLI, and Gemini CLI"
|
|
5163
6663
|
},
|
|
5164
6664
|
subCommands: {
|