agent-state-machine 2.2.0 → 2.2.2

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 (36) hide show
  1. package/bin/cli.js +78 -2
  2. package/lib/remote/client.js +37 -8
  3. package/lib/runtime/agent.js +6 -2
  4. package/lib/runtime/interaction.js +2 -1
  5. package/lib/runtime/prompt.js +37 -1
  6. package/lib/runtime/runtime.js +67 -5
  7. package/package.json +1 -1
  8. package/templates/project-builder/README.md +304 -56
  9. package/templates/project-builder/agents/code-fixer.md +50 -0
  10. package/templates/project-builder/agents/code-writer.md +3 -0
  11. package/templates/project-builder/agents/sanity-checker.md +6 -0
  12. package/templates/project-builder/agents/sanity-runner.js +3 -1
  13. package/templates/project-builder/agents/test-planner.md +3 -1
  14. package/templates/project-builder/config.js +4 -4
  15. package/templates/project-builder/scripts/workflow-helpers.js +104 -2
  16. package/templates/project-builder/workflow.js +151 -14
  17. package/templates/starter/README.md +291 -42
  18. package/templates/starter/config.js +1 -1
  19. package/vercel-server/api/submit/[token].js +2 -13
  20. package/vercel-server/api/ws/cli.js +40 -2
  21. package/vercel-server/local-server.js +32 -22
  22. package/vercel-server/public/remote/assets/index-BsJsLDKc.css +1 -0
  23. package/vercel-server/public/remote/assets/index-CmtT6ADh.js +168 -0
  24. package/vercel-server/public/remote/index.html +2 -2
  25. package/vercel-server/ui/src/App.jsx +69 -62
  26. package/vercel-server/ui/src/components/ChoiceInteraction.jsx +69 -18
  27. package/vercel-server/ui/src/components/ConfirmInteraction.jsx +7 -7
  28. package/vercel-server/ui/src/components/ContentCard.jsx +600 -104
  29. package/vercel-server/ui/src/components/EventsLog.jsx +20 -13
  30. package/vercel-server/ui/src/components/Footer.jsx +9 -4
  31. package/vercel-server/ui/src/components/Header.jsx +12 -3
  32. package/vercel-server/ui/src/components/SendingCard.jsx +33 -0
  33. package/vercel-server/ui/src/components/TextInteraction.jsx +8 -8
  34. package/vercel-server/ui/src/index.css +82 -10
  35. package/vercel-server/public/remote/assets/index-BOKpYANC.js +0 -148
  36. package/vercel-server/public/remote/assets/index-DHL_iHQW.css +0 -1
package/bin/cli.js CHANGED
@@ -3,6 +3,7 @@
3
3
  import path from 'path';
4
4
  import fs from 'fs';
5
5
  import readline from 'readline';
6
+ import { spawn } from 'child_process';
6
7
  import { pathToFileURL, fileURLToPath } from 'url';
7
8
  import { WorkflowRuntime } from '../lib/index.js';
8
9
  import { setup } from '../lib/setup.js';
@@ -11,6 +12,41 @@ import { readRemotePathFromConfig, writeRemotePathToConfig } from '../lib/config
11
12
 
12
13
  import { startLocalServer } from '../vercel-server/local-server.js';
13
14
 
15
+ /**
16
+ * Prevent system sleep on macOS using caffeinate
17
+ * Returns a function to stop caffeinate, or null if not available
18
+ */
19
+ function preventSleep() {
20
+ // Only works on macOS
21
+ if (process.platform !== 'darwin') {
22
+ return null;
23
+ }
24
+
25
+ try {
26
+ // -i: prevent idle sleep (system stays awake)
27
+ // -s: prevent sleep when on AC power
28
+ // Display can still sleep (screen goes black, requires password)
29
+ const caffeinate = spawn('caffeinate', ['-is'], {
30
+ stdio: 'ignore',
31
+ detached: false,
32
+ });
33
+
34
+ caffeinate.on('error', () => {
35
+ // caffeinate not available, ignore
36
+ });
37
+
38
+ return () => {
39
+ try {
40
+ caffeinate.kill();
41
+ } catch {
42
+ // Already dead, ignore
43
+ }
44
+ };
45
+ } catch {
46
+ return null;
47
+ }
48
+ }
49
+
14
50
  const __filename = fileURLToPath(import.meta.url);
