site-agent-pro 1.0.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.
Files changed (81) hide show
  1. package/README.md +689 -0
  2. package/dist/auth/credentialStore.js +62 -0
  3. package/dist/auth/inbox.js +193 -0
  4. package/dist/auth/profile.js +379 -0
  5. package/dist/auth/runner.js +1124 -0
  6. package/dist/backend/dashboardData.js +194 -0
  7. package/dist/backend/runArtifacts.js +48 -0
  8. package/dist/backend/runRepository.js +93 -0
  9. package/dist/bin.js +2 -0
  10. package/dist/cli/backfillSiteChecks.js +143 -0
  11. package/dist/cli/run.js +309 -0
  12. package/dist/cli/trade.js +69 -0
  13. package/dist/config.js +199 -0
  14. package/dist/core/agentProfiles.js +55 -0
  15. package/dist/core/aggregateReport.js +382 -0
  16. package/dist/core/audit.js +30 -0
  17. package/dist/core/customTaskSuite.js +148 -0
  18. package/dist/core/evaluator.js +217 -0
  19. package/dist/core/executor.js +788 -0
  20. package/dist/core/fallbackReport.js +335 -0
  21. package/dist/core/formHeuristics.js +411 -0
  22. package/dist/core/gameplaySummary.js +164 -0
  23. package/dist/core/interaction.js +202 -0
  24. package/dist/core/pageState.js +201 -0
  25. package/dist/core/planner.js +1669 -0
  26. package/dist/core/processSubmissionBatch.js +204 -0
  27. package/dist/core/runAuditJob.js +170 -0
  28. package/dist/core/runner.js +2352 -0
  29. package/dist/core/siteBrief.js +107 -0
  30. package/dist/core/siteChecks.js +1526 -0
  31. package/dist/core/taskDirectives.js +279 -0
  32. package/dist/core/taskHeuristics.js +263 -0
  33. package/dist/dashboard/client.js +1256 -0
  34. package/dist/dashboard/contracts.js +95 -0
  35. package/dist/dashboard/narrative.js +277 -0
  36. package/dist/dashboard/server.js +458 -0
  37. package/dist/dashboard/theme.js +888 -0
  38. package/dist/index.js +84 -0
  39. package/dist/llm/client.js +188 -0
  40. package/dist/paystack/account.js +123 -0
  41. package/dist/paystack/client.js +100 -0
  42. package/dist/paystack/index.js +13 -0
  43. package/dist/paystack/test-paystack.js +83 -0
  44. package/dist/paystack/transfer.js +138 -0
  45. package/dist/paystack/types.js +74 -0
  46. package/dist/paystack/webhook.js +121 -0
  47. package/dist/prompts/browserAgent.js +124 -0
  48. package/dist/prompts/reviewer.js +71 -0
  49. package/dist/reporting/clickReplay.js +290 -0
  50. package/dist/reporting/html.js +930 -0
  51. package/dist/reporting/markdown.js +238 -0
  52. package/dist/reporting/template.js +1141 -0
  53. package/dist/schemas/types.js +361 -0
  54. package/dist/submissions/customTasks.js +196 -0
  55. package/dist/submissions/html.js +770 -0
  56. package/dist/submissions/model.js +56 -0
  57. package/dist/submissions/publicUrl.js +76 -0
  58. package/dist/submissions/service.js +74 -0
  59. package/dist/submissions/store.js +37 -0
  60. package/dist/submissions/types.js +65 -0
  61. package/dist/trade/engine.js +241 -0
  62. package/dist/trade/evm/erc20.js +44 -0
  63. package/dist/trade/extractor.js +148 -0
  64. package/dist/trade/policy.js +35 -0
  65. package/dist/trade/session.js +31 -0
  66. package/dist/trade/types.js +107 -0
  67. package/dist/trade/validator.js +148 -0
  68. package/dist/utils/files.js +59 -0
  69. package/dist/utils/log.js +24 -0
  70. package/dist/utils/playwrightCompat.js +14 -0
  71. package/dist/utils/time.js +3 -0
  72. package/dist/wallet/provider.js +345 -0
  73. package/dist/wallet/relay.js +129 -0
  74. package/dist/wallet/wallet.js +178 -0
  75. package/docs/01-installation.md +134 -0
  76. package/docs/02-running-your-first-audit.md +136 -0
  77. package/docs/03-configuration.md +233 -0
  78. package/docs/04-how-the-agent-thinks.md +41 -0
  79. package/docs/05-extending-personas-and-tasks.md +42 -0
  80. package/docs/06-hardening-for-production.md +92 -0
  81. package/package.json +60 -0
