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/index.js ADDED
@@ -0,0 +1,360 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ /**
5
+ * HALO Local Agent
6
+ *
7
+ * Usage:
8
+ * npx halo-agent init — first-time setup (saves config to ~/.halo-agent/config.json)
9
+ * npx halo-agent start — start polling for queued jobs
10
+ *
11
+ * The agent connects to your real Chrome browser via CDP (port 9222).
12
+ * It uses your real session — your cookies, IP, fingerprint.
13
+ * No headless browser. No bot detection.
14
+ */
15
+
16
+ const { loadConfig, saveConfig } = require('./config');
17
+ const { connectToChrome } = require('./browser');
18
+ const { startPolling } = require('./poller');
19
+ const { startLocalServer } = require('./localServer');
20
+
21
+ const [,, command, ...cmdArgs] = process.argv;
22
+
23
+ async function main() {
24
+ if (command === 'init') {
25
+ await runInit();
26
+ } else if (command === 'pair') {
27
+ await runPair(cmdArgs[0]);
28
+ } else if (command === 'start' || !command) {
29
+ await runStart();
30
+ } else if (command === 'token') {
31
+ await runUpdateToken(cmdArgs[0]);
32
+ } else if (command === 'install-autostart') {
33
+ await runInstallAutostart();
34
+ } else if (command === 'uninstall-autostart') {
35
+ await runUninstallAutostart();
36
+ } else {
37
+ console.log('Usage:');
38
+ console.log(' halo-agent pair <code> — one-click pair with the dashboard');
39
+ console.log(' halo-agent start — start the agent');
40
+ console.log(' halo-agent install-autostart — auto-start at login');
41
+ console.log(' halo-agent uninstall-autostart — remove login auto-start');
42
+ console.log(' halo-agent init — first-time setup (legacy: token paste)');
43
+ console.log(' halo-agent token <value> — update auth token');
44
+ process.exit(1);
45
+ }
46
+ }
47
+
48
+ async function runUpdateToken(newToken) {
49
+ const { loadConfig, saveConfig } = require('./config');
50
+ const token = newToken || (() => {
51
+ // Read from stdin if not passed as arg
52
+ const readline = require('readline');
53
+ return new Promise(r => {
54
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
55
+ rl.question('Paste new token: ', ans => { rl.close(); r(ans.trim()); });
56
+ });
57
+ })();
58
+ const resolved = typeof token === 'string' ? token : await token;
59
+ const config = loadConfig() || {};
60
+ config.token = resolved.trim();
61
+ saveConfig(config);
62
+ console.log('Token updated.');
63
+ }
64
+
65
+ // One-click pairing. The user generates a 6-char code in the dashboard
66
+ // (Profile > Connect agent), then runs `halo-agent pair <code>`. We exchange
67
+ // it for a long-lived session token and save it. Far better UX than asking
68
+ // the user to copy-paste an API token.
69
+ async function runPair(code) {
70
+ if (!code) {
71
+ console.error('Usage: halo-agent pair <code>');
72
+ console.error('Get a code from the HALO dashboard: Profile -> Connect agent.');
73
+ process.exit(1);
74
+ }
75
+ const trimmed = String(code).trim().toUpperCase();
76
+
77
+ // Default API URL — overridable via env for self-hosted Workers.
78
+ const apiUrl = process.env.HALO_API_URL || 'https://halo-apply-os.amoghi2tb.workers.dev';
79
+
80
+ console.log(`\nPairing with ${apiUrl}...`);
81
+ let res;
82
+ try {
83
+ res = await fetch(`${apiUrl}/agent-pair/claim`, {
84
+ method: 'POST',
85
+ headers: { 'Content-Type': 'application/json' },
86
+ body: JSON.stringify({ code: trimmed }),
87
+ });
88
+ } catch (e) {
89
+ console.error('Network error reaching HALO:', e.message);
90
+ process.exit(1);
91
+ }
92
+
93
+ if (!res.ok) {
94
+ let msg = `HTTP ${res.status}`;
95
+ try { const j = await res.json(); msg = j.error || msg; } catch {}
96
+ console.error(`Pair failed: ${msg}`);
97
+ console.error('Codes expire 5 minutes after they are generated — get a fresh one if needed.');
98
+ process.exit(1);
99
+ }
100
+ const data = await res.json();
101
+ if (!data.token) { console.error('Pair response missing token.'); process.exit(1); }
102
+
103
+ const { loadConfig, saveConfig } = require('./config');
104
+ const existing = loadConfig() || {};
105
+ const config = {
106
+ ...existing,
107
+ apiUrl: data.api_url || apiUrl,
108
+ token: data.token,
109
+ userId: data.user_id,
110
+ };
111
+ saveConfig(config);
112
+
113
+ console.log('\nPaired. Token saved to ~/.halo-agent/config.json');
114
+ console.log('Run `halo-agent start` to connect the agent.\n');
115
+ }
116
+
117
+ // Auto-start at login. The user picked "always on" — once installed, the
118
+ // agent reconnects automatically at boot and stays connected. Platform-
119
+ // specific service hooks:
120
+ // macOS -> LaunchAgent plist in ~/Library/LaunchAgents
121
+ // Windows -> .vbs in the user's Startup folder (runs node detached)
122
+ // Linux -> systemd --user unit
123
+ //
124
+ // On install we also write a shell wrapper that resolves the node + agent
125
+ // paths at install time so the service doesn't depend on the user's shell
126
+ // PATH at boot. The wrapper logs to ~/.halo-agent/agent.log.
127
+ async function runInstallAutostart() {
128
+ const fs = require('fs');
129
+ const path = require('path');
130
+ const os = require('os');
131
+ const { CONFIG_DIR } = require('./config');
132
+
133
+ if (!fs.existsSync(CONFIG_DIR)) fs.mkdirSync(CONFIG_DIR, { recursive: true });
134
+
135
+ // Resolve the agent's entry path at install time so the service
136
+ // doesn't break if `npx` cache moves.
137
+ const nodePath = process.execPath; // e.g. /usr/local/bin/node
138
+ const agentPath = path.resolve(__filename);
139
+ const logPath = path.join(CONFIG_DIR, 'agent.log');
140
+
141
+ if (process.platform === 'darwin') {
142
+ const plistDir = path.join(os.homedir(), 'Library', 'LaunchAgents');
143
+ const plistPath = path.join(plistDir, 'com.halo.agent.plist');
144
+ if (!fs.existsSync(plistDir)) fs.mkdirSync(plistDir, { recursive: true });
145
+ const plist = `<?xml version="1.0" encoding="UTF-8"?>
146
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
147
+ <plist version="1.0">
148
+ <dict>
149
+ <key>Label</key><string>com.halo.agent</string>
150
+ <key>ProgramArguments</key>
151
+ <array>
152
+ <string>${nodePath}</string>
153
+ <string>${agentPath}</string>
154
+ <string>start</string>
155
+ </array>
156
+ <key>RunAtLoad</key><true/>
157
+ <key>KeepAlive</key><true/>
158
+ <key>StandardOutPath</key><string>${logPath}</string>
159
+ <key>StandardErrorPath</key><string>${logPath}</string>
160
+ </dict>
161
+ </plist>`;
162
+ fs.writeFileSync(plistPath, plist);
163
+ // Load it so it starts immediately (and auto-loads on subsequent logins).
164
+ try {
165
+ const { spawnSync } = require('child_process');
166
+ spawnSync('launchctl', ['unload', plistPath], { stdio: 'ignore' });
167
+ spawnSync('launchctl', ['load', plistPath], { stdio: 'ignore' });
168
+ } catch { /* user can `launchctl load` manually */ }
169
+ console.log(`\nInstalled login auto-start (macOS LaunchAgent).`);
170
+ console.log(` plist: ${plistPath}`);
171
+ console.log(` logs: ${logPath}`);
172
+ console.log(`The agent will start automatically at login. It's running now.`);
173
+ return;
174
+ }
175
+
176
+ if (process.platform === 'win32') {
177
+ // A .vbs in the Startup folder runs the node process hidden (no
178
+ // terminal flash). Simpler than schtasks; works for per-user always-on.
179
+ const startupDir = path.join(process.env.APPDATA || os.homedir(), 'Microsoft', 'Windows', 'Start Menu', 'Programs', 'Startup');
180
+ if (!fs.existsSync(startupDir)) fs.mkdirSync(startupDir, { recursive: true });
181
+ const vbsPath = path.join(startupDir, 'halo-agent.vbs');
182
+ const vbs = `Set WshShell = CreateObject("WScript.Shell")
183
+ WshShell.Run """${nodePath.replace(/"/g, '""')}"" ""${agentPath.replace(/"/g, '""')}"" start", 0, False
184
+ Set WshShell = Nothing`;
185
+ fs.writeFileSync(vbsPath, vbs);
186
+ console.log(`\nInstalled login auto-start (Windows Startup folder).`);
187
+ console.log(` vbs: ${vbsPath}`);
188
+ console.log(` logs: ${logPath} (agent appends its own output)`);
189
+ console.log(`The agent will start at your next login. Run \`halo-agent start\` now to begin this session.`);
190
+ return;
191
+ }
192
+
193
+ // Linux: systemd --user unit. Requires systemd (most modern distros).
194
+ const unitDir = path.join(os.homedir(), '.config', 'systemd', 'user');
195
+ if (!fs.existsSync(unitDir)) fs.mkdirSync(unitDir, { recursive: true });
196
+ const unitPath = path.join(unitDir, 'halo-agent.service');
197
+ const unit = `[Unit]
198
+ Description=HALO local apply agent
199
+ After=network-online.target
200
+
201
+ [Service]
202
+ Type=simple
203
+ ExecStart=${nodePath} ${agentPath} start
204
+ Restart=on-failure
205
+ RestartSec=10
206
+ StandardOutput=append:${logPath}
207
+ StandardError=append:${logPath}
208
+
209
+ [Install]
210
+ WantedBy=default.target
211
+ `;
212
+ fs.writeFileSync(unitPath, unit);
213
+ try {
214
+ const { spawnSync } = require('child_process');
215
+ spawnSync('systemctl', ['--user', 'daemon-reload'], { stdio: 'ignore' });
216
+ spawnSync('systemctl', ['--user', 'enable', '--now', 'halo-agent.service'], { stdio: 'ignore' });
217
+ // Linger so the unit survives logout — best effort.
218
+ spawnSync('loginctl', ['enable-linger', os.userInfo().username], { stdio: 'ignore' });
219
+ } catch { /* user can enable manually */ }
220
+ console.log(`\nInstalled login auto-start (systemd --user).`);
221
+ console.log(` unit: ${unitPath}`);
222
+ console.log(` logs: ${logPath}`);
223
+ console.log(`The agent should be running now: systemctl --user status halo-agent`);
224
+ }
225
+
226
+ async function runUninstallAutostart() {
227
+ const fs = require('fs');
228
+ const path = require('path');
229
+ const os = require('os');
230
+
231
+ if (process.platform === 'darwin') {
232
+ const plistPath = path.join(os.homedir(), 'Library', 'LaunchAgents', 'com.halo.agent.plist');
233
+ try {
234
+ const { spawnSync } = require('child_process');
235
+ spawnSync('launchctl', ['unload', plistPath], { stdio: 'ignore' });
236
+ } catch {}
237
+ if (fs.existsSync(plistPath)) fs.unlinkSync(plistPath);
238
+ console.log('Removed macOS LaunchAgent.');
239
+ return;
240
+ }
241
+ if (process.platform === 'win32') {
242
+ const vbsPath = path.join(process.env.APPDATA || os.homedir(), 'Microsoft', 'Windows', 'Start Menu', 'Programs', 'Startup', 'halo-agent.vbs');
243
+ if (fs.existsSync(vbsPath)) fs.unlinkSync(vbsPath);
244
+ console.log('Removed Windows Startup entry.');
245
+ return;
246
+ }
247
+ try {
248
+ const { spawnSync } = require('child_process');
249
+ spawnSync('systemctl', ['--user', 'disable', '--now', 'halo-agent.service'], { stdio: 'ignore' });
250
+ } catch {}
251
+ const unitPath = path.join(os.homedir(), '.config', 'systemd', 'user', 'halo-agent.service');
252
+ if (fs.existsSync(unitPath)) fs.unlinkSync(unitPath);
253
+ console.log('Removed systemd --user unit.');
254
+ }
255
+
256
+ async function runInit() {
257
+ const readline = require('readline');
258
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
259
+ const ask = (q) => new Promise(r => rl.question(q, r));
260
+
261
+ console.log('\nHALO Agent Setup\n');
262
+ console.log('You need your HALO API token and (optionally) a CapSolver API key.\n');
263
+
264
+ const apiUrl = await ask('HALO API URL [https://halo-apply-os.workers.dev]: ');
265
+ const token = await ask('HALO API token (from Settings in dashboard): ');
266
+ const captchaKey = await ask('CapSolver API key (optional, press Enter to skip): ');
267
+ const anthropicKey = await ask('Anthropic API key for vision fallback (optional): ');
268
+ const autoSubmit = (await ask('Auto-submit without confirmation? [y/N]: ')).toLowerCase() === 'y';
269
+
270
+ console.log('\nCloudflare Access (if your Worker is behind CF Access):');
271
+ console.log('Get these from: Cloudflare Zero Trust > Access > Service Auth > Create Service Token');
272
+ const cfClientId = await ask('CF Access Client ID (optional, press Enter to skip): ');
273
+ const cfClientSecret = await ask('CF Access Client Secret (optional, press Enter to skip): ');
274
+
275
+ rl.close();
276
+
277
+ const config = {
278
+ apiUrl: apiUrl.trim() || 'https://halo-apply-os.workers.dev',
279
+ token: token.trim(),
280
+ captchaApiKey: captchaKey.trim() || null,
281
+ anthropicApiKey: anthropicKey.trim() || null,
282
+ autoSubmit,
283
+ typingSpeed: 'normal',
284
+ useVisionFallback: true,
285
+ cfAccessClientId: cfClientId.trim() || null,
286
+ cfAccessClientSecret: cfClientSecret.trim() || null,
287
+ };
288
+
289
+ saveConfig(config);
290
+ console.log('\nConfig saved to ~/.halo-agent/config.json');
291
+ console.log('Run "halo-agent start" to begin.\n');
292
+ }
293
+
294
+ async function runStart() {
295
+ const config = loadConfig();
296
+ if (!config || !config.token) {
297
+ console.error('No config found. Run "halo-agent init" first.');
298
+ process.exit(1);
299
+ }
300
+
301
+ console.log('\nHALO Agent starting...');
302
+ console.log('Connecting to your Chrome browser...\n');
303
+
304
+ // Pre-check: if Chrome is running WITHOUT debug port, warn immediately
305
+ const { isChromeDebuggable } = require('./browser');
306
+ const alreadyDebuggable = await isChromeDebuggable();
307
+ if (!alreadyDebuggable) {
308
+ const { execSync } = require('child_process');
309
+ let chromeRunning = false;
310
+ let chromeHasDebugFlag = false;
311
+ try {
312
+ execSync('pgrep -x "Google Chrome"', { stdio: 'ignore' });
313
+ chromeRunning = true;
314
+ } catch {}
315
+ if (chromeRunning) {
316
+ try {
317
+ const psOut = execSync('ps aux', { encoding: 'utf8' });
318
+ chromeHasDebugFlag = psOut.includes('--remote-debugging-port=9222');
319
+ } catch {}
320
+ }
321
+ if (chromeRunning && !chromeHasDebugFlag) {
322
+ console.error('Chrome is already running WITHOUT remote debugging enabled.\n');
323
+ console.error('You need to fully quit Chrome first, then relaunch it with the debug flag.\n');
324
+ console.error('Run these commands:\n');
325
+ console.error(' pkill -a -i "Google Chrome"');
326
+ console.error(' sleep 2');
327
+ console.error(' "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" --remote-debugging-port=9222 --profile-directory=Default &');
328
+ console.error(' sleep 4');
329
+ console.error(' node index.js start\n');
330
+ process.exit(1);
331
+ }
332
+ }
333
+
334
+ let chromeConn;
335
+ try {
336
+ chromeConn = await connectToChrome();
337
+ console.log('\nConnected to Chrome. Polling for queued jobs...');
338
+ console.log('Go to your HALO dashboard and click "Auto-Apply" on any job.\n');
339
+ } catch (err) {
340
+ console.error('\nCould not connect to Chrome:', err.message);
341
+ process.exit(1);
342
+ }
343
+
344
+ // Start the local HTTP server so the HALO dashboard can trigger Manus automation
345
+ startLocalServer();
346
+
347
+ // Graceful shutdown
348
+ process.on('SIGINT', async () => {
349
+ console.log('\nShutting down...');
350
+ try { await chromeConn.browser.close(); } catch {}
351
+ process.exit(0);
352
+ });
353
+
354
+ await startPolling(chromeConn, config);
355
+ }
356
+
357
+ main().catch(err => {
358
+ console.error('Fatal error:', err.message);
359
+ process.exit(1);
360
+ });
package/localServer.js ADDED
@@ -0,0 +1,270 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * localServer.js
5
+ *
6
+ * Lightweight HTTP server that runs on localhost:7788.
7
+ *
8
+ * Endpoints:
9
+ * GET /health — liveness check
10
+ * POST /manus-automate — dispatch a prompt to Manus
11
+ * POST /fill — extension-triggered fill (Architecture 4 handoff)
12
+ * GET /fill-status/:fillId — poll fill progress from extension
13
+ * POST /fill-confirm/:fillId — user confirmed submit from extension overlay
14
+ */
15
+
16
+ const http = require('http');
17
+ const { connectChrome, dispatchManusTask, pollManusTaskStatus } = require('./manusAutomate');
18
+ const { runExtensionFill } = require('./orchestrator');
19
+ const { loadConfig, cfAccessHeaders } = require('./config');
20
+
21
+ const PORT = 7788;
22
+ let chromeConn = null; // reused across requests
23
+
24
+ // In-memory fill session map: fillId -> { state, message, fieldsFilled, reviewScreenshot, error, confirmResolve }
25
+ const fillSessions = new Map();
26
+
27
+ function readBody(req) {
28
+ return new Promise((resolve, reject) => {
29
+ let body = '';
30
+ req.on('data', chunk => { body += chunk; });
31
+ req.on('end', () => {
32
+ try { resolve(JSON.parse(body || '{}')); }
33
+ catch (e) { reject(new Error('Invalid JSON: ' + e.message)); }
34
+ });
35
+ req.on('error', reject);
36
+ });
37
+ }
38
+
39
+ function send(res, status, data) {
40
+ const body = JSON.stringify(data);
41
+ res.writeHead(status, {
42
+ 'Content-Type': 'application/json',
43
+ 'Access-Control-Allow-Origin': '*',
44
+ 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
45
+ 'Access-Control-Allow-Headers': 'Content-Type, Authorization',
46
+ });
47
+ res.end(body);
48
+ }
49
+
50
+ async function ensureChromeConn() {
51
+ if (chromeConn) {
52
+ // Verify connection is still alive
53
+ try {
54
+ chromeConn.browser.contexts(); // will throw if disconnected
55
+ return chromeConn;
56
+ } catch {
57
+ chromeConn = null;
58
+ }
59
+ }
60
+ chromeConn = await connectChrome();
61
+ return chromeConn;
62
+ }
63
+
64
+ async function handleRequest(req, res) {
65
+ const url = new URL(req.url, `http://localhost:${PORT}`);
66
+
67
+ // CORS preflight
68
+ if (req.method === 'OPTIONS') {
69
+ send(res, 204, {});
70
+ return;
71
+ }
72
+
73
+ // GET /health
74
+ if (req.method === 'GET' && url.pathname === '/health') {
75
+ let chromeOk = false;
76
+ try {
77
+ await ensureChromeConn();
78
+ chromeOk = true;
79
+ } catch {}
80
+ send(res, 200, { ok: true, chrome: chromeOk, port: PORT });
81
+ return;
82
+ }
83
+
84
+ // POST /manus-automate
85
+ if (req.method === 'POST' && url.pathname === '/manus-automate') {
86
+ let body;
87
+ try {
88
+ body = await readBody(req);
89
+ } catch (e) {
90
+ send(res, 400, { error: e.message });
91
+ return;
92
+ }
93
+
94
+ const { prompt, jobId, queueId, apiUrl, token } = body;
95
+ if (!prompt) { send(res, 400, { error: 'prompt is required' }); return; }
96
+
97
+ // Respond immediately so the dashboard doesn't wait (automation runs async)
98
+ send(res, 202, { ok: true, message: 'Manus automation started', jobId, queueId });
99
+
100
+ // Run automation in the background
101
+ setImmediate(async () => {
102
+ try {
103
+ console.log(`[local-server] Starting Manus automation for job ${jobId}...`);
104
+
105
+ const conn = await ensureChromeConn();
106
+ const result = await dispatchManusTask(prompt, conn);
107
+
108
+ const cfg = loadConfig() || {};
109
+ const cfHeaders = cfAccessHeaders(cfg);
110
+
111
+ if ('error' in result) {
112
+ console.error('[local-server] dispatchManusTask failed:', result.error);
113
+ if (queueId && apiUrl && token) {
114
+ await fetch(`${apiUrl}/apply-queue/${queueId}`, {
115
+ method: 'PATCH',
116
+ headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', ...cfHeaders },
117
+ body: JSON.stringify({ status: 'NEEDS_ATTENTION', needs_attention_reason: result.error }),
118
+ }).catch(() => {});
119
+ }
120
+ return;
121
+ }
122
+
123
+ console.log(`[local-server] Task dispatched: taskId=${result.taskId}, url=${result.taskUrl}`);
124
+
125
+ // Report task URL back to HALO (store on queue item as submission_id / note)
126
+ if (queueId && apiUrl && token) {
127
+ await fetch(`${apiUrl}/apply-queue/${queueId}`, {
128
+ method: 'PATCH',
129
+ headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', ...cfHeaders },
130
+ body: JSON.stringify({
131
+ status: 'IN_PROGRESS',
132
+ submission_id: result.taskId || null,
133
+ progress_note: result.taskUrl || null,
134
+ }),
135
+ }).catch(() => {});
136
+ }
137
+
138
+ // Poll Manus for task completion and report back
139
+ if (result.taskId && apiUrl && token) {
140
+ await pollManusTaskStatus(result.taskId, queueId, { apiUrl, token, cfHeaders });
141
+ } else {
142
+ console.warn('[local-server] No taskId extracted — cannot poll for completion');
143
+ }
144
+ } catch (e) {
145
+ console.error('[local-server] Automation error:', e.message);
146
+ const cfg = loadConfig() || {};
147
+ if (queueId && apiUrl && token) {
148
+ await fetch(`${apiUrl}/apply-queue/${queueId}`, {
149
+ method: 'PATCH',
150
+ headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', ...cfAccessHeaders(cfg) },
151
+ body: JSON.stringify({ status: 'NEEDS_ATTENTION', needs_attention_reason: e.message }),
152
+ }).catch(() => {});
153
+ }
154
+ }
155
+ });
156
+
157
+ return;
158
+ }
159
+
160
+ // POST /fill — extension hands off a job page for agent filling
161
+ if (req.method === 'POST' && url.pathname === '/fill') {
162
+ let body;
163
+ try { body = await readBody(req); } catch (e) { send(res, 400, { error: e.message }); return; }
164
+
165
+ const { apply_url, job_id, ats_type, packet, config: extConfig } = body;
166
+ if (!apply_url) { send(res, 400, { error: 'apply_url is required' }); return; }
167
+
168
+ const fillId = 'fill_' + Date.now() + '_' + Math.random().toString(36).slice(2, 8);
169
+ fillSessions.set(fillId, {
170
+ state: 'ANALYZING',
171
+ message: 'Starting agent fill...',
172
+ fieldsFilled: 0,
173
+ reviewScreenshot: null,
174
+ error: null,
175
+ confirmResolve: null,
176
+ });
177
+
178
+ // Respond immediately — fill runs async
179
+ send(res, 202, { ok: true, fill_id: fillId });
180
+
181
+ // Run fill in background
182
+ setImmediate(async () => {
183
+ const session = fillSessions.get(fillId);
184
+ const reportFillStatus = (state, extra = {}) => {
185
+ Object.assign(session, { state, ...extra });
186
+ };
187
+
188
+ try {
189
+ const conn = await ensureChromeConn();
190
+ await runExtensionFill({
191
+ fillId,
192
+ apply_url,
193
+ job_id,
194
+ ats_type,
195
+ packet,
196
+ extConfig,
197
+ chromeConn: conn,
198
+ reportFillStatus,
199
+ waitForConfirm: () => new Promise(resolve => { session.confirmResolve = resolve; }),
200
+ });
201
+ } catch (e) {
202
+ console.error(`[local-server] Fill ${fillId} error:`, e.message);
203
+ const s = fillSessions.get(fillId);
204
+ if (s) { s.state = 'FAILED'; s.error = e.message; }
205
+ }
206
+ });
207
+
208
+ return;
209
+ }
210
+
211
+ // GET /fill-status/:fillId — extension polls this every 2s
212
+ const fillStatusMatch = url.pathname.match(/^\/fill-status\/(.+)$/);
213
+ if (req.method === 'GET' && fillStatusMatch) {
214
+ const fillId = fillStatusMatch[1];
215
+ const session = fillSessions.get(fillId);
216
+ if (!session) { send(res, 404, { error: 'Fill session not found' }); return; }
217
+ send(res, 200, {
218
+ state: session.state,
219
+ message: session.message,
220
+ fields_filled: session.fieldsFilled,
221
+ review_screenshot: session.reviewScreenshot || null,
222
+ error: session.error || null,
223
+ });
224
+ return;
225
+ }
226
+
227
+ // POST /fill-confirm/:fillId — user confirmed submit from extension overlay
228
+ const fillConfirmMatch = url.pathname.match(/^\/fill-confirm\/(.+)$/);
229
+ if (req.method === 'POST' && fillConfirmMatch) {
230
+ const fillId = fillConfirmMatch[1];
231
+ const session = fillSessions.get(fillId);
232
+ if (!session) { send(res, 404, { error: 'Fill session not found' }); return; }
233
+ if (session.confirmResolve) {
234
+ session.confirmResolve();
235
+ session.confirmResolve = null;
236
+ }
237
+ send(res, 200, { ok: true });
238
+ return;
239
+ }
240
+
241
+ send(res, 404, { error: 'Not found' });
242
+ }
243
+
244
+ function startLocalServer() {
245
+ const server = http.createServer(async (req, res) => {
246
+ try {
247
+ await handleRequest(req, res);
248
+ } catch (e) {
249
+ console.error('[local-server] Unhandled error:', e.message);
250
+ try { send(res, 500, { error: e.message }); } catch {}
251
+ }
252
+ });
253
+
254
+ server.listen(PORT, '127.0.0.1', () => {
255
+ console.log(`[local-server] HALO agent server running on http://127.0.0.1:${PORT}`);
256
+ console.log(`[local-server] Dashboard will send Auto-Apply requests here.`);
257
+ });
258
+
259
+ server.on('error', (e) => {
260
+ if (e.code === 'EADDRINUSE') {
261
+ console.warn(`[local-server] Port ${PORT} already in use — another agent instance may be running.`);
262
+ } else {
263
+ console.error('[local-server] Server error:', e.message);
264
+ }
265
+ });
266
+
267
+ return server;
268
+ }
269
+
270
+ module.exports = { startLocalServer };