agent-state-machine 1.3.3 → 1.4.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.
- package/bin/cli.js +32 -9
- package/lib/remote/client.js +252 -0
- package/lib/runtime/prompt.js +65 -6
- package/lib/runtime/runtime.js +208 -25
- package/package.json +1 -1
package/bin/cli.js
CHANGED
|
@@ -23,13 +23,16 @@ function getVersion() {
|
|
|
23
23
|
}
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
// Default remote server URL (can be overridden with STATE_MACHINE_REMOTE_URL env var)
|
|
27
|
+
const DEFAULT_REMOTE_URL = process.env.STATE_MACHINE_REMOTE_URL || 'https://supamachine.vercel.app';
|
|
28
|
+
|
|
26
29
|
function printHelp() {
|
|
27
30
|
console.log(`
|
|
28
31
|
Agent State Machine CLI (Native JS Workflows Only) v${getVersion()}
|
|
29
32
|
|
|
30
33
|
Usage:
|
|
31
34
|
state-machine --setup <workflow-name> Create a new workflow project
|
|
32
|
-
state-machine run <workflow-name>
|
|
35
|
+
state-machine run <workflow-name> [--remote] Run a workflow (--remote enables remote follow)
|
|
33
36
|
state-machine follow <workflow-name> View prompt trace history in browser with live updates
|
|
34
37
|
state-machine status [workflow-name] Show current state (or list all)
|
|
35
38
|
state-machine history <workflow-name> [limit] Show execution history logs
|
|
@@ -40,9 +43,13 @@ Usage:
|
|
|
40
43
|
|
|
41
44
|
Options:
|
|
42
45
|
--setup, -s Initialize a new workflow with directory structure
|
|
46
|
+
--remote, -r Enable remote follow (generates shareable URL for browser access)
|
|
43
47
|
--help, -h Show help
|
|
44
48
|
--version, -v Show version
|
|
45
49
|
|
|
50
|
+
Environment Variables:
|
|
51
|
+
STATE_MACHINE_REMOTE_URL Override the default remote server URL
|
|
52
|
+
|
|
46
53
|
Workflow Structure:
|
|
47
54
|
workflows/<name>/
|
|
48
55
|
├── workflow.js # Native JS workflow (async/await)
|
|
@@ -136,7 +143,7 @@ function listWorkflows() {
|
|
|
136
143
|
console.log('');
|
|
137
144
|
}
|
|
138
145
|
|
|
139
|
-
async function runOrResume(workflowName) {
|
|
146
|
+
async function runOrResume(workflowName, { remoteEnabled = false } = {}) {
|
|
140
147
|
const workflowDir = resolveWorkflowDir(workflowName);
|
|
141
148
|
|
|
142
149
|
if (!fs.existsSync(workflowDir)) {
|
|
@@ -154,7 +161,20 @@ async function runOrResume(workflowName) {
|
|
|
154
161
|
const runtime = new WorkflowRuntime(workflowDir);
|
|
155
162
|
const workflowUrl = pathToFileURL(entry).href;
|
|
156
163
|
|
|
157
|
-
|
|
164
|
+
// Enable remote follow mode if requested
|
|
165
|
+
if (remoteEnabled) {
|
|
166
|
+
const remoteUrl = process.env.STATE_MACHINE_REMOTE_URL || DEFAULT_REMOTE_URL;
|
|
167
|
+
await runtime.enableRemote(remoteUrl);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
try {
|
|
171
|
+
await runtime.runWorkflow(workflowUrl);
|
|
172
|
+
} finally {
|
|
173
|
+
// Always disable remote on completion
|
|
174
|
+
if (remoteEnabled) {
|
|
175
|
+
await runtime.disableRemote();
|
|
176
|
+
}
|
|
177
|
+
}
|
|
158
178
|
}
|
|
159
179
|
|
|
160
180
|
async function main() {
|
|
@@ -185,14 +205,17 @@ async function main() {
|
|
|
185
205
|
case 'run':
|
|
186
206
|
if (!workflowName) {
|
|
187
207
|
console.error('Error: Workflow name required');
|
|
188
|
-
console.error(`Usage: state-machine ${command} <workflow-name
|
|
208
|
+
console.error(`Usage: state-machine ${command} <workflow-name> [--remote]`);
|
|
189
209
|
process.exit(1);
|
|
190
210
|
}
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
211
|
+
{
|
|
212
|
+
const remoteEnabled = args.includes('--remote') || args.includes('-r');
|
|
213
|
+
try {
|
|
214
|
+
await runOrResume(workflowName, { remoteEnabled });
|
|
215
|
+
} catch (err) {
|
|
216
|
+
console.error('Error:', err.message || String(err));
|
|
217
|
+
process.exit(1);
|
|
218
|
+
}
|
|
196
219
|
}
|
|
197
220
|
break;
|
|
198
221
|
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File: /lib/remote/client.js
|
|
3
|
+
*
|
|
4
|
+
* RemoteClient - HTTP client for connecting to the remote follow server
|
|
5
|
+
* Uses HTTP POST for sending events and long-polling for receiving interactions
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import crypto from 'crypto';
|
|
9
|
+
import http from 'http';
|
|
10
|
+
import https from 'https';
|
|
11
|
+
|
|
12
|
+
// ANSI Colors
|
|
13
|
+
const C = {
|
|
14
|
+
reset: '\x1b[0m',
|
|
15
|
+
bold: '\x1b[1m',
|
|
16
|
+
dim: '\x1b[2m',
|
|
17
|
+
red: '\x1b[31m',
|
|
18
|
+
green: '\x1b[32m',
|
|
19
|
+
yellow: '\x1b[33m',
|
|
20
|
+
cyan: '\x1b[36m'
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Generate a cryptographically secure session token
|
|
25
|
+
* @returns {string} 32-byte base64url-encoded token
|
|
26
|
+
*/
|
|
27
|
+
export function generateSessionToken() {
|
|
28
|
+
return crypto.randomBytes(32).toString('base64url');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Make an HTTP/HTTPS request
|
|
33
|
+
*/
|
|
34
|
+
function makeRequest(url, options, body = null) {
|
|
35
|
+
return new Promise((resolve, reject) => {
|
|
36
|
+
const parsedUrl = new URL(url);
|
|
37
|
+
const client = parsedUrl.protocol === 'https:' ? https : http;
|
|
38
|
+
|
|
39
|
+
const req = client.request(parsedUrl, options, (res) => {
|
|
40
|
+
let data = '';
|
|
41
|
+
|
|
42
|
+
res.on('data', (chunk) => {
|
|
43
|
+
data += chunk;
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
res.on('end', () => {
|
|
47
|
+
let parsedData = null;
|
|
48
|
+
if (data) {
|
|
49
|
+
try {
|
|
50
|
+
parsedData = JSON.parse(data);
|
|
51
|
+
} catch {
|
|
52
|
+
// Response is not JSON (could be HTML error page)
|
|
53
|
+
parsedData = { error: data.substring(0, 200) };
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
resolve({
|
|
57
|
+
status: res.statusCode,
|
|
58
|
+
data: parsedData,
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
req.on('error', reject);
|
|
64
|
+
|
|
65
|
+
if (body) {
|
|
66
|
+
req.write(JSON.stringify(body));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
req.end();
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* RemoteClient class - manages HTTP connection to remote server
|
|
75
|
+
*/
|
|
76
|
+
export class RemoteClient {
|
|
77
|
+
/**
|
|
78
|
+
* @param {object} options
|
|
79
|
+
* @param {string} options.serverUrl - Base URL of remote server (e.g., https://example.vercel.app)
|
|
80
|
+
* @param {string} options.workflowName - Name of the workflow
|
|
81
|
+
* @param {function} options.onInteractionResponse - Callback when interaction response received
|
|
82
|
+
* @param {function} [options.onStatusChange] - Callback when connection status changes
|
|
83
|
+
*/
|
|
84
|
+
constructor(options) {
|
|
85
|
+
this.serverUrl = options.serverUrl.replace(/\/$/, ''); // Remove trailing slash
|
|
86
|
+
this.workflowName = options.workflowName;
|
|
87
|
+
this.onInteractionResponse = options.onInteractionResponse;
|
|
88
|
+
this.onStatusChange = options.onStatusChange || (() => {});
|
|
89
|
+
|
|
90
|
+
this.sessionToken = generateSessionToken();
|
|
91
|
+
this.connected = false;
|
|
92
|
+
this.polling = false;
|
|
93
|
+
this.pollAbortController = null;
|
|
94
|
+
this.initialHistorySent = false;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Get the full remote URL for browser access
|
|
99
|
+
*/
|
|
100
|
+
getRemoteUrl() {
|
|
101
|
+
return `${this.serverUrl}/s/${this.sessionToken}`;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Connect to the remote server
|
|
106
|
+
* @returns {Promise<void>}
|
|
107
|
+
*/
|
|
108
|
+
async connect() {
|
|
109
|
+
try {
|
|
110
|
+
// Test connection by making a simple request
|
|
111
|
+
const testUrl = `${this.serverUrl}/api/history/${this.sessionToken}`;
|
|
112
|
+
const response = await makeRequest(testUrl, { method: 'GET' });
|
|
113
|
+
|
|
114
|
+
// 404 is expected for new sessions
|
|
115
|
+
if (response.status !== 404 && response.status !== 200) {
|
|
116
|
+
throw new Error(`Server returned ${response.status}`);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
this.connected = true;
|
|
120
|
+
this.onStatusChange('connected');
|
|
121
|
+
|
|
122
|
+
// Start polling for interactions
|
|
123
|
+
this.startPolling();
|
|
124
|
+
|
|
125
|
+
} catch (err) {
|
|
126
|
+
console.log(`${C.yellow}Warning: Could not connect to remote server: ${err.message}${C.reset}`);
|
|
127
|
+
// Don't fail - remote is optional
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Send a message to the server via HTTP POST
|
|
133
|
+
*/
|
|
134
|
+
async send(msg) {
|
|
135
|
+
if (!this.connected) {
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
try {
|
|
140
|
+
const url = `${this.serverUrl}/api/ws/cli`;
|
|
141
|
+
await makeRequest(url, {
|
|
142
|
+
method: 'POST',
|
|
143
|
+
headers: {
|
|
144
|
+
'Content-Type': 'application/json',
|
|
145
|
+
},
|
|
146
|
+
}, msg);
|
|
147
|
+
} catch (err) {
|
|
148
|
+
console.error(`${C.dim}Remote: Failed to send message: ${err.message}${C.reset}`);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Send initial session info with history
|
|
154
|
+
* @param {Array} history - Array of history entries
|
|
155
|
+
*/
|
|
156
|
+
async sendSessionInit(history = []) {
|
|
157
|
+
this.initialHistorySent = true;
|
|
158
|
+
await this.send({
|
|
159
|
+
type: 'session_init',
|
|
160
|
+
sessionToken: this.sessionToken,
|
|
161
|
+
workflowName: this.workflowName,
|
|
162
|
+
history,
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Send an event to the server
|
|
168
|
+
* @param {object} event - Event object with timestamp, event type, etc.
|
|
169
|
+
*/
|
|
170
|
+
async sendEvent(event) {
|
|
171
|
+
// Only send events after initial history has been sent
|
|
172
|
+
if (!this.initialHistorySent) {
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
await this.send({
|
|
177
|
+
type: 'event',
|
|
178
|
+
sessionToken: this.sessionToken,
|
|
179
|
+
...event,
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Send session end notification
|
|
185
|
+
* @param {string} reason - Reason for ending (completed, failed, user_quit)
|
|
186
|
+
*/
|
|
187
|
+
async sendSessionEnd(reason = 'completed') {
|
|
188
|
+
await this.send({
|
|
189
|
+
type: 'session_end',
|
|
190
|
+
sessionToken: this.sessionToken,
|
|
191
|
+
reason,
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Start polling for interaction responses
|
|
197
|
+
*/
|
|
198
|
+
startPolling() {
|
|
199
|
+
if (this.polling) return;
|
|
200
|
+
this.polling = true;
|
|
201
|
+
|
|
202
|
+
this.poll();
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Poll for interaction responses
|
|
207
|
+
*/
|
|
208
|
+
async poll() {
|
|
209
|
+
while (this.polling && this.connected) {
|
|
210
|
+
try {
|
|
211
|
+
const url = `${this.serverUrl}/api/ws/cli?token=${this.sessionToken}&timeout=30000`;
|
|
212
|
+
const response = await makeRequest(url, { method: 'GET' });
|
|
213
|
+
|
|
214
|
+
if (response.status === 200 && response.data) {
|
|
215
|
+
const { type, slug, targetKey, response: interactionResponse } = response.data;
|
|
216
|
+
|
|
217
|
+
if (type === 'interaction_response' && this.onInteractionResponse) {
|
|
218
|
+
this.onInteractionResponse(slug, targetKey, interactionResponse);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// If 204 (no content), just continue polling
|
|
223
|
+
// Small delay between polls
|
|
224
|
+
await new Promise(resolve => setTimeout(resolve, 500));
|
|
225
|
+
|
|
226
|
+
} catch (err) {
|
|
227
|
+
// Connection error - wait and retry
|
|
228
|
+
await new Promise(resolve => setTimeout(resolve, 5000));
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Stop polling
|
|
235
|
+
*/
|
|
236
|
+
stopPolling() {
|
|
237
|
+
this.polling = false;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Disconnect from the server
|
|
242
|
+
*/
|
|
243
|
+
async disconnect() {
|
|
244
|
+
this.stopPolling();
|
|
245
|
+
|
|
246
|
+
if (this.connected) {
|
|
247
|
+
await this.sendSessionEnd('completed');
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
this.connected = false;
|
|
251
|
+
}
|
|
252
|
+
}
|
package/lib/runtime/prompt.js
CHANGED
|
@@ -11,6 +11,15 @@ import path from 'path';
|
|
|
11
11
|
import readline from 'readline';
|
|
12
12
|
import { getCurrentRuntime } from './runtime.js';
|
|
13
13
|
|
|
14
|
+
const C = {
|
|
15
|
+
reset: '\x1b[0m',
|
|
16
|
+
bold: '\x1b[1m',
|
|
17
|
+
cyan: '\x1b[36m',
|
|
18
|
+
yellow: '\x1b[33m',
|
|
19
|
+
green: '\x1b[32m',
|
|
20
|
+
dim: '\x1b[2m'
|
|
21
|
+
};
|
|
22
|
+
|
|
14
23
|
/**
|
|
15
24
|
* Request user input with memory-based resume support
|
|
16
25
|
* @param {string} question - Question to ask the user
|
|
@@ -35,9 +44,8 @@ export async function initialPrompt(question, options = {}) {
|
|
|
35
44
|
|
|
36
45
|
// Check if we're in TTY mode (interactive terminal)
|
|
37
46
|
if (process.stdin.isTTY && process.stdout.isTTY) {
|
|
38
|
-
// Interactive mode - prompt directly
|
|
39
|
-
|
|
40
|
-
const answer = await askQuestion(question);
|
|
47
|
+
// Interactive mode - prompt directly, with remote support
|
|
48
|
+
const answer = await askQuestionWithRemote(runtime, question, slug, memoryKey);
|
|
41
49
|
console.log('');
|
|
42
50
|
|
|
43
51
|
// Save the response to memory
|
|
@@ -88,7 +96,57 @@ ${question}
|
|
|
88
96
|
}
|
|
89
97
|
|
|
90
98
|
/**
|
|
91
|
-
* Interactive terminal question
|
|
99
|
+
* Interactive terminal question with remote support
|
|
100
|
+
* Allows both local TTY input and remote browser responses
|
|
101
|
+
*/
|
|
102
|
+
function askQuestionWithRemote(runtime, question, slug, memoryKey) {
|
|
103
|
+
return new Promise((resolve) => {
|
|
104
|
+
let resolved = false;
|
|
105
|
+
|
|
106
|
+
const cleanup = () => {
|
|
107
|
+
resolved = true;
|
|
108
|
+
runtime.pendingRemoteInteraction = null;
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
// Set up remote interaction listener if remote is enabled
|
|
112
|
+
if (runtime.remoteEnabled) {
|
|
113
|
+
runtime.pendingRemoteInteraction = {
|
|
114
|
+
slug,
|
|
115
|
+
targetKey: memoryKey,
|
|
116
|
+
resolve: (response) => {
|
|
117
|
+
if (resolved) return;
|
|
118
|
+
cleanup();
|
|
119
|
+
rl.close();
|
|
120
|
+
console.log(`\n${C.green}✓ Answered via remote${C.reset}`);
|
|
121
|
+
resolve(response);
|
|
122
|
+
},
|
|
123
|
+
reject: () => {} // Prompts don't reject
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const rl = readline.createInterface({
|
|
128
|
+
input: process.stdin,
|
|
129
|
+
output: process.stdout
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
// Show remote URL if available
|
|
133
|
+
let prompt = `\n${C.cyan}${C.bold}${question}${C.reset}`;
|
|
134
|
+
if (runtime.remoteEnabled && runtime.remoteUrl) {
|
|
135
|
+
prompt += `\n${C.dim}(Remote: ${runtime.remoteUrl})${C.reset}`;
|
|
136
|
+
}
|
|
137
|
+
prompt += `\n${C.yellow}> ${C.reset}`;
|
|
138
|
+
|
|
139
|
+
rl.question(prompt, (answer) => {
|
|
140
|
+
if (resolved) return;
|
|
141
|
+
cleanup();
|
|
142
|
+
rl.close();
|
|
143
|
+
resolve(answer.trim());
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Interactive terminal question (simple, no remote support)
|
|
92
150
|
*/
|
|
93
151
|
function askQuestion(question) {
|
|
94
152
|
return new Promise((resolve) => {
|
|
@@ -97,7 +155,8 @@ function askQuestion(question) {
|
|
|
97
155
|
output: process.stdout
|
|
98
156
|
});
|
|
99
157
|
|
|
100
|
-
|
|
158
|
+
// Colorize the question and the input marker
|
|
159
|
+
rl.question(`\n${C.cyan}${C.bold}${question}${C.reset}\n${C.yellow}> ${C.reset}`, (answer) => {
|
|
101
160
|
rl.close();
|
|
102
161
|
resolve(answer.trim());
|
|
103
162
|
});
|
|
@@ -113,4 +172,4 @@ function generateSlug(question) {
|
|
|
113
172
|
.replace(/[^a-z0-9]+/g, '-')
|
|
114
173
|
.replace(/^-+|-+$/g, '')
|
|
115
174
|
.substring(0, 30);
|
|
116
|
-
}
|
|
175
|
+
}
|
package/lib/runtime/runtime.js
CHANGED
|
@@ -10,11 +10,26 @@ import fs from 'fs';
|
|
|
10
10
|
import path from 'path';
|
|
11
11
|
import readline from 'readline';
|
|
12
12
|
import { createMemoryProxy } from './memory.js';
|
|
13
|
+
import { RemoteClient } from '../remote/client.js';
|
|
13
14
|
|
|
14
15
|
// Global runtime reference for agent() and memory access
|
|
15
16
|
// stored on globalThis to ensure singleton access across different module instances (CLI vs local)
|
|
16
17
|
const RUNTIME_KEY = Symbol.for('agent-state-machine.runtime');
|
|
17
18
|
|
|
19
|
+
// ANSI Colors
|
|
20
|
+
const C = {
|
|
21
|
+
reset: '\x1b[0m',
|
|
22
|
+
bold: '\x1b[1m',
|
|
23
|
+
dim: '\x1b[2m',
|
|
24
|
+
red: '\x1b[31m',
|
|
25
|
+
green: '\x1b[32m',
|
|
26
|
+
yellow: '\x1b[33m',
|
|
27
|
+
blue: '\x1b[34m',
|
|
28
|
+
magenta: '\x1b[35m',
|
|
29
|
+
cyan: '\x1b[36m',
|
|
30
|
+
gray: '\x1b[90m'
|
|
31
|
+
};
|
|
32
|
+
|
|
18
33
|
export function getCurrentRuntime() {
|
|
19
34
|
return globalThis[RUNTIME_KEY];
|
|
20
35
|
}
|
|
@@ -63,6 +78,12 @@ export class WorkflowRuntime {
|
|
|
63
78
|
|
|
64
79
|
// Load steering
|
|
65
80
|
this.steering = this.loadSteering();
|
|
81
|
+
|
|
82
|
+
// Remote follow state
|
|
83
|
+
this.remoteClient = null;
|
|
84
|
+
this.remoteEnabled = false;
|
|
85
|
+
this.remoteUrl = null;
|
|
86
|
+
this.pendingRemoteInteraction = null; // { slug, targetKey, resolve, reject }
|
|
66
87
|
}
|
|
67
88
|
|
|
68
89
|
ensureDirectories() {
|
|
@@ -82,7 +103,7 @@ export class WorkflowRuntime {
|
|
|
82
103
|
try {
|
|
83
104
|
return JSON.parse(fs.readFileSync(this.stateFile, 'utf-8'));
|
|
84
105
|
} catch (err) {
|
|
85
|
-
console.warn(
|
|
106
|
+
console.warn(`${C.yellow}Warning: Failed to load state: ${err.message}${C.reset}`);
|
|
86
107
|
}
|
|
87
108
|
}
|
|
88
109
|
return {};
|
|
@@ -105,7 +126,7 @@ export class WorkflowRuntime {
|
|
|
105
126
|
const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
|
106
127
|
steering.enabled = config.enabled !== false;
|
|
107
128
|
} catch (err) {
|
|
108
|
-
console.warn(
|
|
129
|
+
console.warn(`${C.yellow}Warning: Failed to load steering config: ${err.message}${C.reset}`);
|
|
109
130
|
}
|
|
110
131
|
}
|
|
111
132
|
|
|
@@ -149,6 +170,39 @@ export class WorkflowRuntime {
|
|
|
149
170
|
existing = fs.readFileSync(this.historyFile, 'utf-8');
|
|
150
171
|
}
|
|
151
172
|
fs.writeFileSync(this.historyFile, line + existing);
|
|
173
|
+
|
|
174
|
+
// Forward to remote if connected
|
|
175
|
+
if (this.remoteClient && this.remoteEnabled) {
|
|
176
|
+
this.remoteClient.sendEvent(entry);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Load all history entries from history.jsonl
|
|
182
|
+
* @returns {Array} Array of history entry objects
|
|
183
|
+
*/
|
|
184
|
+
loadHistory() {
|
|
185
|
+
if (!fs.existsSync(this.historyFile)) {
|
|
186
|
+
return [];
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
try {
|
|
190
|
+
const content = fs.readFileSync(this.historyFile, 'utf-8');
|
|
191
|
+
return content
|
|
192
|
+
.trim()
|
|
193
|
+
.split('\n')
|
|
194
|
+
.filter(Boolean)
|
|
195
|
+
.map(line => {
|
|
196
|
+
try {
|
|
197
|
+
return JSON.parse(line);
|
|
198
|
+
} catch {
|
|
199
|
+
return null;
|
|
200
|
+
}
|
|
201
|
+
})
|
|
202
|
+
.filter(Boolean);
|
|
203
|
+
} catch {
|
|
204
|
+
return [];
|
|
205
|
+
}
|
|
152
206
|
}
|
|
153
207
|
|
|
154
208
|
/**
|
|
@@ -188,7 +242,7 @@ export class WorkflowRuntime {
|
|
|
188
242
|
this.persist();
|
|
189
243
|
this.prependHistory({ event: 'WORKFLOW_COMPLETED' });
|
|
190
244
|
|
|
191
|
-
console.log(`\n✓ Workflow '${this.workflowName}' completed successfully
|
|
245
|
+
console.log(`\n${C.green}✓ Workflow '${this.workflowName}' completed successfully!${C.reset}`);
|
|
192
246
|
} catch (err) {
|
|
193
247
|
this.status = 'FAILED';
|
|
194
248
|
this._error = err.message;
|
|
@@ -199,7 +253,7 @@ export class WorkflowRuntime {
|
|
|
199
253
|
error: err.message
|
|
200
254
|
});
|
|
201
255
|
|
|
202
|
-
console.error(`\n✗ Workflow '${this.workflowName}' failed: ${err.message}`);
|
|
256
|
+
console.error(`\n${C.red}✗ Workflow '${this.workflowName}' failed: ${err.message}${C.reset}`);
|
|
203
257
|
throw err;
|
|
204
258
|
} finally {
|
|
205
259
|
clearCurrentRuntime();
|
|
@@ -208,10 +262,17 @@ export class WorkflowRuntime {
|
|
|
208
262
|
|
|
209
263
|
/**
|
|
210
264
|
* Wait for user to confirm interaction is complete, then return the response
|
|
265
|
+
* Supports both local TTY input and remote browser responses
|
|
211
266
|
*/
|
|
212
267
|
async waitForInteraction(filePath, slug, targetKey) {
|
|
213
|
-
console.log(`\n⏸
|
|
214
|
-
|
|
268
|
+
console.log(`\n${C.yellow}${C.bold}⏸ Interaction required.${C.reset}`);
|
|
269
|
+
|
|
270
|
+
if (this.remoteEnabled && this.remoteUrl) {
|
|
271
|
+
console.log(` ${C.cyan}Remote:${C.reset} ${this.remoteUrl}`);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
console.log(` ${C.dim}Edit: ${filePath}${C.reset}`);
|
|
275
|
+
console.log(` Enter ${C.bold}y${C.reset} to proceed or ${C.bold}q${C.reset} to quit.\n`);
|
|
215
276
|
|
|
216
277
|
const rl = readline.createInterface({
|
|
217
278
|
input: process.stdin,
|
|
@@ -219,12 +280,57 @@ export class WorkflowRuntime {
|
|
|
219
280
|
});
|
|
220
281
|
|
|
221
282
|
return new Promise((resolve, reject) => {
|
|
283
|
+
// Track if we've already resolved (to prevent double-resolution)
|
|
284
|
+
let resolved = false;
|
|
285
|
+
|
|
286
|
+
const cleanup = () => {
|
|
287
|
+
resolved = true;
|
|
288
|
+
rl.close();
|
|
289
|
+
this.pendingRemoteInteraction = null;
|
|
290
|
+
};
|
|
291
|
+
|
|
292
|
+
// Set up remote interaction listener
|
|
293
|
+
if (this.remoteEnabled) {
|
|
294
|
+
this.pendingRemoteInteraction = {
|
|
295
|
+
slug,
|
|
296
|
+
targetKey,
|
|
297
|
+
resolve: (response) => {
|
|
298
|
+
if (resolved) return;
|
|
299
|
+
cleanup();
|
|
300
|
+
|
|
301
|
+
// Store in memory
|
|
302
|
+
this._rawMemory[targetKey] = response;
|
|
303
|
+
this.persist();
|
|
304
|
+
|
|
305
|
+
this.prependHistory({
|
|
306
|
+
event: 'INTERACTION_RESOLVED',
|
|
307
|
+
slug,
|
|
308
|
+
targetKey,
|
|
309
|
+
source: 'remote'
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
console.log(`\n${C.green}✓ Interaction resolved (remote): ${slug}${C.reset}`);
|
|
313
|
+
resolve(response);
|
|
314
|
+
},
|
|
315
|
+
reject: (err) => {
|
|
316
|
+
if (resolved) return;
|
|
317
|
+
cleanup();
|
|
318
|
+
reject(err);
|
|
319
|
+
}
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// Local TTY input loop
|
|
222
324
|
const ask = () => {
|
|
223
|
-
|
|
325
|
+
if (resolved) return;
|
|
326
|
+
|
|
327
|
+
rl.question(`${C.dim}> ${C.reset}`, (answer) => {
|
|
328
|
+
if (resolved) return;
|
|
329
|
+
|
|
224
330
|
const a = answer.trim().toLowerCase();
|
|
225
331
|
if (a === 'y') {
|
|
226
|
-
|
|
227
|
-
// Read and return the response
|
|
332
|
+
cleanup();
|
|
333
|
+
// Read and return the response from file
|
|
228
334
|
try {
|
|
229
335
|
const response = this.readInteractionResponse(filePath, slug, targetKey);
|
|
230
336
|
resolve(response);
|
|
@@ -232,7 +338,7 @@ export class WorkflowRuntime {
|
|
|
232
338
|
reject(err);
|
|
233
339
|
}
|
|
234
340
|
} else if (a === 'q') {
|
|
235
|
-
|
|
341
|
+
cleanup();
|
|
236
342
|
reject(new Error('User quit workflow'));
|
|
237
343
|
} else {
|
|
238
344
|
ask();
|
|
@@ -243,6 +349,17 @@ export class WorkflowRuntime {
|
|
|
243
349
|
});
|
|
244
350
|
}
|
|
245
351
|
|
|
352
|
+
/**
|
|
353
|
+
* Handle interaction response received from remote browser
|
|
354
|
+
* Called by RemoteClient when it receives an interaction_response message
|
|
355
|
+
*/
|
|
356
|
+
handleRemoteInteraction(slug, targetKey, response) {
|
|
357
|
+
if (this.pendingRemoteInteraction &&
|
|
358
|
+
this.pendingRemoteInteraction.slug === slug) {
|
|
359
|
+
this.pendingRemoteInteraction.resolve(response);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
246
363
|
/**
|
|
247
364
|
* Read the user's response from an interaction file
|
|
248
365
|
*/
|
|
@@ -268,7 +385,7 @@ export class WorkflowRuntime {
|
|
|
268
385
|
targetKey
|
|
269
386
|
});
|
|
270
387
|
|
|
271
|
-
console.log(`\n✓ Interaction resolved: ${slug}`);
|
|
388
|
+
console.log(`\n${C.green}✓ Interaction resolved: ${slug}${C.reset}`);
|
|
272
389
|
return response;
|
|
273
390
|
}
|
|
274
391
|
|
|
@@ -276,20 +393,27 @@ export class WorkflowRuntime {
|
|
|
276
393
|
* Show workflow status
|
|
277
394
|
*/
|
|
278
395
|
showStatus() {
|
|
279
|
-
console.log(`\
|
|
280
|
-
console.log('─'.repeat(40));
|
|
281
|
-
|
|
396
|
+
console.log(`\n${C.bold}Workflow: ${C.cyan}${this.workflowName}${C.reset}`);
|
|
397
|
+
console.log(`${C.dim}${'─'.repeat(40)}${C.reset}`);
|
|
398
|
+
|
|
399
|
+
let statusColor = C.reset;
|
|
400
|
+
if (this.status === 'COMPLETED') statusColor = C.green;
|
|
401
|
+
if (this.status === 'FAILED') statusColor = C.red;
|
|
402
|
+
if (this.status === 'RUNNING') statusColor = C.blue;
|
|
403
|
+
if (this.status === 'IDLE') statusColor = C.gray;
|
|
404
|
+
|
|
405
|
+
console.log(`Status: ${statusColor}${this.status}${C.reset}`);
|
|
282
406
|
|
|
283
407
|
if (this.startedAt) {
|
|
284
408
|
console.log(`Started: ${this.startedAt}`);
|
|
285
409
|
}
|
|
286
410
|
|
|
287
411
|
if (this._error) {
|
|
288
|
-
console.log(`Error: ${this._error}`);
|
|
412
|
+
console.log(`Error: ${C.red}${this._error}${C.reset}`);
|
|
289
413
|
}
|
|
290
414
|
|
|
291
415
|
const memoryKeys = Object.keys(this._rawMemory).filter((k) => !k.startsWith('_'));
|
|
292
|
-
console.log(`\nMemory Keys: ${memoryKeys.length}`);
|
|
416
|
+
console.log(`\nMemory Keys: ${C.yellow}${memoryKeys.length}${C.reset}`);
|
|
293
417
|
if (memoryKeys.length > 0) {
|
|
294
418
|
console.log(` ${memoryKeys.slice(0, 10).join(', ')}${memoryKeys.length > 10 ? '...' : ''}`);
|
|
295
419
|
}
|
|
@@ -299,8 +423,8 @@ export class WorkflowRuntime {
|
|
|
299
423
|
* Show execution history
|
|
300
424
|
*/
|
|
301
425
|
showHistory(limit = 20) {
|
|
302
|
-
console.log(`\
|
|
303
|
-
console.log('─'.repeat(40));
|
|
426
|
+
console.log(`\n${C.bold}History: ${C.cyan}${this.workflowName}${C.reset}`);
|
|
427
|
+
console.log(`${C.dim}${'─'.repeat(40)}${C.reset}`);
|
|
304
428
|
|
|
305
429
|
if (!fs.existsSync(this.historyFile)) {
|
|
306
430
|
console.log('No history found.');
|
|
@@ -318,10 +442,17 @@ export class WorkflowRuntime {
|
|
|
318
442
|
try {
|
|
319
443
|
const entry = JSON.parse(line);
|
|
320
444
|
const time = entry.timestamp ? entry.timestamp.split('T')[1]?.split('.')[0] : '';
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
if (entry.
|
|
324
|
-
if (entry.
|
|
445
|
+
|
|
446
|
+
let eventColor = C.reset;
|
|
447
|
+
if (entry.event.endsWith('_STARTED')) eventColor = C.blue;
|
|
448
|
+
if (entry.event.endsWith('_COMPLETED')) eventColor = C.green;
|
|
449
|
+
if (entry.event.endsWith('_FAILED')) eventColor = C.red;
|
|
450
|
+
if (entry.event.includes('INTERACTION')) eventColor = C.yellow;
|
|
451
|
+
|
|
452
|
+
console.log(`${C.dim}[${time}]${C.reset} ${eventColor}${entry.event}${C.reset}`);
|
|
453
|
+
if (entry.agent) console.log(` ${C.dim}Agent:${C.reset} ${entry.agent}`);
|
|
454
|
+
if (entry.slug) console.log(` ${C.dim}Slug:${C.reset} ${entry.slug}`);
|
|
455
|
+
if (entry.error) console.log(` ${C.red}Error: ${entry.error}${C.reset}`);
|
|
325
456
|
} catch {}
|
|
326
457
|
}
|
|
327
458
|
}
|
|
@@ -341,7 +472,7 @@ export class WorkflowRuntime {
|
|
|
341
472
|
this.persist();
|
|
342
473
|
this.prependHistory({ event: 'WORKFLOW_RESET' });
|
|
343
474
|
|
|
344
|
-
console.log(`\n✓ Workflow '${this.workflowName}' reset`);
|
|
475
|
+
console.log(`\n${C.yellow}✓ Workflow '${this.workflowName}' reset${C.reset}`);
|
|
345
476
|
}
|
|
346
477
|
|
|
347
478
|
/**
|
|
@@ -370,10 +501,62 @@ export class WorkflowRuntime {
|
|
|
370
501
|
|
|
371
502
|
this.persist();
|
|
372
503
|
|
|
373
|
-
console.log(`\n✓ Workflow '${this.workflowName}' hard reset (history and interactions cleared)`);
|
|
504
|
+
console.log(`\n${C.red}✓ Workflow '${this.workflowName}' hard reset (history and interactions cleared)${C.reset}`);
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/**
|
|
508
|
+
* Enable remote follow mode
|
|
509
|
+
* @param {string} serverUrl - Base URL of the remote server
|
|
510
|
+
* @returns {Promise<string>} The remote URL for browser access
|
|
511
|
+
*/
|
|
512
|
+
async enableRemote(serverUrl) {
|
|
513
|
+
this.remoteClient = new RemoteClient({
|
|
514
|
+
serverUrl,
|
|
515
|
+
workflowName: this.workflowName,
|
|
516
|
+
onInteractionResponse: (slug, targetKey, response) => {
|
|
517
|
+
this.handleRemoteInteraction(slug, targetKey, response);
|
|
518
|
+
},
|
|
519
|
+
onStatusChange: (status) => {
|
|
520
|
+
if (status === 'disconnected') {
|
|
521
|
+
console.log(`${C.yellow}Remote: Connection lost, attempting to reconnect...${C.reset}`);
|
|
522
|
+
} else if (status === 'connected' && this.remoteEnabled) {
|
|
523
|
+
console.log(`${C.green}Remote: Reconnected${C.reset}`);
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
await this.remoteClient.connect();
|
|
529
|
+
|
|
530
|
+
// Send existing history if connected
|
|
531
|
+
if (this.remoteClient.connected) {
|
|
532
|
+
const history = this.loadHistory();
|
|
533
|
+
this.remoteClient.sendSessionInit(history);
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
this.remoteEnabled = true;
|
|
537
|
+
this.remoteUrl = this.remoteClient.getRemoteUrl();
|
|
538
|
+
|
|
539
|
+
console.log(`\n${C.cyan}${C.bold}Remote follow enabled${C.reset}`);
|
|
540
|
+
console.log(` ${C.dim}URL:${C.reset} ${this.remoteUrl}\n`);
|
|
541
|
+
|
|
542
|
+
return this.remoteUrl;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
/**
|
|
546
|
+
* Disable remote follow mode and disconnect
|
|
547
|
+
*/
|
|
548
|
+
async disableRemote() {
|
|
549
|
+
if (this.remoteClient) {
|
|
550
|
+
await this.remoteClient.disconnect();
|
|
551
|
+
this.remoteClient = null;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
this.remoteEnabled = false;
|
|
555
|
+
this.remoteUrl = null;
|
|
556
|
+
this.pendingRemoteInteraction = null;
|
|
374
557
|
}
|
|
375
558
|
}
|
|
376
559
|
|
|
377
560
|
function escapeRegExp(string) {
|
|
378
561
|
return string.replace(/[.*+?^${}()|[\\]/g, '\\$&'); // $& means the whole matched string
|
|
379
|
-
}
|
|
562
|
+
}
|