keystone-cli 0.1.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/README.md +69 -16
  2. package/package.json +14 -3
  3. package/src/cli.ts +183 -84
  4. package/src/db/workflow-db.ts +0 -7
  5. package/src/expression/evaluator.test.ts +46 -0
  6. package/src/expression/evaluator.ts +36 -0
  7. package/src/parser/agent-parser.test.ts +10 -0
  8. package/src/parser/agent-parser.ts +13 -5
  9. package/src/parser/config-schema.ts +24 -5
  10. package/src/parser/schema.ts +1 -1
  11. package/src/parser/workflow-parser.ts +5 -9
  12. package/src/runner/llm-adapter.test.ts +0 -8
  13. package/src/runner/llm-adapter.ts +33 -10
  14. package/src/runner/llm-executor.test.ts +230 -96
  15. package/src/runner/llm-executor.ts +9 -4
  16. package/src/runner/mcp-client.test.ts +204 -88
  17. package/src/runner/mcp-client.ts +349 -22
  18. package/src/runner/mcp-manager.test.ts +73 -15
  19. package/src/runner/mcp-manager.ts +84 -18
  20. package/src/runner/mcp-server.test.ts +4 -1
  21. package/src/runner/mcp-server.ts +25 -11
  22. package/src/runner/shell-executor.ts +3 -3
  23. package/src/runner/step-executor.test.ts +2 -2
  24. package/src/runner/step-executor.ts +31 -16
  25. package/src/runner/tool-integration.test.ts +21 -14
  26. package/src/runner/workflow-runner.ts +34 -7
  27. package/src/templates/agents/explore.md +54 -0
  28. package/src/templates/agents/general.md +8 -0
  29. package/src/templates/agents/keystone-architect.md +54 -0
  30. package/src/templates/agents/my-agent.md +3 -0
  31. package/src/templates/agents/summarizer.md +28 -0
  32. package/src/templates/agents/test-agent.md +10 -0
  33. package/src/templates/approval-process.yaml +36 -0
  34. package/src/templates/basic-inputs.yaml +19 -0
  35. package/src/templates/basic-shell.yaml +20 -0
  36. package/src/templates/batch-processor.yaml +43 -0
  37. package/src/templates/cleanup-finally.yaml +22 -0
  38. package/src/templates/composition-child.yaml +13 -0
  39. package/src/templates/composition-parent.yaml +14 -0
  40. package/src/templates/data-pipeline.yaml +38 -0
  41. package/src/templates/full-feature-demo.yaml +64 -0
  42. package/src/templates/human-interaction.yaml +12 -0
  43. package/src/templates/invalid.yaml +5 -0
  44. package/src/templates/llm-agent.yaml +8 -0
  45. package/src/templates/loop-parallel.yaml +37 -0
  46. package/src/templates/retry-policy.yaml +36 -0
  47. package/src/templates/scaffold-feature.yaml +48 -0
  48. package/src/templates/state.db +0 -0
  49. package/src/templates/state.db-shm +0 -0
  50. package/src/templates/state.db-wal +0 -0
  51. package/src/templates/stop-watch.yaml +17 -0
  52. package/src/templates/workflow.db +0 -0
  53. package/src/utils/auth-manager.test.ts +86 -0
  54. package/src/utils/auth-manager.ts +89 -0
  55. package/src/utils/config-loader.test.ts +32 -2
  56. package/src/utils/config-loader.ts +11 -1
  57. package/src/utils/mermaid.test.ts +27 -3
@@ -1,16 +1,23 @@
1
- import { MCPClient } from './mcp-client';
2
1
  import { ConfigLoader } from '../utils/config-loader';
2
+ import { MCPClient } from './mcp-client';
3
3
  import type { Logger } from './workflow-runner';
4
4
 
5
5
  export interface MCPServerConfig {
6
6
  name: string;
7
- command: string;
7
+ type?: 'local' | 'remote';
8
+ command?: string;
8
9
  args?: string[];
9
10
  env?: Record<string, string>;
11
+ url?: string;
12
+ headers?: Record<string, string>;
13
+ oauth?: {
14
+ scope?: string;
15
+ };
10
16
  }
11
17
 
