chamba 0.1.0 → 0.2.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 (72) hide show
  1. package/README.md +49 -0
  2. package/dist/cli.js +662 -161
  3. package/dist/server.js +355 -111
  4. package/inject/annotate.js +1 -1
  5. package/package.json +1 -1
  6. package/skill/SKILL.md +91 -19
  7. package/web/assets/{abnfDiagram-VRR7QNED-aw1_rWFY.js → abnfDiagram-VRR7QNED-C5V2hrtE.js} +1 -1
  8. package/web/assets/{arc-BMhSK0Sy.js → arc-d7OTmxZy.js} +1 -1
  9. package/web/assets/{architectureDiagram-ZJ3FMSHR-BZDcNKY3.js → architectureDiagram-ZJ3FMSHR--CztaeAS.js} +1 -1
  10. package/web/assets/{blockDiagram-677ZJIJ3-CLVR_gtH.js → blockDiagram-677ZJIJ3-DkA5Z_uK.js} +1 -1
  11. package/web/assets/{c4Diagram-LMCZKHZV-YjWWkNOJ.js → c4Diagram-LMCZKHZV-C78dI0l-.js} +1 -1
  12. package/web/assets/channel-D9AxU8kk.js +1 -0
  13. package/web/assets/{chunk-2Q5K7J3B-Bd9qqRl0.js → chunk-2Q5K7J3B-LBNP12XV.js} +1 -1
  14. package/web/assets/{chunk-32BRIVSS-Cq9EhooX.js → chunk-32BRIVSS-DZoXrhCs.js} +1 -1
  15. package/web/assets/{chunk-5VM5RSS4-DYxyelz5.js → chunk-5VM5RSS4-CPjoRxF_.js} +1 -1
  16. package/web/assets/{chunk-EX3LRPZG-COaiqIlv.js → chunk-EX3LRPZG-DSlL8FE9.js} +1 -1
  17. package/web/assets/{chunk-JWPE2WC7-CMWXKnkF.js → chunk-JWPE2WC7-BjbBLYge.js} +1 -1
  18. package/web/assets/{chunk-MOJQB5TN-BRzAoPL7.js → chunk-MOJQB5TN-DCKqdaDr.js} +1 -1
  19. package/web/assets/{chunk-RYQCIY6F-BRght6pl.js → chunk-RYQCIY6F-D5yAURhE.js} +1 -1
  20. package/web/assets/{chunk-V7JOEXUC-BlbCYGxG.js → chunk-V7JOEXUC-DKY2sn06.js} +1 -1
  21. package/web/assets/{chunk-VR4S4FIN-CPoKxaoM.js → chunk-VR4S4FIN-DGiVMHh6.js} +1 -1
  22. package/web/assets/{chunk-XXDRQBXY-DeNDfu5r.js → chunk-XXDRQBXY-CUI1Tkrr.js} +1 -1
  23. package/web/assets/classDiagram-OUVF2IWQ-D7gWuzIF.js +1 -0
  24. package/web/assets/classDiagram-v2-EOCWNBFH-D7gWuzIF.js +1 -0
  25. package/web/assets/{cose-bilkent-JH36ORCC-C_V1sCw0.js → cose-bilkent-JH36ORCC-Ao3LzalV.js} +1 -1
  26. package/web/assets/{cynefin-VYW2F7L2-CtWqChj_.js → cynefin-VYW2F7L2-7JQwpRwH.js} +1 -1
  27. package/web/assets/{cynefinDiagram-TSTJHNR4-Q0g7rhQg.js → cynefinDiagram-TSTJHNR4-BfuANy_V.js} +1 -1
  28. package/web/assets/{dagre-VKFMJZFB-spMt8V5o.js → dagre-VKFMJZFB-DHVnVJCL.js} +1 -1
  29. package/web/assets/{diagram-FQU43EPY-CF7kRW9U.js → diagram-FQU43EPY-OYrtwA2f.js} +1 -1
  30. package/web/assets/{diagram-G47NLZAW-B-5ygaqt.js → diagram-G47NLZAW-CbkLZxYp.js} +1 -1
  31. package/web/assets/{diagram-NH7WQ7WH-CdOGPV2-.js → diagram-NH7WQ7WH-B0z_jTdi.js} +1 -1
  32. package/web/assets/{diagram-OA4YK3LP-D1_1pb2t.js → diagram-OA4YK3LP-BRu_2nwx.js} +1 -1
  33. package/web/assets/{diagram-WEI45ONY-gD_xjCgB.js → diagram-WEI45ONY-B9cpw86D.js} +1 -1
  34. package/web/assets/{ebnfDiagram-CCIWWBDH-aW4E-V1v.js → ebnfDiagram-CCIWWBDH-DaBQ7T4R.js} +1 -1
  35. package/web/assets/{erDiagram-Q63AITRT-BWndxFVg.js → erDiagram-Q63AITRT-D6PQiFj3.js} +1 -1
  36. package/web/assets/{flowDiagram-23GEKE2U-CVc37qLo.js → flowDiagram-23GEKE2U-p_8Wj7It.js} +1 -1
  37. package/web/assets/{ganttDiagram-NO4QXBWP-DV-2BraZ.js → ganttDiagram-NO4QXBWP-BZBwuqu3.js} +1 -1
  38. package/web/assets/{gitGraphDiagram-IHSO6WYX-YCy7dADs.js → gitGraphDiagram-IHSO6WYX-DyPcQ-91.js} +1 -1
  39. package/web/assets/{highlighted-body-OFNGDK62-q_mVDG84.js → highlighted-body-OFNGDK62-Cq7RJZcn.js} +1 -1
  40. package/web/assets/index-BN-QUr5b.js +350 -0
  41. package/web/assets/index-D6-LVQ5m.css +1 -0
  42. package/web/assets/{infoDiagram-FWYZ7A6U-ig4wRILz.js → infoDiagram-FWYZ7A6U-sAgBAeWC.js} +1 -1
  43. package/web/assets/{ishikawaDiagram-FXEZZL3T-BE8nJNWr.js → ishikawaDiagram-FXEZZL3T-COEn9wCs.js} +1 -1
  44. package/web/assets/{journeyDiagram-5HDEW3XC-_olL4LFF.js → journeyDiagram-5HDEW3XC-BPddPRXW.js} +1 -1
  45. package/web/assets/{kanban-definition-HUTT4EX6-DTFmbw9d.js → kanban-definition-HUTT4EX6-B8TW97Oy.js} +1 -1
  46. package/web/assets/{linear-CbmtQHdI.js → linear-CnW5uQ_k.js} +1 -1
  47. package/web/assets/{mermaid.core-CyLpSBwh.js → mermaid.core-bOV5iNX_.js} +4 -4
  48. package/web/assets/{mindmap-definition-LN4V7U3C-BFXQ_blT.js → mindmap-definition-LN4V7U3C-Dtr4xW_-.js} +1 -1
  49. package/web/assets/{pegDiagram-2B236MQR-BzFkomCY.js → pegDiagram-2B236MQR-DqBITK5N.js} +1 -1
  50. package/web/assets/{pieDiagram-ENE6RG2P-r6gWlwuV.js → pieDiagram-ENE6RG2P-DXjxZgy3.js} +1 -1
  51. package/web/assets/{quadrantDiagram-ABIIQ3AL-DTFxtS0e.js → quadrantDiagram-ABIIQ3AL-D4MXx3U7.js} +1 -1
  52. package/web/assets/{railroadDiagram-RFXS5EU6-CbZAYLVe.js → railroadDiagram-RFXS5EU6-LPfpHg4_.js} +1 -1
  53. package/web/assets/{requirementDiagram-TGXJPOKE-DzGi5GvN.js → requirementDiagram-TGXJPOKE-CiDswbjt.js} +1 -1
  54. package/web/assets/{sankeyDiagram-HTMAVEWB-CBWOwAUD.js → sankeyDiagram-HTMAVEWB-VfN_qTEI.js} +1 -1
  55. package/web/assets/{sequenceDiagram-DBY2YBRQ-QX9rQ9ow.js → sequenceDiagram-DBY2YBRQ-kJRIx4zm.js} +1 -1
  56. package/web/assets/{sizeCapture-X5ZJPWSS-X8SU5JUW.js → sizeCapture-X5ZJPWSS-D8vJbAt-.js} +1 -1
  57. package/web/assets/{stateDiagram-2N3HPSRC-kQgWzKK8.js → stateDiagram-2N3HPSRC-9qku-qAj.js} +1 -1
  58. package/web/assets/stateDiagram-v2-6OUMAXLB-DbOQICyv.js +1 -0
  59. package/web/assets/{swimlanes-5IMT3BWC-eKT1_uaa.js → swimlanes-5IMT3BWC-Cdb5FDUM.js} +2 -2
  60. package/web/assets/swimlanesDiagram-G3AALYLV-EDpLmqQv.js +8 -0
  61. package/web/assets/{timeline-definition-FHXFAJF6-BRKXt19C.js → timeline-definition-FHXFAJF6-D0hyJCMm.js} +1 -1
  62. package/web/assets/{vennDiagram-L72KCM5P-C-mbeAXb.js → vennDiagram-L72KCM5P-KsCkGMtr.js} +1 -1
  63. package/web/assets/{wardleyDiagram-EHGQE667-Doojx4Uh.js → wardleyDiagram-EHGQE667-B4jT_1ak.js} +1 -1
  64. package/web/assets/{xychartDiagram-FW5EYKEG-CQZh8Trp.js → xychartDiagram-FW5EYKEG-Bmy0rt8j.js} +1 -1
  65. package/web/index.html +17 -2
  66. package/web/assets/channel-8ymR7Shm.js +0 -1
  67. package/web/assets/classDiagram-OUVF2IWQ-g3gy3kDO.js +0 -1
  68. package/web/assets/classDiagram-v2-EOCWNBFH-g3gy3kDO.js +0 -1
  69. package/web/assets/index-BG6acTBk.css +0 -1
  70. package/web/assets/index-CBBSnvKL.js +0 -335
  71. package/web/assets/stateDiagram-v2-6OUMAXLB-2kXWc9Fv.js +0 -1
  72. package/web/assets/swimlanesDiagram-G3AALYLV-B5i-k7ui.js +0 -8
