atom-agent 1.4.0 → 1.5.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 (67) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/README.md +220 -224
  3. package/dist/App.js +922 -341
  4. package/dist/adapters.js +127 -14
  5. package/dist/agent/goal-evaluator.js +3 -0
  6. package/dist/agent/loop.js +211 -430
  7. package/dist/agent/tool-pipeline.js +398 -0
  8. package/dist/agent/turn-events.js +12 -0
  9. package/dist/cli.js +57 -8
  10. package/dist/compact.js +72 -8
  11. package/dist/config.js +19 -0
  12. package/dist/context-manager.js +6 -2
  13. package/dist/extensions.js +6 -0
  14. package/dist/file-diffs.js +108 -0
  15. package/dist/kilo.js +1 -1
  16. package/dist/local-discovery.js +2 -2
  17. package/dist/media.js +276 -0
  18. package/dist/overflow.js +140 -0
  19. package/dist/policy.js +8 -0
  20. package/dist/scheduler.js +38 -9
  21. package/dist/session-revert.js +125 -0
  22. package/dist/sessions.js +101 -0
  23. package/dist/snapshots.js +69 -0
  24. package/dist/system.js +2 -89
  25. package/dist/telemetry.js +26 -1
  26. package/dist/todos.js +241 -0
  27. package/dist/tools/filesystem.js +102 -22
  28. package/dist/tools/registry.js +184 -45
  29. package/dist/tools/ripgrep.js +7 -6
  30. package/dist/tools/search.js +172 -17
  31. package/dist/tools/shared.js +6 -0
  32. package/dist/tools.js +7 -39
  33. package/dist/ui/diff-panel.js +1 -1
  34. package/dist/ui/diff-view.js +13 -5
  35. package/dist/ui/diff.js +67 -0
  36. package/dist/ui/errors.js +20 -6
  37. package/dist/ui/input.js +24 -20
  38. package/dist/ui/live-tail.js +36 -1
  39. package/dist/ui/markdown.js +9 -4
  40. package/dist/ui/modals.js +7 -5
  41. package/dist/ui/paint-scheduler.js +120 -0
  42. package/dist/ui/palette.js +4 -2
  43. package/dist/ui/pickers.js +4 -1
  44. package/dist/ui/side-by-side.js +81 -22
  45. package/dist/ui/status-bar.js +63 -8
  46. package/dist/ui/stream-store.js +7 -0
  47. package/dist/ui/theme.js +23 -1
  48. package/dist/ui/todo-panel.js +5 -2
  49. package/dist/ui/tool-inspector.js +33 -4
  50. package/dist/ui/transcript.js +8 -5
  51. package/dist/web/events.js +93 -0
  52. package/dist/web/runtime.js +790 -0
  53. package/dist/web/server.js +570 -0
  54. package/dist/web/ui/app.js +1925 -0
  55. package/dist/web/ui/index.html +135 -0
  56. package/dist/web/ui/styles.css +515 -0
  57. package/dist/zen.js +115 -4
  58. package/documentation/cli.md +5 -5
  59. package/documentation/configuration.md +11 -6
  60. package/documentation/development.md +4 -3
  61. package/documentation/goals.md +1 -1
  62. package/documentation/index.md +4 -4
  63. package/documentation/providers.md +2 -3
  64. package/documentation/skills.md +3 -3
  65. package/documentation/tools.md +8 -3
  66. package/documentation/troubleshooting.md +1 -1
  67. package/package.json +3 -2
