@rk0429/agentic-relay 1.1.1 → 1.3.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.
Files changed (2) hide show
  1. package/dist/relay.mjs +1557 -141
  2. package/package.json +1 -1
package/dist/relay.mjs CHANGED
@@ -46,6 +46,120 @@ var init_logger = __esm({
46
46
  }
47
47
  });
48
48
 
49
+ // src/core/metadata-validation.ts
50
+ function isPlainObject(value) {
51
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
52
+ return false;
53
+ }
54
+ const prototype = Object.getPrototypeOf(value);
55
+ return prototype === Object.prototype || prototype === null;
56
+ }
57
+ function utf8Size(value) {
58
+ return new TextEncoder().encode(value).length;
59
+ }
60
+ function validateMetadataKey(key) {
61
+ if (DANGEROUS_METADATA_KEYS.has(key)) {
62
+ throw new Error(`metadata key "${key}" is not allowed`);
63
+ }
64
+ if (key.length > MAX_METADATA_KEY_LENGTH) {
65
+ throw new Error(
66
+ `metadata key "${key}" exceeds ${MAX_METADATA_KEY_LENGTH} chars`
67
+ );
68
+ }
69
+ }
70
+ function validateMetadataValue(value, path2, depth) {
71
+ if (depth > MAX_METADATA_NESTING_DEPTH) {
72
+ throw new Error(
73
+ `metadata nesting depth exceeds ${MAX_METADATA_NESTING_DEPTH} at "${path2}"`
74
+ );
75
+ }
76
+ if (typeof value === "string") {
77
+ if (value.length > MAX_METADATA_STRING_VALUE_LENGTH) {
78
+ throw new Error(
79
+ `metadata string at "${path2}" exceeds ${MAX_METADATA_STRING_VALUE_LENGTH} chars`
80
+ );
81
+ }
82
+ return;
83
+ }
84
+ if (value === null || value === void 0 || typeof value === "number" || typeof value === "boolean") {
85
+ return;
86
+ }
87
+ if (Array.isArray(value)) {
88
+ for (let index = 0; index < value.length; index += 1) {
89
+ validateMetadataValue(value[index], `${path2}[${index}]`, depth + 1);
90
+ }
91
+ return;
92
+ }
93
+ if (typeof value === "object") {
94
+ if (!isPlainObject(value)) {
95
+ throw new Error(`metadata value at "${path2}" must be a plain object`);
96
+ }
97
+ for (const [key, nestedValue] of Object.entries(value)) {
98
+ validateMetadataKey(key);
99
+ validateMetadataValue(nestedValue, `${path2}.${key}`, depth + 1);
100
+ }
101
+ return;
102
+ }
103
+ throw new Error(`metadata value at "${path2}" has unsupported type`);
104
+ }
105
+ function validateMetadata(raw) {
106
+ if (raw === void 0 || raw === null) {
107
+ return {};
108
+ }
109
+ if (!isPlainObject(raw)) {
110
+ throw new Error("metadata must be a plain object");
111
+ }
112
+ let serialized;
113
+ try {
114
+ serialized = JSON.stringify(raw);
115
+ } catch {
116
+ throw new Error("metadata must be JSON-serializable");
117
+ }
118
+ if (utf8Size(serialized) > MAX_METADATA_SIZE_BYTES) {
119
+ throw new Error(`metadata exceeds ${MAX_METADATA_SIZE_BYTES} bytes`);
120
+ }
121
+ const entries = Object.entries(raw);
122
+ if (entries.length > MAX_METADATA_KEY_COUNT) {
123
+ throw new Error(`metadata has ${entries.length} keys, max is ${MAX_METADATA_KEY_COUNT}`);
124
+ }
125
+ for (const [key, value] of entries) {
126
+ validateMetadataKey(key);
127
+ validateMetadataValue(value, key, 1);
128
+ }
129
+ const typed = raw;
130
+ if (typed.taskId !== void 0 && typeof typed.taskId !== "string") {
131
+ throw new Error("metadata.taskId must be a string");
132
+ }
133
+ if (typed.taskId !== void 0 && typeof typed.taskId === "string" && !TASK_ID_PATTERN.test(typed.taskId)) {
134
+ throw new Error("metadata.taskId must match ^(TASK|GOAL)-\\d{3,}$");
135
+ }
136
+ if (typed.agentType !== void 0 && typeof typed.agentType !== "string") {
137
+ throw new Error("metadata.agentType must be a string");
138
+ }
139
+ if (typed.label !== void 0 && typeof typed.label !== "string") {
140
+ throw new Error("metadata.label must be a string");
141
+ }
142
+ if (typed.tags !== void 0) {
143
+ if (!Array.isArray(typed.tags) || !typed.tags.every((tag) => typeof tag === "string")) {
144
+ throw new Error("metadata.tags must be string[]");
145
+ }
146
+ }
147
+ return typed;
148
+ }
149
+ var DANGEROUS_METADATA_KEYS, TASK_ID_PATTERN, MAX_METADATA_SIZE_BYTES, MAX_METADATA_KEY_COUNT, MAX_METADATA_KEY_LENGTH, MAX_METADATA_STRING_VALUE_LENGTH, MAX_METADATA_NESTING_DEPTH;
150
+ var init_metadata_validation = __esm({
151
+ "src/core/metadata-validation.ts"() {
152
+ "use strict";
153
+ DANGEROUS_METADATA_KEYS = /* @__PURE__ */ new Set(["__proto__", "constructor", "prototype"]);
154
+ TASK_ID_PATTERN = /^(TASK|GOAL)-\d{3,}$/;
155
+ MAX_METADATA_SIZE_BYTES = 8 * 1024;
156
+ MAX_METADATA_KEY_COUNT = 20;
157
+ MAX_METADATA_KEY_LENGTH = 64;
158
+ MAX_METADATA_STRING_VALUE_LENGTH = 1024;
159
+ MAX_METADATA_NESTING_DEPTH = 3;
160
+ }
161
+ });
162
+
49
163
  // src/mcp-server/deferred-cleanup-task-store.ts
50
164
  import { isTerminal } from "@modelcontextprotocol/sdk/experimental/tasks/interfaces.js";
51
165
  import { randomBytes } from "crypto";
@@ -169,6 +283,424 @@ var init_deferred_cleanup_task_store = __esm({
169
283
  }
170
284
  });
171
285
 
