agent-state-machine 2.0.3 → 2.0.5

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
@@ -44,11 +44,11 @@ Requirements: Node.js >= 16.
44
44
  ```bash
45
45
  state-machine --setup <workflow-name>
46
46
  state-machine run <workflow-name>
47
+ state-machine run <workflow-name> -reset
48
+ state-machine run <workflow-name> -reset-hard
47
49
 
48
50
 
49
51
  state-machine history <workflow-name> [limit]
50
- state-machine reset <workflow-name> (clears memory/state)
51
- state-machine reset-hard <workflow-name> (clears everything: history/interactions/memory)
52
52
  ```
53
53
 
54
54
  Workflows live in:
@@ -140,7 +140,7 @@ export default async function() {
140
140
 
141
141
  `state-machine run` restarts your workflow from the top, loading the persisted state.
142
142
 
143
- If the workflow needs human input, it will **block inline** in the terminal. You’ll be told which `interactions/<slug>.md` file to edit; after you fill it in, press `y` in the same terminal session to continue.
143
+ If the workflow needs human input, it will **block inline** in the terminal. You can answer in the terminal, edit `interactions/<slug>.md`, or respond in the browser.
144
144
 
145
145
  If the process is interrupted, running `state-machine run <workflow-name>` again will continue execution (assuming your workflow uses `memory` to skip completed steps).
146
146
 
@@ -172,8 +172,8 @@ memory.count = (memory.count || 0) + 1;
172
172
 
173
173
  Gets user input.
174
174
 
175
- - In a TTY, it prompts in the terminal.
176
- - Otherwise it creates `interactions/<slug>.md` and blocks until you confirm in the terminal.
175
+ - In a TTY, it prompts in the terminal (or via the browser when remote follow is enabled).
176
+ - Otherwise it creates `interactions/<slug>.md` and blocks until you confirm in the terminal (or respond in the browser).
177
177
 
178
178
  ```js
179
179
  const repo = await initialPrompt('What repo should I work on?', { slug: 'repo' });
@@ -298,7 +298,7 @@ export const config = {
298
298
  };
299
299
  ```
300
300
 
301
- The runtime captures the fully-built prompt in `state/history.jsonl`, viewable in the browser with live updates when running with the `--local` flag or via the remote URL.
301
+ The runtime captures the fully-built prompt in `state/history.jsonl`, viewable in the browser with live updates when running with the `--local` flag or via the remote URL. Remote follow links persist across runs (stored in `workflow.js` config) unless you pass `-n`/`--new` to regenerate.
302
302
 
303
303
  ---
304
304
 
@@ -164,6 +164,18 @@ export class RemoteClient {
164
164
  });
165
165
  }
166
166
 