package/dist/server.js CHANGED
@@ -2,91 +2,99 @@
2
2
  import { serve as honoServe } from "@hono/node-server";
3
3
 
4
4
  // packages/shared/src/annotations.ts
5
- import { z as z2 } from "zod";
5
+ import { z as z3 } from "zod";
6
6
 
7
7
  // packages/shared/src/messages.ts
8
+ import { z as z2 } from "zod";
9
+
10
+ // packages/shared/src/session.ts
8
11
  import { z } from "zod";
9
- var attachmentSchema = z.object({
12
+ var sessionKindSchema = z.enum(["everyday", "spec"]);
13
+ var sessionStatusSchema = z.enum(["active", "ended"]);
14
+ var sessionMetaSchema = z.object({
10
15
  id: z.string().min(1),
11
- name: z.string().min(1),
12
- relPath: z.string().min(1),
13
- contentType: z.string().min(1),
14
- size: z.number().int().nonnegative()
16
+ kind: sessionKindSchema.optional(),
17
+ title: z.string().optional(),
18
+ status: sessionStatusSchema,
19
+ createdAt: z.string().datetime(),
20
+ updatedAt: z.string().datetime()
21
+ });
22
+
23
+ // packages/shared/src/messages.ts
24
+ var attachmentSchema = z2.object({
25
+ id: z2.string().min(1),
26
+ name: z2.string().min(1),
27
+ relPath: z2.string().min(1),
28
+ contentType: z2.string().min(1),
29
+ size: z2.number().int().nonnegative()
15
30
  });
16
- var messagePayloadSchema = z.object({
17
- text: z.string().min(1).optional(),
18
- attachments: z.array(attachmentSchema).optional()
31
+ var messagePayloadSchema = z2.object({
32
+ text: z2.string().min(1).optional(),
33
+ attachments: z2.array(attachmentSchema).optional()
19
34
  }).refine((p) => (p.text?.length ?? 0) > 0 || (p.attachments?.length ?? 0) > 0, {
20
35
  message: "a message needs text or at least one attachment"
21
36
  });
22
- var systemReasonSchema = z.enum(["session-created", "user-moved-away", "session-ended"]);
23
- var systemPayloadSchema = z.object({
37
+ var systemReasonSchema = z2.enum([
38
+ "session-created",
39
+ "user-moved-away",
40
+ "session-ended",
41
+ "kind-chosen"
42
+ ]);
43
+ var systemPayloadSchema = z2.object({
24
44
  reason: systemReasonSchema,
25
- to: z.string().min(1).optional()
45
+ to: z2.string().min(1).optional(),
46
+ kind: sessionKindSchema.optional()
26
47
  });
27
48
 
28
49
  // packages/shared/src/annotations.ts
29
50
  var ANNOTATION_BATCH = "annotation-batch";
30
- var annotationSourceSchema = z2.object({
31
- file: z2.string().min(1),
32
- line: z2.number().int().positive().optional(),
33
- column: z2.number().int().positive().optional()
51
+ var annotationSourceSchema = z3.object({
52
+ file: z3.string().min(1),
53
+ line: z3.number().int().positive().optional(),
54
+ column: z3.number().int().positive().optional()
34
55
  });
35
- var annotationComponentSchema = z2.object({
36
- name: z2.string().min(1),
56
+ var annotationComponentSchema = z3.object({
57
+ name: z3.string().min(1),
37
58
  source: annotationSourceSchema.optional()
38
59
  });
39
- var annotationTargetSchema = z2.object({
40
- selector: z2.string().min(1),
41
- pageUrl: z2.string().min(1),
42
- viewport: z2.object({
43
- width: z2.number().int().positive(),
44
- height: z2.number().int().positive()
60
+ var annotationTargetSchema = z3.object({
61
+ selector: z3.string().min(1),
62
+ pageUrl: z3.string().min(1),
63
+ viewport: z3.object({
64
+ width: z3.number().int().positive(),
65
+ height: z3.number().int().positive()
45
66
  }),
46
- label: z2.string().optional(),
67
+ label: z3.string().optional(),
47
68
  component: annotationComponentSchema.optional()
48
69
  });
49
- var annotationItemSchema = z2.object({
50
- note: z2.string().min(1).optional(),
70
+ var annotationItemSchema = z3.object({
71
+ note: z3.string().min(1).optional(),
51
72
  target: annotationTargetSchema,
52
73
  screenshot: attachmentSchema.optional()
53
74
  });
54
- var annotationBatchPayloadSchema = z2.object({
55
- items: z2.array(annotationItemSchema).min(1)
75
+ var annotationBatchPayloadSchema = z3.object({
76
+ items: z3.array(annotationItemSchema).min(1)
56
77
  });
57
78
 
58
79
  // packages/shared/src/api.ts
59
80
  import { z as z7 } from "zod";
60
81
 
61
82
  // packages/shared/src/events.ts
62
- import { z as z3 } from "zod";
63
- var eventSourceSchema = z3.enum(["user", "agent", "system"]);
64
- var eventEnvelopeSchema = z3.object({
65
- id: z3.string().uuid(),
66
- seq: z3.number().int().nonnegative(),
67
- sessionId: z3.string().min(1),
68
- ts: z3.string().datetime(),
69
- type: z3.string().min(1),
83
+ import { z as z4 } from "zod";
84
+ var eventSourceSchema = z4.enum(["user", "agent", "system"]);
85
+ var eventEnvelopeSchema = z4.object({
86
+ id: z4.string().uuid(),
87
+ seq: z4.number().int().nonnegative(),
88
+ sessionId: z4.string().min(1),
89
+ ts: z4.string().datetime(),
90
+ type: z4.string().min(1),
70
91
  source: eventSourceSchema,
71
- payload: z3.unknown()
92
+ payload: z4.unknown()
72
93
  });
73
94
 
74
95
  // packages/shared/src/presence.ts
75
- import { z as z4 } from "zod";
76
- var presenceSchema = z4.enum(["listening", "away"]);
77
-
78
- // packages/shared/src/session.ts
79
96
  import { z as z5 } from "zod";
80
- var sessionKindSchema = z5.enum(["everyday", "spec"]);
81
- var sessionStatusSchema = z5.enum(["active", "ended"]);
82
- var sessionMetaSchema = z5.object({
83
- id: z5.string().min(1),
84
- kind: sessionKindSchema,
85
- title: z5.string().optional(),
86
- status: sessionStatusSchema,
87
- createdAt: z5.string().datetime(),
88
- updatedAt: z5.string().datetime()
89
- });
97
+ var presenceSchema = z5.enum(["listening", "working", "away"]);
90
98
 
91
99
  // packages/shared/src/spec.ts
92
100
  import { z as z6 } from "zod";
@@ -129,6 +137,9 @@ var createSessionReqSchema = z7.object({
129
137
  kind: sessionKindSchema.optional(),
130
138
  title: z7.string().min(1).optional()
131
139
  });
140
+ var setSessionKindReqSchema = z7.object({
141
+ kind: sessionKindSchema
142
+ });
132
143
  var messageBodySchema = z7.object({
133
144
  text: z7.string().min(1).optional(),
134
145
  attachmentIds: z7.array(z7.string().min(1)).optional()
@@ -163,7 +174,8 @@ var eventResSchema = z7.object({
163
174
  });
164
175
  var pollResSchema = z7.object({
165
176
  events: z7.array(eventEnvelopeSchema),
166
- cursor: cursorSchema
177
+ cursor: cursorSchema,
178
+ ended: z7.boolean().optional()
167
179
  });
168
180
  var fetchMoreSchema = z7.object({
169
181
  hasMore: z7.boolean(),
@@ -189,7 +201,10 @@ var sessionResSchema = z7.object({
189
201
  });
190
202
  var healthResSchema = z7.object({
191
203
  ok: z7.boolean(),
192
- sessions: cursorSchema
204
+ sessions: cursorSchema,
205
+ home: z7.string().min(1).optional(),
206
+ port: z7.number().int().positive().optional(),
207
+ version: z7.string().min(1).optional()
193
208
  });
194
209
 
195
210
  // packages/shared/src/issues.ts
@@ -221,6 +236,10 @@ var wsServerMessageSchema = z9.discriminatedUnion("t", [
221
236
  t: z9.literal("presence"),
222
237
  state: presenceSchema
223
238
  }),
239
+ z9.object({
240
+ t: z9.literal("kind"),
241
+ meta: sessionMetaSchema
242
+ }),
224
243
  z9.object({
225
244
  t: z9.literal("ended"),
226
245
  meta: sessionMetaSchema
@@ -357,7 +376,12 @@ function resolveWebDist() {
357
376
  }
358
377
  function createStaticHandler(webDist) {
359
378
  return async (c) => {
360
- const pathname = decodeURIComponent(new URL(c.req.url).pathname);
379
+ let pathname;
380
+ try {
381
+ pathname = decodeURIComponent(new URL(c.req.url).pathname);
382
+ } catch {
383
+ return c.json({ error: "not found" }, 404);
384
+ }
361
385
  if (pathname.startsWith("/api") || pathname.startsWith("/ws")) {
362
386
  return c.json({ error: "not found" }, 404);
363
387
  }
@@ -389,6 +413,14 @@ async function readFileOrNull(filePath) {
389
413
  }
390
414
 
391
415
  // packages/server/src/app.ts
416
+ var INLINE_SAFE_TYPES = /* @__PURE__ */ new Set([
417
+ "image/png",
418
+ "image/jpeg",
419
+ "image/gif",
420
+ "image/webp",
421
+ "image/x-icon"
422
+ ]);
423
+ var MAX_UPLOAD_BYTES = 25 * 1024 * 1024;
392
424
  function createApp(ctx) {
393
425
  const app = new Hono();
394
426
  const { injectWebSocket, upgradeWebSocket } = createNodeWebSocket({ app });
@@ -400,7 +432,13 @@ function createApp(ctx) {
400
432
  app.get("/api/health", async (c) => {
401
433
  const all = await ctx.sessions.list();
402
434
  const active = all.filter((meta) => meta.status === "active").length;
403
- return c.json({ ok: true, sessions: active });
435
+ return c.json({
436
+ ok: true,
437
+ sessions: active,
438
+ home: ctx.home,
439
+ port: ctx.port,
440
+ version: ctx.version
441
+ });
404
442
  });
405
443
  app.post("/api/sessions", async (c) => {
406
444
  const parsed = createSessionReqSchema.safeParse(await readJson(c));
@@ -447,9 +485,14 @@ function createApp(ctx) {
447
485
  const meta = await ctx.sessions.getMeta(id);
448
486
  if (!meta) return notFound(c);
449
487
  if (meta.status === "ended") return c.json({ error: "session ended" }, 409);
488
+ const declaredLength = Number(c.req.header("content-length"));
489
+ if (Number.isFinite(declaredLength) && declaredLength > MAX_UPLOAD_BYTES) {
490
+ return c.json({ error: "file too large" }, 413);
491
+ }
450
492
  const body = await c.req.parseBody();
451
493
  const file = body.file;
452
494
  if (!(file instanceof File)) return c.json({ error: "expected a `file` field" }, 400);
495
+ if (file.size > MAX_UPLOAD_BYTES) return c.json({ error: "file too large" }, 413);
453
496
  const bytes = new Uint8Array(await file.arrayBuffer());
454
497
  if (bytes.byteLength === 0) return c.json({ error: "empty file" }, 400);
455
498
  const attachment = await ctx.sessions.saveAttachment(id, file.name || "file", bytes);
@@ -458,7 +501,13 @@ function createApp(ctx) {
458
501
  app.get("/api/sessions/:id/attachments/:attId", async (c) => {
459
502
  const file = await ctx.sessions.readAttachment(c.req.param("id"), c.req.param("attId"));
460
503
  if (!file) return notFound(c);
461
- return c.body(file.bytes, 200, { "content-type": file.contentType });
504
+ const headers = {
505
+ "content-type": file.contentType,
506
+ "x-content-type-options": "nosniff"
507
+ };
508
+ const baseType = file.contentType.split(";")[0]?.trim().toLowerCase() ?? "";
509
+ if (!INLINE_SAFE_TYPES.has(baseType)) headers["content-disposition"] = "attachment";
510
+ return c.body(file.bytes, 200, headers);
462
511
  });
463
512
  app.post("/api/sessions/:id/annotations", async (c) => {
464
513
  const id = c.req.param("id");
@@ -532,11 +581,13 @@ function createApp(ctx) {
532
581
  const since = parseNonNeg(c.req.query("since"), 0);
533
582
  const wait = meta.status === "ended" ? 0 : clamp(parseNonNeg(c.req.query("wait"), 25e3), 0, 3e4);
534
583
  ctx.presence.enter(id);
584
+ let delivered = false;
535
585
  try {
536
586
  const res = await ctx.sessions.poll(id, since, wait, c.req.raw.signal);
537
- return c.json(res);
587
+ delivered = res.events.length > 0;
588
+ return c.json({ ...res, ended: meta.status === "ended" });
538
589
  } finally {
539
- ctx.presence.leave(id);
590
+ ctx.presence.leave(id, c.req.raw.signal.aborted, delivered);
540
591
  }
541
592
  });
542
593
  app.get("/api/sessions/:id/events", async (c) => {
@@ -555,6 +606,19 @@ function createApp(ctx) {
555
606
  if (!digest) return notFound(c);
556
607
  return c.json(digest);
557
608
  });
609
+ app.post("/api/sessions/:id/kind", async (c) => {
610
+ const id = c.req.param("id");
611
+ const parsed = setSessionKindReqSchema.safeParse(await readJson(c));
612
+ if (!parsed.success) return badRequest(c, parsed.error.issues);
613
+ const result = await ctx.sessions.setKind(id, parsed.data.kind);
614
+ if (!result.ok) {
615
+ if (result.reason === "unknown") return notFound(c);
616
+ const message = result.reason === "ended" ? "session ended" : "kind already set";
617
+ return c.json({ error: message }, 409);
618
+ }
619
+ ctx.hub.broadcast(id, { t: "kind", meta: result.meta });
620
+ return c.json({ session: summaryOf(result.meta) });
621
+ });
558
622
  app.post("/api/sessions/:id/end", async (c) => {
559
623
  const id = c.req.param("id");
560
624
  const meta = await ctx.sessions.end(id);
@@ -568,7 +632,7 @@ function createApp(ctx) {
568
632
  const id = new URL(c.req.url).searchParams.get("s") ?? "";
569
633
  return {
570
634
  onOpen: (_evt, ws) => {
571
- void openSocket(ctx, id, ws);
635
+ openSocket(ctx, id, ws).catch(() => ws.close(1011, "internal error"));
572
636
  },
573
637
  onClose: (_evt, ws) => {
574
638
  ctx.hub.remove(id, ws);
@@ -675,11 +739,62 @@ import { unlinkSync } from "node:fs";
675
739
  import { open, readFile as readFile4, rm } from "node:fs/promises";
676
740
 
677
741
  // packages/server/src/sessions/paths.ts
742
+ import { existsSync as existsSync3, realpathSync } from "node:fs";
678
743
  import { appendFile, mkdir, readFile as readFile3 } from "node:fs/promises";
679
744
  import path4 from "node:path";
680
- function resolveHome() {
681
- const override = process.env.CHAMBA_DIR;
682
- return override && override.length > 0 ? path4.resolve(override) : path4.join(process.cwd(), ".chamba");
745
+ function defaultContext() {
746
+ return { cwd: process.cwd(), env: process.env };
747
+ }
748
+ function resolveHome(ctx = defaultContext()) {
749
+ return findExistingHome(ctx) ?? plannedHome(ctx);
750
+ }
751
+ function findExistingHome(ctx = defaultContext()) {
752
+ const dir = ctx.env.CHAMBA_DIR;
753
+ if (dir && dir.length > 0) return canonicalize(dir);
754
+ const inside = enclosingChamba(ctx.cwd);
755
+ if (inside) return canonicalize(inside);
756
+ const root = gitRoot(ctx.cwd);
757
+ let cursor = path4.resolve(ctx.cwd);
758
+ while (true) {
759
+ if (existsSync3(path4.join(cursor, ".chamba"))) return canonicalize(path4.join(cursor, ".chamba"));
760
+ if (!root) break;
761
+ if (cursor === root) break;
762
+ const parent = path4.dirname(cursor);
763
+ if (parent === cursor) break;
764
+ cursor = parent;
765
+ }
766
+ return null;
767
+ }
768
+ function plannedHome(ctx = defaultContext()) {
769
+ const root = gitRoot(ctx.cwd) ?? path4.resolve(ctx.cwd);
770
+ return canonicalize(path4.join(root, ".chamba"));
771
+ }
772
+ function gitRoot(cwd) {
773
+ let cursor = path4.resolve(cwd);
774
+ while (true) {
775
+ if (existsSync3(path4.join(cursor, ".git"))) return cursor;
776
+ const parent = path4.dirname(cursor);
777
+ if (parent === cursor) return null;
778
+ cursor = parent;
779
+ }
780
+ }
781
+ function enclosingChamba(cwd) {
782
+ const parts = path4.resolve(cwd).split(path4.sep);
783
+ const index = parts.lastIndexOf(".chamba");
784
+ if (index < 0) return null;
785
+ return parts.slice(0, index + 1).join(path4.sep) || path4.sep;
786
+ }
787
+ function canonicalize(p) {
788
+ const resolved = path4.resolve(p);
789
+ let existing = resolved;
790
+ const tail = [];
791
+ while (!existsSync3(existing)) {
792
+ tail.unshift(path4.basename(existing));
793
+ const parent = path4.dirname(existing);
794
+ if (parent === existing) return resolved;
795
+ existing = parent;
796
+ }
797
+ return path4.join(realpathSync(existing), ...tail);
683
798
  }
684
799
  function sessionsDir(home) {
685
800
  return path4.join(home, "sessions");
@@ -844,12 +959,20 @@ function resolveBindHost(argv = process.argv.slice(2)) {
844
959
  if (env !== void 0 && env.length > 0) return env;
845
960
  return DEFAULT_BIND_HOST;
846
961
  }
847
- function resolvePort(argv = process.argv.slice(2)) {
962
+ function resolvePort(argv = process.argv.slice(2), home) {
848
963
  const flag = readFlag(argv, "--port");
849
964
  if (flag !== void 0) return parsePort(flag, "--port");
850
965
  const env = process.env.CHAMBA_PORT;
851
966
  if (env !== void 0 && env.length > 0) return parsePort(env, "CHAMBA_PORT");
852
- return DEFAULT_PORT;
967
+ return home !== void 0 ? derivePort(home) : DEFAULT_PORT;
968
+ }
969
+ function derivePort(home) {
970
+ let hash = 2166136261;
971
+ for (let i = 0; i < home.length; i++) {
972
+ hash ^= home.charCodeAt(i);
973
+ hash = Math.imul(hash, 16777619);
974
+ }
975
+ return DEFAULT_PORT + Math.abs(hash) % 512;
853
976
  }
854
977
  function readFlag(argv, name) {
855
978
  for (let i = 0; i < argv.length; i++) {
@@ -861,7 +984,7 @@ function readFlag(argv, name) {
861
984
  }
862
985
  function parsePort(value, source) {
863
986
  const port = Number(value);
864
- if (!Number.isInteger(port) || port < 0 || port > 65535) {
987
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
865
988
  throw new Error(`invalid ${source}: ${value}`);
866
989
  }
867
990
  return port;
@@ -869,8 +992,16 @@ function parsePort(value, source) {
869
992
 
870
993
  // packages/server/src/presence.ts
871
994
  var PresenceTracker = class {
872
- constructor(graceMs = 9e3) {
873
- this.graceMs = graceMs;
995
+ /**
996
+ * `idleAwayMs` is how long presence stays `listening` after a normal poll
997
+ * return - long enough to bridge real work gaps. `dropAwayMs` is the shorter
998
+ * window used when a poll's client disconnected, so a killed or crashed agent
999
+ * surfaces quickly. Both are unrelated to `ServeOptions.idleMs`, which is the
1000
+ * server's own self-shutdown window.
1001
+ */
1002
+ constructor(idleAwayMs = 30 * 6e4, dropAwayMs = 1e4) {
1003
+ this.idleAwayMs = idleAwayMs;
1004
+ this.dropAwayMs = dropAwayMs;
874
1005
  }
875
1006
  active = /* @__PURE__ */ new Map();
876
1007
  lastSeen = /* @__PURE__ */ new Map();
@@ -887,18 +1018,27 @@ var PresenceTracker = class {
887
1018
  this.clearTimer(id);
888
1019
  this.set(id, "listening");
889
1020
  }
890
- /** A poll returned or timed out: still present through the grace window. */
891
- leave(id) {
1021
+ /**
1022
+ * A poll returned or timed out: still present through the away window. `dropped`
1023
+ * marks a client disconnect (the agent's process died or was killed mid-poll),
1024
+ * which uses the short window; a normal return uses the long one. `delivered`
1025
+ * marks a clean return that carried input - the agent took the human's message
1026
+ * and left to work - so with no poll left parked the state flips to `working`,
1027
+ * with the long window as the backstop if the agent never comes back.
1028
+ */
1029
+ leave(id, dropped = false, delivered = false) {
892
1030
  const remaining = Math.max(0, (this.active.get(id) ?? 1) - 1);
893
1031
  this.active.set(id, remaining);
894
1032
  this.lastSeen.set(id, Date.now());
895
- if (remaining === 0) this.scheduleAway(id);
1033
+ if (remaining > 0) return;
1034
+ if (delivered && !dropped) this.set(id, "working");
1035
+ this.scheduleAway(id, dropped ? this.dropAwayMs : this.idleAwayMs);
896
1036
  }
897
1037
  /** Any other sign of life (e.g. a reply) keeps the agent listening. */
898
1038
  beat(id) {
899
1039
  this.lastSeen.set(id, Date.now());
900
1040
  this.set(id, "listening");
901
- if ((this.active.get(id) ?? 0) === 0) this.scheduleAway(id);
1041
+ if ((this.active.get(id) ?? 0) === 0) this.scheduleAway(id, this.idleAwayMs);
902
1042
  }
903
1043
  /** True while any session has a parked poll - used to hold off idle shutdown. */
904
1044
  hasActivePolls() {
@@ -911,12 +1051,12 @@ var PresenceTracker = class {
911
1051
  this.listeners.add(listener);
912
1052
  return () => this.listeners.delete(listener);
913
1053
  }
914
- scheduleAway(id) {
1054
+ scheduleAway(id, delayMs) {
915
1055
  this.clearTimer(id);
916
1056
  const timer = setTimeout(() => {
917
1057
  this.timers.delete(id);
918
1058
  if ((this.active.get(id) ?? 0) === 0) this.set(id, "away");
919
- }, this.graceMs);
1059
+ }, delayMs);
920
1060
  timer.unref?.();
921
1061
  this.timers.set(id, timer);
922
1062
  }
@@ -935,19 +1075,19 @@ var PresenceTracker = class {
935
1075
  };
936
1076
 
937
1077
  // packages/server/src/sessions/index.ts
938
- import { randomBytes, randomUUID as randomUUID2 } from "node:crypto";
1078
+ import { randomBytes, randomUUID as randomUUID3 } from "node:crypto";
939
1079
  import { mkdir as mkdir3, readdir as readdir2, readFile as readFile7, stat, writeFile as writeFile2 } from "node:fs/promises";
940
1080
  import path7 from "node:path";
941
1081
 
942
1082
  // packages/server/src/version.ts
943
- import { existsSync as existsSync3, readFileSync } from "node:fs";
1083
+ import { existsSync as existsSync4, readFileSync } from "node:fs";
944
1084
  import path5 from "node:path";
945
1085
  import { fileURLToPath as fileURLToPath3 } from "node:url";
946
1086
  function chambaVersion() {
947
1087
  let dir = path5.dirname(fileURLToPath3(import.meta.url));
948
1088
  for (; ; ) {
949
1089
  const manifest = path5.join(dir, "package.json");
950
- if (existsSync3(manifest)) {
1090
+ if (existsSync4(manifest)) {
951
1091
  try {
952
1092
  const { version } = JSON.parse(readFileSync(manifest, "utf8"));
953
1093
  if (typeof version === "string" && version) return version;
@@ -970,7 +1110,7 @@ function buildIssueRecord(input) {
970
1110
  chambaVersion: chambaVersion(),
971
1111
  session: {
972
1112
  id: input.meta.id,
973
- kind: input.meta.kind,
1113
+ kind: input.meta.kind ?? "unset",
974
1114
  status: input.meta.status,
975
1115
  ...input.meta.title ? { title: input.meta.title } : {}
976
1116
  },
@@ -1038,7 +1178,7 @@ function truncate(text, max) {
1038
1178
 
1039
1179
  // packages/server/src/sessions/log.ts
1040
1180
  import { randomUUID } from "node:crypto";
1041
- import { mkdir as mkdir2, open as open2, readFile as readFile5 } from "node:fs/promises";
1181
+ import { mkdir as mkdir2, open as open2, readFile as readFile5, truncate as truncate2 } from "node:fs/promises";
1042
1182
  import path6 from "node:path";
1043
1183
  var SessionLog = class {
1044
1184
  constructor(id, file) {
@@ -1064,17 +1204,37 @@ var SessionLog = class {
1064
1204
  }
1065
1205
  async replay() {
1066
1206
  await mkdir2(path6.dirname(this.file), { recursive: true });
1207
+ this.events = [];
1067
1208
  let content = "";
1068
1209
  try {
1069
1210
  content = await readFile5(this.file, "utf8");
1070
1211
  } catch (err) {
1071
1212
  if (err.code !== "ENOENT") throw err;
1072
1213
  }
1073
- for (const line of content.split("\n")) {
1074
- const trimmed = line.trim();
1075
- if (trimmed.length === 0) continue;
1076
- this.events.push(eventEnvelopeSchema.parse(JSON.parse(trimmed)));
1214
+ const lines = content.split("\n");
1215
+ let validBytes = 0;
1216
+ let truncateTo = null;
1217
+ for (let i = 0; i < lines.length; i++) {
1218
+ const line = lines[i] ?? "";
1219
+ const hasSeparator = i < lines.length - 1;
1220
+ const byteLen = Buffer.byteLength(line, "utf8") + (hasSeparator ? 1 : 0);
1221
+ if (line.trim().length === 0) {
1222
+ validBytes += byteLen;
1223
+ continue;
1224
+ }
1225
+ try {
1226
+ this.events.push(eventEnvelopeSchema.parse(JSON.parse(line)));
1227
+ validBytes += byteLen;
1228
+ } catch (err) {
1229
+ const isLastContentLine = lines.slice(i + 1).every((rest) => rest.trim().length === 0);
1230
+ if (isLastContentLine && !hasSeparator) {
1231
+ truncateTo = validBytes;
1232
+ break;
1233
+ }
1234
+ throw err;
1235
+ }
1077
1236
  }
1237
+ if (truncateTo !== null) await truncate2(this.file, truncateTo);
1078
1238
  this.handle = await open2(this.file, "a");
1079
1239
  this.loaded = true;
1080
1240
  }
@@ -1173,7 +1333,8 @@ var SessionLog = class {
1173
1333
  };
1174
1334
 
1175
1335
  // packages/server/src/sessions/meta.ts
1176
- import { readdir, readFile as readFile6, rename, writeFile } from "node:fs/promises";
1336
+ import { randomUUID as randomUUID2 } from "node:crypto";
1337
+ import { readdir, readFile as readFile6, rename, rm as rm2, writeFile } from "node:fs/promises";
1177
1338
  async function readMeta(home, id) {
1178
1339
  try {
1179
1340
  const raw = await readFile6(metaPath(home, id), "utf8");
@@ -1185,10 +1346,15 @@ async function readMeta(home, id) {
1185
1346
  }
1186
1347
  async function writeMeta(home, meta) {
1187
1348
  const target = metaPath(home, meta.id);
1188
- const tmp = `${target}.tmp`;
1189
- await writeFile(tmp, `${JSON.stringify(meta, null, 2)}
1349
+ const tmp = `${target}.${randomUUID2()}.tmp`;
1350
+ try {
1351
+ await writeFile(tmp, `${JSON.stringify(meta, null, 2)}
1190
1352
  `, "utf8");
1191
- await rename(tmp, target);
1353
+ await rename(tmp, target);
1354
+ } catch (err) {
1355
+ await rm2(tmp, { force: true }).catch(() => void 0);
1356
+ throw err;
1357
+ }
1192
1358
  }
1193
1359
  async function listSessionIds(home) {
1194
1360
  try {
@@ -1207,16 +1373,36 @@ var Sessions = class {
1207
1373
  }
1208
1374
  logs = /* @__PURE__ */ new Map();
1209
1375
  metaCache = /* @__PURE__ */ new Map();
1376
+ metaLocks = /* @__PURE__ */ new Map();
1377
+ /**
1378
+ * Serialize a meta read-modify-write for one session. Without this, a message's
1379
+ * `touch` can read `active` from cache, then write it back on top of an `end`
1380
+ * that landed in between - leaving `meta.json` `active` while the log holds a
1381
+ * `session-ended` event. Chaining every read+write per session makes the last
1382
+ * writer see the previous one's result, so status can never regress.
1383
+ */
1384
+ withMetaLock(id, fn) {
1385
+ const prev = this.metaLocks.get(id) ?? Promise.resolve();
1386
+ const run = prev.then(fn, fn);
1387
+ this.metaLocks.set(
1388
+ id,
1389
+ run.then(
1390
+ () => void 0,
1391
+ () => void 0
1392
+ )
1393
+ );
1394
+ return run;
1395
+ }
1210
1396
  async init() {
1211
1397
  await ensureChambaHome(this.home);
1212
1398
  }
1213
1399
  async create(req) {
1214
1400
  await ensureChambaHome(this.home);
1215
- const id = await this.mintId();
1401
+ const id = await this.generateId();
1216
1402
  const now = (/* @__PURE__ */ new Date()).toISOString();
1217
1403
  const meta = {
1218
1404
  id,
1219
- kind: req.kind ?? "everyday",
1405
+ ...req.kind ? { kind: req.kind } : {},
1220
1406
  ...req.title ? { title: req.title } : {},
1221
1407
  status: "active",
1222
1408
  createdAt: now,
@@ -1299,14 +1485,14 @@ var Sessions = class {
1299
1485
  }
1300
1486
  /**
1301
1487
  * Persist an uploaded file into the session's attachments directory and return
1302
- * its durable reference. The stored name embeds a minted id and the sanitized
1488
+ * its durable reference. The stored name embeds a generated id and the sanitized
1303
1489
  * original (`<attId>__<name>`), so the file is self-describing on disk and
1304
1490
  * resolvable later without a sidecar; the recorded path stays session-relative.
1305
1491
  */
1306
1492
  async saveAttachment(id, name, bytes) {
1307
1493
  const dir = attachmentsDir(this.home, id);
1308
1494
  await mkdir3(dir, { recursive: true });
1309
- const attId = randomUUID2();
1495
+ const attId = randomUUID3();
1310
1496
  const safeName = sanitizeName(name);
1311
1497
  const stored = `${attId}__${safeName}`;
1312
1498
  await writeFile2(path7.join(dir, stored), bytes);
@@ -1368,9 +1554,10 @@ var Sessions = class {
1368
1554
  };
1369
1555
  }
1370
1556
  /** Read a stored artifact by its on-disk filename (basename of a ref's
1371
- * `relPath`), or null. The name is guarded to a flat safe filename so the serve
1372
- * route cannot be walked out of the artifacts directory. */
1557
+ * `relPath`), or null. The session id and the name are both guarded to flat safe
1558
+ * tokens so the serve route cannot be walked out of the artifacts directory. */
1373
1559
  async readArtifact(id, file) {
1560
+ if (!isSafeId(id)) return null;
1374
1561
  if (!/^[A-Za-z0-9._-]+$/.test(file) || file.includes("..")) return null;
1375
1562
  try {
1376
1563
  const content = await readFile7(path7.join(artifactsDir(this.home, id), file), "utf8");
@@ -1397,7 +1584,7 @@ var Sessions = class {
1397
1584
  const ext = path7.extname(ref.name) || extFromContentType(file.contentType);
1398
1585
  resolved.push({ name: `screenshot-${resolved.length + 1}${ext}`, bytes: file.bytes });
1399
1586
  }
1400
- const id = await this.mintId();
1587
+ const id = await this.generateId();
1401
1588
  const dir = issueDir(this.home, id);
1402
1589
  await mkdir3(dir, { recursive: true });
1403
1590
  for (const shot of resolved) await writeFile2(path7.join(dir, shot.name), shot.bytes);
@@ -1417,8 +1604,11 @@ var Sessions = class {
1417
1604
  return { id, path: dir };
1418
1605
  }
1419
1606
  /** The on-disk filename for an attachment id, or null. The id is a uuid (no
1420
- * `__`), so the `<attId>__` prefix uniquely identifies one stored file. */
1607
+ * `__`), so the `<attId>__` prefix uniquely identifies one stored file. The
1608
+ * session id is guarded too: the attachment-serve route reaches here without the
1609
+ * `getMeta` check the other routes use, so an unsafe id must fail closed here. */
1421
1610
  async storedAttachment(id, attId) {
1611
+ if (!isSafeId(id)) return null;
1422
1612
  if (!/^[0-9a-f-]+$/i.test(attId)) return null;
1423
1613
  let entries;
1424
1614
  try {
@@ -1483,27 +1673,63 @@ var Sessions = class {
1483
1673
  source: "system",
1484
1674
  payload: { reason: "session-ended" }
1485
1675
  });
1486
- const current = await this.getMeta(id) ?? meta;
1487
- const ended = { ...current, status: "ended", updatedAt: (/* @__PURE__ */ new Date()).toISOString() };
1488
- await writeMeta(this.home, ended);
1489
- this.metaCache.set(id, ended);
1490
- return ended;
1676
+ return this.withMetaLock(id, async () => {
1677
+ const current = await this.getMeta(id) ?? meta;
1678
+ const ended = {
1679
+ ...current,
1680
+ status: "ended",
1681
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
1682
+ };
1683
+ await writeMeta(this.home, ended);
1684
+ this.metaCache.set(id, ended);
1685
+ return ended;
1686
+ });
1491
1687
  }
1492
- async touch(id) {
1688
+ /**
1689
+ * Set the kind of an undecided session - the human's choice in the browser.
1690
+ * Allowed once: a session that is unknown, ended, or already has a kind is
1691
+ * refused. Appends a `kind-chosen` marker so the agent's poll learns the choice,
1692
+ * then locks the kind into meta. Keeps the property that a decided session's
1693
+ * kind never changes (an everyday session never grows spec events).
1694
+ */
1695
+ async setKind(id, kind) {
1493
1696
  const meta = await this.getMeta(id);
1494
- if (!meta) return;
1495
- const updated = { ...meta, updatedAt: (/* @__PURE__ */ new Date()).toISOString() };
1496
- await writeMeta(this.home, updated);
1497
- this.metaCache.set(id, updated);
1697
+ if (!meta) return { ok: false, reason: "unknown" };
1698
+ if (meta.status === "ended") return { ok: false, reason: "ended" };
1699
+ if (meta.kind) return { ok: false, reason: "already-set" };
1700
+ await this.append(id, {
1701
+ type: "system",
1702
+ source: "system",
1703
+ payload: { reason: "kind-chosen", kind }
1704
+ });
1705
+ return this.withMetaLock(id, async () => {
1706
+ const current = await this.getMeta(id) ?? meta;
1707
+ const decided = { ...current, kind, updatedAt: (/* @__PURE__ */ new Date()).toISOString() };
1708
+ await writeMeta(this.home, decided);
1709
+ this.metaCache.set(id, decided);
1710
+ return { ok: true, meta: decided };
1711
+ });
1712
+ }
1713
+ touch(id) {
1714
+ return this.withMetaLock(id, async () => {
1715
+ const meta = await this.getMeta(id);
1716
+ if (!meta) return;
1717
+ const updated = { ...meta, updatedAt: (/* @__PURE__ */ new Date()).toISOString() };
1718
+ await writeMeta(this.home, updated);
1719
+ this.metaCache.set(id, updated);
1720
+ });
1498
1721
  }
1499
- async mintId() {
1722
+ async generateId() {
1500
1723
  for (let attempt = 0; attempt < 5; attempt++) {
1501
1724
  const id = `${Date.now().toString(36)}${randomBytes(3).toString("hex")}`;
1502
1725
  if (await readMeta(this.home, id) === null) return id;
1503
1726
  }
1504
- return randomUUID2();
1727
+ return randomUUID3();
1505
1728
  }
1506
1729
  };
1730
+ function isSafeId(id) {
1731
+ return /^[A-Za-z0-9_-]+$/.test(id);
1732
+ }
1507
1733
  var ISSUE_TAIL = 20;
1508
1734
  function extFromContentType(contentType) {
1509
1735
  const type = contentType.split(";")[0]?.trim().toLowerCase();
@@ -1594,7 +1820,7 @@ var WsHub = class {
1594
1820
  var DEFAULT_IDLE_MS = 5 * 6e4;
1595
1821
  async function serve(options = {}) {
1596
1822
  const home = options.home ?? resolveHome();
1597
- const port = options.port ?? resolvePort();
1823
+ const port = options.port ?? resolvePort(process.argv.slice(2), home);
1598
1824
  const host = options.host ?? resolveBindHost();
1599
1825
  const webDist = options.webDist ?? resolveWebDist();
1600
1826
  const injectDist = options.injectDist ?? resolveInjectDist();
@@ -1602,7 +1828,16 @@ async function serve(options = {}) {
1602
1828
  await sessions.init();
1603
1829
  const presence = new PresenceTracker();
1604
1830
  const hub = new WsHub();
1605
- const { app, injectWebSocket } = createApp({ sessions, presence, hub, webDist, injectDist });
1831
+ const { app, injectWebSocket } = createApp({
1832
+ sessions,
1833
+ presence,
1834
+ hub,
1835
+ webDist,
1836
+ injectDist,
1837
+ home,
1838
+ port,
1839
+ version: chambaVersion()
1840
+ });
1606
1841
  const pid = await acquirePidfile(home, port);
1607
1842
  const server = honoServe({ fetch: app.fetch, hostname: host, port });
1608
1843
  injectWebSocket(server);
@@ -1622,12 +1857,21 @@ async function serve(options = {}) {
1622
1857
  await pid.release();
1623
1858
  };
1624
1859
  stopWatchdog = startIdleWatchdog({
1625
- idleMs: options.idleMs ?? DEFAULT_IDLE_MS,
1860
+ idleMs: options.idleMs ?? parseIdleMs(process.env.CHAMBA_IDLE_MS) ?? DEFAULT_IDLE_MS,
1626
1861
  isBusy: () => hub.hasClients() || presence.hasActivePolls(),
1627
- onIdle: () => void close()
1862
+ // `close()` drains in-flight connections (a POST is neither a WS client nor a
1863
+ // parked poll, so `isBusy` does not count it) and releases the pidfile; the
1864
+ // explicit exit then guarantees the detached process actually goes away rather
1865
+ // than lingering on some stray handle.
1866
+ onIdle: () => void close().then(() => process.exit(0))
1628
1867
  });
1629
1868
  return { port, url: `http://localhost:${port}/`, home, sessions, close };
1630
1869
  }
1870
+ function parseIdleMs(value) {
1871
+ if (value === void 0 || value.length === 0) return null;
1872
+ const n = Number(value);
1873
+ return Number.isFinite(n) && n >= 0 ? Math.floor(n) : null;
1874
+ }
1631
1875
  function waitListening(server) {
1632
1876
  return new Promise((resolve, reject) => {
1633
1877
  const cleanup = () => {