chamba 0.3.1 → 0.4.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 (91) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +47 -44
  3. package/bin/chamba.js +212 -0
  4. package/dist/commands/advanced.js +278 -0
  5. package/dist/commands/dev.js +619 -0
  6. package/dist/commands/doctor.js +29 -0
  7. package/dist/commands/menu.js +80 -0
  8. package/dist/commands/onboard.js +229 -0
  9. package/dist/commands/settings.js +349 -0
  10. package/dist/lib/agent-context.js +177 -0
  11. package/dist/lib/browser.js +40 -0
  12. package/dist/lib/chamba-yaml.js +191 -0
  13. package/dist/lib/constants.js +135 -0
  14. package/dist/lib/dockerfile-builder.js +267 -0
  15. package/dist/lib/env.js +78 -0
  16. package/dist/lib/global-config.js +66 -0
  17. package/dist/lib/pnpm-store.js +19 -0
  18. package/dist/lib/ports.js +210 -0
  19. package/dist/lib/safe-rm.js +26 -0
  20. package/dist/lib/sessions.js +34 -0
  21. package/dist/lib/shadows.js +174 -0
  22. package/dist/lib/webterm.js +490 -0
  23. package/dist/lib/workspace-identity.js +260 -0
  24. package/package.json +61 -23
  25. package/schema/chamba.schema.json +65 -0
  26. package/templates/.dockerignore +3 -0
  27. package/templates/Dockerfile +173 -0
  28. package/templates/claude-statusline.sh +120 -0
  29. package/templates/context/baseline.md +13 -0
  30. package/templates/context/context-usage.md +1 -0
  31. package/templates/context/git-mode-local.md +1 -0
  32. package/templates/context/git-mode-strict.md +1 -0
  33. package/templates/context/git-mode-unrestricted.md +1 -0
  34. package/templates/context/git-unavailable.md +1 -0
  35. package/templates/context/shadow-paths.md +3 -0
  36. package/templates/context-usage.sh +249 -0
  37. package/templates/git-readonly-wrapper.mjs +309 -0
  38. package/templates/npmrc +2 -0
  39. package/templates/pnpm-config.yaml +9 -0
  40. package/templates/runtime-constants.mjs +18 -0
  41. package/templates/skills/chamba-statusline/SKILL.md +79 -0
  42. package/templates/skills/context-usage/SKILL.md +53 -0
  43. package/templates/skills/web-pane/SKILL.md +62 -0
  44. package/templates/startup-git-mode.mjs +145 -0
  45. package/templates/startup.mjs +333 -0
  46. package/templates/webpane.sh +126 -0
  47. package/templates/webterm/README.md +157 -0
  48. package/templates/webterm/artifacts.js +583 -0
  49. package/templates/webterm/config.js +269 -0
  50. package/templates/webterm/context/claude.md +14 -0
  51. package/templates/webterm/conversation.js +248 -0
  52. package/templates/webterm/package-lock.json +884 -0
  53. package/templates/webterm/package.json +17 -0
  54. package/templates/webterm/pane.js +156 -0
  55. package/templates/webterm/proc.js +89 -0
  56. package/templates/webterm/public/app/alerts.js +472 -0
  57. package/templates/webterm/public/app/cards.js +123 -0
  58. package/templates/webterm/public/app/clipboard.js +229 -0
  59. package/templates/webterm/public/app/composer.js +226 -0
  60. package/templates/webterm/public/app/connection.js +342 -0
  61. package/templates/webterm/public/app/dictation.js +98 -0
  62. package/templates/webterm/public/app/dom.js +37 -0
  63. package/templates/webterm/public/app/drafts.js +244 -0
  64. package/templates/webterm/public/app/frames.js +166 -0
  65. package/templates/webterm/public/app/main.js +82 -0
  66. package/templates/webterm/public/app/new-session.js +188 -0
  67. package/templates/webterm/public/app/note.js +24 -0
  68. package/templates/webterm/public/app/pane-frame.js +166 -0
  69. package/templates/webterm/public/app/pane.js +353 -0
  70. package/templates/webterm/public/app/state.js +51 -0
  71. package/templates/webterm/public/app/status-strip.js +170 -0
  72. package/templates/webterm/public/app/tabs.js +475 -0
  73. package/templates/webterm/public/app/terminal.js +102 -0
  74. package/templates/webterm/public/app/theme.js +46 -0
  75. package/templates/webterm/public/favicon.svg +21 -0
  76. package/templates/webterm/public/index.html +105 -0
  77. package/templates/webterm/public/styles.css +1193 -0
  78. package/templates/webterm/server.js +1142 -0
  79. package/templates/webterm/sessions.js +515 -0
  80. package/templates/webterm/snapshot.js +135 -0
  81. package/templates/webterm.sh +167 -0
  82. package/dist/cli.js +0 -1582
  83. package/dist/server.js +0 -1831
  84. package/inject/annotate.js +0 -18
  85. package/skill/README.md +0 -12
  86. package/skill/SKILL.md +0 -93
  87. package/web/assets/highlighted-body-OFNGDK62-Bn4Eu7CG.js +0 -1
  88. package/web/assets/index-B9DI4F1Z.js +0 -202
  89. package/web/assets/index-DK_n6CTo.css +0 -2
  90. package/web/assets/mermaid-GHXKKRXX-CEMduc-U.js +0 -1
  91. package/web/index.html +0 -28