286
+ // src/core/agent-event-store.ts
287
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
288
+ import { join as join6 } from "path";
289
+ import { homedir as homedir5 } from "os";
290
+ import { nanoid as nanoid2 } from "nanoid";
291
+ function getRelayHome2() {
292
+ return process.env["RELAY_HOME"] ?? join6(homedir5(), ".relay");
293
+ }
294
+ function isValidEventType(type) {
295
+ return type === "session-start" || type === "session-complete" || type === "session-error" || type === "session-stale" || type === "context-threshold";
296
+ }
297
+ var DEFAULT_CONFIG2, AgentEventStore;
298
+ var init_agent_event_store = __esm({
299
+ "src/core/agent-event-store.ts"() {
300
+ "use strict";
301
+ init_logger();
302
+ DEFAULT_CONFIG2 = {
303
+ maxEvents: 1e3,
304
+ ttlMs: 36e5,
305
+ backend: "jsonl",
306
+ sessionDir: join6(getRelayHome2(), "sessions"),
307
+ eventsFileName: "events.jsonl"
308
+ };
309
+ AgentEventStore = class {
310
+ config;
311
+ eventsFilePath;
312
+ cleanupTimer = null;
313
+ events = [];
314
+ constructor(config) {
315
+ const ttlMs = config?.ttlMs ?? (config?.ttlSec ?? 3600) * 1e3;
316
+ this.config = {
317
+ ...DEFAULT_CONFIG2,
318
+ ...config,
319
+ ttlMs
320
+ };
321
+ this.eventsFilePath = this.config.backend === "jsonl" ? join6(this.config.sessionDir, this.config.eventsFileName) : null;
322
+ if (this.eventsFilePath) {
323
+ mkdirSync(this.config.sessionDir, { recursive: true });
324
+ this.restoreFromJsonl();
325
+ }
326
+ this.cleanupTimer = setInterval(() => {
327
+ this.prune();
328
+ }, 6e4);
329
+ this.cleanupTimer.unref();
330
+ }
331
+ restoreFromJsonl() {
332
+ if (!this.eventsFilePath || !existsSync(this.eventsFilePath)) {
333
+ return;
334
+ }
335
+ let malformedCount = 0;
336
+ try {
337
+ const raw = readFileSync(this.eventsFilePath, "utf-8");
338
+ const lines = raw.split("\n").filter((line) => line.trim().length > 0);
339
+ for (const line of lines) {
340
+ try {
341
+ const parsed = JSON.parse(line);
342
+ 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) {
343
+ malformedCount += 1;
344
+ continue;
345
+ }
346
+ this.events.push({
347
+ eventId: parsed.eventId,
348
+ timestamp: parsed.timestamp,
349
+ type: parsed.type,
350
+ sessionId: parsed.sessionId,
351
+ parentSessionId: parsed.parentSessionId,
352
+ backendId: parsed.backendId,
353
+ metadata: parsed.metadata,
354
+ data: parsed.data
355
+ });
356
+ } catch {
357
+ malformedCount += 1;
358
+ }
359
+ }
360
+ this.events.sort((a, b) => {
361
+ const diff = new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime();
362
+ return diff !== 0 ? diff : 0;
363
+ });
364
+ this.prune();
365
+ } catch (error) {
366
+ logger.warn(
367
+ `Failed to restore event store from JSONL: ${error instanceof Error ? error.message : String(error)}`
368
+ );
369
+ return;
370
+ }
371
+ if (malformedCount > 0) {
372
+ logger.warn(
373
+ `Skipped ${malformedCount} malformed event line(s) while restoring ${this.eventsFilePath}`
374
+ );
375
+ }
376
+ }
377
+ record(event) {
378
+ const fullEvent = {
379
+ ...event,
380
+ eventId: `evt-${nanoid2()}`,
381
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
382
+ metadata: event.metadata ?? {},
383
+ data: event.data ?? {}
384
+ };
385
+ this.events.push(fullEvent);
386
+ if (this.eventsFilePath) {
387
+ try {
388
+ appendFileSync(
389
+ this.eventsFilePath,
390
+ `${JSON.stringify(fullEvent)}
391
+ `,
392
+ "utf-8"
393
+ );
394
+ } catch (error) {
395
+ logger.warn(
396
+ `Failed to append event JSONL: ${error instanceof Error ? error.message : String(error)}`
397
+ );
398
+ }
399
+ }
400
+ this.prune();
401
+ return fullEvent;
402
+ }
403
+ query(params) {
404
+ this.prune();
405
+ const limit = Math.max(1, Math.min(200, params.limit));
406
+ let candidates = this.events;
407
+ if (params.afterEventId) {
408
+ const index = candidates.findIndex((e) => e.eventId === params.afterEventId);
409
+ if (index < 0) {
410
+ throw new Error(
411
+ `RELAY_INVALID_CURSOR: cursor "${params.afterEventId}" was not found`
412
+ );
413
+ }
414
+ candidates = candidates.slice(index + 1);
415
+ }
416
+ if (params.types && params.types.length > 0) {
417
+ const wanted = new Set(params.types);
418
+ candidates = candidates.filter((event) => wanted.has(event.type));
419
+ }
420
+ if (params.sessionId) {
421
+ candidates = candidates.filter((event) => event.sessionId === params.sessionId);
422
+ }
423
+ if (params.parentSessionId) {
424
+ if (params.recursive) {
425
+ const descendants = /* @__PURE__ */ new Set();
426
+ const queue = [params.parentSessionId];
427
+ while (queue.length > 0) {
428
+ const parent = queue.shift();
429
+ const children = this.events.filter((event) => event.parentSessionId === parent).map((event) => event.sessionId);
430
+ for (const child of children) {
431
+ if (!descendants.has(child)) {
432
+ descendants.add(child);
433
+ queue.push(child);
434
+ }
435
+ }
436
+ }
437
+ candidates = candidates.filter((event) => descendants.has(event.sessionId));
438
+ } else {
439
+ candidates = candidates.filter(
440
+ (event) => event.parentSessionId === params.parentSessionId
441
+ );
442
+ }
443
+ }
444
+ const events = candidates.slice(0, limit);
445
+ return {
446
+ events,
447
+ hasMore: candidates.length > events.length
448
+ };
449
+ }
450
+ prune() {
451
+ const now = Date.now();
452
+ const ttlCutoff = now - this.config.ttlMs;
453
+ let nextEvents = this.events.filter((event) => {
454
+ const ts = new Date(event.timestamp).getTime();
455
+ return !Number.isNaN(ts) && ts >= ttlCutoff;
456
+ });
457
+ if (nextEvents.length > this.config.maxEvents) {
458
+ nextEvents = nextEvents.slice(nextEvents.length - this.config.maxEvents);
459
+ }
460
+ if (nextEvents.length !== this.events.length) {
461
+ this.events = nextEvents;
462
+ if (this.eventsFilePath) {
463
+ try {
464
+ const serialized = this.events.length > 0 ? `${this.events.map((event) => JSON.stringify(event)).join("\n")}
465
+ ` : "";
466
+ writeFileSync(this.eventsFilePath, serialized, "utf-8");
467
+ } catch (error) {
468
+ logger.warn(
469
+ `Failed to prune event JSONL file: ${error instanceof Error ? error.message : String(error)}`
470
+ );
471
+ }
472
+ }
473
+ }
474
+ }
475
+ cleanup() {
476
+ if (this.cleanupTimer) {
477
+ clearInterval(this.cleanupTimer);
478
+ this.cleanupTimer = null;
479
+ }
480
+ this.events = [];
481
+ }
482
+ };
483
+ }
484
+ });
485
+
486
+ // src/core/session-health-monitor.ts
487
+ var DEFAULT_CONFIG3, SessionHealthMonitor;
488
+ var init_session_health_monitor = __esm({
489
+ "src/core/session-health-monitor.ts"() {
490
+ "use strict";
491
+ init_logger();
492
+ DEFAULT_CONFIG3 = {
493
+ enabled: true,
494
+ heartbeatIntervalSec: 300,
495
+ staleThresholdSec: 300,
496
+ cleanupAfterSec: 3600,
497
+ maxActiveSessions: 20,
498
+ checkIntervalSec: 60,
499
+ memoryDir: "./memory"
500
+ };
501
+ SessionHealthMonitor = class {
502
+ constructor(config, sessionManager2, hooksEngine2, contextMonitor2, agentEventStore) {
503
+ this.sessionManager = sessionManager2;
504
+ this.hooksEngine = hooksEngine2;
505
+ this.contextMonitor = contextMonitor2;
506
+ this.agentEventStore = agentEventStore;
507
+ this.config = { ...DEFAULT_CONFIG3, ...config };
508
+ this.staleThresholdMs = this.config.staleThresholdSec * 1e3;
509
+ this.cleanupAfterMs = this.config.cleanupAfterSec * 1e3;
510
+ this.checkIntervalMs = this.config.checkIntervalSec * 1e3;
511
+ }
512
+ config;
513
+ staleThresholdMs;
514
+ cleanupAfterMs;
515
+ checkIntervalMs;
516
+ timer = null;
517
+ isChecking = false;
518
+ start() {
519
+ if (!this.config.enabled || this.timer) {
520
+ return;
521
+ }
522
+ this.timer = setInterval(() => {
523
+ void this.checkHealthLoop();
524
+ }, this.checkIntervalMs);
525
+ this.timer.unref();
526
+ }
527
+ stop() {
528
+ if (this.timer) {
529
+ clearInterval(this.timer);
530
+ this.timer = null;
531
+ }
532
+ }
533
+ async checkHealth(options) {
534
+ let sessions;
535
+ if (options?.sessionId) {
536
+ const session = await this.sessionManager.get(options.sessionId);
537
+ if (!session) {
538
+ throw new Error(`Session not found: ${options.sessionId}`);
539
+ }
540
+ sessions = [session];
541
+ } else if (options?.includeCompleted) {
542
+ sessions = await this.sessionManager.list();
543
+ } else {
544
+ sessions = await this.sessionManager.list({ status: "active" });
545
+ }
546
+ const now = Date.now();
547
+ const statuses = sessions.map(
548
+ (session) => this.buildHealthStatus(session, now)
549
+ );
550
+ const staleSessions = statuses.filter((status) => status.isStale).map((status) => status.relaySessionId);
551
+ return {
552
+ sessions: statuses,
553
+ staleSessions,
554
+ summary: {
555
+ total: statuses.length,
556
+ active: statuses.filter((s) => s.status === "active").length,
557
+ stale: staleSessions.length,
558
+ completed: statuses.filter((s) => s.status === "completed").length,
559
+ error: statuses.filter((s) => s.status === "error").length
560
+ }
561
+ };
562
+ }
563
+ isStale(session) {
564
+ if (session.status !== "active") {
565
+ return false;
566
+ }
567
+ return this.getLastActivityAtMs(session) + this.staleThresholdMs < Date.now();
568
+ }
569
+ async handleStaleSession(session) {
570
+ if (session.status !== "active") {
571
+ return;
572
+ }
573
+ if (session.staleNotifiedAt) {
574
+ return;
575
+ }
576
+ const now = /* @__PURE__ */ new Date();
577
+ const staleSinceMs = now.getTime() - this.getLastActivityAtMs(session);
578
+ const errorMessage = `Session stale: no activity for ${staleSinceMs}ms`;
579
+ const contextUsage = this.contextMonitor?.getUsage(session.relaySessionId);
580
+ await this.sessionManager.update(session.relaySessionId, {
581
+ status: "error",
582
+ errorCode: "RELAY_SESSION_STALE",
583
+ errorMessage,
584
+ staleNotifiedAt: now
585
+ });
586
+ this.agentEventStore.record({
587
+ type: "session-stale",
588
+ sessionId: session.relaySessionId,
589
+ parentSessionId: session.parentSessionId ?? void 0,
590
+ backendId: session.backendId,
591
+ metadata: session.metadata,
592
+ data: {
593
+ lastHeartbeatAt: session.lastHeartbeatAt.toISOString(),
594
+ staleSinceMs,
595
+ taskId: session.metadata.taskId,
596
+ label: session.metadata.label,
597
+ agentType: session.metadata.agentType,
598
+ contextUsage: contextUsage ? {
599
+ usagePercent: contextUsage.usagePercent,
600
+ estimatedTokens: contextUsage.estimatedTokens,
601
+ contextWindow: contextUsage.contextWindow
602
+ } : void 0
603
+ }
604
+ });
605
+ if (this.hooksEngine) {
606
+ await this.hooksEngine.emit("on-session-stale", {
607
+ event: "on-session-stale",
608
+ sessionId: session.relaySessionId,
609
+ backendId: session.backendId,
610
+ timestamp: now.toISOString(),
611
+ data: {
612
+ lastHeartbeatAt: session.lastHeartbeatAt.toISOString(),
613
+ staleSinceMs,
614
+ taskId: session.metadata.taskId,
615
+ label: session.metadata.label,
616
+ agentType: session.metadata.agentType,
617
+ contextUsage: contextUsage ? {
618
+ usagePercent: contextUsage.usagePercent,
619
+ estimatedTokens: contextUsage.estimatedTokens
620
+ } : void 0,
621
+ memoryDir: this.config.memoryDir ?? "./memory"
622
+ }
623
+ });
624
+ }
625
+ }
626
+ async cleanupOldSessions() {
627
+ const { deletedSessionIds } = await this.sessionManager.cleanup(
628
+ this.cleanupAfterMs
629
+ );
630
+ if (this.contextMonitor) {
631
+ for (const sessionId of deletedSessionIds) {
632
+ this.contextMonitor.removeSession(sessionId);
633
+ }
634
+ }
635
+ }
636
+ async checkHealthLoop() {
637
+ if (this.isChecking) return;
638
+ this.isChecking = true;
639
+ try {
640
+ const health = await this.checkHealth({ includeCompleted: false });
641
+ for (const staleId of health.staleSessions) {
642
+ const session = await this.sessionManager.get(staleId);
643
+ if (!session || !this.isStale(session)) {
644
+ continue;
645
+ }
646
+ await this.handleStaleSession(session);
647
+ }
648
+ await this.cleanupOldSessions();
649
+ const activeHealthyCount = health.sessions.filter(
650
+ (session) => session.status === "active" && !session.isStale
651
+ ).length;
652
+ const warnThreshold = Math.ceil(this.config.maxActiveSessions * 0.8);
653
+ if (activeHealthyCount >= warnThreshold) {
654
+ logger.warn(
655
+ `Active session usage high: ${activeHealthyCount}/${this.config.maxActiveSessions}`
656
+ );
657
+ }
658
+ } catch (error) {
659
+ logger.warn(
660
+ `Session health check failed: ${error instanceof Error ? error.message : String(error)}`
661
+ );
662
+ } finally {
663
+ this.isChecking = false;
664
+ }
665
+ }
666
+ buildHealthStatus(session, now) {
667
+ const lastActivityAtMs = this.getLastActivityAtMs(session);
668
+ const isStale = session.status === "active" && lastActivityAtMs + this.staleThresholdMs < now;
669
+ const issues = [];
670
+ if (isStale) {
671
+ issues.push("stale");
672
+ }
673
+ const usage = this.contextMonitor?.getUsage(session.relaySessionId);
674
+ if (usage && usage.usagePercent >= 95) {
675
+ issues.push("high_context_usage");
676
+ }
677
+ return {
678
+ relaySessionId: session.relaySessionId,
679
+ status: session.status,
680
+ backendId: session.backendId,
681
+ healthy: issues.length === 0,
682
+ issues,
683
+ staleSince: isStale ? new Date(lastActivityAtMs + this.staleThresholdMs).toISOString() : void 0,
684
+ isStale,
685
+ lastHeartbeatAt: session.lastHeartbeatAt.toISOString(),
686
+ metadata: session.metadata,
687
+ contextUsage: usage ? {
688
+ usagePercent: usage.usagePercent,
689
+ estimatedTokens: usage.estimatedTokens,
690
+ contextWindow: usage.contextWindow
691
+ } : void 0
692
+ };
693
+ }
694
+ getLastActivityAtMs(session) {
695
+ return Math.max(
696
+ session.lastHeartbeatAt.getTime(),
697
+ session.updatedAt.getTime()
698
+ );
699
+ }
700
+ };
701
+ }
702
+ });
703
+
172
704
  // src/mcp-server/recursion-guard.ts
173
705
  import { createHash } from "crypto";
174
706
  var RecursionGuard;
