keystone-cli 0.1.1 → 0.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 (51) hide show
  1. package/README.md +52 -15
  2. package/package.json +1 -1
  3. package/src/cli.ts +90 -81
  4. package/src/db/workflow-db.ts +0 -7
  5. package/src/expression/evaluator.test.ts +42 -0
  6. package/src/expression/evaluator.ts +28 -0
  7. package/src/parser/agent-parser.test.ts +10 -0
  8. package/src/parser/agent-parser.ts +2 -1
  9. package/src/parser/config-schema.ts +13 -5
  10. package/src/parser/workflow-parser.ts +0 -5
  11. package/src/runner/llm-adapter.test.ts +0 -8
  12. package/src/runner/llm-adapter.ts +33 -10
  13. package/src/runner/llm-executor.test.ts +59 -18
  14. package/src/runner/llm-executor.ts +1 -1
  15. package/src/runner/mcp-client.test.ts +166 -88
  16. package/src/runner/mcp-client.ts +156 -22
  17. package/src/runner/mcp-manager.test.ts +73 -15
  18. package/src/runner/mcp-manager.ts +44 -18
  19. package/src/runner/mcp-server.test.ts +4 -1
  20. package/src/runner/mcp-server.ts +25 -11
  21. package/src/runner/shell-executor.ts +3 -3
  22. package/src/runner/step-executor.ts +10 -9
  23. package/src/runner/tool-integration.test.ts +21 -14
  24. package/src/runner/workflow-runner.ts +25 -5
  25. package/src/templates/agents/explore.md +54 -0
  26. package/src/templates/agents/general.md +8 -0
  27. package/src/templates/agents/keystone-architect.md +54 -0
  28. package/src/templates/agents/my-agent.md +3 -0
  29. package/src/templates/agents/summarizer.md +28 -0
  30. package/src/templates/agents/test-agent.md +10 -0
  31. package/src/templates/approval-process.yaml +36 -0
  32. package/src/templates/basic-inputs.yaml +19 -0
  33. package/src/templates/basic-shell.yaml +20 -0
  34. package/src/templates/batch-processor.yaml +43 -0
  35. package/src/templates/cleanup-finally.yaml +22 -0
  36. package/src/templates/composition-child.yaml +13 -0
  37. package/src/templates/composition-parent.yaml +14 -0
  38. package/src/templates/data-pipeline.yaml +38 -0
  39. package/src/templates/full-feature-demo.yaml +64 -0
  40. package/src/templates/human-interaction.yaml +12 -0
  41. package/src/templates/invalid.yaml +5 -0
  42. package/src/templates/llm-agent.yaml +8 -0
  43. package/src/templates/loop-parallel.yaml +37 -0
  44. package/src/templates/retry-policy.yaml +36 -0
  45. package/src/templates/scaffold-feature.yaml +48 -0
  46. package/src/templates/state.db +0 -0
  47. package/src/templates/state.db-shm +0 -0
  48. package/src/templates/state.db-wal +0 -0
  49. package/src/templates/stop-watch.yaml +17 -0
  50. package/src/templates/workflow.db +0 -0
  51. package/src/utils/config-loader.test.ts +2 -2
@@ -1,10 +1,12 @@
1
1
  import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from 'bun:test';
2
- import { MCPManager } from './mcp-manager';
3
- import { MCPClient } from './mcp-client';
4
- import { ConfigLoader } from '../utils/config-loader';
5
2
  import * as child_process from 'node:child_process';
6
- import { Readable, Writable } from 'node:stream';
7
3
  import { EventEmitter } from 'node:events';
4
+ import { Readable, Writable } from 'node:stream';
5
+ import { ConfigLoader } from '../utils/config-loader';
6
+ import { MCPClient, type MCPResponse } from './mcp-client';
7
+ import { MCPManager } from './mcp-manager';
8
+
9
+ import type { Config } from '../parser/config-schema';
8
10
 