167
+ /**
168
+ * Sync history to remote (for manual edits to history.jsonl)
169
+ * @param {Array} history - Array of history entries
170
+ */
171
+ async sendHistorySync(history = []) {
172
+ await this.send({
173
+ type: 'history_sync',
174
+ sessionToken: this.sessionToken,
175
+ history,
176
+ });
177
+ }
178
+
167
179
  /**
168
180
  * Send an event to the server
169
181
  * @param {object} event - Event object with timestamp, event type, etc.
@@ -164,6 +164,9 @@ export class WorkflowRuntime {
164
164
 
165
165
  const line = JSON.stringify(entry) + '\n';
166
166
 
167
+ // Track when we're writing to avoid triggering the file watcher
168
+ this._lastHistoryWrite = Date.now();
169
+
167
170
  // Prepend to file (read existing, write new + existing)
168
171
  let existing = '';
169
172
  if (fs.existsSync(this.historyFile)) {
@@ -533,22 +536,69 @@ export class WorkflowRuntime {
533
536
  // Send existing history if connected
534
537
  if (this.remoteClient.connected) {
535
538
  const history = this.loadHistory();
536
- this.remoteClient.sendSessionInit(history);
539
+ await this.remoteClient.sendSessionInit(history);
537
540
  }
538
541
 
539
542
  this.remoteEnabled = true;
540
543
  this.remoteUrl = this.remoteClient.getRemoteUrl();
541
544
 
545
+ // Watch history.jsonl for manual edits and sync to remote
546
+ this.startHistoryWatcher();
547
+
542
548
  console.log(`\n${C.cyan}${C.bold}Remote follow enabled${C.reset}`);
543
549
  console.log(` ${C.dim}URL:${C.reset} ${this.remoteUrl}\n`);
544
550
 
545
551
  return this.remoteUrl;
546
552
  }
547
553
 
554
+ /**
555
+ * Start watching history.jsonl for manual edits
556
+ */
557
+ startHistoryWatcher() {
558
+ if (this.historyWatcher) return;
559
+
560
+ // Debounce to avoid multiple syncs for rapid changes
561
+ let debounceTimer = null;
562
+
563
+ try {
564
+ this.historyWatcher = fs.watch(this.historyFile, (eventType) => {
565
+ if (eventType !== 'change') return;
566
+
567
+ // Debounce: wait 300ms after last change before syncing
568
+ if (debounceTimer) clearTimeout(debounceTimer);
569
+
570
+ debounceTimer = setTimeout(async () => {
571
+ // Don't sync if we just wrote to the file ourselves (within 500ms)
572
+ if (this._lastHistoryWrite && Date.now() - this._lastHistoryWrite < 500) return;
573
+
574
+ if (this.remoteClient && this.remoteEnabled) {
575
+ const history = this.loadHistory();
576
+ await this.remoteClient.sendHistorySync(history);
577
+ console.log(`${C.dim}Remote: History synced from file${C.reset}`);
578
+ }
579
+ }, 300);
580
+ });
581
+ } catch (err) {
582
+ // File might not exist yet, that's ok
583
+ }
584
+ }
585
+
586
+ /**
587
+ * Stop watching history.jsonl
588
+ */
589
+ stopHistoryWatcher() {
590
+ if (this.historyWatcher) {
591
+ this.historyWatcher.close();
592
+ this.historyWatcher = null;
593
+ }
594
+ }
595
+
548
596
  /**
549
597
  * Disable remote follow mode and disconnect
550
598
  */
