agent-state-machine 2.0.10 → 2.0.12

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
@@ -2,6 +2,7 @@
2
2
 
3
3
  import path from 'path';
4
4
  import fs from 'fs';
5
+ import readline from 'readline';
5
6
  import { pathToFileURL, fileURLToPath } from 'url';
6
7
  import { WorkflowRuntime } from '../lib/index.js';
7
8
  import { setup } from '../lib/setup.js';
@@ -62,6 +63,7 @@ Environment Variables:
62
63
  Workflow Structure:
63
64
  workflows/<name>/
64
65
  ├── workflow.js # Native JS workflow (async/await)
66
+ ├── config.js # Model/API key configuration
65
67
  ├── package.json # Sets "type": "module" for this workflow folder
66
68
  ├── agents/ # Custom agents (.js/.mjs/.cjs or .md)
67
69
  ├── interactions/ # Human-in-the-loop files (auto-created)
@@ -70,6 +72,28 @@ Workflow Structure:
70
72
  `);
71
73
  }
72
74
 
75
+ async function confirmHardReset(workflowName) {
76
+ if (!process.stdin.isTTY) {
77
+ console.error('Error: Hard reset requires confirmation in a TTY.');
78
+ return false;
79
+ }
80
+
81
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
82
+ const ask = (q) => new Promise((resolve) => rl.question(q, resolve));
83
+ try {
84
+ const answer = String(
85
+ await ask(
86
+ `Hard reset deletes history, interactions, and memory for '${workflowName}'. Type 'y' to continue: `
87
+ )
88
+ )
89
+ .trim()
90
+ .toLowerCase();
91
+ return answer === 'y' || answer === 'yes';
92
+ } finally {
93
+ rl.close();
94
+ }
95
+ }
96
+
73
97
  function workflowsRoot() {
74
98
  return path.join(process.cwd(), 'workflows');
75
99
  }
@@ -162,8 +186,8 @@ function findConfigObjectRange(source) {
162
186
  return null;
163
187
  }
164
188
 
165
- function readRemotePathFromWorkflow(workflowFile) {
166
- const source = fs.readFileSync(workflowFile, 'utf-8');
189
+ function readRemotePathFromConfig(configFile) {
190
+ const source = fs.readFileSync(configFile, 'utf-8');
167
191
  const range = findConfigObjectRange(source);
168
192
  if (!range) return null;
169
193
  const configSource = source.slice(range.start, range.end + 1);
@@ -171,19 +195,19 @@ function readRemotePathFromWorkflow(workflowFile) {
171
195
  return match ? match[2] : null;
172
196
  }
173
197
 
174
- function writeRemotePathToWorkflow(workflowFile, remotePath) {
175
- const source = fs.readFileSync(workflowFile, 'utf-8');
198
+ function writeRemotePathToConfig(configFile, remotePath) {
199
+ const source = fs.readFileSync(configFile, 'utf-8');
176
200
  const range = findConfigObjectRange(source);
177
201
  const remoteLine = `remotePath: "${remotePath}"`;
178
202
 
179
203
  if (!range) {
180
204
  const hasConfigExport = /export\s+const\s+config\s*=/.test(source);
181
205
  if (hasConfigExport) {
182
- 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.');
183
207
  }
184
208
  const trimmed = source.replace(/\s*$/, '');
185
209
  const appended = `${trimmed}\n\nexport const config = {\n ${remoteLine}\n};\n`;
186
- fs.writeFileSync(workflowFile, appended);
210
+ fs.writeFileSync(configFile, appended);
187
211
  return;
188
212
  }
189
213
 
@@ -224,15 +248,15 @@ function writeRemotePathToWorkflow(workflowFile, remotePath) {
224
248
  source.slice(0, range.start) +
225
249
  updatedConfigSource +
226
250
  source.slice(range.end + 1);
227
- fs.writeFileSync(workflowFile, updatedSource);
251
+ fs.writeFileSync(configFile, updatedSource);
228
252
  }
229
253
 
230
- function ensureRemotePath(workflowFile, { forceNew = false } = {}) {
231
- const existing = readRemotePathFromWorkflow(workflowFile);
254
+ function ensureRemotePath(configFile, { forceNew = false } = {}) {
255
+ const existing = readRemotePathFromConfig(configFile);
232
256
  if (existing && !forceNew) return existing;
233
257
 
234
258
  const remotePath = generateSessionToken();
235
- writeRemotePathToWorkflow(workflowFile, remotePath);
259
+ writeRemotePathToConfig(configFile, remotePath);
236
260
  return remotePath;
237
261
  }
238
262
 
@@ -253,6 +277,7 @@ function summarizeStatus(state) {
253
277
  if (s === 'COMPLETED') return ' [completed]';
254
278
  if (s === 'FAILED') return ' [failed - can resume]';
255
279
  if (s === 'PAUSED') return ' [paused - can resume]';
280
+ if (s === 'STOPPED') return ' [stopped - can resume]';
256
281
  if (s === 'RUNNING') return ' [running]';
257
282
  if (s === 'IDLE') return ' [idle]';
258
283
  return state.status ? ` [${state.status}]` : '';
@@ -327,12 +352,18 @@ async function runOrResume(
327
352
 
328
353
  const runtime = new WorkflowRuntime(workflowDir);
329
354
  if (preResetHard) {
355
+ const confirmed = await confirmHardReset(workflowName);
356
+ if (!confirmed) {
357
+ console.log('Hard reset cancelled.');
358
+ return;
359
+ }
330
360
  runtime.resetHard();
331
361
  } else if (preReset) {
332
362
  runtime.reset();
333
363
  }
334
364
 
335
365
  const workflowUrl = pathToFileURL(entry).href;
366
+ const configFile = path.join(workflowDir, 'config.js');
336
367
 
337
368
  let localServer = null;
338
369
  let remoteUrl = null;
@@ -354,7 +385,7 @@ async function runOrResume(
354
385
 
355
386
  // Enable remote follow mode if we have a URL
356
387
  if (remoteUrl) {
357
- const sessionToken = ensureRemotePath(entry, { forceNew: forceNewRemotePath });
388
+ const sessionToken = ensureRemotePath(configFile, { forceNew: forceNewRemotePath });
358
389
  await runtime.enableRemote(remoteUrl, { sessionToken });
359
390
  }
360
391
 
@@ -478,6 +509,11 @@ async function main() {
478
509
  {
479
510
  const workflowDir = resolveWorkflowDir(workflowName);
480
511
  const runtime = new WorkflowRuntime(workflowDir);
512
+ const confirmed = await confirmHardReset(workflowName);
513
+ if (!confirmed) {
514
+ console.log('Hard reset cancelled.');
515
+ process.exit(0);
516
+ }
481
517
  runtime.resetHard();
482
518
  }
483
519
  break;
package/lib/llm.js CHANGED
@@ -314,7 +314,7 @@ async function executeAPI(provider, model, prompt, apiKey, options = {}) {
314
314
  *
315
315
  * @param {object} context - The workflow context (contains _config, _steering, etc.)
316
316
  * @param {object} options - Options for the LLM call
317
- * @param {string} options.model - Model key from workflow.js models config
317
+ * @param {string} options.model - Model key from config.js models config
318
318
  * @param {string} options.prompt - The prompt to send
319
319
  * @param {boolean} options.includeContext - Whether to include context in prompt (default: true)
320
320
  * @param {number} options.maxTokens - Max tokens for API calls (default: 4096)
@@ -359,7 +359,7 @@ export async function llm(context, options) {
359
359
 
360
360
  if (!apiKey) {
361
361
  throw new Error(
362
- `No API key found for ${provider}. Set in workflow.js apiKeys or ${provider.toUpperCase()}_API_KEY env var`
362
+ `No API key found for ${provider}. Set in config.js apiKeys or ${provider.toUpperCase()}_API_KEY env var`
363
363
  );
364
364
  }
365
365
 
@@ -459,4 +459,4 @@ export async function llmJSON(context, options) {
459
459
  ...response,
460
460
  data: parseJSON(response.text)
461
461
  };
462
- }
462
+ }
@@ -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
 
@@ -218,6 +219,50 @@ export class WorkflowRuntime {
218
219
  async runWorkflow(workflowPath) {
219
220
  setCurrentRuntime(this);
220
221
 
222
+ // Handle Ctrl+C and termination signals to update status before exit
223
+ const handleShutdown = async (signal) => {
224
+ this.status = 'STOPPED';
225
+ this._error = `Workflow interrupted by ${signal}`;
226
+ this.persist();
227
+
228
+ // Log to history (local file)
229
+ const historyEntry = {
230
+ timestamp: new Date().toISOString(),
231
+ event: 'WORKFLOW_STOPPED',
232
+ reason: signal
233
+ };
234
+ const line = JSON.stringify(historyEntry) + '\n';
235
+ let existing = '';
236
+ if (fs.existsSync(this.historyFile)) {
237
+ existing = fs.readFileSync(this.historyFile, 'utf-8');
238
+ }
239
+ fs.writeFileSync(this.historyFile, line + existing);
240
+
241
+ // Send to remote and wait for it to complete before exiting
242
+ if (this.remoteClient && this.remoteEnabled) {
243
+ try {
244
+ await this.remoteClient.sendEvent(historyEntry);
245
+ } catch {
246
+ // Ignore errors during shutdown
247
+ }
248
+ }
249
+
250
+ console.log(`\n${C.yellow}⚠ Workflow '${this.workflowName}' stopped (${signal})${C.reset}`);
251
+ cleanupSignalHandlers();
252
+ clearCurrentRuntime();
253
+ process.exit(130); // 128 + SIGINT (2)
254
+ };
255
+
256
+ const sigintHandler = () => handleShutdown('SIGINT');
257
+ const sigtermHandler = () => handleShutdown('SIGTERM');
258
+ process.on('SIGINT', sigintHandler);
259
+ process.on('SIGTERM', sigtermHandler);
260
+
261
+ const cleanupSignalHandlers = () => {
262
+ process.removeListener('SIGINT', sigintHandler);
263
+ process.removeListener('SIGTERM', sigtermHandler);
264
+ };
265
+
221
266
  try {
222
267
  this.status = 'RUNNING';
223
268
  this._error = null;
@@ -226,18 +271,24 @@ export class WorkflowRuntime {
226
271
 
227
272
  this.prependHistory({ event: 'WORKFLOW_STARTED' });
228
273
 
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 || {};
274
+ const configPath = path.join(this.workflowDir, 'config.js');
275
+ if (!fs.existsSync(configPath)) {
276
+ throw new Error(`config.js not found in ${this.workflowDir}`);
277
+ }
278
+ const configUrl = pathToFileURL(configPath);
279
+ configUrl.searchParams.set('t', Date.now().toString());
280
+ const configModule = await import(configUrl.href);
281
+ const cfg = configModule.config || configModule.default || {};
235
282
  this.workflowConfig = {
236
283
  models: cfg.models || {},
237
284
  apiKeys: cfg.apiKeys || {},
238
285
  description: cfg.description || ''
239
286
  };
240
287
 
288
+ // Import workflow module
289
+ const workflowModule = await import(workflowPath);
290
+ const runFn = workflowModule.default || workflowModule.run || workflowModule;
291
+
241
292
  if (typeof runFn !== 'function') {
242
293
  throw new Error('Workflow module must export a default async function');
243
294
  }
@@ -263,6 +314,7 @@ export class WorkflowRuntime {
263
314
  console.error(`\n${C.red}✗ Workflow '${this.workflowName}' failed: ${err.message}${C.reset}`);
264
315
  throw err;
265
316
  } finally {
317
+ cleanupSignalHandlers();
266
318
  clearCurrentRuntime();
267
319
  }
268
320
  }
@@ -406,6 +458,7 @@ export class WorkflowRuntime {
406
458
  let statusColor = C.reset;
407
459
  if (this.status === 'COMPLETED') statusColor = C.green;
408
460
  if (this.status === 'FAILED') statusColor = C.red;
461
+ if (this.status === 'STOPPED') statusColor = C.yellow;
409
462
  if (this.status === 'RUNNING') statusColor = C.blue;
410
463
  if (this.status === 'IDLE') statusColor = C.gray;
411
464
 
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.10",
3
+ "version": "2.0.12",
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":