agent-state-machine 2.0.11 → 2.0.13

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.
package/README.md CHANGED
@@ -56,6 +56,7 @@ Workflows live in:
56
56
  ```text
57
57
  workflows/<name>/
58
58
  ├── workflow.js # Native JS workflow (async/await)
59
+ ├── config.js # Model/API key configuration
59
60
  ├── package.json # Sets "type": "module" for this workflow folder
60
61
  ├── agents/ # Custom agents (.js/.mjs/.cjs or .md)
61
62
  ├── interactions/ # Human-in-the-loop files (auto-created)
@@ -67,6 +68,8 @@ workflows/<name>/
67
68
 
68
69
  ## Writing workflows (native JS)
69
70
 
71
+ Edit `config.js` to set models and API keys for the workflow.
72
+
70
73
  ```js
71
74
  /**
72
75
  /**
@@ -83,20 +86,6 @@ workflows/<name>/
83
86
  import { agent, memory, askHuman, parallel } from 'agent-state-machine';
84
87
  import { notify } from './scripts/mac-notification.js';
85
88
 
86
- // Model configuration (also supports models in a separate config export)
87
- export const config = {
88
- models: {
89
- low: "gemini",
90
- med: "codex --model gpt-5.2",
91
- high: "claude -m claude-opus-4-20250514 -p",
92
- },
93
- apiKeys: {
94
- gemini: process.env.GEMINI_API_KEY,
95
- anthropic: process.env.ANTHROPIC_API_KEY,
96
- openai: process.env.OPENAI_API_KEY,
97
- }
98
- };
99
-
100
89
  export default async function() {
101
90
  console.log('Starting project-builder workflow...');
102
91
 
@@ -298,7 +287,7 @@ export const config = {
298
287
  };
299
288
  ```
300
289
 
301
- The runtime captures the fully-built prompt in `state/history.jsonl`, viewable in the browser with live updates when running with the `--local` flag or via the remote URL. Remote follow links persist across runs (stored in `workflow.js` config) unless you pass `-n`/`--new` to regenerate.
290
+ The runtime captures the fully-built prompt in `state/history.jsonl`, viewable in the browser with live updates when running with the `--local` flag or via the remote URL. Remote follow links persist across runs (stored in `config.js`) unless you pass `-n`/`--new` to regenerate.
302
291
 
303
292
  ---
304
293
 
package/bin/cli.js CHANGED
@@ -63,6 +63,7 @@ Environment Variables:
63
63
  Workflow Structure:
64
64
  workflows/<name>/
65
65
  ├── workflow.js # Native JS workflow (async/await)
66
+ ├── config.js # Model/API key configuration
66
67
  ├── package.json # Sets "type": "module" for this workflow folder
67
68
  ├── agents/ # Custom agents (.js/.mjs/.cjs or .md)
68
69
  ├── interactions/ # Human-in-the-loop files (auto-created)
@@ -185,8 +186,8 @@ function findConfigObjectRange(source) {
185
186
  return null;
186
187
  }
187
188
 
188
- function readRemotePathFromWorkflow(workflowFile) {
189
- const source = fs.readFileSync(workflowFile, 'utf-8');
189
+ function readRemotePathFromConfig(configFile) {
190
+ const source = fs.readFileSync(configFile, 'utf-8');
190
191
  const range = findConfigObjectRange(source);
191
192
  if (!range) return null;
192
193
  const configSource = source.slice(range.start, range.end + 1);
@@ -194,19 +195,19 @@ function readRemotePathFromWorkflow(workflowFile) {
194
195
  return match ? match[2] : null;
195
196
  }
196
197
 
197
- function writeRemotePathToWorkflow(workflowFile, remotePath) {
198
- const source = fs.readFileSync(workflowFile, 'utf-8');
198
+ function writeRemotePathToConfig(configFile, remotePath) {
199
+ const source = fs.readFileSync(configFile, 'utf-8');
199
200
  const range = findConfigObjectRange(source);
200
201
  const remoteLine = `remotePath: "${remotePath}"`;
201
202
 
202
203
  if (!range) {
203
204
  const hasConfigExport = /export\s+const\s+config\s*=/.test(source);
204
205
  if (hasConfigExport) {
205
- throw new Error('Workflow config export is not an object literal; add remotePath manually.');
206
+ throw new Error('Config export is not an object literal; add remotePath manually.');
206
207
  }
207
208
  const trimmed = source.replace(/\s*$/, '');
208
209
  const appended = `${trimmed}\n\nexport const config = {\n ${remoteLine}\n};\n`;
209
- fs.writeFileSync(workflowFile, appended);
210
+ fs.writeFileSync(configFile, appended);
210
211
  return;
211
212
  }
212
213
 
@@ -247,15 +248,15 @@ function writeRemotePathToWorkflow(workflowFile, remotePath) {
247
248
  source.slice(0, range.start) +
248
249
  updatedConfigSource +
249
250
  source.slice(range.end + 1);
250
- fs.writeFileSync(workflowFile, updatedSource);
251
+ fs.writeFileSync(configFile, updatedSource);
251
252
  }
252
253
 
253
- function ensureRemotePath(workflowFile, { forceNew = false } = {}) {
254
- const existing = readRemotePathFromWorkflow(workflowFile);
254
+ function ensureRemotePath(configFile, { forceNew = false } = {}) {
255
+ const existing = readRemotePathFromConfig(configFile);
255
256
  if (existing && !forceNew) return existing;
256
257
 
257
258
  const remotePath = generateSessionToken();
258
- writeRemotePathToWorkflow(workflowFile, remotePath);
259
+ writeRemotePathToConfig(configFile, remotePath);
259
260
  return remotePath;
260
261
  }
261
262
 
@@ -276,6 +277,7 @@ function summarizeStatus(state) {
276
277
  if (s === 'COMPLETED') return ' [completed]';
277
278
  if (s === 'FAILED') return ' [failed - can resume]';
278
279
  if (s === 'PAUSED') return ' [paused - can resume]';
280
+ if (s === 'STOPPED') return ' [stopped - can resume]';
279
281
  if (s === 'RUNNING') return ' [running]';
280
282
  if (s === 'IDLE') return ' [idle]';
281
283
  return state.status ? ` [${state.status}]` : '';
@@ -361,6 +363,7 @@ async function runOrResume(
361
363
  }
362
364
 
363
365
  const workflowUrl = pathToFileURL(entry).href;
366
+ const configFile = path.join(workflowDir, 'config.js');
364
367
 
365
368
  let localServer = null;
366
369
  let remoteUrl = null;
@@ -382,7 +385,7 @@ async function runOrResume(
382
385
 
383
386
  // Enable remote follow mode if we have a URL
384
387
  if (remoteUrl) {
385
- const sessionToken = ensureRemotePath(entry, { forceNew: forceNewRemotePath });
388
+ const sessionToken = ensureRemotePath(configFile, { forceNew: forceNewRemotePath });
386
389
  await runtime.enableRemote(remoteUrl, { sessionToken });
387
390
  }
388
391
 
package/lib/llm.js CHANGED
@@ -51,6 +51,7 @@ export function buildPrompt(context, options) {
51
51
  delete cleanContext._steering;
52
52
  delete cleanContext._loop;
53
53
  delete cleanContext._config;
54
+ delete cleanContext._memory;
54
55
 
55
56
  // Add the actual prompt
56
57
  parts.push('# Task\n\n');
@@ -70,13 +71,22 @@ export function buildPrompt(context, options) {
70
71
  parts.push('{ "interact": "your question here" }\n\n');
71
72
  parts.push('Only use this format when you genuinely need user input to proceed.\n\n---\n');
72
73
 
73
- // Add global steering if available
74
+ // Add global steering if available (always first)
74
75
  if (context._steering?.global) {
75
76
  parts.push('# System Instructions\n');
76
77
  parts.push(context._steering.global);
77
78
  parts.push('\n---\n');
78
79
  }
79
80
 
81
+ // Add additional steering files if available
82
+ if (context._steering?.additional && context._steering.additional.length > 0) {
83
+ parts.push('# Additional Guidelines\n');
84
+ for (const content of context._steering.additional) {
85
+ parts.push(content);
86
+ parts.push('\n---\n');
87
+ }
88
+ }
89
+
80
90
  return parts.join('\n');
81
91
  }
82
92
 
@@ -314,7 +324,7 @@ async function executeAPI(provider, model, prompt, apiKey, options = {}) {
314
324
  *
315
325
  * @param {object} context - The workflow context (contains _config, _steering, etc.)
316
326
  * @param {object} options - Options for the LLM call
317
- * @param {string} options.model - Model key from workflow.js models config
327
+ * @param {string} options.model - Model key from config.js models config
318
328
  * @param {string} options.prompt - The prompt to send
319
329
  * @param {boolean} options.includeContext - Whether to include context in prompt (default: true)
320
330
  * @param {number} options.maxTokens - Max tokens for API calls (default: 4096)
@@ -359,7 +369,7 @@ export async function llm(context, options) {
359
369
 
360
370
  if (!apiKey) {
361
371
  throw new Error(
362
- `No API key found for ${provider}. Set in workflow.js apiKeys or ${provider.toUpperCase()}_API_KEY env var`
372
+ `No API key found for ${provider}. Set in config.js apiKeys or ${provider.toUpperCase()}_API_KEY env var`
363
373
  );
364
374
  }
365
375
 
@@ -459,4 +469,4 @@ export async function llmJSON(context, options) {
459
469
  ...response,
460
470
  data: parseJSON(response.text)
461
471
  };
462
- }
472
+ }
@@ -17,50 +17,87 @@ const require = createRequire(import.meta.url);
17
17
  /**
18
18
  * Run an agent with context
19
19
  * @param {string} name - Agent name (file basename)
20
- * @param {object} params - Parameters passed to agent
20
+ * @param {object} params - Parameters passed to agent (default: {})
21
+ * @param {object} options - Agent execution options (default: {})
22
+ * @param {number|false} options.retry - Number of retries (default: 2, meaning 3 total attempts). Set to false to disable.
23
+ * @param {string|string[]} options.steering - Additional steering files to load from steering/ folder
21
24
  */
