@q-agent/agent 0.1.1 → 0.1.7

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.
@@ -48,15 +48,17 @@ const fs = __importStar(require("fs"));
48
48
  const os = __importStar(require("os"));
49
49
  const path = __importStar(require("path"));
50
50
  const api = __importStar(require("./api"));
51
+ const bus_1 = require("./bus");
51
52
  const ensureBrowser_1 = require("./ensureBrowser");
52
53
  const paths_1 = require("./paths");
53
54
  const playwrightConfig_1 = require("./playwrightConfig");
54
55
  const report_1 = require("./report");
55
56
  const session_1 = require("./session");
57
+ const version_1 = require("./version");
56
58
  // Mirrors api/app/config.py's Settings.exec_timeout_s / auth_capture_timeout_s.
57
59
  const EXEC_TIMEOUT_MS = 600_000;
58
60
  const AUTH_CAPTURE_TIMEOUT_MS = 300_000;
59
- const IDLE_POLL_MS = 3_000;
61
+ const IDLE_POLL_MS = 1_000;
60
62
  let activeChild = null;
61
63
  /** Kill whatever child process (capture browser or Playwright run) is active. Used by the CLI's SIGINT handler. */
62
64
  function killActiveChild() {
@@ -71,7 +73,7 @@ function killActiveChild() {
71
73
  }
72
74
  function nodePathEnv(nm) {
73
75
  const existing = process.env.NODE_PATH;
74
- return { ...process.env, NODE_PATH: existing ? `${nm}${path.delimiter}${existing}` : nm };
76
+ return { ...process.env, ...(0, paths_1.childNodeEnv)(), NODE_PATH: existing ? `${nm}${path.delimiter}${existing}` : nm };
75
77
  }
76
78
  function runProcess(cmd, args, cwd, env, timeoutMs) {
77
79
  return new Promise((resolve) => {
@@ -166,64 +168,88 @@ async function failAllResults(cfg, job, message) {
166
168
  const total = job.specs.length;
167
169
  await api.postComplete(cfg, job.executionId, { passed: 0, failed: total, log: message }).catch((err) => console.error("postComplete failed:", err));
168
170
  }
171
+ /** Reuse a valid local session, or capture one headed — shared by the run + heal paths. */
172
+ async function resolveJobAuth(cfg, job) {
173
+ if (!job.manualAuth)
174
+ return { storageState: "", sessionStoragePath: "" };
175
+ let origin = job.authOrigins[0] || "";
176
+ if (!origin && job.baseUrl) {
177
+ try {
178
+ origin = new URL(job.baseUrl).origin;
179
+ }
180
+ catch {
181
+ origin = "";
182
+ }
183
+ }
184
+ if (origin && (0, session_1.hasValidSession)(origin)) {
185
+ const paths = (0, session_1.sessionPathsForOrigin)(origin);
186
+ return { storageState: paths.storageStatePath, sessionStoragePath: paths.sessionStoragePath };
187
+ }
188
+ if (origin && job.baseUrl) {
189
+ const paths = (0, session_1.sessionPathsForOrigin)(origin);
190
+ await api.postEvent(cfg, job.executionId, "exec.auth.waiting", { url: job.baseUrl });
191
+ (0, bus_1.emit)("auth-waiting", { url: job.baseUrl });
192
+ const captured = await captureAuth(job.baseUrl, paths.storageStatePath, paths.sessionStoragePath);
193
+ if (captured) {
194
+ await api.postEvent(cfg, job.executionId, "exec.auth.captured", {});
195
+ (0, bus_1.emit)("auth-captured", {});
196
+ return { storageState: paths.storageStatePath, sessionStoragePath: paths.sessionStoragePath };
197
+ }
198
+ const message = "Manual login was not completed — enable/redo login capture";
199
+ await api.postEvent(cfg, job.executionId, "exec.auth.error", { message });
200
+ (0, bus_1.emit)("error", { message });
201
+ return { storageState: "", sessionStoragePath: "", error: message };
202
+ }
203
+ const message = "Set a base URL for the project first.";
204
+ await api.postEvent(cfg, job.executionId, "exec.auth.error", { message });
205
+ (0, bus_1.emit)("error", { message });
206
+ return { storageState: "", sessionStoragePath: "", error: message };
207
+ }
208
+ /** Best-effort: read the distilled DOM JSON from a parsed attachment list. */
209
+ function loadDistilledDom(workDir, attachments) {
210
+ const att = attachments.find((a) => a.kind === "dom-distilled");
211
+ if (!att)
212
+ return null;
213
+ const p = path.isAbsolute(att.path) ? att.path : path.join(workDir, att.path);
214
+ try {
215
+ return JSON.parse(fs.readFileSync(p, "utf-8"));
216
+ }
217
+ catch {
218
+ return null;
219
+ }
220
+ }
169
221
  /** Process one claimed job end-to-end: write specs/config, resolve auth, run Playwright, push results. */
170
222
  async function processJob(cfg, job) {
223
+ if (job.heal) {
224
+ await processHealJob(cfg, job, job.heal);
225
+ return;
226
+ }
171
227
  const workDir = fs.mkdtempSync(path.join(os.tmpdir(), "qagent-"));
228
+ // Set once the (detached) evidence uploader takes ownership of workDir — it
229
+ // then removes the dir when its uploads finish. Until then, this function's
230
+ // finally cleans up (error / early-return paths).
231
+ let handedOff = false;
172
232
  try {
173
233
  for (const spec of job.specs) {
174
234
  fs.writeFileSync(path.join(workDir, spec.filename), spec.code, "utf-8");
175
235
  }
176
- // ---- Resolve auth: reuse a valid local session, or capture one headed.
177
- let storageState = "";
178
- let sessionStoragePath = "";
179
- if (job.manualAuth) {
180
- let origin = job.authOrigins[0] || "";
181
- if (!origin && job.baseUrl) {
182
- try {
183
- origin = new URL(job.baseUrl).origin;
184
- }
185
- catch {
186
- origin = "";
187
- }
188
- }
189
- if (origin && (0, session_1.hasValidSession)(origin)) {
190
- const paths = (0, session_1.sessionPathsForOrigin)(origin);
191
- storageState = paths.storageStatePath;
192
- sessionStoragePath = paths.sessionStoragePath;
193
- }
194
- else if (origin && job.baseUrl) {
195
- const paths = (0, session_1.sessionPathsForOrigin)(origin);
196
- await api.postEvent(cfg, job.executionId, "exec.auth.waiting", { url: job.baseUrl });
197
- const captured = await captureAuth(job.baseUrl, paths.storageStatePath, paths.sessionStoragePath);
198
- if (captured) {
199
- storageState = paths.storageStatePath;
200
- sessionStoragePath = paths.sessionStoragePath;
201
- await api.postEvent(cfg, job.executionId, "exec.auth.captured", {});
202
- }
203
- else {
204
- const message = "Manual login was not completed — enable/redo login capture";
205
- await api.postEvent(cfg, job.executionId, "exec.auth.error", { message });
206
- await failAllResults(cfg, job, message);
207
- return;
208
- }
209
- }
210
- else {
211
- const message = "Set a base URL for the project first.";
212
- await api.postEvent(cfg, job.executionId, "exec.auth.error", { message });
213
- await failAllResults(cfg, job, message);
214
- return;
215
- }
236
+ const auth = await resolveJobAuth(cfg, job);
237
+ if (auth.error) {
238
+ await failAllResults(cfg, job, auth.error);
239
+ return;
216
240
  }
241
+ const { storageState, sessionStoragePath } = auth;
217
242
  (0, playwrightConfig_1.writeConfig)(workDir, job.workers, job.headless, job.baseUrl, storageState);
218
- // Replay captured sessionStorage (MSAL/SPA tokens) only when a manual-auth
219
- // session actually exists; otherwise normalize spec imports back to
220
- // '@playwright/test' (mirrors `_apply_auth_fixtures`'s `use_fixtures` gate).
221
- const useFixtures = Boolean(job.manualAuth && storageState && sessionStoragePath && fs.statSync(sessionStoragePath).size > 0);
222
- (0, playwrightConfig_1.applyAuthFixtures)(workDir, job.specs.map((s) => s.filename), sessionStoragePath || path.join(workDir, "sessionStorage.json"), useFixtures);
243
+ // Always inject the generated fixtures.ts (DOM capture on every run) + rewrite
244
+ // spec imports to it. sessionStorage replay (MSAL/SPA tokens) is additionally
245
+ // enabled only when a manual-auth session actually exists.
246
+ const replaySession = Boolean(job.manualAuth && storageState && sessionStoragePath && fs.statSync(sessionStoragePath).size > 0);
247
+ (0, playwrightConfig_1.applyFixtures)(workDir, job.specs.map((s) => s.filename), sessionStoragePath || path.join(workDir, "sessionStorage.json"), replaySession);
223
248
  const total = job.specs.length;
224
249
  for (let i = 0; i < job.specs.length; i++) {
225
250
  const { ticket, caseCode } = identityFor(job.specs[i]);
226
251
  await api.postEvent(cfg, job.executionId, "exec.case.running", { ticket, caseCode, index: i + 1, total });
252
+ (0, bus_1.emit)("case-running", { ticket, caseCode, index: i + 1, total });
227
253
  }
228
254
  // A single-spec job targets just that one file (the "run this test"
229
255
  // action); a multi-case job executes the whole suite — same distinction
@@ -255,6 +281,11 @@ async function processJob(cfg, job) {
255
281
  let passed = 0;
256
282
  let failed = 0;
257
283
  const matched = new Set();
284
+ // Evidence uploads are deferred until AFTER results + complete are posted:
285
+ // the browser has already closed when a test finishes, so the web should mark
286
+ // the case/run done immediately rather than waiting on (potentially multi-MB)
287
+ // video/trace/DOM uploads over the network.
288
+ const pendingEvidence = [];
258
289
  for (const spec of job.specs) {
259
290
  const entry = parsed.find((e) => path.basename(e.file) === spec.filename);
260
291
  if (!entry)
@@ -271,21 +302,6 @@ async function processJob(cfg, job) {
271
302
  passed++;
272
303
  else if (entry.status === "fail")
273
304
  failed++;
274
- for (const att of entry.attachments) {
275
- const filePath = path.isAbsolute(att.path) ? att.path : path.join(workDir, att.path);
276
- if (!fs.existsSync(filePath))
277
- continue;
278
- const { ticket, caseCode } = identityFor(spec);
279
- await api
280
- .postEvidence(cfg, job.executionId, {
281
- ticketExternalId: ticket,
282
- caseCode,
283
- kind: att.kind,
284
- filePath,
285
- filename: path.basename(filePath),
286
- })
287
- .catch((err) => console.error("postEvidence failed:", err));
288
- }
289
305
  const { ticket, caseCode } = identityFor(spec);
290
306
  await api.postEvent(cfg, job.executionId, "exec.case.result", {
291
307
  ticket,
@@ -293,6 +309,7 @@ async function processJob(cfg, job) {
293
309
  status: entry.status,
294
310
  durationMs,
295
311
  });
312
+ (0, bus_1.emit)("case-result", { ticket, caseCode, status: entry.status, durationMs });
296
313
  const progress = total ? Math.trunc((100 * matched.size) / total) : 100;
297
314
  await api.postEvent(cfg, job.executionId, "exec.progress", {
298
315
  progress,
@@ -300,6 +317,13 @@ async function processJob(cfg, job) {
300
317
  failed,
301
318
  remaining: total - matched.size,
302
319
  });
320
+ (0, bus_1.emit)("progress", { progress, passed, failed, remaining: total - matched.size });
321
+ for (const att of entry.attachments) {
322
+ const filePath = path.isAbsolute(att.path) ? att.path : path.join(workDir, att.path);
323
+ if (!fs.existsSync(filePath))
324
+ continue;
325
+ pendingEvidence.push({ ticket, caseCode, kind: att.kind, filePath });
326
+ }
303
327
  }
304
328
  // Any spec Playwright didn't report on (e.g. run_error) is marked failed.
305
329
  for (const spec of job.specs) {
@@ -325,6 +349,259 @@ async function processJob(cfg, job) {
325
349
  // matching the server's own log persistence.
326
350
  const logText = (procOutput || runError || "").slice(-20000);
327
351
  await api.postComplete(cfg, job.executionId, { passed, failed, log: logText });
352
+ (0, bus_1.emit)("job-complete", { executionId: job.executionId, passed, failed });
353
+ // Now that the run is marked done, upload the (deferred) evidence artifacts.
354
+ // These run DETACHED from the claim loop: the run's outcome is already
355
+ // reported, so the agent must be free to claim the next run immediately
356
+ // rather than blocking here until every (potentially multi-MB) artifact
357
+ // finishes uploading — that block is what stalled the agent when a new run
358
+ // was started mid-upload. The uploader owns workDir cleanup from here.
359
+ handedOff = true;
360
+ void uploadEvidenceThenCleanup(cfg, job.executionId, pendingEvidence, workDir);
361
+ }
362
+ finally {
363
+ if (!handedOff)
364
+ fs.rmSync(workDir, { recursive: true, force: true });
365
+ }
366
+ }
367
+ /**
368
+ * Upload a completed job's deferred evidence artifacts, then remove its workDir.
369
+ * Runs detached from the claim loop (see {@link processJob}) so uploads never
370
+ * delay claiming the next run. Never throws: each upload's failure is logged and
371
+ * the workDir is always removed, even if some uploads fail or time out.
372
+ */
373
+ async function uploadEvidenceThenCleanup(cfg, executionId, pendingEvidence, workDir) {
374
+ try {
375
+ for (const ev of pendingEvidence) {
376
+ await api
377
+ .postEvidence(cfg, executionId, {
378
+ ticketExternalId: ev.ticket,
379
+ caseCode: ev.caseCode,
380
+ kind: ev.kind,
381
+ filePath: ev.filePath,
382
+ filename: path.basename(ev.filePath),
383
+ })
384
+ .catch((err) => console.error("postEvidence failed:", err));
385
+ }
386
+ }
387
+ finally {
388
+ fs.rmSync(workDir, { recursive: true, force: true });
389
+ }
390
+ }
391
+ /**
392
+ * Process a standalone manual-login capture: open a headed browser at the
393
+ * project's base URL ON THIS MACHINE, let the operator log in, and save the
394
+ * session locally (keyed by origin) so subsequent runs reuse it. The session
395
+ * never leaves this machine — only the pass/fail outcome is reported back.
396
+ */
397
+ async function processCapture(cfg, capture) {
398
+ let origin = capture.origin;
399
+ if (!origin && capture.baseUrl) {
400
+ try {
401
+ origin = new URL(capture.baseUrl).origin;
402
+ }
403
+ catch {
404
+ origin = "";
405
+ }
406
+ }
407
+ if (!origin || !capture.baseUrl) {
408
+ await api.postCaptureComplete(cfg, capture.captureId, false, "Missing base URL / origin").catch(() => { });
409
+ return;
410
+ }
411
+ const paths = (0, session_1.sessionPathsForOrigin)(origin);
412
+ // Force a fresh login: remove any prior session so captureAuth's
413
+ // "storageState is non-empty" success check can't pass off a stale file
414
+ // (which would falsely report success without opening a browser).
415
+ try {
416
+ fs.rmSync(paths.storageStatePath, { force: true });
417
+ fs.rmSync(paths.sessionStoragePath, { force: true });
418
+ }
419
+ catch {
420
+ // best-effort
421
+ }
422
+ (0, bus_1.emit)("auth-waiting", { url: capture.baseUrl });
423
+ console.log(`Capturing login for ${capture.projectKey} at ${capture.baseUrl}`);
424
+ let ok = false;
425
+ try {
426
+ ok = await captureAuth(capture.baseUrl, paths.storageStatePath, paths.sessionStoragePath);
427
+ }
428
+ catch (err) {
429
+ console.error("Capture crashed:", err.message);
430
+ }
431
+ if (ok)
432
+ (0, bus_1.emit)("auth-captured", {});
433
+ else
434
+ (0, bus_1.emit)("error", { message: "Login was not captured — the window closed before a session was saved." });
435
+ await api
436
+ .postCaptureComplete(cfg, capture.captureId, ok, ok ? undefined : "Login was not captured")
437
+ .catch((err) => console.error("postCaptureComplete failed:", err));
438
+ }
439
+ /**
440
+ * Run the self-heal LOOP for one case locally (#260): run the spec + capture DOM,
441
+ * and while it fails ask the server to classify + fix it (Claude + KB live
442
+ * server-side), apply the returned code, and re-run — up to `maxAttempts`. Streams
443
+ * `heal.progress`, uploads the final attempt's result + evidence, then posts the
444
+ * outcome to `/agent/heal/{caseId}/finalize`.
445
+ */
446
+ async function processHealJob(cfg, job, heal) {
447
+ const spec = job.specs[0];
448
+ const { ticket, caseCode } = identityFor(spec);
449
+ const workDir = fs.mkdtempSync(path.join(os.tmpdir(), "qagent-heal-"));
450
+ const progress = (phase, attempt, message, error = "") => api
451
+ .postEvent(cfg, job.executionId, "heal.progress", {
452
+ caseId: heal.caseId, ticket, caseCode, attempt, maxAttempts: heal.maxAttempts,
453
+ phase, message, error: (error || "").slice(0, 600),
454
+ })
455
+ .catch(() => { });
456
+ let currentCode = spec.code;
457
+ let finalStatus = "fail";
458
+ let finalError = "";
459
+ let elapsedMs = 0;
460
+ let lastDom = null;
461
+ let lastAttachments = [];
462
+ let lastFixBefore = null;
463
+ let lastFixAfter = null;
464
+ let blockReason = "";
465
+ let gateReport = "";
466
+ const attempts = [];
467
+ try {
468
+ const auth = await resolveJobAuth(cfg, job);
469
+ if (auth.error) {
470
+ finalError = auth.error;
471
+ }
472
+ else {
473
+ const { storageState, sessionStoragePath } = auth;
474
+ for (let attempt = 1; attempt <= heal.maxAttempts; attempt++) {
475
+ fs.writeFileSync(path.join(workDir, spec.filename), currentCode, "utf-8");
476
+ (0, playwrightConfig_1.writeConfig)(workDir, 1, job.headless, job.baseUrl, storageState);
477
+ const replaySession = Boolean(job.manualAuth && storageState && sessionStoragePath && fs.statSync(sessionStoragePath).size > 0);
478
+ (0, playwrightConfig_1.applyFixtures)(workDir, [spec.filename], sessionStoragePath || path.join(workDir, "sessionStorage.json"), replaySession);
479
+ await progress("running", attempt, `Running spec (attempt ${attempt}/${heal.maxAttempts})`);
480
+ const started = Date.now();
481
+ const { stdout, stderr, timedOut } = await runPlaywright(workDir, 1, spec.filename);
482
+ elapsedMs = Date.now() - started;
483
+ const output = [stdout, stderr].filter(Boolean).join("\n").trim();
484
+ let entry;
485
+ const reportPath = path.join(workDir, "report.json");
486
+ if (!timedOut && fs.existsSync(reportPath)) {
487
+ try {
488
+ const parsed = (0, report_1.parsePlaywrightReport)(JSON.parse(fs.readFileSync(reportPath, "utf-8")));
489
+ entry = parsed.find((e) => path.basename(e.file) === spec.filename);
490
+ }
491
+ catch {
492
+ // fall through to the no-report error below
493
+ }
494
+ }
495
+ const rec = { attempt };
496
+ if (entry) {
497
+ lastAttachments = entry.attachments;
498
+ lastDom = loadDistilledDom(workDir, entry.attachments);
499
+ }
500
+ if (entry && entry.status === "pass") {
501
+ finalStatus = "pass";
502
+ finalError = "";
503
+ rec.status = "pass";
504
+ attempts.push(rec);
505
+ await progress("passed", attempt, "Spec passed");
506
+ break;
507
+ }
508
+ finalStatus = "fail";
509
+ finalError = entry
510
+ ? entry.error_message || output || "Test failed"
511
+ : timedOut
512
+ ? "Playwright run timed out"
513
+ : output || "No result reported by Playwright";
514
+ rec.status = "fail";
515
+ rec.error = finalError;
516
+ if (attempt >= heal.maxAttempts) {
517
+ attempts.push(rec);
518
+ await progress("failed", attempt, "Still failing after max attempts", finalError);
519
+ break;
520
+ }
521
+ await progress("fixing", attempt, "Asking the server to fix the spec", finalError);
522
+ let fix;
523
+ try {
524
+ fix = await api.postHealFix(cfg, heal.caseId, {
525
+ currentCode, error: finalError, output, domDistilled: lastDom, attempt,
526
+ });
527
+ }
528
+ catch (err) {
529
+ finalError = `Heal fix request failed: ${err.message}`;
530
+ rec.error = finalError;
531
+ attempts.push(rec);
532
+ await progress("failed", attempt, finalError, finalError);
533
+ break;
534
+ }
535
+ rec.action = fix.action;
536
+ if (fix.action === "product_defect") {
537
+ finalStatus = "product_defect";
538
+ rec.failureClass = fix.failureClass;
539
+ rec.reason = fix.reason;
540
+ attempts.push(rec);
541
+ await progress("product_defect", attempt, "Product defect suspected — routing to report", finalError);
542
+ break;
543
+ }
544
+ if (fix.action === "blocked") {
545
+ finalStatus = "blocked";
546
+ blockReason = fix.reason || "";
547
+ gateReport = fix.gate || "";
548
+ rec.gate = "blocked";
549
+ rec.error = fix.reason;
550
+ attempts.push(rec);
551
+ await progress("failed", attempt, "Fix blocked by placeholder gate", fix.reason || "");
552
+ break;
553
+ }
554
+ if (fix.action === "rejected") {
555
+ finalStatus = "fail";
556
+ rec.gate = "rejected";
557
+ rec.error = fix.reason;
558
+ attempts.push(rec);
559
+ await progress("failed", attempt, "Fix rejected", fix.reason || "");
560
+ break;
561
+ }
562
+ // action === "fixed": apply it and re-run.
563
+ rec.fixed = true;
564
+ rec.diff = fix.diff;
565
+ attempts.push(rec);
566
+ lastFixBefore = currentCode;
567
+ lastFixAfter = fix.code || currentCode;
568
+ currentCode = fix.code || currentCode;
569
+ }
570
+ }
571
+ // Report the final attempt's result + evidence against the heal execution.
572
+ const passResult = finalStatus === "pass";
573
+ await api
574
+ .postResult(cfg, job.executionId, {
575
+ file: spec.filename, status: passResult ? "pass" : "fail",
576
+ duration_ms: elapsedMs, error_message: passResult ? "" : finalError,
577
+ })
578
+ .catch((err) => console.error("postResult failed:", err));
579
+ for (const att of lastAttachments) {
580
+ const filePath = path.isAbsolute(att.path) ? att.path : path.join(workDir, att.path);
581
+ if (!fs.existsSync(filePath))
582
+ continue;
583
+ await api
584
+ .postEvidence(cfg, job.executionId, {
585
+ ticketExternalId: ticket, caseCode, kind: att.kind, filePath, filename: path.basename(filePath),
586
+ })
587
+ .catch((err) => console.error("postEvidence failed:", err));
588
+ }
589
+ await api
590
+ .postEvent(cfg, job.executionId, "exec.case.result", {
591
+ ticket, caseCode, status: passResult ? "pass" : "fail", durationMs: elapsedMs,
592
+ })
593
+ .catch(() => { });
594
+ await api
595
+ .postHealFinalize(cfg, heal.caseId, {
596
+ finalStatus, finalError, finalCode: currentCode,
597
+ blockReason, gateReport, domDistilled: lastDom,
598
+ lastFixBefore, lastFixAfter, attempts,
599
+ })
600
+ .catch((err) => console.error("postHealFinalize failed:", err));
601
+ await api
602
+ .postComplete(cfg, job.executionId, { passed: passResult ? 1 : 0, failed: passResult ? 0 : 1, log: finalError })
603
+ .catch((err) => console.error("postComplete failed:", err));
604
+ (0, bus_1.emit)("job-complete", { executionId: job.executionId, passed: passResult ? 1 : 0, failed: passResult ? 0 : 1 });
328
605
  }
329
606
  finally {
330
607
  fs.rmSync(workDir, { recursive: true, force: true });
@@ -339,7 +616,7 @@ async function runAgentLoop(cfg, signal) {
339
616
  console.error("Chromium is required to run tests — aborting.");
340
617
  return;
341
618
  }
342
- console.log(`Local Agent started — polling ${cfg.serverUrl} as device #${cfg.deviceId} (${cfg.deviceName})`);
619
+ console.log(`Local Agent v${(0, version_1.agentVersion)()} started — polling ${cfg.serverUrl} as device #${cfg.deviceId} (${cfg.deviceName})`);
343
620
  while (!signal.aborted) {
344
621
  let job = null;
345
622
  try {
@@ -349,16 +626,30 @@ async function runAgentLoop(cfg, signal) {
349
626
  console.error("Claim failed:", err.message);
350
627
  }
351
628
  if (!job) {
629
+ // No execution queued — check for a standalone login-capture request.
630
+ let capture = null;
631
+ try {
632
+ capture = await api.claimNextCapture(cfg);
633
+ }
634
+ catch (err) {
635
+ console.error("Capture claim failed:", err.message);
636
+ }
637
+ if (capture) {
638
+ await processCapture(cfg, capture);
639
+ continue;
640
+ }
352
641
  await new Promise((r) => setTimeout(r, IDLE_POLL_MS));
353
642
  continue;
354
643
  }
355
644
  console.log(`Claimed execution #${job.executionId} (run ${job.runCode}, ${job.specs.length} spec(s))`);
645
+ (0, bus_1.emit)("job-claimed", { executionId: job.executionId, runCode: job.runCode, total: job.specs.length });
356
646
  try {
357
647
  await processJob(cfg, job);
358
648
  console.log(`Execution #${job.executionId} complete`);
359
649
  }
360
650
  catch (err) {
361
651
  console.error(`Execution #${job.executionId} crashed:`, err);
652
+ (0, bus_1.emit)("error", { message: `Execution #${job.executionId} crashed: ${err.message}` });
362
653
  await api
363
654
  .postComplete(cfg, job.executionId, {
364
655
  passed: 0,