@rk0429/agentic-relay 0.4.0 → 0.6.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 +874 -91
  2. package/package.json +1 -1
package/dist/relay.mjs CHANGED
@@ -158,19 +158,117 @@ var init_recursion_guard = __esm({
158
158
  // src/mcp-server/tools/spawn-agent.ts
159
159
  import { z as z2 } from "zod";
160
160
  import { nanoid as nanoid2 } from "nanoid";
161
+ import { existsSync, readFileSync } from "fs";
162
+ import { join as join6 } from "path";
163
+ function buildContextInjection(metadata) {
164
+ const parts = [];
165
+ if (metadata.stateContent && typeof metadata.stateContent === "string") {
166
+ parts.push(`<context-injection type="state">
167
+ ${metadata.stateContent}
168
+ </context-injection>`);
169
+ }
170
+ if (Array.isArray(metadata.results) && metadata.results.length > 0) {
171
+ const formatted = metadata.results.map((r, i) => `[${i + 1}] ${r.title} (${r.date}, score: ${r.score})
172
+ ${r.snippet}`).join("\n\n");
173
+ parts.push(`<context-injection type="search-results">
174
+ ${formatted}
175
+ </context-injection>`);
176
+ }
177
+ return parts.join("\n\n");
178
+ }
179
+ function readPreviousState(dailynoteDir) {
180
+ try {
181
+ const statePath = join6(dailynoteDir, "_state.md");
182
+ if (existsSync(statePath)) {
183
+ return readFileSync(statePath, "utf-8");
184
+ }
185
+ return null;
186
+ } catch (error) {
187
+ logger.warn(
188
+ `Failed to read _state.md: ${error instanceof Error ? error.message : String(error)}`
189
+ );
190
+ return null;
191
+ }
192
+ }
193
+ function readAgentDefinition(definitionPath) {
194
+ try {
195
+ if (!existsSync(definitionPath)) {
196
+ logger.warn(`Agent definition file not found at ${definitionPath}`);
197
+ return null;
198
+ }
199
+ const content = readFileSync(definitionPath, "utf-8");
200
+ if (content.trim().length === 0) {
201
+ logger.warn(`Agent definition file is empty at ${definitionPath}`);
202
+ return null;
203
+ }
204
+ return content;
205
+ } catch (error) {
206
+ logger.warn(
207
+ `Failed to read agent definition: ${error instanceof Error ? error.message : String(error)}`
208
+ );
209
+ return null;
210
+ }
211
+ }
212
+ function readSkillContext(skillContext) {
213
+ try {
214
+ const skillMdPath = join6(skillContext.skillPath, "SKILL.md");
215
+ if (!existsSync(skillMdPath)) {
216
+ logger.warn(
217
+ `SKILL.md not found at ${skillMdPath}`
218
+ );
219
+ return null;
220
+ }
221
+ const parts = [];
222
+ const skillContent = readFileSync(skillMdPath, "utf-8");
223
+ parts.push(skillContent);
224
+ if (skillContext.subskill) {
225
+ const subskillPath = join6(
226
+ skillContext.skillPath,
227
+ "subskills",
228
+ skillContext.subskill,
229
+ "SUBSKILL.md"
230
+ );
231
+ if (existsSync(subskillPath)) {
232
+ const subskillContent = readFileSync(subskillPath, "utf-8");
233
+ parts.push(subskillContent);
234
+ } else {
235
+ logger.warn(
236
+ `SUBSKILL.md not found at ${subskillPath}`
237
+ );
238
+ }
239
+ }
240
+ return parts.join("\n\n");
241
+ } catch (error) {
242
+ logger.warn(
243
+ `Failed to read skill context: ${error instanceof Error ? error.message : String(error)}`
244
+ );
245
+ return null;
246
+ }
247
+ }
161
248
  function buildContextFromEnv() {
162
249
  const traceId = process.env["RELAY_TRACE_ID"] ?? `trace-${nanoid2()}`;
163
250
  const parentSessionId = process.env["RELAY_PARENT_SESSION_ID"] ?? null;
164
251
  const depth = Number(process.env["RELAY_DEPTH"] ?? "0");
165
252
  return { traceId, parentSessionId, depth };
166
253
  }
167
- async function executeSpawnAgent(input, registry2, sessionManager2, guard, hooksEngine2, contextMonitor2) {
254
+ async function executeSpawnAgent(input, registry2, sessionManager2, guard, hooksEngine2, contextMonitor2, backendSelector) {
255
+ let effectiveBackend = input.backend;
256
+ if (backendSelector) {
257
+ const availableBackends = registry2.listIds();
258
+ const selectionContext = {
259
+ availableBackends,
260
+ preferredBackend: input.preferredBackend,
261
+ agentType: input.agent,
262
+ taskType: input.taskType
263
+ };
264
+ effectiveBackend = backendSelector.selectBackend(selectionContext);
265
+ }
168
266
  const envContext = buildContextFromEnv();
169
267
  const promptHash = RecursionGuard.hashPrompt(input.prompt);
170
268
  const context = {
171
269
  traceId: envContext.traceId,
172
270
  depth: envContext.depth,
173
- backend: input.backend,
271
+ backend: effectiveBackend,
174
272
  promptHash
175
273
  };
176
274
  const guardResult = guard.canSpawn(context);
@@ -183,27 +281,54 @@ async function executeSpawnAgent(input, registry2, sessionManager2, guard, hooks
183
281
  stderr: `Spawn blocked: ${guardResult.reason}`
184
282
  };
185
283
  }
186
- const adapter = registry2.get(input.backend);
284
+ const adapter = registry2.get(effectiveBackend);
187
285
  const installed = await adapter.isInstalled();
188
286
  if (!installed) {
189
287
  return {
190
288
  sessionId: "",
191
289
  exitCode: 1,
192
290
  stdout: "",
193
- stderr: `Backend "${input.backend}" is not installed`
291
+ stderr: `Backend "${effectiveBackend}" is not available. Use list_available_backends to see available options.`
194
292
  };
195
293
  }
294
+ const spawnStartedAt = (/* @__PURE__ */ new Date()).toISOString();
196
295
  const session = await sessionManager2.create({
197
- backendId: input.backend,
296
+ backendId: effectiveBackend,
198
297
  parentSessionId: envContext.parentSessionId ?? void 0,
199
298
  depth: envContext.depth + 1
200
299
  });
300
+ let collectedMetadata = {};
301
+ if (hooksEngine2 && !input.resumeSessionId) {
302
+ try {
303
+ const cwd = process.cwd();
304
+ const dailynoteDir = join6(cwd, "daily_note");
305
+ const hookInput = {
306
+ schemaVersion: "1.0",
307
+ event: "session-init",
308
+ sessionId: session.relaySessionId,
309
+ backendId: effectiveBackend,
310
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
311
+ data: {
312
+ workingDirectory: cwd,
313
+ dailynoteDir,
314
+ isFirstSession: !existsSync(join6(dailynoteDir, "_state.md")),
315
+ previousState: readPreviousState(dailynoteDir)
316
+ }
317
+ };
318
+ const { mergedMetadata } = await hooksEngine2.emitAndCollectMetadata("session-init", hookInput);
319
+ collectedMetadata = { ...collectedMetadata, ...mergedMetadata };
320
+ } catch (error) {
321
+ logger.warn(
322
+ `session-init hook error: ${error instanceof Error ? error.message : String(error)}`
323
+ );
324
+ }
325
+ }
201
326
  if (hooksEngine2) {
202
327
  try {
203
328
  const hookInput = {
204
329
  event: "pre-spawn",
205
330
  sessionId: session.relaySessionId,
206
- backendId: input.backend,
331
+ backendId: effectiveBackend,
207
332
  timestamp: (/* @__PURE__ */ new Date()).toISOString(),
208
333
  data: {
209
334
  prompt: input.prompt,
@@ -211,37 +336,75 @@ async function executeSpawnAgent(input, registry2, sessionManager2, guard, hooks
211
336
  model: input.model
212
337
  }
213
338
  };
214
- await hooksEngine2.emit("pre-spawn", hookInput);
339
+ const { mergedMetadata } = await hooksEngine2.emitAndCollectMetadata("pre-spawn", hookInput);
340
+ collectedMetadata = { ...collectedMetadata, ...mergedMetadata };
215
341
  } catch (error) {
216
342
  logger.debug(
217
343
  `pre-spawn hook error: ${error instanceof Error ? error.message : String(error)}`
218
344
  );
219
345
  }
220
346
  }
347
+ const injectionText = buildContextInjection(collectedMetadata);
348
+ let enhancedSystemPrompt = injectionText ? input.systemPrompt ? `${input.systemPrompt}
349
+
350
+ ${injectionText}` : injectionText : input.systemPrompt;
351
+ if (input.skillContext) {
352
+ const skillText = readSkillContext(input.skillContext);
353
+ if (skillText) {
354
+ const wrapped = `<skill-context path="${input.skillContext.skillPath}"${input.skillContext.subskill ? ` subskill="${input.skillContext.subskill}"` : ""}>
355
+ ${skillText}
356
+ </skill-context>`;
357
+ enhancedSystemPrompt = enhancedSystemPrompt ? `${enhancedSystemPrompt}
358
+
359
+ ${wrapped}` : wrapped;
360
+ }
361
+ }
362
+ if (input.agentDefinition) {
363
+ const defText = readAgentDefinition(input.agentDefinition.definitionPath);
364
+ if (defText) {
365
+ const wrapped = `<agent-definition source="${input.agentDefinition.definitionPath}">
366
+ ${defText}
367
+ </agent-definition>`;
368
+ enhancedSystemPrompt = enhancedSystemPrompt ? `${enhancedSystemPrompt}
369
+
370
+ ${wrapped}` : wrapped;
371
+ }
372
+ }
221
373
  try {
222
- const result = await adapter.execute({
223
- prompt: input.prompt,
224
- agent: input.agent,
225
- model: input.model,
226
- maxTurns: input.maxTurns,
227
- resume: input.resumeSessionId,
228
- mcpContext: {
229
- parentSessionId: session.relaySessionId,
230
- depth: envContext.depth + 1,
231
- maxDepth: guard.getConfig().maxDepth,
232
- traceId: envContext.traceId
374
+ let result;
375
+ if (input.resumeSessionId) {
376
+ if (!adapter.continueSession) {
377
+ return {
378
+ sessionId: session.relaySessionId,
379
+ exitCode: 1,
380
+ stdout: "",
381
+ stderr: `Backend "${effectiveBackend}" does not support session continuation (continueSession).`
382
+ };
233
383
  }
234
- });
384
+ result = await adapter.continueSession(input.resumeSessionId, input.prompt);
385
+ } else {
386
+ result = await adapter.execute({
387
+ prompt: input.prompt,
388
+ agent: input.agent,
389
+ systemPrompt: enhancedSystemPrompt,
390
+ model: input.model,
391
+ maxTurns: input.maxTurns,
392
+ mcpContext: {
393
+ parentSessionId: session.relaySessionId,
394
+ depth: envContext.depth + 1,
395
+ maxDepth: guard.getConfig().maxDepth,
396
+ traceId: envContext.traceId
397
+ }
398
+ });
399
+ }
235
400
  if (contextMonitor2) {
236
401
  const estimatedTokens = Math.ceil(
237
402
  (result.stdout.length + result.stderr.length) / 4
238
403
  );
239
- const maxTokens = input.backend === "gemini" ? 128e3 : 2e5;
240
404
  contextMonitor2.updateUsage(
241
405
  session.relaySessionId,
242
- input.backend,
243
- estimatedTokens,
244
- maxTokens
406
+ effectiveBackend,
407
+ estimatedTokens
245
408
  );
246
409
  }
247
410
  guard.recordSpawn(context);
@@ -249,15 +412,27 @@ async function executeSpawnAgent(input, registry2, sessionManager2, guard, hooks
249
412
  await sessionManager2.update(session.relaySessionId, { status });
250
413
  if (hooksEngine2) {
251
414
  try {
415
+ const postSpawnData = {
416
+ exitCode: result.exitCode,
417
+ status,
418
+ taskDescription: input.prompt,
419
+ startedAt: spawnStartedAt,
420
+ backgroundMode: false,
421
+ sessionId: session.relaySessionId,
422
+ selectedBackend: effectiveBackend
423
+ };
424
+ if (input.agent !== void 0) {
425
+ postSpawnData.agentType = input.agent;
426
+ }
427
+ if (input.model !== void 0) {
428
+ postSpawnData.model = input.model;
429
+ }
252
430
  const hookInput = {
253
431
  event: "post-spawn",
254
432
  sessionId: session.relaySessionId,
255
- backendId: input.backend,
433
+ backendId: effectiveBackend,
256
434
  timestamp: (/* @__PURE__ */ new Date()).toISOString(),
257
- data: {
258
- exitCode: result.exitCode,
259
- status
260
- }
435
+ data: postSpawnData
261
436
  };
262
437
  await hooksEngine2.emit("post-spawn", hookInput);
263
438
  } catch (hookError) {
@@ -270,7 +445,8 @@ async function executeSpawnAgent(input, registry2, sessionManager2, guard, hooks
270
445
  sessionId: session.relaySessionId,
271
446
  exitCode: result.exitCode,
272
447
  stdout: result.stdout,
273
- stderr: result.stderr
448
+ stderr: result.stderr,
449
+ nativeSessionId: result.nativeSessionId
274
450
  };
275
451
  } catch (error) {
276
452
  await sessionManager2.update(session.relaySessionId, { status: "error" });
@@ -293,9 +469,19 @@ var init_spawn_agent = __esm({
293
469
  backend: z2.enum(["claude", "codex", "gemini"]),
294
470
  prompt: z2.string(),
295
471
  agent: z2.string().optional(),
472
+ systemPrompt: z2.string().optional(),
296
473
  resumeSessionId: z2.string().optional(),
297
474
  model: z2.string().optional(),
298
- maxTurns: z2.number().optional()
475
+ maxTurns: z2.number().optional(),
476
+ skillContext: z2.object({
477
+ skillPath: z2.string().describe("Path to the skill directory (e.g., '.agents/skills/software-engineer/')"),
478
+ subskill: z2.string().optional().describe("Specific subskill to activate")
479
+ }).optional().describe("Skill context to inject into the subagent's system prompt"),
480
+ agentDefinition: z2.object({
481
+ definitionPath: z2.string().describe("Path to the agent definition file (e.g., '.claude/agents/software-engineer.md')")
482
+ }).optional().describe("Agent definition file to inject into the sub-agent's system prompt"),
483
+ preferredBackend: z2.enum(["claude", "codex", "gemini"]).optional().describe("Preferred backend override. Takes priority over automatic selection based on agent/task type."),
484
+ taskType: z2.enum(["code", "document", "analysis", "mixed"]).optional().describe("Task type hint for automatic backend selection when preferredBackend is not specified.")
299
485
  });
300
486
  }
301
487
  });
@@ -339,8 +525,14 @@ async function executeGetContextStatus(input, sessionManager2, contextMonitor2)
339
525
  if (usage) {
340
526
  return {
341
527
  sessionId: input.sessionId,
528
+ backendId: usage.backendId,
342
529
  usagePercent: usage.usagePercent,
343
- isEstimated: usage.isEstimated
530
+ isEstimated: usage.isEstimated,
531
+ contextWindow: usage.contextWindow,
532
+ compactThreshold: usage.compactThreshold,
533
+ estimatedTokens: usage.estimatedTokens,
534
+ remainingBeforeCompact: usage.remainingBeforeCompact,
535
+ notifyThreshold: usage.notifyThreshold
344
536
  };
345
537
  }
346
538
  }
@@ -360,6 +552,83 @@ var init_get_context_status = __esm({
360
552
  }
361
553
  });
362
554
 
555
+ // src/mcp-server/tools/list-available-backends.ts
556
+ async function executeListAvailableBackends(registry2) {
557
+ const backends = [];
558
+ for (const adapter of registry2.list()) {
559
+ const health = await adapter.checkHealth();
560
+ backends.push({
561
+ id: adapter.id,
562
+ ...health
563
+ });
564
+ }
565
+ return backends;
566
+ }
567
+ var init_list_available_backends = __esm({
568
+ "src/mcp-server/tools/list-available-backends.ts"() {
569
+ "use strict";
570
+ }
571
+ });
572
+
573
+ // src/core/backend-selector.ts
574
+ var DEFAULT_AGENT_TO_BACKEND_MAP, TASK_TYPE_TO_BACKEND_MAP, DEFAULT_BACKEND, BackendSelector;
575
+ var init_backend_selector = __esm({
576
+ "src/core/backend-selector.ts"() {
577
+ "use strict";
578
+ DEFAULT_AGENT_TO_BACKEND_MAP = {
579
+ "software-engineer": "codex",
580
+ "devops-engineer": "codex",
581
+ "skill-developer": "codex",
582
+ "security-auditor": "codex",
583
+ "analytics-engineer": "codex",
584
+ "payments-billing": "codex"
585
+ };
586
+ TASK_TYPE_TO_BACKEND_MAP = {
587
+ code: "codex",
588
+ document: "claude",
589
+ analysis: "claude",
590
+ mixed: "claude"
591
+ };
592
+ DEFAULT_BACKEND = "claude";
593
+ BackendSelector = class {
594
+ defaultBackend;
595
+ agentToBackendMap;
596
+ constructor(config) {
597
+ this.defaultBackend = config?.defaultBackend ?? DEFAULT_BACKEND;
598
+ this.agentToBackendMap = config?.agentToBackendMap ?? DEFAULT_AGENT_TO_BACKEND_MAP;
599
+ }
600
+ selectBackend(context) {
601
+ const { availableBackends, preferredBackend, agentType, taskType } = context;
602
+ if (availableBackends.length === 0) {
603
+ throw new Error("No backends available");
604
+ }
605
+ if (preferredBackend && availableBackends.includes(preferredBackend)) {
606
+ return preferredBackend;
607
+ }
608
+ if (agentType) {
609
+ const mapped = this.agentToBackendMap[agentType];
610
+ if (mapped && availableBackends.includes(mapped)) {
611
+ return mapped;
612
+ }
613
+ if (!mapped && availableBackends.includes("claude")) {
614
+ return "claude";
615
+ }
616
+ }
617
+ if (taskType) {
618
+ const mapped = TASK_TYPE_TO_BACKEND_MAP[taskType];
619
+ if (mapped && availableBackends.includes(mapped)) {
620
+ return mapped;
621
+ }
622
+ }
623
+ if (availableBackends.includes(this.defaultBackend)) {
624
+ return this.defaultBackend;
625
+ }
626
+ return availableBackends[0];
627
+ }
628
+ };
629
+ }
630
+ });
631
+
363
632
  // src/mcp-server/server.ts
364
633
  var server_exports = {};
365
634
  __export(server_exports, {
@@ -379,6 +648,8 @@ var init_server = __esm({
379
648
  init_spawn_agent();
380
649
  init_list_sessions();
381
650
  init_get_context_status();
651
+ init_list_available_backends();
652
+ init_backend_selector();
382
653
  init_logger();
383
654
  RelayMCPServer = class {
384
655
  constructor(registry2, sessionManager2, guardConfig, hooksEngine2, contextMonitor2) {
@@ -387,6 +658,7 @@ var init_server = __esm({
387
658
  this.hooksEngine = hooksEngine2;
388
659
  this.contextMonitor = contextMonitor2;
389
660
  this.guard = new RecursionGuard(guardConfig);
661
+ this.backendSelector = new BackendSelector();
390
662
  this.server = new McpServer({
391
663
  name: "agentic-relay",
392
664
  version: "0.4.0"
@@ -395,17 +667,30 @@ var init_server = __esm({
395
667
  }
396
668
  server;
397
669
  guard;
670
+ backendSelector;
398
671
  registerTools() {
399
672
  this.server.tool(
400
673
  "spawn_agent",
401
- "Spawn a sub-agent on the specified backend CLI (Claude Code, Codex CLI, or Gemini CLI). The agent executes the given prompt in non-interactive mode and returns the result.",
674
+ "Spawn a sub-agent on the specified backend CLI (Claude Code, Codex CLI, or Gemini CLI). The agent executes the given prompt in non-interactive mode and returns the result. Use 'agent' for named agent configurations (Claude only), 'systemPrompt' for custom role instructions (all backends), 'skillContext' to inject a skill definition (SKILL.md/SUBSKILL.md), 'agentDefinition' to inject an agent definition file into the sub-agent's system prompt, 'preferredBackend' to override automatic backend selection, or 'taskType' to hint at the task nature for backend selection.",
402
675
  {
403
676
  backend: z5.enum(["claude", "codex", "gemini"]),
404
677
  prompt: z5.string(),
405
- agent: z5.string().optional(),
678
+ agent: z5.string().optional().describe("Named agent configuration (Claude only)"),
679
+ systemPrompt: z5.string().optional().describe(
680
+ "System prompt / role instructions for the sub-agent (all backends)"
681
+ ),
406
682
  resumeSessionId: z5.string().optional(),
407
683
  model: z5.string().optional(),
408
- maxTurns: z5.number().optional()
684
+ maxTurns: z5.number().optional(),
685
+ skillContext: z5.object({
686
+ skillPath: z5.string().describe("Path to the skill directory (e.g., '.agents/skills/software-engineer/')"),
687
+ subskill: z5.string().optional().describe("Specific subskill to activate")
688
+ }).optional().describe("Skill context to inject into the sub-agent's system prompt"),
689
+ agentDefinition: z5.object({
690
+ definitionPath: z5.string().describe("Path to the agent definition file (e.g., '.claude/agents/software-engineer.md')")
691
+ }).optional().describe("Agent definition file to inject into the sub-agent's system prompt"),
692
+ preferredBackend: z5.enum(["claude", "codex", "gemini"]).optional().describe("Preferred backend override. Takes priority over automatic selection based on agent/task type."),
693
+ taskType: z5.enum(["code", "document", "analysis", "mixed"]).optional().describe("Task type hint for automatic backend selection when preferredBackend is not specified.")
409
694
  },
410
695
  async (params) => {
411
696
  try {
@@ -415,7 +700,8 @@ var init_server = __esm({
415
700
  this.sessionManager,
416
701
  this.guard,
417
702
  this.hooksEngine,
418
- this.contextMonitor
703
+ this.contextMonitor,
704
+ this.backendSelector
419
705
  );
420
706
  const isError = result.exitCode !== 0;
421
707
  const text = isError ? `Error (exit ${result.exitCode}): ${result.stderr || result.stdout}` : `Session: ${result.sessionId}
@@ -494,6 +780,30 @@ ${result.stdout}`;
494
780
  }
495
781
  }
496
782
  );
783
+ this.server.tool(
784
+ "list_available_backends",
785
+ "List all registered backends with their health status. Use this before spawn_agent to check which backends are available.",
786
+ {},
787
+ async () => {
788
+ try {
789
+ const result = await executeListAvailableBackends(this.registry);
790
+ return {
791
+ content: [
792
+ {
793
+ type: "text",
794
+ text: JSON.stringify(result, null, 2)
795
+ }
796
+ ]
797
+ };
798
+ } catch (error) {
799
+ const message = error instanceof Error ? error.message : String(error);
800
+ return {
801
+ content: [{ type: "text", text: `Error: ${message}` }],
802
+ isError: true
803
+ };
804
+ }
805
+ }
806
+ );
497
807
  }
498
808
  async start(options) {
499
809
  const transportType = options?.transport ?? "stdio";
@@ -542,7 +852,7 @@ ${result.stdout}`;
542
852
 
543
853
  // src/bin/relay.ts
544
854
  import { defineCommand as defineCommand10, runMain } from "citty";
545
- import { join as join8 } from "path";
855
+ import { join as join9 } from "path";
546
856
  import { homedir as homedir6 } from "os";
547
857
 
548
858
  // src/infrastructure/process-manager.ts
@@ -671,6 +981,10 @@ var AdapterRegistry = class {
671
981
  this.factories.clear();
672
982
  return [...this.adapters.values()];
673
983
  }
984
+ /** Return registered backend IDs without instantiating lazy adapters */
985
+ listIds() {
986
+ return [.../* @__PURE__ */ new Set([...this.adapters.keys(), ...this.factories.keys()])];
987
+ }
674
988
  };
675
989
 
676
990
  // src/adapters/base-adapter.ts
@@ -698,6 +1012,42 @@ var BaseAdapter = class {
698
1012
  }
699
1013
  return result.stdout.trim();
700
1014
  }
1015
+ async continueSession(_nativeSessionId, _prompt) {
1016
+ return {
1017
+ exitCode: 1,
1018
+ stdout: "",
1019
+ stderr: `continueSession not supported for ${this.id}`
1020
+ };
1021
+ }
1022
+ async checkHealth() {
1023
+ const HEALTH_TIMEOUT = 5e3;
1024
+ const installed = await Promise.race([
1025
+ this.isInstalled(),
1026
+ new Promise(
1027
+ (_, reject) => setTimeout(() => reject(new Error("timeout")), HEALTH_TIMEOUT)
1028
+ )
1029
+ ]).catch(() => false);
1030
+ if (!installed) {
1031
+ return {
1032
+ installed: false,
1033
+ authenticated: false,
1034
+ healthy: false,
1035
+ message: `${this.id} is not installed`
1036
+ };
1037
+ }
1038
+ const version = await Promise.race([
1039
+ this.getVersion(),
1040
+ new Promise(
1041
+ (_, reject) => setTimeout(() => reject(new Error("timeout")), HEALTH_TIMEOUT)
1042
+ )
1043
+ ]).catch(() => void 0);
1044
+ return {
1045
+ installed: true,
1046
+ authenticated: true,
1047
+ healthy: true,
1048
+ version
1049
+ };
1050
+ }
701
1051
  async getMCPConfig() {
702
1052
  logger.warn(`getMCPConfig not implemented for ${this.id}`);
703
1053
  return [];
@@ -799,6 +1149,48 @@ var ClaudeAdapter = class extends BaseAdapter {
799
1149
  getConfigPath() {
800
1150
  return join(homedir(), ".claude.json");
801
1151
  }
1152
+ async checkHealth() {
1153
+ const HEALTH_TIMEOUT = 5e3;
1154
+ const installed = await Promise.race([
1155
+ this.isInstalled(),
1156
+ new Promise(
1157
+ (_, reject) => setTimeout(() => reject(new Error("timeout")), HEALTH_TIMEOUT)
1158
+ )
1159
+ ]).catch(() => false);
1160
+ if (!installed) {
1161
+ return {
1162
+ installed: false,
1163
+ authenticated: false,
1164
+ healthy: false,
1165
+ message: "claude is not installed"
1166
+ };
1167
+ }
1168
+ const version = await Promise.race([
1169
+ this.getVersion(),
1170
+ new Promise(
1171
+ (_, reject) => setTimeout(() => reject(new Error("timeout")), HEALTH_TIMEOUT)
1172
+ )
1173
+ ]).catch(() => void 0);
1174
+ let authenticated = true;
1175
+ try {
1176
+ const result = await Promise.race([
1177
+ this.processManager.execute(this.command, ["auth", "status"]),
1178
+ new Promise(
1179
+ (_, reject) => setTimeout(() => reject(new Error("timeout")), HEALTH_TIMEOUT)
1180
+ )
1181
+ ]);
1182
+ authenticated = result.exitCode === 0;
1183
+ } catch {
1184
+ authenticated = true;
1185
+ }
1186
+ return {
1187
+ installed: true,
1188
+ authenticated,
1189
+ healthy: authenticated,
1190
+ version,
1191
+ ...!authenticated ? { message: "claude authentication not configured" } : {}
1192
+ };
1193
+ }
802
1194
  mapFlags(flags) {
803
1195
  return {
804
1196
  args: mapCommonToNative("claude", flags)
@@ -825,7 +1217,8 @@ var ClaudeAdapter = class extends BaseAdapter {
825
1217
  env,
826
1218
  cwd: process.cwd(),
827
1219
  ...flags.model ? { model: flags.model } : {},
828
- ...flags.maxTurns ? { maxTurns: flags.maxTurns } : {}
1220
+ ...flags.maxTurns ? { maxTurns: flags.maxTurns } : {},
1221
+ ...flags.systemPrompt ? { systemPrompt: flags.systemPrompt } : {}
829
1222
  };
830
1223
  if (permissionMode === "bypassPermissions") {
831
1224
  options.permissionMode = "bypassPermissions";
@@ -877,7 +1270,8 @@ var ClaudeAdapter = class extends BaseAdapter {
877
1270
  env,
878
1271
  cwd: process.cwd(),
879
1272
  ...flags.model ? { model: flags.model } : {},
880
- ...flags.maxTurns ? { maxTurns: flags.maxTurns } : {}
1273
+ ...flags.maxTurns ? { maxTurns: flags.maxTurns } : {},
1274
+ ...flags.systemPrompt ? { systemPrompt: flags.systemPrompt } : {}
881
1275
  };
882
1276
  if (permissionMode === "bypassPermissions") {
883
1277
  options.permissionMode = "bypassPermissions";
@@ -942,6 +1336,44 @@ var ClaudeAdapter = class extends BaseAdapter {
942
1336
  };
943
1337
  }
944
1338
  }
1339
+ async continueSession(nativeSessionId, prompt) {
1340
+ try {
1341
+ const { query } = await loadClaudeSDK();
1342
+ const permissionMode = this.getPermissionMode();
1343
+ const options = {
1344
+ resume: nativeSessionId,
1345
+ maxTurns: 1,
1346
+ cwd: process.cwd()
1347
+ };
1348
+ if (permissionMode === "bypassPermissions") {
1349
+ options.permissionMode = "bypassPermissions";
1350
+ options.allowDangerouslySkipPermissions = true;
1351
+ }
1352
+ const q = query({
1353
+ prompt,
1354
+ options
1355
+ });
1356
+ let resultText = "";
1357
+ for await (const message of q) {
1358
+ if (message.type === "result") {
1359
+ if (message.subtype === "success") {
1360
+ resultText = message.result;
1361
+ }
1362
+ }
1363
+ }
1364
+ return {
1365
+ exitCode: 0,
1366
+ stdout: resultText,
1367
+ stderr: ""
1368
+ };
1369
+ } catch (error) {
1370
+ return {
1371
+ exitCode: 1,
1372
+ stdout: "",
1373
+ stderr: error instanceof Error ? error.message : String(error)
1374
+ };
1375
+ }
1376
+ }
945
1377
  async resumeSession(sessionId, flags) {
946
1378
  await this.processManager.spawnInteractive(
947
1379
  this.command,
@@ -1157,6 +1589,48 @@ var CodexAdapter = class extends BaseAdapter {
1157
1589
  getConfigPath() {
1158
1590
  return join2(homedir2(), ".codex", "config.toml");
1159
1591
  }
1592
+ async checkHealth() {
1593
+ const HEALTH_TIMEOUT = 5e3;
1594
+ const installed = await Promise.race([
1595
+ this.isInstalled(),
1596
+ new Promise(
1597
+ (_, reject) => setTimeout(() => reject(new Error("timeout")), HEALTH_TIMEOUT)
1598
+ )
1599
+ ]).catch(() => false);
1600
+ if (!installed) {
1601
+ return {
1602
+ installed: false,
1603
+ authenticated: false,
1604
+ healthy: false,
1605
+ message: "codex is not installed"
1606
+ };
1607
+ }
1608
+ const version = await Promise.race([
1609
+ this.getVersion(),
1610
+ new Promise(
1611
+ (_, reject) => setTimeout(() => reject(new Error("timeout")), HEALTH_TIMEOUT)
1612
+ )
1613
+ ]).catch(() => void 0);
1614
+ let authenticated = true;
1615
+ try {
1616
+ const result = await Promise.race([
1617
+ this.processManager.execute(this.command, ["login", "status"]),
1618
+ new Promise(
1619
+ (_, reject) => setTimeout(() => reject(new Error("timeout")), HEALTH_TIMEOUT)
1620
+ )
1621
+ ]);
1622
+ authenticated = result.exitCode === 0;
1623
+ } catch {
1624
+ authenticated = true;
1625
+ }
1626
+ return {
1627
+ installed: true,
1628
+ authenticated,
1629
+ healthy: authenticated,
1630
+ version,
1631
+ ...!authenticated ? { message: "codex authentication not configured" } : {}
1632
+ };
1633
+ }
1160
1634
  mapFlags(flags) {
1161
1635
  const args = mapCommonToNative("codex", flags);
1162
1636
  if (flags.outputFormat === "json") {
@@ -1174,15 +1648,39 @@ var CodexAdapter = class extends BaseAdapter {
1174
1648
  }
1175
1649
  await this.processManager.spawnInteractive(this.command, args);
1176
1650
  }
1651
+ /**
1652
+ * Resolve the effective system prompt from flags.
1653
+ * Priority: systemPrompt > agent fallback > none
1654
+ */
1655
+ resolveSystemPrompt(flags) {
1656
+ if (flags.systemPrompt) return flags.systemPrompt;
1657
+ if (flags.agent) {
1658
+ return `You are acting as the "${flags.agent}" agent. Follow the instructions and role defined for this agent.`;
1659
+ }
1660
+ return void 0;
1661
+ }
1662
+ /**
1663
+ * Build the effective prompt with system instructions prepended if needed.
1664
+ * Codex SDK does not support a native instructions/systemPrompt parameter,
1665
+ * so we inject role context via a prompt prefix.
1666
+ */
1667
+ buildEffectivePrompt(prompt, systemPrompt) {
1668
+ if (!systemPrompt) return prompt;
1669
+ return `[System Instructions]
1670
+ ${systemPrompt}
1671
+
1672
+ [User Request]
1673
+ ${prompt}`;
1674
+ }
1177
1675
  async execute(flags) {
1178
1676
  if (!flags.prompt) {
1179
1677
  throw new Error("execute requires a prompt (-p flag)");
1180
1678
  }
1181
- if (flags.agent) {
1182
- logger.warn(
1183
- `Codex CLI does not support --agent flag. Ignoring agent "${flags.agent}".`
1184
- );
1185
- }
1679
+ const systemPrompt = this.resolveSystemPrompt(flags);
1680
+ const effectivePrompt = this.buildEffectivePrompt(
1681
+ flags.prompt,
1682
+ systemPrompt
1683
+ );
1186
1684
  try {
1187
1685
  const { Codex } = await loadCodexSDK();
1188
1686
  const codexOptions = {};
@@ -1200,11 +1698,12 @@ var CodexAdapter = class extends BaseAdapter {
1200
1698
  workingDirectory: process.cwd(),
1201
1699
  approvalPolicy: "never"
1202
1700
  });
1203
- const result = await thread.run(flags.prompt);
1701
+ const result = await thread.run(effectivePrompt);
1204
1702
  return {
1205
1703
  exitCode: 0,
1206
1704
  stdout: result.finalResponse,
1207
- stderr: ""
1705
+ stderr: "",
1706
+ ...thread.id ? { nativeSessionId: thread.id } : {}
1208
1707
  };
1209
1708
  } catch (error) {
1210
1709
  return {
@@ -1218,11 +1717,11 @@ var CodexAdapter = class extends BaseAdapter {
1218
1717
  if (!flags.prompt) {
1219
1718
  throw new Error("executeStreaming requires a prompt (-p flag)");
1220
1719
  }
1221
- if (flags.agent) {
1222
- logger.warn(
1223
- `Codex CLI does not support --agent flag. Ignoring agent "${flags.agent}".`
1224
- );
1225
- }
1720
+ const systemPrompt = this.resolveSystemPrompt(flags);
1721
+ const effectivePrompt = this.buildEffectivePrompt(
1722
+ flags.prompt,
1723
+ systemPrompt
1724
+ );
1226
1725
  try {
1227
1726
  const { Codex } = await loadCodexSDK();
1228
1727
  const codexOptions = {};
@@ -1240,10 +1739,13 @@ var CodexAdapter = class extends BaseAdapter {
1240
1739
  workingDirectory: process.cwd(),
1241
1740
  approvalPolicy: "never"
1242
1741
  });
1243
- const streamedTurn = await thread.runStreamed(flags.prompt);
1742
+ const streamedTurn = await thread.runStreamed(effectivePrompt);
1244
1743
  const completedMessages = [];
1744
+ let threadId;
1245
1745
  for await (const event of streamedTurn.events) {
1246
- if (event.type === "item.started") {
1746
+ if (event.type === "thread.started") {
1747
+ threadId = event.thread_id;
1748
+ } else if (event.type === "item.started") {
1247
1749
  const item = event.item;
1248
1750
  if (item?.type === "agent_message" && item.text) {
1249
1751
  yield { type: "text", text: item.text };
@@ -1291,13 +1793,15 @@ var CodexAdapter = class extends BaseAdapter {
1291
1793
  const finalResponse = completedMessages.join("\n");
1292
1794
  yield {
1293
1795
  type: "done",
1294
- result: { exitCode: 0, stdout: finalResponse, stderr: "" }
1796
+ result: { exitCode: 0, stdout: finalResponse, stderr: "" },
1797
+ nativeSessionId: threadId ?? thread.id ?? void 0
1295
1798
  };
1296
1799
  } else if (event.type === "turn.failed") {
1297
1800
  const errorMessage = event.error?.message ?? "Turn failed";
1298
1801
  yield {
1299
1802
  type: "done",
1300
- result: { exitCode: 1, stdout: "", stderr: errorMessage }
1803
+ result: { exitCode: 1, stdout: "", stderr: errorMessage },
1804
+ nativeSessionId: threadId ?? thread.id ?? void 0
1301
1805
  };
1302
1806
  } else if (event.type === "error") {
1303
1807
  yield {
@@ -1315,6 +1819,28 @@ var CodexAdapter = class extends BaseAdapter {
1315
1819
  };
1316
1820
  }
1317
1821
  }
1822
+ async continueSession(nativeSessionId, prompt) {
1823
+ try {
1824
+ const { Codex } = await loadCodexSDK();
1825
+ const codex = new Codex();
1826
+ const thread = codex.resumeThread(nativeSessionId, {
1827
+ workingDirectory: process.cwd(),
1828
+ approvalPolicy: "never"
1829
+ });
1830
+ const result = await thread.run(prompt);
1831
+ return {
1832
+ exitCode: 0,
1833
+ stdout: result.finalResponse,
1834
+ stderr: ""
1835
+ };
1836
+ } catch (error) {
1837
+ return {
1838
+ exitCode: 1,
1839
+ stdout: "",
1840
+ stderr: error instanceof Error ? error.message : String(error)
1841
+ };
1842
+ }
1843
+ }
1318
1844
  async resumeSession(sessionId, flags) {
1319
1845
  const args = [];
1320
1846
  if (flags.model) {
@@ -1408,6 +1934,38 @@ var GeminiAdapter = class extends BaseAdapter {
1408
1934
  getConfigPath() {
1409
1935
  return join3(homedir3(), ".gemini", "settings.json");
1410
1936
  }
1937
+ async checkHealth() {
1938
+ const HEALTH_TIMEOUT = 5e3;
1939
+ const installed = await Promise.race([
1940
+ this.isInstalled(),
1941
+ new Promise(
1942
+ (_, reject) => setTimeout(() => reject(new Error("timeout")), HEALTH_TIMEOUT)
1943
+ )
1944
+ ]).catch(() => false);
1945
+ if (!installed) {
1946
+ return {
1947
+ installed: false,
1948
+ authenticated: false,
1949
+ healthy: false,
1950
+ message: "gemini is not installed"
1951
+ };
1952
+ }
1953
+ const version = await Promise.race([
1954
+ this.getVersion(),
1955
+ new Promise(
1956
+ (_, reject) => setTimeout(() => reject(new Error("timeout")), HEALTH_TIMEOUT)
1957
+ )
1958
+ ]).catch(() => void 0);
1959
+ const hasApiKey = !!process.env["GEMINI_API_KEY"];
1960
+ const hasGoogleAdc = !!process.env["GOOGLE_APPLICATION_CREDENTIALS"] || !!process.env["CLOUDSDK_CONFIG"];
1961
+ const authenticated = hasApiKey || hasGoogleAdc || true;
1962
+ return {
1963
+ installed: true,
1964
+ authenticated,
1965
+ healthy: true,
1966
+ version
1967
+ };
1968
+ }
1411
1969
  mapFlags(flags) {
1412
1970
  const args = mapCommonToNative("gemini", flags);
1413
1971
  if (flags.outputFormat) {
@@ -1428,15 +1986,34 @@ var GeminiAdapter = class extends BaseAdapter {
1428
1986
  }
1429
1987
  await this.processManager.spawnInteractive(this.command, args);
1430
1988
  }
1989
+ /**
1990
+ * Resolve the effective prompt with system instructions prepended if needed.
1991
+ * Gemini CLI has no native system prompt flag, so we use a prompt prefix.
1992
+ * Priority: systemPrompt > agent fallback > none
1993
+ */
1994
+ buildEffectivePrompt(flags) {
1995
+ const prompt = flags.prompt;
1996
+ if (flags.systemPrompt) {
1997
+ return `[System Instructions]
1998
+ ${flags.systemPrompt}
1999
+
2000
+ [User Request]
2001
+ ${prompt}`;
2002
+ }
2003
+ if (flags.agent) {
2004
+ return `[System Instructions]
2005
+ You are acting as the "${flags.agent}" agent.
2006
+
2007
+ [User Request]
2008
+ ${prompt}`;
2009
+ }
2010
+ return prompt;
2011
+ }
1431
2012
  async execute(flags) {
1432
2013
  if (!flags.prompt) {
1433
2014
  throw new Error("execute requires a prompt (-p flag)");
1434
2015
  }
1435
- if (flags.agent) {
1436
- logger.warn(
1437
- `Gemini CLI does not support --agent flag. Ignoring agent "${flags.agent}".`
1438
- );
1439
- }
2016
+ const effectivePrompt = this.buildEffectivePrompt(flags);
1440
2017
  const args = [];
1441
2018
  if (flags.model) {
1442
2019
  args.push("--model", flags.model);
@@ -1447,7 +2024,7 @@ var GeminiAdapter = class extends BaseAdapter {
1447
2024
  if (flags.verbose) {
1448
2025
  args.push("--verbose");
1449
2026
  }
1450
- args.push("-p", flags.prompt);
2027
+ args.push("-p", effectivePrompt);
1451
2028
  return this.processManager.execute(this.command, args);
1452
2029
  }
1453
2030
  async resumeSession(sessionId, flags) {
@@ -1703,6 +2280,7 @@ var mcpServerConfigSchema = z.object({
1703
2280
  env: z.record(z.string()).optional()
1704
2281
  });
1705
2282
  var hookEventSchema = z.enum([
2283
+ "session-init",
1706
2284
  "pre-prompt",
1707
2285
  "post-response",
1708
2286
  "on-error",
@@ -1718,9 +2296,24 @@ var hookDefinitionSchema = z.object({
1718
2296
  enabled: z.boolean().optional(),
1719
2297
  onError: z.enum(["ignore", "warn", "abort"]).optional()
1720
2298
  });
2299
+ var hookChainStepSchema = z.object({
2300
+ stepId: z.string(),
2301
+ hook: hookDefinitionSchema,
2302
+ pipeOutput: z.boolean().optional()
2303
+ });
2304
+ var hookChainSchema = z.object({
2305
+ name: z.string(),
2306
+ steps: z.array(hookChainStepSchema).min(1),
2307
+ onError: z.enum(["ignore", "warn", "fail"]).optional()
2308
+ });
1721
2309
  var hooksConfigSchema = z.object({
1722
- definitions: z.array(hookDefinitionSchema)
2310
+ definitions: z.array(hookDefinitionSchema),
2311
+ chains: z.array(hookChainSchema).optional()
1723
2312
  });
2313
+ var backendContextConfigSchema = z.object({
2314
+ contextWindow: z.number().positive().optional(),
2315
+ compactThreshold: z.number().positive().optional()
2316
+ }).optional();
1724
2317
  var relayConfigSchema = z.object({
1725
2318
  defaultBackend: backendIdSchema.optional(),
1726
2319
  mcpServers: z.record(mcpServerConfigSchema).optional(),
@@ -1731,9 +2324,16 @@ var relayConfigSchema = z.object({
1731
2324
  }).optional(),
1732
2325
  hooks: hooksConfigSchema.optional(),
1733
2326
  contextMonitor: z.object({
1734
- enabled: z.boolean(),
1735
- thresholdPercent: z.number().min(0).max(100),
1736
- notifyMethod: z.enum(["stderr", "hook"])
2327
+ enabled: z.boolean().optional(),
2328
+ thresholdPercent: z.number().min(0).max(100).optional(),
2329
+ notifyThreshold: z.number().positive().optional(),
2330
+ notifyPercent: z.number().min(0).max(100).optional(),
2331
+ notifyMethod: z.enum(["stderr", "hook"]).optional(),
2332
+ backends: z.object({
2333
+ claude: backendContextConfigSchema,
2334
+ codex: backendContextConfigSchema,
2335
+ gemini: backendContextConfigSchema
2336
+ }).optional()
1737
2337
  }).optional(),
1738
2338
  mcpServerMode: z.object({
1739
2339
  maxDepth: z.number().int().positive(),
@@ -2006,6 +2606,7 @@ var HooksEngine = class _HooksEngine {
2006
2606
  static COMMAND_PATTERN = /^[a-zA-Z0-9_./-]+$/;
2007
2607
  static ARG_PATTERN = /^[a-zA-Z0-9_.=:/-]+$/;
2008
2608
  definitions = [];
2609
+ chains = [];
2009
2610
  registered = false;
2010
2611
  validateCommand(command) {
2011
2612
  if (!command || command.trim().length === 0) {
@@ -2046,6 +2647,7 @@ var HooksEngine = class _HooksEngine {
2046
2647
  return false;
2047
2648
  }
2048
2649
  });
2650
+ this.chains = config.chains ?? [];
2049
2651
  if (!this.registered) {
2050
2652
  for (const event of this.getUniqueEvents()) {
2051
2653
  this.eventBus.on(event, async () => {
@@ -2087,10 +2689,132 @@ var HooksEngine = class _HooksEngine {
2087
2689
  await this.eventBus.emit(event, input);
2088
2690
  return results;
2089
2691
  }
2692
+ /**
2693
+ * Emit a hook event, execute all matching hooks, and return merged metadata.
2694
+ * Merge rule: later hooks overwrite earlier hooks' metadata fields.
2695
+ * If any hook returns allow: false, subsequent hooks are skipped.
2696
+ */
2697
+ async emitAndCollectMetadata(event, hookInput) {
2698
+ const matchingDefs = this.definitions.filter((def) => def.event === event);
2699
+ const results = [];
2700
+ let mergedMetadata = {};
2701
+ for (const def of matchingDefs) {
2702
+ try {
2703
+ const result = await this.executeHook(def, hookInput);
2704
+ results.push(result);
2705
+ if (result.output.metadata && typeof result.output.metadata === "object") {
2706
+ mergedMetadata = { ...mergedMetadata, ...result.output.metadata };
2707
+ }
2708
+ if (!result.output.allow) {
2709
+ logger.info(
2710
+ `Hook "${def.command}" returned allow: false, skipping subsequent hooks for event "${event}"`
2711
+ );
2712
+ break;
2713
+ }
2714
+ } catch (error) {
2715
+ const strategy = def.onError ?? "warn";
2716
+ const message = error instanceof Error ? error.message : String(error);
2717
+ if (strategy === "abort") {
2718
+ throw new Error(
2719
+ `Hook "${def.command}" failed (abort): ${message}`
2720
+ );
2721
+ }
2722
+ if (strategy === "warn") {
2723
+ logger.warn(
2724
+ `Hook "${def.command}" failed (warn): ${message}`
2725
+ );
2726
+ }
2727
+ results.push({
2728
+ exitCode: 1,
2729
+ stdout: "",
2730
+ stderr: message,
2731
+ durationMs: 0,
2732
+ output: { ...DEFAULT_HOOK_OUTPUT }
2733
+ });
2734
+ }
2735
+ }
2736
+ await this.eventBus.emit(event, hookInput);
2737
+ return { results, mergedMetadata };
2738
+ }
2090
2739
  /** Get the count of enabled definitions for a given event */
2091
2740
  getDefinitionCount(event) {
2092
2741
  return this.definitions.filter((def) => def.event === event).length;
2093
2742
  }
2743
+ /** Get all loaded chain names */
2744
+ getChainNames() {
2745
+ return this.chains.map((c) => c.name);
2746
+ }
2747
+ /**
2748
+ * Execute a named hook chain.
2749
+ * Steps execute in sequence. If pipeOutput is true, previous step's
2750
+ * HookOutput is merged into the next step's HookInput.data.
2751
+ */
2752
+ async executeChain(chainName, initialInput) {
2753
+ const chain = this.chains.find((c) => c.name === chainName);
2754
+ if (!chain) {
2755
+ throw new Error(`Hook chain "${chainName}" not found`);
2756
+ }
2757
+ const errorStrategy = chain.onError ?? "warn";
2758
+ const stepResults = [];
2759
+ let mergedMetadata = {};
2760
+ let previousOutput;
2761
+ let overallSuccess = true;
2762
+ for (const step of chain.steps) {
2763
+ const startTime = Date.now();
2764
+ let stepInput;
2765
+ if (step.pipeOutput && previousOutput) {
2766
+ stepInput = {
2767
+ ...initialInput,
2768
+ data: {
2769
+ ...initialInput.data,
2770
+ ...previousOutput.metadata,
2771
+ _previousMessage: previousOutput.message,
2772
+ _previousAllow: previousOutput.allow
2773
+ }
2774
+ };
2775
+ } else {
2776
+ stepInput = { ...initialInput };
2777
+ }
2778
+ try {
2779
+ const result = await this.executeHook(step.hook, stepInput);
2780
+ const durationMs = Date.now() - startTime;
2781
+ stepResults.push({
2782
+ stepId: step.stepId,
2783
+ success: true,
2784
+ output: result.output,
2785
+ durationMs
2786
+ });
2787
+ if (result.output.metadata && typeof result.output.metadata === "object") {
2788
+ mergedMetadata = { ...mergedMetadata, ...result.output.metadata };
2789
+ }
2790
+ previousOutput = result.output;
2791
+ } catch (error) {
2792
+ const durationMs = Date.now() - startTime;
2793
+ const message = error instanceof Error ? error.message : String(error);
2794
+ stepResults.push({
2795
+ stepId: step.stepId,
2796
+ success: false,
2797
+ error: message,
2798
+ durationMs
2799
+ });
2800
+ overallSuccess = false;
2801
+ if (errorStrategy === "fail") {
2802
+ break;
2803
+ }
2804
+ if (errorStrategy === "warn") {
2805
+ logger.warn(
2806
+ `Hook chain "${chainName}" step "${step.stepId}" failed (warn): ${message}`
2807
+ );
2808
+ }
2809
+ }
2810
+ }
2811
+ return {
2812
+ chainName,
2813
+ stepResults,
2814
+ success: overallSuccess,
2815
+ mergedMetadata
2816
+ };
2817
+ }
2094
2818
  async executeHook(def, input) {
2095
2819
  this.validateCommand(def.command);
2096
2820
  this.validateArgs(def.args ?? []);
@@ -2129,34 +2853,75 @@ var HooksEngine = class _HooksEngine {
2129
2853
  };
2130
2854
 
2131
2855
  // src/core/context-monitor.ts
2856
+ var DEFAULT_BACKEND_CONTEXT = {
2857
+ claude: { contextWindow: 2e5, compactThreshold: 19e4 },
2858
+ codex: { contextWindow: 272e3, compactThreshold: 258400 },
2859
+ gemini: { contextWindow: 1048576, compactThreshold: 524288 }
2860
+ };
2861
+ var DEFAULT_NOTIFY_PERCENT = 70;
2132
2862
  var DEFAULT_CONFIG = {
2133
2863
  enabled: true,
2134
- thresholdPercent: 75,
2135
- notifyMethod: "stderr"
2864
+ notifyMethod: "hook"
2136
2865
  };
2137
2866
  var ContextMonitor = class {
2138
2867
  constructor(hooksEngine2, config) {
2139
2868
  this.hooksEngine = hooksEngine2;
2140
2869
  this.config = { ...DEFAULT_CONFIG, ...config };
2870
+ if (this.config.thresholdPercent !== void 0 && this.config.notifyPercent === void 0 && this.config.notifyThreshold === void 0) {
2871
+ this.config.notifyPercent = this.config.thresholdPercent;
2872
+ }
2141
2873
  }
2142
2874
  config;
2143
2875
  usageMap = /* @__PURE__ */ new Map();
2876
+ /** Get backend context config, merging user overrides with defaults */
2877
+ getBackendConfig(backendId) {
2878
+ const defaults = DEFAULT_BACKEND_CONTEXT[backendId];
2879
+ const overrides = this.config.backends?.[backendId];
2880
+ return {
2881
+ contextWindow: overrides?.contextWindow ?? defaults.contextWindow,
2882
+ compactThreshold: overrides?.compactThreshold ?? defaults.compactThreshold
2883
+ };
2884
+ }
2885
+ /** Calculate the notification threshold in tokens for a given backend */
2886
+ getNotifyThreshold(backendId) {
2887
+ if (this.config.notifyThreshold !== void 0) {
2888
+ return this.config.notifyThreshold;
2889
+ }
2890
+ const backendConfig = this.getBackendConfig(backendId);
2891
+ const notifyPercent = this.config.notifyPercent ?? DEFAULT_NOTIFY_PERCENT;
2892
+ return Math.round(backendConfig.contextWindow * notifyPercent / 100);
2893
+ }
2144
2894
  /** Update token usage for a session and check threshold */
2145
- updateUsage(sessionId, backendId, estimatedTokens, maxTokens) {
2895
+ updateUsage(sessionId, backendId, estimatedTokens) {
2146
2896
  if (!this.config.enabled) return;
2147
- const usagePercent = maxTokens > 0 ? Math.round(estimatedTokens / maxTokens * 100) : 0;
2897
+ const backendConfig = this.getBackendConfig(backendId);
2898
+ const contextWindow = backendConfig.contextWindow;
2899
+ const usagePercent = contextWindow > 0 ? Math.round(estimatedTokens / contextWindow * 100) : 0;
2148
2900
  const existing = this.usageMap.get(sessionId);
2149
- const wasNotified = existing?.notified ?? false;
2901
+ let wasNotified = existing?.notified ?? false;
2902
+ if (existing && estimatedTokens < existing.estimatedTokens * 0.7) {
2903
+ wasNotified = false;
2904
+ }
2150
2905
  this.usageMap.set(sessionId, {
2151
2906
  estimatedTokens,
2152
- maxTokens,
2907
+ contextWindow,
2908
+ compactThreshold: backendConfig.compactThreshold,
2153
2909
  usagePercent,
2154
2910
  backendId,
2155
2911
  notified: wasNotified
2156
2912
  });
2157
- if (usagePercent >= this.config.thresholdPercent && !wasNotified) {
2158
- this.usageMap.get(sessionId).notified = true;
2159
- this.notify(sessionId, backendId, usagePercent);
2913
+ const notifyAt = this.getNotifyThreshold(backendId);
2914
+ if (estimatedTokens >= notifyAt && !wasNotified) {
2915
+ const entry = this.usageMap.get(sessionId);
2916
+ entry.notified = true;
2917
+ this.notify(
2918
+ sessionId,
2919
+ backendId,
2920
+ usagePercent,
2921
+ estimatedTokens,
2922
+ contextWindow,
2923
+ backendConfig.compactThreshold
2924
+ );
2160
2925
  }
2161
2926
  }
2162
2927
  /** Get usage info for a session */
@@ -2165,17 +2930,31 @@ var ContextMonitor = class {
2165
2930
  if (!entry) return null;
2166
2931
  return {
2167
2932
  usagePercent: entry.usagePercent,
2168
- isEstimated: true
2933
+ isEstimated: true,
2934
+ backendId: entry.backendId,
2935
+ contextWindow: entry.contextWindow,
2936
+ compactThreshold: entry.compactThreshold,
2937
+ estimatedTokens: entry.estimatedTokens,
2938
+ remainingBeforeCompact: Math.max(
2939
+ 0,
2940
+ entry.compactThreshold - entry.estimatedTokens
2941
+ ),
2942
+ notifyThreshold: this.getNotifyThreshold(entry.backendId)
2169
2943
  };
2170
2944
  }
2171
2945
  /** Remove usage tracking for a session */
2172
2946
  removeSession(sessionId) {
2173
2947
  this.usageMap.delete(sessionId);
2174
2948
  }
2175
- notify(sessionId, backendId, usagePercent) {
2949
+ notify(sessionId, backendId, usagePercent, currentTokens, contextWindow, compactThreshold) {
2950
+ const remainingBeforeCompact = Math.max(
2951
+ 0,
2952
+ compactThreshold - currentTokens
2953
+ );
2954
+ const warningMessage = `${backendId} session ${sessionId} at ${usagePercent}% (${currentTokens}/${contextWindow} tokens). Compact in ~${remainingBeforeCompact} tokens. Save your work state now.`;
2176
2955
  if (this.config.notifyMethod === "stderr") {
2177
2956
  process.stderr.write(
2178
- `[relay] Context usage warning: session ${sessionId} is at ${usagePercent}% (threshold: ${this.config.thresholdPercent}%)
2957
+ `[relay] Context warning: ${warningMessage}
2179
2958
  `
2180
2959
  );
2181
2960
  } else if (this.config.notifyMethod === "hook" && this.hooksEngine) {
@@ -2186,7 +2965,10 @@ var ContextMonitor = class {
2186
2965
  timestamp: (/* @__PURE__ */ new Date()).toISOString(),
2187
2966
  data: {
2188
2967
  usagePercent,
2189
- thresholdPercent: this.config.thresholdPercent
2968
+ currentTokens,
2969
+ contextWindow,
2970
+ compactThreshold,
2971
+ remainingBeforeCompact
2190
2972
  }
2191
2973
  };
2192
2974
  void this.hooksEngine.emit("on-context-threshold", hookInput);
@@ -2323,6 +3105,7 @@ function createBackendCommand(backendId, registry2, sessionManager2, hooksEngine
2323
3105
  try {
2324
3106
  if (flags.prompt) {
2325
3107
  logger.debug(`Executing prompt on ${backendId}`);
3108
+ let nativeSessionId;
2326
3109
  if (adapter.executeStreaming) {
2327
3110
  for await (const event of adapter.executeStreaming(flags)) {
2328
3111
  switch (event.type) {
@@ -2353,18 +3136,17 @@ function createBackendCommand(backendId, registry2, sessionManager2, hooksEngine
2353
3136
  break;
2354
3137
  case "usage": {
2355
3138
  if (contextMonitor2 && relaySessionId) {
2356
- const maxTokens = backendId === "gemini" ? 128e3 : 2e5;
2357
3139
  contextMonitor2.updateUsage(
2358
3140
  relaySessionId,
2359
3141
  backendId,
2360
- event.inputTokens + event.outputTokens,
2361
- maxTokens
3142
+ event.inputTokens + event.outputTokens
2362
3143
  );
2363
3144
  }
2364
3145
  break;
2365
3146
  }
2366
3147
  case "done":
2367
3148
  process.exitCode = event.result.exitCode;
3149
+ nativeSessionId = event.nativeSessionId;
2368
3150
  if (event.nativeSessionId && sessionManager2 && relaySessionId) {
2369
3151
  try {
2370
3152
  await sessionManager2.update(relaySessionId, {
@@ -2381,6 +3163,7 @@ function createBackendCommand(backendId, registry2, sessionManager2, hooksEngine
2381
3163
  if (result.stdout) process.stdout.write(result.stdout);
2382
3164
  if (result.stderr) process.stderr.write(result.stderr);
2383
3165
  process.exitCode = result.exitCode;
3166
+ nativeSessionId = result.nativeSessionId;
2384
3167
  if (result.nativeSessionId && sessionManager2 && relaySessionId) {
2385
3168
  try {
2386
3169
  await sessionManager2.update(relaySessionId, {
@@ -2989,7 +3772,7 @@ function createVersionCommand(registry2) {
2989
3772
  // src/commands/doctor.ts
2990
3773
  import { defineCommand as defineCommand8 } from "citty";
2991
3774
  import { access, constants, readdir as readdir2 } from "fs/promises";
2992
- import { join as join6 } from "path";
3775
+ import { join as join7 } from "path";
2993
3776
  import { homedir as homedir5 } from "os";
2994
3777
  import { execFile } from "child_process";
2995
3778
  import { promisify } from "util";
@@ -3050,8 +3833,8 @@ async function checkConfig(configManager2) {
3050
3833
  }
3051
3834
  }
3052
3835
  async function checkSessionsDir() {
3053
- const relayHome2 = process.env["RELAY_HOME"] ?? join6(homedir5(), ".relay");
3054
- const sessionsDir = join6(relayHome2, "sessions");
3836
+ const relayHome2 = process.env["RELAY_HOME"] ?? join7(homedir5(), ".relay");
3837
+ const sessionsDir = join7(relayHome2, "sessions");
3055
3838
  try {
3056
3839
  await access(sessionsDir, constants.W_OK);
3057
3840
  return {
@@ -3164,8 +3947,8 @@ async function checkBackendAuthEnv() {
3164
3947
  return results;
3165
3948
  }
3166
3949
  async function checkSessionsDiskUsage() {
3167
- const relayHome2 = process.env["RELAY_HOME"] ?? join6(homedir5(), ".relay");
3168
- const sessionsDir = join6(relayHome2, "sessions");
3950
+ const relayHome2 = process.env["RELAY_HOME"] ?? join7(homedir5(), ".relay");
3951
+ const sessionsDir = join7(relayHome2, "sessions");
3169
3952
  try {
3170
3953
  const entries = await readdir2(sessionsDir);
3171
3954
  const fileCount = entries.length;
@@ -3239,7 +4022,7 @@ function createDoctorCommand(registry2, configManager2) {
3239
4022
  init_logger();
3240
4023
  import { defineCommand as defineCommand9 } from "citty";
3241
4024
  import { mkdir as mkdir6, writeFile as writeFile6, access as access2, readFile as readFile6 } from "fs/promises";
3242
- import { join as join7 } from "path";
4025
+ import { join as join8 } from "path";
3243
4026
  var DEFAULT_CONFIG2 = {
3244
4027
  defaultBackend: "claude",
3245
4028
  backends: {},
@@ -3253,8 +4036,8 @@ function createInitCommand() {
3253
4036
  },
3254
4037
  async run() {
3255
4038
  const projectDir = process.cwd();
3256
- const relayDir = join7(projectDir, ".relay");
3257
- const configPath = join7(relayDir, "config.json");
4039
+ const relayDir = join8(projectDir, ".relay");
4040
+ const configPath = join8(relayDir, "config.json");
3258
4041
  try {
3259
4042
  await access2(relayDir);
3260
4043
  logger.info(
@@ -3270,7 +4053,7 @@ function createInitCommand() {
3270
4053
  "utf-8"
3271
4054
  );
3272
4055
  logger.success(`Created ${configPath}`);
3273
- const gitignorePath = join7(projectDir, ".gitignore");
4056
+ const gitignorePath = join8(projectDir, ".gitignore");
3274
4057
  try {
3275
4058
  const gitignoreContent = await readFile6(gitignorePath, "utf-8");
3276
4059
  if (!gitignoreContent.includes(".relay/config.local.json")) {
@@ -3295,8 +4078,8 @@ registry.registerLazy("claude", () => new ClaudeAdapter(processManager));
3295
4078
  registry.registerLazy("codex", () => new CodexAdapter(processManager));
3296
4079
  registry.registerLazy("gemini", () => new GeminiAdapter(processManager));
3297
4080
  var sessionManager = new SessionManager();
3298
- var relayHome = process.env["RELAY_HOME"] ?? join8(homedir6(), ".relay");
3299
- var projectRelayDir = join8(process.cwd(), ".relay");
4081
+ var relayHome = process.env["RELAY_HOME"] ?? join9(homedir6(), ".relay");
4082
+ var projectRelayDir = join9(process.cwd(), ".relay");
3300
4083
  var configManager = new ConfigManager(relayHome, projectRelayDir);
3301
4084
  var authManager = new AuthManager(registry);
3302
4085
  var eventBus = new EventBus();