@@ -0,0 +1,458 @@
1
+ import fs from "node:fs";
2
+ import http from "node:http";
3
+ import path from "node:path";
4
+ import dotenv from "dotenv";
5
+ import ts from "typescript";
6
+ import { buildRunDetail, buildStandaloneReportHtml, listVisibleRunSummaries } from "../backend/dashboardData.js";
7
+ import { artifactContentType, isAllowedDashboardArtifact, isImageArtifact, isVideoArtifact } from "../backend/runArtifacts.js";
8
+ import { createLocalRunRepository } from "../backend/runRepository.js";
9
+ import { config } from "../config.js";
10
+ import { buildDefaultTradeRunOptions } from "../trade/policy.js";
11
+ import { TradeStrategySchema } from "../trade/types.js";
12
+ import { readUtf8 } from "../utils/files.js";
13
+ import { info, warn } from "../utils/log.js";
14
+ import { canAccessPublicReport, renderExpiredReportPage, renderLandingPage, renderReportUnavailablePage, renderSubmissionStatusPage } from "../submissions/html.js";
15
+ import { readSubmittedInstructionSource, SUBMISSION_TASKS_REQUIRED_MESSAGE } from "../submissions/customTasks.js";
16
+ import { findSubmissionByReportToken } from "../submissions/store.js";
17
+ import { parseSubmissionTargetMode, validateSubmissionUrl } from "../submissions/publicUrl.js";
18
+ import { SubmissionService } from "../submissions/service.js";
19
+ import { DASHBOARD_CSS as SHARED_DASHBOARD_CSS, DASHBOARD_HEAD_TAGS } from "./theme.js";
20
+ import { handleWebhook } from "../paystack/index.js";
21
+ dotenv.config();
22
+ const DASHBOARD_SRC_DIR = path.join(process.cwd(), "src", "dashboard");
23
+ const CLIENT_ENTRY = path.join(DASHBOARD_SRC_DIR, "client.ts");
24
+ const NARRATIVE_ENTRY = path.join(DASHBOARD_SRC_DIR, "narrative.ts");
25
+ const DEFAULT_PORT = 4173;
26
+ const DEFAULT_HOST = "127.0.0.1";
27
+ const RENDER_HOST = "0.0.0.0";
28
+ function parsePort(value) {
29
+ const parsed = Number(value);
30
+ return Number.isInteger(parsed) && parsed >= 1 && parsed <= 65535 ? parsed : DEFAULT_PORT;
31
+ }
32
+ function resolveDashboardHost() {
33
+ const configuredHost = process.env.DASHBOARD_HOST?.trim();
34
+ if (configuredHost) {
35
+ return configuredHost;
36
+ }
37
+ return process.env.RENDER === "true" ? RENDER_HOST : DEFAULT_HOST;
38
+ }
39
+ function transpileDashboardModule(entryPath) {
40
+ const source = readUtf8(entryPath);
41
+ const transpiled = ts.transpileModule(source, {
42
+ compilerOptions: {
43
+ target: ts.ScriptTarget.ES2022,
44
+ module: ts.ModuleKind.ES2022,
45
+ importsNotUsedAsValues: ts.ImportsNotUsedAsValues.Remove,
46
+ isolatedModules: true
47
+ },
48
+ fileName: entryPath
49
+ });
50
+ return transpiled.outputText;
51
+ }
52
+ function getClientScript() {
53
+ return transpileDashboardModule(CLIENT_ENTRY);
54
+ }
55
+ function getNarrativeScript() {
56
+ return transpileDashboardModule(NARRATIVE_ENTRY);
57
+ }
58
+ function renderDashboardHtml() {
59
+ return `<!doctype html>
60
+ <html lang="en">
61
+ <head>
62
+ <meta charset="utf-8" />
63
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
64
+ <title>Site Agent Dashboard</title>
65
+ ${DASHBOARD_HEAD_TAGS}
66
+ <style>${SHARED_DASHBOARD_CSS}</style>
67
+ </head>
68
+ <body>
69
+ <div id="app"></div>
70
+ <script type="module" src="/app.js"></script>
71
+ </body>
72
+ </html>`;
73
+ }
74
+ function sendText(res, statusCode, body, contentType) {
75
+ res.writeHead(statusCode, {
76
+ "Content-Type": `${contentType}; charset=utf-8`,
77
+ "Cache-Control": "no-store"
78
+ });
79
+ res.end(body);
80
+ }
81
+ function sendJson(res, data, statusCode = 200) {
82
+ sendText(res, statusCode, JSON.stringify(data, null, 2), "application/json");
83
+ }
84
+ function sendRedirect(res, location, statusCode = 303) {
85
+ res.writeHead(statusCode, { Location: location });
86
+ res.end();
87
+ }
88
+ async function readRequestBody(req) {
89
+ const chunks = [];
90
+ for await (const chunk of req) {
91
+ chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
92
+ }
93
+ return Buffer.concat(chunks);
94
+ }
95
+ async function readRequestForm(req) {
96
+ const body = await readRequestBody(req);
97
+ const contentType = req.headers["content-type"] ?? "application/x-www-form-urlencoded";
98
+ const request = new Request("http://site-agent.local/submit", {
99
+ method: "POST",
100
+ headers: {
101
+ "content-type": Array.isArray(contentType) ? (contentType[0] ?? "application/x-www-form-urlencoded") : contentType
102
+ },
103
+ body: new Uint8Array(body)
104
+ });
105
+ return request.formData();
106
+ }
107
+ async function handleRequest(req, res, args) {
108
+ const requestUrl = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
109
+ const pathParts = requestUrl.pathname.split("/").filter(Boolean);
110
+ if (requestUrl.pathname === "/favicon.ico") {
111
+ res.writeHead(204);
112
+ res.end();
113
+ return;
114
+ }
115
+ if (req.method === "POST" && requestUrl.pathname === "/webhooks/paystack") {
116
+ await handleWebhook(req, res, {
117
+ onChargeSuccess: async (data) => {
118
+ const amountNaira = (Number(data["amount"] ?? 0)) / 100;
119
+ const customer = data["customer"];
120
+ const email = customer?.["email"];
121
+ info(`[paystack] Received ₦${amountNaira} from ${email ?? "unknown"} — triggering auto-audit`);
122
+ // Trigger a default audit run when payment is received
123
+ // In a real production app, you'd match the 'metadata' or 'email' to a specific user/site
124
+ await args.submissionService.createSubmission({
125
+ url: "https://www.hackquest.io/hackathons/0G-APAC-Hackathon", // Default target for this hackathon agent
126
+ agentCount: 1,
127
+ customTasks: [
128
+ "Perform a full site audit focusing on the hackathon tracks and submission requirements.",
129
+ "Verify all links in the overview and prize tabs are reachable.",
130
+ "Check for any mobile layout issues on the main landing page."
131
+ ],
132
+ instructionText: "Automated audit triggered via Paystack payment."
133
+ });
134
+ },
135
+ onTransferSuccess: (data) => {
136
+ info(`[paystack] Transfer successful: ${data["transfer_code"]}`);
137
+ },
138
+ onTransferFailed: (data) => {
139
+ warn(`[paystack] Transfer failed: ${data["transfer_code"]}`);
140
+ }
141
+ });
142
+ return;
143
+ }
144
+ if (req.method === "POST" && requestUrl.pathname === "/submit") {
145
+ const form = await readRequestForm(req);
146
+ const urlInput = typeof form.get("url") === "string" ? form.get("url") : "";
147
+ const selectedTargetMode = parseSubmissionTargetMode(form.get("target"));
148
+ const submittedInstructions = await readSubmittedInstructionSource(form);
149
+ const requestedAgentCount = Number(form.get("agents") ?? "1");
150
+ const requestedTradeEnabled = form.has("trade_enabled");
151
+ const requestedTradeDryRun = form.has("trade_dry_run");
152
+ const requestedTradeStrategy = typeof form.get("trade_strategy") === "string" ? String(form.get("trade_strategy")) : "";
153
+ const requestedTradeConfirmations = Number(form.get("trade_confirmations") ?? "");
154
+ const normalizedAgentCount = Number.isFinite(requestedAgentCount)
155
+ ? Math.min(5, Math.max(1, Math.round(requestedAgentCount)))
156
+ : 1;
157
+ const defaultTradeOptions = buildDefaultTradeRunOptions();
158
+ const normalizedTradeOptions = {
159
+ enabled: requestedTradeEnabled || requestedTradeDryRun || defaultTradeOptions.enabled,
160
+ dryRun: requestedTradeDryRun,
161
+ strategy: TradeStrategySchema.safeParse(requestedTradeStrategy).success
162
+ ? TradeStrategySchema.parse(requestedTradeStrategy)
163
+ : defaultTradeOptions.strategy,
164
+ confirmations: Number.isInteger(requestedTradeConfirmations) && requestedTradeConfirmations >= 0 && requestedTradeConfirmations <= 12
165
+ ? requestedTradeConfirmations
166
+ : defaultTradeOptions.confirmations
167
+ };
168
+ const urlValidation = validateSubmissionUrl(urlInput, {
169
+ allowPrivateHosts: true,
170
+ targetMode: selectedTargetMode
171
+ });
172
+ const submissionError = !urlValidation.valid
173
+ ? urlValidation.reason ?? "Enter a valid http or https URL."
174
+ : submittedInstructions.customTasks.length === 0
175
+ ? SUBMISSION_TASKS_REQUIRED_MESSAGE
176
+ : null;
177
+ if (submissionError) {
178
+ sendText(res, 400, renderLandingPage({
179
+ error: submissionError,
180
+ submittedUrl: urlInput,
181
+ selectedAgentCount: normalizedAgentCount,
182
+ submittedInstructions: submittedInstructions.instructionText,
183
+ tradeOptions: normalizedTradeOptions,
184
+ allowPrivateTargets: true,
185
+ selectedTargetMode
186
+ }), "text/html");
187
+ return;
188
+ }
189
+ const submission = await args.submissionService.createSubmission({
190
+ url: urlValidation.normalizedUrl ?? urlInput.trim(),
191
+ agentCount: normalizedAgentCount,
192
+ tradeOptions: normalizedTradeOptions,
193
+ customTasks: submittedInstructions.customTasks,
194
+ instructionText: submittedInstructions.instructionText,
195
+ instructionFileName: submittedInstructions.instructionFileName
196
+ });
197
+ sendRedirect(res, `/submissions/${encodeURIComponent(submission.id)}`);
198
+ return;
199
+ }
200
+ if (req.method !== "GET") {
201
+ sendText(res, 405, "Method not allowed", "text/plain");
202
+ return;
203
+ }
204
+ if (requestUrl.pathname === "/health") {
205
+ sendJson(res, { ok: true, service: "site-agent-dashboard" });
206
+ return;
207
+ }
208
+ if (requestUrl.pathname === "/") {
209
+ sendText(res, 200, renderLandingPage({ allowPrivateTargets: true }), "text/html");
210
+ return;
211
+ }
212
+ if (requestUrl.pathname === "/dashboard") {
213
+ sendText(res, 200, renderDashboardHtml(), "text/html");
214
+ return;
215
+ }
216
+ if (requestUrl.pathname === "/app.js") {
217
+ sendText(res, 200, getClientScript(), "application/javascript");
218
+ return;
219
+ }
220
+ if (requestUrl.pathname === "/narrative.js") {
221
+ sendText(res, 200, getNarrativeScript(), "application/javascript");
222
+ return;
223
+ }
224
+ if (pathParts[0] === "submissions" && pathParts[1] && pathParts.length === 2) {
225
+ const submission = await args.submissionService.getSubmission(decodeURIComponent(pathParts[1]));
226
+ if (!submission) {
227
+ sendText(res, 404, renderReportUnavailablePage({
228
+ title: "Submission not found",
229
+ message: "We could not find that submission."
230
+ }), "text/html");
231
+ return;
232
+ }
233
+ sendText(res, 200, renderSubmissionStatusPage({ appBaseUrl: args.appBaseUrl, submission }), "text/html");
234
+ return;
235
+ }
236
+ if (pathParts[0] === "r" && pathParts[1] && pathParts.length === 2) {
237
+ const submission = findSubmissionByReportToken(decodeURIComponent(pathParts[1]));
238
+ if (!submission) {
239
+ sendText(res, 404, renderReportUnavailablePage({
240
+ title: "Task output not found",
241
+ message: "This task output link does not exist."
242
+ }), "text/html");
243
+ return;
244
+ }
245
+ const access = canAccessPublicReport(submission);
246
+ if (!access.allowed) {
247
+ const html = access.reason === "This task output link has expired."
248
+ ? renderExpiredReportPage(submission)
249
+ : renderReportUnavailablePage({
250
+ title: "Task output not ready",
251
+ message: access.reason ?? "This task output is not ready yet."
252
+ });
253
+ const statusCode = access.reason === "This task output link has expired." ? 410 : 202;
254
+ sendText(res, statusCode, html, "text/html");
255
+ return;
256
+ }
257
+ const htmlReport = await buildStandaloneReportHtml(args.runRepository, submission.runId ?? "");
258
+ if (!htmlReport) {
259
+ sendText(res, 404, renderReportUnavailablePage({
260
+ title: "Task output not found",
261
+ message: "The task output artifact could not be loaded."
262
+ }), "text/html");
263
+ return;
264
+ }
265
+ sendText(res, 200, htmlReport, "text/html");
266
+ return;
267
+ }
268
+ if ((pathParts[0] === "reports" || pathParts[0] === "outputs") && pathParts[1] && pathParts.length === 2) {
269
+ const runId = decodeURIComponent(pathParts[1]);
270
+ const htmlReport = await buildStandaloneReportHtml(args.runRepository, runId);
271
+ if (!htmlReport) {
272
+ sendText(res, 404, "Task output not found", "text/plain");
273
+ return;
274
+ }
275
+ sendText(res, 200, htmlReport, "text/html");
276
+ return;
277
+ }
278
+ if (requestUrl.pathname === "/api/runs") {
279
+ const runs = await listVisibleRunSummaries(args.runRepository);
280
+ sendJson(res, runs);
281
+ return;
282
+ }
283
+ if (pathParts[0] === "api" && pathParts[1] === "runs" && pathParts[2]) {
284
+ const runId = decodeURIComponent(pathParts[2]);
285
+ if (pathParts.length === 3) {
286
+ const runDetail = await buildRunDetail(args.runRepository, runId);
287
+ if (!runDetail) {
288
+ sendJson(res, { error: `Run '${runId}' not found.` }, 404);
289
+ return;
290
+ }
291
+ sendJson(res, runDetail);
292
+ return;
293
+ }
294
+ if (pathParts.length === 5 && pathParts[3] === "artifacts" && pathParts[4]) {
295
+ const fileName = decodeURIComponent(pathParts[4]);
296
+ if (!isAllowedDashboardArtifact(fileName)) {
297
+ sendJson(res, { error: "Artifact not available for download." }, 400);
298
+ return;
299
+ }
300
+ if (!(await args.runRepository.hasRun(runId))) {
301
+ sendJson(res, { error: `Run '${runId}' not found.` }, 404);
302
+ return;
303
+ }
304
+ if (fileName === "report.html") {
305
+ const htmlReport = (await args.runRepository.readTextArtifact(runId, "report.html")) ??
306
+ (await buildStandaloneReportHtml(args.runRepository, runId));
307
+ if (!htmlReport) {
308
+ sendJson(res, { error: `Artifact '${fileName}' not found.` }, 404);
309
+ return;
310
+ }
311
+ res.writeHead(200, {
312
+ "Content-Type": "text/html; charset=utf-8",
313
+ "Content-Disposition": `attachment; filename="${runId}-${fileName}"`,
314
+ "Cache-Control": "no-store"
315
+ });
316
+ res.end(htmlReport);
317
+ return;
318
+ }
319
+ if (isImageArtifact(fileName)) {
320
+ const artifact = await args.runRepository.readBinaryArtifact(runId, fileName);
321
+ if (!artifact) {
322
+ sendJson(res, { error: `Artifact '${fileName}' not found.` }, 404);
323
+ return;
324
+ }
325
+ res.writeHead(200, {
326
+ "Content-Type": artifactContentType(fileName),
327
+ "Cache-Control": "no-store"
328
+ });
329
+ res.end(artifact);
330
+ return;
331
+ }
332
+ if (isVideoArtifact(fileName)) {
333
+ const artifactPath = await args.runRepository.resolveArtifactPath(runId, fileName);
334
+ if (!artifactPath) {
335
+ sendJson(res, { error: `Artifact '${fileName}' not found.` }, 404);
336
+ return;
337
+ }
338
+ const stat = fs.statSync(artifactPath);
339
+ const fileSize = stat.size;
340
+ const range = req.headers.range;
341
+ if (range) {
342
+ const parts = range.replace(/bytes=/, "").split("-");
343
+ const startPart = parts[0];
344
+ if (startPart === undefined) {
345
+ res.writeHead(400);
346
+ res.end("Invalid Range");
347
+ return;
348
+ }
349
+ const start = parseInt(startPart, 10);
350
+ const end = parts[1] ? parseInt(parts[1], 10) : fileSize - 1;
351
+ if (start >= fileSize) {
352
+ res.writeHead(416, { "Content-Range": `bytes */${fileSize}` });
353
+ res.end();
354
+ return;
355
+ }
356
+ const chunksize = end - start + 1;
357
+ const file = fs.createReadStream(artifactPath, { start, end });
358
+ res.writeHead(206, {
359
+ "Content-Range": `bytes ${start}-${end}/${fileSize}`,
360
+ "Accept-Ranges": "bytes",
361
+ "Content-Length": chunksize,
362
+ "Content-Type": artifactContentType(fileName),
363
+ "Cache-Control": "no-store"
364
+ });
365
+ file.pipe(res);
366
+ }
367
+ else {
368
+ res.writeHead(200, {
369
+ "Content-Length": fileSize,
370
+ "Content-Type": artifactContentType(fileName),
371
+ "Cache-Control": "no-store"
372
+ });
373
+ fs.createReadStream(artifactPath).pipe(res);
374
+ }
375
+ return;
376
+ }
377
+ const artifact = await args.runRepository.readTextArtifact(runId, fileName);
378
+ if (!artifact) {
379
+ sendJson(res, { error: `Artifact '${fileName}' not found.` }, 404);
380
+ return;
381
+ }
382
+ res.writeHead(200, {
383
+ "Content-Type": fileName.endsWith(".json") ? "application/json; charset=utf-8" : "text/markdown; charset=utf-8",
384
+ "Content-Disposition": `attachment; filename="${runId}-${fileName}"`,
385
+ "Cache-Control": "no-store"
386
+ });
387
+ res.end(artifact);
388
+ return;
389
+ }
390
+ }
391
+ sendText(res, 404, "Not found", "text/plain");
392
+ }
393
+ function freePort(port) {
394
+ return new Promise((resolve) => {
395
+ // Find and kill whatever process is holding the port
396
+ const isWin = process.platform === "win32";
397
+ const cmd = isWin
398
+ ? `for /f "tokens=5" %a in ('netstat -aon ^| findstr :${port}') do taskkill /F /PID %a`
399
+ : `lsof -ti:${port} | xargs kill -9`;
400
+ import("node:child_process").then(({ exec }) => {
401
+ exec(cmd, () => resolve()); // ignore errors (port may already be free)
402
+ }).catch(() => resolve());
403
+ });
404
+ }
405
+ function tryListen(server, port, host) {
406
+ return new Promise((resolve, reject) => {
407
+ server.once("error", (err) => {
408
+ if (err.code === "EADDRINUSE") {
409
+ resolve(-1); // signal: port busy, try next
410
+ }
411
+ else {
412
+ reject(err);
413
+ }
414
+ });
415
+ server.listen(port, host, () => resolve(port));
416
+ });
417
+ }
418
+ export async function startDashboard(options = {}) {
419
+ const preferredPort = options.port ?? parsePort(process.env.PORT ?? process.env.DASHBOARD_PORT);
420
+ const host = options.host ?? resolveDashboardHost();
421
+ const submissionService = new SubmissionService();
422
+ const runRepository = createLocalRunRepository();
423
+ const server = http.createServer((req, res) => {
424
+ handleRequest(req, res, { appBaseUrl: "", runRepository, submissionService }).catch((error) => {
425
+ const message = error instanceof Error ? error.message : "Unknown dashboard error";
426
+ warn(`Dashboard request failed: ${message}`);
427
+ sendJson(res, { error: message }, 500);
428
+ });
429
+ });
430
+ // Free the preferred port if something else is already using it (e.g. a stale session)
431
+ await freePort(preferredPort);
432
+ // Small delay to allow the OS to release the port after the kill
433
+ await new Promise((resolve) => setTimeout(resolve, 300));
434
+ let boundPort = -1;
435
+ const MAX_ATTEMPTS = 3;
436
+ for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
437
+ const candidatePort = preferredPort + attempt;
438
+ const result = await tryListen(server, candidatePort, host);
439
+ if (result !== -1) {
440
+ boundPort = result;
441
+ break;
442
+ }
443
+ if (attempt === 0) {
444
+ warn(`Port ${preferredPort} still in use after freeing, trying ${preferredPort + 1}...`);
445
+ }
446
+ server.removeAllListeners("error");
447
+ }
448
+ if (boundPort === -1) {
449
+ throw new Error(`Could not find a free port after ${MAX_ATTEMPTS} attempts starting from ${preferredPort}.`);
450
+ }
451
+ const url = `http://${host === DEFAULT_HOST ? "localhost" : host}:${boundPort}`;
452
+ const appBaseUrl = config.appBaseUrl || url;
453
+ // Patch appBaseUrl into the request handler closure via a shared ref
454
+ server.__appBaseUrl = appBaseUrl;
455
+ info(`Dashboard ready at ${url}`);
456
+ submissionService.resumePendingSubmissions();
457
+ return { server, port: boundPort, host, url };
458
+ }