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 +6 -6
- package/lib/remote/client.js +12 -0
- package/lib/runtime/runtime.js +51 -1
- package/package.json +1 -1
- package/vercel-server/api/events/[token].js +21 -23
- package/vercel-server/api/ws/cli.js +23 -27
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
|
|
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
|
|
package/lib/remote/client.js
CHANGED
|
@@ -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.
|
package/lib/runtime/runtime.js
CHANGED
|
@@ -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
|
@@ -2,14 +2,14 @@
|
|
|
2
2
|
* File: /vercel-server/api/events/[token].js
|
|
3
3
|
*
|
|
4
4
|
* SSE endpoint for browser connections
|
|
5
|
-
* Streams
|
|
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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
|
52
|
-
const
|
|
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:
|
|
55
|
+
entries: events,
|
|
56
56
|
})}\n\n`);
|
|
57
57
|
|
|
58
|
-
//
|
|
59
|
-
|
|
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
|
|
67
|
+
const newLength = await getEventsLength(token);
|
|
73
68
|
|
|
74
69
|
if (newLength > lastEventIndex) {
|
|
75
|
-
// Get new events (newest
|
|
76
|
-
const
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
},
|
|
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
|
-
|
|
16
|
-
|
|
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
|
-
//
|
|
63
|
-
|
|
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
|
-
//
|
|
83
|
-
await
|
|
84
|
-
|
|
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
|
|
105
|
-
await
|
|
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
|
-
//
|
|
123
|
-
await
|
|
124
|
-
|
|
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
|
}
|