@rk0429/agentic-relay 1.1.1 → 1.2.0

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