9
11
  describe('MCPManager', () => {
10
12
  let spawnSpy: ReturnType<typeof spyOn>;
@@ -15,7 +17,7 @@ describe('MCPManager', () => {
15
17
  const mockProcess = Object.assign(new EventEmitter(), {
16
18
  stdout: new Readable({ read() {} }),
17
19
  stdin: new Writable({
18
- write(_chunk, _encoding, cb) {
20
+ write(_chunk, _encoding, cb: (error?: Error | null) => void) {
19
21
  cb();
20
22
  },
21
23
  }),
@@ -35,6 +37,7 @@ describe('MCPManager', () => {
35
37
  ConfigLoader.setConfig({
36
38
  mcp_servers: {
37
39
  'test-server': {
40
+ type: 'local',
38
41
  command: 'node',
39
42
  args: ['test.js'],
40
43
  env: { FOO: 'bar' },
@@ -43,7 +46,9 @@ describe('MCPManager', () => {
43
46
  providers: {},
44
47
  model_mappings: {},
45
48
  default_provider: 'openai',
46
- });
49
+ storage: { retention_days: 30 },
50
+ workflows_directory: 'workflows',
51
+ } as unknown as Config);
47
52
 
48
53
  const manager = new MCPManager();
49
54
  const servers = manager.getGlobalServers();
@@ -56,13 +61,16 @@ describe('MCPManager', () => {
56
61
  ConfigLoader.setConfig({
57
62
  mcp_servers: {
58
63
  'test-server': {
64
+ type: 'local',
59
65
  command: 'node',
60
66
  },
61
67
  },
62
68
  providers: {},
63
69
  model_mappings: {},
64
70
  default_provider: 'openai',
65
- });
71
+ storage: { retention_days: 30 },
72
+ workflows_directory: 'workflows',
73
+ } as unknown as Config);
66
74
 
67
75
  const initSpy = spyOn(MCPClient.prototype, 'initialize').mockResolvedValue({
68
76
  result: { protocolVersion: '1.0' },
@@ -99,6 +107,7 @@ describe('MCPManager', () => {
99
107
  const manager = new MCPManager();
100
108
  const client = await manager.getClient({
101
109
  name: 'adhoc',
110
+ type: 'local',
102
111
  command: 'node',
103
112
  });
104
113
 
@@ -114,30 +123,79 @@ describe('MCPManager', () => {
114
123
  expect(client).toBeUndefined();
115
124
  });
116
125
 
126
+ it('should handle concurrent connection requests without double-spawning', async () => {
127
+ ConfigLoader.setConfig({
128
+ mcp_servers: {
129
+ 'concurrent-server': {
130
+ type: 'local',
131
+ command: 'node',
132
+ },
133
+ },
134
+ providers: {},
135
+ model_mappings: {},
136
+ default_provider: 'openai',
137
+ storage: { retention_days: 30 },
138
+ workflows_directory: 'workflows',
139
+ } as unknown as Config);
140
+
141
+ // Mock initialize to take some time
142
+ let initCalls = 0;
143
+ const initSpy = spyOn(MCPClient.prototype, 'initialize').mockImplementation(async () => {
144
+ initCalls++;
145
+ await new Promise((resolve) => setTimeout(resolve, 50));
146
+ return {
147
+ result: { protocolVersion: '1.0' },
148
+ jsonrpc: '2.0',
149
+ id: 0,
150
+ } as MCPResponse;
151
+ });
152
+
153
+ const manager = new MCPManager();
154
+
155
+ // Fire off multiple requests concurrently
156
+ const p1 = manager.getClient('concurrent-server');
157
+ const p2 = manager.getClient('concurrent-server');
158
+ const p3 = manager.getClient('concurrent-server');
159
+
160
+ const [c1, c2, c3] = await Promise.all([p1, p2, p3]);
161
+
162
+ expect(c1).toBeDefined();
163
+ expect(c1).toBe(c2);
164
+ expect(c1).toBe(c3);
165
+ expect(initCalls).toBe(1); // Crucial: only one initialization
166
+
167
+ initSpy.mockRestore();
168
+ });
169
+
117
170
  it('should handle connection failure', async () => {
118
171
  ConfigLoader.setConfig({
119
172
  mcp_servers: {
120
173
  'fail-server': {
174
+ type: 'local',
121
175
  command: 'fail',
122
176
  },
123
177
  },
124
178
  providers: {},
125
179
  model_mappings: {},
126
180
  default_provider: 'openai',
127
- });
128
-
129
- const initSpy = spyOn(MCPClient.prototype, 'initialize').mockRejectedValue(
130
- new Error('Connection failed')
181
+ storage: { retention_days: 30 },
182
+ workflows_directory: 'workflows',
183
+ } as unknown as Config);
184
+
185
+ const createLocalSpy = spyOn(MCPClient, 'createLocal').mockImplementation(
186
+ async (_cmd: string) => {
187
+ const client = Object.create(MCPClient.prototype);
188
+ spyOn(client, 'initialize').mockRejectedValue(new Error('Connection failed'));
189
+ spyOn(client, 'stop').mockReturnValue(undefined);
190
+ return client;
191
+ }
131
192
  );
132
- const stopSpy = spyOn(MCPClient.prototype, 'stop').mockReturnValue(undefined);
133
193
 
134
194
  const manager = new MCPManager();
135
195
  const client = await manager.getClient('fail-server');
136
196
 
137
197
  expect(client).toBeUndefined();
138
- expect(stopSpy).toHaveBeenCalled();
139
198
 
140
- initSpy.mockRestore();
141
- stopSpy.mockRestore();
199
+ createLocalSpy.mockRestore();
142
200
  });
143
201
  });
@@ -1,16 +1,20 @@
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>;
10
13
  }
11
14
 
12
15
  export class MCPManager {
13
16
  private clients: Map<string, MCPClient> = new Map();
17
+ private connectionPromises: Map<string, Promise<MCPClient | undefined>> = new Map();
14
18
  private sharedServers: Map<string, MCPServerConfig> = new Map();
15
19
 
16
20
  constructor() {
@@ -23,10 +27,8 @@ export class MCPManager {
23
27
  for (const [name, server] of Object.entries(config.mcp_servers)) {
24
28
  this.sharedServers.set(name, {
25
29
  name,
26
- command: server.command,
27
- args: server.args,
28
- env: server.env,
29
- });
30
+ ...server,
31
+ } as MCPServerConfig);
30
32
  }
31
33
  }
32
34
  }
@@ -49,23 +51,47 @@ export class MCPManager {
49
51
  }
50
52
 
51
53
  const key = this.getServerKey(config);
54
+
55
+ // Check if we already have a client
52
56
  if (this.clients.has(key)) {
53
57
  return this.clients.get(key);
54
58
  }
55
59
 
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;
60
+ // Check if we are already connecting
61
+ if (this.connectionPromises.has(key)) {
62
+ return this.connectionPromises.get(key);
68
63
  }
64
+
65
+ // Start a new connection and cache the promise
66
+ const connectionPromise = (async () => {
67
+ logger.log(` šŸ”Œ Connecting to MCP server: ${config.name} (${config.type || 'local'})`);
68
+
69
+ let client: MCPClient;
70
+ try {
71
+ if (config.type === 'remote') {
72
+ if (!config.url) throw new Error('Remote MCP server missing URL');
73
+ client = await MCPClient.createRemote(config.url, config.headers || {});
74
+ } else {
75
+ if (!config.command) throw new Error('Local MCP server missing command');
76
+ client = await MCPClient.createLocal(config.command, config.args || [], config.env || {});
77
+ }
78
+
79
+ await client.initialize();
80
+ this.clients.set(key, client);
81
+ return client;
82
+ } catch (error) {
83
+ logger.error(
84
+ ` āœ— Failed to connect to MCP server ${config.name}: ${error instanceof Error ? error.message : String(error)}`
85
+ );
86
+ return undefined;
87
+ } finally {
88
+ // Remove promise from cache once settled
89
+ this.connectionPromises.delete(key);
90
+ }
91
+ })();
92
+
93
+ this.connectionPromises.set(key, connectionPromise);
94
+ return connectionPromise;
69
95
  }
70
96
 
71
97
  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
@@ -84,11 +84,12 @@ export async function executeStep(
84
84
  // Apply transformation if specified and step succeeded
85
85
  if (step.transform && result.status === 'success') {
86
86
  const transformContext = {
87
- // Provide raw output properties (like stdout, data) directly in context
88
- // Fix: Spread output FIRST, then context to prevent shadowing
87
+ // 1. Provide raw output properties (like stdout, data) directly in context for convenience
89
88
  ...(typeof result.output === 'object' && result.output !== null ? result.output : {}),
90
- output: result.output,
89
+ // 2. Add core context (inputs, secrets, etc.). This takes priority over output properties for security.
91
90
  ...context,
91
+ // 3. Explicitly add 'output' so it refers to the current step's result even if context or output properties have a collision.
92
+ output: result.output,
92
93
  };
93
94
 
94
95
  try {
@@ -159,7 +160,7 @@ async function executeFileStep(
159
160
  context: ExpressionContext,
160
161
  _logger: Logger
161
162
  ): Promise<StepResult> {
162
- const path = ExpressionEvaluator.evaluate(step.path, context) as string;
163
+ const path = ExpressionEvaluator.evaluateString(step.path, context);
163
164
 
164
165
  switch (step.op) {
165
166
  case 'read': {
@@ -178,7 +179,7 @@ async function executeFileStep(
178
179
  if (!step.content) {
179
180
  throw new Error('Content is required for write operation');
180
181
  }
181
- const content = ExpressionEvaluator.evaluate(step.content, context) as string;
182
+ const content = ExpressionEvaluator.evaluateString(step.content, context);
182
183
  const bytes = await Bun.write(path, content);
183
184
  return {
184
185
  output: { path, bytes },
@@ -190,7 +191,7 @@ async function executeFileStep(
190
191
  if (!step.content) {
191
192
  throw new Error('Content is required for append operation');
192
193
  }
193
- const content = ExpressionEvaluator.evaluate(step.content, context) as string;
194
+ const content = ExpressionEvaluator.evaluateString(step.content, context);
194
195
 
195
196
  // Use Node.js fs for efficient append operation
196
197
  const fs = await import('node:fs/promises');
@@ -215,13 +216,13 @@ async function executeRequestStep(
215
216
  context: ExpressionContext,
216
217
  _logger: Logger
217
218
  ): Promise<StepResult> {
218
- const url = ExpressionEvaluator.evaluate(step.url, context) as string;
219
+ const url = ExpressionEvaluator.evaluateString(step.url, context);
219
220
 
220
221
  // Evaluate headers
221
222
  const headers: Record<string, string> = {};
222
223
  if (step.headers) {
223
224
  for (const [key, value] of Object.entries(step.headers)) {
224
- headers[key] = ExpressionEvaluator.evaluate(value, context) as string;
225
+ headers[key] = ExpressionEvaluator.evaluateString(value, context);
225
226
  }
226
227
  }
227
228
 
@@ -290,7 +291,7 @@ async function executeHumanStep(
290
291
  context: ExpressionContext,
291
292
  logger: Logger
292
293
  ): Promise<StepResult> {
293
- const message = ExpressionEvaluator.evaluate(step.message, context) as string;
294
+ const message = ExpressionEvaluator.evaluateString(step.message, context);
294
295
 
295
296
  // If not a TTY (e.g. MCP server), suspend execution
296
297
  if (!process.stdin.isTTY) {
@@ -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',
@@ -45,6 +45,7 @@ export interface RunOptions {
45
45
  resumeRunId?: string;
46
46
  logger?: Logger;
47
47
  mcpManager?: MCPManager;
48
+ preventExit?: boolean; // Defaults to false
48
49
  }
49
50
 
50
51
  export interface StepContext {
@@ -76,9 +77,12 @@ export class WorkflowRunner {
76
77
  private restored = false;
77
78
  private logger: Logger;
78
79
  private mcpManager: MCPManager;
80
+ private options: RunOptions;
81
+ private signalHandler?: (signal: string) => void;
79
82
 
80
83
  constructor(workflow: Workflow, options: RunOptions = {}) {
81
84
  this.workflow = workflow;
85
+ this.options = options;
82
86
  this.db = new WorkflowDb(options.dbPath);
83
87
  this.secrets = this.loadSecrets();
84
88
  this.redactor = new Redactor(this.secrets);
@@ -257,7 +261,7 @@ export class WorkflowRunner {
257
261
  * Setup signal handlers for graceful shutdown
258
262
  */
259
263
  private setupSignalHandlers(): void {
260
- const handleShutdown = async (signal: string) => {
264
+ const handler = async (signal: string) => {
261
265
  this.logger.log(`\n\nšŸ›‘ Received ${signal}. Cleaning up...`);
262
266
  try {
263
267
  await this.db.updateRunStatus(
@@ -267,15 +271,30 @@ export class WorkflowRunner {
267
271
  `Cancelled by user (${signal})`
268
272
  );
269
273
  this.logger.log('āœ“ Run status updated to failed');
270
- this.db.close();
271
274
  } catch (error) {
272
275
  this.logger.error('Error during cleanup:', error);
273
276
  }
274
- process.exit(130); // Standard exit code for SIGINT
277
+
278
+ // Only exit if not embedded
279
+ if (!this.options.preventExit) {
280
+ process.exit(130);
281
+ }
275
282
  };
276
283
 
277
- process.on('SIGINT', () => handleShutdown('SIGINT'));
278
- process.on('SIGTERM', () => handleShutdown('SIGTERM'));
284
+ this.signalHandler = handler;
285
+
286
+ process.on('SIGINT', handler);
287
+ process.on('SIGTERM', handler);
288
+ }
289
+
290
+ /**
291
+ * Remove signal handlers
292
+ */
293
+ private removeSignalHandlers(): void {
294
+ if (this.signalHandler) {
295
+ process.removeListener('SIGINT', this.signalHandler);
296
+ process.removeListener('SIGTERM', this.signalHandler);
297
+ }
279
298
  }
280
299
 
281
300
  /**
@@ -853,6 +872,7 @@ export class WorkflowRunner {
853
872
  await this.db.updateRunStatus(this.runId, 'failed', undefined, errorMsg);
854
873
  throw error;
855
874
  } finally {
875
+ this.removeSignalHandlers();
856
876
  await this.runFinally();
857
877
  await this.mcpManager.stopAll();
858
878
  this.db.close();
@@ -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.