agent-state-machine 1.4.2 → 2.0.1

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.
@@ -342,6 +342,35 @@ async function handleSubmitPost(req, res, token) {
342
342
  /**
343
343
  * Serve session UI
344
344
  */
345
+ const MASTER_TEMPLATE_PATH = path.join(__dirname, '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
+
345
374
  function serveSessionUI(res, token) {
346
375
  const session = getSession(token);
347
376
 
@@ -359,340 +388,12 @@ function serveSessionUI(res, token) {
359
388
  `);
360
389
  }
361
390
 
362
- // Read the session UI template from api/session/[token].js and extract HTML
363
- // For simplicity, serve a standalone version
364
391
  const html = getSessionHTML(token, session.workflowName);
365
392
  res.writeHead(200, { 'Content-Type': 'text/html' });
366
393
  res.end(html);
367
394
  }
368
395
 
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
- }
396
+ // getSessionHTML was moved up and updated to read from MASTER_TEMPLATE_PATH
696
397
 
697
398
  /**
698
399
  * Serve static files
@@ -17,28 +17,40 @@
17
17
  <ol class="space-y-4 text-zinc-300">
18
18
  <li class="flex gap-3">
19
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>
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>
21
33
  </li>
22
34
  <li class="pl-6">
23
35
  <code class="bg-zinc-800 px-3 py-2 rounded block text-sm">
24
- state-machine run my-workflow --remote
36
+ npx state-machine run my-workflow
25
37
  </code>
26
38
  </li>
27
39
 
28
40
  <li class="flex gap-3 mt-4">
29
- <span class="text-zinc-500 font-mono">2.</span>
41
+ <span class="text-zinc-500 font-mono">4.</span>
30
42
  <span>The CLI will print a unique URL. Share it with anyone who needs to follow along or interact with the workflow.</span>
31
43
  </li>
32
44
 
33
45
  <li class="flex gap-3 mt-4">
34
- <span class="text-zinc-500 font-mono">3.</span>
46
+ <span class="text-zinc-500 font-mono">5.</span>
35
47
  <span>Open the URL in a browser to see live workflow events and submit interaction responses.</span>
36
48
  </li>
37
49
  </ol>
38
50
  </div>
39
51
 
40
52
  <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>
53
+ Learn more at <a href="https://github.com/superbasicman/state-machine" target="_blank" class="text-cyan-400 hover:underline">GitHub</a>
42
54
  </p>
43
55
  </div>
44
56
  </body>