@@ -0,0 +1,570 @@
1
+ // ATOM WebUI server: a local frontend for the existing ATOM runtime.
2
+ //
3
+ // Design (same posture as src/telemetry-server.ts):
4
+ // - `node:http` + `node:fs` builtins only. No new dependencies.
5
+ // - Loopback-only by default (`127.0.0.1`); never binds a LAN interface
6
+ // unless explicitly asked (`host` option).
7
+ // - Serves the dependency-free frontend (src/web/ui, copied to dist/web/ui
8
+ // by scripts/copy-web-ui.mjs) plus a JSON API over the WebRuntime.
9
+ // - Every handler is guarded — a bad request or a failing store yields a
10
+ // status code, never a crash. Responses carry `Cache-Control: no-store`
11
+ // on API routes (the static UI is immutable per version and may cache).
12
+ // - Realtime is SSE per session (GET /api/sessions/:id/events): the runtime
13
+ // fans out turn events; the server frames them (see src/web/events.ts),
14
+ // replays missed events via Last-Event-ID, and heartbeats idle streams.
15
+ //
16
+ // Routes:
17
+ // - GET /, /app.js, /styles.css → frontend
18
+ // - GET /api/health → {ok, service, version, sessions}
19
+ // - GET /api/providers → provider catalog (hasKey booleans only —
20
+ // keys/secrets never cross the API)
21
+ // - GET /api/tools → tool catalog (name, description, needsApproval)
22
+ // - GET /api/sessions → session summaries (most recent first)
23
+ // - POST /api/sessions → create ({title?, provider?, model?, effort?, mode?})
24
+ // - GET /api/sessions/:id → full record + busy + pending approval/question
25
+ // - PATCH /api/sessions/:id → settings ({provider?, model?, effort?, mode?,
26
+ // title?}; 409 while busy)
27
+ // - POST /api/sessions/:id/messages → start a turn ({content, ...overrides});
28
+ // 202 accepted; 400 validation/start failure; 404 unknown; 409 busy
29
+ // - GET /api/sessions/:id/events → SSE stream (replay + heartbeat)
30
+ // - POST /api/sessions/:id/cancel → {cancelled}
31
+ // - POST /api/sessions/:id/approve → {resolved} ({id, decision})
32
+ // - POST /api/sessions/:id/answer → {resolved} ({id, answer})
33
+ // - anything else → 404; wrong method on a known route → 405.
34
+ import { createServer } from "node:http";
35
+ import { existsSync, readFileSync } from "node:fs";
36
+ import * as path from "node:path";
37
+ import { fileURLToPath } from "node:url";
38
+ import { formatSSE, sseHeartbeat } from "./events.js";
39
+ import { validateSendBody, WebRuntime } from "./runtime.js";
40
+ export const WEB_SERVER_DEFAULT_HOST = "127.0.0.1";
41
+ export const WEB_SERVER_DEFAULT_PORT = 0;
42
+ export const WEB_SERVER_PORT_ENV = "ATOM_WEB_PORT";
43
+ export const WEB_SERVICE = "atom-web";
44
+ export const WEB_VERSION = 1;
45
+ const HEARTBEAT_MS = 15_000;
46
+ const BODY_CAP_BYTES = 1_000_000;
47
+ export function parseWebPort(value) {
48
+ if (value === undefined || value === null)
49
+ return null;
50
+ const text = String(value).trim();
51
+ if (!/^\d+$/.test(text))
52
+ return null;
53
+ const n = Number(text);
54
+ if (!Number.isSafeInteger(n) || n < 1 || n > 65535)
55
+ return null;
56
+ return n;
57
+ }
58
+ export function resolveWebPort(env = process.env, cliValue) {
59
+ return (parseWebPort(cliValue) ??
60
+ parseWebPort(env[WEB_SERVER_PORT_ENV]) ??
61
+ WEB_SERVER_DEFAULT_PORT);
62
+ }
63
+ // UI directory: the compiled server lives in dist/web/, the UI beside it at
64
+ // dist/web/ui/ (see scripts/copy-web-ui.mjs); under tsx/vitest it is
65
+ // src/web/ui/. Probe the adjacent dir first, then the caller's checkout.
66
+ function resolveUiDir() {
67
+ const candidates = [];
68
+ try {
69
+ const here = path.dirname(fileURLToPath(import.meta.url));
70
+ candidates.push(path.join(here, "ui"));
71
+ }
72
+ catch {
73
+ // import.meta.url unavailable — fall through to the checkout probe
74
+ }
75
+ candidates.push(path.join(process.cwd(), "src", "web", "ui"));
76
+ for (const dir of candidates) {
77
+ try {
78
+ if (existsSync(path.join(dir, "index.html")))
79
+ return dir;
80
+ }
81
+ catch {
82
+ // probe next
83
+ }
84
+ }
85
+ return null;
86
+ }
87
+ const UI_CONTENT_TYPES = {
88
+ ".html": "text/html; charset=utf-8",
89
+ ".js": "text/javascript; charset=utf-8",
90
+ ".css": "text/css; charset=utf-8",
91
+ };
92
+ function sendJson(res, status, body) {
93
+ try {
94
+ const text = JSON.stringify(body);
95
+ res.writeHead(status, {
96
+ "Content-Type": "application/json; charset=utf-8",
97
+ "Cache-Control": "no-store",
98
+ "X-Content-Type-Options": "nosniff",
99
+ "Content-Length": Buffer.byteLength(text),
100
+ });
101
+ res.end(text);
102
+ }
103
+ catch {
104
+ try {
105
+ res.end();
106
+ }
107
+ catch {
108
+ // never throw out of a handler
109
+ }
110
+ }
111
+ }
112
+ function readJsonBody(req) {
113
+ return new Promise((resolve) => {
114
+ const chunks = [];
115
+ let size = 0;
116
+ let failed = false;
117
+ req.on("data", (chunk) => {
118
+ if (failed)
119
+ return;
120
+ size += chunk.length;
121
+ if (size > BODY_CAP_BYTES) {
122
+ failed = true;
123
+ resolve({ ok: false, error: "request body too large" });
124
+ return;
125
+ }
126
+ chunks.push(chunk);
127
+ });
128
+ req.on("end", () => {
129
+ if (failed)
130
+ return;
131
+ const text = Buffer.concat(chunks).toString("utf8");
132
+ if (!text) {
133
+ resolve({ ok: true, body: {} });
134
+ return;
135
+ }
136
+ try {
137
+ resolve({ ok: true, body: JSON.parse(text) });
138
+ }
139
+ catch {
140
+ resolve({ ok: false, error: "body must be valid JSON" });
141
+ }
142
+ });
143
+ req.on("error", () => resolve({ ok: false, error: "failed to read request body" }));
144
+ });
145
+ }
146
+ // Summaries stay light: histories ride the item route only, so listing
147
+ // hundreds of sessions never dumps megabytes of transcripts.
148
+ function sessionSummary(s) {
149
+ return {
150
+ id: s.id,
151
+ title: s.title,
152
+ createdAt: s.createdAt,
153
+ updatedAt: s.updatedAt,
154
+ cwd: s.cwd,
155
+ provider: s.provider,
156
+ model: s.model,
157
+ effort: s.effort,
158
+ mode: s.mode,
159
+ turnCount: s.turns.length,
160
+ };
161
+ }
162
+ const SESSION_ID_RE = /^[A-Za-z0-9_.-]+$/;
163
+ function isSessionId(id) {
164
+ return id.length > 0 && id.length <= 128 && SESSION_ID_RE.test(id);
165
+ }
166
+ export function startWebServer(opts = {}) {
167
+ const host = typeof opts.host === "string" && opts.host.length > 0 ? opts.host : WEB_SERVER_DEFAULT_HOST;
168
+ const port = typeof opts.port === "number" && Number.isSafeInteger(opts.port) && opts.port >= 0 && opts.port <= 65535
169
+ ? opts.port
170
+ : WEB_SERVER_DEFAULT_PORT;
171
+ const runtime = opts.runtime ?? new WebRuntime(opts.home);
172
+ const uiDir = resolveUiDir();
173
+ function serveUiFile(res, name) {
174
+ try {
175
+ if (!uiDir) {
176
+ sendJson(res, 500, { error: "frontend assets unavailable" });
177
+ return;
178
+ }
179
+ const file = name === "/" ? "index.html" : name.slice(1);
180
+ if (file.includes("..") || file.includes("\\") || !/^(index\.html|app\.js|styles\.css)$/.test(file)) {
181
+ sendJson(res, 404, { error: "not found" });
182
+ return;
183
+ }
184
+ const full = path.join(uiDir, file);
185
+ let raw;
186
+ try {
187
+ raw = readFileSync(full);
188
+ }
189
+ catch {
190
+ sendJson(res, 404, { error: "not found" });
191
+ return;
192
+ }
193
+ const ext = path.extname(file);
194
+ res.writeHead(200, {
195
+ "Content-Type": UI_CONTENT_TYPES[ext] ?? "application/octet-stream",
196
+ "X-Content-Type-Options": "nosniff",
197
+ "Content-Length": raw.length,
198
+ });
199
+ res.end(raw);
200
+ }
201
+ catch {
202
+ sendJson(res, 500, { error: "internal error" });
203
+ }
204
+ }
205
+ async function handle(req, res) {
206
+ try {
207
+ const method = req.method ?? "GET";
208
+ let pathname = "/";
209
+ try {
210
+ pathname = new URL(req.url ?? "/", "http://localhost").pathname;
211
+ }
212
+ catch {
213
+ sendJson(res, 400, { error: "bad request" });
214
+ return;
215
+ }
216
+ // Static frontend.
217
+ if ((pathname === "/" || pathname === "/app.js" || pathname === "/styles.css") && method === "GET") {
218
+ serveUiFile(res, pathname);
219
+ return;
220
+ }
221
+ // Session SSE stream (headers + replay + heartbeat + cleanup).
222
+ const eventsMatch = pathname.match(/^\/api\/sessions\/([^/]+)\/events$/);
223
+ if (eventsMatch) {
224
+ const id = decodeURIComponent(eventsMatch[1] ?? "");
225
+ if (!isSessionId(id) || !runtime.getSessionRecord(id)) {
226
+ sendJson(res, 404, { error: "session not found" });
227
+ return;
228
+ }
229
+ if (method !== "GET") {
230
+ sendJson(res, 405, { error: "method not allowed" });
231
+ return;
232
+ }
233
+ let lastEventId;
234
+ try {
235
+ const rawHeader = req.headers["last-event-id"];
236
+ const raw = Array.isArray(rawHeader) ? rawHeader[0] : rawHeader;
237
+ if (typeof raw === "string" && raw.trim().length > 0) {
238
+ const n = Number(raw.trim());
239
+ if (Number.isFinite(n))
240
+ lastEventId = Math.floor(n);
241
+ }
242
+ }
243
+ catch {
244
+ lastEventId = undefined;
245
+ }
246
+ res.writeHead(200, {
247
+ "Content-Type": "text/event-stream; charset=utf-8",
248
+ "Cache-Control": "no-store",
249
+ "X-Content-Type-Options": "nosniff",
250
+ Connection: "keep-alive",
251
+ });
252
+ const write = (frame) => {
253
+ try {
254
+ return res.write(frame);
255
+ }
256
+ catch {
257
+ return false;
258
+ }
259
+ };
260
+ const onEvent = (event) => {
261
+ write(formatSSE(event));
262
+ };
263
+ let unsubscribe = null;
264
+ try {
265
+ unsubscribe = runtime.subscribe(id, onEvent, lastEventId);
266
+ }
267
+ catch {
268
+ sendJson(res, 404, { error: "session not found" });
269
+ return;
270
+ }
271
+ const heartbeat = setInterval(() => {
272
+ if (!write(sseHeartbeat())) {
273
+ try {
274
+ clearInterval(heartbeat);
275
+ }
276
+ catch {
277
+ // ignore
278
+ }
279
+ }
280
+ }, HEARTBEAT_MS);
281
+ try {
282
+ heartbeat.unref?.();
283
+ }
284
+ catch {
285
+ // ignore
286
+ }
287
+ const cleanup = () => {
288
+ try {
289
+ clearInterval(heartbeat);
290
+ }
291
+ catch {
292
+ // ignore
293
+ }
294
+ try {
295
+ unsubscribe?.();
296
+ }
297
+ catch {
298
+ // ignore
299
+ }
300
+ };
301
+ req.on("close", cleanup);
302
+ res.on("close", cleanup);
303
+ return;
304
+ }
305
+ // Session item routes.
306
+ const itemMatch = pathname.match(/^\/api\/sessions\/([^/]+)(\/[^/]+)?$/);
307
+ if (itemMatch) {
308
+ const id = decodeURIComponent(itemMatch[1] ?? "");
309
+ const suffix = itemMatch[2] ?? "";
310
+ if (!isSessionId(id)) {
311
+ sendJson(res, 404, { error: "session not found" });
312
+ return;
313
+ }
314
+ // GET /api/sessions/:id (live view: persisted identity + in-memory
315
+ // turn state, so a fresh client sees the running turn too)
316
+ if (suffix === "" && method === "GET") {
317
+ const live = runtime.getLiveSession(id);
318
+ if (!live) {
319
+ sendJson(res, 404, { error: "session not found" });
320
+ return;
321
+ }
322
+ sendJson(res, 200, live);
323
+ return;
324
+ }
325
+ // PATCH /api/sessions/:id
326
+ if (suffix === "" && method === "PATCH") {
327
+ if (runtime.isBusy(id)) {
328
+ sendJson(res, 409, { error: "session is busy (another turn is running)" });
329
+ return;
330
+ }
331
+ const parsed = await readJsonBody(req);
332
+ if (!parsed.ok) {
333
+ sendJson(res, 400, { error: parsed.error });
334
+ return;
335
+ }
336
+ const updated = runtime.updateWebSession(id, (parsed.body ?? {}));
337
+ if (!updated) {
338
+ sendJson(res, 404, { error: "session not found" });
339
+ return;
340
+ }
341
+ sendJson(res, 200, updated);
342
+ return;
343
+ }
344
+ // POST /api/sessions/:id/messages
345
+ if (suffix === "/messages" && method === "POST") {
346
+ const record = runtime.getSessionRecord(id);
347
+ if (!record) {
348
+ sendJson(res, 404, { error: "session not found" });
349
+ return;
350
+ }
351
+ if (runtime.isBusy(id)) {
352
+ sendJson(res, 409, { error: "session is busy (another turn is running)" });
353
+ return;
354
+ }
355
+ const parsed = await readJsonBody(req);
356
+ if (!parsed.ok) {
357
+ sendJson(res, 400, { error: parsed.error });
358
+ return;
359
+ }
360
+ const problem = validateSendBody(parsed.body);
361
+ if (problem) {
362
+ sendJson(res, 400, { error: problem });
363
+ return;
364
+ }
365
+ const body = parsed.body;
366
+ const turnOpts = {
367
+ provider: body["provider"],
368
+ model: typeof body["model"] === "string" ? body["model"] : undefined,
369
+ effort: body["effort"],
370
+ mode: body["mode"],
371
+ };
372
+ try {
373
+ // Synchronous start-gate (validateTurnStart is await-free, so it
374
+ // throws instead of rejecting — the only honest 400/409 source).
375
+ runtime.validateTurnStart(id, body.content, turnOpts);
376
+ }
377
+ catch (e) {
378
+ // Start failures only (unknown session, busy, validation, missing
379
+ // key): the turn never started, so 400/409 is honest.
380
+ const message = e instanceof Error ? e.message : String(e);
381
+ const status = /busy|waiting/i.test(message) ? 409 : 400;
382
+ sendJson(res, status, { error: message });
383
+ return;
384
+ }
385
+ // Fire-and-forget: progress arrives as SSE events. Awaiting here
386
+ // would hold the HTTP request for the whole turn.
387
+ void runtime.sendMessage(id, body.content, turnOpts).catch(() => {
388
+ // In-turn failures surface as SSE error/cancelled events —
389
+ // never as unhandled rejections.
390
+ });
391
+ sendJson(res, 202, { accepted: true });
392
+ return;
393
+ }
394
+ // POST /api/sessions/:id/cancel
395
+ if (suffix === "/cancel" && method === "POST") {
396
+ if (!runtime.getSessionRecord(id)) {
397
+ sendJson(res, 404, { error: "session not found" });
398
+ return;
399
+ }
400
+ sendJson(res, 200, { cancelled: runtime.cancelTurn(id) });
401
+ return;
402
+ }
403
+ // POST /api/sessions/:id/approve
404
+ if (suffix === "/approve" && method === "POST") {
405
+ if (!runtime.getSessionRecord(id)) {
406
+ sendJson(res, 404, { error: "session not found" });
407
+ return;
408
+ }
409
+ const parsed = await readJsonBody(req);
410
+ if (!parsed.ok) {
411
+ sendJson(res, 400, { error: parsed.error });
412
+ return;
413
+ }
414
+ const b = (parsed.body ?? {});
415
+ if (typeof b["id"] !== "string" || (b["decision"] !== "once" && b["decision"] !== "always" && b["decision"] !== "no")) {
416
+ sendJson(res, 400, { error: 'body must be {id: string, decision: "once"|"always"|"no"}' });
417
+ return;
418
+ }
419
+ sendJson(res, 200, {
420
+ resolved: runtime.resolveApproval(id, b["id"], b["decision"]),
421
+ });
422
+ return;
423
+ }
424
+ // POST /api/sessions/:id/answer
425
+ if (suffix === "/answer" && method === "POST") {
426
+ if (!runtime.getSessionRecord(id)) {
427
+ sendJson(res, 404, { error: "session not found" });
428
+ return;
429
+ }
430
+ const parsed = await readJsonBody(req);
431
+ if (!parsed.ok) {
432
+ sendJson(res, 400, { error: parsed.error });
433
+ return;
434
+ }
435
+ const b = (parsed.body ?? {});
436
+ if (typeof b["id"] !== "string" || typeof b["answer"] !== "string" || b["answer"].length === 0) {
437
+ sendJson(res, 400, { error: "body must be {id: string, answer: non-empty string}" });
438
+ return;
439
+ }
440
+ sendJson(res, 200, {
441
+ resolved: runtime.answerQuestion(id, b["id"], b["answer"]),
442
+ });
443
+ return;
444
+ }
445
+ sendJson(res, method === "GET" || method === "PATCH" || method === "POST" ? 404 : 405, {
446
+ error: "not found",
447
+ });
448
+ return;
449
+ }
450
+ // Collection + catalog routes.
451
+ if (pathname === "/api/sessions" && method === "GET") {
452
+ sendJson(res, 200, runtime.listSessions().map(sessionSummary));
453
+ return;
454
+ }
455
+ if (pathname === "/api/sessions" && method === "POST") {
456
+ const parsed = await readJsonBody(req);
457
+ if (!parsed.ok) {
458
+ sendJson(res, 400, { error: parsed.error });
459
+ return;
460
+ }
461
+ const b = (parsed.body ?? {});
462
+ try {
463
+ const created = runtime.createWebSession({
464
+ title: typeof b["title"] === "string" ? b["title"] : undefined,
465
+ provider: b["provider"],
466
+ model: typeof b["model"] === "string" ? b["model"] : undefined,
467
+ effort: b["effort"],
468
+ mode: b["mode"],
469
+ });
470
+ sendJson(res, 201, created);
471
+ }
472
+ catch (e) {
473
+ sendJson(res, 400, { error: e instanceof Error ? e.message : String(e) });
474
+ }
475
+ return;
476
+ }
477
+ if (pathname === "/api/sessions" && method !== "GET" && method !== "POST") {
478
+ sendJson(res, 405, { error: "method not allowed" });
479
+ return;
480
+ }
481
+ if (pathname === "/api/providers" && method === "GET") {
482
+ sendJson(res, 200, runtime.listProviders());
483
+ return;
484
+ }
485
+ if (pathname === "/api/tools" && method === "GET") {
486
+ sendJson(res, 200, runtime.listTools());
487
+ return;
488
+ }
489
+ if (pathname === "/api/health" && method === "GET") {
490
+ sendJson(res, 200, {
491
+ ok: true,
492
+ service: WEB_SERVICE,
493
+ version: WEB_VERSION,
494
+ sessions: runtime.listSessions().length,
495
+ });
496
+ return;
497
+ }
498
+ if (pathname === "/api/health" ||
499
+ pathname === "/api/providers" ||
500
+ pathname === "/api/tools") {
501
+ sendJson(res, 405, { error: "method not allowed" });
502
+ return;
503
+ }
504
+ sendJson(res, 404, { error: "not found" });
505
+ }
506
+ catch {
507
+ sendJson(res, 500, { error: "internal error" });
508
+ }
509
+ }
510
+ return new Promise((resolve, reject) => {
511
+ let server;
512
+ try {
513
+ server = createServer((req, res) => {
514
+ void handle(req, res);
515
+ });
516
+ }
517
+ catch (e) {
518
+ reject(e instanceof Error ? e : new Error(String(e)));
519
+ return;
520
+ }
521
+ const sockets = new Set();
522
+ try {
523
+ server.on("connection", (s) => {
524
+ sockets.add(s);
525
+ s.on("close", () => sockets.delete(s));
526
+ });
527
+ }
528
+ catch {
529
+ // observer wiring must never break startup
530
+ }
531
+ const onError = (e) => {
532
+ reject(e instanceof Error ? e : new Error(String(e)));
533
+ };
534
+ server.once("error", onError);
535
+ server.listen(port, host, () => {
536
+ server.off("error", onError);
537
+ let actual = port;
538
+ try {
539
+ const addr = server.address();
540
+ if (addr !== null && typeof addr === "object")
541
+ actual = addr.port;
542
+ }
543
+ catch {
544
+ // keep the requested port in the URL on introspection failure
545
+ }
546
+ resolve({
547
+ url: `http://${host}:${actual}/`,
548
+ host,
549
+ port: actual,
550
+ runtime,
551
+ close: () => new Promise((done) => {
552
+ try {
553
+ for (const s of sockets) {
554
+ try {
555
+ s.destroy();
556
+ }
557
+ catch {
558
+ // ignore per-socket failures
559
+ }
560
+ }
561
+ server.close(() => done());
562
+ }
563
+ catch {
564
+ done();
565
+ }
566
+ }),
567
+ });
568
+ });
569
+ });
570
+ }