package/dist/server.js DELETED
@@ -1,1831 +0,0 @@
1
- // packages/server/src/index.ts
2
- import { serve as honoServe } from "@hono/node-server";
3
-
4
- // packages/shared/src/annotations.ts
5
- import { z as z3 } from "zod";
6
-
7
- // packages/shared/src/messages.ts
8
- import { z as z2 } from "zod";
9
-
10
- // packages/shared/src/events.ts
11
- import { z } from "zod";
12
- var eventSourceSchema = z.enum(["user", "agent", "system"]);
13
- var eventEnvelopeSchema = z.object({
14
- id: z.uuid(),
15
- seq: z.number().int().nonnegative(),
16
- sessionId: z.string().min(1),
17
- ts: z.iso.datetime(),
18
- type: z.string().min(1),
19
- source: eventSourceSchema,
20
- payload: z.unknown()
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()
30
- });
31
- var replyRefSchema = z2.object({
32
- seq: z2.number().int().nonnegative(),
33
- id: z2.string().min(1),
34
- source: eventSourceSchema,
35
- snippet: z2.string()
36
- });
37
- var messagePayloadSchema = z2.object({
38
- text: z2.string().min(1).optional(),
39
- attachments: z2.array(attachmentSchema).optional(),
40
- replyTo: replyRefSchema.optional(),
41
- answerIn: z2.literal("terminal").optional()
42
- }).refine((p) => (p.text?.length ?? 0) > 0 || (p.attachments?.length ?? 0) > 0, {
43
- error: "a message needs text or at least one attachment"
44
- });
45
- var systemReasonSchema = z2.enum(["session-created", "session-ended"]);
46
- var systemPayloadSchema = z2.object({
47
- reason: systemReasonSchema
48
- });
49
-
50
- // packages/shared/src/annotations.ts
51
- var ANNOTATION_BATCH = "annotation-batch";
52
- var annotationSourceSchema = z3.object({
53
- file: z3.string().min(1),
54
- line: z3.number().int().positive().optional(),
55
- column: z3.number().int().positive().optional()
56
- });
57
- var annotationComponentSchema = z3.object({
58
- name: z3.string().min(1),
59
- source: annotationSourceSchema.optional()
60
- });
61
- var annotationTargetSchema = z3.object({
62
- selector: z3.string().min(1),
63
- pageUrl: z3.string().min(1),
64
- viewport: z3.object({
65
- width: z3.number().int().positive(),
66
- height: z3.number().int().positive()
67
- }),
68
- label: z3.string().optional(),
69
- component: annotationComponentSchema.optional()
70
- });
71
- var annotationItemSchema = z3.object({
72
- note: z3.string().min(1).optional(),
73
- target: annotationTargetSchema,
74
- screenshot: attachmentSchema.optional()
75
- });
76
- var annotationBatchPayloadSchema = z3.object({
77
- items: z3.array(annotationItemSchema).min(1)
78
- });
79
-
80
- // packages/shared/src/api.ts
81
- import { z as z6 } from "zod";
82
-
83
- // packages/shared/src/presence.ts
84
- import { z as z4 } from "zod";
85
- var presenceSchema = z4.enum(["listening", "working", "away"]);
86
-
87
- // packages/shared/src/session.ts
88
- import { z as z5 } from "zod";
89
- var sessionStatusSchema = z5.enum(["active", "ended"]);
90
- var sessionMetaSchema = z5.object({
91
- id: z5.string().min(1),
92
- /** The agent session that created this session (e.g. a Claude Code session id).
93
- * `open` uses it to reconnect the same agent session to its existing workspace. */
94
- agentKey: z5.string().min(1).optional(),
95
- title: z5.string().optional(),
96
- status: sessionStatusSchema,
97
- createdAt: z5.string().datetime(),
98
- updatedAt: z5.string().datetime()
99
- });
100
-
101
- // packages/shared/src/api.ts
102
- var cursorSchema = z6.number().int().nonnegative();
103
- var POLL_DEFAULT_WAIT_MS = 25e3;
104
- var POLL_MAX_WAIT_MS = 3e4;
105
- var SESSION_QUERY_PARAM = "s";
106
- var createSessionReqSchema = z6.object({
107
- title: z6.string().min(1).optional(),
108
- agentKey: z6.string().min(1).optional()
109
- });
110
- var messageBodyBase = z6.object({
111
- text: z6.string().min(1).optional(),
112
- attachmentIds: z6.array(z6.string().min(1)).optional(),
113
- replyTo: z6.number().int().nonnegative().optional()
114
- });
115
- var needsContent = (r) => (r.text?.length ?? 0) > 0 || (r.attachmentIds?.length ?? 0) > 0;
116
- var needsContentMessage = { error: "a message needs text or at least one attachment" };
117
- var postMessageReqSchema = messageBodyBase.extend({ answerIn: z6.literal("terminal").optional() }).refine(needsContent, needsContentMessage);
118
- var replyReqSchema = messageBodyBase.refine(needsContent, needsContentMessage);
119
- var annotationItemReqSchema = z6.object({
120
- note: z6.string().min(1).optional(),
121
- target: annotationTargetSchema,
122
- screenshotAttachmentId: z6.string().min(1).optional()
123
- });
124
- var postAnnotationsReqSchema = z6.object({
125
- items: z6.array(annotationItemReqSchema).min(1)
126
- });
127
- var attachmentResSchema = z6.object({
128
- attachment: attachmentSchema
129
- });
130
- var eventResSchema = z6.object({
131
- event: eventEnvelopeSchema
132
- });
133
- var pollResSchema = z6.object({
134
- events: z6.array(eventEnvelopeSchema),
135
- cursor: cursorSchema,
136
- ended: z6.boolean().optional(),
137
- /** Set when the server is shutting down on purpose and woke this poll to say
138
- * so; the caller should exit its loop cleanly instead of retrying. */
139
- stopping: z6.boolean().optional()
140
- });
141
- var fetchMoreSchema = z6.object({
142
- hasMore: z6.boolean(),
143
- earliestLoadedSeq: cursorSchema,
144
- hint: z6.string()
145
- });
146
- var attachDigestSchema = z6.object({
147
- meta: sessionMetaSchema,
148
- presence: presenceSchema,
149
- tail: z6.array(eventEnvelopeSchema),
150
- cursor: cursorSchema,
151
- fetchMore: fetchMoreSchema
152
- });
153
- var sessionSummarySchema = z6.object({
154
- meta: sessionMetaSchema,
155
- presence: presenceSchema
156
- });
157
- var listResSchema = z6.object({
158
- sessions: z6.array(sessionSummarySchema)
159
- });
160
- var sessionResSchema = z6.object({
161
- session: sessionSummarySchema
162
- });
163
- var healthResSchema = z6.object({
164
- ok: z6.boolean(),
165
- sessions: cursorSchema,
166
- home: z6.string().min(1).optional(),
167
- port: z6.number().int().positive().optional(),
168
- version: z6.string().min(1).optional()
169
- });
170
-
171
- // packages/shared/src/issues.ts
172
- import { z as z7 } from "zod";
173
- var issueTypeSchema = z7.enum(["bug", "feature"]);
174
- var postIssueReqSchema = z7.object({
175
- type: issueTypeSchema,
176
- text: z7.string().min(1),
177
- screenshotAttachmentIds: z7.array(z7.string().min(1)).optional()
178
- });
179
- var issueResSchema = z7.object({
180
- id: z7.string().min(1),
181
- path: z7.string().min(1)
182
- });
183
-
184
- // packages/shared/src/page.ts
185
- import { z as z8 } from "zod";
186
- var PAGE = "page";
187
- var pageRefSchema = z8.object({
188
- name: z8.string().min(1),
189
- relPath: z8.string().min(1),
190
- contentType: z8.string().min(1),
191
- size: z8.number().int().nonnegative()
192
- });
193
- var pagePayloadSchema = z8.object({
194
- page: pageRefSchema
195
- });
196
- var postPageReqSchema = z8.object({
197
- name: z8.string().min(1).max(120),
198
- content: z8.string().min(1)
199
- });
200
-
201
- // packages/shared/src/payload.ts
202
- var attachmentsSchema = attachmentSchema.array();
203
- function eventText(event) {
204
- const payload = event.payload;
205
- if (payload && typeof payload === "object") {
206
- const record = payload;
207
- if (typeof record.text === "string") return record.text;
208
- if (typeof record.reason === "string") return record.reason;
209
- }
210
- return "";
211
- }
212
-
213
- // packages/shared/src/snippet.ts
214
- function eventSnippet(event, max = 80) {
215
- return truncate(sanitize(rawSnippet(event)), max);
216
- }
217
- function rawSnippet(event) {
218
- if (event.type === "message") {
219
- const parsed = messagePayloadSchema.safeParse(event.payload);
220
- if (!parsed.success) return "";
221
- const line = parsed.data.text?.split("\n", 1)[0]?.trim();
222
- if (line) return line;
223
- const first = parsed.data.attachments?.[0];
224
- return first ? first.name : "";
225
- }
226
- if (event.type === ANNOTATION_BATCH) {
227
- const parsed = annotationBatchPayloadSchema.safeParse(event.payload);
228
- if (!parsed.success) return "";
229
- const count = parsed.data.items.length;
230
- return count === 1 ? "1 annotation" : `${count} annotations`;
231
- }
232
- if (event.type === PAGE) {
233
- const parsed = pagePayloadSchema.safeParse(event.payload);
234
- return parsed.success ? `page ${parsed.data.page.name}` : "";
235
- }
236
- if (event.type === "system") {
237
- const parsed = systemPayloadSchema.safeParse(event.payload);
238
- return parsed.success ? parsed.data.reason : "";
239
- }
240
- return "";
241
- }
242
- function sanitize(text) {
243
- return text.replace(/["[\]]/g, "").replace(/\s+/g, " ").trim();
244
- }
245
- function truncate(text, max) {
246
- const points = Array.from(text);
247
- if (points.length <= max) return text;
248
- return `${points.slice(0, max).join("").trimEnd()}\u2026`;
249
- }
250
-
251
- // packages/shared/src/ws.ts
252
- import { z as z9 } from "zod";
253
- var wsServerMessageSchema = z9.discriminatedUnion("t", [
254
- z9.object({
255
- t: z9.literal("hello"),
256
- meta: sessionMetaSchema,
257
- presence: presenceSchema
258
- }),
259
- z9.object({
260
- t: z9.literal("append"),
261
- event: eventEnvelopeSchema
262
- }),
263
- z9.object({
264
- t: z9.literal("presence"),
265
- state: presenceSchema
266
- }),
267
- z9.object({
268
- t: z9.literal("ended"),
269
- meta: sessionMetaSchema
270
- })
271
- ]);
272
-
273
- // packages/server/src/app.ts
274
- import { createNodeWebSocket } from "@hono/node-ws";
275
- import { Hono } from "hono";
276
-
277
- // packages/server/src/inject.ts
278
- import { existsSync as existsSync2 } from "node:fs";
279
- import path3 from "node:path";
280
- import { fileURLToPath as fileURLToPath2 } from "node:url";
281
-
282
- // packages/server/src/static.ts
283
- import { existsSync } from "node:fs";
284
- import { readFile } from "node:fs/promises";
285
- import path2 from "node:path";
286
- import { fileURLToPath } from "node:url";
287
-
288
- // packages/server/src/mime.ts
289
- import path from "node:path";
290
- var CONTENT_TYPES = {
291
- ".html": "text/html; charset=utf-8",
292
- ".js": "text/javascript; charset=utf-8",
293
- ".mjs": "text/javascript; charset=utf-8",
294
- ".css": "text/css; charset=utf-8",
295
- ".json": "application/json; charset=utf-8",
296
- ".svg": "image/svg+xml",
297
- ".png": "image/png",
298
- ".jpg": "image/jpeg",
299
- ".jpeg": "image/jpeg",
300
- ".gif": "image/gif",
301
- ".webp": "image/webp",
302
- ".ico": "image/x-icon",
303
- ".woff2": "font/woff2",
304
- ".map": "application/json; charset=utf-8",
305
- ".pdf": "application/pdf",
306
- ".txt": "text/plain; charset=utf-8",
307
- ".md": "text/markdown; charset=utf-8"
308
- };
309
- function contentTypeFor(filePath) {
310
- return CONTENT_TYPES[path.extname(filePath).toLowerCase()] ?? "application/octet-stream";
311
- }
312
- var EXTENSIONS = {};
313
- for (const [ext, type] of Object.entries(CONTENT_TYPES)) {
314
- const base = type.split(";")[0] ?? type;
315
- if (!(base in EXTENSIONS)) EXTENSIONS[base] = ext;
316
- }
317
- function extensionFor(contentType) {
318
- const base = contentType.split(";")[0]?.trim().toLowerCase() ?? "";
319
- return EXTENSIONS[base] ?? ".bin";
320
- }
321
-
322
- // packages/server/src/static.ts
323
- function resolveWebDist() {
324
- const here = path2.dirname(fileURLToPath(import.meta.url));
325
- const dev = path2.resolve(here, "../../web/dist");
326
- const packaged = path2.resolve(here, "../web");
327
- return [dev, packaged].find((dir) => existsSync(path2.join(dir, "index.html"))) ?? dev;
328
- }
329
- function createStaticHandler(webDist) {
330
- return async (c) => {
331
- let pathname;
332
- try {
333
- pathname = decodeURIComponent(new URL(c.req.url).pathname);
334
- } catch {
335
- return c.json({ error: "not found" }, 404);
336
- }
337
- if (pathname.startsWith("/api") || pathname.startsWith("/ws")) {
338
- return c.json({ error: "not found" }, 404);
339
- }
340
- const relative = pathname === "/" ? "index.html" : pathname.replace(/^\/+/, "");
341
- const filePath = path2.resolve(webDist, relative);
342
- if (filePath !== webDist && !filePath.startsWith(webDist + path2.sep)) {
343
- return c.json({ error: "not found" }, 404);
344
- }
345
- const direct = await readFileOrNull(filePath);
346
- if (direct) {
347
- return c.body(direct, 200, { "content-type": contentTypeFor(filePath) });
348
- }
349
- const indexHtml = await readFileOrNull(path2.join(webDist, "index.html"));
350
- if (indexHtml) {
351
- return c.body(indexHtml, 200, { "content-type": "text/html; charset=utf-8" });
352
- }
353
- return c.text("web build not found - run `pnpm --filter @chamba/web build`", 404);
354
- };
355
- }
356
- async function readFileOrNull(filePath) {
357
- try {
358
- const raw = await readFile(filePath);
359
- const bytes = new Uint8Array(raw.byteLength);
360
- bytes.set(raw);
361
- return bytes;
362
- } catch {
363
- return null;
364
- }
365
- }
366
-
367
- // packages/server/src/inject.ts
368
- function resolveInjectDist() {
369
- const here = path3.dirname(fileURLToPath2(import.meta.url));
370
- const dev = path3.resolve(here, "../../inject/dist");
371
- const packaged = path3.resolve(here, "../inject");
372
- return [dev, packaged].find((dir) => existsSync2(path3.join(dir, "annotate.js"))) ?? dev;
373
- }
374
- function createInjectHandler(injectDist) {
375
- const file = path3.join(injectDist, "annotate.js");
376
- return async (c) => {
377
- const bytes = await readFileOrNull(file);
378
- if (!bytes) {
379
- return c.text("annotation client not built - run `pnpm --filter @chamba/inject build`", 404);
380
- }
381
- return c.body(bytes, 200, {
382
- "content-type": "text/javascript; charset=utf-8",
383
- "cache-control": "no-cache",
384
- "access-control-allow-origin": "*"
385
- });
386
- };
387
- }
388
-
389
- // packages/server/src/render.ts
390
- var INJECT_TAG = '<script src="/inject/annotate.js"></script>';
391
- function renderPageHtml(html) {
392
- let idx = -1;
393
- for (const match of html.matchAll(/<\/body>/gi)) idx = match.index;
394
- if (idx === -1) return `${html}
395
- ${INJECT_TAG}`;
396
- return `${html.slice(0, idx)}${INJECT_TAG}
397
- ${html.slice(idx)}`;
398
- }
399
-
400
- // packages/server/src/sessions/index.ts
401
- import { randomBytes, randomUUID as randomUUID3 } from "node:crypto";
402
- import { mkdir as mkdir3, readdir as readdir2, readFile as readFile5, stat, writeFile as writeFile2 } from "node:fs/promises";
403
- import path7 from "node:path";
404
-
405
- // packages/server/src/version.ts
406
- import { existsSync as existsSync3, readFileSync } from "node:fs";
407
- import path4 from "node:path";
408
- import { fileURLToPath as fileURLToPath3 } from "node:url";
409
- function chambaVersion() {
410
- let dir = path4.dirname(fileURLToPath3(import.meta.url));
411
- for (; ; ) {
412
- const manifest = path4.join(dir, "package.json");
413
- if (existsSync3(manifest)) {
414
- try {
415
- const { version } = JSON.parse(readFileSync(manifest, "utf8"));
416
- if (typeof version === "string" && version) return version;
417
- } catch {
418
- }
419
- }
420
- const parent = path4.dirname(dir);
421
- if (parent === dir) return "0.0.0-dev";
422
- dir = parent;
423
- }
424
- }
425
-
426
- // packages/server/src/issues.ts
427
- function buildIssueRecord(input) {
428
- return {
429
- id: input.id,
430
- type: input.type,
431
- text: input.text,
432
- filedAt: input.filedAt,
433
- chambaVersion: chambaVersion(),
434
- session: {
435
- id: input.meta.id,
436
- status: input.meta.status,
437
- ...input.meta.title ? { title: input.meta.title } : {}
438
- },
439
- env: {
440
- node: process.version,
441
- platform: process.platform,
442
- arch: process.arch,
443
- cwd: process.cwd()
444
- },
445
- screenshots: input.screenshots,
446
- recentEvents: input.tail.map(summarizeEvent)
447
- };
448
- }
449
- function renderIssueMarkdown(record) {
450
- const lines = [
451
- `# ${record.type === "bug" ? "Bug" : "Feature request"}`,
452
- "",
453
- record.text,
454
- "",
455
- "## Context",
456
- "",
457
- `- chamba version: ${record.chambaVersion}`,
458
- `- session: ${record.session.id} (${record.session.status})`,
459
- ...record.session.title ? [`- session title: ${record.session.title}`] : [],
460
- `- environment: node ${record.env.node} on ${record.env.platform}/${record.env.arch}`,
461
- `- cwd: ${record.env.cwd}`,
462
- `- filed: ${record.filedAt}`
463
- ];
464
- if (record.screenshots.length > 0) {
465
- lines.push("", "### Screenshots", "");
466
- for (const name of record.screenshots) lines.push(`- ${name}`);
467
- }
468
- if (record.recentEvents.length > 0) {
469
- lines.push("", "### Recent session events", "");
470
- for (const event of record.recentEvents) {
471
- const text = event.text ? `: ${event.text}` : "";
472
- lines.push(`- [${event.seq}] ${event.ts} ${event.source}/${event.type}${text}`);
473
- }
474
- }
475
- return `${lines.join("\n")}
476
- `;
477
- }
478
- function summarizeEvent(event) {
479
- return {
480
- seq: event.seq,
481
- ts: event.ts,
482
- source: event.source,
483
- type: event.type,
484
- ...eventText(event) ? { text: truncate2(eventText(event), 200) } : {}
485
- };
486
- }
487
- function truncate2(text, max) {
488
- const oneLine = text.replace(/\s+/g, " ").trim();
489
- return oneLine.length > max ? `${oneLine.slice(0, max - 1)}\u2026` : oneLine;
490
- }
491
-
492
- // packages/server/src/sessions/log.ts
493
- import { randomUUID } from "node:crypto";
494
- import { mkdir, open, readFile as readFile2, truncate as truncate3 } from "node:fs/promises";
495
- import path5 from "node:path";
496
- function isIncoming(event) {
497
- return event.source !== "agent";
498
- }
499
- var SessionLog = class {
500
- constructor(id, file) {
501
- this.id = id;
502
- this.file = file;
503
- }
504
- id;
505
- file;
506
- events = [];
507
- handle = null;
508
- loaded = false;
509
- loading = null;
510
- mutex = Promise.resolve();
511
- listeners = /* @__PURE__ */ new Set();
512
- /** Replay the ndjson file into memory and open the append handle. Idempotent. */
513
- async load() {
514
- if (this.loaded) return;
515
- if (this.loading) return this.loading;
516
- this.loading = this.replay();
517
- try {
518
- await this.loading;
519
- } finally {
520
- this.loading = null;
521
- }
522
- }
523
- async replay() {
524
- await mkdir(path5.dirname(this.file), { recursive: true });
525
- this.events = [];
526
- let content = "";
527
- try {
528
- content = await readFile2(this.file, "utf8");
529
- } catch (err) {
530
- if (err.code !== "ENOENT") throw err;
531
- }
532
- const lines = content.split("\n");
533
- let validBytes = 0;
534
- let truncateTo = null;
535
- for (let i = 0; i < lines.length; i++) {
536
- const line = lines[i] ?? "";
537
- const hasSeparator = i < lines.length - 1;
538
- const byteLen = Buffer.byteLength(line, "utf8") + (hasSeparator ? 1 : 0);
539
- if (line.trim().length === 0) {
540
- validBytes += byteLen;
541
- continue;
542
- }
543
- try {
544
- this.events.push(eventEnvelopeSchema.parse(JSON.parse(line)));
545
- validBytes += byteLen;
546
- } catch (err) {
547
- const isLastContentLine = lines.slice(i + 1).every((rest) => rest.trim().length === 0);
548
- if (isLastContentLine && !hasSeparator) {
549
- truncateTo = validBytes;
550
- break;
551
- }
552
- throw err;
553
- }
554
- }
555
- if (truncateTo !== null) await truncate3(this.file, truncateTo);
556
- this.handle = await open(this.file, "a");
557
- this.loaded = true;
558
- }
559
- /** Total events == next seq to be assigned == the "caught up" cursor. */
560
- get nextSeq() {
561
- return this.events.length;
562
- }
563
- /** The event holding `seq`, or undefined. `seq` equals the array index, so this
564
- * is a direct lookup. */
565
- at(seq) {
566
- return this.events[seq];
567
- }
568
- /** Every event with `seq >= since`, in order. Pure read from memory. */
569
- readSince(since) {
570
- if (since <= 0) return this.events.slice();
571
- return this.events.slice(since);
572
- }
573
- /** The last `n` events (for the catch-up digest). */
574
- tail(n) {
575
- if (n <= 0) return [];
576
- return this.events.slice(Math.max(0, this.events.length - n));
577
- }
578
- /** True if a qualifying incoming event exists at or beyond `since`. */
579
- hasIncoming(since) {
580
- for (let i = Math.max(0, since); i < this.events.length; i++) {
581
- const event = this.events[i];
582
- if (event && isIncoming(event)) return true;
583
- }
584
- return false;
585
- }
586
- /**
587
- * Append one event durably. Serialized so seq assignment and the write can
588
- * never interleave; resolves only after `fsync`. Listeners (WS broadcast,
589
- * presence, parked polls) fire synchronously right after the in-memory push,
590
- * with no `await` in between - so a poller that just read `events` and is about
591
- * to register cannot miss the wakeup.
592
- */
593
- async append(input) {
594
- await this.load();
595
- const run = this.mutex.then(async () => {
596
- const handle = this.handle;
597
- if (!handle) throw new Error("session log not open");
598
- const event = {
599
- id: randomUUID(),
600
- seq: this.events.length,
601
- sessionId: this.id,
602
- ts: (/* @__PURE__ */ new Date()).toISOString(),
603
- type: input.type,
604
- source: input.source,
605
- payload: input.payload
606
- };
607
- await handle.write(`${JSON.stringify(event)}
608
- `);
609
- await handle.sync();
610
- this.events.push(event);
611
- this.emit(event);
612
- return event;
613
- });
614
- this.mutex = run.catch(() => void 0);
615
- return run;
616
- }
617
- /** Subscribe to every future append (WS/presence). Returns an unsubscribe. */
618
- subscribe(listener) {
619
- this.listeners.add(listener);
620
- return () => this.listeners.delete(listener);
621
- }
622
- /**
623
- * Resolve on the next append or when `timeoutMs`/`signal` fires. Registration
624
- * is synchronous (in the Promise executor), so callers can qualify-check then
625
- * await this with no yield between - no lost wakeup.
626
- */
627
- waitForAppend(timeoutMs, signal) {
628
- return new Promise((resolve) => {
629
- const finish = () => {
630
- clearTimeout(timer);
631
- this.listeners.delete(onAppend);
632
- signal?.removeEventListener("abort", finish);
633
- resolve();
634
- };
635
- const onAppend = () => finish();
636
- const timer = setTimeout(finish, timeoutMs);
637
- this.listeners.add(onAppend);
638
- if (signal) {
639
- if (signal.aborted) {
640
- finish();
641
- return;
642
- }
643
- signal.addEventListener("abort", finish, { once: true });
644
- }
645
- });
646
- }
647
- emit(event) {
648
- for (const listener of [...this.listeners]) listener(event);
649
- }
650
- };
651
-
652
- // packages/server/src/sessions/meta.ts
653
- import { readdir, readFile as readFile4 } from "node:fs/promises";
654
-
655
- // packages/server/src/sessions/atomic.ts
656
- import { randomUUID as randomUUID2 } from "node:crypto";
657
- import { rename, rm, writeFile } from "node:fs/promises";
658
- async function writeJsonAtomic(file, value) {
659
- const tmp = `${file}.${randomUUID2()}.tmp`;
660
- try {
661
- await writeFile(tmp, `${JSON.stringify(value, null, 2)}
662
- `, "utf8");
663
- await rename(tmp, file);
664
- } catch (err) {
665
- await rm(tmp, { force: true }).catch(() => void 0);
666
- throw err;
667
- }
668
- }
669
-
670
- // packages/server/src/sessions/paths.ts
671
- import { existsSync as existsSync4, realpathSync } from "node:fs";
672
- import { appendFile, mkdir as mkdir2, readFile as readFile3 } from "node:fs/promises";
673
- import path6 from "node:path";
674
- function defaultContext() {
675
- return { cwd: process.cwd(), env: process.env };
676
- }
677
- function resolveHome(ctx = defaultContext()) {
678
- return findExistingHome(ctx) ?? plannedHome(ctx);
679
- }
680
- function findExistingHome(ctx = defaultContext()) {
681
- const dir = ctx.env.CHAMBA_DIR;
682
- if (dir && dir.length > 0) return canonicalize(dir);
683
- const inside = enclosingChamba(ctx.cwd);
684
- if (inside) return canonicalize(inside);
685
- const root = gitRoot(ctx.cwd);
686
- let cursor = path6.resolve(ctx.cwd);
687
- while (true) {
688
- if (existsSync4(path6.join(cursor, ".chamba"))) return canonicalize(path6.join(cursor, ".chamba"));
689
- if (!root) break;
690
- if (cursor === root) break;
691
- const parent = path6.dirname(cursor);
692
- if (parent === cursor) break;
693
- cursor = parent;
694
- }
695
- return null;
696
- }
697
- function plannedHome(ctx = defaultContext()) {
698
- const root = gitRoot(ctx.cwd) ?? path6.resolve(ctx.cwd);
699
- return canonicalize(path6.join(root, ".chamba"));
700
- }
701
- function gitRoot(cwd) {
702
- let cursor = path6.resolve(cwd);
703
- while (true) {
704
- if (existsSync4(path6.join(cursor, ".git"))) return cursor;
705
- const parent = path6.dirname(cursor);
706
- if (parent === cursor) return null;
707
- cursor = parent;
708
- }
709
- }
710
- function enclosingChamba(cwd) {
711
- const parts = path6.resolve(cwd).split(path6.sep);
712
- const index = parts.lastIndexOf(".chamba");
713
- if (index < 0) return null;
714
- return parts.slice(0, index + 1).join(path6.sep) || path6.sep;
715
- }
716
- function canonicalize(p) {
717
- const resolved = path6.resolve(p);
718
- let existing = resolved;
719
- const tail = [];
720
- while (!existsSync4(existing)) {
721
- tail.unshift(path6.basename(existing));
722
- const parent = path6.dirname(existing);
723
- if (parent === existing) return resolved;
724
- existing = parent;
725
- }
726
- return path6.join(realpathSync(existing), ...tail);
727
- }
728
- function sessionsDir(home) {
729
- return path6.join(home, "sessions");
730
- }
731
- function sessionDir(home, id) {
732
- return path6.join(sessionsDir(home), id);
733
- }
734
- function logPath(home, id) {
735
- return path6.join(sessionDir(home, id), "log.ndjson");
736
- }
737
- function attachmentsDir(home, id) {
738
- return path6.join(sessionDir(home, id), "attachments");
739
- }
740
- function pagesDir(home, id) {
741
- return path6.join(sessionDir(home, id), "pages");
742
- }
743
- function issuesDir(home) {
744
- return path6.join(home, "issues");
745
- }
746
- function issueDir(home, id) {
747
- return path6.join(issuesDir(home), id);
748
- }
749
- function metaPath(home, id) {
750
- return path6.join(sessionDir(home, id), "meta.json");
751
- }
752
- function pidfilePath(home) {
753
- return path6.join(home, "server.json");
754
- }
755
- async function ensureChambaHome(home) {
756
- await mkdir2(sessionsDir(home), { recursive: true });
757
- await ensureGitignored(home);
758
- }
759
- async function ensureGitignored(home) {
760
- if (path6.basename(home) !== ".chamba") return;
761
- const gitignore = path6.join(path6.dirname(home), ".gitignore");
762
- const entry = ".chamba/";
763
- try {
764
- let content = "";
765
- try {
766
- content = await readFile3(gitignore, "utf8");
767
- } catch (err) {
768
- if (err.code !== "ENOENT") return;
769
- }
770
- const already = content.split(/\r?\n/).some((line) => {
771
- const trimmed = line.trim();
772
- return trimmed === entry || trimmed === ".chamba";
773
- });
774
- if (already) return;
775
- const prefix = content.length > 0 && !content.endsWith("\n") ? "\n" : "";
776
- await appendFile(gitignore, `${prefix}${entry}
777
- `, "utf8");
778
- } catch {
779
- }
780
- }
781
-
782
- // packages/server/src/sessions/meta.ts
783
- async function readMeta(home, id) {
784
- try {
785
- const raw = await readFile4(metaPath(home, id), "utf8");
786
- return sessionMetaSchema.parse(JSON.parse(raw));
787
- } catch (err) {
788
- if (err.code === "ENOENT") return null;
789
- throw err;
790
- }
791
- }
792
- function writeMeta(home, meta) {
793
- return writeJsonAtomic(metaPath(home, meta.id), meta);
794
- }
795
- async function listSessionIds(home) {
796
- try {
797
- const entries = await readdir(sessionsDir(home), { withFileTypes: true });
798
- return entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name);
799
- } catch (err) {
800
- if (err.code === "ENOENT") return [];
801
- throw err;
802
- }
803
- }
804
-
805
- // packages/server/src/sessions/index.ts
806
- var ReplyTargetError = class extends Error {
807
- };
808
- var Sessions = class {
809
- constructor(home) {
810
- this.home = home;
811
- }
812
- home;
813
- logs = /* @__PURE__ */ new Map();
814
- metaCache = /* @__PURE__ */ new Map();
815
- metaLocks = /* @__PURE__ */ new Map();
816
- /**
817
- * Serialize a meta read-modify-write for one session. Without this, a message's
818
- * `touch` can read `active` from cache, then write it back on top of an `end`
819
- * that landed in between - leaving `meta.json` `active` while the log holds a
820
- * `session-ended` event. Chaining every read+write per session makes the last
821
- * writer see the previous one's result, so status can never regress.
822
- */
823
- withMetaLock(id, fn) {
824
- const prev = this.metaLocks.get(id) ?? Promise.resolve();
825
- const run = prev.then(fn, fn);
826
- this.metaLocks.set(
827
- id,
828
- run.then(
829
- () => void 0,
830
- () => void 0
831
- )
832
- );
833
- return run;
834
- }
835
- async init() {
836
- await ensureChambaHome(this.home);
837
- }
838
- async create(req) {
839
- await ensureChambaHome(this.home);
840
- const id = await this.generateId();
841
- const now = (/* @__PURE__ */ new Date()).toISOString();
842
- const meta = {
843
- id,
844
- ...req.title ? { title: req.title } : {},
845
- ...req.agentKey ? { agentKey: req.agentKey } : {},
846
- status: "active",
847
- createdAt: now,
848
- updatedAt: now
849
- };
850
- await this.logFor(id);
851
- await writeMeta(this.home, meta);
852
- this.metaCache.set(id, meta);
853
- await this.append(id, {
854
- type: "system",
855
- source: "system",
856
- payload: { reason: "session-created" }
857
- });
858
- return await this.getMeta(id) ?? meta;
859
- }
860
- async getMeta(id) {
861
- const cached = this.metaCache.get(id);
862
- if (cached) return cached;
863
- const meta = await readMeta(this.home, id);
864
- if (meta) this.metaCache.set(id, meta);
865
- return meta;
866
- }
867
- async list() {
868
- const ids = await listSessionIds(this.home);
869
- const metas = await Promise.all(ids.map((id) => this.getMeta(id)));
870
- return metas.filter((meta) => meta !== null).sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
871
- }
872
- /** Get (or lazily create + replay) the in-memory log for a session. */
873
- async logFor(id) {
874
- let log = this.logs.get(id);
875
- if (!log) {
876
- log = new SessionLog(id, logPath(this.home, id));
877
- this.logs.set(id, log);
878
- }
879
- await log.load();
880
- return log;
881
- }
882
- /** Durable append; also bumps the session's `updatedAt`. */
883
- async append(id, input) {
884
- const log = await this.logFor(id);
885
- const event = await log.append(input);
886
- await this.touch(id);
887
- return event;
888
- }
889
- async postMessage(id, body) {
890
- const replyTo = await this.resolveReplyRef(id, body.replyTo);
891
- return this.append(id, {
892
- type: "message",
893
- source: "user",
894
- payload: messagePayload(body, replyTo)
895
- });
896
- }
897
- async reply(id, body) {
898
- const replyTo = await this.resolveReplyRef(id, body.replyTo);
899
- return this.append(id, {
900
- type: "message",
901
- source: "agent",
902
- payload: messagePayload(body, replyTo)
903
- });
904
- }
905
- /**
906
- * Resolve a `replyTo` seq to its denormalized reference at append time, so the
907
- * stored message carries everything a reader needs to render the reference
908
- * without a lookup. The fields cannot go stale: log events are immutable.
909
- * Throws `ReplyTargetError` on an unknown seq or a system target.
910
- */
911
- async resolveReplyRef(id, seq) {
912
- if (seq === void 0) return void 0;
913
- const log = await this.logFor(id);
914
- const target = log.at(seq);
915
- if (!target) throw new ReplyTargetError("unknown replyTo event");
916
- if (target.source === "system") throw new ReplyTargetError("cannot reply to a system event");
917
- return { seq: target.seq, id: target.id, source: target.source, snippet: eventSnippet(target) };
918
- }
919
- /** Append a batch of element comments as one `annotation-batch` event (source
920
- * `user`, so the agent's poll delivers it like any incoming line). */
921
- postAnnotations(id, items) {
922
- return this.append(id, { type: ANNOTATION_BATCH, source: "user", payload: { items } });
923
- }
924
- /** Announce a shown/revised page. The bytes were written by `savePage`; this
925
- * records the reference as an event so the Illustration tab and the thread
926
- * learn about the revision. Agent-sourced. */
927
- postPage(id, page) {
928
- return this.append(id, { type: PAGE, source: "agent", payload: { page } });
929
- }
930
- /**
931
- * Persist a page's HTML under `pages/` and return its durable reference. The
932
- * stored name is the sanitized page name with a fixed `.html` extension, so
933
- * re-showing the same name overwrites in place (a revision) and the event log
934
- * keeps the history of revisions by seq. The ref carries that same sanitized
935
- * name: a page's name is its identity, and returning the raw input instead
936
- * would let two spellings that sanitize alike pose as two pages while sharing
937
- * one stored file.
938
- */
939
- async savePage(id, name, content) {
940
- const dir = pagesDir(this.home, id);
941
- await mkdir3(dir, { recursive: true });
942
- const base = sanitizeName(name).replace(/\.html?$/i, "") || "page";
943
- const stored = `${base}.html`;
944
- const bytes = Buffer.from(content, "utf8");
945
- await writeFile2(path7.join(dir, stored), bytes);
946
- return {
947
- name: base,
948
- relPath: `pages/${stored}`,
949
- contentType: "text/html; charset=utf-8",
950
- size: bytes.byteLength
951
- };
952
- }
953
- /** Read a stored page by its on-disk filename (basename of a ref's `relPath`),
954
- * or null. The session id and the name are both guarded to flat safe tokens so
955
- * the serve route cannot be walked out of the pages directory. */
956
- async readPage(id, file) {
957
- if (!isSafeId(id)) return null;
958
- if (!/^[A-Za-z0-9._-]+$/.test(file) || file.includes("..")) return null;
959
- try {
960
- const content = await readFile5(path7.join(pagesDir(this.home, id), file), "utf8");
961
- return { content };
962
- } catch {
963
- return null;
964
- }
965
- }
966
- /**
967
- * Persist an uploaded file into the session's attachments directory and return
968
- * its durable reference. The stored name embeds a generated id and the sanitized
969
- * original (`<attId>__<name>`), so the file is self-describing on disk and
970
- * resolvable later without a sidecar; the recorded path stays session-relative.
971
- */
972
- async saveAttachment(id, name, bytes) {
973
- const dir = attachmentsDir(this.home, id);
974
- await mkdir3(dir, { recursive: true });
975
- const attId = randomUUID3();
976
- const safeName = sanitizeName(name);
977
- const stored = `${attId}__${safeName}`;
978
- await writeFile2(path7.join(dir, stored), bytes);
979
- return {
980
- id: attId,
981
- name: safeName,
982
- relPath: `attachments/${stored}`,
983
- contentType: contentTypeFor(safeName),
984
- size: bytes.byteLength
985
- };
986
- }
987
- /** Rebuild an attachment reference from disk by its id (null if absent). */
988
- async resolveAttachment(id, attId) {
989
- const stored = await this.storedAttachment(id, attId);
990
- if (!stored) return null;
991
- const info = await stat(path7.join(attachmentsDir(this.home, id), stored));
992
- return {
993
- id: attId,
994
- name: stored.slice(attId.length + 2),
995
- relPath: `attachments/${stored}`,
996
- contentType: contentTypeFor(stored),
997
- size: info.size
998
- };
999
- }
1000
- /** Read an attachment's bytes, name, and content type for serving (null if
1001
- * absent). The bytes are copied into a plain `ArrayBuffer`-backed view so the
1002
- * response layer can stream them directly (a `Buffer` widens to
1003
- * `ArrayBufferLike`). */
1004
- async readAttachment(id, attId) {
1005
- const stored = await this.storedAttachment(id, attId);
1006
- if (!stored) return null;
1007
- const raw = await readFile5(path7.join(attachmentsDir(this.home, id), stored));
1008
- const bytes = new Uint8Array(new ArrayBuffer(raw.byteLength));
1009
- bytes.set(raw);
1010
- return { bytes, name: stored.slice(attId.length + 2), contentType: contentTypeFor(stored) };
1011
- }
1012
- /**
1013
- * File an offline issue report under `.chamba/issues/<id>/`. Referenced
1014
- * screenshots are copied in as self-contained files, then `report.md` and
1015
- * `context.json` capture the report plus auto-collected context (chamba
1016
- * version, the session, a tail of recent events, basic env). Returns the new
1017
- * id and the absolute directory, or `null` if a referenced screenshot id is
1018
- * unknown for this session. Deliberately never appends to the log - an issue is
1019
- * an on-disk artifact, not a conversation event.
1020
- */
1021
- async saveIssue(meta, req) {
1022
- const resolved = [];
1023
- for (const attId of req.screenshotAttachmentIds ?? []) {
1024
- const file = await this.readAttachment(meta.id, attId);
1025
- if (!file) return null;
1026
- const ext = path7.extname(file.name) || extensionFor(file.contentType);
1027
- resolved.push({ name: `screenshot-${resolved.length + 1}${ext}`, bytes: file.bytes });
1028
- }
1029
- const id = await this.generateId();
1030
- const dir = issueDir(this.home, id);
1031
- await mkdir3(dir, { recursive: true });
1032
- for (const shot of resolved) await writeFile2(path7.join(dir, shot.name), shot.bytes);
1033
- const tail = (await this.logFor(meta.id)).tail(ISSUE_TAIL);
1034
- const record = buildIssueRecord({
1035
- id,
1036
- type: req.type,
1037
- text: req.text,
1038
- filedAt: (/* @__PURE__ */ new Date()).toISOString(),
1039
- meta,
1040
- tail,
1041
- screenshots: resolved.map((shot) => shot.name)
1042
- });
1043
- await writeFile2(path7.join(dir, "report.md"), renderIssueMarkdown(record), "utf8");
1044
- await writeFile2(path7.join(dir, "context.json"), `${JSON.stringify(record, null, 2)}
1045
- `, "utf8");
1046
- return { id, path: dir };
1047
- }
1048
- /** The on-disk filename for an attachment id, or null. The id is a uuid (no
1049
- * `__`), so the `<attId>__` prefix uniquely identifies one stored file. The
1050
- * session id is guarded too: the attachment-serve route reaches here without the
1051
- * `getMeta` check the other routes use, so an unsafe id must fail closed here. */
1052
- async storedAttachment(id, attId) {
1053
- if (!isSafeId(id)) return null;
1054
- if (!/^[0-9a-f-]+$/i.test(attId)) return null;
1055
- let entries;
1056
- try {
1057
- entries = await readdir2(attachmentsDir(this.home, id));
1058
- } catch {
1059
- return null;
1060
- }
1061
- return entries.find((entry) => entry.startsWith(`${attId}__`)) ?? null;
1062
- }
1063
- /**
1064
- * The agent long-poll: return every incoming (non-agent) event at or beyond
1065
- * `since`, parking until one arrives or the wait elapses. The sync
1066
- * qualify-check and waiter registration have no `await` between them, so an
1067
- * append concurrent with a poll can never be missed.
1068
- */
1069
- async poll(id, since, waitMs, signal) {
1070
- const log = await this.logFor(id);
1071
- const deadline = Date.now() + waitMs;
1072
- while (true) {
1073
- if (log.hasIncoming(since)) {
1074
- const events = log.readSince(since).filter(isIncoming);
1075
- return { events, cursor: log.nextSeq };
1076
- }
1077
- const remaining = deadline - Date.now();
1078
- if (remaining <= 0 || signal?.aborted) return { events: [], cursor: log.nextSeq };
1079
- await log.waitForAppend(remaining, signal);
1080
- }
1081
- }
1082
- /** Non-blocking history read (all sources), optionally capped. */
1083
- async events(id, since, limit) {
1084
- const log = await this.logFor(id);
1085
- const all = log.readSince(since);
1086
- const events = limit && limit > 0 ? all.slice(0, limit) : all;
1087
- return { events, cursor: log.nextSeq };
1088
- }
1089
- /** The catch-up digest: metadata, a recent tail, the poll cursor, how to get more. */
1090
- async attach(id, tailN, presence) {
1091
- const meta = await this.getMeta(id);
1092
- if (!meta) return null;
1093
- const log = await this.logFor(id);
1094
- const tail = log.tail(tailN);
1095
- const earliest = tail.length > 0 ? tail[0]?.seq ?? 0 : log.nextSeq;
1096
- return {
1097
- meta,
1098
- presence,
1099
- tail,
1100
- cursor: log.nextSeq,
1101
- fetchMore: {
1102
- hasMore: earliest > 0,
1103
- earliestLoadedSeq: earliest,
1104
- // The server describes its own API here, not any client's command
1105
- // grammar - a caller renders its own wording from the structured fields.
1106
- hint: earliest > 0 ? `GET /api/sessions/${meta.id}/events?since=0 returns the history before seq ${earliest}` : "none"
1107
- }
1108
- };
1109
- }
1110
- /** End a session: append the lifecycle marker, flip status to `ended`. */
1111
- async end(id) {
1112
- const meta = await this.getMeta(id);
1113
- if (!meta) return null;
1114
- if (meta.status === "ended") return meta;
1115
- await this.append(id, {
1116
- type: "system",
1117
- source: "system",
1118
- payload: { reason: "session-ended" }
1119
- });
1120
- return this.withMetaLock(id, async () => {
1121
- const current = await this.getMeta(id) ?? meta;
1122
- const ended = {
1123
- ...current,
1124
- status: "ended",
1125
- updatedAt: (/* @__PURE__ */ new Date()).toISOString()
1126
- };
1127
- await writeMeta(this.home, ended);
1128
- this.metaCache.set(id, ended);
1129
- return ended;
1130
- });
1131
- }
1132
- touch(id) {
1133
- return this.withMetaLock(id, async () => {
1134
- const meta = await this.getMeta(id);
1135
- if (!meta) return;
1136
- const updated = { ...meta, updatedAt: (/* @__PURE__ */ new Date()).toISOString() };
1137
- await writeMeta(this.home, updated);
1138
- this.metaCache.set(id, updated);
1139
- });
1140
- }
1141
- async generateId() {
1142
- for (let attempt = 0; attempt < 5; attempt++) {
1143
- const id = `${Date.now().toString(36)}${randomBytes(3).toString("hex")}`;
1144
- if (await readMeta(this.home, id) === null) return id;
1145
- }
1146
- return randomUUID3();
1147
- }
1148
- };
1149
- function isSafeId(id) {
1150
- return /^[A-Za-z0-9_-]+$/.test(id);
1151
- }
1152
- var ISSUE_TAIL = 20;
1153
- function messagePayload(body, replyTo) {
1154
- return {
1155
- ...body.text ? { text: body.text } : {},
1156
- ...body.attachments && body.attachments.length > 0 ? { attachments: body.attachments } : {},
1157
- ...replyTo ? { replyTo } : {},
1158
- ...body.answerIn ? { answerIn: body.answerIn } : {}
1159
- };
1160
- }
1161
- function sanitizeName(name) {
1162
- const base = name.split(/[\\/]/).pop() ?? name;
1163
- const cleaned = base.replace(/[^A-Za-z0-9._-]/g, "_").replace(/^\.+/, "");
1164
- return cleaned.length > 0 ? cleaned.slice(0, 120) : "file";
1165
- }
1166
-
1167
- // packages/server/src/app.ts
1168
- var INLINE_SAFE_TYPES = /* @__PURE__ */ new Set([
1169
- "image/png",
1170
- "image/jpeg",
1171
- "image/gif",
1172
- "image/webp",
1173
- "image/x-icon"
1174
- ]);
1175
- var MAX_UPLOAD_BYTES = 25 * 1024 * 1024;
1176
- function createApp(ctx) {
1177
- const app = new Hono();
1178
- const { injectWebSocket, upgradeWebSocket } = createNodeWebSocket({ app });
1179
- ctx.presence.onChange((id, state) => ctx.hub.broadcast(id, { t: "presence", state }));
1180
- const summaryOf = (meta) => ({
1181
- meta,
1182
- presence: ctx.presence.get(meta.id)
1183
- });
1184
- app.get("/api/health", async (c) => {
1185
- const all = await ctx.sessions.list();
1186
- const active = all.filter((meta) => meta.status === "active").length;
1187
- return c.json({
1188
- ok: true,
1189
- sessions: active,
1190
- home: ctx.home,
1191
- port: ctx.port,
1192
- version: ctx.version
1193
- });
1194
- });
1195
- app.post("/api/sessions", async (c) => {
1196
- const parsed = createSessionReqSchema.safeParse(await readJson(c));
1197
- if (!parsed.success) return badRequest(c, parsed.error.issues);
1198
- const meta = await ctx.sessions.create(parsed.data);
1199
- return c.json({ session: summaryOf(meta) }, 201);
1200
- });
1201
- app.get("/api/sessions", async (c) => {
1202
- const all = await ctx.sessions.list();
1203
- return c.json({ sessions: all.map(summaryOf) });
1204
- });
1205
- app.get("/api/sessions/:id", async (c) => {
1206
- const meta = await ctx.sessions.getMeta(c.req.param("id"));
1207
- if (!meta) return notFound(c);
1208
- return c.json({ session: summaryOf(meta) });
1209
- });
1210
- app.post("/api/sessions/:id/messages", async (c) => {
1211
- const id = c.req.param("id");
1212
- const gate = await requireActiveSession(c, ctx, id);
1213
- if (gate instanceof Response) return gate;
1214
- const parsed = postMessageReqSchema.safeParse(await readJson(c));
1215
- if (!parsed.success) return badRequest(c, parsed.error.issues);
1216
- return appendMessage(c, ctx, id, parsed.data, (body) => ctx.sessions.postMessage(id, body));
1217
- });
1218
- app.post("/api/sessions/:id/reply", async (c) => {
1219
- const id = c.req.param("id");
1220
- const gate = await requireActiveSession(c, ctx, id);
1221
- if (gate instanceof Response) return gate;
1222
- const parsed = replyReqSchema.safeParse(await readJson(c));
1223
- if (!parsed.success) return badRequest(c, parsed.error.issues);
1224
- return appendMessage(c, ctx, id, parsed.data, async (body) => {
1225
- const event = await ctx.sessions.reply(id, body);
1226
- ctx.presence.beat(id);
1227
- return event;
1228
- });
1229
- });
1230
- app.post("/api/sessions/:id/attachments", async (c) => {
1231
- const id = c.req.param("id");
1232
- const gate = await requireActiveSession(c, ctx, id);
1233
- if (gate instanceof Response) return gate;
1234
- const declaredLength = Number(c.req.header("content-length"));
1235
- if (Number.isFinite(declaredLength) && declaredLength > MAX_UPLOAD_BYTES) {
1236
- return c.json({ error: "file too large" }, 413);
1237
- }
1238
- const body = await c.req.parseBody();
1239
- const file = body.file;
1240
- if (!(file instanceof File)) return c.json({ error: "expected a `file` field" }, 400);
1241
- if (file.size > MAX_UPLOAD_BYTES) return c.json({ error: "file too large" }, 413);
1242
- const bytes = new Uint8Array(await file.arrayBuffer());
1243
- if (bytes.byteLength === 0) return c.json({ error: "empty file" }, 400);
1244
- const attachment = await ctx.sessions.saveAttachment(id, file.name || "file", bytes);
1245
- return c.json({ attachment }, 201);
1246
- });
1247
- app.get("/api/sessions/:id/attachments/:attId", async (c) => {
1248
- const file = await ctx.sessions.readAttachment(c.req.param("id"), c.req.param("attId"));
1249
- if (!file) return notFound(c);
1250
- const headers = {
1251
- "content-type": file.contentType,
1252
- "x-content-type-options": "nosniff",
1253
- // Attachment bytes are immutable and keyed by a generated id, so the
1254
- // browser can cache them forever instead of refetching on every reload.
1255
- "cache-control": "private, max-age=31536000, immutable"
1256
- };
1257
- const baseType = file.contentType.split(";")[0]?.trim().toLowerCase() ?? "";
1258
- if (!INLINE_SAFE_TYPES.has(baseType)) headers["content-disposition"] = "attachment";
1259
- return c.body(file.bytes, 200, headers);
1260
- });
1261
- app.post("/api/sessions/:id/annotations", async (c) => {
1262
- const id = c.req.param("id");
1263
- const gate = await requireActiveSession(c, ctx, id);
1264
- if (gate instanceof Response) return gate;
1265
- const parsed = postAnnotationsReqSchema.safeParse(await readJson(c));
1266
- if (!parsed.success) return badRequest(c, parsed.error.issues);
1267
- const items = await resolveAnnotationItems(ctx, id, parsed.data.items);
1268
- if (items === null) return c.json({ error: "unknown attachment" }, 400);
1269
- const event = await ctx.sessions.postAnnotations(id, items);
1270
- return c.json({ event }, 201);
1271
- });
1272
- app.post("/api/sessions/:id/pages", async (c) => {
1273
- const id = c.req.param("id");
1274
- const gate = await requireActiveSession(c, ctx, id);
1275
- if (gate instanceof Response) return gate;
1276
- const parsed = postPageReqSchema.safeParse(await readJson(c));
1277
- if (!parsed.success) return badRequest(c, parsed.error.issues);
1278
- const ref = await ctx.sessions.savePage(id, parsed.data.name, parsed.data.content);
1279
- const event = await ctx.sessions.postPage(id, ref);
1280
- ctx.presence.beat(id);
1281
- return c.json({ event }, 201);
1282
- });
1283
- app.post("/api/sessions/:id/issues", async (c) => {
1284
- const id = c.req.param("id");
1285
- const meta = await ctx.sessions.getMeta(id);
1286
- if (!meta) return notFound(c);
1287
- const parsed = postIssueReqSchema.safeParse(await readJson(c));
1288
- if (!parsed.success) return badRequest(c, parsed.error.issues);
1289
- const saved = await ctx.sessions.saveIssue(meta, parsed.data);
1290
- if (!saved) return c.json({ error: "unknown attachment" }, 400);
1291
- return c.json(saved, 201);
1292
- });
1293
- app.get("/api/sessions/:id/poll", async (c) => {
1294
- const id = c.req.param("id");
1295
- const meta = await ctx.sessions.getMeta(id);
1296
- if (!meta) return notFound(c);
1297
- const since = parseNonNeg(c.req.query("since"), 0);
1298
- const wait = meta.status === "ended" ? 0 : clamp(parseNonNeg(c.req.query("wait"), POLL_DEFAULT_WAIT_MS), 0, POLL_MAX_WAIT_MS);
1299
- ctx.presence.enter(id);
1300
- let delivered = false;
1301
- try {
1302
- const signal = AbortSignal.any([c.req.raw.signal, ctx.stopping]);
1303
- const res = await ctx.sessions.poll(id, since, wait, signal);
1304
- delivered = res.events.length > 0;
1305
- return c.json({
1306
- ...res,
1307
- ended: meta.status === "ended",
1308
- ...ctx.stopping.aborted ? { stopping: true } : {}
1309
- });
1310
- } finally {
1311
- ctx.presence.leave(id, c.req.raw.signal.aborted, delivered);
1312
- }
1313
- });
1314
- app.get("/api/sessions/:id/events", async (c) => {
1315
- const id = c.req.param("id");
1316
- const meta = await ctx.sessions.getMeta(id);
1317
- if (!meta) return notFound(c);
1318
- const since = parseNonNeg(c.req.query("since"), 0);
1319
- const limitRaw = c.req.query("limit");
1320
- const limit = limitRaw === void 0 ? void 0 : parseNonNeg(limitRaw, 0);
1321
- return c.json(await ctx.sessions.events(id, since, limit));
1322
- });
1323
- app.get("/api/sessions/:id/attach", async (c) => {
1324
- const id = c.req.param("id");
1325
- const tail = clamp(parseNonNeg(c.req.query("tail"), 20), 0, 500);
1326
- const digest = await ctx.sessions.attach(id, tail, ctx.presence.get(id));
1327
- if (!digest) return notFound(c);
1328
- return c.json(digest);
1329
- });
1330
- app.post("/api/sessions/:id/end", async (c) => {
1331
- const id = c.req.param("id");
1332
- const meta = await ctx.sessions.end(id);
1333
- if (!meta) return notFound(c);
1334
- ctx.hub.broadcast(id, { t: "ended", meta });
1335
- return c.json({ session: summaryOf(meta) });
1336
- });
1337
- app.get(
1338
- "/ws",
1339
- upgradeWebSocket((c) => {
1340
- const id = new URL(c.req.url).searchParams.get(SESSION_QUERY_PARAM) ?? "";
1341
- return {
1342
- onOpen: (_evt, ws) => {
1343
- openSocket(ctx, id, ws).catch(() => ws.close(1011, "internal error"));
1344
- },
1345
- onClose: (_evt, ws) => {
1346
- ctx.hub.remove(id, ws);
1347
- }
1348
- };
1349
- })
1350
- );
1351
- app.get("/api/sessions/:id/pages/:name", async (c) => {
1352
- const id = c.req.param("id");
1353
- const meta = await ctx.sessions.getMeta(id);
1354
- if (!meta) return notFound(c);
1355
- const name = c.req.param("name");
1356
- const page = await ctx.sessions.readPage(id, name);
1357
- if (!page) return notFound(c);
1358
- const raw = c.req.query("raw");
1359
- const html = raw === "1" || raw === "true" ? page.content : renderPageHtml(page.content);
1360
- return c.body(html, 200, {
1361
- "content-type": "text/html; charset=utf-8",
1362
- "cache-control": "no-cache"
1363
- });
1364
- });
1365
- app.get("/inject/annotate.js", createInjectHandler(ctx.injectDist));
1366
- app.get("*", createStaticHandler(ctx.webDist));
1367
- return { app, injectWebSocket };
1368
- }
1369
- async function openSocket(ctx, id, ws) {
1370
- const meta = await ctx.sessions.getMeta(id);
1371
- if (!meta) {
1372
- ws.close(1008, "unknown session");
1373
- return;
1374
- }
1375
- const first = ctx.hub.add(id, ws);
1376
- if (first) {
1377
- const log = await ctx.sessions.logFor(id);
1378
- const unsubscribe = log.subscribe((event) => ctx.hub.broadcast(id, { t: "append", event }));
1379
- ctx.hub.setForwarder(id, unsubscribe);
1380
- }
1381
- const hello = { t: "hello", meta, presence: ctx.presence.get(id) };
1382
- ws.send(JSON.stringify(hello));
1383
- }
1384
- async function requireActiveSession(c, ctx, id) {
1385
- const meta = await ctx.sessions.getMeta(id);
1386
- if (!meta) return notFound(c);
1387
- if (meta.status === "ended") return c.json({ error: "session ended" }, 409);
1388
- return meta;
1389
- }
1390
- async function appendMessage(c, ctx, id, req, append) {
1391
- const attachments = await resolveAttachments(ctx, id, req.attachmentIds);
1392
- if (attachments === null) return c.json({ error: "unknown attachment" }, 400);
1393
- try {
1394
- const event = await append({
1395
- text: req.text,
1396
- attachments,
1397
- replyTo: req.replyTo,
1398
- ..."answerIn" in req && req.answerIn ? { answerIn: req.answerIn } : {}
1399
- });
1400
- return c.json({ event }, 201);
1401
- } catch (err) {
1402
- if (err instanceof ReplyTargetError) return c.json({ error: err.message }, 400);
1403
- throw err;
1404
- }
1405
- }
1406
- async function resolveAttachments(ctx, id, ids) {
1407
- if (!ids || ids.length === 0) return [];
1408
- const refs = await Promise.all(ids.map((attId) => ctx.sessions.resolveAttachment(id, attId)));
1409
- const resolved = [];
1410
- for (const ref of refs) {
1411
- if (!ref) return null;
1412
- resolved.push(ref);
1413
- }
1414
- return resolved;
1415
- }
1416
- async function resolveAnnotationItems(ctx, id, items) {
1417
- const refs = await Promise.all(
1418
- items.map(
1419
- (item) => item.screenshotAttachmentId ? ctx.sessions.resolveAttachment(id, item.screenshotAttachmentId) : Promise.resolve(void 0)
1420
- )
1421
- );
1422
- const resolved = [];
1423
- for (let i = 0; i < items.length; i++) {
1424
- const item = items[i];
1425
- const ref = refs[i];
1426
- if (!item || ref === null) return null;
1427
- resolved.push({
1428
- ...item.note ? { note: item.note } : {},
1429
- target: item.target,
1430
- ...ref ? { screenshot: ref } : {}
1431
- });
1432
- }
1433
- return resolved;
1434
- }
1435
- async function readJson(c) {
1436
- try {
1437
- return await c.req.json();
1438
- } catch {
1439
- return {};
1440
- }
1441
- }
1442
- function notFound(c) {
1443
- return c.json({ error: "unknown session" }, 404);
1444
- }
1445
- function badRequest(c, issues) {
1446
- return c.json({ error: "invalid request", issues }, 400);
1447
- }
1448
- function parseNonNeg(value, fallback) {
1449
- if (value === void 0) return fallback;
1450
- const n = Number(value);
1451
- return Number.isFinite(n) && n >= 0 ? Math.floor(n) : fallback;
1452
- }
1453
- function clamp(n, min, max) {
1454
- return Math.min(max, Math.max(min, n));
1455
- }
1456
-
1457
- // packages/server/src/lifecycle.ts
1458
- import { unlinkSync } from "node:fs";
1459
- import { open as open2, readFile as readFile6, rm as rm2 } from "node:fs/promises";
1460
- var ServerConflictError = class extends Error {
1461
- constructor(existing) {
1462
- super(`chamba server already running (pid ${existing.pid}, port ${existing.port})`);
1463
- this.existing = existing;
1464
- this.name = "ServerConflictError";
1465
- }
1466
- existing;
1467
- };
1468
- async function acquirePidfile(home, port) {
1469
- const file = pidfilePath(home);
1470
- const info = { pid: process.pid, port, startedAt: (/* @__PURE__ */ new Date()).toISOString() };
1471
- for (let attempt = 0; attempt < 3; attempt++) {
1472
- try {
1473
- const handle = await open2(file, "wx");
1474
- await handle.writeFile(`${JSON.stringify(info, null, 2)}
1475
- `);
1476
- await handle.close();
1477
- return makeHandle(file, info.pid);
1478
- } catch (err) {
1479
- if (err.code !== "EEXIST") throw err;
1480
- const existing = await readPidfile(file);
1481
- if (existing && existing.pid !== process.pid && await isServerLive(existing)) {
1482
- throw new ServerConflictError(existing);
1483
- }
1484
- await rm2(file, { force: true });
1485
- }
1486
- }
1487
- throw new Error("could not acquire chamba pidfile");
1488
- }
1489
- function makeHandle(file, ownerPid) {
1490
- let released = false;
1491
- const releaseSync = () => {
1492
- if (released) return;
1493
- released = true;
1494
- try {
1495
- unlinkSync(file);
1496
- } catch {
1497
- }
1498
- };
1499
- process.once("exit", releaseSync);
1500
- return {
1501
- release: async () => {
1502
- if (released) return;
1503
- released = true;
1504
- try {
1505
- const current = await readPidfile(file);
1506
- if (!current || current.pid === ownerPid) await rm2(file, { force: true });
1507
- } catch {
1508
- }
1509
- }
1510
- };
1511
- }
1512
- async function readPidfile(file) {
1513
- try {
1514
- const raw = await readFile6(file, "utf8");
1515
- const parsed = JSON.parse(raw);
1516
- if (typeof parsed.pid === "number" && typeof parsed.port === "number") return parsed;
1517
- return null;
1518
- } catch {
1519
- return null;
1520
- }
1521
- }
1522
- function isPidAlive(pid) {
1523
- try {
1524
- process.kill(pid, 0);
1525
- return true;
1526
- } catch (err) {
1527
- return err.code === "EPERM";
1528
- }
1529
- }
1530
- async function isServerLive(info) {
1531
- if (!isPidAlive(info.pid)) return false;
1532
- try {
1533
- const controller = new AbortController();
1534
- const timer = setTimeout(() => controller.abort(), 500);
1535
- const res = await fetch(`http://127.0.0.1:${info.port}/api/health`, {
1536
- signal: controller.signal
1537
- });
1538
- clearTimeout(timer);
1539
- return res.ok;
1540
- } catch {
1541
- return false;
1542
- }
1543
- }
1544
- function startIdleWatchdog(opts) {
1545
- if (opts.idleMs <= 0) return () => void 0;
1546
- const interval = opts.intervalMs ?? Math.min(opts.idleMs, 5e3);
1547
- let idleSince = Date.now();
1548
- const timer = setInterval(() => {
1549
- if (opts.isBusy()) {
1550
- idleSince = Date.now();
1551
- return;
1552
- }
1553
- if (Date.now() - idleSince >= opts.idleMs) opts.onIdle();
1554
- }, interval);
1555
- timer.unref?.();
1556
- return () => clearInterval(timer);
1557
- }
1558
-
1559
- // packages/server/src/port.ts
1560
- var DEFAULT_PORT = 4319;
1561
- var DEFAULT_BIND_HOST = "127.0.0.1";
1562
- function resolveBindHost(argv = process.argv.slice(2)) {
1563
- const flag = readFlag(argv, "--host");
1564
- if (flag !== void 0 && flag.length > 0) return flag;
1565
- const env = process.env.CHAMBA_HOST;
1566
- if (env !== void 0 && env.length > 0) return env;
1567
- return DEFAULT_BIND_HOST;
1568
- }
1569
- function resolvePort(argv = process.argv.slice(2), home) {
1570
- const flag = readFlag(argv, "--port");
1571
- if (flag !== void 0) return parsePort(flag, "--port");
1572
- const env = process.env.CHAMBA_PORT;
1573
- if (env !== void 0 && env.length > 0) return parsePort(env, "CHAMBA_PORT");
1574
- return home !== void 0 ? derivePort(home) : DEFAULT_PORT;
1575
- }
1576
- function derivePort(home) {
1577
- let hash = 2166136261;
1578
- for (let i = 0; i < home.length; i++) {
1579
- hash ^= home.charCodeAt(i);
1580
- hash = Math.imul(hash, 16777619);
1581
- }
1582
- return DEFAULT_PORT + Math.abs(hash) % 512;
1583
- }
1584
- function readFlag(argv, name) {
1585
- for (let i = 0; i < argv.length; i++) {
1586
- const arg = argv[i];
1587
- if (arg === name) return argv[i + 1];
1588
- if (arg?.startsWith(`${name}=`)) return arg.slice(name.length + 1);
1589
- }
1590
- return void 0;
1591
- }
1592
- function parsePort(value, source) {
1593
- const port = Number(value);
1594
- if (!Number.isInteger(port) || port < 1 || port > 65535) {
1595
- throw new Error(`invalid ${source}: ${value}`);
1596
- }
1597
- return port;
1598
- }
1599
-
1600
- // packages/server/src/presence.ts
1601
- var PresenceTracker = class {
1602
- /**
1603
- * `idleAwayMs` is how long presence stays `listening` after a normal poll
1604
- * return - long enough to bridge real work gaps. `dropAwayMs` is the shorter
1605
- * window used when a poll's client disconnected, so a killed or crashed agent
1606
- * surfaces quickly. Both are unrelated to `ServeOptions.idleMs`, which is the
1607
- * server's own self-shutdown window.
1608
- */
1609
- constructor(idleAwayMs = 30 * 6e4, dropAwayMs = 1e4) {
1610
- this.idleAwayMs = idleAwayMs;
1611
- this.dropAwayMs = dropAwayMs;
1612
- }
1613
- idleAwayMs;
1614
- dropAwayMs;
1615
- active = /* @__PURE__ */ new Map();
1616
- state = /* @__PURE__ */ new Map();
1617
- timers = /* @__PURE__ */ new Map();
1618
- listeners = /* @__PURE__ */ new Set();
1619
- get(id) {
1620
- return this.state.get(id) ?? "away";
1621
- }
1622
- /** A poll has parked: the agent is definitely present. */
1623
- enter(id) {
1624
- this.active.set(id, (this.active.get(id) ?? 0) + 1);
1625
- this.clearTimer(id);
1626
- this.set(id, "listening");
1627
- }
1628
- /**
1629
- * A poll returned or timed out: still present through the away window. `dropped`
1630
- * marks a client disconnect (the agent's process died or was killed mid-poll),
1631
- * which uses the short window; a normal return uses the long one. `delivered`
1632
- * marks a clean return that carried input - the agent took the human's message
1633
- * and left to work - so with no poll left parked the state flips to `working`,
1634
- * with the long window as the backstop if the agent never comes back.
1635
- */
1636
- leave(id, dropped = false, delivered = false) {
1637
- const remaining = Math.max(0, (this.active.get(id) ?? 1) - 1);
1638
- this.active.set(id, remaining);
1639
- if (remaining > 0) return;
1640
- if (delivered && !dropped) this.set(id, "working");
1641
- this.scheduleAway(id, dropped ? this.dropAwayMs : this.idleAwayMs);
1642
- }
1643
- /** Any other sign of life (e.g. a reply) keeps the agent listening. */
1644
- beat(id) {
1645
- this.set(id, "listening");
1646
- if ((this.active.get(id) ?? 0) === 0) this.scheduleAway(id, this.idleAwayMs);
1647
- }
1648
- /** True while any session has a parked poll - used to hold off idle shutdown. */
1649
- hasActivePolls() {
1650
- for (const count of this.active.values()) {
1651
- if (count > 0) return true;
1652
- }
1653
- return false;
1654
- }
1655
- onChange(listener) {
1656
- this.listeners.add(listener);
1657
- return () => this.listeners.delete(listener);
1658
- }
1659
- scheduleAway(id, delayMs) {
1660
- this.clearTimer(id);
1661
- const timer = setTimeout(() => {
1662
- this.timers.delete(id);
1663
- if ((this.active.get(id) ?? 0) === 0) this.set(id, "away");
1664
- }, delayMs);
1665
- timer.unref?.();
1666
- this.timers.set(id, timer);
1667
- }
1668
- clearTimer(id) {
1669
- const timer = this.timers.get(id);
1670
- if (timer) {
1671
- clearTimeout(timer);
1672
- this.timers.delete(id);
1673
- }
1674
- }
1675
- set(id, next) {
1676
- if (this.state.get(id) === next) return;
1677
- this.state.set(id, next);
1678
- for (const listener of [...this.listeners]) listener(id, next);
1679
- }
1680
- };
1681
-
1682
- // packages/server/src/ws.ts
1683
- var WsHub = class {
1684
- rooms = /* @__PURE__ */ new Map();
1685
- forwarders = /* @__PURE__ */ new Map();
1686
- /** Add a socket to a room; returns true if it is the room's first socket. */
1687
- add(id, ws) {
1688
- let room = this.rooms.get(id);
1689
- if (!room) {
1690
- room = /* @__PURE__ */ new Set();
1691
- this.rooms.set(id, room);
1692
- }
1693
- room.add(ws);
1694
- return room.size === 1;
1695
- }
1696
- remove(id, ws) {
1697
- const room = this.rooms.get(id);
1698
- if (!room) return;
1699
- room.delete(ws);
1700
- if (room.size === 0) {
1701
- this.rooms.delete(id);
1702
- const unsubscribe = this.forwarders.get(id);
1703
- if (unsubscribe) {
1704
- unsubscribe();
1705
- this.forwarders.delete(id);
1706
- }
1707
- }
1708
- }
1709
- setForwarder(id, unsubscribe) {
1710
- this.forwarders.set(id, unsubscribe);
1711
- }
1712
- broadcast(id, message) {
1713
- const room = this.rooms.get(id);
1714
- if (!room) return;
1715
- const data = JSON.stringify(message);
1716
- for (const ws of [...room]) {
1717
- try {
1718
- ws.send(data);
1719
- } catch {
1720
- }
1721
- }
1722
- }
1723
- hasClients() {
1724
- return this.rooms.size > 0;
1725
- }
1726
- };
1727
-
1728
- // packages/server/src/index.ts
1729
- var DEFAULT_IDLE_MS = 5 * 6e4;
1730
- async function serve(options = {}) {
1731
- const home = options.home ?? resolveHome();
1732
- const port = options.port ?? resolvePort(process.argv.slice(2), home);
1733
- const host = options.host ?? resolveBindHost();
1734
- const webDist = options.webDist ?? resolveWebDist();
1735
- const injectDist = options.injectDist ?? resolveInjectDist();
1736
- const sessions = new Sessions(home);
1737
- await sessions.init();
1738
- const presence = new PresenceTracker();
1739
- const hub = new WsHub();
1740
- const stopping = new AbortController();
1741
- const { app, injectWebSocket } = createApp({
1742
- sessions,
1743
- presence,
1744
- hub,
1745
- webDist,
1746
- injectDist,
1747
- home,
1748
- port,
1749
- version: chambaVersion(),
1750
- stopping: stopping.signal
1751
- });
1752
- const pid = await acquirePidfile(home, port);
1753
- const server = honoServe({ fetch: app.fetch, hostname: host, port });
1754
- injectWebSocket(server);
1755
- try {
1756
- await waitListening(server);
1757
- } catch (err) {
1758
- await pid.release();
1759
- throw err;
1760
- }
1761
- let closing = false;
1762
- let stopWatchdog = () => void 0;
1763
- const close = async () => {
1764
- if (closing) return;
1765
- closing = true;
1766
- stopWatchdog();
1767
- stopping.abort();
1768
- await closeServer(server);
1769
- await pid.release();
1770
- };
1771
- stopWatchdog = startIdleWatchdog({
1772
- idleMs: options.idleMs ?? parseIdleMs(process.env.CHAMBA_IDLE_MS) ?? DEFAULT_IDLE_MS,
1773
- isBusy: () => hub.hasClients() || presence.hasActivePolls(),
1774
- // `close()` drains in-flight connections (a POST is neither a WS client nor a
1775
- // parked poll, so `isBusy` does not count it) and releases the pidfile; the
1776
- // explicit exit then guarantees the detached process actually goes away rather
1777
- // than lingering on some stray handle.
1778
- onIdle: () => void close().then(() => process.exit(0))
1779
- });
1780
- return { port, url: `http://localhost:${port}/`, home, sessions, close };
1781
- }
1782
- function parseIdleMs(value) {
1783
- if (value === void 0 || value.length === 0) return null;
1784
- const n = Number(value);
1785
- return Number.isFinite(n) && n >= 0 ? Math.floor(n) : null;
1786
- }
1787
- function waitListening(server) {
1788
- return new Promise((resolve, reject) => {
1789
- const cleanup = () => {
1790
- server.off("listening", onListening);
1791
- server.off("error", onError);
1792
- };
1793
- const onListening = () => {
1794
- cleanup();
1795
- resolve();
1796
- };
1797
- const onError = (err) => {
1798
- cleanup();
1799
- reject(err);
1800
- };
1801
- server.once("listening", onListening);
1802
- server.once("error", onError);
1803
- });
1804
- }
1805
- function closeServer(server) {
1806
- return new Promise((resolve) => server.close(() => resolve()));
1807
- }
1808
-
1809
- // packages/server/src/bin.ts
1810
- async function main() {
1811
- try {
1812
- const running = await serve();
1813
- process.stdout.write(`chamba server listening on ${running.url}
1814
- `);
1815
- const shutdown = () => {
1816
- void running.close().then(() => process.exit(0));
1817
- };
1818
- process.once("SIGINT", shutdown);
1819
- process.once("SIGTERM", shutdown);
1820
- } catch (err) {
1821
- if (err instanceof ServerConflictError) {
1822
- process.stdout.write(`${err.message}
1823
- `);
1824
- process.exit(0);
1825
- }
1826
- process.stderr.write(`chamba server failed: ${err.message}
1827
- `);
1828
- process.exit(1);
1829
- }
1830
- }
1831
- void main();