22
- export async function agent(name, params = {}) {
25
+ export async function agent(name, params = {}, options = {}) {
23
26
  const runtime = getCurrentRuntime();
24
27
  if (!runtime) {
25
28
  throw new Error('agent() must be called within a workflow context');
26
29
  }
27
30
 
28
- console.log(` [Agent: ${name}] Starting...`);
31
+ // Parse retry option: default is 2 retries (3 total attempts)
32
+ const retryCount = options.retry === false ? 0 : (options.retry ?? 2);
29
33
 
30
- try {
31
- const result = await executeAgent(runtime, name, params);
34
+ let lastError;
32
35
 
33
- if (result && typeof result === 'object' && result._debug_prompt) {
34
- delete result._debug_prompt;
35
- }
36
+ for (let attempt = 0; attempt <= retryCount; attempt++) {
37
+ try {
38
+ if (attempt > 0) {
39
+ console.log(` [Agent: ${name}] Retry attempt ${attempt}/${retryCount}...`);
40
+ } else {
41
+ console.log(` [Agent: ${name}] Starting...`);
42
+ }
43
+
44
+ const result = await executeAgent(runtime, name, params, options);
45
+
46
+ if (result && typeof result === 'object' && result._debug_prompt) {
47
+ delete result._debug_prompt;
48
+ }
49
+
50
+ console.log(` [Agent: ${name}] Completed`);
51
+ if (runtime._agentSuppressCompletion?.has(name)) {
52
+ runtime._agentSuppressCompletion.delete(name);
53
+ return result;
54
+ }
55
+
56
+ runtime.prependHistory({
57
+ event: 'AGENT_COMPLETED',
58
+ agent: name,
59
+ output: result,
60
+ attempts: attempt + 1
61
+ });
36
62
 
37
- console.log(` [Agent: ${name}] Completed`);
38
- if (runtime._agentSuppressCompletion?.has(name)) {
39
- runtime._agentSuppressCompletion.delete(name);
40
63
  return result;
64
+ } catch (error) {
65
+ lastError = error;
66
+
67
+ if (attempt < retryCount) {
68
+ console.error(` [Agent: ${name}] Error (attempt ${attempt + 1}/${retryCount + 1}): ${error.message}`);
69
+ runtime.prependHistory({
70
+ event: 'AGENT_RETRY',
71
+ agent: name,
72
+ attempt: attempt + 1,
73
+ error: error.message
74
+ });
75
+ }
41
76
  }
77
+ }
42
78
 
43
- runtime.prependHistory({
44
- event: 'AGENT_COMPLETED',
45
- agent: name,
46
- output: result
47
- });
79
+ // All retries exhausted - record failure
80
+ runtime.prependHistory({
81
+ event: 'AGENT_FAILED',
82
+ agent: name,
83
+ error: lastError.message,
84
+ attempts: retryCount + 1
85
+ });
48
86
 
49
- return result;
50
- } catch (error) {
51
- runtime.prependHistory({
52
- event: 'AGENT_FAILED',
53
- agent: name,
54
- error: error.message
55
- });
56
- throw error;
57
- }
87
+ // Store error in accessible location (not auto-spread to context)
88
+ runtime._agentErrors.push({
89
+ agent: name,
90
+ error: lastError.message,
91
+ timestamp: new Date().toISOString()
92
+ });
93
+
94
+ throw lastError;
58
95
  }
59
96
 
60
97
  /**
61
98
  * Execute an agent (load and run)
62
99
  */
63
- export async function executeAgent(runtime, name, params) {
100
+ export async function executeAgent(runtime, name, params, options = {}) {
64
101
  const agentsDir = runtime.agentsDir;
65
102
 
66
103
  // Try JS agents (.js/.mjs/.cjs)
@@ -72,14 +109,14 @@ export async function executeAgent(runtime, name, params) {
72
109
 
73
110
  for (const p of jsCandidates) {
74
111
  if (fs.existsSync(p)) {
75
- return executeJSAgent(runtime, p, name, params);
112
+ return executeJSAgent(runtime, p, name, params, options);
76
113
  }
77
114
  }
78
115
 
79
116
  // Try Markdown agent
80
117
  const mdPath = path.join(agentsDir, `${name}.md`);
81
118
  if (fs.existsSync(mdPath)) {
82
- return executeMDAgent(runtime, mdPath, name, params);
119
+ return executeMDAgent(runtime, mdPath, name, params, options);
83
120
  }
84
121
 
85
122
  throw new Error(
@@ -94,7 +131,7 @@ export async function executeAgent(runtime, name, params) {
94
131
  * - ESM (.js/.mjs): loaded via dynamic import with cache-bust for hot reload
95
132
  * - CJS (.cjs): loaded via require() with cache clear
96
133
  */
97
- async function executeJSAgent(runtime, agentPath, name, params) {
134
+ async function executeJSAgent(runtime, agentPath, name, params, options = {}) {
98
135
  const ext = path.extname(agentPath).toLowerCase();
99
136
 
100
137
  let agentModule;
@@ -119,11 +156,15 @@ async function executeJSAgent(runtime, agentPath, name, params) {
119
156
 
120
157
  logAgentStart(runtime, name);
121
158
 
122
- // Build context
159
+ // Build steering context (global + any additional files from options)
160
+ const steeringContext = options.steering
161
+ ? runtime.loadSteeringFiles(options.steering)
162
+ : runtime.steering;
163
+
164
+ // Build context - only spread params, NOT memory (explicit context passing)
123
165
  const context = {
124
- ...runtime._rawMemory,
125
166
  ...params,
126
- _steering: runtime.steering,
167
+ _steering: steeringContext,
127
168
  _config: {
128
169
  models: runtime.workflowConfig.models,
129
170
  apiKeys: runtime.workflowConfig.apiKeys,
@@ -175,7 +216,7 @@ async function executeJSAgent(runtime, agentPath, name, params) {
175
216
  /**
176
217
  * Execute a Markdown agent (prompt-based)
177
218
  */
178
- async function executeMDAgent(runtime, agentPath, name, params) {
219
+ async function executeMDAgent(runtime, agentPath, name, params, options = {}) {
179
220
  const { llm, buildPrompt, parseJSON, parseInteractionRequest } = await import('../llm.js');
180
221
 
181
222
  const content = fs.readFileSync(agentPath, 'utf-8');
@@ -184,11 +225,28 @@ async function executeMDAgent(runtime, agentPath, name, params) {
184
225
  const outputKey = config.output || 'result';
185
226
  const targetKey = config.interactionKey || outputKey;
186
227
 
187
- // Build context
228
+ // Combine steering from options (runtime call) and frontmatter (static)
229
+ let steeringNames = [];
230
+
231
+ if (options.steering) {
232
+ const optSteering = Array.isArray(options.steering) ? options.steering : [options.steering];
233
+ steeringNames.push(...optSteering);
234
+ }
235
+
236
+ if (config.steering) {
237
+ const fmSteering = parseSteeringFrontmatter(config.steering);
238
+ steeringNames.push(...fmSteering);
239
+ }
240
+
241
+ // Build steering context (global + any additional files)
242
+ const steeringContext = steeringNames.length > 0
243
+ ? runtime.loadSteeringFiles(steeringNames)
244
+ : runtime.steering;
245
+
246
+ // Build context - only spread params, NOT memory (explicit context passing)
188
247
  const context = {
189
- ...runtime._rawMemory,
190
248
  ...params,
191
- _steering: runtime.steering,
249
+ _steering: steeringContext,
192
250
  _config: {
193
251
  models: runtime.workflowConfig.models,
194
252
  apiKeys: runtime.workflowConfig.apiKeys,
@@ -296,6 +354,36 @@ function parseMarkdownAgent(content) {
296
354
  return { config: {}, prompt: content.trim() };
297
355
  }
298
356
 
357
+ /**
358
+ * Parse steering value from frontmatter
359
+ * Supports: "name", "name1, name2", "[name1, name2]", "['name1', 'name2']"
360
+ * @param {string} value - Frontmatter steering value
361
+ * @returns {string[]} Array of steering file names
362
+ */
363
+ function parseSteeringFrontmatter(value) {
364
+ if (!value) return [];
365
+
366
+ const trimmed = value.trim();
367
+
368
+ // Handle array format: [a, b, c] or ["a", "b", "c"]
369
+ if (trimmed.startsWith('[') && trimmed.endsWith(']')) {
370
+ const inner = trimmed.slice(1, -1);
371
+ return inner.split(',')
372
+ .map(s => s.trim().replace(/^["']|["']$/g, ''))
373
+ .filter(Boolean);
374
+ }
375
+
376
+ // Handle comma-separated: a, b, c
377
+ if (trimmed.includes(',')) {
378
+ return trimmed.split(',')
379
+ .map(s => s.trim())
380
+ .filter(Boolean);
381
+ }
382
+
383
+ // Single value
384
+ return [trimmed];
385
+ }
386
+
299
387
  /**
300
388
  * Interpolate {{variables}} in prompt template
301
389
  */
@@ -9,6 +9,7 @@
9
9
  import fs from 'fs';
10
10
  import path from 'path';
11
11
  import readline from 'readline';
12
+ import { pathToFileURL } from 'url';
12
13
  import { createMemoryProxy } from './memory.js';
13
14
  import { RemoteClient } from '../remote/client.js';
14
15
 
@@ -88,6 +89,9 @@ export class WorkflowRuntime {
88
89
  // Agent interaction tracking for history logging
89
90
  this._agentResumeFlags = new Set();
90
91
  this._agentSuppressCompletion = new Set();
92
+
93
+ // Agent error tracking (not persisted to memory, but accessible during run)
94
+ this._agentErrors = [];
91
95
  }
92
96
 
93
97
  ensureDirectories() {
@@ -141,6 +145,39 @@ export class WorkflowRuntime {
141
145
  return steering;
142
146
  }
143
147
 
148
+ /**
149
+ * Load a specific steering file by name
150
+ * @param {string} name - Name of the steering file (without .md extension)
151
+ * @returns {string} Content of the steering file, or empty string if not found
152
+ */
153
+ loadSteeringFile(name) {
154
+ const filePath = path.join(this.steeringDir, `${name}.md`);
155
+
156
+ if (fs.existsSync(filePath)) {
157
+ return fs.readFileSync(filePath, 'utf-8');
158
+ }
159
+
160
+ console.warn(`${C.yellow}Warning: Steering file not found: ${name}.md${C.reset}`);
161
+ return '';
162
+ }
163
+
164
+ /**
165
+ * Load multiple steering files and combine with global
166
+ * @param {string|string[]} steeringNames - Names of steering files to load
167
+ * @returns {{ enabled: boolean, global: string, additional: string[] }}
168
+ */
169
+ loadSteeringFiles(steeringNames) {
170
+ const names = Array.isArray(steeringNames) ? steeringNames : [steeringNames];
171
+ const additional = names
172
+ .map(name => this.loadSteeringFile(name))
173
+ .filter(content => content.length > 0);
174
+
175
+ return {
176
+ ...this.steering,
177
+ additional
178
+ };
179
+ }
180
+
144
181
  /**
145
182
  * Persist state to disk
146
183
  */
@@ -218,6 +255,50 @@ export class WorkflowRuntime {
218
255
  async runWorkflow(workflowPath) {
219
256
  setCurrentRuntime(this);
220
257
 
258
+ // Handle Ctrl+C and termination signals to update status before exit
259
+ const handleShutdown = async (signal) => {
260
+ this.status = 'STOPPED';
261
+ this._error = `Workflow interrupted by ${signal}`;
262
+ this.persist();
263
+
264
+ // Log to history (local file)
265
+ const historyEntry = {
266
+ timestamp: new Date().toISOString(),
267
+ event: 'WORKFLOW_STOPPED',
268
+ reason: signal
269
+ };
270
+ const line = JSON.stringify(historyEntry) + '\n';
271
+ let existing = '';
272
+ if (fs.existsSync(this.historyFile)) {
273
+ existing = fs.readFileSync(this.historyFile, 'utf-8');
274
+ }
275
+ fs.writeFileSync(this.historyFile, line + existing);
276
+
277
+ // Send to remote and wait for it to complete before exiting
278
+ if (this.remoteClient && this.remoteEnabled) {
279
+ try {
280
+ await this.remoteClient.sendEvent(historyEntry);
281
+ } catch {
282
+ // Ignore errors during shutdown
283
+ }
284
+ }
285
+
286
+ console.log(`\n${C.yellow}⚠ Workflow '${this.workflowName}' stopped (${signal})${C.reset}`);
287
+ cleanupSignalHandlers();
288
+ clearCurrentRuntime();
289
+ process.exit(130); // 128 + SIGINT (2)
290
+ };
291
+
292
+ const sigintHandler = () => handleShutdown('SIGINT');
293
+ const sigtermHandler = () => handleShutdown('SIGTERM');
294
+ process.on('SIGINT', sigintHandler);
295
+ process.on('SIGTERM', sigtermHandler);
296
+
297
+ const cleanupSignalHandlers = () => {
298
+ process.removeListener('SIGINT', sigintHandler);
299
+ process.removeListener('SIGTERM', sigtermHandler);
300
+ };
301
+
221
302
  try {
222
303
  this.status = 'RUNNING';
223
304
  this._error = null;
@@ -226,18 +307,24 @@ export class WorkflowRuntime {
226
307
 
227
308
  this.prependHistory({ event: 'WORKFLOW_STARTED' });
228
309
 
229
- // Import workflow module
230
- const workflowModule = await import(workflowPath);
231
- const runFn = workflowModule.default || workflowModule.run || workflowModule;
232
-
233
- // Load workflow config from module export
234
- const cfg = workflowModule.config || {};
310
+ const configPath = path.join(this.workflowDir, 'config.js');
311
+ if (!fs.existsSync(configPath)) {
312
+ throw new Error(`config.js not found in ${this.workflowDir}`);
313
+ }
314
+ const configUrl = pathToFileURL(configPath);
315
+ configUrl.searchParams.set('t', Date.now().toString());
316
+ const configModule = await import(configUrl.href);
317
+ const cfg = configModule.config || configModule.default || {};
235
318
  this.workflowConfig = {
236
319
  models: cfg.models || {},
237
320
  apiKeys: cfg.apiKeys || {},
238
321
  description: cfg.description || ''
239
322
  };
240
323
 
324
+ // Import workflow module
325
+ const workflowModule = await import(workflowPath);
326
+ const runFn = workflowModule.default || workflowModule.run || workflowModule;
327
+
241
328
  if (typeof runFn !== 'function') {
242
329
  throw new Error('Workflow module must export a default async function');
243
330
  }
@@ -263,6 +350,7 @@ export class WorkflowRuntime {
263
350
  console.error(`\n${C.red}✗ Workflow '${this.workflowName}' failed: ${err.message}${C.reset}`);
264
351
  throw err;
265
352
  } finally {
353
+ cleanupSignalHandlers();
266
354
  clearCurrentRuntime();
267
355
  }
268
356
  }
@@ -406,6 +494,7 @@ export class WorkflowRuntime {
406
494
  let statusColor = C.reset;
407
495
  if (this.status === 'COMPLETED') statusColor = C.green;
408
496
  if (this.status === 'FAILED') statusColor = C.red;
497
+ if (this.status === 'STOPPED') statusColor = C.yellow;
409
498
  if (this.status === 'RUNNING') statusColor = C.blue;
410
499
  if (this.status === 'IDLE') statusColor = C.gray;
411
500
 
package/lib/setup.js CHANGED
@@ -62,20 +62,6 @@ async function setup(workflowName) {
62
62
  import { agent, memory, askHuman, parallel } from 'agent-state-machine';
63
63
  import { notify } from './scripts/mac-notification.js';
64
64
 
65
- // Model configuration (also supports models in a separate config export)
66
- export const config = {
67
- models: {
68
- low: "gemini",
69
- med: "codex --model gpt-5.2",
70
- high: "claude -m claude-opus-4-20250514 -p",
71
- },
72
- apiKeys: {
73
- gemini: process.env.GEMINI_API_KEY,
74
- anthropic: process.env.ANTHROPIC_API_KEY,
75
- openai: process.env.OPENAI_API_KEY,
76
- }
77
- };
78
-
79
65
  export default async function() {
80
66
  console.log('Starting ${workflowName} workflow...');
81
67
 
@@ -119,6 +105,24 @@ export default async function() {
119
105
  fs.writeFileSync(workflowFile, workflowJs);
120
106
  console.log(` Created: ${path.relative(process.cwd(), workflowFile)}`);
121
107
 
108
+ const configJs = `export const config = {
109
+ models: {
110
+ low: "gemini",
111
+ med: "codex --model gpt-5.2",
112
+ high: "claude -m claude-opus-4-20250514 -p",
113
+ },
114
+ apiKeys: {
115
+ gemini: process.env.GEMINI_API_KEY,
116
+ anthropic: process.env.ANTHROPIC_API_KEY,
117
+ openai: process.env.OPENAI_API_KEY,
118
+ }
119
+ };
120
+ `;
121
+
122
+ const configFile = path.join(workflowDir, 'config.js');
123
+ fs.writeFileSync(configFile, configJs);
124
+ console.log(` Created: ${path.relative(process.cwd(), configFile)}`);
125
+
122
126
  // Create example JS agent (ESM)
123
127
  // Create example JS agent (ESM)
124
128
  const exampleAgent = `/**
@@ -138,7 +142,7 @@ export default async function handler(context) {
138
142
  console.log('[Agent: example] Steering loaded (' + context._steering.global.length + ' chars)');
139
143
  }
140
144
 
141
- // Example: Call an LLM (configure models in workflow.js)
145
+ // Example: Call an LLM (configure models in config.js)
142
146
  // const response = await llm(context, {
143
147
  // model: 'smart',
144
148
  // prompt: 'Say hello and describe what you can help with.'
@@ -295,6 +299,7 @@ A workflow created with agent-state-machine (native JS format).
295
299
  \\\`\\\`\\\`
296
300
  ${workflowName}/
297
301
  ├── workflow.js # Native JS workflow (async/await)
302
+ ├── config.js # Model/API key configuration
298
303
  ├── package.json # Sets "type": "module" for this workflow folder
299
304
  ├── agents/ # Custom agents (.js/.mjs/.cjs or .md)
300
305
  ├── interactions/ # Human-in-the-loop inputs (created at runtime)
@@ -304,6 +309,8 @@ ${workflowName}/
304
309
 
305
310
  ## Usage
306
311
 
312
+ Edit \`config.js\` to set models and API keys for this workflow.
313
+
307
314
  Run the workflow (or resume if interrupted):
308
315
  \\\`\\\`\\\`bash
309
316
  state-machine run ${workflowName}
@@ -411,8 +418,9 @@ Generate a greeting for {{name}}.
411
418
  console.log(`\n✓ Workflow '${workflowName}' created successfully!\n`);
412
419
  console.log('Next steps:');
413
420
  console.log(` 1. Edit workflows/${workflowName}/workflow.js to implement your flow`);
414
- console.log(` 2. Add custom agents in workflows/${workflowName}/agents/`);
415
- console.log(` 3. Run: state-machine run ${workflowName}\n`);
421
+ console.log(` 2. Edit workflows/${workflowName}/config.js to set models/API keys`);
422
+ console.log(` 3. Add custom agents in workflows/${workflowName}/agents/`);
423
+ console.log(` 4. Run: state-machine run ${workflowName}\n`);
416
424
  }
417
425
 
418
426
  export { setup };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-state-machine",
3
- "version": "2.0.11",
3
+ "version": "2.0.13",
4
4
  "type": "module",
5
5
  "description": "A workflow orchestrator for running agents and scripts in sequence with state management",
6
6
  "main": "lib/index.js",
@@ -486,11 +486,24 @@
486
486
 
487
487
  useEffect(() => localStorage.setItem("rf_theme", theme), [theme]);
488
488
 
489
+ // Helper to check if workflow is currently running based on history
490
+ const isWorkflowRunning = (entries) => {
491
+ // Find the most recent workflow lifecycle event (history is newest-first)
492
+ for (const entry of entries) {
493
+ if (entry.event === "WORKFLOW_STARTED") return true;
494
+ if (entry.event === "WORKFLOW_STOPPED" ||
495
+ entry.event === "WORKFLOW_COMPLETED" ||
496
+ entry.event === "WORKFLOW_FAILED") return false;
497
+ }
498
+ return false; // No lifecycle events found
499
+ };
500
+
489
501
  useEffect(() => {
490
502
  if (history.length === 0) { setPendingInteraction(null); return; }
491
503
 
492
504
  const resolvedSlugs = new Set();
493
505
  let pending = null;
506
+ const workflowRunning = isWorkflowRunning(history);
494
507
 
495
508
  for (const entry of history) {
496
509
  const isResolution =
@@ -510,7 +523,8 @@
510
523
  }
511
524
  }
512
525
 
513
- setPendingInteraction(pending);
526
+ // Only show pending interaction if workflow is running
527
+ setPendingInteraction(workflowRunning ? pending : null);
514
528
  }, [history]);
515
529
 
516
530
  const fetchData = async () => {
@@ -520,8 +534,18 @@
520
534
  if (data.entries) setHistory(data.entries);
521
535
  if (data.workflowName) setWorkflowName(data.workflowName);
522
536
 
523
- if (token && data.cliConnected !== undefined) setStatus(data.cliConnected ? "connected" : "disconnected");
524
- else if (!token) setStatus("connected");
537
+ // Check if workflow is currently running based on most recent lifecycle event
538
+ const workflowRunning = isWorkflowRunning(data.entries || []);
539
+
540
+ if (workflowRunning) {
541
+ setStatus("connected");
542
+ } else if (token && data.cliConnected !== undefined) {
543
+ setStatus(data.cliConnected ? "connected" : "disconnected");
544
+ } else if (!token) {
545
+ setStatus("connected");
546
+ } else {
547
+ setStatus("disconnected");
548
+ }
525
549
 
526
550
  setLoading(false);
527
551
  return true;
@@ -569,6 +593,8 @@
569
593
  break;
570
594
  case "history":
571
595
  setHistory(data.entries || []);
596
+ // Update status based on workflow lifecycle
597
+ setStatus(isWorkflowRunning(data.entries || []) ? "connected" : "disconnected");
572
598
  break;
573
599
  case "event":
574
600
  setHistory((prev) => {
@@ -578,6 +604,14 @@
578
604
  }
579
605
  return [data, ...prev];
580
606
  });
607
+ // Update status based on workflow lifecycle events
608
+ if (data.event === "WORKFLOW_STARTED") {
609
+ setStatus("connected");
610
+ } else if (data.event === "WORKFLOW_STOPPED" ||
611
+ data.event === "WORKFLOW_COMPLETED" ||
612
+ data.event === "WORKFLOW_FAILED") {
613
+ setStatus("disconnected");
614
+ }
581
615
  break;
582
616
  case "cli_connected":
583
617
  case "cli_reconnected":