551
599
  async disableRemote() {
600
+ this.stopHistoryWatcher();
601
+
552
602
  if (this.remoteClient) {
553
603
  await this.remoteClient.disconnect();
554
604
  this.remoteClient = null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-state-machine",
3
- "version": "2.0.3",
3
+ "version": "2.0.5",
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",
@@ -2,14 +2,14 @@
2
2
  * File: /vercel-server/api/events/[token].js
3
3
  *
4
4
  * SSE endpoint for browser connections
5
- * Streams history and real-time events to connected browsers
5
+ * Streams events to connected browsers from a single source of truth (events list)
6
6
  */
7
7
 
8
8
  import {
9
9
  getSession,
10
- getHistory,
11
- redis,
12
- KEYS,
10
+ getEvents,
11
+ getEventsLength,
12
+ getEventsRange,
13
13
  refreshSession,
14
14
  } from '../../lib/redis.js';
15
15
 
@@ -48,20 +48,15 @@ export default async function handler(req, res) {
48
48
  workflowName: session.workflowName,
49
49
  })}\n\n`);
50
50
 
51
- // Send existing history
52
- const history = await getHistory(token);
51
+ // Send all existing events (single source of truth)
52
+ const events = await getEvents(token);
53
53
  res.write(`data: ${JSON.stringify({
54
54
  type: 'history',
55
- entries: history,
55
+ entries: events,
56
56
  })}\n\n`);
57
57
 
58
- // Poll for new events
59
- const eventsListKey = `${KEYS.events(token)}:list`;
60
- let lastEventIndex = 0;
61
-
62
- // Get current list length to start from
63
- const currentLength = await redis.llen(eventsListKey);
64
- lastEventIndex = currentLength;
58
+ // Track current position for polling new events
59
+ let lastEventIndex = await getEventsLength(token);
65
60
 
66
61
  const pollInterval = setInterval(async () => {
67
62
  try {
@@ -69,15 +64,19 @@ export default async function handler(req, res) {
69
64
  await refreshSession(token);
70
65
 
71
66
  // Check for new events
72
- const newLength = await redis.llen(eventsListKey);
67
+ const newLength = await getEventsLength(token);
73
68
 
74
69
  if (newLength > lastEventIndex) {
75
- // Get new events (newest first)
76
- const newEvents = await redis.lrange(eventsListKey, 0, newLength - lastEventIndex - 1);
77
-
78
- for (const event of newEvents.reverse()) {
79
- const eventData = typeof event === 'object' ? event : JSON.parse(event);
80
- res.write(`data: ${JSON.stringify(eventData)}\n\n`);
70
+ // Get new events (they're prepended, so newest are at the start)
71
+ const newCount = newLength - lastEventIndex;
72
+ const newEvents = await getEventsRange(token, 0, newCount - 1);
73
+
74
+ // Send in chronological order (oldest first of the new batch)
75
+ for (const eventData of newEvents.reverse()) {
76
+ res.write(`data: ${JSON.stringify({
77
+ type: 'event',
78
+ ...eventData,
79
+ })}\n\n`);
81
80
  }
82
81
 
83
82
  lastEventIndex = newLength;
@@ -94,14 +93,13 @@ export default async function handler(req, res) {
94
93
  } catch (err) {
95
94
  console.error('Error polling events:', err);
96
95
  }
97
- }, 2000);
96
+ }, 1000); // Poll every 1 second for faster updates
98
97
 
99
98
  // Clean up on client disconnect
100
99
  req.on('close', () => {
101
100
  clearInterval(pollInterval);
102
101
  });
103
102
 
104
- // For Vercel, we need to keep the connection alive but also respect function timeout
105
103
  // Send keepalive pings
106
104
  const keepaliveInterval = setInterval(() => {
107
105
  res.write(': keepalive\n\n');
@@ -12,9 +12,8 @@ import {
12
12
  getSession,
13
13
  updateSession,
14
14
  setCLIConnected,
15
- addHistoryEvent,
16
- addHistoryEvents,
17
- publishEvent,
15
+ addEvent,
16
+ setEvents,
18
17
  redis,
19
18
  KEYS,
20
19
  } from '../../lib/redis.js';
@@ -59,16 +58,8 @@ async function handlePost(req, res) {
59
58
  // Create session
60
59
  await createSession(sessionToken, { workflowName, cliConnected: true });
61
60
 
62
- // Store initial history
63
- if (history && history.length > 0) {
64
- await addHistoryEvents(sessionToken, history);
65
- }
66
-
67
- // Notify browsers
68
- await publishEvent(sessionToken, {
69
- type: 'cli_connected',
70
- workflowName,
71
- });
61
+ // Replace events with the provided history snapshot (single source of truth)
62
+ await setEvents(sessionToken, history || []);
72
63
 
73
64
  return res.status(200).json({ success: true });
74
65
  }
@@ -79,9 +70,10 @@ async function handlePost(req, res) {
79
70
  // Update session as connected
80
71
  await setCLIConnected(sessionToken, true);
81
72
 
82
- // Notify browsers
83
- await publishEvent(sessionToken, {
84
- type: 'cli_reconnected',
73
+ // Add reconnect event to events list
74
+ await addEvent(sessionToken, {
75
+ timestamp: new Date().toISOString(),
76
+ event: 'CLI_RECONNECTED',
85
77
  workflowName,
86
78
  });
87
79
 
@@ -101,14 +93,8 @@ async function handlePost(req, res) {
101
93
  delete historyEvent.sessionToken;
102
94
  delete historyEvent.type;
103
95
 
104
- // Add to history
105
- await addHistoryEvent(sessionToken, historyEvent);
106
-
107
- // Notify browsers
108
- await publishEvent(sessionToken, {
109
- type: 'event',
110
- ...historyEvent,
111
- });
96
+ // Add to events list (single source of truth)
97
+ await addEvent(sessionToken, historyEvent);
112
98
 
113
99
  return res.status(200).json({ success: true });
114
100
  }
@@ -119,15 +105,25 @@ async function handlePost(req, res) {
119
105
  // Mark CLI as disconnected
120
106
  await setCLIConnected(sessionToken, false);
121
107
 
122
- // Notify browsers
123
- await publishEvent(sessionToken, {
124
- type: 'cli_disconnected',
108
+ // Add disconnect event to events list
109
+ await addEvent(sessionToken, {
110
+ timestamp: new Date().toISOString(),
111
+ event: 'CLI_DISCONNECTED',
125
112
  reason,
126
113
  });
127
114
 
128
115
  return res.status(200).json({ success: true });
129
116
  }
130
117
 
118
+ case 'history_sync': {
119
+ const { history } = body;
120
+
121
+ // Replace events with synced history (for manual edits to history.jsonl)
122
+ await setEvents(sessionToken, history || []);
123
+
124
+ return res.status(200).json({ success: true });
125
+ }
126
+
131
127
  default:
132
128
  return res.status(400).json({ error: `Unknown message type: ${type}` });
133
129
  }