agent-state-machine 1.4.1 → 2.0.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-state-machine",
3
- "version": "1.4.1",
3
+ "version": "2.0.0",
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",
@@ -27,6 +27,8 @@
27
27
  },
28
28
  "files": [
29
29
  "bin",
30
- "lib"
30
+ "lib",
31
+ "vercel-server/local-server.js",
32
+ "vercel-server/public"
31
33
  ]
32
34
  }
@@ -0,0 +1,547 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Local development server for testing remote follow
5
+ * Uses in-memory storage instead of Redis
6
+ *
7
+ * Usage:
8
+ * node vercel-server/local-server.js
9
+ *
10
+ * Or import and start programmatically:
11
+ * import { startLocalServer } from './vercel-server/local-server.js';
12
+ * const { port, url } = await startLocalServer();
13
+ */
14
+
15
+ import http from 'http';
16
+ import fs from 'fs';
17
+ import path from 'path';
18
+ import { fileURLToPath } from 'url';
19
+
20
+ const __filename = fileURLToPath(import.meta.url);
21
+ const __dirname = path.dirname(__filename);
22
+
23
+ const PORT = process.env.PORT || 3001;
24
+
25
+ // In-memory session storage
26
+ const sessions = new Map();
27
+
28
+ // SSE clients per session
29
+ const sseClients = new Map(); // token -> Set<res>
30
+
31
+ /**
32
+ * Get or create a session
33
+ */
34
+ function getSession(token) {
35
+ return sessions.get(token);
36
+ }
37
+
38
+ function createSession(token, data) {
39
+ const session = {
40
+ workflowName: data.workflowName,
41
+ cliConnected: true,
42
+ history: data.history || [],
43
+ pendingInteractions: [],
44
+ createdAt: Date.now(),
45
+ };
46
+ sessions.set(token, session);
47
+ return session;
48
+ }
49
+
50
+ /**
51
+ * Broadcast event to all SSE clients for a session
52
+ */
53
+ function broadcastToSession(token, event) {
54
+ const clients = sseClients.get(token);
55
+ if (!clients) return;
56
+
57
+ const data = JSON.stringify(event);
58
+ for (const client of clients) {
59
+ try {
60
+ client.write(`data: ${data}\n\n`);
61
+ } catch (e) {
62
+ clients.delete(client);
63
+ }
64
+ }
65
+ }
66
+
67
+ /**
68
+ * Parse request body
69
+ */
70
+ async function parseBody(req) {
71
+ return new Promise((resolve) => {
72
+ let body = '';
73
+ req.on('data', chunk => body += chunk);
74
+ req.on('end', () => {
75
+ try {
76
+ resolve(body ? JSON.parse(body) : {});
77
+ } catch {
78
+ resolve({});
79
+ }
80
+ });
81
+ });
82
+ }
83
+
84
+ /**
85
+ * Parse URL and query params
86
+ */
87
+ function parseUrl(req) {
88
+ const url = new URL(req.url, `http://localhost:${PORT}`);
89
+ return {
90
+ pathname: url.pathname,
91
+ query: Object.fromEntries(url.searchParams),
92
+ };
93
+ }
94
+
95
+ /**
96
+ * Send JSON response
97
+ */
98
+ function sendJson(res, status, data) {
99
+ res.writeHead(status, {
100
+ 'Content-Type': 'application/json',
101
+ 'Access-Control-Allow-Origin': '*',
102
+ 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
103
+ 'Access-Control-Allow-Headers': 'Content-Type',
104
+ });
105
+ res.end(JSON.stringify(data));
106
+ }
107
+
108
+ /**
109
+ * Handle CLI POST requests
110
+ */
111
+ async function handleCliPost(req, res) {
112
+ const body = await parseBody(req);
113
+ const { type, sessionToken } = body;
114
+
115
+ if (!sessionToken) {
116
+ return sendJson(res, 400, { error: 'Missing sessionToken' });
117
+ }
118
+
119
+ switch (type) {
120
+ case 'session_init': {
121
+ const { workflowName, history } = body;
122
+ createSession(sessionToken, { workflowName, history });
123
+
124
+ broadcastToSession(sessionToken, {
125
+ type: 'cli_connected',
126
+ workflowName,
127
+ });
128
+
129
+ // Send history to any connected browsers
130
+ if (history && history.length > 0) {
131
+ broadcastToSession(sessionToken, {
132
+ type: 'history',
133
+ entries: history,
134
+ });
135
+ }
136
+
137
+ return sendJson(res, 200, { success: true });
138
+ }
139
+
140
+ case 'event': {
141
+ const session = getSession(sessionToken);
142
+ if (!session) {
143
+ return sendJson(res, 404, { error: 'Session not found' });
144
+ }
145
+
146
+ const { timestamp, event, ...eventData } = body;
147
+ const historyEvent = {
148
+ timestamp: timestamp || new Date().toISOString(),
149
+ event,
150
+ ...eventData,
151
+ };
152
+ delete historyEvent.sessionToken;
153
+ delete historyEvent.type;
154
+
155
+ // Add to history
156
+ session.history.unshift(historyEvent);
157
+
158
+ // Broadcast to browsers
159
+ broadcastToSession(sessionToken, {
160
+ type: 'event',
161
+ ...historyEvent,
162
+ });
163
+
164
+ return sendJson(res, 200, { success: true });
165
+ }
166
+
167
+ case 'session_end': {
168
+ const session = getSession(sessionToken);
169
+ if (session) {
170
+ session.cliConnected = false;
171
+ broadcastToSession(sessionToken, {
172
+ type: 'cli_disconnected',
173
+ reason: body.reason,
174
+ });
175
+ }
176
+ return sendJson(res, 200, { success: true });
177
+ }
178
+
179
+ default:
180
+ return sendJson(res, 400, { error: `Unknown type: ${type}` });
181
+ }
182
+ }
183
+
184
+ /**
185
+ * Handle CLI GET (long-poll for interactions)
186
+ */
187
+ async function handleCliGet(req, res, query) {
188
+ const { token, timeout = '30000' } = query;
189
+
190
+ if (!token) {
191
+ return sendJson(res, 400, { error: 'Missing token' });
192
+ }
193
+
194
+ const session = getSession(token);
195
+ if (!session) {
196
+ return sendJson(res, 404, { error: 'Session not found' });
197
+ }
198
+
199
+ const timeoutMs = Math.min(parseInt(timeout, 10), 55000);
200
+ const startTime = Date.now();
201
+
202
+ // Poll for pending interactions
203
+ const checkInterval = setInterval(() => {
204
+ if (session.pendingInteractions.length > 0) {
205
+ clearInterval(checkInterval);
206
+ const interaction = session.pendingInteractions.shift();
207
+ return sendJson(res, 200, {
208
+ type: 'interaction_response',
209
+ ...interaction,
210
+ });
211
+ }
212
+
213
+ if (Date.now() - startTime >= timeoutMs) {
214
+ clearInterval(checkInterval);
215
+ res.writeHead(204);
216
+ res.end();
217
+ }
218
+ }, 500);
219
+
220
+ // Clean up on client disconnect
221
+ req.on('close', () => {
222
+ clearInterval(checkInterval);
223
+ });
224
+ }
225
+
226
+ /**
227
+ * Handle SSE events endpoint for browsers
228
+ */
229
+ function handleEventsSSE(req, res, token) {
230
+ const session = getSession(token);
231
+ if (!session) {
232
+ return sendJson(res, 404, { error: 'Session not found' });
233
+ }
234
+
235
+ // Set up SSE
236
+ res.writeHead(200, {
237
+ 'Content-Type': 'text/event-stream',
238
+ 'Cache-Control': 'no-cache',
239
+ 'Connection': 'keep-alive',
240
+ 'Access-Control-Allow-Origin': '*',
241
+ });
242
+
243
+ res.write('retry: 3000\n\n');
244
+
245
+ // Send initial status
246
+ res.write(`data: ${JSON.stringify({
247
+ type: 'status',
248
+ cliConnected: session.cliConnected,
249
+ workflowName: session.workflowName,
250
+ })}\n\n`);
251
+
252
+ // Send existing history
253
+ res.write(`data: ${JSON.stringify({
254
+ type: 'history',
255
+ entries: session.history,
256
+ })}\n\n`);
257
+
258
+ // Add to SSE clients
259
+ if (!sseClients.has(token)) {
260
+ sseClients.set(token, new Set());
261
+ }
262
+ sseClients.get(token).add(res);
263
+
264
+ // Keepalive
265
+ const keepalive = setInterval(() => {
266
+ res.write(': keepalive\n\n');
267
+ }, 15000);
268
+
269
+ // Clean up on disconnect
270
+ req.on('close', () => {
271
+ clearInterval(keepalive);
272
+ const clients = sseClients.get(token);
273
+ if (clients) {
274
+ clients.delete(res);
275
+ }
276
+ });
277
+ }
278
+
279
+ /**
280
+ * Handle history GET
281
+ */
282
+ function handleHistoryGet(res, token) {
283
+ const session = getSession(token);
284
+ if (!session) {
285
+ return sendJson(res, 404, { error: 'Session not found' });
286
+ }
287
+
288
+ return sendJson(res, 200, {
289
+ workflowName: session.workflowName,
290
+ cliConnected: session.cliConnected,
291
+ entries: session.history,
292
+ });
293
+ }
294
+
295
+ /**
296
+ * Handle interaction submit POST
297
+ */
298
+ async function handleSubmitPost(req, res, token) {
299
+ const session = getSession(token);
300
+ if (!session) {
301
+ return sendJson(res, 404, { error: 'Session not found' });
302
+ }
303
+
304
+ if (!session.cliConnected) {
305
+ return sendJson(res, 503, { error: 'CLI is disconnected' });
306
+ }
307
+
308
+ const body = await parseBody(req);
309
+ const { slug, targetKey, response } = body;
310
+
311
+ if (!slug || !response) {
312
+ return sendJson(res, 400, { error: 'Missing slug or response' });
313
+ }
314
+
315
+ // Add to pending interactions for CLI to pick up
316
+ session.pendingInteractions.push({
317
+ slug,
318
+ targetKey: targetKey || `_interaction_${slug}`,
319
+ response,
320
+ });
321
+
322
+ // Log to history (include answer preview)
323
+ const event = {
324
+ timestamp: new Date().toISOString(),
325
+ event: 'INTERACTION_SUBMITTED',
326
+ slug,
327
+ targetKey: targetKey || `_interaction_${slug}`,
328
+ answer: response.substring(0, 200) + (response.length > 200 ? '...' : ''),
329
+ source: 'remote',
330
+ };
331
+ session.history.unshift(event);
332
+
333
+ // Broadcast to browsers
334
+ broadcastToSession(token, {
335
+ type: 'event',
336
+ ...event,
337
+ });
338
+
339
+ return sendJson(res, 200, { success: true });
340
+ }
341
+
342
+ /**
343
+ * Serve session UI
344
+ */
345
+ const MASTER_TEMPLATE_PATH = path.join(__dirname, '..', 'lib', 'ui', 'index.html');
346
+
347
+ /**
348
+ * Get session HTML by reading the master template from lib/ui/index.html
349
+ */
350
+ function getSessionHTML(token, workflowName) {
351
+ try {
352
+ const template = fs.readFileSync(MASTER_TEMPLATE_PATH, 'utf8');
353
+ return template
354
+ .replace(/\{\{SESSION_TOKEN\}\}/g, token)
355
+ .replace(/\{\{WORKFLOW_NAME\}\}/g, workflowName || 'Workflow');
356
+ } catch (err) {
357
+ console.error('Error loading master template:', err);
358
+ return `
359
+ <!DOCTYPE html>
360
+ <html>
361
+ <head><title>Error</title></head>
362
+ <body style="font-family: system-ui; max-width: 600px; margin: 100px auto; text-align: center;">
363
+ <h1>Error loading UI template</h1>
364
+ <p>${err.message}</p>
365
+ <p>Make sure <code>lib/ui/index.html</code> exists.</p>
366
+ </body>
367
+ </html>
368
+ `;
369
+ }
370
+ }
371
+
372
+
373
+
374
+ function serveSessionUI(res, token) {
375
+ const session = getSession(token);
376
+
377
+ if (!session) {
378
+ res.writeHead(404, { 'Content-Type': 'text/html' });
379
+ return res.end(`
380
+ <!DOCTYPE html>
381
+ <html>
382
+ <head><title>Session Not Found</title></head>
383
+ <body style="font-family: system-ui; max-width: 600px; margin: 100px auto; text-align: center;">
384
+ <h1>Session Not Found</h1>
385
+ <p>This session has expired or does not exist.</p>
386
+ </body>
387
+ </html>
388
+ `);
389
+ }
390
+
391
+ const html = getSessionHTML(token, session.workflowName);
392
+ res.writeHead(200, { 'Content-Type': 'text/html' });
393
+ res.end(html);
394
+ }
395
+
396
+ // getSessionHTML was moved up and updated to read from MASTER_TEMPLATE_PATH
397
+
398
+ /**
399
+ * Serve static files
400
+ */
401
+ function serveStatic(res, filepath) {
402
+ const fullPath = path.join(__dirname, 'public', filepath);
403
+
404
+ if (!fs.existsSync(fullPath)) {
405
+ res.writeHead(404);
406
+ return res.end('Not found');
407
+ }
408
+
409
+ const ext = path.extname(fullPath);
410
+ const contentTypes = {
411
+ '.html': 'text/html',
412
+ '.js': 'application/javascript',
413
+ '.css': 'text/css',
414
+ '.json': 'application/json',
415
+ };
416
+
417
+ const content = fs.readFileSync(fullPath);
418
+ res.writeHead(200, { 'Content-Type': contentTypes[ext] || 'text/plain' });
419
+ res.end(content);
420
+ }
421
+
422
+ /**
423
+ * Main request handler
424
+ */
425
+ async function handleRequest(req, res) {
426
+ const { pathname, query } = parseUrl(req);
427
+
428
+ // Handle CORS preflight
429
+ if (req.method === 'OPTIONS') {
430
+ res.writeHead(200, {
431
+ 'Access-Control-Allow-Origin': '*',
432
+ 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
433
+ 'Access-Control-Allow-Headers': 'Content-Type',
434
+ });
435
+ return res.end();
436
+ }
437
+
438
+ // Route: CLI endpoint
439
+ if (pathname === '/api/ws/cli') {
440
+ if (req.method === 'POST') {
441
+ return handleCliPost(req, res);
442
+ }
443
+ if (req.method === 'GET') {
444
+ return handleCliGet(req, res, query);
445
+ }
446
+ }
447
+
448
+ // Route: Session UI
449
+ const sessionMatch = pathname.match(/^\/s\/([^/]+)$/);
450
+ if (sessionMatch) {
451
+ return serveSessionUI(res, sessionMatch[1]);
452
+ }
453
+
454
+ // Route: Events SSE
455
+ const eventsMatch = pathname.match(/^\/api\/events\/([^/]+)$/);
456
+ if (eventsMatch && req.method === 'GET') {
457
+ return handleEventsSSE(req, res, eventsMatch[1]);
458
+ }
459
+
460
+ // Route: History
461
+ const historyMatch = pathname.match(/^\/api\/history\/([^/]+)$/);
462
+ if (historyMatch && req.method === 'GET') {
463
+ return handleHistoryGet(res, historyMatch[1]);
464
+ }
465
+
466
+ // Route: Submit
467
+ const submitMatch = pathname.match(/^\/api\/submit\/([^/]+)$/);
468
+ if (submitMatch && req.method === 'POST') {
469
+ return handleSubmitPost(req, res, submitMatch[1]);
470
+ }
471
+
472
+ // Route: Static files
473
+ if (pathname === '/' || pathname === '/index.html') {
474
+ return serveStatic(res, 'index.html');
475
+ }
476
+
477
+ // 404
478
+ res.writeHead(404);
479
+ res.end('Not found');
480
+ }
481
+
482
+ /**
483
+ * Start the local server programmatically
484
+ * @param {number} initialPort - Starting port to try (default 3000)
485
+ * @param {boolean} silent - Suppress console output
486
+ * @returns {Promise<{port: number, url: string, server: http.Server}>}
487
+ */
488
+ export function startLocalServer(initialPort = 3000, silent = false) {
489
+ return new Promise((resolve, reject) => {
490
+ let port = initialPort;
491
+ const maxPort = initialPort + 100;
492
+
493
+ const tryPort = () => {
494
+ const server = http.createServer(handleRequest);
495
+
496
+ server.on('error', (e) => {
497
+ if (e.code === 'EADDRINUSE') {
498
+ if (port < maxPort) {
499
+ port++;
500
+ tryPort();
501
+ } else {
502
+ reject(new Error(`Could not find open port between ${initialPort} and ${maxPort}`));
503
+ }
504
+ } else {
505
+ reject(e);
506
+ }
507
+ });
508
+
509
+ server.listen(port, () => {
510
+ const url = `http://localhost:${port}`;
511
+ if (!silent) {
512
+ console.log(`Local server running at ${url}`);
513
+ }
514
+ resolve({ port, url, server });
515
+ });
516
+ };
517
+
518
+ tryPort();
519
+ });
520
+ }
521
+
522
+ // Run standalone if executed directly
523
+ const isMainModule = process.argv[1] && (
524
+ process.argv[1].endsWith('local-server.js') ||
525
+ process.argv[1].endsWith('local-server')
526
+ );
527
+
528
+ if (isMainModule) {
529
+ const PORT = process.env.PORT || 3000;
530
+ startLocalServer(parseInt(PORT, 10)).then(({ port, url }) => {
531
+ console.log(`
532
+ ┌─────────────────────────────────────────────────────────────┐
533
+ │ Agent State Machine - Local Remote Follow Server │
534
+ ├─────────────────────────────────────────────────────────────┤
535
+ │ Server running at: ${url.padEnd(37)}│
536
+ │ │
537
+ │ To test remote follow, run your workflow with: │
538
+ │ state-machine run <workflow-name> --local │
539
+ │ │
540
+ │ Press Ctrl+C to stop │
541
+ └─────────────────────────────────────────────────────────────┘
542
+ `);
543
+ }).catch(err => {
544
+ console.error('Failed to start server:', err.message);
545
+ process.exit(1);
546
+ });
547
+ }
@@ -0,0 +1,57 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Agent State Machine - Remote Follow</title>
7
+ <script src="https://cdn.tailwindcss.com"></script>
8
+ </head>
9
+ <body class="bg-zinc-950 text-zinc-100 min-h-screen flex items-center justify-center">
10
+ <div class="max-w-lg text-center p-8">
11
+ <h1 class="text-3xl font-bold mb-4">Agent State Machine</h1>
12
+ <p class="text-zinc-400 mb-8">Remote Follow Server</p>
13
+
14
+ <div class="bg-zinc-900 rounded-lg p-6 text-left">
15
+ <h2 class="text-lg font-semibold mb-4">How to use</h2>
16
+
17
+ <ol class="space-y-4 text-zinc-300">
18
+ <li class="flex gap-3">
19
+ <span class="text-zinc-500 font-mono">1.</span>
20
+ <span>Install package:
21
+ <br><code class="bg-zinc-800 px-2 py-0.5 rounded">npm i -D agent-state-machine</code></span>
22
+ </li>
23
+
24
+ <li class="flex gap-3">
25
+ <span class="text-zinc-500 font-mono">2.</span>
26
+ <span>Initialize your workflow:
27
+ <br><code class="bg-zinc-800 px-2 py-0.5 rounded">npx state-machine --setup my-workflow</code></span>
28
+ </li>
29
+
30
+ <li class="flex gap-3">
31
+ <span class="text-zinc-500 font-mono">3.</span>
32
+ <span>Run your workflow:</span>
33
+ </li>
34
+ <li class="pl-6">
35
+ <code class="bg-zinc-800 px-3 py-2 rounded block text-sm">
36
+ npx state-machine run my-workflow
37
+ </code>
38
+ </li>
39
+
40
+ <li class="flex gap-3 mt-4">
41
+ <span class="text-zinc-500 font-mono">4.</span>
42
+ <span>The CLI will print a unique URL. Share it with anyone who needs to follow along or interact with the workflow.</span>
43
+ </li>
44
+
45
+ <li class="flex gap-3 mt-4">
46
+ <span class="text-zinc-500 font-mono">5.</span>
47
+ <span>Open the URL in a browser to see live workflow events and submit interaction responses.</span>
48
+ </li>
49
+ </ol>
50
+ </div>
51
+
52
+ <p class="text-zinc-500 text-sm mt-8">
53
+ Learn more at <a href="https://github.com/superbasicman/state-machine" target="_blank" class="text-cyan-400 hover:underline">GitHub</a>
54
+ </p>
55
+ </div>
56
+ </body>
57
+ </html>