15
51
  const __dirname = path.dirname(__filename);
16
52
 
@@ -61,6 +97,8 @@ Options:
61
97
  --template, -t Template name for --setup (default: starter)
62
98
  --local, -l Use local server instead of remote (starts on localhost:3000)
63
99
  --new, -n Generate a new remote follow path
100
+ --full-auto, -a Auto-select first option for choice interactions (no blocking)
101
+ --delay, -d Seconds to wait before auto-select in full-auto mode (default: 20)
64
102
  -reset Reset workflow state before running
65
103
  -reset-hard Hard reset workflow before running
66
104
  --help, -h Show help
@@ -202,7 +240,9 @@ async function runOrResume(
202
240
  useLocalServer = false,
203
241
  forceNewRemotePath = false,
204
242
  preReset = false,
205
- preResetHard = false
243
+ preResetHard = false,
244
+ fullAuto = false,
245
+ autoSelectDelay = null
206
246
  } = {}
207
247
  ) {
208
248
  const workflowDir = resolveWorkflowDir(workflowName);
@@ -258,9 +298,31 @@ async function runOrResume(
258
298
  await runtime.enableRemote(remoteUrl, { sessionToken, uiBaseUrl: useLocalServer });
259
299
  }
260
300
 
301
+ // Set full-auto mode from CLI flag (will be merged with config.js during runWorkflow)
302
+ if (fullAuto) {
303
+ runtime.workflowConfig.fullAuto = true;
304
+ if (autoSelectDelay !== null) {
305
+ runtime.workflowConfig.autoSelectDelay = autoSelectDelay;
306
+ }
307
+ const delay = runtime.workflowConfig.autoSelectDelay;
308
+ console.log(`\n\x1b[36m\x1b[1m⚡ Full-auto mode enabled\x1b[0m - Agent will auto-select recommended options after ${delay}s countdown`);
309
+ }
310
+
311
+ // Prevent system sleep while workflow runs (macOS only)
312
+ // Display can still sleep, but system stays awake for remote follow
313
+ const stopCaffeinate = preventSleep();
314
+ if (stopCaffeinate) {
315
+ console.log('☕ Preventing system sleep while workflow runs (display may still sleep)');
316
+ }
317
+
261
318
  try {
262
319
  await runtime.runWorkflow(workflowUrl);
263
320
  } finally {
321
+ // Allow sleep again
322
+ if (stopCaffeinate) {
323
+ stopCaffeinate();
324
+ }
325
+
264
326
  // Keep local server alive after run so the session remains accessible.
265
327
  if (!useLocalServer && remoteUrl) {
266
328
  await runtime.disableRemote();
@@ -322,14 +384,28 @@ async function main() {
322
384
  const forceNewRemotePath = args.includes('--new') || args.includes('-n');
323
385
  const preReset = args.includes('-reset');
324
386
  const preResetHard = args.includes('-reset-hard');
387
+ const fullAuto = args.includes('--full-auto') || args.includes('-a');
325
388
  const remoteEnabled = !useLocalServer; // Use Vercel if not local
389
+
390
+ // Parse --delay or -d flag
391
+ let autoSelectDelay = null;
392
+ const delayFlagIndex = args.findIndex((arg) => arg === '--delay' || arg === '-d');
393
+ if (delayFlagIndex !== -1 && args[delayFlagIndex + 1]) {
394
+ const delayValue = parseInt(args[delayFlagIndex + 1], 10);
395
+ if (!isNaN(delayValue) && delayValue >= 0) {
396
+ autoSelectDelay = delayValue;
397
+ }
398
+ }
399
+
326
400
  try {
327
401
  await runOrResume(workflowName, {
328
402
  remoteEnabled,
329
403
  useLocalServer,
330
404
  forceNewRemotePath,
331
405
  preReset,
332
- preResetHard
406
+ preResetHard,
407
+ fullAuto,
408
+ autoSelectDelay
333
409
  });
334
410
  } catch (err) {
335
411
  console.error('Error:', err.message || String(err));
@@ -29,9 +29,13 @@ export function generateSessionToken() {
29
29
  }
30
30
 
31
31
  /**
32
- * Make an HTTP/HTTPS request
32
+ * Make an HTTP/HTTPS request with timeout
33
+ * @param {string} url - Request URL
34
+ * @param {object} options - Request options
35
+ * @param {object|null} body - Request body
36
+ * @param {number} timeoutMs - Request timeout in milliseconds
33
37
  */
34
- function makeRequest(url, options, body = null) {
38
+ function makeRequest(url, options, body = null, timeoutMs = 60000) {
35
39
  return new Promise((resolve, reject) => {
36
40
  const parsedUrl = new URL(url);
37
41
  const client = parsedUrl.protocol === 'https:' ? https : http;
@@ -60,6 +64,12 @@ function makeRequest(url, options, body = null) {
60
64
  });
61
65
  });
62
66
 
67
+ // Timeout prevents hanging on sleep/wake cycles
68
+ req.setTimeout(timeoutMs, () => {
69
+ req.destroy();
70
+ reject(new Error('Request timeout'));
71
+ });
72
+
63
73
  req.on('error', reject);
64
74
 
65
75
  if (body) {
@@ -222,28 +232,47 @@ export class RemoteClient {
222
232
 
223
233
  /**
224
234
  * Poll for interaction responses
235
+ * Uses 35s timeout to stay under Vercel's 50s limit with buffer
225
236
  */
226
237
  async poll() {
238
+ let consecutiveErrors = 0;
239
+
227
240
  while (this.polling && this.connected) {
228
241
  try {
242
+ // Request 30s poll from server, with 35s client timeout
229
243
  const url = `${this.serverUrl}/api/ws/cli?token=${this.sessionToken}&timeout=30000`;
230
- const response = await makeRequest(url, { method: 'GET' });
244
+ const response = await makeRequest(url, { method: 'GET' }, null, 35000);
245
+
246
+ consecutiveErrors = 0; // Reset on success
231
247
 
232
248
  if (response.status === 200 && response.data) {
233
249
  const { type, slug, targetKey, response: interactionResponse } = response.data;
234
250
 
235
251
  if (type === 'interaction_response' && this.onInteractionResponse) {
252
+ // Confirm receipt BEFORE processing - removes from Redis pending queue
253
+ // This ensures we don't lose the interaction if processing fails
254
+ try {
255
+ const confirmUrl = `${this.serverUrl}/api/ws/cli?token=${this.sessionToken}`;
256
+ await makeRequest(confirmUrl, { method: 'DELETE' }, null, 10000);
257
+ } catch (err) {
258
+ // Non-fatal - interaction will be re-delivered on next poll
259
+ console.error(`${C.dim}Remote: Failed to confirm receipt: ${err.message}${C.reset}`);
260
+ }
261
+
236
262
  this.onInteractionResponse(slug, targetKey, interactionResponse);
237
263
  }
238
264
  }
239
265
 
240
- // If 204 (no content), just continue polling
241
- // Small delay between polls
242
- await new Promise(resolve => setTimeout(resolve, 500));
266
+ // If 204 (no content), just continue polling immediately
267
+ // Small delay only on success to prevent tight loop
268
+ await new Promise(resolve => setTimeout(resolve, 100));
243
269
 
244
270
  } catch (err) {
245
- // Connection error - wait and retry
246
- await new Promise(resolve => setTimeout(resolve, 5000));
271
+ consecutiveErrors++;
272
+
273
+ // Exponential backoff: 1s, 2s, 4s, max 10s
274
+ const backoff = Math.min(1000 * Math.pow(2, consecutiveErrors - 1), 10000);
275
+ await new Promise(resolve => setTimeout(resolve, backoff));
247
276
  }
248
277
  }
249
278
  }
@@ -533,7 +533,10 @@ ${content}
533
533
  validation: interaction.validation,
534
534
  confirmLabel: interaction.confirmLabel,
535
535
  cancelLabel: interaction.cancelLabel,
536
- context: interaction.context
536
+ context: interaction.context,
537
+ // Include full-auto info for remote UI countdown
538
+ fullAuto: runtime.workflowConfig.fullAuto || false,
539
+ autoSelectDelay: runtime.workflowConfig.autoSelectDelay ?? 20
537
540
  });
538
541
 
539
542
  if (effectiveAgentName) {
@@ -541,7 +544,8 @@ ${content}
541
544
  }
542
545
 
543
546
  // Block and wait for user input (instead of throwing)
544
- const response = await runtime.waitForInteraction(filePath, slug, targetKey);
547
+ // Pass the full interaction object for full-auto mode support
548
+ const response = await runtime.waitForInteraction(filePath, slug, targetKey, interaction);
545
549
 
546
550
  return response;
547
551
  }
@@ -117,8 +117,9 @@ export function formatInteractionPrompt(interaction) {
117
117
  interaction.options.forEach((opt, index) => {
118
118
  const letter = String.fromCharCode(65 + index);
119
119
  const label = opt.label || opt.key || `Option ${index + 1}`;
120
+ const recommended = index === 0 ? ' (Recommended)' : '';
120
121
  const desc = opt.description ? ` - ${opt.description}` : '';
121
- lines.push(`- ${letter}: ${label}${desc}`);
122
+ lines.push(`- ${letter}: ${label}${recommended}${desc}`);
122
123
  });
123
124
  if (interaction.allowCustom) {
124
125
  lines.push('- Other: Provide a custom response');
@@ -51,9 +51,45 @@ export async function askHuman(question, options = {}) {
51
51
  validation: interaction?.validation,
52
52
  confirmLabel: interaction?.confirmLabel,
53
53
  cancelLabel: interaction?.cancelLabel,
54
- context: interaction?.context
54
+ context: interaction?.context,
55
+ // Include full-auto info for remote UI countdown
56
+ fullAuto: runtime.workflowConfig.fullAuto || false,
57
+ autoSelectDelay: runtime.workflowConfig.autoSelectDelay ?? 20
55
58
  });
56
59
 
60
+ // Full-auto mode: show countdown and auto-select first option for choice interactions
61
+ if (runtime.workflowConfig.fullAuto && interaction?.type === 'choice') {
62
+ const options = interaction.options || [];
63
+ if (options.length > 0) {
64
+ const firstOption = options[0];
65
+ const autoResponse = firstOption.key || firstOption.label;
66
+ const delay = runtime.workflowConfig.autoSelectDelay ?? 20;
67
+
68
+ console.log(`\n${C.cyan}${C.bold}${interaction.prompt || 'Choice required'}${C.reset}`);
69
+ if (runtime.remoteEnabled && runtime.remoteUrl) {
70
+ console.log(`${C.dim}(Remote: ${runtime.remoteUrl})${C.reset}`);
71
+ }
72
+
73
+ // Countdown timer
74
+ for (let i = delay; i > 0; i--) {
75
+ process.stdout.write(`\r${C.yellow}⚡ Agent deciding for you in ${i}...${C.reset} `);
76
+ await new Promise(r => setTimeout(r, 1000));
77
+ }
78
+ console.log(`\r${C.green}${C.bold}⚡ Auto-selected: ${autoResponse}${C.reset} \n`);
79
+
80
+ runtime._rawMemory[memoryKey] = autoResponse;
81
+ runtime.persist();
82
+
83
+ await runtime.prependHistory({
84
+ event: 'PROMPT_AUTO_ANSWERED',
85
+ slug,
86
+ autoSelected: autoResponse
87
+ });
88
+
89
+ return autoResponse;
90
+ }
91
+ }
92
+
57
93
  // Check if we're in TTY mode (interactive terminal)
58
94
  if (process.stdin.isTTY && process.stdout.isTTY) {
59
95
  // Interactive mode - prompt directly, with remote support
@@ -80,7 +80,11 @@ export class WorkflowRuntime {
80
80
  projectRoot: path.resolve(workflowDir, '../..'),
81
81
  fileTracking: true,
82
82
  fileTrackingIgnore: DEFAULT_IGNORE,
83
- fileTrackingKeepDeleted: false
83
+ fileTrackingKeepDeleted: false,
84
+ // Full-auto mode (auto-select first option for choice interactions)
85
+ fullAuto: false,
86
+ maxQuickFixAttempts: 10,
87
+ autoSelectDelay: 20 // seconds before auto-selecting in full-auto mode
84
88
  };
85
89
 
86
90
  // Load steering
@@ -320,6 +324,8 @@ export class WorkflowRuntime {
320
324
  configUrl.searchParams.set('t', Date.now().toString());
321
325
  const configModule = await import(configUrl.href);
322
326
  const cfg = configModule.config || configModule.default || {};
327
+ // Preserve CLI-set fullAuto (it takes precedence over config.js)
328
+ const cliFullAuto = this.workflowConfig.fullAuto;
323
329
  this.workflowConfig = {
324
330
  models: cfg.models || {},
325
331
  apiKeys: cfg.apiKeys || {},
@@ -328,7 +334,11 @@ export class WorkflowRuntime {
328
334
  projectRoot: cfg.projectRoot || path.resolve(this.workflowDir, '../..'),
329
335
  fileTracking: cfg.fileTracking ?? true,
330
336
  fileTrackingIgnore: cfg.fileTrackingIgnore || DEFAULT_IGNORE,
331
- fileTrackingKeepDeleted: cfg.fileTrackingKeepDeleted ?? false
337
+ fileTrackingKeepDeleted: cfg.fileTrackingKeepDeleted ?? false,
338
+ // Full-auto mode: CLI flag takes precedence, then config.js, then default false
339
+ fullAuto: cliFullAuto || cfg.fullAuto || false,
340
+ maxQuickFixAttempts: cfg.maxQuickFixAttempts ?? 10,
341
+ autoSelectDelay: cfg.autoSelectDelay ?? this.workflowConfig.autoSelectDelay // seconds before auto-selecting
332
342
  };
333
343
 
334
344
  // Import workflow module
@@ -368,8 +378,17 @@ export class WorkflowRuntime {
368
378
  /**
369
379
  * Wait for user to confirm interaction is complete, then return the response
370
380
  * Supports both local TTY input and remote browser responses
381
+ * @param {string} filePath - Path to the interaction file
382
+ * @param {string} slug - Interaction slug
383
+ * @param {string} targetKey - Memory key to store response
384
+ * @param {object} [interaction] - Optional interaction object for full-auto mode
371
385
  */
372
- async waitForInteraction(filePath, slug, targetKey) {
386
+ async waitForInteraction(filePath, slug, targetKey, interaction = null) {
387
+ // Determine if we're in full-auto mode for choice interactions
388
+ const isFullAutoChoice = this.workflowConfig.fullAuto && interaction?.type === 'choice' && interaction.options?.length > 0;
389
+ const autoResponse = isFullAutoChoice ? (interaction.options[0].key || interaction.options[0].label) : null;
390
+ const delay = isFullAutoChoice ? (this.workflowConfig.autoSelectDelay ?? 20) : 0;
391
+
373
392
  console.log(`\n${C.yellow}${C.bold}⏸ Interaction required.${C.reset}`);
374
393
 
375
394
  if (this.remoteEnabled && this.remoteUrl) {
@@ -387,11 +406,16 @@ export class WorkflowRuntime {
387
406
  return new Promise((resolve, reject) => {
388
407
  // Track if we've already resolved (to prevent double-resolution)
389
408
  let resolved = false;
409
+ let countdownTimer = null;
390
410
 
391
411
  const cleanup = () => {
392
412
  resolved = true;
393
413
  rl.close();
394
414
  this.pendingRemoteInteraction = null;
415
+ if (countdownTimer) {
416
+ clearTimeout(countdownTimer);
417
+ countdownTimer = null;
418
+ }
395
419
  };
396
420
 
397
421
  // Set up remote interaction listener
@@ -414,7 +438,9 @@ export class WorkflowRuntime {
414
438
  source: 'remote'
415
439
  });
416
440
 
441
+ const displayResponse = typeof response === 'object' ? JSON.stringify(response) : response;
417
442
  console.log(`\n${C.green}✓ Interaction resolved (remote): ${slug}${C.reset}`);
443
+ console.log(` ${C.cyan}Selected:${C.reset} ${displayResponse}`);
418
444
  resolve(response);
419
445
  },
420
446
  reject: (err) => {
@@ -425,7 +451,38 @@ export class WorkflowRuntime {
425
451
  };
426
452
  }
427
453
 
428
- // Local TTY input loop
454
+ // Full-auto countdown (can be interrupted by remote submission or local input)
455
+ if (isFullAutoChoice) {
456
+ let remaining = delay;
457
+ const tick = () => {
458
+ if (resolved) return;
459
+ if (remaining <= 0) {
460
+ // Auto-select
461
+ cleanup();
462
+ console.log(`\r ${C.cyan}${C.bold}⚡ Auto-selected: ${autoResponse}${C.reset} `);
463
+ console.log(` ${C.cyan}Selected:${C.reset} ${autoResponse}`);
464
+
465
+ this._rawMemory[targetKey] = autoResponse;
466
+ this.persist();
467
+
468
+ this.prependHistory({
469
+ event: 'INTERACTION_AUTO_RESOLVED',
470
+ slug,
471
+ targetKey,
472
+ autoSelected: autoResponse
473
+ });
474
+
475
+ resolve(autoResponse);
476
+ return;
477
+ }
478
+ process.stdout.write(`\r ${C.cyan}${C.bold}⚡ Agent deciding for you in ${remaining}...${C.reset} `);
479
+ remaining--;
480
+ countdownTimer = setTimeout(tick, 1000);
481
+ };
482
+ tick();
483
+ }
484
+
485
+ // Local TTY input loop (always active - can interrupt countdown in full-auto mode)
429
486
  const ask = () => {
430
487
  if (resolved) return;
431
488
 
@@ -438,13 +495,18 @@ export class WorkflowRuntime {
438
495
  // Read and return the response from file
439
496
  try {
440
497
  const response = await this.readInteractionResponse(filePath, slug, targetKey);
498
+ console.log(` ${C.cyan}Selected:${C.reset} ${typeof response === 'object' ? JSON.stringify(response) : response}`);
441
499
  resolve(response);
442
500
  } catch (err) {
443
501
  reject(err);
444
502
  }
445
503
  } else if (a === 'q') {
446
504
  cleanup();
447
- reject(new Error('User quit workflow'));
505
+ console.log(`\n${C.yellow}⏹ Workflow stopped by user${C.reset}`);
506
+ this.status = 'STOPPED';
507
+ this.persist();
508
+ this.prependHistory({ event: 'WORKFLOW_STOPPED', reason: 'user_quit' });
509
+ process.exit(0);
448
510
  } else {
449
511
  ask();
450
512
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-state-machine",
3
- "version": "2.2.0",
3
+ "version": "2.2.2",
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",