@@ -288,9 +820,9 @@ var init_recursion_guard = __esm({
288
820
 
289
821
  // src/mcp-server/tools/spawn-agent.ts
290
822
  import { z as z2 } from "zod";
291
- import { nanoid as nanoid2 } from "nanoid";
292
- import { existsSync, readFileSync } from "fs";
293
- import { join as join6, normalize, resolve, sep } from "path";
823
+ import { nanoid as nanoid3 } from "nanoid";
824
+ import { existsSync as existsSync2, readFileSync as readFileSync2 } from "fs";
825
+ import { join as join7, normalize, resolve, sep } from "path";
294
826
  function buildContextInjection(metadata) {
295
827
  const parts = [];
296
828
  if (metadata.stateContent && typeof metadata.stateContent === "string") {
@@ -309,9 +841,9 @@ ${formatted}
309
841
  }
310
842
  function readPreviousState(dailynoteDir) {
311
843
  try {
312
- const statePath = join6(dailynoteDir, "_state.md");
313
- if (existsSync(statePath)) {
314
- return readFileSync(statePath, "utf-8");
844
+ const statePath = join7(dailynoteDir, "_state.md");
845
+ if (existsSync2(statePath)) {
846
+ return readFileSync2(statePath, "utf-8");
315
847
  }
316
848
  return null;
317
849
  } catch (error) {
@@ -336,11 +868,11 @@ function validatePathWithinProject(filePath, projectRoot) {
336
868
  function readAgentDefinition(definitionPath, projectRoot = process.cwd()) {
337
869
  try {
338
870
  const safeDefinitionPath = validatePathWithinProject(definitionPath, projectRoot);
339
- if (!existsSync(safeDefinitionPath)) {
871
+ if (!existsSync2(safeDefinitionPath)) {
340
872
  logger.warn(`Agent definition file not found at ${safeDefinitionPath}`);
341
873
  return null;
342
874
  }
343
- const content = readFileSync(safeDefinitionPath, "utf-8");
875
+ const content = readFileSync2(safeDefinitionPath, "utf-8");
344
876
  if (content.trim().length === 0) {
345
877
  logger.warn(`Agent definition file is empty at ${safeDefinitionPath}`);
346
878
  return null;
@@ -356,25 +888,25 @@ function readAgentDefinition(definitionPath, projectRoot = process.cwd()) {
356
888
  function readSkillContext(skillContext, projectRoot = process.cwd()) {
357
889
  try {
358
890
  const safeSkillPath = validatePathWithinProject(skillContext.skillPath, projectRoot);
359
- const skillMdPath = validatePathWithinProject(join6(safeSkillPath, "SKILL.md"), projectRoot);
360
- if (!existsSync(skillMdPath)) {
891
+ const skillMdPath = validatePathWithinProject(join7(safeSkillPath, "SKILL.md"), projectRoot);
892
+ if (!existsSync2(skillMdPath)) {
361
893
  logger.warn(
362
894
  `SKILL.md not found at ${skillMdPath}`
363
895
  );
364
896
  return null;
365
897
  }
366
898
  const parts = [];
367
- const skillContent = readFileSync(skillMdPath, "utf-8");
899
+ const skillContent = readFileSync2(skillMdPath, "utf-8");
368
900
  parts.push(skillContent);
369
901
  if (skillContext.subskill) {
370
- const subskillPath = validatePathWithinProject(join6(
902
+ const subskillPath = validatePathWithinProject(join7(
371
903
  safeSkillPath,
372
904
  "subskills",
373
905
  skillContext.subskill,
374
906
  "SUBSKILL.md"
375
907
  ), projectRoot);
376
- if (existsSync(subskillPath)) {
377
- const subskillContent = readFileSync(subskillPath, "utf-8");
908
+ if (existsSync2(subskillPath)) {
909
+ const subskillContent = readFileSync2(subskillPath, "utf-8");
378
910
  parts.push(subskillContent);
379
911
  } else {
380
912
  logger.warn(
@@ -393,11 +925,11 @@ function readSkillContext(skillContext, projectRoot = process.cwd()) {
393
925
  function readProjectMcpJson(cwd) {
394
926
  try {
395
927
  const dir = cwd ?? process.cwd();
396
- const mcpJsonPath = join6(dir, ".mcp.json");
397
- if (!existsSync(mcpJsonPath)) {
928
+ const mcpJsonPath = join7(dir, ".mcp.json");
929
+ if (!existsSync2(mcpJsonPath)) {
398
930
  return {};
399
931
  }
400
- const raw = readFileSync(mcpJsonPath, "utf-8");
932
+ const raw = readFileSync2(mcpJsonPath, "utf-8");
401
933
  const parsed = JSON.parse(raw);
402
934
  const servers = parsed.mcpServers;
403
935
  if (!servers || typeof servers !== "object") {
@@ -438,6 +970,17 @@ function buildChildMcpServers(parentMcpServers, childHttpUrl) {
438
970
  }
439
971
  return result;
440
972
  }
973
+ function resolveValidatedSessionMetadata(input) {
974
+ const validated = validateMetadata(input.metadata);
975
+ const mergedMetadata = { ...validated };
976
+ if (mergedMetadata.agentType === void 0 && input.agent !== void 0) {
977
+ mergedMetadata.agentType = input.agent;
978
+ }
979
+ if (mergedMetadata.label === void 0 && input.label !== void 0) {
980
+ mergedMetadata.label = input.label;
981
+ }
982
+ return validateMetadata(mergedMetadata);
983
+ }
441
984
  function inferFailureReason(stderr, stdout, sdkErrorMetadata) {
442
985
  if (sdkErrorMetadata) {
443
986
  if (sdkErrorMetadata.subtype === "error_max_turns") return "max_turns_exhausted";
@@ -449,13 +992,42 @@ function inferFailureReason(stderr, stdout, sdkErrorMetadata) {
449
992
  if (combined.includes("429") || combined.includes("capacity_exhausted") || combined.includes("model_capacity_exhausted") || combined.includes("ratelimitexceeded") || combined.includes("resource_exhausted")) return "rate_limit";
450
993
  return "adapter_error";
451
994
  }
995
+ function failureReasonToErrorCode(reason) {
996
+ switch (reason) {
997
+ case "recursion_blocked":
998
+ return "RELAY_RECURSION_BLOCKED";
999
+ case "metadata_validation":
1000
+ return "RELAY_METADATA_VALIDATION";
1001
+ case "backend_unavailable":
1002
+ return "RELAY_BACKEND_UNAVAILABLE";
1003
+ case "instruction_file_error":
1004
+ return "RELAY_INSTRUCTION_FILE_ERROR";
1005
+ case "session_continuation_unsupported":
1006
+ return "RELAY_SESSION_CONTINUATION_UNSUPPORTED";
1007
+ case "session_not_found":
1008
+ return "RELAY_SESSION_NOT_FOUND";
1009
+ case "timeout":
1010
+ return "RELAY_TIMEOUT";
1011
+ case "max_turns_exhausted":
1012
+ return "RELAY_MAX_TURNS_EXHAUSTED";
1013
+ case "rate_limit":
1014
+ return "RELAY_RATE_LIMIT";
1015
+ case "adapter_error":
1016
+ return "RELAY_ADAPTER_ERROR";
1017
+ case "unknown":
1018
+ return "RELAY_UNKNOWN_ERROR";
1019
+ }
1020
+ }
1021
+ function isShortCircuitSpawnResult(value) {
1022
+ return "_noSession" in value;
1023
+ }
452
1024
  function buildContextFromEnv() {
453
- const traceId = process.env["RELAY_TRACE_ID"] ?? `trace-${nanoid2()}`;
1025
+ const traceId = process.env["RELAY_TRACE_ID"] ?? `trace-${nanoid3()}`;
454
1026
  const parentSessionId = process.env["RELAY_PARENT_SESSION_ID"] ?? null;
455
1027
  const depth = Number(process.env["RELAY_DEPTH"] ?? "0");
456
1028
  return { traceId, parentSessionId, depth };
457
1029
  }
458
- async function executeSpawnAgent(input, registry2, sessionManager2, guard, hooksEngine2, contextMonitor2, backendSelector, childHttpUrl, onProgress) {
1030
+ async function executeSpawnAgent(input, registry2, sessionManager2, guard, hooksEngine2, contextMonitor2, backendSelector, childHttpUrl, onProgress, agentEventStore, hookMemoryDir = "./memory") {
459
1031
  onProgress?.({ stage: "initializing", percent: 0 });
460
1032
  let effectiveBackend;
461
1033
  let selectionReason;
@@ -509,16 +1081,55 @@ async function executeSpawnAgent(input, registry2, sessionManager2, guard, hooks
509
1081
  };
510
1082
  }
511
1083
  const spawnStartedAt = (/* @__PURE__ */ new Date()).toISOString();
512
- const session = await sessionManager2.create({
1084
+ let validatedMetadata;
1085
+ try {
1086
+ validatedMetadata = resolveValidatedSessionMetadata(input);
1087
+ } catch (error) {
1088
+ const message = error instanceof Error ? error.message : String(error);
1089
+ return {
1090
+ sessionId: "",
1091
+ exitCode: 1,
1092
+ stdout: "",
1093
+ stderr: `RELAY_METADATA_VALIDATION: ${message}`,
1094
+ failureReason: "metadata_validation"
1095
+ };
1096
+ }
1097
+ let session;
1098
+ try {
1099
+ session = await sessionManager2.create({
1100
+ backendId: effectiveBackend,
1101
+ parentSessionId: envContext.parentSessionId ?? void 0,
1102
+ depth: envContext.depth + 1,
1103
+ metadata: validatedMetadata
1104
+ });
1105
+ } catch (error) {
1106
+ const message = error instanceof Error ? error.message : String(error);
1107
+ return {
1108
+ sessionId: "",
1109
+ exitCode: 1,
1110
+ stdout: "",
1111
+ stderr: message,
1112
+ failureReason: "unknown"
1113
+ };
1114
+ }
1115
+ agentEventStore?.record({
1116
+ type: "session-start",
1117
+ sessionId: session.relaySessionId,
1118
+ parentSessionId: session.parentSessionId ?? void 0,
513
1119
  backendId: effectiveBackend,
514
- parentSessionId: envContext.parentSessionId ?? void 0,
515
- depth: envContext.depth + 1
1120
+ metadata: session.metadata,
1121
+ data: {
1122
+ taskId: session.metadata.taskId,
1123
+ label: session.metadata.label,
1124
+ agentType: session.metadata.agentType,
1125
+ selectedBackend: effectiveBackend
1126
+ }
516
1127
  });
517
1128
  let collectedMetadata = {};
518
1129
  if (hooksEngine2 && !input.resumeSessionId) {
519
1130
  try {
520
1131
  const cwd = process.cwd();
521
- const dailynoteDir = join6(cwd, "daily_note");
1132
+ const dailynoteDir = join7(cwd, "daily_note");
522
1133
  const hookInput = {
523
1134
  schemaVersion: "1.0",
524
1135
  event: "session-init",
@@ -528,7 +1139,7 @@ async function executeSpawnAgent(input, registry2, sessionManager2, guard, hooks
528
1139
  data: {
529
1140
  workingDirectory: cwd,
530
1141
  dailynoteDir,
531
- isFirstSession: !existsSync(join6(dailynoteDir, "_state.md")),
1142
+ isFirstSession: !existsSync2(join7(dailynoteDir, "_state.md")),
532
1143
  previousState: readPreviousState(dailynoteDir)
533
1144
  }
534
1145
  };
@@ -592,7 +1203,7 @@ ${wrapped}` : wrapped;
592
1203
  try {
593
1204
  const projectRoot = process.cwd();
594
1205
  const safePath = validatePathWithinProject(input.taskInstructionPath, projectRoot);
595
- if (!existsSync(safePath)) {
1206
+ if (!existsSync2(safePath)) {
596
1207
  return {
597
1208
  sessionId: "",
598
1209
  exitCode: 1,
@@ -601,7 +1212,7 @@ ${wrapped}` : wrapped;
601
1212
  failureReason: "instruction_file_error"
602
1213
  };
603
1214
  }
604
- const instructionContent = readFileSync(safePath, "utf-8");
1215
+ const instructionContent = readFileSync2(safePath, "utf-8");
605
1216
  effectivePrompt = `${instructionContent}
606
1217
 
607
1218
  ${input.prompt}`;
@@ -617,6 +1228,14 @@ ${input.prompt}`;
617
1228
  }
618
1229
  }
619
1230
  onProgress?.({ stage: "spawning", percent: 10 });
1231
+ const heartbeatTimer = setInterval(() => {
1232
+ void sessionManager2.updateHeartbeat(session.relaySessionId).catch((heartbeatError) => {
1233
+ logger.warn(
1234
+ `Internal heartbeat update failed: ${heartbeatError instanceof Error ? heartbeatError.message : String(heartbeatError)}`
1235
+ );
1236
+ });
1237
+ }, 3e4);
1238
+ heartbeatTimer.unref();
620
1239
  try {
621
1240
  const executePromise = (async () => {
622
1241
  if (input.resumeSessionId) {
@@ -678,17 +1297,30 @@ ${input.prompt}`;
678
1297
  });
679
1298
  }
680
1299
  })();
681
- const result = await executePromise;
682
- if (result && "_noSession" in result) {
683
- return {
684
- sessionId: session.relaySessionId,
685
- exitCode: result.exitCode,
686
- stdout: result.stdout,
687
- stderr: result.stderr,
688
- ..."_failureReason" in result ? { failureReason: result._failureReason } : {}
689
- };
1300
+ let rawResult;
1301
+ try {
1302
+ rawResult = await executePromise;
1303
+ } finally {
1304
+ clearInterval(heartbeatTimer);
690
1305
  }
691
1306
  onProgress?.({ stage: "executing", percent: 50 });
1307
+ let isShortCircuit = false;
1308
+ let failureReasonFromShortCircuit;
1309
+ let result;
1310
+ if (isShortCircuitSpawnResult(rawResult)) {
1311
+ isShortCircuit = true;
1312
+ failureReasonFromShortCircuit = rawResult._failureReason;
1313
+ result = {
1314
+ exitCode: rawResult.exitCode,
1315
+ stdout: rawResult.stdout,
1316
+ stderr: rawResult.stderr,
1317
+ nativeSessionId: void 0,
1318
+ tokenUsage: void 0,
1319
+ sdkErrorMetadata: void 0
1320
+ };
1321
+ } else {
1322
+ result = rawResult;
1323
+ }
692
1324
  if (contextMonitor2) {
693
1325
  const estimatedTokens = Math.ceil(
694
1326
  (result.stdout.length + result.stderr.length) / 4
@@ -696,16 +1328,28 @@ ${input.prompt}`;
696
1328
  contextMonitor2.updateUsage(
697
1329
  session.relaySessionId,
698
1330
  effectiveBackend,
699
- estimatedTokens
1331
+ estimatedTokens,
1332
+ session.metadata
700
1333
  );
1334
+ await sessionManager2.updateHeartbeat(session.relaySessionId).catch(() => void 0);
1335
+ }
1336
+ if (!isShortCircuit) {
1337
+ guard.recordSpawn(context);
701
1338
  }
702
- guard.recordSpawn(context);
703
1339
  const status = result.exitCode === 0 ? "completed" : "error";
704
- const failureReason = result.exitCode !== 0 ? inferFailureReason(result.stderr, result.stdout, result.sdkErrorMetadata) : void 0;
1340
+ const failureReason = result.exitCode !== 0 ? failureReasonFromShortCircuit ?? inferFailureReason(result.stderr, result.stdout, result.sdkErrorMetadata) : void 0;
705
1341
  await sessionManager2.update(session.relaySessionId, {
706
1342
  status,
707
- ...result.nativeSessionId ? { nativeSessionId: result.nativeSessionId } : {}
1343
+ ...result.nativeSessionId ? { nativeSessionId: result.nativeSessionId } : {},
1344
+ ...status === "error" ? {
1345
+ errorMessage: result.stderr.slice(0, 500),
1346
+ errorCode: failureReason ? failureReasonToErrorCode(failureReason) : "RELAY_UNKNOWN_ERROR"
1347
+ } : {
1348
+ errorMessage: void 0,
1349
+ errorCode: void 0
1350
+ }
708
1351
  });
1352
+ contextMonitor2?.removeSession(session.relaySessionId);
709
1353
  const completedAt = (/* @__PURE__ */ new Date()).toISOString();
710
1354
  const metadata = {
711
1355
  durationMs: new Date(completedAt).getTime() - new Date(spawnStartedAt).getTime(),
@@ -716,6 +1360,103 @@ ${input.prompt}`;
716
1360
  completedAt,
717
1361
  ...result.tokenUsage ? { tokenUsage: result.tokenUsage } : {}
718
1362
  };
1363
+ if (status === "completed") {
1364
+ agentEventStore?.record({
1365
+ type: "session-complete",
1366
+ sessionId: session.relaySessionId,
1367
+ parentSessionId: session.parentSessionId ?? void 0,
1368
+ backendId: effectiveBackend,
1369
+ metadata: session.metadata,
1370
+ data: {
1371
+ exitCode: result.exitCode,
1372
+ durationMs: metadata.durationMs,
1373
+ taskId: session.metadata.taskId,
1374
+ label: session.metadata.label,
1375
+ agentType: session.metadata.agentType,
1376
+ nativeSessionId: result.nativeSessionId,
1377
+ selectedBackend: effectiveBackend,
1378
+ tokenUsage: result.tokenUsage
1379
+ }
1380
+ });
1381
+ if (hooksEngine2) {
1382
+ try {
1383
+ await hooksEngine2.emit("on-session-complete", {
1384
+ event: "on-session-complete",
1385
+ sessionId: session.relaySessionId,
1386
+ backendId: effectiveBackend,
1387
+ timestamp: completedAt,
1388
+ data: {
1389
+ exitCode: result.exitCode,
1390
+ durationMs: metadata.durationMs,
1391
+ taskId: session.metadata.taskId,
1392
+ label: session.metadata.label,
1393
+ agentType: session.metadata.agentType,
1394
+ nativeSessionId: result.nativeSessionId,
1395
+ selectedBackend: effectiveBackend,
1396
+ tokenUsage: result.tokenUsage,
1397
+ memoryDir: hookMemoryDir
1398
+ }
1399
+ });
1400
+ } catch (hookError) {
1401
+ logger.debug(
1402
+ `on-session-complete hook error: ${hookError instanceof Error ? hookError.message : String(hookError)}`
1403
+ );
1404
+ }
1405
+ }
1406
+ } else if (failureReason) {
1407
+ const retryableReasons = [
1408
+ "timeout",
1409
+ "rate_limit",
1410
+ "adapter_error",
1411
+ "unknown"
1412
+ ];
1413
+ const isRetryable = retryableReasons.includes(failureReason);
1414
+ const errorMessage = result.stderr.slice(0, 500);
1415
+ agentEventStore?.record({
1416
+ type: "session-error",
1417
+ sessionId: session.relaySessionId,
1418
+ parentSessionId: session.parentSessionId ?? void 0,
1419
+ backendId: effectiveBackend,
1420
+ metadata: session.metadata,
1421
+ data: {
1422
+ exitCode: result.exitCode,
1423
+ failureReason,
1424
+ errorMessage,
1425
+ durationMs: metadata.durationMs,
1426
+ taskId: session.metadata.taskId,
1427
+ label: session.metadata.label,
1428
+ agentType: session.metadata.agentType,
1429
+ selectedBackend: effectiveBackend,
1430
+ isRetryable
1431
+ }
1432
+ });
1433
+ if (hooksEngine2) {
1434
+ try {
1435
+ await hooksEngine2.emit("on-session-error", {
1436
+ event: "on-session-error",
1437
+ sessionId: session.relaySessionId,
1438
+ backendId: effectiveBackend,
1439
+ timestamp: completedAt,
1440
+ data: {
1441
+ exitCode: result.exitCode,
1442
+ failureReason,
1443
+ errorMessage,
1444
+ durationMs: metadata.durationMs,
1445
+ taskId: session.metadata.taskId,
1446
+ label: session.metadata.label,
1447
+ agentType: session.metadata.agentType,
1448
+ selectedBackend: effectiveBackend,
1449
+ isRetryable,
1450
+ memoryDir: hookMemoryDir
1451
+ }
1452
+ });
1453
+ } catch (hookError) {
1454
+ logger.debug(
1455
+ `on-session-error hook error: ${hookError instanceof Error ? hookError.message : String(hookError)}`
1456
+ );
1457
+ }
1458
+ }
1459
+ }
719
1460
  onProgress?.({ stage: "completed", percent: 100 });
720
1461
  if (hooksEngine2) {
721
1462
  try {
@@ -758,9 +1499,69 @@ ${input.prompt}`;
758
1499
  ...failureReason ? { failureReason } : {}
759
1500
  };
760
1501
  } catch (error) {
761
- await sessionManager2.update(session.relaySessionId, { status: "error" });
1502
+ clearInterval(heartbeatTimer);
762
1503
  const message = error instanceof Error ? error.message : String(error);
763
1504
  const catchFailureReason = message.toLowerCase().includes("timed out") || message.toLowerCase().includes("timeout") ? "timeout" : "unknown";
1505
+ await sessionManager2.update(session.relaySessionId, {
1506
+ status: "error",
1507
+ errorMessage: message.slice(0, 500),
1508
+ errorCode: failureReasonToErrorCode(catchFailureReason)
1509
+ });
1510
+ contextMonitor2?.removeSession(session.relaySessionId);
1511
+ const completedAt = (/* @__PURE__ */ new Date()).toISOString();
1512
+ const durationMs = new Date(completedAt).getTime() - new Date(spawnStartedAt).getTime();
1513
+ const errorMessage = message.slice(0, 500);
1514
+ const retryableReasons = [
1515
+ "timeout",
1516
+ "rate_limit",
1517
+ "adapter_error",
1518
+ "unknown"
1519
+ ];
1520
+ const isRetryable = retryableReasons.includes(catchFailureReason);
1521
+ agentEventStore?.record({
1522
+ type: "session-error",
1523
+ sessionId: session.relaySessionId,
1524
+ parentSessionId: session.parentSessionId ?? void 0,
1525
+ backendId: effectiveBackend,
1526
+ metadata: session.metadata,
1527
+ data: {
1528
+ exitCode: 1,
1529
+ failureReason: catchFailureReason,
1530
+ errorMessage,
1531
+ durationMs,
1532
+ taskId: session.metadata.taskId,
1533
+ label: session.metadata.label,
1534
+ agentType: session.metadata.agentType,
1535
+ selectedBackend: effectiveBackend,
1536
+ isRetryable
1537
+ }
1538
+ });
1539
+ if (hooksEngine2) {
1540
+ try {
1541
+ await hooksEngine2.emit("on-session-error", {
1542
+ event: "on-session-error",
1543
+ sessionId: session.relaySessionId,
1544
+ backendId: effectiveBackend,
1545
+ timestamp: completedAt,
1546
+ data: {
1547
+ exitCode: 1,
1548
+ failureReason: catchFailureReason,
1549
+ errorMessage,
1550
+ durationMs,
1551
+ taskId: session.metadata.taskId,
1552
+ label: session.metadata.label,
1553
+ agentType: session.metadata.agentType,
1554
+ selectedBackend: effectiveBackend,
1555
+ isRetryable,
1556
+ memoryDir: hookMemoryDir
1557
+ }
1558
+ });
1559
+ } catch (hookError) {
1560
+ logger.debug(
1561
+ `on-session-error hook error: ${hookError instanceof Error ? hookError.message : String(hookError)}`
1562
+ );
1563
+ }
1564
+ }
764
1565
  return {
765
1566
  sessionId: session.relaySessionId,
766
1567
  exitCode: 1,
@@ -776,6 +1577,7 @@ var init_spawn_agent = __esm({
776
1577
  "use strict";
777
1578
  init_recursion_guard();
778
1579
  init_logger();
1580
+ init_metadata_validation();
779
1581
  spawnAgentInputSchema = z2.object({
780
1582
  fallbackBackend: z2.enum(["claude", "codex", "gemini"]).optional().describe(
781
1583
  "Optional fallback backend. Used only when BackendSelector is not active or cannot determine a backend. When BackendSelector is active, backend is auto-selected by priority: preferredBackend > agentType mapping > taskType mapping > default (claude)."
@@ -811,7 +1613,14 @@ var init_spawn_agent = __esm({
811
1613
  taskInstructionPath: z2.string().optional().describe(
812
1614
  "Path to a file containing task instructions. Content is prepended to the prompt. Path is resolved relative to the project root and validated against path traversal."
813
1615
  ),
814
- label: z2.string().optional().describe("Human-readable label for identifying this agent in parallel results and logs.")
1616
+ label: z2.string().optional().describe("Human-readable label for identifying this agent in parallel results and logs."),
1617
+ metadata: z2.object({
1618
+ taskId: z2.string().regex(TASK_ID_PATTERN).optional(),
1619
+ agentType: z2.string().optional(),
1620
+ label: z2.string().optional(),
1621
+ parentTaskId: z2.string().optional(),
1622
+ tags: z2.array(z2.string()).optional()
1623
+ }).catchall(z2.unknown()).optional().describe("Session metadata for task linkage and orchestration context.")
815
1624
  });
816
1625
  }
817
1626
  });
@@ -908,7 +1717,31 @@ var init_conflict_detector = __esm({
908
1717
  });
909
1718
 
910
1719
  // src/mcp-server/tools/spawn-agents-parallel.ts
911
- async function executeSpawnAgentsParallel(agents, registry2, sessionManager2, guard, hooksEngine2, contextMonitor2, backendSelector, childHttpUrl, onProgress) {
1720
+ async function executeSpawnAgentsParallel(agents, registry2, sessionManager2, guard, hooksEngine2, contextMonitor2, backendSelector, childHttpUrl, onProgress, agentEventStore, hookMemoryDir = "./memory") {
1721
+ for (let index = 0; index < agents.length; index += 1) {
1722
+ try {
1723
+ resolveValidatedSessionMetadata(agents[index]);
1724
+ } catch (error) {
1725
+ const message = error instanceof Error ? error.message : String(error);
1726
+ const reason = `RELAY_METADATA_VALIDATION: agent index ${index}: ${message}`;
1727
+ return {
1728
+ results: agents.map((agent, resultIndex) => ({
1729
+ index: resultIndex,
1730
+ sessionId: "",
1731
+ exitCode: 1,
1732
+ stdout: "",
1733
+ stderr: reason,
1734
+ error: reason,
1735
+ failureReason: "metadata_validation",
1736
+ ...agent.label ? { label: agent.label } : {},
1737
+ originalInput: agent
1738
+ })),
1739
+ totalCount: agents.length,
1740
+ successCount: 0,
1741
+ failureCount: agents.length
1742
+ };
1743
+ }
1744
+ }
912
1745
  const envContext = buildContextFromEnv();
913
1746
  if (envContext.depth >= guard.getConfig().maxDepth) {
914
1747
  const reason = `Max depth exceeded: ${envContext.depth} >= ${guard.getConfig().maxDepth}`;
@@ -964,7 +1797,10 @@ async function executeSpawnAgentsParallel(agents, registry2, sessionManager2, gu
964
1797
  hooksEngine2,
965
1798
  contextMonitor2,
966
1799
  backendSelector,
967
- childHttpUrl
1800
+ childHttpUrl,
1801
+ void 0,
1802
+ agentEventStore,
1803
+ hookMemoryDir
968
1804
  ).then((result) => {
969
1805
  completedCount++;
970
1806
  onProgress?.({
@@ -1034,17 +1870,35 @@ var init_spawn_agents_parallel = __esm({
1034
1870
 
1035
1871
  // src/mcp-server/tools/list-sessions.ts
1036
1872
  import { z as z3 } from "zod";
1037
- async function executeListSessions(input, sessionManager2) {
1873
+ async function executeListSessions(input, sessionManager2, options) {
1874
+ const validatedInput = listSessionsInputSchema.parse(input);
1875
+ const normalizedBackendId = validatedInput.backendId ?? validatedInput.backend;
1876
+ if (validatedInput.backendId && validatedInput.backend && validatedInput.backendId !== validatedInput.backend) {
1877
+ logger.warn(
1878
+ `list_sessions: both backendId and backend were provided; backendId="${validatedInput.backendId}" is used`
1879
+ );
1880
+ }
1881
+ const staleThresholdMs = (options?.staleThresholdSec ?? 300) * 1e3;
1038
1882
  const sessions = await sessionManager2.list({
1039
- backendId: input.backend,
1040
- limit: input.limit
1883
+ backendId: normalizedBackendId,
1884
+ limit: validatedInput.limit,
1885
+ status: validatedInput.staleOnly ? "active" : validatedInput.status,
1886
+ taskId: validatedInput.taskId,
1887
+ label: validatedInput.label,
1888
+ tags: validatedInput.tags,
1889
+ staleOnly: validatedInput.staleOnly ?? false,
1890
+ staleThresholdMs
1041
1891
  });
1042
1892
  return {
1043
1893
  sessions: sessions.map((s) => ({
1044
1894
  relaySessionId: s.relaySessionId,
1045
1895
  backendId: s.backendId,
1046
1896
  status: s.status,
1047
- createdAt: s.createdAt.toISOString()
1897
+ createdAt: s.createdAt.toISOString(),
1898
+ updatedAt: s.updatedAt.toISOString(),
1899
+ lastHeartbeatAt: s.lastHeartbeatAt.toISOString(),
1900
+ isStale: s.status === "active" && s.lastHeartbeatAt.getTime() + staleThresholdMs < Date.now(),
1901
+ metadata: s.metadata
1048
1902
  }))
1049
1903
  };
1050
1904
  }
@@ -1052,15 +1906,82 @@ var listSessionsInputSchema;
1052
1906
  var init_list_sessions = __esm({
1053
1907
  "src/mcp-server/tools/list-sessions.ts"() {
1054
1908
  "use strict";
1909
+ init_logger();
1910
+ init_metadata_validation();
1055
1911
  listSessionsInputSchema = z3.object({
1912
+ backendId: z3.enum(["claude", "codex", "gemini"]).optional(),
1056
1913
  backend: z3.enum(["claude", "codex", "gemini"]).optional(),
1057
- limit: z3.number().optional().default(10)
1914
+ limit: z3.number().int().min(1).max(100).optional().default(10),
1915
+ status: z3.enum(["active", "completed", "error"]).optional(),
1916
+ taskId: z3.string().regex(TASK_ID_PATTERN).optional(),
1917
+ label: z3.string().optional(),
1918
+ tags: z3.array(z3.string()).optional(),
1919
+ staleOnly: z3.boolean().optional().default(false)
1058
1920
  });
1059
1921
  }
1060
1922
  });
1061
1923
 
1062
- // src/mcp-server/tools/get-context-status.ts
1924
+ // src/mcp-server/tools/check-session-health.ts
1063
1925
  import { z as z4 } from "zod";
1926
+ async function executeCheckSessionHealth(input, sessionHealthMonitor) {
1927
+ return sessionHealthMonitor.checkHealth({
1928
+ sessionId: input.sessionId,
1929
+ includeCompleted: input.includeCompleted ?? false
1930
+ });
1931
+ }
1932
+ var checkSessionHealthInputSchema;
1933
+ var init_check_session_health = __esm({
1934
+ "src/mcp-server/tools/check-session-health.ts"() {
1935
+ "use strict";
1936
+ checkSessionHealthInputSchema = z4.object({
1937
+ sessionId: z4.string().optional().describe("Specific session to inspect. Omit to check all sessions."),
1938
+ includeCompleted: z4.boolean().optional().default(false).describe("When true, include completed/error sessions in addition to active ones.")
1939
+ });
1940
+ }
1941
+ });
1942
+
1943
+ // src/mcp-server/tools/poll-agent-events.ts
1944
+ import { z as z5 } from "zod";
1945
+ function executePollAgentEvents(input, agentEventStore) {
1946
+ const limit = input.limit ?? 50;
1947
+ const result = agentEventStore.query({
1948
+ afterEventId: input.cursor,
1949
+ types: input.types,
1950
+ sessionId: input.sessionId,
1951
+ parentSessionId: input.parentSessionId,
1952
+ recursive: false,
1953
+ limit
1954
+ });
1955
+ const lastEventId = result.events.length > 0 ? result.events[result.events.length - 1].eventId : input.cursor ?? null;
1956
+ return {
1957
+ events: result.events,
1958
+ lastEventId,
1959
+ hasMore: result.hasMore
1960
+ };
1961
+ }
1962
+ var EVENT_TYPE_VALUES, pollAgentEventsInputSchema;
1963
+ var init_poll_agent_events = __esm({
1964
+ "src/mcp-server/tools/poll-agent-events.ts"() {
1965
+ "use strict";
1966
+ EVENT_TYPE_VALUES = [
1967
+ "session-start",
1968
+ "session-complete",
1969
+ "session-error",
1970
+ "session-stale",
1971
+ "context-threshold"
1972
+ ];
1973
+ pollAgentEventsInputSchema = z5.object({
1974
+ cursor: z5.string().optional().describe("Exclusive cursor (previous lastEventId). Omit to start from oldest."),
1975
+ types: z5.array(z5.enum(EVENT_TYPE_VALUES)).optional().describe("Filter by event types."),
1976
+ sessionId: z5.string().optional().describe("Filter by session ID."),
1977
+ parentSessionId: z5.string().optional().describe("Filter to direct child sessions of this parent session."),
1978
+ limit: z5.number().int().min(1).max(200).optional().default(50).describe("Maximum number of events to return.")
1979
+ });
1980
+ }
1981
+ });
1982
+
1983
+ // src/mcp-server/tools/get-context-status.ts
1984
+ import { z as z6 } from "zod";
1064
1985
  async function executeGetContextStatus(input, sessionManager2, contextMonitor2) {
1065
1986
  const session = await sessionManager2.get(input.sessionId);
1066
1987
  if (!session) {
@@ -1092,8 +2013,8 @@ var getContextStatusInputSchema;
1092
2013
  var init_get_context_status = __esm({
1093
2014
  "src/mcp-server/tools/get-context-status.ts"() {
1094
2015
  "use strict";
1095
- getContextStatusInputSchema = z4.object({
1096
- sessionId: z4.string()
2016
+ getContextStatusInputSchema = z6.object({
2017
+ sessionId: z6.string()
1097
2018
  });
1098
2019
  }
1099
2020
  });
@@ -1366,7 +2287,7 @@ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/
1366
2287
  import { InMemoryTaskMessageQueue } from "@modelcontextprotocol/sdk/experimental/tasks/stores/in-memory.js";
1367
2288
  import { createServer } from "http";
1368
2289
  import { randomUUID } from "crypto";
1369
- import { z as z5 } from "zod";
2290
+ import { z as z7 } from "zod";
1370
2291
  function createMcpServerOptions() {
1371
2292
  const taskStore = new DeferredCleanupTaskStore();
1372
2293
  return {
@@ -1381,41 +2302,79 @@ var init_server = __esm({
1381
2302
  "src/mcp-server/server.ts"() {
1382
2303
  "use strict";
1383
2304
  init_deferred_cleanup_task_store();
2305
+ init_agent_event_store();
2306
+ init_session_health_monitor();
1384
2307
  init_recursion_guard();
1385
2308
  init_spawn_agent();
1386
2309
  init_spawn_agents_parallel();
1387
2310
  init_list_sessions();
2311
+ init_check_session_health();
2312
+ init_poll_agent_events();
1388
2313
  init_get_context_status();
1389
2314
  init_list_available_backends();
1390
2315
  init_backend_selector();
2316
+ init_metadata_validation();
1391
2317
  init_logger();
1392
2318
  init_types();
1393
2319
  init_response_formatter();
1394
2320
  spawnAgentsParallelInputShape = {
1395
- agents: z5.array(spawnAgentInputSchema).min(1).max(10).describe(
2321
+ agents: z7.array(spawnAgentInputSchema).min(1).max(10).describe(
1396
2322
  "Array of agent configurations to execute in parallel (1-10 agents)"
1397
2323
  )
1398
2324
  };
1399
2325
  MAX_CHILD_HTTP_SESSIONS = 100;
1400
2326
  RelayMCPServer = class {
1401
- constructor(registry2, sessionManager2, guardConfig, hooksEngine2, contextMonitor2, inlineSummaryLength, responseOutputDir) {
2327
+ constructor(registry2, sessionManager2, guardConfig, hooksEngine2, contextMonitor2, inlineSummaryLength, responseOutputDir, relayConfig) {
1402
2328
  this.registry = registry2;
1403
2329
  this.sessionManager = sessionManager2;
1404
2330
  this.hooksEngine = hooksEngine2;
1405
2331
  this.contextMonitor = contextMonitor2;
1406
2332
  this.inlineSummaryLength = inlineSummaryLength;
1407
2333
  this.responseOutputDir = responseOutputDir;
2334
+ this.relayConfig = relayConfig;
1408
2335
  this.guard = new RecursionGuard(guardConfig);
1409
2336
  this.backendSelector = new BackendSelector();
2337
+ this.staleThresholdSec = relayConfig?.sessionHealth?.staleThresholdSec ?? 300;
2338
+ this.hookMemoryDir = relayConfig?.hooks?.memoryDir ?? "./memory";
2339
+ this.agentEventStore = new AgentEventStore({
2340
+ backend: relayConfig?.eventStore?.backend,
2341
+ maxEvents: relayConfig?.eventStore?.maxEvents,
2342
+ ttlSec: relayConfig?.eventStore?.ttlSec,
2343
+ sessionDir: relayConfig?.eventStore?.sessionDir ?? this.sessionManager.getSessionsDir(),
2344
+ eventsFileName: relayConfig?.eventStore?.eventsFileName
2345
+ });
2346
+ if (this.contextMonitor) {
2347
+ this.contextMonitor.setAgentEventStore(this.agentEventStore);
2348
+ }
2349
+ this.sessionHealthMonitor = new SessionHealthMonitor(
2350
+ {
2351
+ enabled: relayConfig?.sessionHealth?.enabled,
2352
+ heartbeatIntervalSec: relayConfig?.sessionHealth?.heartbeatIntervalSec,
2353
+ staleThresholdSec: relayConfig?.sessionHealth?.staleThresholdSec,
2354
+ cleanupAfterSec: relayConfig?.sessionHealth?.cleanupAfterSec,
2355
+ maxActiveSessions: relayConfig?.sessionHealth?.maxActiveSessions,
2356
+ checkIntervalSec: relayConfig?.sessionHealth?.checkIntervalSec,
2357
+ memoryDir: this.hookMemoryDir
2358
+ },
2359
+ this.sessionManager,
2360
+ this.hooksEngine ?? null,
2361
+ this.contextMonitor ?? null,
2362
+ this.agentEventStore
2363
+ );
1410
2364
  this.server = new McpServer(
1411
- { name: "agentic-relay", version: "1.1.1" },
2365
+ { name: "agentic-relay", version: "1.3.0" },
1412
2366
  createMcpServerOptions()
1413
2367
  );
1414
2368
  this.registerTools(this.server);
2369
+ this.sessionHealthMonitor.start();
1415
2370
  }
1416
2371
  server;
1417
2372
  guard;
1418
2373
  backendSelector;
2374
+ agentEventStore;
2375
+ sessionHealthMonitor;
2376
+ staleThresholdSec;
2377
+ hookMemoryDir;
1419
2378
  _childHttpServer;
1420
2379
  _childHttpUrl;
1421
2380
  /** URL for child agents to connect via HTTP. Available after start() in stdio mode. */
@@ -1441,7 +2400,10 @@ var init_server = __esm({
1441
2400
  this.hooksEngine,
1442
2401
  this.contextMonitor,
1443
2402
  this.backendSelector,
1444
- this._childHttpUrl
2403
+ this._childHttpUrl,
2404
+ void 0,
2405
+ this.agentEventStore,
2406
+ this.hookMemoryDir
1445
2407
  );
1446
2408
  const controlOptions = {
1447
2409
  inlineSummaryLength: this.inlineSummaryLength ?? DEFAULT_INLINE_SUMMARY_LENGTH,
@@ -1493,7 +2455,10 @@ var init_server = __esm({
1493
2455
  this.hooksEngine,
1494
2456
  this.contextMonitor,
1495
2457
  this.backendSelector,
1496
- this._childHttpUrl
2458
+ this._childHttpUrl,
2459
+ void 0,
2460
+ this.agentEventStore,
2461
+ this.hookMemoryDir
1497
2462
  );
1498
2463
  const controlOptions = {
1499
2464
  inlineSummaryLength: this.inlineSummaryLength ?? DEFAULT_INLINE_SUMMARY_LENGTH,
@@ -1586,12 +2551,12 @@ var init_server = __esm({
1586
2551
  "retry_failed_agents",
1587
2552
  "Retry only the failed agents from a previous spawn_agents_parallel call. Pass the failed results array (with originalInput) directly.",
1588
2553
  {
1589
- failedResults: z5.array(z5.object({
1590
- index: z5.number(),
2554
+ failedResults: z7.array(z7.object({
2555
+ index: z7.number(),
1591
2556
  originalInput: spawnAgentInputSchema
1592
2557
  })).min(1).describe("Array of failed results with their original input configurations"),
1593
- overrides: z5.object({
1594
- preferredBackend: z5.enum(["claude", "codex", "gemini"]).optional()
2558
+ overrides: z7.object({
2559
+ preferredBackend: z7.enum(["claude", "codex", "gemini"]).optional()
1595
2560
  }).optional().describe("Parameter overrides applied to all retried agents")
1596
2561
  },
1597
2562
  async (params) => {
@@ -1611,7 +2576,10 @@ var init_server = __esm({
1611
2576
  this.hooksEngine,
1612
2577
  this.contextMonitor,
1613
2578
  this.backendSelector,
1614
- this._childHttpUrl
2579
+ this._childHttpUrl,
2580
+ void 0,
2581
+ this.agentEventStore,
2582
+ this.hookMemoryDir
1615
2583
  );
1616
2584
  const controlOptions = {
1617
2585
  inlineSummaryLength: this.inlineSummaryLength ?? DEFAULT_INLINE_SUMMARY_LENGTH,
@@ -1635,14 +2603,93 @@ var init_server = __esm({
1635
2603
  "list_sessions",
1636
2604
  "List relay sessions, optionally filtered by backend.",
1637
2605
  {
1638
- backend: z5.enum(["claude", "codex", "gemini"]).optional().describe("Filter sessions by backend type."),
1639
- limit: z5.number().optional().describe("Maximum number of sessions to return. Default: 10.")
2606
+ backendId: z7.enum(["claude", "codex", "gemini"]).optional().describe("Filter sessions by backend type."),
2607
+ backend: z7.enum(["claude", "codex", "gemini"]).optional().describe("Filter sessions by backend type."),
2608
+ limit: z7.number().int().min(1).max(100).optional().describe("Maximum number of sessions to return. Default: 10."),
2609
+ status: z7.enum(["active", "completed", "error"]).optional().describe("Filter sessions by status."),
2610
+ taskId: z7.string().regex(TASK_ID_PATTERN).optional().describe("Filter by metadata.taskId."),
2611
+ label: z7.string().optional().describe("Case-insensitive partial match for metadata.label."),
2612
+ tags: z7.array(z7.string()).optional().describe("Filter sessions containing all provided tags."),
2613
+ staleOnly: z7.boolean().optional().describe("When true, return only stale active sessions.")
1640
2614
  },
1641
2615
  async (params) => {
1642
2616
  try {
1643
2617
  const result = await executeListSessions(
1644
- { backend: params.backend, limit: params.limit ?? 10 },
1645
- this.sessionManager
2618
+ {
2619
+ backendId: params.backendId,
2620
+ backend: params.backend,
2621
+ limit: params.limit ?? 10,
2622
+ status: params.status,
2623
+ taskId: params.taskId,
2624
+ label: params.label,
2625
+ tags: params.tags,
2626
+ staleOnly: params.staleOnly ?? false
2627
+ },
2628
+ this.sessionManager,
2629
+ { staleThresholdSec: this.staleThresholdSec }
2630
+ );
2631
+ return {
2632
+ content: [
2633
+ {
2634
+ type: "text",
2635
+ text: JSON.stringify(result, null, 2)
2636
+ }
2637
+ ]
2638
+ };
2639
+ } catch (error) {
2640
+ const message = error instanceof Error ? error.message : String(error);
2641
+ return {
2642
+ content: [{ type: "text", text: `Error: ${message}` }],
2643
+ isError: true
2644
+ };
2645
+ }
2646
+ }
2647
+ );
2648
+ server.tool(
2649
+ "check_session_health",
2650
+ "Check relay session health without side effects.",
2651
+ checkSessionHealthInputSchema.shape,
2652
+ async (params) => {
2653
+ try {
2654
+ const result = await executeCheckSessionHealth(
2655
+ {
2656
+ sessionId: params.sessionId,
2657
+ includeCompleted: params.includeCompleted ?? false
2658
+ },
2659
+ this.sessionHealthMonitor
2660
+ );
2661
+ return {
2662
+ content: [
2663
+ {
2664
+ type: "text",
2665
+ text: JSON.stringify(result, null, 2)
2666
+ }
2667
+ ]
2668
+ };
2669
+ } catch (error) {
2670
+ const message = error instanceof Error ? error.message : String(error);
2671
+ return {
2672
+ content: [{ type: "text", text: `Error: ${message}` }],
2673
+ isError: true
2674
+ };
2675
+ }
2676
+ }
2677
+ );
2678
+ server.tool(
2679
+ "poll_agent_events",
2680
+ "Poll agent lifecycle events using a cursor.",
2681
+ pollAgentEventsInputSchema.shape,
2682
+ async (params) => {
2683
+ try {
2684
+ const result = executePollAgentEvents(
2685
+ {
2686
+ cursor: params.cursor,
2687
+ types: params.types,
2688
+ sessionId: params.sessionId,
2689
+ parentSessionId: params.parentSessionId,
2690
+ limit: params.limit ?? 50
2691
+ },
2692
+ this.agentEventStore
1646
2693
  );
1647
2694
  return {
1648
2695
  content: [
@@ -1665,7 +2712,7 @@ var init_server = __esm({
1665
2712
  "get_context_status",
1666
2713
  "Get the context usage status of a relay session. Returns usage data from ContextMonitor when available, otherwise estimated values.",
1667
2714
  {
1668
- sessionId: z5.string().describe("Relay session ID to query context usage for.")
2715
+ sessionId: z7.string().describe("Relay session ID to query context usage for.")
1669
2716
  },
1670
2717
  async (params) => {
1671
2718
  try {
@@ -1753,6 +2800,8 @@ var init_server = __esm({
1753
2800
  await new Promise((resolve3) => {
1754
2801
  httpServer.on("close", resolve3);
1755
2802
  });
2803
+ this._httpServer = void 0;
2804
+ await this.close();
1756
2805
  }
1757
2806
  /**
1758
2807
  * Start an HTTP server for child agents.
@@ -1781,7 +2830,7 @@ var init_server = __esm({
1781
2830
  sessionIdGenerator: () => randomUUID()
1782
2831
  });
1783
2832
  const server = new McpServer(
1784
- { name: "agentic-relay", version: "1.1.1" },
2833
+ { name: "agentic-relay", version: "1.3.0" },
1785
2834
  createMcpServerOptions()
1786
2835
  );
1787
2836
  this.registerTools(server);
@@ -1826,6 +2875,17 @@ var init_server = __esm({
1826
2875
  });
1827
2876
  });
1828
2877
  }
2878
+ async close() {
2879
+ this.sessionHealthMonitor.stop();
2880
+ this.agentEventStore.cleanup();
2881
+ if (this._childHttpServer) {
2882
+ await new Promise((resolve3) => {
2883
+ this._childHttpServer.close(() => resolve3());
2884
+ });
2885
+ this._childHttpServer = void 0;
2886
+ this._childHttpUrl = void 0;
2887
+ }
2888
+ }
1829
2889
  /** Exposed for testing and graceful shutdown */
1830
2890
  get httpServer() {
1831
2891
  return this._httpServer;
@@ -1837,8 +2897,8 @@ var init_server = __esm({
1837
2897
 
1838
2898
  // src/bin/relay.ts
1839
2899
  import { defineCommand as defineCommand10, runMain } from "citty";
1840
- import { join as join10 } from "path";
1841
- import { homedir as homedir6 } from "os";
2900
+ import { join as join11 } from "path";
2901
+ import { homedir as homedir7 } from "os";
1842
2902
 
1843
2903
  // src/infrastructure/process-manager.ts
1844
2904
  init_logger();
@@ -3179,7 +4239,9 @@ ${prompt}`;
3179
4239
  };
3180
4240
 
3181
4241
  // src/core/session-manager.ts
3182
- import { readFile as readFile4, writeFile as writeFile4, readdir, mkdir as mkdir4, chmod } from "fs/promises";
4242
+ init_logger();
4243
+ init_metadata_validation();
4244
+ import { readFile as readFile4, writeFile as writeFile4, readdir, mkdir as mkdir4, chmod, rename, unlink } from "fs/promises";
3183
4245
  import { join as join4 } from "path";
3184
4246
  import { homedir as homedir4 } from "os";
3185
4247
  import { nanoid } from "nanoid";
@@ -3193,22 +4255,41 @@ function toSessionData(session) {
3193
4255
  return {
3194
4256
  ...session,
3195
4257
  createdAt: session.createdAt.toISOString(),
3196
- updatedAt: session.updatedAt.toISOString()
4258
+ updatedAt: session.updatedAt.toISOString(),
4259
+ lastHeartbeatAt: session.lastHeartbeatAt.toISOString(),
4260
+ staleNotifiedAt: session.staleNotifiedAt?.toISOString() ?? null
3197
4261
  };
3198
4262
  }
3199
4263
  function fromSessionData(data) {
4264
+ const createdAt = new Date(data.createdAt);
4265
+ const updatedAt = new Date(data.updatedAt);
4266
+ const fallbackHeartbeat = data.lastHeartbeatAt ?? data.createdAt ?? data.updatedAt;
4267
+ const lastHeartbeatAt = new Date(fallbackHeartbeat);
4268
+ const staleNotifiedAt = data.staleNotifiedAt ? new Date(data.staleNotifiedAt) : null;
3200
4269
  return {
3201
4270
  ...data,
3202
- createdAt: new Date(data.createdAt),
3203
- updatedAt: new Date(data.updatedAt)
4271
+ createdAt,
4272
+ updatedAt,
4273
+ lastHeartbeatAt: Number.isNaN(lastHeartbeatAt.getTime()) ? updatedAt : lastHeartbeatAt,
4274
+ staleNotifiedAt: staleNotifiedAt && !Number.isNaN(staleNotifiedAt.getTime()) ? staleNotifiedAt : null,
4275
+ metadata: data.metadata && typeof data.metadata === "object" ? data.metadata : {}
3204
4276
  };
3205
4277
  }
3206
4278
  var SessionManager = class _SessionManager {
3207
4279
  static SESSION_ID_PATTERN = /^relay-[A-Za-z0-9_-]+$/;
4280
+ static DEFAULT_STALE_THRESHOLD_MS = 3e5;
4281
+ static PROTECTED_METADATA_KEYS = /* @__PURE__ */ new Set([
4282
+ "taskId",
4283
+ "parentTaskId",
4284
+ "agentType"
4285
+ ]);
3208
4286
  sessionsDir;
3209
4287
  constructor(sessionsDir) {
3210
4288
  this.sessionsDir = sessionsDir ?? getSessionsDir(getRelayHome());
3211
4289
  }
4290
+ getSessionsDir() {
4291
+ return this.sessionsDir;
4292
+ }
3212
4293
  /** Ensure the sessions directory exists. */
3213
4294
  async ensureDir() {
3214
4295
  await mkdir4(this.sessionsDir, { recursive: true });
@@ -3219,6 +4300,54 @@ var SessionManager = class _SessionManager {
3219
4300
  }
3220
4301
  return join4(this.sessionsDir, `${relaySessionId}.json`);
3221
4302
  }
4303
+ async writeSession(session) {
4304
+ const filePath = this.sessionPath(session.relaySessionId);
4305
+ const tempPath = `${filePath}.${nanoid()}.tmp`;
4306
+ try {
4307
+ await writeFile4(tempPath, JSON.stringify(toSessionData(session), null, 2), "utf-8");
4308
+ await chmod(tempPath, 384);
4309
+ await rename(tempPath, filePath);
4310
+ } catch (error) {
4311
+ await unlink(tempPath).catch(() => void 0);
4312
+ throw error;
4313
+ }
4314
+ }
4315
+ ensureValidTransition(currentStatus, nextStatus) {
4316
+ if (currentStatus === nextStatus) {
4317
+ return;
4318
+ }
4319
+ if (currentStatus === "completed" || currentStatus === "error") {
4320
+ throw new Error(
4321
+ `RELAY_INVALID_TRANSITION: ${currentStatus} -> ${nextStatus}`
4322
+ );
4323
+ }
4324
+ if (currentStatus === "active" && nextStatus !== "completed" && nextStatus !== "error") {
4325
+ throw new Error(
4326
+ `RELAY_INVALID_TRANSITION: ${currentStatus} -> ${nextStatus}`
4327
+ );
4328
+ }
4329
+ }
4330
+ getLastActivityAtMs(session) {
4331
+ return Math.max(
4332
+ session.lastHeartbeatAt.getTime(),
4333
+ session.updatedAt.getTime()
4334
+ );
4335
+ }
4336
+ isStale(session, staleThresholdMs, now = Date.now()) {
4337
+ if (session.status !== "active") return false;
4338
+ return this.getLastActivityAtMs(session) + staleThresholdMs < now;
4339
+ }
4340
+ mergeMetadata(existing, updates) {
4341
+ const merged = { ...existing };
4342
+ for (const [key, value] of Object.entries(updates)) {
4343
+ if (_SessionManager.PROTECTED_METADATA_KEYS.has(key) && Object.prototype.hasOwnProperty.call(existing, key)) {
4344
+ logger.warn(`Attempted to overwrite protected metadata key: ${key}`);
4345
+ continue;
4346
+ }
4347
+ merged[key] = value;
4348
+ }
4349
+ return merged;
4350
+ }
3222
4351
  /** Create a new relay session. */
3223
4352
  async create(params) {
3224
4353
  await this.ensureDir();
@@ -3231,15 +4360,12 @@ var SessionManager = class _SessionManager {
3231
4360
  depth: params.depth ?? 0,
3232
4361
  createdAt: now,
3233
4362
  updatedAt: now,
3234
- status: "active"
4363
+ status: "active",
4364
+ lastHeartbeatAt: now,
4365
+ staleNotifiedAt: null,
4366
+ metadata: validateMetadata(params.metadata)
3235
4367
  };
3236
- const sessionFilePath = this.sessionPath(session.relaySessionId);
3237
- await writeFile4(
3238
- sessionFilePath,
3239
- JSON.stringify(toSessionData(session), null, 2),
3240
- "utf-8"
3241
- );
3242
- await chmod(sessionFilePath, 384);
4368
+ await this.writeSession(session);
3243
4369
  return session;
3244
4370
  }
3245
4371
  /** Update an existing session. */
@@ -3248,18 +4374,38 @@ var SessionManager = class _SessionManager {
3248
4374
  if (!session) {
3249
4375
  throw new Error(`Session not found: ${relaySessionId}`);
3250
4376
  }
4377
+ if (updates.status) {
4378
+ this.ensureValidTransition(session.status, updates.status);
4379
+ }
3251
4380
  const updated = {
3252
4381
  ...session,
3253
4382
  ...updates,
4383
+ metadata: validateMetadata(
4384
+ updates.metadata ? this.mergeMetadata(session.metadata, updates.metadata) : session.metadata
4385
+ ),
3254
4386
  updatedAt: /* @__PURE__ */ new Date()
3255
4387
  };
3256
- const updateFilePath = this.sessionPath(relaySessionId);
3257
- await writeFile4(
3258
- updateFilePath,
3259
- JSON.stringify(toSessionData(updated), null, 2),
3260
- "utf-8"
3261
- );
3262
- await chmod(updateFilePath, 384);
4388
+ await this.writeSession(updated);
4389
+ }
4390
+ /** Update heartbeat timestamp for an active session. */
4391
+ async updateHeartbeat(relaySessionId) {
4392
+ const session = await this.get(relaySessionId);
4393
+ if (!session) {
4394
+ throw new Error(`Session not found: ${relaySessionId}`);
4395
+ }
4396
+ if (session.status !== "active") {
4397
+ throw new Error(
4398
+ `RELAY_INVALID_TRANSITION: ${session.status} -> active`
4399
+ );
4400
+ }
4401
+ const now = /* @__PURE__ */ new Date();
4402
+ const updated = {
4403
+ ...session,
4404
+ lastHeartbeatAt: now,
4405
+ staleNotifiedAt: null,
4406
+ updatedAt: now
4407
+ };
4408
+ await this.writeSession(updated);
3263
4409
  }
3264
4410
  /** Get a session by relay session ID. */
3265
4411
  async get(relaySessionId) {
@@ -3294,6 +4440,31 @@ var SessionManager = class _SessionManager {
3294
4440
  if (filter?.backendId && session.backendId !== filter.backendId) {
3295
4441
  continue;
3296
4442
  }
4443
+ if (filter?.status && session.status !== filter.status) {
4444
+ continue;
4445
+ }
4446
+ if (filter?.taskId && session.metadata.taskId !== filter.taskId) {
4447
+ continue;
4448
+ }
4449
+ if (filter?.label && filter.label.trim().length > 0) {
4450
+ const target = (session.metadata.label ?? "").toLowerCase();
4451
+ if (!target.includes(filter.label.toLowerCase())) {
4452
+ continue;
4453
+ }
4454
+ }
4455
+ if (filter?.tags && filter.tags.length > 0) {
4456
+ const sessionTags = Array.isArray(session.metadata.tags) ? session.metadata.tags : [];
4457
+ const hasAllTags = filter.tags.every((tag) => sessionTags.includes(tag));
4458
+ if (!hasAllTags) {
4459
+ continue;
4460
+ }
4461
+ }
4462
+ if (filter?.staleOnly) {
4463
+ const threshold = filter.staleThresholdMs ?? _SessionManager.DEFAULT_STALE_THRESHOLD_MS;
4464
+ if (!this.isStale(session, threshold)) {
4465
+ continue;
4466
+ }
4467
+ }
3297
4468
  sessions.push(session);
3298
4469
  } catch {
3299
4470
  }
@@ -3306,6 +4477,41 @@ var SessionManager = class _SessionManager {
3306
4477
  }
3307
4478
  return sessions;
3308
4479
  }
4480
+ async listStale(thresholdMs) {
4481
+ const activeSessions = await this.list({ status: "active" });
4482
+ const now = Date.now();
4483
+ return activeSessions.filter((session) => this.isStale(session, thresholdMs, now));
4484
+ }
4485
+ async delete(relaySessionId) {
4486
+ const filePath = this.sessionPath(relaySessionId);
4487
+ try {
4488
+ await unlink(filePath);
4489
+ } catch (error) {
4490
+ if (error.code === "ENOENT") {
4491
+ return;
4492
+ }
4493
+ throw error;
4494
+ }
4495
+ }
4496
+ async cleanup(olderThanMs) {
4497
+ const sessions = await this.list();
4498
+ const now = Date.now();
4499
+ const deletedSessionIds = [];
4500
+ for (const session of sessions) {
4501
+ if (session.status !== "completed" && session.status !== "error") {
4502
+ continue;
4503
+ }
4504
+ if (session.updatedAt.getTime() + olderThanMs >= now) {
4505
+ continue;
4506
+ }
4507
+ await this.delete(session.relaySessionId);
4508
+ deletedSessionIds.push(session.relaySessionId);
4509
+ }
4510
+ return {
4511
+ deletedCount: deletedSessionIds.length,
4512
+ deletedSessionIds
4513
+ };
4514
+ }
3309
4515
  };
3310
4516
 
3311
4517
  // src/core/config-manager.ts
@@ -3328,7 +4534,10 @@ var hookEventSchema = z.enum([
3328
4534
  "on-error",
3329
4535
  "on-context-threshold",
3330
4536
  "pre-spawn",
3331
- "post-spawn"
4537
+ "post-spawn",
4538
+ "on-session-complete",
4539
+ "on-session-error",
4540
+ "on-session-stale"
3332
4541
  ]);
3333
4542
  var hookDefinitionSchema = z.object({
3334
4543
  event: hookEventSchema,
@@ -3350,7 +4559,8 @@ var hookChainSchema = z.object({
3350
4559
  });
3351
4560
  var hooksConfigSchema = z.object({
3352
4561
  definitions: z.array(hookDefinitionSchema),
3353
- chains: z.array(hookChainSchema).optional()
4562
+ chains: z.array(hookChainSchema).optional(),
4563
+ memoryDir: z.string().optional()
3354
4564
  });
3355
4565
  var backendContextConfigSchema = z.object({
3356
4566
  contextWindow: z.number().positive().optional(),
@@ -3365,18 +4575,38 @@ var relayConfigSchema = z.object({
3365
4575
  gemini: z.record(z.unknown()).optional()
3366
4576
  }).optional(),
3367
4577
  hooks: hooksConfigSchema.optional(),
4578
+ sessionHealth: z.object({
4579
+ enabled: z.boolean().optional(),
4580
+ heartbeatIntervalSec: z.number().positive().optional(),
4581
+ staleThresholdSec: z.number().positive().optional(),
4582
+ cleanupAfterSec: z.number().positive().optional(),
4583
+ maxActiveSessions: z.number().int().positive().optional(),
4584
+ checkIntervalSec: z.number().positive().optional()
4585
+ }).optional(),
3368
4586
  contextMonitor: z.object({
3369
4587
  enabled: z.boolean().optional(),
3370
4588
  thresholdPercent: z.number().min(0).max(100).optional(),
3371
4589
  notifyThreshold: z.number().positive().optional(),
3372
4590
  notifyPercent: z.number().min(0).max(100).optional(),
3373
4591
  notifyMethod: z.enum(["stderr", "hook"]).optional(),
4592
+ thresholdLevels: z.object({
4593
+ warning: z.number().min(0).max(100).optional(),
4594
+ critical: z.number().min(0).max(100).optional(),
4595
+ emergency: z.number().min(0).max(100).optional()
4596
+ }).optional(),
3374
4597
  backends: z.object({
3375
4598
  claude: backendContextConfigSchema,
3376
4599
  codex: backendContextConfigSchema,
3377
4600
  gemini: backendContextConfigSchema
3378
4601
  }).optional()
3379
4602
  }).optional(),
4603
+ eventStore: z.object({
4604
+ backend: z.enum(["memory", "jsonl"]).optional(),
4605
+ maxEvents: z.number().int().positive().optional(),
4606
+ ttlSec: z.number().positive().optional(),
4607
+ sessionDir: z.string().optional(),
4608
+ eventsFileName: z.string().optional()
4609
+ }).optional(),
3380
4610
  mcpServerMode: z.object({
3381
4611
  maxDepth: z.number().int().positive(),
3382
4612
  maxCallsPerSession: z.number().int().positive(),
@@ -3397,7 +4627,7 @@ function deepMerge(target, source) {
3397
4627
  for (const key of Object.keys(source)) {
3398
4628
  const sourceVal = source[key];
3399
4629
  const targetVal = result[key];
3400
- if (isPlainObject(sourceVal) && isPlainObject(targetVal)) {
4630
+ if (isPlainObject2(sourceVal) && isPlainObject2(targetVal)) {
3401
4631
  result[key] = deepMerge(
3402
4632
  targetVal,
3403
4633
  sourceVal
@@ -3408,14 +4638,14 @@ function deepMerge(target, source) {
3408
4638
  }
3409
4639
  return result;
3410
4640
  }
3411
- function isPlainObject(value) {
4641
+ function isPlainObject2(value) {
3412
4642
  return typeof value === "object" && value !== null && !Array.isArray(value) && Object.getPrototypeOf(value) === Object.prototype;
3413
4643
  }
3414
4644
  function getByPath(obj, path2) {
3415
4645
  const parts = path2.split(".");
3416
4646
  let current = obj;
3417
4647
  for (const part of parts) {
3418
- if (!isPlainObject(current)) return void 0;
4648
+ if (!isPlainObject2(current)) return void 0;
3419
4649
  current = current[part];
3420
4650
  }
3421
4651
  return current;
@@ -3425,7 +4655,7 @@ function setByPath(obj, path2, value) {
3425
4655
  let current = obj;
3426
4656
  for (let i = 0; i < parts.length - 1; i++) {
3427
4657
  const part = parts[i];
3428
- if (!isPlainObject(current[part])) {
4658
+ if (!isPlainObject2(current[part])) {
3429
4659
  current[part] = {};
3430
4660
  }
3431
4661
  current = current[part];
@@ -3534,7 +4764,7 @@ var ConfigManager = class {
3534
4764
  try {
3535
4765
  const raw = await readFile5(filePath, "utf-8");
3536
4766
  const parsed = JSON.parse(raw);
3537
- if (!isPlainObject(parsed)) return {};
4767
+ if (!isPlainObject2(parsed)) return {};
3538
4768
  return parsed;
3539
4769
  } catch {
3540
4770
  return {};
@@ -3678,7 +4908,7 @@ var HooksEngine = class _HooksEngine {
3678
4908
  }
3679
4909
  /** Load hook definitions from config and register listeners on EventBus */
3680
4910
  loadConfig(config) {
3681
- this.definitions = config.definitions.filter((def) => {
4911
+ this.definitions = (config.definitions ?? []).filter((def) => {
3682
4912
  if (def.enabled === false) return false;
3683
4913
  try {
3684
4914
  this.validateCommand(def.command);
@@ -3906,13 +5136,17 @@ var DEFAULT_BACKEND_CONTEXT = {
3906
5136
  gemini: { contextWindow: 1048576, compactThreshold: 524288 }
3907
5137
  };
3908
5138
  var DEFAULT_NOTIFY_PERCENT = 70;
5139
+ var DEFAULT_CRITICAL_PERCENT = 85;
5140
+ var DEFAULT_EMERGENCY_PERCENT = 95;
3909
5141
  var DEFAULT_CONFIG = {
3910
5142
  enabled: true,
3911
- notifyMethod: "hook"
5143
+ notifyMethod: "hook",
5144
+ memoryDir: "./memory"
3912
5145
  };
3913
5146
  var ContextMonitor = class {
3914
- constructor(hooksEngine2, config) {
5147
+ constructor(hooksEngine2, config, agentEventStore) {
3915
5148
  this.hooksEngine = hooksEngine2;
5149
+ this.agentEventStore = agentEventStore;
3916
5150
  this.config = { ...DEFAULT_CONFIG, ...config };
3917
5151
  if (this.config.thresholdPercent !== void 0 && this.config.notifyPercent === void 0 && this.config.notifyThreshold === void 0) {
3918
5152
  this.config.notifyPercent = this.config.thresholdPercent;
@@ -3920,6 +5154,7 @@ var ContextMonitor = class {
3920
5154
  }
3921
5155
  config;
3922
5156
  usageMap = /* @__PURE__ */ new Map();
5157
+ agentEventStore;
3923
5158
  /** Get backend context config, merging user overrides with defaults */
3924
5159
  getBackendConfig(backendId) {
3925
5160
  const defaults = DEFAULT_BACKEND_CONTEXT[backendId];
@@ -3935,19 +5170,60 @@ var ContextMonitor = class {
3935
5170
  return this.config.notifyThreshold;
3936
5171
  }
3937
5172
  const backendConfig = this.getBackendConfig(backendId);
3938
- const notifyPercent = this.config.notifyPercent ?? DEFAULT_NOTIFY_PERCENT;
5173
+ const notifyPercent = this.config.thresholdLevels?.warning ?? this.config.notifyPercent ?? DEFAULT_NOTIFY_PERCENT;
3939
5174
  return Math.round(backendConfig.contextWindow * notifyPercent / 100);
3940
5175
  }
5176
+ getWarningPercent(backendId) {
5177
+ if (this.config.thresholdLevels?.warning !== void 0) {
5178
+ return this.config.thresholdLevels.warning;
5179
+ }
5180
+ if (this.config.notifyThreshold !== void 0) {
5181
+ const backendConfig = this.getBackendConfig(backendId);
5182
+ if (backendConfig.contextWindow <= 0) return DEFAULT_NOTIFY_PERCENT;
5183
+ return Math.round(
5184
+ this.config.notifyThreshold / backendConfig.contextWindow * 100
5185
+ );
5186
+ }
5187
+ return this.config.notifyPercent ?? DEFAULT_NOTIFY_PERCENT;
5188
+ }
5189
+ getThresholdLevels(backendId, contextWindow) {
5190
+ const warningPercent = this.getWarningPercent(backendId);
5191
+ const warningTokens = this.getNotifyThreshold(backendId);
5192
+ const criticalPercent = this.config.thresholdLevels?.critical ?? DEFAULT_CRITICAL_PERCENT;
5193
+ const emergencyPercent = this.config.thresholdLevels?.emergency ?? DEFAULT_EMERGENCY_PERCENT;
5194
+ return [
5195
+ ["warning", warningPercent, warningTokens],
5196
+ [
5197
+ "critical",
5198
+ criticalPercent,
5199
+ Math.round(contextWindow * criticalPercent / 100)
5200
+ ],
5201
+ [
5202
+ "emergency",
5203
+ emergencyPercent,
5204
+ Math.round(contextWindow * emergencyPercent / 100)
5205
+ ]
5206
+ ];
5207
+ }
5208
+ isNotified(sessionId, level) {
5209
+ const entry = this.usageMap.get(sessionId);
5210
+ return entry?.notifiedLevels.has(level) ?? false;
5211
+ }
5212
+ markNotified(sessionId, level) {
5213
+ const entry = this.usageMap.get(sessionId);
5214
+ if (!entry) return;
5215
+ entry.notifiedLevels.add(level);
5216
+ }
3941
5217
  /** Update token usage for a session and check threshold */
3942
- updateUsage(sessionId, backendId, estimatedTokens) {
5218
+ updateUsage(sessionId, backendId, estimatedTokens, sessionMetadata) {
3943
5219
  if (!this.config.enabled) return;
3944
5220
  const backendConfig = this.getBackendConfig(backendId);
3945
5221
  const contextWindow = backendConfig.contextWindow;
3946
5222
  const usagePercent = contextWindow > 0 ? Math.round(estimatedTokens / contextWindow * 100) : 0;
3947
5223
  const existing = this.usageMap.get(sessionId);
3948
- let wasNotified = existing?.notified ?? false;
5224
+ let notifiedLevels = existing?.notifiedLevels ?? /* @__PURE__ */ new Set();
3949
5225
  if (existing && estimatedTokens < existing.estimatedTokens * 0.7) {
3950
- wasNotified = false;
5226
+ notifiedLevels = /* @__PURE__ */ new Set();
3951
5227
  }
3952
5228
  this.usageMap.set(sessionId, {
3953
5229
  estimatedTokens,
@@ -3955,19 +5231,27 @@ var ContextMonitor = class {
3955
5231
  compactThreshold: backendConfig.compactThreshold,
3956
5232
  usagePercent,
3957
5233
  backendId,
3958
- notified: wasNotified
5234
+ notifiedLevels,
5235
+ sessionMetadata: sessionMetadata ?? existing?.sessionMetadata
3959
5236
  });
3960
- const notifyAt = this.getNotifyThreshold(backendId);
3961
- if (estimatedTokens >= notifyAt && !wasNotified) {
3962
- const entry = this.usageMap.get(sessionId);
3963
- entry.notified = true;
3964
- this.notify(
5237
+ const levels = this.getThresholdLevels(backendId, contextWindow);
5238
+ for (const [level, _thresholdPercent, thresholdTokens] of levels) {
5239
+ if (estimatedTokens < thresholdTokens) {
5240
+ continue;
5241
+ }
5242
+ if (this.isNotified(sessionId, level)) {
5243
+ continue;
5244
+ }
5245
+ this.markNotified(sessionId, level);
5246
+ this.notifyLevel(
3965
5247
  sessionId,
3966
5248
  backendId,
5249
+ level,
3967
5250
  usagePercent,
3968
5251
  estimatedTokens,
3969
5252
  contextWindow,
3970
- backendConfig.compactThreshold
5253
+ backendConfig.compactThreshold,
5254
+ this.usageMap.get(sessionId)?.sessionMetadata
3971
5255
  );
3972
5256
  }
3973
5257
  }
@@ -3993,15 +5277,93 @@ var ContextMonitor = class {
3993
5277
  removeSession(sessionId) {
3994
5278
  this.usageMap.delete(sessionId);
3995
5279
  }
3996
- notify(sessionId, backendId, usagePercent, currentTokens, contextWindow, compactThreshold) {
5280
+ setAgentEventStore(agentEventStore) {
5281
+ this.agentEventStore = agentEventStore;
5282
+ }
5283
+ parseSaveResult(value) {
5284
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
5285
+ return null;
5286
+ }
5287
+ const candidate = value;
5288
+ if (typeof candidate.saved !== "boolean") {
5289
+ return null;
5290
+ }
5291
+ const saveResult = { saved: candidate.saved };
5292
+ if (typeof candidate.snapshotPath === "string") {
5293
+ saveResult.snapshotPath = candidate.snapshotPath;
5294
+ }
5295
+ if (typeof candidate.error === "string") {
5296
+ saveResult.error = candidate.error;
5297
+ }
5298
+ return saveResult;
5299
+ }
5300
+ extractSaveResult(results) {
5301
+ for (let index = results.length - 1; index >= 0; index -= 1) {
5302
+ const metadata = results[index]?.output.metadata;
5303
+ const nested = this.parseSaveResult(
5304
+ metadata?.saveResult
5305
+ );
5306
+ if (nested) {
5307
+ return nested;
5308
+ }
5309
+ const topLevel = this.parseSaveResult(metadata);
5310
+ if (topLevel) {
5311
+ return topLevel;
5312
+ }
5313
+ }
5314
+ const failed = results.find(
5315
+ (result) => result.exitCode !== 0 || (result.stderr?.trim().length ?? 0) > 0
5316
+ );
5317
+ if (failed) {
5318
+ return {
5319
+ saved: false,
5320
+ error: failed.stderr || `hook exited with code ${failed.exitCode}`
5321
+ };
5322
+ }
5323
+ return { saved: false };
5324
+ }
5325
+ recordThresholdEvent(sessionId, backendId, level, usagePercent, currentTokens, contextWindow, compactThreshold, remainingBeforeCompact, saveResult, sessionMetadata) {
5326
+ this.agentEventStore?.record({
5327
+ type: "context-threshold",
5328
+ sessionId,
5329
+ backendId,
5330
+ parentSessionId: void 0,
5331
+ metadata: sessionMetadata ?? {},
5332
+ data: {
5333
+ usagePercent,
5334
+ currentTokens,
5335
+ contextWindow,
5336
+ compactThreshold,
5337
+ remainingBeforeCompact,
5338
+ level,
5339
+ thresholdLevel: level,
5340
+ saveResult,
5341
+ taskId: sessionMetadata?.taskId
5342
+ }
5343
+ });
5344
+ }
5345
+ notifyLevel(sessionId, backendId, level, usagePercent, currentTokens, contextWindow, compactThreshold, sessionMetadata) {
3997
5346
  const remainingBeforeCompact = Math.max(
3998
5347
  0,
3999
5348
  compactThreshold - currentTokens
4000
5349
  );
4001
- const warningMessage = `${backendId} session ${sessionId} at ${usagePercent}% (${currentTokens}/${contextWindow} tokens). Compact in ~${remainingBeforeCompact} tokens. Save your work state now.`;
5350
+ const initialSaveResult = { saved: false };
5351
+ const warningMessage = `${backendId} session ${sessionId} level=${level} at ${usagePercent}% (${currentTokens}/${contextWindow} tokens). Compact in ~${remainingBeforeCompact} tokens. Save your work state now.`;
4002
5352
  if (this.config.notifyMethod === "stderr") {
5353
+ this.recordThresholdEvent(
5354
+ sessionId,
5355
+ backendId,
5356
+ level,
5357
+ usagePercent,
5358
+ currentTokens,
5359
+ contextWindow,
5360
+ compactThreshold,
5361
+ remainingBeforeCompact,
5362
+ initialSaveResult,
5363
+ sessionMetadata
5364
+ );
4003
5365
  process.stderr.write(
4004
- `[relay] Context warning: ${warningMessage}
5366
+ `[relay] Context ${level}: ${warningMessage}
4005
5367
  `
4006
5368
  );
4007
5369
  } else if (this.config.notifyMethod === "hook" && this.hooksEngine) {
@@ -4015,11 +5377,60 @@ var ContextMonitor = class {
4015
5377
  currentTokens,
4016
5378
  contextWindow,
4017
5379
  compactThreshold,
4018
- remainingBeforeCompact
5380
+ remainingBeforeCompact,
5381
+ level,
5382
+ thresholdLevel: level,
5383
+ taskId: sessionMetadata?.taskId,
5384
+ label: sessionMetadata?.label,
5385
+ agentType: sessionMetadata?.agentType,
5386
+ saveResult: initialSaveResult,
5387
+ memoryDir: this.config.memoryDir ?? "./memory"
4019
5388
  }
4020
5389
  };
4021
- void this.hooksEngine.emit("on-context-threshold", hookInput).catch(
4022
- (e) => logger.debug("Context threshold hook error:", e)
5390
+ void this.hooksEngine.emit("on-context-threshold", hookInput).then((results) => {
5391
+ const saveResult = this.extractSaveResult(results);
5392
+ this.recordThresholdEvent(
5393
+ sessionId,
5394
+ backendId,
5395
+ level,
5396
+ usagePercent,
5397
+ currentTokens,
5398
+ contextWindow,
5399
+ compactThreshold,
5400
+ remainingBeforeCompact,
5401
+ saveResult,
5402
+ sessionMetadata
5403
+ );
5404
+ }).catch(
5405
+ (e) => {
5406
+ const errorMessage = e instanceof Error ? e.message : String(e);
5407
+ this.recordThresholdEvent(
5408
+ sessionId,
5409
+ backendId,
5410
+ level,
5411
+ usagePercent,
5412
+ currentTokens,
5413
+ contextWindow,
5414
+ compactThreshold,
5415
+ remainingBeforeCompact,
5416
+ { saved: false, error: errorMessage },
5417
+ sessionMetadata
5418
+ );
5419
+ logger.debug("Context threshold hook error:", e);
5420
+ }
5421
+ );
5422
+ } else {
5423
+ this.recordThresholdEvent(
5424
+ sessionId,
5425
+ backendId,
5426
+ level,
5427
+ usagePercent,
5428
+ currentTokens,
5429
+ contextWindow,
5430
+ compactThreshold,
5431
+ remainingBeforeCompact,
5432
+ initialSaveResult,
5433
+ sessionMetadata
4023
5434
  );
4024
5435
  }
4025
5436
  }
@@ -4615,16 +6026,17 @@ function createMCPCommand(configManager2, registry2, sessionManager2, hooksEngin
4615
6026
  let guardConfig;
4616
6027
  let inlineSummaryLength;
4617
6028
  let responseOutputDir;
6029
+ let relayConfig;
4618
6030
  try {
4619
- const config = await configManager2.getConfig();
4620
- if (config.mcpServerMode) {
6031
+ relayConfig = await configManager2.getConfig();
6032
+ if (relayConfig.mcpServerMode) {
4621
6033
  guardConfig = {
4622
- maxDepth: config.mcpServerMode.maxDepth ?? 5,
4623
- maxCallsPerSession: config.mcpServerMode.maxCallsPerSession ?? 20,
4624
- timeoutSec: config.mcpServerMode.timeoutSec ?? 86400
6034
+ maxDepth: relayConfig.mcpServerMode.maxDepth ?? 5,
6035
+ maxCallsPerSession: relayConfig.mcpServerMode.maxCallsPerSession ?? 20,
6036
+ timeoutSec: relayConfig.mcpServerMode.timeoutSec ?? 86400
4625
6037
  };
4626
- inlineSummaryLength = config.mcpServerMode.inlineSummaryLength;
4627
- responseOutputDir = config.mcpServerMode.responseOutputDir;
6038
+ inlineSummaryLength = relayConfig.mcpServerMode.inlineSummaryLength;
6039
+ responseOutputDir = relayConfig.mcpServerMode.responseOutputDir;
4628
6040
  }
4629
6041
  } catch {
4630
6042
  }
@@ -4637,7 +6049,8 @@ function createMCPCommand(configManager2, registry2, sessionManager2, hooksEngin
4637
6049
  hooksEngine2,
4638
6050
  contextMonitor2,
4639
6051
  inlineSummaryLength,
4640
- responseOutputDir
6052
+ responseOutputDir,
6053
+ relayConfig
4641
6054
  );
4642
6055
  await server.start({ transport, port });
4643
6056
  }
@@ -4799,7 +6212,7 @@ function createVersionCommand(registry2) {
4799
6212
  description: "Show relay and backend versions"
4800
6213
  },
4801
6214
  async run() {
4802
- const relayVersion = "1.1.1";
6215
+ const relayVersion = "1.3.0";
4803
6216
  console.log(`agentic-relay v${relayVersion}`);
4804
6217
  console.log("");
4805
6218
  console.log("Backends:");
@@ -4824,8 +6237,8 @@ function createVersionCommand(registry2) {
4824
6237
  // src/commands/doctor.ts
4825
6238
  import { defineCommand as defineCommand8 } from "citty";
4826
6239
  import { access, constants, readdir as readdir2 } from "fs/promises";
4827
- import { join as join8 } from "path";
4828
- import { homedir as homedir5 } from "os";
6240
+ import { join as join9 } from "path";
6241
+ import { homedir as homedir6 } from "os";
4829
6242
  import { execFile as execFile2 } from "child_process";
4830
6243
  import { promisify as promisify2 } from "util";
4831
6244
  var execFileAsync2 = promisify2(execFile2);
@@ -4885,8 +6298,8 @@ async function checkConfig(configManager2) {
4885
6298
  }
4886
6299
  }
4887
6300
  async function checkSessionsDir() {
4888
- const relayHome2 = process.env["RELAY_HOME"] ?? join8(homedir5(), ".relay");
4889
- const sessionsDir = join8(relayHome2, "sessions");
6301
+ const relayHome2 = process.env["RELAY_HOME"] ?? join9(homedir6(), ".relay");
6302
+ const sessionsDir = join9(relayHome2, "sessions");
4890
6303
  try {
4891
6304
  await access(sessionsDir, constants.W_OK);
4892
6305
  return {
@@ -4999,8 +6412,8 @@ async function checkBackendAuthEnv() {
4999
6412
  return results;
5000
6413
  }
5001
6414
  async function checkSessionsDiskUsage() {
5002
- const relayHome2 = process.env["RELAY_HOME"] ?? join8(homedir5(), ".relay");
5003
- const sessionsDir = join8(relayHome2, "sessions");
6415
+ const relayHome2 = process.env["RELAY_HOME"] ?? join9(homedir6(), ".relay");
6416
+ const sessionsDir = join9(relayHome2, "sessions");
5004
6417
  try {
5005
6418
  const entries = await readdir2(sessionsDir);
5006
6419
  const fileCount = entries.length;
@@ -5074,8 +6487,8 @@ function createDoctorCommand(registry2, configManager2) {
5074
6487
  init_logger();
5075
6488
  import { defineCommand as defineCommand9 } from "citty";
5076
6489
  import { mkdir as mkdir6, writeFile as writeFile6, access as access2, readFile as readFile6 } from "fs/promises";
5077
- import { join as join9 } from "path";
5078
- var DEFAULT_CONFIG2 = {
6490
+ import { join as join10 } from "path";
6491
+ var DEFAULT_CONFIG4 = {
5079
6492
  defaultBackend: "claude",
5080
6493
  backends: {},
5081
6494
  mcpServers: {}
@@ -5088,8 +6501,8 @@ function createInitCommand() {
5088
6501
  },
5089
6502
  async run() {
5090
6503
  const projectDir = process.cwd();
5091
- const relayDir = join9(projectDir, ".relay");
5092
- const configPath = join9(relayDir, "config.json");
6504
+ const relayDir = join10(projectDir, ".relay");
6505
+ const configPath = join10(relayDir, "config.json");
5093
6506
  try {
5094
6507
  await access2(relayDir);
5095
6508
  logger.info(
@@ -5101,11 +6514,11 @@ function createInitCommand() {
5101
6514
  await mkdir6(relayDir, { recursive: true });
5102
6515
  await writeFile6(
5103
6516
  configPath,
5104
- JSON.stringify(DEFAULT_CONFIG2, null, 2) + "\n",
6517
+ JSON.stringify(DEFAULT_CONFIG4, null, 2) + "\n",
5105
6518
  "utf-8"
5106
6519
  );
5107
6520
  logger.success(`Created ${configPath}`);
5108
- const gitignorePath = join9(projectDir, ".gitignore");
6521
+ const gitignorePath = join10(projectDir, ".gitignore");
5109
6522
  try {
5110
6523
  const gitignoreContent = await readFile6(gitignorePath, "utf-8");
5111
6524
  if (!gitignoreContent.includes(".relay/config.local.json")) {
@@ -5131,8 +6544,8 @@ registry.registerLazy("claude", () => new ClaudeAdapter(processManager));
5131
6544
  registry.registerLazy("codex", () => new CodexAdapter(processManager));
5132
6545
  registry.registerLazy("gemini", () => new GeminiAdapter(processManager));
5133
6546
  var sessionManager = new SessionManager();
5134
- var relayHome = process.env["RELAY_HOME"] ?? join10(homedir6(), ".relay");
5135
- var projectRelayDir = join10(process.cwd(), ".relay");
6547
+ var relayHome = process.env["RELAY_HOME"] ?? join11(homedir7(), ".relay");
6548
+ var projectRelayDir = join11(process.cwd(), ".relay");
5136
6549
  var configManager = new ConfigManager(relayHome, projectRelayDir);
5137
6550
  var authManager = new AuthManager(registry);
5138
6551
  var eventBus = new EventBus();
@@ -5143,13 +6556,16 @@ void configManager.getConfig().then((config) => {
5143
6556
  hooksEngine.loadConfig(config.hooks);
5144
6557
  }
5145
6558
  if (config.contextMonitor) {
5146
- contextMonitor = new ContextMonitor(hooksEngine, config.contextMonitor);
6559
+ contextMonitor = new ContextMonitor(hooksEngine, {
6560
+ ...config.contextMonitor,
6561
+ memoryDir: config.hooks?.memoryDir
6562
+ });
5147
6563
  }
5148
6564
  }).catch((e) => logger.debug("Config load failed:", e));
5149
6565
  var main = defineCommand10({
5150
6566
  meta: {
5151
6567
  name: "relay",
5152
- version: "1.1.1",
6568
+ version: "1.3.0",
5153
6569
  description: "Unified CLI proxy for Claude Code, Codex CLI, and Gemini CLI"
5154
6570
  },
5155
6571
  subCommands: {