agent-state-machine 1.4.0 → 1.4.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.
package/bin/cli.js CHANGED
@@ -6,6 +6,7 @@ import { pathToFileURL, fileURLToPath } from 'url';
6
6
  import { WorkflowRuntime } from '../lib/index.js';
7
7
  import { setup } from '../lib/setup.js';
8
8
  import { startServer } from '../lib/ui/server.js';
9
+ import { startLocalServer } from '../vercel-server/local-server.js';
9
10
 
10
11
  const __filename = fileURLToPath(import.meta.url);
11
12
  const __dirname = path.dirname(__filename);
@@ -24,7 +25,7 @@ function getVersion() {
24
25
  }
25
26
 
26
27
  // Default remote server URL (can be overridden with STATE_MACHINE_REMOTE_URL env var)
27
- const DEFAULT_REMOTE_URL = process.env.STATE_MACHINE_REMOTE_URL || 'http://localhost:3001';
28
+ const DEFAULT_REMOTE_URL = process.env.STATE_MACHINE_REMOTE_URL || 'https://supamachine.vercel.app';
28
29
 
29
30
  function printHelp() {
30
31
  console.log(`
@@ -32,7 +33,8 @@ Agent State Machine CLI (Native JS Workflows Only) v${getVersion()}
32
33
 
33
34
  Usage:
34
35
  state-machine --setup <workflow-name> Create a new workflow project
35
- state-machine run <workflow-name> [--remote] Run a workflow (--remote enables remote follow)
36
+ state-machine run <workflow-name> Run a workflow (remote follow enabled by default)
37
+ state-machine run <workflow-name> --local Run with local server (localhost:3000)
36
38
  state-machine follow <workflow-name> View prompt trace history in browser with live updates
37
39
  state-machine status [workflow-name] Show current state (or list all)
38
40
  state-machine history <workflow-name> [limit] Show execution history logs
@@ -43,12 +45,12 @@ Usage:
43
45
 
44
46
  Options:
45
47
  --setup, -s Initialize a new workflow with directory structure
46
- --remote, -r Enable remote follow (generates shareable URL for browser access)
48
+ --local, -l Use local server instead of remote (starts on localhost:3000)
47
49
  --help, -h Show help
48
50
  --version, -v Show version
49
51
 
50
52
  Environment Variables:
51
- STATE_MACHINE_REMOTE_URL Override the default remote server URL
53
+ STATE_MACHINE_REMOTE_URL Override the default remote server URL (for local dev testing)
52
54
 
53
55
  Workflow Structure:
54
56
  workflows/<name>/
@@ -143,7 +145,7 @@ function listWorkflows() {
143
145
  console.log('');
144
146
  }
145
147
 
146
- async function runOrResume(workflowName, { remoteEnabled = false } = {}) {
148
+ async function runOrResume(workflowName, { remoteEnabled = false, useLocalServer = false } = {}) {
147
149
  const workflowDir = resolveWorkflowDir(workflowName);
148
150
 
149
151
  if (!fs.existsSync(workflowDir)) {
@@ -161,19 +163,39 @@ async function runOrResume(workflowName, { remoteEnabled = false } = {}) {
161
163
  const runtime = new WorkflowRuntime(workflowDir);
162
164
  const workflowUrl = pathToFileURL(entry).href;
163
165
 
164
- // Enable remote follow mode if requested
165
- if (remoteEnabled) {
166
- const remoteUrl = process.env.STATE_MACHINE_REMOTE_URL || DEFAULT_REMOTE_URL;
166
+ let localServer = null;
167
+ let remoteUrl = null;
168
+
169
+ // Start local server if --local flag is used
170
+ if (useLocalServer) {
171
+ try {
172
+ const result = await startLocalServer(3000, true);
173
+ localServer = result.server;
174
+ remoteUrl = result.url;
175
+ console.log(`Local server started at ${remoteUrl}`);
176
+ } catch (err) {
177
+ console.error(`Failed to start local server: ${err.message}`);
178
+ process.exit(1);
179
+ }
180
+ } else if (remoteEnabled) {
181
+ remoteUrl = process.env.STATE_MACHINE_REMOTE_URL || DEFAULT_REMOTE_URL;
182
+ }
183
+
184
+ // Enable remote follow mode if we have a URL
185
+ if (remoteUrl) {
167
186
  await runtime.enableRemote(remoteUrl);
168
187
  }
169
188
 
170
189
  try {
171
190
  await runtime.runWorkflow(workflowUrl);
172
191
  } finally {
173
- // Always disable remote on completion
174
- if (remoteEnabled) {
192
+ // Cleanup
193
+ if (remoteUrl) {
175
194
  await runtime.disableRemote();
176
195
  }
196
+ if (localServer) {
197
+ localServer.close();
198
+ }
177
199
  }
178
200
  }
179
201
 
@@ -205,13 +227,15 @@ async function main() {
205
227
  case 'run':
206
228
  if (!workflowName) {
207
229
  console.error('Error: Workflow name required');
208
- console.error(`Usage: state-machine ${command} <workflow-name> [--remote]`);
230
+ console.error(`Usage: state-machine ${command} <workflow-name> [--local]`);
209
231
  process.exit(1);
210
232
  }
211
233
  {
212
- const remoteEnabled = args.includes('--remote') || args.includes('-r');
234
+ // Remote is enabled by default, --local uses local server instead
235
+ const useLocalServer = args.includes('--local') || args.includes('-l');
236
+ const remoteEnabled = !useLocalServer; // Use Vercel if not local
213
237
  try {
214
- await runOrResume(workflowName, { remoteEnabled });
238
+ await runOrResume(workflowName, { remoteEnabled, useLocalServer });
215
239
  } catch (err) {
216
240
  console.error('Error:', err.message || String(err));
217
241
  process.exit(1);
@@ -39,6 +39,7 @@ export async function initialPrompt(question, options = {}) {
39
39
  runtime.prependHistory({
40
40
  event: 'PROMPT_REQUESTED',
41
41
  slug,
42
+ targetKey: memoryKey,
42
43
  question
43
44
  });
44
45
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-state-machine",
3
- "version": "1.4.0",
3
+ "version": "1.4.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",
@@ -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,846 @@
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
+ function serveSessionUI(res, token) {
346
+ const session = getSession(token);
347
+
348
+ if (!session) {
349
+ res.writeHead(404, { 'Content-Type': 'text/html' });
350
+ return res.end(`
351
+ <!DOCTYPE html>
352
+ <html>
353
+ <head><title>Session Not Found</title></head>
354
+ <body style="font-family: system-ui; max-width: 600px; margin: 100px auto; text-align: center;">
355
+ <h1>Session Not Found</h1>
356
+ <p>This session has expired or does not exist.</p>
357
+ </body>
358
+ </html>
359
+ `);
360
+ }
361
+
362
+ // Read the session UI template from api/session/[token].js and extract HTML
363
+ // For simplicity, serve a standalone version
364
+ const html = getSessionHTML(token, session.workflowName);
365
+ res.writeHead(200, { 'Content-Type': 'text/html' });
366
+ res.end(html);
367
+ }
368
+
369
+ /**
370
+ * Get session HTML (inline version for local dev)
371
+ */
372
+ function getSessionHTML(token, workflowName) {
373
+ return `<!DOCTYPE html>
374
+ <html lang="en">
375
+ <head>
376
+ <meta charset="UTF-8">
377
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
378
+ <title>${workflowName} - Remote Follow</title>
379
+ <script src="https://cdn.tailwindcss.com"></script>
380
+ <script src="https://unpkg.com/react@18/umd/react.production.min.js"></script>
381
+ <script src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"></script>
382
+ <script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
383
+ <style>
384
+ .animate-pulse-slow { animation: pulse 3s cubic-bezier(0.4, 0, 0.6, 1) infinite; }
385
+ </style>
386
+ </head>
387
+ <body class="bg-zinc-950 text-zinc-100 min-h-screen">
388
+ <div id="root"></div>
389
+ <script>
390
+ window.SESSION_TOKEN = '${token}';
391
+ window.WORKFLOW_NAME = '${workflowName}';
392
+ </script>
393
+ <script type="text/babel">
394
+ const { useState, useEffect, useRef } = React;
395
+
396
+ function StatusBadge({ status }) {
397
+ const colors = {
398
+ connected: 'bg-green-500',
399
+ disconnected: 'bg-red-500',
400
+ connecting: 'bg-yellow-500 animate-pulse-slow',
401
+ };
402
+ const labels = {
403
+ connected: 'Live',
404
+ disconnected: 'CLI Offline',
405
+ connecting: 'Connecting...',
406
+ };
407
+ return (
408
+ <div className="flex items-center gap-2">
409
+ <div className={\`w-2 h-2 rounded-full \${colors[status] || colors.disconnected}\`}></div>
410
+ <span className="text-xs uppercase tracking-wider text-zinc-400">{labels[status] || status}</span>
411
+ </div>
412
+ );
413
+ }
414
+
415
+ function CopyButton({ text }) {
416
+ const [copied, setCopied] = useState(false);
417
+ const handleCopy = async () => {
418
+ await navigator.clipboard.writeText(text);
419
+ setCopied(true);
420
+ setTimeout(() => setCopied(false), 2000);
421
+ };
422
+ return (
423
+ <button onClick={handleCopy} className="px-2 py-1 text-xs bg-zinc-700 hover:bg-zinc-600 rounded">
424
+ {copied ? 'Copied!' : 'Copy'}
425
+ </button>
426
+ );
427
+ }
428
+
429
+ function JsonView({ data, label }) {
430
+ const [isRaw, setIsRaw] = useState(false);
431
+ const jsonStr = JSON.stringify(data, null, 2);
432
+ return (
433
+ <div className="bg-zinc-800 rounded-lg overflow-hidden">
434
+ <div className="flex justify-between items-center px-3 py-2 bg-zinc-700">
435
+ <span className="text-xs font-medium text-zinc-300">{label}</span>
436
+ <div className="flex gap-2">
437
+ <button onClick={() => setIsRaw(!isRaw)} className="text-xs text-zinc-400 hover:text-zinc-200">
438
+ {isRaw ? 'Clean' : 'Raw'}
439
+ </button>
440
+ <CopyButton text={jsonStr} />
441
+ </div>
442
+ </div>
443
+ <pre className="p-3 text-xs overflow-auto max-h-96 text-zinc-300">{jsonStr}</pre>
444
+ </div>
445
+ );
446
+ }
447
+
448
+ function InteractionForm({ interaction, onSubmit, disabled }) {
449
+ const [response, setResponse] = useState('');
450
+ const [submitting, setSubmitting] = useState(false);
451
+
452
+ const handleSubmit = async (e) => {
453
+ e.preventDefault();
454
+ if (!response.trim() || submitting) return;
455
+ setSubmitting(true);
456
+ try {
457
+ await onSubmit(interaction.slug, interaction.targetKey, response.trim());
458
+ setResponse('');
459
+ } finally {
460
+ setSubmitting(false);
461
+ }
462
+ };
463
+
464
+ return (
465
+ <div className="bg-yellow-900/20 border border-yellow-700/50 rounded-lg p-6 mb-6">
466
+ <div className="text-sm font-bold text-yellow-200 mb-2">Input Required</div>
467
+ <div className="text-sm text-yellow-100/80 mb-4 whitespace-pre-wrap">
468
+ {interaction.question || 'Please provide your input.'}
469
+ </div>
470
+ <form onSubmit={handleSubmit}>
471
+ <textarea
472
+ value={response}
473
+ onChange={(e) => setResponse(e.target.value)}
474
+ className="w-full p-3 bg-zinc-800 border border-zinc-700 rounded-lg text-zinc-100 placeholder-zinc-500 focus:outline-none focus:border-yellow-500"
475
+ rows={4}
476
+ placeholder="Enter your response..."
477
+ disabled={submitting || disabled}
478
+ />
479
+ <div className="flex justify-end mt-3 gap-2">
480
+ {disabled && <span className="text-sm text-red-400">CLI is offline</span>}
481
+ <button
482
+ type="submit"
483
+ disabled={submitting || disabled || !response.trim()}
484
+ className="px-4 py-2 bg-yellow-600 hover:bg-yellow-500 text-white rounded-lg disabled:opacity-50 disabled:cursor-not-allowed"
485
+ >
486
+ {submitting ? 'Submitting...' : 'Submit'}
487
+ </button>
488
+ </div>
489
+ </form>
490
+ </div>
491
+ );
492
+ }
493
+
494
+ function EventCard({ entry }) {
495
+ const eventColors = {
496
+ WORKFLOW_STARTED: 'border-blue-500',
497
+ WORKFLOW_COMPLETED: 'border-green-500',
498
+ WORKFLOW_FAILED: 'border-red-500',
499
+ AGENT_STARTED: 'border-blue-400',
500
+ AGENT_COMPLETED: 'border-green-400',
501
+ AGENT_FAILED: 'border-red-400',
502
+ PROMPT_REQUESTED: 'border-yellow-500',
503
+ PROMPT_ANSWERED: 'border-yellow-400',
504
+ INTERACTION_REQUESTED: 'border-yellow-500',
505
+ INTERACTION_RESOLVED: 'border-yellow-400',
506
+ INTERACTION_SUBMITTED: 'border-yellow-300',
507
+ };
508
+ const borderColor = eventColors[entry.event] || 'border-zinc-600';
509
+ const time = entry.timestamp ? new Date(entry.timestamp).toLocaleTimeString() : '';
510
+
511
+ return (
512
+ <div className={\`border-l-2 \${borderColor} pl-4 py-3\`}>
513
+ <div className="flex justify-between items-start mb-2">
514
+ <span className="font-medium text-sm">{entry.event}</span>
515
+ <span className="text-xs text-zinc-500">{time}</span>
516
+ </div>
517
+ {entry.agent && <div className="text-xs text-zinc-400 mb-1">Agent: <span className="text-zinc-300">{entry.agent}</span></div>}
518
+ {entry.slug && <div className="text-xs text-zinc-400 mb-1">Slug: <span className="text-zinc-300">{entry.slug}</span></div>}
519
+ {entry.question && (
520
+ <div className="text-xs text-zinc-400 mt-2">
521
+ <div className="text-zinc-500 mb-1">Question:</div>
522
+ <div className="text-zinc-300 whitespace-pre-wrap">{entry.question}</div>
523
+ </div>
524
+ )}
525
+ {entry.answer && (
526
+ <div className="text-xs text-zinc-400 mt-2">
527
+ <div className="text-zinc-500 mb-1">Answer:</div>
528
+ <div className="text-zinc-300">{entry.answer}</div>
529
+ </div>
530
+ )}
531
+ {entry.error && <div className="text-xs text-red-400 mt-2">{entry.error}</div>}
532
+ {entry.output && <div className="mt-2"><JsonView data={entry.output} label="Output" /></div>}
533
+ {entry.prompt && (
534
+ <details className="mt-2">
535
+ <summary className="text-xs text-zinc-500 cursor-pointer hover:text-zinc-300">Show Prompt</summary>
536
+ <pre className="mt-2 p-2 bg-zinc-800 rounded text-xs text-zinc-400 overflow-auto max-h-48">
537
+ {typeof entry.prompt === 'string' ? entry.prompt : JSON.stringify(entry.prompt, null, 2)}
538
+ </pre>
539
+ </details>
540
+ )}
541
+ </div>
542
+ );
543
+ }
544
+
545
+ function App() {
546
+ const [history, setHistory] = useState([]);
547
+ const [status, setStatus] = useState('connecting');
548
+ const [pendingInteraction, setPendingInteraction] = useState(null);
549
+ const [sortNewest, setSortNewest] = useState(true);
550
+
551
+ // Detect pending interactions - scan history for unresolved requests
552
+ useEffect(() => {
553
+ if (history.length === 0) {
554
+ setPendingInteraction(null);
555
+ return;
556
+ }
557
+
558
+ // Build set of resolved slugs (scan from newest to oldest)
559
+ const resolvedSlugs = new Set();
560
+ let pending = null;
561
+
562
+ for (const entry of history) {
563
+ const isResolution = entry.event === 'INTERACTION_RESOLVED' ||
564
+ entry.event === 'PROMPT_ANSWERED' ||
565
+ entry.event === 'INTERACTION_SUBMITTED';
566
+ const isRequest = entry.event === 'INTERACTION_REQUESTED' ||
567
+ entry.event === 'PROMPT_REQUESTED';
568
+
569
+ if (isResolution && entry.slug) {
570
+ resolvedSlugs.add(entry.slug);
571
+ }
572
+
573
+ // Find the most recent unresolved request
574
+ if (isRequest && entry.slug && !resolvedSlugs.has(entry.slug) && !pending) {
575
+ pending = {
576
+ slug: entry.slug,
577
+ targetKey: entry.targetKey || \`_interaction_\${entry.slug}\`,
578
+ question: entry.question,
579
+ };
580
+ }
581
+ }
582
+
583
+ setPendingInteraction(pending);
584
+ }, [history]);
585
+
586
+ useEffect(() => {
587
+ const token = window.SESSION_TOKEN;
588
+ fetch(\`/api/history/\${token}\`)
589
+ .then(res => res.json())
590
+ .then(data => {
591
+ if (data.entries) setHistory(data.entries);
592
+ setStatus(data.cliConnected ? 'connected' : 'disconnected');
593
+ })
594
+ .catch(() => setStatus('disconnected'));
595
+
596
+ const eventSource = new EventSource(\`/api/events/\${token}\`);
597
+ eventSource.onopen = () => setStatus('connected');
598
+ eventSource.onerror = () => setStatus('disconnected');
599
+ eventSource.onmessage = (e) => {
600
+ try {
601
+ const data = JSON.parse(e.data);
602
+ switch (data.type) {
603
+ case 'status': setStatus(data.cliConnected ? 'connected' : 'disconnected'); break;
604
+ case 'history': setHistory(data.entries || []); break;
605
+ case 'event':
606
+ // Skip duplicate INTERACTION_SUBMITTED events (from optimistic updates)
607
+ setHistory(prev => {
608
+ if (data.event === 'INTERACTION_SUBMITTED' && data.slug) {
609
+ const hasDupe = prev.some(e =>
610
+ e.event === 'INTERACTION_SUBMITTED' && e.slug === data.slug
611
+ );
612
+ if (hasDupe) return prev;
613
+ }
614
+ return [data, ...prev];
615
+ });
616
+ break;
617
+ case 'cli_connected':
618
+ case 'cli_reconnected': setStatus('connected'); break;
619
+ case 'cli_disconnected': setStatus('disconnected'); break;
620
+ }
621
+ } catch (err) { console.error(err); }
622
+ };
623
+ return () => eventSource.close();
624
+ }, []);
625
+
626
+ const handleSubmit = async (slug, targetKey, response) => {
627
+ // Optimistic update - add event immediately to hide form
628
+ const optimisticEvent = {
629
+ timestamp: new Date().toISOString(),
630
+ event: 'INTERACTION_SUBMITTED',
631
+ slug,
632
+ targetKey,
633
+ answer: response.substring(0, 200) + (response.length > 200 ? '...' : ''),
634
+ source: 'remote',
635
+ };
636
+ setHistory(prev => [optimisticEvent, ...prev]);
637
+
638
+ const res = await fetch(\`/api/submit/\${window.SESSION_TOKEN}\`, {
639
+ method: 'POST',
640
+ headers: { 'Content-Type': 'application/json' },
641
+ body: JSON.stringify({ slug, targetKey, response }),
642
+ });
643
+ if (!res.ok) {
644
+ // Rollback optimistic update on error
645
+ setHistory(prev => prev.filter(e => e !== optimisticEvent));
646
+ const error = await res.json();
647
+ throw new Error(error.error || 'Failed to submit');
648
+ }
649
+ };
650
+
651
+ const sortedHistory = sortNewest ? history : [...history].reverse();
652
+
653
+ return (
654
+ <div className="max-w-4xl mx-auto p-6">
655
+ <div className="sticky top-0 bg-zinc-950/95 backdrop-blur py-4 mb-6 border-b border-zinc-800">
656
+ <div className="flex justify-between items-center">
657
+ <div>
658
+ <h1 className="text-xl font-bold text-zinc-100">{window.WORKFLOW_NAME || 'Workflow'}</h1>
659
+ <div className="text-xs text-zinc-500 mt-1">Remote Follow (Local Dev)</div>
660
+ </div>
661
+ <div className="flex items-center gap-4">
662
+ <button onClick={() => setSortNewest(!sortNewest)} className="text-xs text-zinc-400 hover:text-zinc-200">
663
+ {sortNewest ? 'Newest First' : 'Oldest First'}
664
+ </button>
665
+ <StatusBadge status={status} />
666
+ </div>
667
+ </div>
668
+ </div>
669
+
670
+ {pendingInteraction && (
671
+ <InteractionForm interaction={pendingInteraction} onSubmit={handleSubmit} disabled={status !== 'connected'} />
672
+ )}
673
+
674
+ {status === 'disconnected' && !pendingInteraction && (
675
+ <div className="bg-red-900/20 border border-red-700/50 rounded-lg p-4 mb-6">
676
+ <div className="text-sm text-red-200">CLI is disconnected. Waiting for reconnection...</div>
677
+ </div>
678
+ )}
679
+
680
+ <div className="space-y-2">
681
+ {sortedHistory.length === 0 ? (
682
+ <div className="text-center text-zinc-500 py-12">No events yet. Waiting for workflow activity...</div>
683
+ ) : (
684
+ sortedHistory.map((entry, i) => <EventCard key={\`\${entry.timestamp}-\${i}\`} entry={entry} />)
685
+ )}
686
+ </div>
687
+ </div>
688
+ );
689
+ }
690
+
691
+ ReactDOM.createRoot(document.getElementById('root')).render(<App />);
692
+ </script>
693
+ </body>
694
+ </html>`;
695
+ }
696
+
697
+ /**
698
+ * Serve static files
699
+ */
700
+ function serveStatic(res, filepath) {
701
+ const fullPath = path.join(__dirname, 'public', filepath);
702
+
703
+ if (!fs.existsSync(fullPath)) {
704
+ res.writeHead(404);
705
+ return res.end('Not found');
706
+ }
707
+
708
+ const ext = path.extname(fullPath);
709
+ const contentTypes = {
710
+ '.html': 'text/html',
711
+ '.js': 'application/javascript',
712
+ '.css': 'text/css',
713
+ '.json': 'application/json',
714
+ };
715
+
716
+ const content = fs.readFileSync(fullPath);
717
+ res.writeHead(200, { 'Content-Type': contentTypes[ext] || 'text/plain' });
718
+ res.end(content);
719
+ }
720
+
721
+ /**
722
+ * Main request handler
723
+ */
724
+ async function handleRequest(req, res) {
725
+ const { pathname, query } = parseUrl(req);
726
+
727
+ // Handle CORS preflight
728
+ if (req.method === 'OPTIONS') {
729
+ res.writeHead(200, {
730
+ 'Access-Control-Allow-Origin': '*',
731
+ 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
732
+ 'Access-Control-Allow-Headers': 'Content-Type',
733
+ });
734
+ return res.end();
735
+ }
736
+
737
+ // Route: CLI endpoint
738
+ if (pathname === '/api/ws/cli') {
739
+ if (req.method === 'POST') {
740
+ return handleCliPost(req, res);
741
+ }
742
+ if (req.method === 'GET') {
743
+ return handleCliGet(req, res, query);
744
+ }
745
+ }
746
+
747
+ // Route: Session UI
748
+ const sessionMatch = pathname.match(/^\/s\/([^/]+)$/);
749
+ if (sessionMatch) {
750
+ return serveSessionUI(res, sessionMatch[1]);
751
+ }
752
+
753
+ // Route: Events SSE
754
+ const eventsMatch = pathname.match(/^\/api\/events\/([^/]+)$/);
755
+ if (eventsMatch && req.method === 'GET') {
756
+ return handleEventsSSE(req, res, eventsMatch[1]);
757
+ }
758
+
759
+ // Route: History
760
+ const historyMatch = pathname.match(/^\/api\/history\/([^/]+)$/);
761
+ if (historyMatch && req.method === 'GET') {
762
+ return handleHistoryGet(res, historyMatch[1]);
763
+ }
764
+
765
+ // Route: Submit
766
+ const submitMatch = pathname.match(/^\/api\/submit\/([^/]+)$/);
767
+ if (submitMatch && req.method === 'POST') {
768
+ return handleSubmitPost(req, res, submitMatch[1]);
769
+ }
770
+
771
+ // Route: Static files
772
+ if (pathname === '/' || pathname === '/index.html') {
773
+ return serveStatic(res, 'index.html');
774
+ }
775
+
776
+ // 404
777
+ res.writeHead(404);
778
+ res.end('Not found');
779
+ }
780
+
781
+ /**
782
+ * Start the local server programmatically
783
+ * @param {number} initialPort - Starting port to try (default 3000)
784
+ * @param {boolean} silent - Suppress console output
785
+ * @returns {Promise<{port: number, url: string, server: http.Server}>}
786
+ */
787
+ export function startLocalServer(initialPort = 3000, silent = false) {
788
+ return new Promise((resolve, reject) => {
789
+ let port = initialPort;
790
+ const maxPort = initialPort + 100;
791
+
792
+ const tryPort = () => {
793
+ const server = http.createServer(handleRequest);
794
+
795
+ server.on('error', (e) => {
796
+ if (e.code === 'EADDRINUSE') {
797
+ if (port < maxPort) {
798
+ port++;
799
+ tryPort();
800
+ } else {
801
+ reject(new Error(`Could not find open port between ${initialPort} and ${maxPort}`));
802
+ }
803
+ } else {
804
+ reject(e);
805
+ }
806
+ });
807
+
808
+ server.listen(port, () => {
809
+ const url = `http://localhost:${port}`;
810
+ if (!silent) {
811
+ console.log(`Local server running at ${url}`);
812
+ }
813
+ resolve({ port, url, server });
814
+ });
815
+ };
816
+
817
+ tryPort();
818
+ });
819
+ }
820
+
821
+ // Run standalone if executed directly
822
+ const isMainModule = process.argv[1] && (
823
+ process.argv[1].endsWith('local-server.js') ||
824
+ process.argv[1].endsWith('local-server')
825
+ );
826
+
827
+ if (isMainModule) {
828
+ const PORT = process.env.PORT || 3000;
829
+ startLocalServer(parseInt(PORT, 10)).then(({ port, url }) => {
830
+ console.log(`
831
+ ┌─────────────────────────────────────────────────────────────┐
832
+ │ Agent State Machine - Local Remote Follow Server │
833
+ ├─────────────────────────────────────────────────────────────┤
834
+ │ Server running at: ${url.padEnd(37)}│
835
+ │ │
836
+ │ To test remote follow, run your workflow with: │
837
+ │ state-machine run <workflow-name> --local │
838
+ │ │
839
+ │ Press Ctrl+C to stop │
840
+ └─────────────────────────────────────────────────────────────┘
841
+ `);
842
+ }).catch(err => {
843
+ console.error('Failed to start server:', err.message);
844
+ process.exit(1);
845
+ });
846
+ }
@@ -0,0 +1,45 @@
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>Run your workflow with the <code class="bg-zinc-800 px-2 py-0.5 rounded">--remote</code> flag:</span>
21
+ </li>
22
+ <li class="pl-6">
23
+ <code class="bg-zinc-800 px-3 py-2 rounded block text-sm">
24
+ state-machine run my-workflow --remote
25
+ </code>
26
+ </li>
27
+
28
+ <li class="flex gap-3 mt-4">
29
+ <span class="text-zinc-500 font-mono">2.</span>
30
+ <span>The CLI will print a unique URL. Share it with anyone who needs to follow along or interact with the workflow.</span>
31
+ </li>
32
+
33
+ <li class="flex gap-3 mt-4">
34
+ <span class="text-zinc-500 font-mono">3.</span>
35
+ <span>Open the URL in a browser to see live workflow events and submit interaction responses.</span>
36
+ </li>
37
+ </ol>
38
+ </div>
39
+
40
+ <p class="text-zinc-500 text-sm mt-8">
41
+ Learn more at <a href="https://github.com/anthropics/agent-state-machine" class="text-cyan-400 hover:underline">GitHub</a>
42
+ </p>
43
+ </div>
44
+ </body>
45
+ </html>