agent-state-machine 1.3.2 → 1.4.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
@@ -101,8 +101,8 @@ export default async function() {
101
101
  console.log('Starting project-builder workflow...');
102
102
 
103
103
  // Example: Get user input (saved to memory)
104
- const answer = await initialPrompt('Where do you live?');
105
- console.log('Example prompt answer:', answer);
104
+ const userLocation = await initialPrompt('Where do you live?');
105
+ console.log('Example prompt answer:', userLocation);
106
106
 
107
107
  const userInfo = await agent('yoda-name-collector');
108
108
  memory.userInfo = userInfo;
@@ -113,7 +113,7 @@ export default async function() {
113
113
  console.log('Example agent memory.userInfo:', memory.userInfo || userInfo);
114
114
 
115
115
  // Context is provided automatically
116
- const { greeting } = await agent('yoda-greeter');
116
+ const { greeting } = await agent('yoda-greeter', { userLocation });
117
117
  console.log('Example agent greeting:', greeting);
118
118
 
119
119
  // Or you can provide context manually
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 || 'http://localhost:3001';
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> Run a workflow (loads existing state)
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
- await runtime.runWorkflow(workflowUrl);
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
- try {
192
- await runOrResume(workflowName);
193
- } catch (err) {
194
- console.error('Error:', err.message || String(err));
195
- process.exit(1);
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
+ }
@@ -342,13 +342,10 @@ async function handleInteraction(runtime, interaction) {
342
342
  const filePath = path.join(runtime.interactionsDir, `${slug}.md`);
343
343
 
344
344
  // Create interaction file
345
- const fileContent = `# ${slug}
345
+ const fileContent = `<!-- Note: Edit this file directly and press 'y' in the terminal when finished. Safe to clear this file. -->
346
+ # ${slug}
346
347
 
347
348
  ${content}
348
-
349
- ---
350
- Enter your response below:
351
-
352
349
  `;
353
350
 
354
351
  fs.writeFileSync(filePath, fileContent);
@@ -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
- console.log('');
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
@@ -56,13 +64,10 @@ export async function initialPrompt(question, options = {}) {
56
64
  // Non-TTY mode - create interaction file and wait inline
57
65
  const interactionFile = path.join(runtime.interactionsDir, `${slug}.md`);
58
66
 
59
- const fileContent = `# ${slug}
67
+ const fileContent = `<!-- Note: Edit this file directly and press 'y' in the terminal when finished. Safe to clear this file. -->
68
+ # ${slug}
60
69
 
61
70
  ${question}
62
-
63
- ---
64
- Enter your response below:
65
-
66
71
  `;
67
72
 
68
73
  fs.writeFileSync(interactionFile, fileContent);
@@ -91,7 +96,57 @@ Enter your response below:
91
96
  }
92
97
 
93
98
  /**
94
- * 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)
95
150
  */
96
151
  function askQuestion(question) {
97
152
  return new Promise((resolve) => {
@@ -100,7 +155,8 @@ function askQuestion(question) {
100
155
  output: process.stdout
101
156
  });
102
157
 
103
- rl.question(`${question}\n> `, (answer) => {
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) => {
104
160
  rl.close();
105
161
  resolve(answer.trim());
106
162
  });
@@ -116,4 +172,4 @@ function generateSlug(question) {
116
172
  .replace(/[^a-z0-9]+/g, '-')
117
173
  .replace(/^-+|-+$/g, '')
118
174
  .substring(0, 30);
119
- }
175
+ }
@@ -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(`Warning: Failed to load state: ${err.message}`);
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(`Warning: Failed to load steering config: ${err.message}`);
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⏸ Interaction required. Edit: ${filePath}`);
214
- console.log(` Enter 'y' to proceed or 'q' to quit.\n`);
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
- rl.question('> ', (answer) => {
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
- rl.close();
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
- rl.close();
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
  */
@@ -252,17 +369,10 @@ export class WorkflowRuntime {
252
369
  }
253
370
 
254
371
  const content = fs.readFileSync(filePath, 'utf-8');
372
+ const response = content.trim();
255
373
 
256
- // Extract response after separator line
257
- const separator = '---';
258
- const parts = content.split(separator);
259
- if (parts.length < 2) {
260
- throw new Error(`Interaction file missing separator: ${filePath}`);
261
- }
262
-
263
- const response = parts.slice(1).join(separator).trim();
264
- if (!response || response === 'Enter your response below:') {
265
- throw new Error(`Interaction response is empty. Please fill in: ${filePath}`);
374
+ if (!response) {
375
+ throw new Error(`Interaction file is empty: ${filePath}`);
266
376
  }
267
377
 
268
378
  // Store in memory for reference
@@ -275,7 +385,7 @@ export class WorkflowRuntime {
275
385
  targetKey
276
386
  });
277
387
 
278
- console.log(`\n✓ Interaction resolved: ${slug}`);
388
+ console.log(`\n${C.green}✓ Interaction resolved: ${slug}${C.reset}`);
279
389
  return response;
280
390
  }
281
391
 
@@ -283,20 +393,27 @@ export class WorkflowRuntime {
283
393
  * Show workflow status
284
394
  */
285
395
  showStatus() {
286
- console.log(`\nWorkflow: ${this.workflowName}`);
287
- console.log('─'.repeat(40));
288
- console.log(`Status: ${this.status}`);
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}`);
289
406
 
290
407
  if (this.startedAt) {
291
408
  console.log(`Started: ${this.startedAt}`);
292
409
  }
293
410
 
294
411
  if (this._error) {
295
- console.log(`Error: ${this._error}`);
412
+ console.log(`Error: ${C.red}${this._error}${C.reset}`);
296
413
  }
297
414
 
298
415
  const memoryKeys = Object.keys(this._rawMemory).filter((k) => !k.startsWith('_'));
299
- console.log(`\nMemory Keys: ${memoryKeys.length}`);
416
+ console.log(`\nMemory Keys: ${C.yellow}${memoryKeys.length}${C.reset}`);
300
417
  if (memoryKeys.length > 0) {
301
418
  console.log(` ${memoryKeys.slice(0, 10).join(', ')}${memoryKeys.length > 10 ? '...' : ''}`);
302
419
  }
@@ -306,8 +423,8 @@ export class WorkflowRuntime {
306
423
  * Show execution history
307
424
  */
308
425
  showHistory(limit = 20) {
309
- console.log(`\nHistory: ${this.workflowName}`);
310
- 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}`);
311
428
 
312
429
  if (!fs.existsSync(this.historyFile)) {
313
430
  console.log('No history found.');
@@ -325,10 +442,17 @@ export class WorkflowRuntime {
325
442
  try {
326
443
  const entry = JSON.parse(line);
327
444
  const time = entry.timestamp ? entry.timestamp.split('T')[1]?.split('.')[0] : '';
328
- console.log(`[${time}] ${entry.event}`);
329
- if (entry.agent) console.log(` Agent: ${entry.agent}`);
330
- if (entry.slug) console.log(` Slug: ${entry.slug}`);
331
- if (entry.error) console.log(` Error: ${entry.error}`);
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}`);
332
456
  } catch {}
333
457
  }