12
18
  export class MCPManager {
13
19
  private clients: Map<string, MCPClient> = new Map();
20
+ private connectionPromises: Map<string, Promise<MCPClient | undefined>> = new Map();
14
21
  private sharedServers: Map<string, MCPServerConfig> = new Map();
15
22
 
16
23
  constructor() {
@@ -23,10 +30,8 @@ export class MCPManager {
23
30
  for (const [name, server] of Object.entries(config.mcp_servers)) {
24
31
  this.sharedServers.set(name, {
25
32
  name,
26
- command: server.command,
27
- args: server.args,
28
- env: server.env,
29
- });
33
+ ...server,
34
+ } as MCPServerConfig);
30
35
  }
31
36
  }
32
37
  }
@@ -49,23 +54,84 @@ export class MCPManager {
49
54
  }
50
55
 
51
56
  const key = this.getServerKey(config);
57
+
58
+ // Check if we already have a client
52
59
  if (this.clients.has(key)) {
53
60
  return this.clients.get(key);
54
61
  }
55
62
 
56
- logger.log(` šŸ”Œ Connecting to MCP server: ${config.name}`);
57
- const client = new MCPClient(config.command, config.args || [], config.env || {});
58
- try {
59
- await client.initialize();
60
- this.clients.set(key, client);
61
- return client;
62
- } catch (error) {
63
- logger.error(
64
- ` āœ— Failed to connect to MCP server ${config.name}: ${error instanceof Error ? error.message : String(error)}`
65
- );
66
- client.stop();
67
- return undefined;
63
+ // Check if we are already connecting
64
+ if (this.connectionPromises.has(key)) {
65
+ return this.connectionPromises.get(key);
68
66
  }
67
+
68
+ // Start a new connection and cache the promise
69
+ const connectionPromise = (async () => {
70
+ logger.log(` šŸ”Œ Connecting to MCP server: ${config.name} (${config.type || 'local'})`);
71
+
72
+ let client: MCPClient;
73
+ try {
74
+ if (config.type === 'remote') {
75
+ if (!config.url) throw new Error('Remote MCP server missing URL');
76
+
77
+ const headers = { ...(config.headers || {}) };
78
+
79
+ if (config.oauth) {
80
+ const { AuthManager } = await import('../utils/auth-manager');
81
+ const auth = AuthManager.load();
82
+ const token = auth.mcp_tokens?.[config.name]?.access_token;
83
+
84
+ if (!token) {
85
+ throw new Error(
86
+ `MCP server ${config.name} requires OAuth. Please run "keystone mcp login ${config.name}" first.`
87
+ );
88
+ }
89
+
90
+ headers.Authorization = `Bearer ${token}`;
91
+ }
92
+
93
+ client = await MCPClient.createRemote(config.url, headers);
94
+ } else {
95
+ if (!config.command) throw new Error('Local MCP server missing command');
96
+
97
+ const env = { ...(config.env || {}) };
98
+
99
+ if (config.oauth) {
100
+ const { AuthManager } = await import('../utils/auth-manager');
101
+ const auth = AuthManager.load();
102
+ const token = auth.mcp_tokens?.[config.name]?.access_token;
103
+
104
+ if (!token) {
105
+ throw new Error(
106
+ `MCP server ${config.name} requires OAuth. Please run "keystone mcp login ${config.name}" first.`
107
+ );
108
+ }
109
+
110
+ // Pass token to the local proxy via environment variables
111
+ // Most proxies expect AUTHORIZATION or MCP_TOKEN
112
+ env.AUTHORIZATION = `Bearer ${token}`;
113
+ env.MCP_TOKEN = token;
114
+ }
115
+
116
+ client = await MCPClient.createLocal(config.command, config.args || [], env);
117
+ }
118
+
119
+ await client.initialize();
120
+ this.clients.set(key, client);
121
+ return client;
122
+ } catch (error) {
123
+ logger.error(
124
+ ` āœ— Failed to connect to MCP server ${config.name}: ${error instanceof Error ? error.message : String(error)}`
125
+ );
126
+ return undefined;
127
+ } finally {
128
+ // Remove promise from cache once settled
129
+ this.connectionPromises.delete(key);
130
+ }
131
+ })();
132
+
133
+ this.connectionPromises.set(key, connectionPromise);
134
+ return connectionPromise;
69
135
  }
70
136
 
