halo-agent 1.1.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 +43 -0
- package/browser.js +157 -0
- package/captcha.js +217 -0
- package/config.js +37 -0
- package/filler.js +987 -0
- package/index.js +360 -0
- package/localServer.js +270 -0
- package/manusAutomate.js +349 -0
- package/orchestrator.js +1122 -0
- package/package.json +49 -0
- package/poller.js +172 -0
- package/scanPage.js +606 -0
- package/vision.js +398 -0
package/manusAutomate.js
ADDED
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* manusAutomate.js
|
|
5
|
+
*
|
|
6
|
+
* Uses Playwright (connected to the user's real Chrome via CDP) to:
|
|
7
|
+
* 1. Open manus.im/app
|
|
8
|
+
* 2. Click "New Task" to create a task
|
|
9
|
+
* 3. Paste the generated prompt into the task input
|
|
10
|
+
* 4. Click Send / Submit
|
|
11
|
+
* 5. Extract the resulting Manus task URL / ID
|
|
12
|
+
* 6. Poll Manus task progress and report status back to HALO
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const { chromium } = require('playwright');
|
|
16
|
+
|
|
17
|
+
const CDP_URL = 'http://localhost:9222';
|
|
18
|
+
const MANUS_APP_URL = 'https://manus.im/app';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Connect to the user's real Chrome via CDP.
|
|
22
|
+
* Returns a Playwright browser object connected to the existing Chrome session.
|
|
23
|
+
*/
|
|
24
|
+
async function connectChrome() {
|
|
25
|
+
const browser = await chromium.connectOverCDP(CDP_URL);
|
|
26
|
+
const context = browser.contexts()[0] || await browser.newContext();
|
|
27
|
+
return { browser, context };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Navigate to manus.im/app, click New Task, paste the prompt, and submit.
|
|
32
|
+
* Returns the Manus task URL and task ID extracted from the resulting page.
|
|
33
|
+
*
|
|
34
|
+
* @param {string} prompt - Full task prompt to paste into Manus
|
|
35
|
+
* @param {object} chromeConn - { browser, context } from connectChrome()
|
|
36
|
+
* @returns {{ taskUrl: string, taskId: string } | { error: string }}
|
|
37
|
+
*/
|
|
38
|
+
async function dispatchManusTask(prompt, chromeConn) {
|
|
39
|
+
const { context } = chromeConn;
|
|
40
|
+
let page = null;
|
|
41
|
+
|
|
42
|
+
try {
|
|
43
|
+
// Open a new tab to manus.im/app
|
|
44
|
+
page = await context.newPage();
|
|
45
|
+
console.log('[manus-automate] Navigating to manus.im/app...');
|
|
46
|
+
await page.goto(MANUS_APP_URL, { waitUntil: 'domcontentloaded', timeout: 30000 });
|
|
47
|
+
await page.waitForTimeout(2000); // let React/SPA hydrate
|
|
48
|
+
|
|
49
|
+
// Wait for the page to be usable — look for a "New Task" button or chat input
|
|
50
|
+
// Manus UI may show a task list or a blank home state
|
|
51
|
+
console.log('[manus-automate] Looking for New Task button...');
|
|
52
|
+
|
|
53
|
+
// Try multiple selectors for "New Task" button (Manus UI may vary)
|
|
54
|
+
const newTaskSelectors = [
|
|
55
|
+
'button:has-text("New Task")',
|
|
56
|
+
'button:has-text("New task")',
|
|
57
|
+
'a:has-text("New Task")',
|
|
58
|
+
'[aria-label="New Task"]',
|
|
59
|
+
'[data-testid="new-task-button"]',
|
|
60
|
+
'button:has-text("New")',
|
|
61
|
+
];
|
|
62
|
+
|
|
63
|
+
let clicked = false;
|
|
64
|
+
for (const sel of newTaskSelectors) {
|
|
65
|
+
try {
|
|
66
|
+
const el = page.locator(sel).first();
|
|
67
|
+
const visible = await el.isVisible({ timeout: 3000 });
|
|
68
|
+
if (visible) {
|
|
69
|
+
await el.click();
|
|
70
|
+
console.log(`[manus-automate] Clicked New Task (selector: ${sel})`);
|
|
71
|
+
clicked = true;
|
|
72
|
+
await page.waitForTimeout(1500);
|
|
73
|
+
break;
|
|
74
|
+
}
|
|
75
|
+
} catch {}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// If no "New Task" button found, we may already be on a blank task input page
|
|
79
|
+
if (!clicked) {
|
|
80
|
+
console.log('[manus-automate] No New Task button found — assuming already on task input page');
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Find the task input textarea or contenteditable
|
|
84
|
+
console.log('[manus-automate] Looking for task input field...');
|
|
85
|
+
const inputSelectors = [
|
|
86
|
+
'textarea[placeholder*="task"]',
|
|
87
|
+
'textarea[placeholder*="Task"]',
|
|
88
|
+
'textarea[placeholder*="message"]',
|
|
89
|
+
'textarea[placeholder*="Message"]',
|
|
90
|
+
'div[contenteditable="true"]',
|
|
91
|
+
'textarea',
|
|
92
|
+
'[role="textbox"]',
|
|
93
|
+
];
|
|
94
|
+
|
|
95
|
+
let inputEl = null;
|
|
96
|
+
for (const sel of inputSelectors) {
|
|
97
|
+
try {
|
|
98
|
+
const el = page.locator(sel).first();
|
|
99
|
+
const visible = await el.isVisible({ timeout: 3000 });
|
|
100
|
+
if (visible) {
|
|
101
|
+
inputEl = el;
|
|
102
|
+
console.log(`[manus-automate] Found input (selector: ${sel})`);
|
|
103
|
+
break;
|
|
104
|
+
}
|
|
105
|
+
} catch {}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (!inputEl) {
|
|
109
|
+
// Take a screenshot for debugging
|
|
110
|
+
const shot = await page.screenshot({ type: 'jpeg', quality: 60 }).catch(() => null);
|
|
111
|
+
if (shot) {
|
|
112
|
+
const fs = require('fs');
|
|
113
|
+
const path = require('path');
|
|
114
|
+
const shotPath = path.join(require('os').tmpdir(), 'manus_debug.jpg');
|
|
115
|
+
fs.writeFileSync(shotPath, shot);
|
|
116
|
+
console.warn(`[manus-automate] Debug screenshot saved to: ${shotPath}`);
|
|
117
|
+
}
|
|
118
|
+
throw new Error('Could not find task input field on manus.im/app');
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Click the input to focus it
|
|
122
|
+
await inputEl.click();
|
|
123
|
+
await page.waitForTimeout(300);
|
|
124
|
+
|
|
125
|
+
// Clear any existing content
|
|
126
|
+
await page.keyboard.press('Control+A');
|
|
127
|
+
await page.keyboard.press('Delete');
|
|
128
|
+
await page.waitForTimeout(200);
|
|
129
|
+
|
|
130
|
+
// Type the prompt using clipboard paste for speed and accuracy
|
|
131
|
+
// (direct typing would take forever on a 3000+ char prompt)
|
|
132
|
+
console.log('[manus-automate] Pasting prompt into input...');
|
|
133
|
+
|
|
134
|
+
// Set clipboard value via page.evaluate and then Ctrl+V
|
|
135
|
+
await page.evaluate((text) => {
|
|
136
|
+
// Write to clipboard using the clipboard API
|
|
137
|
+
return navigator.clipboard.writeText(text).catch(() => {
|
|
138
|
+
// Fallback: create a temp textarea, copy from it
|
|
139
|
+
const ta = document.createElement('textarea');
|
|
140
|
+
ta.value = text;
|
|
141
|
+
document.body.appendChild(ta);
|
|
142
|
+
ta.select();
|
|
143
|
+
document.execCommand('copy');
|
|
144
|
+
document.body.removeChild(ta);
|
|
145
|
+
});
|
|
146
|
+
}, prompt);
|
|
147
|
+
|
|
148
|
+
await page.waitForTimeout(300);
|
|
149
|
+
await inputEl.click();
|
|
150
|
+
await page.waitForTimeout(200);
|
|
151
|
+
await page.keyboard.press('Meta+V'); // Cmd+V on macOS
|
|
152
|
+
await page.waitForTimeout(800);
|
|
153
|
+
|
|
154
|
+
// Verify text was entered (check input value or text content)
|
|
155
|
+
let hasContent = false;
|
|
156
|
+
try {
|
|
157
|
+
const tagName = await inputEl.evaluate(el => el.tagName.toLowerCase());
|
|
158
|
+
if (tagName === 'textarea') {
|
|
159
|
+
const val = await inputEl.inputValue();
|
|
160
|
+
hasContent = val.length > 100;
|
|
161
|
+
} else {
|
|
162
|
+
const text = await inputEl.innerText();
|
|
163
|
+
hasContent = text.length > 100;
|
|
164
|
+
}
|
|
165
|
+
} catch {}
|
|
166
|
+
|
|
167
|
+
if (!hasContent) {
|
|
168
|
+
// Fallback: try typing a small prefix to trigger the input, then paste
|
|
169
|
+
console.warn('[manus-automate] Paste may have failed — trying Control+V fallback');
|
|
170
|
+
await inputEl.click();
|
|
171
|
+
await page.keyboard.press('Control+V'); // Linux/Windows fallback
|
|
172
|
+
await page.waitForTimeout(800);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
console.log('[manus-automate] Prompt entered. Looking for Send button...');
|
|
176
|
+
|
|
177
|
+
// Find and click the Send / Submit button
|
|
178
|
+
const sendSelectors = [
|
|
179
|
+
'button[type="submit"]',
|
|
180
|
+
'button:has-text("Send")',
|
|
181
|
+
'button:has-text("Run")',
|
|
182
|
+
'button:has-text("Submit")',
|
|
183
|
+
'button:has-text("Start")',
|
|
184
|
+
'[aria-label="Send"]',
|
|
185
|
+
'[aria-label="Run task"]',
|
|
186
|
+
'[data-testid="send-button"]',
|
|
187
|
+
];
|
|
188
|
+
|
|
189
|
+
let sent = false;
|
|
190
|
+
for (const sel of sendSelectors) {
|
|
191
|
+
try {
|
|
192
|
+
const el = page.locator(sel).first();
|
|
193
|
+
const enabled = await el.isEnabled({ timeout: 2000 });
|
|
194
|
+
const visible = await el.isVisible({ timeout: 1000 });
|
|
195
|
+
if (enabled && visible) {
|
|
196
|
+
await el.click();
|
|
197
|
+
console.log(`[manus-automate] Clicked Send (selector: ${sel})`);
|
|
198
|
+
sent = true;
|
|
199
|
+
break;
|
|
200
|
+
}
|
|
201
|
+
} catch {}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (!sent) {
|
|
205
|
+
// Try pressing Enter in the input as a fallback
|
|
206
|
+
console.warn('[manus-automate] No Send button found — pressing Enter');
|
|
207
|
+
await inputEl.press('Enter');
|
|
208
|
+
sent = true;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Wait for Manus to accept the task and navigate to a task URL
|
|
212
|
+
// The URL typically becomes something like manus.im/app/session/XXXXX
|
|
213
|
+
console.log('[manus-automate] Waiting for task to be created...');
|
|
214
|
+
await page.waitForTimeout(3000);
|
|
215
|
+
|
|
216
|
+
// Try to detect the task URL from the current page URL
|
|
217
|
+
let taskUrl = page.url();
|
|
218
|
+
let taskId = null;
|
|
219
|
+
|
|
220
|
+
// Poll up to 15s for the URL to change to a task-specific URL
|
|
221
|
+
for (let i = 0; i < 15; i++) {
|
|
222
|
+
const currentUrl = page.url();
|
|
223
|
+
const taskMatch = currentUrl.match(/manus\.im\/app\/(?:session|task)\/([a-zA-Z0-9_-]+)/);
|
|
224
|
+
if (taskMatch) {
|
|
225
|
+
taskUrl = currentUrl;
|
|
226
|
+
taskId = taskMatch[1];
|
|
227
|
+
console.log(`[manus-automate] Task created: ${taskUrl}`);
|
|
228
|
+
break;
|
|
229
|
+
}
|
|
230
|
+
await page.waitForTimeout(1000);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
if (!taskId) {
|
|
234
|
+
// Try to extract task ID from page content (Manus may store it in DOM)
|
|
235
|
+
try {
|
|
236
|
+
taskId = await page.evaluate(() => {
|
|
237
|
+
// Look for task ID in URL, data attributes, or text
|
|
238
|
+
const url = window.location.href;
|
|
239
|
+
const m = url.match(/\/(?:session|task)\/([a-zA-Z0-9_-]+)/);
|
|
240
|
+
if (m) return m[1];
|
|
241
|
+
|
|
242
|
+
// Try meta tags or window state
|
|
243
|
+
const metaTaskId = document.querySelector('[data-task-id]');
|
|
244
|
+
if (metaTaskId) return metaTaskId.getAttribute('data-task-id');
|
|
245
|
+
|
|
246
|
+
return null;
|
|
247
|
+
});
|
|
248
|
+
} catch {}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
if (!taskId) {
|
|
252
|
+
console.warn('[manus-automate] Could not extract task ID — task may still have been created');
|
|
253
|
+
// Return the current URL as best effort
|
|
254
|
+
return { taskUrl: page.url(), taskId: null };
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
return { taskUrl, taskId };
|
|
258
|
+
} finally {
|
|
259
|
+
// Don't close the page — let the user see Manus running in their browser
|
|
260
|
+
// page.close() would be rude here
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Poll a Manus task for status updates and report them back to HALO.
|
|
266
|
+
* Runs for up to 2 hours before giving up.
|
|
267
|
+
*
|
|
268
|
+
* @param {string} taskId - Manus task session/task ID
|
|
269
|
+
* @param {string} queueId - HALO apply_queue row ID
|
|
270
|
+
* @param {object} config - halo-agent config ({ apiUrl, token })
|
|
271
|
+
*/
|
|
272
|
+
async function pollManusTaskStatus(taskId, queueId, config) {
|
|
273
|
+
console.log(`[manus-automate] Polling Manus task ${taskId} for completion...`);
|
|
274
|
+
|
|
275
|
+
const POLL_INTERVAL = 10000; // 10s
|
|
276
|
+
const MAX_POLLS = 720; // 2 hours max
|
|
277
|
+
|
|
278
|
+
for (let i = 0; i < MAX_POLLS; i++) {
|
|
279
|
+
await new Promise(r => setTimeout(r, POLL_INTERVAL));
|
|
280
|
+
|
|
281
|
+
try {
|
|
282
|
+
// Ask HALO backend to check Manus task status (HALO holds the API key)
|
|
283
|
+
const res = await fetch(`${config.apiUrl}/manus/status/${taskId}`, {
|
|
284
|
+
headers: { Authorization: `Bearer ${config.token}` },
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
if (!res.ok) {
|
|
288
|
+
console.warn(`[manus-automate] Status poll failed (${res.status}) — retrying`);
|
|
289
|
+
continue;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const data = await res.json();
|
|
293
|
+
const status = (data.status || '').toLowerCase();
|
|
294
|
+
|
|
295
|
+
console.log(`[manus-automate] Task ${taskId} status: ${status} (poll ${i + 1})`);
|
|
296
|
+
|
|
297
|
+
// Map Manus status to HALO queue status
|
|
298
|
+
if (status === 'completed' || status === 'done' || status === 'finished') {
|
|
299
|
+
await reportQueueStatus(config, queueId, 'DONE');
|
|
300
|
+
console.log(`[manus-automate] Task ${taskId} completed successfully.`);
|
|
301
|
+
return 'DONE';
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
if (status === 'failed' || status === 'error') {
|
|
305
|
+
await reportQueueStatus(config, queueId, 'NEEDS_ATTENTION', {
|
|
306
|
+
needs_attention_reason: 'Manus task failed. Check manus.im for details.',
|
|
307
|
+
});
|
|
308
|
+
return 'NEEDS_ATTENTION';
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
if (status === 'cancelled') {
|
|
312
|
+
await reportQueueStatus(config, queueId, 'CANCELLED');
|
|
313
|
+
return 'CANCELLED';
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// Still running — update HALO with latest message snippet
|
|
317
|
+
const latestMsg = (data.recentMessages || [])[0];
|
|
318
|
+
if (latestMsg?.content) {
|
|
319
|
+
const snippet = String(latestMsg.content).slice(0, 200);
|
|
320
|
+
await reportQueueStatus(config, queueId, 'IN_PROGRESS', { progress_note: snippet });
|
|
321
|
+
}
|
|
322
|
+
} catch (e) {
|
|
323
|
+
console.warn(`[manus-automate] Poll error: ${e.message}`);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// Timed out
|
|
328
|
+
await reportQueueStatus(config, queueId, 'NEEDS_ATTENTION', {
|
|
329
|
+
needs_attention_reason: 'Manus task polling timed out after 2 hours. Check manus.im manually.',
|
|
330
|
+
});
|
|
331
|
+
return 'TIMEOUT';
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
async function reportQueueStatus(config, queueId, status, extra = {}) {
|
|
335
|
+
try {
|
|
336
|
+
await fetch(`${config.apiUrl}/apply-queue/${queueId}`, {
|
|
337
|
+
method: 'PATCH',
|
|
338
|
+
headers: {
|
|
339
|
+
Authorization: `Bearer ${config.token}`,
|
|
340
|
+
'Content-Type': 'application/json',
|
|
341
|
+
},
|
|
342
|
+
body: JSON.stringify({ status, ...extra }),
|
|
343
|
+
});
|
|
344
|
+
} catch (e) {
|
|
345
|
+
console.warn(`[manus-automate] Could not report status ${status}:`, e.message);
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
module.exports = { connectChrome, dispatchManusTask, pollManusTaskStatus };
|