334
458
  }
@@ -348,7 +472,7 @@ export class WorkflowRuntime {
348
472
  this.persist();
349
473
  this.prependHistory({ event: 'WORKFLOW_RESET' });
350
474
 
351
- console.log(`\n✓ Workflow '${this.workflowName}' reset`);
475
+ console.log(`\n${C.yellow}✓ Workflow '${this.workflowName}' reset${C.reset}`);
352
476
  }
353
477
 
354
478
  /**
@@ -377,6 +501,62 @@ export class WorkflowRuntime {
377
501
 
378
502
  this.persist();
379
503
 
380
- 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;
381
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;
557
+ }
558
+ }
559
+
560
+ function escapeRegExp(string) {
561
+ return string.replace(/[.*+?^${}()|[\\]/g, '\\$&'); // $& means the whole matched string
382
562
  }
package/lib/setup.js CHANGED
@@ -80,8 +80,8 @@ export default async function() {
80
80
  console.log('Starting ${workflowName} workflow...');
81
81
 
82
82
  // Example: Get user input (saved to memory)
83
- const answer = await initialPrompt('Where do you live?');
84
- console.log('Example prompt answer:', answer);
83
+ const userLocation = await initialPrompt('Where do you live?');
84
+ console.log('Example prompt answer:', userLocation);
85
85
 
86
86
  const userInfo = await agent('yoda-name-collector');
87
87
  memory.userInfo = userInfo;
@@ -92,7 +92,7 @@ export default async function() {
92
92
  console.log('Example agent memory.userInfo:', memory.userInfo || userInfo);
93
93
 
94
94
  // Context is provided automatically
95
- const { greeting } = await agent('yoda-greeter');
95
+ const { greeting } = await agent('yoda-greeter', { userLocation });
96
96
  console.log('Example agent greeting:', greeting);
97
97
 
98
98
  // Or you can provide context manually
@@ -171,7 +171,7 @@ output: greeting
171
171
 
172
172
  # Greeting Task
173
173
 
174
- Generate a friendly greeting for {{name}} in a yoda style. Prompt user for their actual {{name}} if you dont have it.
174
+ Generate a friendly greeting for {{name}} from {{location}} in a yoda style. Prompt user for their actual {{name}} if you dont have it.
175
175
 
176
176
  Once you have it create a yoda-greeting.md file in root dir with the greeting.
177
177
 
@@ -345,8 +345,8 @@ export default async function() {
345
345
  console.log('Starting project-builder workflow...');
346
346
 
347
347
  // Example: Get user input (saved to memory)
348
- const answer = await initialPrompt('Where do you live?');
349
- console.log('Example prompt answer:', answer);
348
+ const userLocation = await initialPrompt('Where do you live?');
349
+ console.log('Example prompt answer:', userLocation);
350
350
 
351
351
  const userInfo = await agent('yoda-name-collector');
352
352
  memory.userInfo = userInfo;
@@ -357,7 +357,7 @@ export default async function() {
357
357
  console.log('Example agent memory.userInfo:', memory.userInfo || userInfo);
358
358
 
359
359
  // Context is provided automatically
360
- const { greeting } = await agent('yoda-greeter');
360
+ const { greeting } = await agent('yoda-greeter', { userLocation });
361
361
  console.log('Example agent greeting:', greeting);
362
362
 
363
363
  // Or you can provide context manually
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-state-machine",
3
- "version": "1.3.2",
3
+ "version": "1.4.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",