agent-state-machine 1.4.1 → 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 +36 -12
- package/lib/runtime/prompt.js +1 -0
- package/package.json +4 -2
- package/vercel-server/local-server.js +846 -0
- package/vercel-server/public/index.html +45 -0
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);
|
|
@@ -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>
|
|
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
|
-
--
|
|
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
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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
|
-
//
|
|
174
|
-
if (
|
|
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> [--
|
|
230
|
+
console.error(`Usage: state-machine ${command} <workflow-name> [--local]`);
|
|
209
231
|
process.exit(1);
|
|
210
232
|
}
|
|
211
233
|
{
|
|
212
|
-
|
|
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);
|
package/lib/runtime/prompt.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-state-machine",
|
|
3
|
-
"version": "1.4.
|
|
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>
|