agent-state-machine 1.4.2 → 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/README.md CHANGED
@@ -45,7 +45,7 @@ Requirements: Node.js >= 16.
45
45
  state-machine --setup <workflow-name>
46
46
  state-machine run <workflow-name>
47
47
 
48
- state-machine follow <workflow-name> (view prompt trace history in browser with live updates)
48
+
49
49
  state-machine history <workflow-name> [limit]
50
50
  state-machine reset <workflow-name> (clears memory/state)
51
51
  state-machine reset-hard <workflow-name> (clears everything: history/interactions/memory)
@@ -298,11 +298,7 @@ export const config = {
298
298
  };
299
299
  ```
300
300
 
301
- The runtime captures the fully-built prompt in `state/history.jsonl`, viewable in the browser with live updates via:
302
-
303
- ```bash
304
- state-machine follow <workflow-name>
305
- ```
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.
306
302
 
307
303
  ---
308
304
 
package/bin/cli.js CHANGED
@@ -5,7 +5,7 @@ import fs from 'fs';
5
5
  import { pathToFileURL, fileURLToPath } from 'url';
6
6
  import { WorkflowRuntime } from '../lib/index.js';
7
7
  import { setup } from '../lib/setup.js';
8
- import { startServer } from '../lib/ui/server.js';
8
+
9
9
  import { startLocalServer } from '../vercel-server/local-server.js';
10
10
 
11
11
  const __filename = fileURLToPath(import.meta.url);
@@ -34,8 +34,8 @@ Agent State Machine CLI (Native JS Workflows Only) v${getVersion()}
34
34
  Usage:
35
35
  state-machine --setup <workflow-name> Create a new workflow project
36
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)
38
- state-machine follow <workflow-name> View prompt trace history in browser with live updates
37
+ state-machine run <workflow-name> -l Run with local server (localhost:3000)
38
+
39
39
  state-machine status [workflow-name] Show current state (or list all)
40
40
  state-machine history <workflow-name> [limit] Show execution history logs
41
41
  state-machine reset <workflow-name> Reset workflow state (clears memory/state)
@@ -269,22 +269,7 @@ async function main() {
269
269
  }
270
270
  break;
271
271
 
272
- case 'follow':
273
- if (!workflowName) {
274
- console.error('Error: Workflow name required');
275
- console.error('Usage: state-machine follow <workflow-name>');
276
- process.exit(1);
277
- }
278
- {
279
- const workflowDir = resolveWorkflowDir(workflowName);
280
- if (!fs.existsSync(workflowDir)) {
281
- console.error(`Error: Workflow '${workflowName}' not found`);
282
- process.exit(1);
283
- }
284
- startServer(workflowDir);
285
- // Do not exit, server needs to stay alive
286
- }
287
- break;
272
+
288
273
 
289
274
  case 'reset':
290
275
  if (!workflowName) {
package/lib/ui/index.html CHANGED
@@ -4,7 +4,7 @@
4
4
  <head>
5
5
  <meta charset="UTF-8">
6
6
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
7
- <title>Workflow Prompts Viewer</title>
7
+ <title>{{WORKFLOW_NAME}} - Remote Follow</title>
8
8
  <script src="https://cdn.tailwindcss.com"></script>
9
9
  <script>
10
10
  tailwind.config = {
@@ -38,14 +38,36 @@
38
38
  .dark ::-webkit-scrollbar-thumb:hover {
39
39
  background: #3f3f46;
40
40
  }
41
+
42
+ @keyframes pulse {
43
+
44
+ 0%,
45
+ 100% {
46
+ opacity: 1;
47
+ }
48
+
49
+ 50% {
50
+ opacity: .7;
51
+ }
52
+ }
53
+
54
+ .animate-pulse-slow {
55
+ animation: pulse 3s cubic-bezier(0.4, 0, 0.6, 1) infinite;
56
+ }
41
57
  </style>
42
58
  </head>
43
59
 
44
60
  <body>
45
61
  <div id="root"></div>
46
62
 
63
+ <script>
64
+ // Placeholders replaced by server
65
+ window.SESSION_TOKEN = '{{SESSION_TOKEN}}';
66
+ window.WORKFLOW_NAME_TEMPLATE = '{{WORKFLOW_NAME}}';
67
+ </script>
68
+
47
69
  <script type="text/babel">
48
- const { useState, useEffect } = React;
70
+ const { useState, useEffect, useRef } = React;
49
71
 
50
72
  // Icons
51
73
  const SunIcon = () => (
@@ -72,6 +94,25 @@
72
94
  </svg>
73
95
  );
74
96
 
97
+ function StatusBadge({ status }) {
98
+ const colors = {
99
+ connected: 'bg-green-500',
100
+ disconnected: 'bg-red-500',
101
+ connecting: 'bg-yellow-500 animate-pulse-slow',
102
+ };
103
+ const labels = {
104
+ connected: 'Live',
105
+ disconnected: 'Offline',
106
+ connecting: 'Connecting...',
107
+ };
108
+ return (
109
+ <div className="flex items-center gap-2">
110
+ <div className={`w-2 h-2 rounded-full ${colors[status] || colors.disconnected}`}></div>
111
+ <span className="text-[10px] uppercase tracking-wider text-zinc-500 font-bold">{labels[status] || status}</span>
112
+ </div>
113
+ );
114
+ }
115
+
75
116
  function CopyButton({ text, className }) {
76
117
  const [copied, setCopied] = useState(false);
77
118
 
@@ -101,8 +142,7 @@
101
142
  const rawContent = isObject ? JSON.stringify(data, null, 2) : String(data);
102
143
  const lineBreak = '';
103
144
 
104
- // UPDATED: "clean" view loops through ALL top-level keys.
105
- // Headers render in CAPS, BOLD, and BLUE.
145
+ // "clean" view loops through ALL top-level keys.
106
146
  let cleanParts = null;
107
147
  if (isObject) {
108
148
  const keys = Object.keys(data);
@@ -117,7 +157,7 @@
117
157
  <div className="text-[11px] font-extrabold uppercase tracking-wider text-blue-600 dark:text-blue-400 mb-2">
118
158
  {k}
119
159
  </div>
120
- <div className="whitespace-pre-wrap">
160
+ <div className="whitespace-pre-wrap leading-relaxed">
121
161
  {prettyVal}
122
162
  </div>
123
163
  </div>
@@ -126,9 +166,7 @@
126
166
  }
127
167
  }
128
168
 
129
- // For RAW view we still render as a string
130
- const rawContentUnescaped = rawContent; // keep raw exact
131
-
169
+ const rawContentUnescaped = rawContent;
132
170
  const hasToggle = isObject || rawContent.includes('\\n');
133
171
 
134
172
  if (onTop) {
@@ -193,107 +231,272 @@
193
231
  );
194
232
  }
195
233
 
234
+ function InteractionForm({ interaction, onSubmit, disabled }) {
235
+ const [response, setResponse] = useState('');
236
+ const [submitting, setSubmitting] = useState(false);
237
+
238
+ const handleSubmit = async (e) => {
239
+ e.preventDefault();
240
+ if (!response.trim() || submitting) return;
241
+ setSubmitting(true);
242
+ try {
243
+ await onSubmit(interaction.slug, interaction.targetKey, response.trim());
244
+ setResponse('');
245
+ } finally {
246
+ setSubmitting(false);
247
+ }
248
+ };
249
+
250
+ return (
251
+ <div className="bg-yellow-100/50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-700/50 rounded-2xl p-6 mb-8 transition-all hover:border-yellow-300 dark:hover:border-yellow-600/50">
252
+ <div className="text-[10px] font-black uppercase tracking-[0.2em] text-yellow-600 dark:text-yellow-500 mb-3 flex items-center gap-2">
253
+ <span className="w-2 h-2 rounded-full bg-yellow-500 animate-pulse"></span>
254
+ Input Required
255
+ </div>
256
+ <div className="text-sm text-yellow-900 dark:text-yellow-100/90 mb-5 whitespace-pre-wrap leading-relaxed italic">
257
+ {interaction.question || 'Please provide your input.'}
258
+ </div>
259
+ <form onSubmit={handleSubmit} className="flex flex-col gap-4">
260
+ <textarea
261
+ value={response}
262
+ onChange={(e) => setResponse(e.target.value)}
263
+ className="w-full p-4 bg-white dark:bg-zinc-800 border border-yellow-200 dark:border-zinc-700 rounded-xl text-zinc-900 dark:text-zinc-100 placeholder-zinc-400 focus:outline-none focus:ring-1 focus:ring-yellow-500 transition-all min-h-[100px]"
264
+ placeholder="Type your response here..."
265
+ disabled={submitting || disabled}
266
+ />
267
+ <div className="flex justify-end items-center gap-4">
268
+ {disabled && <span className="text-[10px] uppercase font-bold text-red-500 tracking-wider">CLI Offline</span>}
269
+ <button
270
+ type="submit"
271
+ disabled={submitting || disabled || !response.trim()}
272
+ className="px-6 py-2.5 bg-yellow-500 hover:bg-yellow-600 text-white font-bold text-xs uppercase tracking-[0.15em] rounded-lg disabled:opacity-50 disabled:cursor-not-allowed transition-all shadow-sm"
273
+ >
274
+ {submitting ? 'Submitting...' : 'Submit Response'}
275
+ </button>
276
+ </div>
277
+ </form>
278
+ </div>
279
+ );
280
+ }
281
+
196
282
  function App() {
197
283
  const [history, setHistory] = useState([]);
198
284
  const [loading, setLoading] = useState(true);
199
285
  const [error, setError] = useState(null);
200
- const [workflowName, setWorkflowName] = useState('');
286
+ const [status, setStatus] = useState('connecting');
287
+ const [workflowName, setWorkflowName] = useState(window.WORKFLOW_NAME_TEMPLATE || '');
201
288
  const [theme, setTheme] = useState('dark');
202
- const [sortOrder, setSortOrder] = useState('newest'); // 'newest' | 'oldest'
289
+ const [sortNewest, setSortNewest] = useState(true);
290
+ const [pendingInteraction, setPendingInteraction] = useState(null);
291
+
292
+ const token = window.SESSION_TOKEN && window.SESSION_TOKEN !== '{{' + 'SESSION_TOKEN' + '}}' ? window.SESSION_TOKEN : null;
293
+
294
+ // Build API URLs
295
+ const historyUrl = token ? `/api/history/${token}` : '/api/history';
296
+ const eventsUrl = token ? `/api/events/${token}` : '/api/events';
297
+ const submitUrl = token ? `/api/submit/${token}` : '/api/submit';
203
298
 
299
+ // Detect pending interactions
204
300
  useEffect(() => {
205
- const fetchData = () => {
206
- fetch('/api/history')
207
- .then(res => res.json())
208
- .then(data => {
209
- setHistory(data.entries);
210
- setWorkflowName(data.workflowName);
211
- setLoading(false);
212
- })
213
- .catch(err => {
214
- setError(err.message);
215
- setLoading(false);
216
- });
217
- };
301
+ if (history.length === 0) {
302
+ setPendingInteraction(null);
303
+ return;
304
+ }
305
+
306
+ const resolvedSlugs = new Set();
307
+ let pending = null;
308
+
309
+ for (const entry of history) {
310
+ const isResolution = entry.event === 'INTERACTION_RESOLVED' ||
311
+ entry.event === 'PROMPT_ANSWERED' ||
312
+ entry.event === 'INTERACTION_SUBMITTED';
313
+ const isRequest = entry.event === 'INTERACTION_REQUESTED' ||
314
+ entry.event === 'PROMPT_REQUESTED';
315
+
316
+ if (isResolution && entry.slug) {
317
+ resolvedSlugs.add(entry.slug);
318
+ }
218
319
 
219
- // Initial fetch
320
+ if (isRequest && entry.slug && !resolvedSlugs.has(entry.slug) && !pending) {
321
+ pending = {
322
+ slug: entry.slug,
323
+ targetKey: entry.targetKey || `_interaction_${entry.slug}`,
324
+ question: entry.question,
325
+ };
326
+ }
327
+ }
328
+
329
+ setPendingInteraction(pending);
330
+ }, [history]);
331
+
332
+ const fetchData = async () => {
333
+ try {
334
+ const res = await fetch(historyUrl);
335
+ const data = await res.json();
336
+ if (data.entries) setHistory(data.entries);
337
+ if (data.workflowName) setWorkflowName(data.workflowName);
338
+
339
+ // In remote mode, the API also tells us connectivity
340
+ if (token && data.cliConnected !== undefined) {
341
+ setStatus(data.cliConnected ? 'connected' : 'disconnected');
342
+ } else if (!token) {
343
+ // Local mode is always "connected" if the page loads
344
+ setStatus('connected');
345
+ }
346
+
347
+ setLoading(false);
348
+ return true;
349
+ } catch (err) {
350
+ setError(err.message);
351
+ setLoading(false);
352
+ return false;
353
+ }
354
+ };
355
+
356
+ useEffect(() => {
220
357
  fetchData();
221
358
 
222
- // Setup SSE
223
- const eventSource = new EventSource('/api/events');
224
- eventSource.onmessage = (event) => {
225
- if (event.data === 'update') {
359
+ let eventSource = null;
360
+ let reconnectTimeout = null;
361
+ let pollInterval = null;
362
+ let reconnectAttempts = 0;
363
+
364
+ const connect = () => {
365
+ if (eventSource) eventSource.close();
366
+ eventSource = new EventSource(eventsUrl);
367
+
368
+ eventSource.onopen = () => {
369
+ setStatus('connected');
370
+ reconnectAttempts = 0;
226
371
  fetchData();
227
- }
372
+ };
373
+
374
+ eventSource.onerror = () => {
375
+ setStatus('disconnected');
376
+ eventSource.close();
377
+ const delay = Math.min(1000 * Math.pow(2, reconnectAttempts), 10000);
378
+ reconnectAttempts++;
379
+ reconnectTimeout = setTimeout(connect, delay);
380
+ };
381
+
382
+ eventSource.onmessage = (e) => {
383
+ try {
384
+ if (e.data === 'update') {
385
+ fetchData();
386
+ return;
387
+ }
388
+
389
+ const data = JSON.parse(e.data);
390
+ switch (data.type) {
391
+ case 'status':
392
+ setStatus(data.cliConnected ? 'connected' : 'disconnected');
393
+ if (data.workflowName) setWorkflowName(data.workflowName);
394
+ break;
395
+ case 'history': setHistory(data.entries || []); break;
396
+ case 'event':
397
+ setHistory(prev => {
398
+ if (data.event === 'INTERACTION_SUBMITTED' && data.slug) {
399
+ const hasDupe = prev.some(e =>
400
+ e.event === 'INTERACTION_SUBMITTED' && e.slug === data.slug
401
+ );
402
+ if (hasDupe) return prev;
403
+ }
404
+ return [data, ...prev];
405
+ });
406
+ break;
407
+ case 'cli_connected':
408
+ case 'cli_reconnected': setStatus('connected'); break;
409
+ case 'cli_disconnected': setStatus('disconnected'); break;
410
+ }
411
+ } catch (err) { /* Not JSON or update ping */ }
412
+ };
228
413
  };
229
414
 
415
+ connect();
416
+ pollInterval = setInterval(fetchData, 10000);
417
+
230
418
  return () => {
231
- eventSource.close();
419
+ if (eventSource) eventSource.close();
420
+ if (reconnectTimeout) clearTimeout(reconnectTimeout);
421
+ if (pollInterval) clearInterval(pollInterval);
232
422
  };
233
423
  }, []);
234
424
 
235
- const toggleTheme = () => {
236
- setTheme(prev => prev === 'dark' ? 'light' : 'dark');
237
- };
425
+ const handleSubmit = async (slug, targetKey, response) => {
426
+ const optimisticEvent = {
427
+ timestamp: new Date().toISOString(),
428
+ event: 'INTERACTION_SUBMITTED',
429
+ slug,
430
+ targetKey,
431
+ answer: response.substring(0, 200) + (response.length > 200 ? '...' : ''),
432
+ source: 'remote',
433
+ };
434
+ setHistory(prev => [optimisticEvent, ...prev]);
238
435
 
239
- const toggleSort = () => {
240
- setSortOrder(prev => prev === 'newest' ? 'oldest' : 'newest');
436
+ try {
437
+ const res = await fetch(submitUrl, {
438
+ method: 'POST',
439
+ headers: { 'Content-Type': 'application/json' },
440
+ body: JSON.stringify({ slug, targetKey, response }),
441
+ });
442
+ if (!res.ok) {
443
+ setHistory(prev => prev.filter(e => e !== optimisticEvent));
444
+ const error = await res.json();
445
+ throw new Error(error.error || 'Failed to submit');
446
+ }
447
+ setTimeout(fetchData, 1000);
448
+ } catch (err) {
449
+ setHistory(prev => prev.filter(e => e !== optimisticEvent));
450
+ alert(err.message);
451
+ }
241
452
  };
242
453
 
243
- if (loading) return (
244
- <div className={theme}>
245
- <div className="flex items-center justify-center h-screen bg-gray-50 dark:bg-black text-gray-500 dark:text-zinc-500">
246
- Loading history...
247
- </div>
248
- </div>
249
- );
454
+ const toggleTheme = () => setTheme(prev => prev === 'dark' ? 'light' : 'dark');
455
+ const toggleSort = () => setSortNewest(prev => !prev);
250
456
 
251
- if (error) return (
457
+ if (loading && !history.length) return (
252
458
  <div className={theme}>
253
- <div className="flex items-center justify-center h-screen bg-gray-50 dark:bg-black text-red-500">
254
- Error: {error}
459
+ <div className="flex items-center justify-center h-screen bg-gray-50 dark:bg-black text-zinc-500 uppercase tracking-widest text-[10px] font-black">
460
+ Opening Terminal...
255
461
  </div>
256
462
  </div>
257
463
  );
258
464
 
259
- // Filter for events we want to display - NOW INCLUDES EVERYTHING
260
- // We only filter out nulls or malformed entries if any
261
- let visibleEvents = history;
262
-
263
- // Apply Sort
264
- // History from API is "Newest First" (index 0 is latest)
265
- if (sortOrder === 'oldest') {
266
- visibleEvents = [...visibleEvents].reverse();
267
- }
268
-
465
+ let visibleEvents = sortNewest ? history : [...history].reverse();
269
466
  const formatTime = (ts) => new Date(ts).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
270
467
 
271
468
  return (
272
469
  <div className={theme}>
273
- <div className="min-h-screen bg-gray-50 dark:bg-black transition-colors duration-200">
274
- <div className="max-w-5xl mx-auto min-h-screen flex flex-col">
470
+ <div className="min-h-screen bg-gray-50 dark:bg-black transition-colors duration-500">
471
+ <div className="max-w-4xl mx-auto min-h-screen flex flex-col p-6">
275
472
 
276
473
  {/* Sticky Header */}
277
- <header className="sticky top-0 z-50 py-4 px-6 bg-gray-50/90 dark:bg-black/90 backdrop-blur-md border-b border-gray-200 dark:border-zinc-800 flex items-center justify-between mb-8 transition-colors">
278
- <div className="flex-1">
279
- <h1 className="text-xl font-bold text-gray-800 dark:text-zinc-100 transition-colors uppercase tracking-tight">{workflowName}</h1>
280
- <p className="text-gray-500 dark:text-zinc-500 text-xs mt-0.5">Runtimes History & Prompt Logs</p>
474
+ <header className="sticky top-0 z-50 py-6 bg-gray-50/90 dark:bg-black/90 backdrop-blur-md border-b border-gray-200 dark:border-zinc-800 flex items-center justify-between mb-8 transition-all">
475
+ <div>
476
+ <h1 className="text-xl font-black text-gray-800 dark:text-zinc-100 transition-colors uppercase tracking-tight">
477
+ {workflowName || 'Workflow'}
478
+ </h1>
479
+ <div className="flex items-center gap-3 mt-1.5">
480
+ <div className="text-zinc-400 dark:text-zinc-600 text-[10px] font-black uppercase tracking-widest">
481
+ {token ? 'Remote Follow' : 'Local Terminal'}
482
+ </div>
483
+ <div className="w-1 h-1 rounded-full bg-zinc-300 dark:bg-zinc-800"></div>
484
+ <StatusBadge status={status} />
485
+ </div>
281
486
  </div>
282
- <div className="flex items-center space-x-2">
487
+ <div className="flex items-center space-x-3">
283
488
  <button
284
489
  onClick={toggleSort}
285
- className="p-2 rounded-full bg-gray-200 dark:bg-zinc-900 text-gray-800 dark:text-zinc-200 hover:bg-gray-300 dark:hover:bg-zinc-800 transition-colors"
286
- title={sortOrder === 'newest' ? "Sort: Newest First" : "Sort: Oldest First"}
490
+ className="p-2.5 rounded-xl bg-gray-200 dark:bg-zinc-900 text-gray-600 dark:text-zinc-400 hover:text-blue-500 transition-all border border-transparent hover:border-gray-300 dark:hover:border-zinc-700"
491
+ title={sortNewest ? "Sort: Newest First" : "Sort: Oldest First"}
287
492
  >
288
- {sortOrder === 'newest' ?
289
- <svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 4h13M3 8h9m-9 4h6m4 0l4-4m0 0l4 4m-4-4v12" /></svg>
290
- :
291
- <svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 4h13M3 8h9m-9 4h6m4 0l4-4m0 0l4 4m-4-4v12" transform="scale(1, -1) translate(0, -24)" /></svg>
292
- }
493
+ <svg xmlns="http://www.w3.org/2000/svg" className={`h-5 w-5 transition-transform ${!sortNewest ? 'rotate-180' : ''}`} fill="none" viewBox="0 0 24 24" stroke="currentColor">
494
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 4h13M3 8h9m-9 4h6m4 0l4-4m0 0l4 4m-4-4v12" />
495
+ </svg>
293
496
  </button>
294
497
  <button
295
498
  onClick={toggleTheme}
296
- className="p-2 rounded-full bg-gray-200 dark:bg-zinc-900 text-gray-800 dark:text-zinc-200 hover:bg-gray-300 dark:hover:bg-zinc-800 transition-colors"
499
+ className="p-2.5 rounded-xl bg-gray-200 dark:bg-zinc-900 text-gray-600 dark:text-zinc-400 hover:text-blue-500 transition-all border border-transparent hover:border-gray-300 dark:hover:border-zinc-700"
297
500
  title="Toggle Theme"
298
501
  >
299
502
  {theme === 'dark' ? <SunIcon /> : <MoonIcon />}
@@ -301,10 +504,30 @@
301
504
  </div>
302
505
  </header>
303
506
 
507
+ {/* Pending Interaction at Top */}
508
+ {pendingInteraction && (
509
+ <InteractionForm
510
+ interaction={pendingInteraction}
511
+ onSubmit={handleSubmit}
512
+ disabled={status !== 'connected'}
513
+ />
514
+ )}
515
+
516
+ {/* Disconnected Warning */}
517
+ {status === 'disconnected' && !pendingInteraction && (
518
+ <div className="bg-red-50 dark:bg-red-900/10 border border-red-200 dark:border-red-900/40 rounded-2xl p-4 mb-8 text-center">
519
+ <div className="text-[10px] font-black uppercase tracking-widest text-red-600 dark:text-red-500">
520
+ Terminal Connection Lost &bull; Retrying...
521
+ </div>
522
+ </div>
523
+ )}
524
+
304
525
  {/* Content */}
305
- <div className="flex-1 space-y-8 px-6">
526
+ <div className="flex-1 space-y-12">
306
527
  {visibleEvents.length === 0 && (
307
- <div className="text-center text-gray-400 py-10">No execution history found.</div>
528
+ <div className="text-center text-zinc-400 dark:text-zinc-700 py-20 uppercase text-[10px] font-bold tracking-[0.3em]">
529
+ Waiting for events...
530
+ </div>
308
531
  )}
309
532
 
310
533
  {visibleEvents.map((item, idx) => {
@@ -312,19 +535,21 @@
312
535
  if (item.event.startsWith('WORKFLOW_')) {
313
536
  const colorMap = {
314
537
  'WORKFLOW_STARTED': 'text-green-500 dark:text-green-400',
315
- 'WORKFLOW_COMPLETED': 'text-blue-500 dark:text-blue-400',
538
+ 'WORKFLOW_COMPLETED': 'text-blue-510 dark:text-blue-400',
316
539
  'WORKFLOW_FAILED': 'text-red-500 dark:text-red-400',
317
540
  'WORKFLOW_RESET': 'text-yellow-500 dark:text-yellow-400'
318
541
  };
319
542
  return (
320
543
  <div key={idx} className="flex flex-col items-center py-4">
321
- <div className="flex items-center space-x-3 text-[10px] uppercase tracking-[0.2em] font-bold text-zinc-400 dark:text-zinc-600">
322
- <div className="h-px w-8 bg-zinc-200 dark:bg-zinc-800"></div>
323
- <span className={colorMap[item.event]}>{item.event.replace('_', ' ')}</span>
324
- <span>{formatTime(item.timestamp)}</span>
325
- <div className="h-px w-8 bg-zinc-200 dark:bg-zinc-800"></div>
544
+ <div className="flex items-center space-x-4 text-[10px] uppercase tracking-[0.25em] font-black text-zinc-300 dark:text-zinc-800">
545
+ <div className="h-px w-10 bg-current opacity-20"></div>
546
+ <span className={`${colorMap[item.event] || 'text-zinc-500'} dark:opacity-80`}>
547
+ {item.event.replace('WORKFLOW_', '')}
548
+ </span>
549
+ <span className="text-zinc-400 dark:text-zinc-700 font-medium tracking-normal">{formatTime(item.timestamp)}</span>
550
+ <div className="h-px w-10 bg-current opacity-20"></div>
326
551
  </div>
327
- {item.error && <div className="mt-2 text-red-500 text-xs font-mono">{item.error}</div>}
552
+ {item.error && <div className="mt-2 text-red-500 text-xs font-mono max-w-md text-center">{item.error}</div>}
328
553
  </div>
329
554
  );
330
555
  }
@@ -333,9 +558,9 @@
333
558
  if (item.event === 'AGENT_STARTED') {
334
559
  return (
335
560
  <div key={idx} className="flex justify-start">
336
- <div className="text-[10px] text-zinc-400 dark:text-zinc-600 uppercase tracking-widest flex items-center space-x-2">
561
+ <div className="text-[10px] text-zinc-400 dark:text-zinc-600 uppercase tracking-widest font-bold flex items-center space-x-2">
337
562
  <span className="w-1.5 h-1.5 rounded-full bg-blue-500/50 animate-pulse"></span>
338
- <span>Agent <span className="text-zinc-600 dark:text-zinc-400">{item.agent}</span> started</span>
563
+ <span>Agent <span className="text-zinc-800 dark:text-zinc-300">{item.agent}</span> started</span>
339
564
  <span>&bull;</span>
340
565
  <span>{formatTime(item.timestamp)}</span>
341
566
  </div>
@@ -347,9 +572,9 @@
347
572
  if (item.event === 'AGENT_FAILED') {
348
573
  return (
349
574
  <div key={idx} className="flex justify-center">
350
- <div className="bg-red-50 dark:bg-red-950/20 border border-red-200 dark:border-red-900/50 rounded-lg px-4 py-2 text-red-600 dark:text-red-400 text-xs font-mono w-full max-w-2xl">
351
- <div className="font-bold mb-1 underline">AGENT FAILED: {item.agent}</div>
352
- <div>{item.error}</div>
575
+ <div className="bg-red-50 dark:bg-red-950/20 border border-red-200 dark:border-red-900/50 rounded-2xl px-6 py-4 text-red-600 dark:text-red-400 text-xs font-mono w-full max-w-2xl shadow-sm">
576
+ <div className="font-black mb-2 uppercase tracking-tight">AGENT FAILED: {item.agent}</div>
577
+ <div className="leading-relaxed opacity-80">{item.error}</div>
353
578
  </div>
354
579
  </div>
355
580
  );
@@ -357,14 +582,14 @@
357
582
 
358
583
  // 4. Interaction / Prompt Requested
359
584
  if (item.event === 'INTERACTION_REQUESTED' || item.event === 'PROMPT_REQUESTED') {
360
- const isPrompt = item.event === 'PROMPT_REQUESTED';
361
585
  return (
362
- <div key={idx} className="flex justify-center">
363
- <div className="bg-zinc-100 dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-800 border-dashed rounded-lg px-6 py-4 text-center max-w-md w-full">
364
- <div className="text-[10px] text-zinc-400 dark:text-zinc-600 uppercase font-bold tracking-widest mb-1">
365
- {isPrompt ? 'User Input Requested' : 'Human Intervention Needed'}
586
+ <div key={idx} className="flex justify-center animate-in fade-in slide-in-from-bottom-2">
587
+ <div className="bg-zinc-100 dark:bg-zinc-900/50 border border-zinc-200 dark:border-zinc-800 border-dashed rounded-2xl px-8 py-6 text-center max-w-md w-full">
588
+ <div className="text-[10px] text-zinc-400 dark:text-zinc-600 uppercase font-black tracking-[0.2em] mb-2 flex items-center justify-center gap-2">
589
+ <span className="w-1 h-1 rounded-full bg-yellow-500"></span>
590
+ Intervention Required
366
591
  </div>
367
- <div className="text-xs text-zinc-600 dark:text-zinc-400 italic">
592
+ <div className="text-xs text-zinc-600 dark:text-zinc-400 italic font-medium leading-relaxed">
368
593
  {item.question ? `"${item.question}"` : `Waiting for response to "${item.slug}"...`}
369
594
  </div>
370
595
  </div>
@@ -373,14 +598,15 @@
373
598
  }
374
599
 
375
600
  // 5. Prompt Answered
376
- if (item.event === 'PROMPT_ANSWERED') {
601
+ if (item.event === 'PROMPT_ANSWERED' || item.event === 'INTERACTION_SUBMITTED') {
602
+ const isManual = item.source === 'remote';
377
603
  return (
378
604
  <div key={idx} className="flex justify-center">
379
- <div className="bg-green-50 dark:bg-green-950/20 border border-green-200 dark:border-green-900/50 rounded-lg px-4 py-2 text-center max-w-md w-full">
380
- <div className="text-[10px] text-green-600 dark:text-green-400 uppercase font-bold tracking-widest mb-1">
381
- User Answered
605
+ <div className="bg-green-50/50 dark:bg-green-950/10 border border-green-200/50 dark:border-green-900/30 rounded-2xl px-6 py-3 text-center max-w-md w-full">
606
+ <div className="text-[9px] text-green-600 dark:text-green-500 uppercase font-black tracking-widest mb-1">
607
+ {isManual ? 'Resolved via Browser' : 'User Answered'}
382
608
  </div>
383
- <div className="text-xs text-green-700 dark:text-green-300 italic font-medium">
609
+ <div className="text-xs text-green-800 dark:text-green-300 italic font-bold">
384
610
  "{item.answer}"
385
611
  </div>
386
612
  </div>
@@ -388,20 +614,19 @@
388
614
  );
389
615
  }
390
616
 
391
- // 6. Agent Completed / Interaction Resolved (The Bubbles)
617
+ // 6. Agent Completed / Interaction Resolved
392
618
  if (item.event === 'AGENT_COMPLETED' || item.event === 'INTERACTION_RESOLVED') {
393
619
  return (
394
- <div key={idx} className="flex flex-col space-y-4">
620
+ <div key={idx} className="flex flex-col space-y-6">
395
621
  {/* Header Line */}
396
- <div className="flex items-center justify-center space-x-2 text-[10px] text-zinc-400 dark:text-zinc-600 uppercase tracking-widest">
397
- <span className="font-black text-zinc-500 dark:text-zinc-400">{item.agent || item.slug}</span>
398
- <span>&bull;</span>
399
- <span>COMPLETED</span>
400
- <span>&bull;</span>
401
- <span>{formatTime(item.timestamp)}</span>
622
+ <div className="flex items-center justify-center space-x-3 text-[10px] text-zinc-300 dark:text-zinc-800 uppercase tracking-widest font-black">
623
+ <div className="h-px flex-1 bg-current opacity-20"></div>
624
+ <span className="text-zinc-500 dark:text-zinc-400">{item.agent || item.slug}</span>
625
+ <span className="text-zinc-400 dark:text-zinc-700 font-medium">DONE &bull; {formatTime(item.timestamp)}</span>
626
+ <div className="h-px flex-1 bg-current opacity-20"></div>
402
627
  </div>
403
628
 
404
- {/* Output (Response) - NOW ON TOP */}
629
+ {/* Output (Response) - ON TOP */}
405
630
  {(item.output || item.result) && (
406
631
  <JsonView
407
632
  data={item.output || item.result}
@@ -410,13 +635,13 @@
410
635
  />
411
636
  )}
412
637
 
413
- {/* Prompt (Input) - NOW ON BOTTOM */}
638
+ {/* Prompt (Input) - ON BOTTOM */}
414
639
  {item.prompt && (
415
640
  <div className="flex justify-start w-full group">
416
- <div className="max-w-[85%] bg-white dark:bg-zinc-900 border border-gray-200 dark:border-zinc-800 rounded-2xl rounded-tl-none shadow-sm p-6 transition-all hover:border-zinc-300 dark:hover:border-zinc-700 relative">
417
- <div className="flex justify-between items-center mb-3">
418
- <div className="text-[9px] font-black text-zinc-300 dark:text-zinc-700 uppercase tracking-[0.2em]">Prompt / Input</div>
419
- <div className="absolute top-4 right-4 opacity-0 group-hover:opacity-100 transition-opacity">
641
+ <div className="max-w-[85%] bg-white dark:bg-zinc-900 border border-gray-200 dark:border-zinc-800 rounded-2xl rounded-tl-none shadow-sm p-8 transition-all hover:border-zinc-300 dark:hover:border-zinc-700 relative">
642
+ <div className="flex justify-between items-center mb-6">
643
+ <div className="text-[9px] font-black text-zinc-300 dark:text-zinc-700 uppercase tracking-[0.3em]">Prompt / Input</div>
644
+ <div className="absolute top-6 right-6 opacity-0 group-hover:opacity-100 transition-opacity">
420
645
  <CopyButton text={item.prompt} className="text-gray-400 hover:text-gray-600 dark:text-zinc-600 dark:hover:text-zinc-400" />
421
646
  </div>
422
647
  </div>
@@ -430,12 +655,12 @@
430
655
  );
431
656
  }
432
657
 
433
- // 7. CATCH-ALL for Unknown Events
658
+ // 7. CATCH-ALL
434
659
  return (
435
660
  <div key={idx} className="flex justify-center px-4">
436
661
  <JsonView
437
662
  data={JSON.parse(JSON.stringify(item, (key, value) => {
438
- if (key === 'event' || key === 'timestamp') return undefined; // Keep it clean
663
+ if (key === 'event' || key === 'timestamp') return undefined;
439
664
  return value;
440
665
  }))}
441
666
  label={item.event}
@@ -446,8 +671,8 @@
446
671
  })}
447
672
  </div>
448
673
 
449
- <footer className="mt-20 mb-8 text-center text-zinc-400 dark:text-zinc-800 text-[10px] uppercase tracking-[0.3em] transition-colors">
450
- Agent State Machine &bull; Debug Terminal
674
+ <footer className="mt-32 mb-12 text-center text-zinc-300 dark:text-zinc-800 text-[10px] font-black uppercase tracking-[0.4em] transition-colors">
675
+ SUPAMACHINE &bull; Terminal v1.4
451
676
  </footer>
452
677
  </div>
453
678
  </div>
@@ -460,4 +685,4 @@
460
685
  </script>
461
686
  </body>
462
687
 
463
- </html>
688
+ </html>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-state-machine",
3
- "version": "1.4.2",
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",
@@ -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, '..', '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
+
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>
package/lib/ui/server.js DELETED
@@ -1,150 +0,0 @@
1
- /**
2
- * File: /lib/ui/server.js
3
- */
4
-
5
- import http from 'http';
6
- import fs from 'fs';
7
- import path from 'path';
8
- import { fileURLToPath } from 'url';
9
-
10
- const __filename = fileURLToPath(import.meta.url);
11
- const __dirname = path.dirname(__filename);
12
-
13
- export function startServer(workflowDir, initialPort = 3000) {
14
- const clients = new Set();
15
- const stateDir = path.join(workflowDir, 'state');
16
-
17
- // Watch for changes in the state directory
18
- // We debounce slightly to avoid sending multiple events for a single write burst
19
- let debounceTimer;
20
- const broadcastUpdate = () => {
21
- if (debounceTimer) clearTimeout(debounceTimer);
22
- debounceTimer = setTimeout(() => {
23
- const msg = 'data: update\n\n';
24
- for (const client of clients) {
25
- try {
26
- client.write(msg);
27
- } catch (e) {
28
- clients.delete(client);
29
- }
30
- }
31
- }, 100);
32
- };
33
-
34
- try {
35
- if (fs.existsSync(stateDir)) {
36
- fs.watch(stateDir, (eventType, filename) => {
37
- if (filename && (filename === 'history.jsonl' || filename.startsWith('history'))) {
38
- broadcastUpdate();
39
- }
40
- });
41
- } else {
42
- console.warn('Warning: State directory does not exist yet. Live updates might not work until it is created.');
43
- }
44
- } catch (err) {
45
- console.warn('Warning: Failed to setup file watcher:', err.message);
46
- }
47
-
48
- // Request Handler
49
- const requestHandler = (req, res) => {
50
- // Serve the main HTML page
51
- if (req.url === '/' || req.url === '/index.html') {
52
- const htmlPath = path.join(__dirname, 'index.html');
53
- fs.readFile(htmlPath, (err, content) => {
54
- if (err) {
55
- res.writeHead(500);
56
- res.end('Error loading UI');
57
- return;
58
- }
59
- res.writeHead(200, { 'Content-Type': 'text/html' });
60
- res.end(content);
61
- });
62
- return;
63
- }
64
-
65
- // Server-Sent Events endpoint
66
- if (req.url === '/api/events') {
67
- res.writeHead(200, {
68
- 'Content-Type': 'text/event-stream',
69
- 'Cache-Control': 'no-cache',
70
- 'Connection': 'keep-alive',
71
- });
72
- res.write('retry: 10000\n\n');
73
-
74
- clients.add(res);
75
-
76
- req.on('close', () => {
77
- clients.delete(res);
78
- });
79
- return;
80
- }
81
-
82
- // Serve API
83
- if (req.url === '/api/history') {
84
- const historyFile = path.join(stateDir, 'history.jsonl');
85
-
86
- if (!fs.existsSync(historyFile)) {
87
- res.writeHead(200, { 'Content-Type': 'application/json' });
88
- res.end(JSON.stringify({
89
- workflowName: path.basename(workflowDir),
90
- entries: []
91
- }));
92
- return;
93
- }
94
-
95
- try {
96
- const fileContent = fs.readFileSync(historyFile, 'utf-8');
97
- const lines = fileContent.trim().split('\n');
98
- const entries = lines
99
- .map(line => {
100
- try { return JSON.parse(line); } catch { return null; }
101
- })
102
- .filter(Boolean);
103
-
104
- res.writeHead(200, { 'Content-Type': 'application/json' });
105
- res.end(JSON.stringify({
106
- workflowName: path.basename(workflowDir),
107
- entries
108
- }));
109
- } catch (err) {
110
- res.writeHead(500, { 'Content-Type': 'application/json' });
111
- res.end(JSON.stringify({ error: err.message }));
112
- }
113
- return;
114
- }
115
-
116
- // 404
117
- res.writeHead(404);
118
- res.end('Not found');
119
- };
120
-
121
- // Port hunting logic
122
- let port = initialPort;
123
- const maxPort = initialPort + 100; // Try up to 100 ports
124
-
125
- const attemptServer = () => {
126
- const server = http.createServer(requestHandler);
127
-
128
- server.on('error', (e) => {
129
- if (e.code === 'EADDRINUSE') {
130
- if (port < maxPort) {
131
- console.log(`Port ${port} is in use, trying ${port + 1}...`);
132
- port++;
133
- attemptServer();
134
- } else {
135
- console.error(`Error: Could not find an open port between ${initialPort} and ${maxPort}.`);
136
- }
137
- } else {
138
- console.error('Server error:', e);
139
- }
140
- });
141
-
142
- server.listen(port, () => {
143
- console.log(`\n> Follow UI running at http://localhost:${port}`);
144
- console.log(`> Viewing history for: ${workflowDir}`);
145
- console.log(`> Press Ctrl+C to stop`);
146
- });
147
- };
148
-
149
- attemptServer();
150
- }