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.
@@ -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 };