71
137
  private getServerKey(config: MCPServerConfig): string {
@@ -219,7 +219,7 @@ describe('MCPServer', () => {
219
219
  const writeSpy = spyOn(process.stdout, 'write').mockImplementation(() => true);
220
220
  const consoleSpy = spyOn(console, 'error').mockImplementation(() => {});
221
221
 
222
- await server.start();
222
+ const startPromise = server.start();
223
223
 
224
224
  // Simulate stdin data
225
225
  const message = {
@@ -236,6 +236,9 @@ describe('MCPServer', () => {
236
236
  const output = JSON.parse(writeSpy.mock.calls[0][0] as string);
237
237
  expect(output.id).toBe(9);
238
238
 
239
+ process.stdin.emit('close');
240
+ await startPromise;
241
+
239
242
  writeSpy.mockRestore();
240
243
  consoleSpy.mockRestore();
241
244
  });
@@ -1,4 +1,5 @@
1
1
  import * as readline from 'node:readline';
2
+ import pkg from '../../package.json' with { type: 'json' };
2
3
  import { WorkflowDb } from '../db/workflow-db';
3
4
  import { WorkflowParser } from '../parser/workflow-parser';
4
5
  import { generateMermaidGraph } from '../utils/mermaid';
@@ -26,21 +27,32 @@ export class MCPServer {
26
27
  terminal: false,
27
28
  });
28
29
 
29
- rl.on('line', async (line) => {
30
- if (!line.trim()) return;
30
+ return new Promise<void>((resolve) => {
31
+ rl.on('line', async (line) => {
32
+ if (!line.trim()) return;
31
33
 
32
- try {
33
- const message = JSON.parse(line) as MCPMessage;
34
- const response = await this.handleMessage(message);
35
- if (response) {
36
- process.stdout.write(`${JSON.stringify(response)}\n`);
34
+ try {
35
+ const message = JSON.parse(line) as MCPMessage;
36
+ const response = await this.handleMessage(message);
37
+ if (response) {
38
+ process.stdout.write(`${JSON.stringify(response)}\n`);
39
+ }
40
+ } catch (error) {
41
+ console.error('Error handling MCP message:', error);
37
42
  }
38
- } catch (error) {
39
- console.error('Error handling MCP message:', error);
40
- }
43
+ });
44
+
45
+ rl.on('close', () => {
46
+ this.stop();
47
+ resolve();
48
+ });
41
49
  });
42
50
  }
43
51
 
52
+ stop() {
53
+ this.db.close();
54
+ }
55
+
44
56
  private async handleMessage(message: MCPMessage) {
45
57
  const { method, params, id } = message;
46
58
 
@@ -56,7 +68,7 @@ export class MCPServer {
56
68
  },
57
69
  serverInfo: {
58
70
  name: 'keystone-mcp',
59
- version: '0.1.0',
71
+ version: pkg.version,
60
72
  },
61
73
  },
62
74
  };
@@ -172,6 +184,7 @@ export class MCPServer {
172
184
  const runner = new WorkflowRunner(workflow, {
173
185
  inputs,
174
186
  logger,
187
+ preventExit: true,
175
188
  });
176
189
 
177
190
  // Note: This waits for completion. For long workflows, we might want to
@@ -337,6 +350,7 @@ export class MCPServer {
337
350
  const runner = new WorkflowRunner(workflow, {
338
351
  resumeRunId: run_id,
339
352
  logger,
353
+ preventExit: true,
340
354
  });
341
355
 
342
356
  let outputs: Record<string, unknown> | undefined;
@@ -88,7 +88,7 @@ export async function executeShell(
88
88
  logger: Logger = console
89
89
  ): Promise<ShellResult> {
90
90
  // Evaluate the command string
91
- const command = ExpressionEvaluator.evaluate(step.run, context) as string;
91
+ const command = ExpressionEvaluator.evaluateString(step.run, context);
92
92
 
93
93
  // Check for potential shell injection risks
94
94
  if (detectShellInjectionRisk(command)) {
@@ -101,12 +101,12 @@ export async function executeShell(
101
101
  const env: Record<string, string> = {};
102
102
  if (step.env) {
103
103
  for (const [key, value] of Object.entries(step.env)) {
104
- env[key] = ExpressionEvaluator.evaluate(value, context) as string;
104
+ env[key] = ExpressionEvaluator.evaluateString(value, context);
105
105
  }
106
106
  }
107
107
 
108
108
  // Set working directory if specified
109
- const cwd = step.dir ? (ExpressionEvaluator.evaluate(step.dir, context) as string) : undefined;
109
+ const cwd = step.dir ? ExpressionEvaluator.evaluateString(step.dir, context) : undefined;
110
110
 
111
111
  try {
112
112
  // Execute command using sh -c to allow shell parsing
@@ -374,7 +374,7 @@ describe('step-executor', () => {
374
374
  const step: WorkflowStep = {
375
375
  id: 'w1',
376
376
  type: 'workflow',
377
- workflow: 'child.yaml',
377
+ path: 'child.yaml',
378
378
  };
379
379
  // @ts-ignore
380
380
  const executeWorkflowFn = mock(() =>
@@ -392,7 +392,7 @@ describe('step-executor', () => {
392
392
  const step: WorkflowStep = {
393
393
  id: 'w1',
394
394
  type: 'workflow',
395
- workflow: 'child.yaml',
395
+ path: 'child.yaml',
396
396
  };
397
397
  const result = await executeStep(step, context);
398
398
  expect(result.status).toBe('failed');
@@ -42,7 +42,8 @@ export async function executeStep(
42
42
  context: ExpressionContext,
43
43
  logger: Logger = console,
44
44
  executeWorkflowFn?: (step: WorkflowStep, context: ExpressionContext) => Promise<StepResult>,
45
- mcpManager?: MCPManager
45
+ mcpManager?: MCPManager,
46
+ workflowDir?: string
46
47
  ): Promise<StepResult> {
47
48
  try {
48
49
  let result: StepResult;
@@ -66,9 +67,10 @@ export async function executeStep(
66
67
  result = await executeLlmStep(
67
68
  step,
68
69
  context,
69
- (s, c) => executeStep(s, c, logger, executeWorkflowFn, mcpManager),
70
+ (s, c) => executeStep(s, c, logger, executeWorkflowFn, mcpManager, workflowDir),
70
71
  logger,
71
- mcpManager
72
+ mcpManager,
73
+ workflowDir
72
74
  );
73
75
  break;
74
76
  case 'workflow':
@@ -84,11 +86,12 @@ export async function executeStep(
84
86
  // Apply transformation if specified and step succeeded
85
87
  if (step.transform && result.status === 'success') {
86
88
  const transformContext = {
87
- // Provide raw output properties (like stdout, data) directly in context
88
- // Fix: Spread output FIRST, then context to prevent shadowing
89
+ // 1. Provide raw output properties (like stdout, data) directly in context for convenience
89
90
  ...(typeof result.output === 'object' && result.output !== null ? result.output : {}),
90
- output: result.output,
91
+ // 2. Add core context (inputs, secrets, etc.). This takes priority over output properties for security.
91
92
  ...context,
93
+ // 3. Explicitly add 'output' so it refers to the current step's result even if context or output properties have a collision.
94
+ output: result.output,
92
95
  };
93
96
 
94
97
  try {
@@ -159,7 +162,7 @@ async function executeFileStep(
159
162
  context: ExpressionContext,
160
163
  _logger: Logger
161
164
  ): Promise<StepResult> {
162
- const path = ExpressionEvaluator.evaluate(step.path, context) as string;
165
+ const path = ExpressionEvaluator.evaluateString(step.path, context);
163
166
 
164
167
  switch (step.op) {
165
168
  case 'read': {
@@ -178,7 +181,14 @@ async function executeFileStep(
178
181
  if (!step.content) {
179
182
  throw new Error('Content is required for write operation');
180
183
  }
181
- const content = ExpressionEvaluator.evaluate(step.content, context) as string;
184
+ const content = ExpressionEvaluator.evaluateString(step.content, context);
185
+
186
+ // Ensure parent directory exists
187
+ const fs = await import('node:fs/promises');
188
+ const pathModule = await import('node:path');
189
+ const dir = pathModule.dirname(path);
190
+ await fs.mkdir(dir, { recursive: true });
191
+
182
192
  const bytes = await Bun.write(path, content);
183
193
  return {
184
194
  output: { path, bytes },
@@ -190,10 +200,15 @@ async function executeFileStep(
190
200
  if (!step.content) {
191
201
  throw new Error('Content is required for append operation');
192
202
  }
193
- const content = ExpressionEvaluator.evaluate(step.content, context) as string;
203
+ const content = ExpressionEvaluator.evaluateString(step.content, context);
194
204
 
195
- // Use Node.js fs for efficient append operation
205
+ // Ensure parent directory exists
196
206
  const fs = await import('node:fs/promises');
207
+ const pathModule = await import('node:path');
208
+ const dir = pathModule.dirname(path);
209
+ await fs.mkdir(dir, { recursive: true });
210
+
211
+ // Use Node.js fs for efficient append operation
197
212
  await fs.appendFile(path, content, 'utf-8');
198
213
 
199
214
  return {
@@ -215,13 +230,13 @@ async function executeRequestStep(
215
230
  context: ExpressionContext,
216
231
  _logger: Logger
217
232
  ): Promise<StepResult> {
218
- const url = ExpressionEvaluator.evaluate(step.url, context) as string;
233
+ const url = ExpressionEvaluator.evaluateString(step.url, context);
219
234
 
220
235
  // Evaluate headers
221
236
  const headers: Record<string, string> = {};
222
237
  if (step.headers) {
223
238
  for (const [key, value] of Object.entries(step.headers)) {
224
- headers[key] = ExpressionEvaluator.evaluate(value, context) as string;
239
+ headers[key] = ExpressionEvaluator.evaluateString(value, context);
225
240
  }
226
241
  }
227
242
 
@@ -290,7 +305,7 @@ async function executeHumanStep(
290
305
  context: ExpressionContext,
291
306
  logger: Logger
292
307
  ): Promise<StepResult> {
293
- const message = ExpressionEvaluator.evaluate(step.message, context) as string;
308
+ const message = ExpressionEvaluator.evaluateString(step.message, context);
294
309
 
295
310
  // If not a TTY (e.g. MCP server), suspend execution
296
311
  if (!process.stdin.isTTY) {
@@ -309,10 +324,10 @@ async function executeHumanStep(
309
324
  try {
310
325
  if (step.inputType === 'confirm') {
311
326
  logger.log(`\nā“ ${message}`);
312
- logger.log('Press Enter to continue, or Ctrl+C to cancel...');
313
- await rl.question('');
327
+ const answer = await rl.question('Confirm? (Y/n): ');
328
+ const isConfirmed = answer.toLowerCase() !== 'n';
314
329
  return {
315
- output: true,
330
+ output: isConfirmed,
316
331
  status: 'success',
317
332
  };
318
333
  }
@@ -1,12 +1,19 @@
1
1
  import { afterAll, beforeAll, describe, expect, it, mock } from 'bun:test';
2
- import { OpenAIAdapter, CopilotAdapter, AnthropicAdapter } from './llm-adapter';
3
- import { MCPClient } from './mcp-client';
4
- import { executeLlmStep } from './llm-executor';
5
- import type { LlmStep, Step } from '../parser/schema';
2
+ import { mkdirSync, unlinkSync, writeFileSync } from 'node:fs';
3
+ import { join } from 'node:path';
6
4
  import type { ExpressionContext } from '../expression/evaluator';
5
+ import type { LlmStep, Step } from '../parser/schema';
6
+ import {
7
+ AnthropicAdapter,
8
+ CopilotAdapter,
9
+ type LLMMessage,
10
+ type LLMResponse,
11
+ type LLMTool,
12
+ OpenAIAdapter,
13
+ } from './llm-adapter';
14
+ import { executeLlmStep } from './llm-executor';
15
+ import { MCPClient, type MCPResponse } from './mcp-client';
7
16
  import type { StepResult } from './step-executor';
8
- import { join } from 'node:path';
9
- import { mkdirSync, writeFileSync, unlinkSync } from 'node:fs';
10
17
 
11
18
  interface MockToolCall {
12
19
  function: {
@@ -54,16 +61,16 @@ Test system prompt`;
54
61
  };
55
62
  });
56
63
 
57
- OpenAIAdapter.prototype.chat = mockChat as any;
58
- CopilotAdapter.prototype.chat = mockChat as any;
59
- AnthropicAdapter.prototype.chat = mockChat as any;
64
+ OpenAIAdapter.prototype.chat = mockChat as unknown as typeof originalOpenAIChat;
65
+ CopilotAdapter.prototype.chat = mockChat as unknown as typeof originalCopilotChat;
66
+ AnthropicAdapter.prototype.chat = mockChat as unknown as typeof originalAnthropicChat;
60
67
 
61
68
  // Use mock.module for MCPClient
62
69
  const originalInitialize = MCPClient.prototype.initialize;
63
70
  const originalListTools = MCPClient.prototype.listTools;
64
71
  const originalStop = MCPClient.prototype.stop;
65
72
 
66
- const mockInitialize = mock(async () => ({}) as any);
73
+ const mockInitialize = mock(async () => ({}) as MCPResponse);
67
74
  const mockListTools = mock(async () => [
68
75
  {
69
76
  name: 'mcp-tool',
@@ -141,16 +148,16 @@ Test system prompt`;
141
148
  };
142
149
  });
143
150
 
144
- OpenAIAdapter.prototype.chat = mockChat as any;
145
- CopilotAdapter.prototype.chat = mockChat as any;
146
- AnthropicAdapter.prototype.chat = mockChat as any;
151
+ OpenAIAdapter.prototype.chat = mockChat as unknown as typeof originalOpenAIChat;
152
+ CopilotAdapter.prototype.chat = mockChat as unknown as typeof originalCopilotChat;
153
+ AnthropicAdapter.prototype.chat = mockChat as unknown as typeof originalAnthropicChat;
147
154
 
148
155
  const originalInitialize = MCPClient.prototype.initialize;
149
156
  const originalListTools = MCPClient.prototype.listTools;
150
157
  const originalCallTool = MCPClient.prototype.callTool;
151
158
  const originalStop = MCPClient.prototype.stop;
152
159
 
153
- const mockInitialize = mock(async () => ({}) as any);
160
+ const mockInitialize = mock(async () => ({}) as MCPResponse);
154
161
  const mockListTools = mock(async () => [
155
162
  {
156
163
  name: 'mcp-tool',
@@ -1,4 +1,5 @@
1
1
  import { randomUUID } from 'node:crypto';
2
+ import { dirname } from 'node:path';
2
3
  import { WorkflowDb } from '../db/workflow-db.ts';
3
4
  import type { ExpressionContext } from '../expression/evaluator.ts';
4
5
  import { ExpressionEvaluator } from '../expression/evaluator.ts';
@@ -45,6 +46,8 @@ export interface RunOptions {
45
46
  resumeRunId?: string;
46
47
  logger?: Logger;
47
48
  mcpManager?: MCPManager;
49
+ preventExit?: boolean; // Defaults to false
50
+ workflowDir?: string;
48
51
  }
49
52
 
50
53
  export interface StepContext {
@@ -76,9 +79,12 @@ export class WorkflowRunner {
76
79
  private restored = false;
77
80
  private logger: Logger;
78
81
  private mcpManager: MCPManager;
82
+ private options: RunOptions;
83
+ private signalHandler?: (signal: string) => void;
79
84
 
80
85
  constructor(workflow: Workflow, options: RunOptions = {}) {
81
86
  this.workflow = workflow;
87
+ this.options = options;
82
88
  this.db = new WorkflowDb(options.dbPath);
83
89
  this.secrets = this.loadSecrets();
84
90
  this.redactor = new Redactor(this.secrets);
@@ -257,7 +263,7 @@ export class WorkflowRunner {
257
263
  * Setup signal handlers for graceful shutdown
258
264
  */
259
265
  private setupSignalHandlers(): void {
260
- const handleShutdown = async (signal: string) => {
266
+ const handler = async (signal: string) => {
261
267
  this.logger.log(`\n\nšŸ›‘ Received ${signal}. Cleaning up...`);
262
268
  try {
263
269
  await this.db.updateRunStatus(
@@ -267,15 +273,30 @@ export class WorkflowRunner {
267
273
  `Cancelled by user (${signal})`
268
274
  );
269
275
  this.logger.log('āœ“ Run status updated to failed');
270
- this.db.close();
271
276
  } catch (error) {
272
277
  this.logger.error('Error during cleanup:', error);
273
278
  }
274
- process.exit(130); // Standard exit code for SIGINT
279
+
280
+ // Only exit if not embedded
281
+ if (!this.options.preventExit) {
282
+ process.exit(130);
283
+ }
275
284
  };
276
285
 
277
- process.on('SIGINT', () => handleShutdown('SIGINT'));
278
- process.on('SIGTERM', () => handleShutdown('SIGTERM'));
286
+ this.signalHandler = handler;
287
+
288
+ process.on('SIGINT', handler);
289
+ process.on('SIGTERM', handler);
290
+ }
291
+
292
+ /**
293
+ * Remove signal handlers
294
+ */
295
+ private removeSignalHandlers(): void {
296
+ if (this.signalHandler) {
297
+ process.removeListener('SIGINT', this.signalHandler);
298
+ process.removeListener('SIGTERM', this.signalHandler);
299
+ }
279
300
  }
280
301
 
281
302
  /**
@@ -437,7 +458,8 @@ export class WorkflowRunner {
437
458
  context,
438
459
  this.logger,
439
460
  this.executeSubWorkflow.bind(this),
440
- this.mcpManager
461
+ this.mcpManager,
462
+ this.options.workflowDir
441
463
  );
442
464
  if (result.status === 'failed') {
443
465
  throw new Error(result.error || 'Step failed');
@@ -681,6 +703,7 @@ export class WorkflowRunner {
681
703
  ): Promise<StepResult> {
682
704
  const workflowPath = WorkflowRegistry.resolvePath(step.path);
683
705
  const workflow = WorkflowParser.loadWorkflow(workflowPath);
706
+ const subWorkflowDir = dirname(workflowPath);
684
707
 
685
708
  // Evaluate inputs for the sub-workflow
686
709
  const inputs: Record<string, unknown> = {};
@@ -697,6 +720,7 @@ export class WorkflowRunner {
697
720
  dbPath: this.db.dbPath,
698
721
  logger: this.logger,
699
722
  mcpManager: this.mcpManager,
723
+ workflowDir: subWorkflowDir,
700
724
  });
701
725
 
702
726
  try {
@@ -853,8 +877,11 @@ export class WorkflowRunner {
853
877
  await this.db.updateRunStatus(this.runId, 'failed', undefined, errorMsg);
854
878
  throw error;
855
879
  } finally {
880
+ this.removeSignalHandlers();
856
881
  await this.runFinally();
857
- await this.mcpManager.stopAll();
882
+ if (!this.options.mcpManager) {
883
+ await this.mcpManager.stopAll();
884
+ }
858
885
  this.db.close();
859
886
  }
860
887
  }
@@ -0,0 +1,54 @@
1
+ ---
2
+ name: explore
3
+ description: Agent for exploring and understanding codebases
4
+ model: claude-sonnet-4.5
5
+ ---
6
+
7
+ # Explore Agent
8
+
9
+ You are an expert at exploring and understanding new codebases. Your role is to map out the structure, identify key components, and understand how the system works.
10
+
11
+ ## Core Competencies
12
+
13
+ ### Codebase Exploration
14
+ - Directory structure analysis
15
+ - Key file identification
16
+ - Entry point discovery
17
+ - Configuration analysis
18
+
19
+ ### Architectural Mapping
20
+ - Component identification
21
+ - Service boundaries
22
+ - Data flow mapping
23
+ - Dependency analysis
24
+
25
+ ### Pattern Recognition
26
+ - Coding conventions
27
+ - Design patterns in use
28
+ - Framework-specific idioms
29
+ - Error handling patterns
30
+
31
+ ## Exploration Process
32
+
33
+ 1. **Scan Structure** - Understand the high-level directory organization
34
+ 2. **Identify Entry Points** - Find where the application or specific features start
35
+ 3. **Trace Flows** - Follow the data or execution path for key use cases
36
+ 4. **Analyze Configuration** - Understand how the system is set up and tuned
37
+ 5. **Map Dependencies** - Identify internal and external connections
38
+
39
+ ## Output Format
40
+
41
+ ### Exploration Summary
42
+ - **Key Files & Directories**: Most important parts of the codebase
43
+ - **Architecture Overview**: How the pieces fit together
44
+ - **Notable Patterns**: Consistent ways the code is written
45
+ - **Dependencies**: Critical internal and external links
46
+ - **Concerns/Complexity**: Areas that might be difficult to work with
47
+
48
+ ## Guidelines
49
+
50
+ - Focus on the big picture first, then dive into details
51
+ - Identify both what is there and what is missing
52
+ - Look for consistency and deviations
53
+ - Provide clear references to files and directories
54
+ - Summarize findings for technical and non-technical audiences
@@ -0,0 +1,8 @@
1
+ ---
2
+ name: general
3
+ description: "A general-purpose assistant for various tasks"
4
+ model: gpt-4o
5
+ ---
6
+
7
+ # Identity
8
+ You are a versatile and helpful assistant capable of handling a wide range of tasks, from information retrieval to analysis and formatting.