chamba 0.3.0 → 0.3.1

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 (3) hide show
  1. package/dist/cli.js +534 -643
  2. package/dist/server.js +1395 -1483
  3. package/package.json +2 -3
package/dist/server.js CHANGED
@@ -7,17 +7,17 @@ import { z as z3 } from "zod";
7
7
  // packages/shared/src/messages.ts
8
8
  import { z as z2 } from "zod";
9
9
 
10
- // packages/shared/src/session.ts
10
+ // packages/shared/src/events.ts
11
11
  import { z } from "zod";
12
- var sessionKindSchema = z.enum(["everyday", "spec"]);
13
- var sessionStatusSchema = z.enum(["active", "ended"]);
14
- var sessionMetaSchema = z.object({
15
- id: z.string().min(1),
16
- kind: sessionKindSchema.optional(),
17
- title: z.string().optional(),
18
- status: sessionStatusSchema,
19
- createdAt: z.string().datetime(),
20
- updatedAt: z.string().datetime()
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
21
  });
22
22
 
23
23
  // packages/shared/src/messages.ts
@@ -28,22 +28,23 @@ var attachmentSchema = z2.object({
28
28
  contentType: z2.string().min(1),
29
29
  size: z2.number().int().nonnegative()
30
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
+ });
31
37
  var messagePayloadSchema = z2.object({
32
38
  text: z2.string().min(1).optional(),
33
- attachments: z2.array(attachmentSchema).optional()
39
+ attachments: z2.array(attachmentSchema).optional(),
40
+ replyTo: replyRefSchema.optional(),
41
+ answerIn: z2.literal("terminal").optional()
34
42
  }).refine((p) => (p.text?.length ?? 0) > 0 || (p.attachments?.length ?? 0) > 0, {
35
- message: "a message needs text or at least one attachment"
43
+ error: "a message needs text or at least one attachment"
36
44
  });
37
- var systemReasonSchema = z2.enum([
38
- "session-created",
39
- "user-moved-away",
40
- "session-ended",
41
- "kind-chosen"
42
- ]);
45
+ var systemReasonSchema = z2.enum(["session-created", "session-ended"]);
43
46
  var systemPayloadSchema = z2.object({
44
- reason: systemReasonSchema,
45
- to: z2.string().min(1).optional(),
46
- kind: sessionKindSchema.optional()
47
+ reason: systemReasonSchema
47
48
  });
48
49
 
49
50
  // packages/shared/src/annotations.ts
@@ -77,148 +78,175 @@ var annotationBatchPayloadSchema = z3.object({
77
78
  });
78
79
 
79
80
  // packages/shared/src/api.ts
80
- import { z as z7 } from "zod";
81
+ import { z as z6 } from "zod";
81
82
 
82
- // packages/shared/src/events.ts
83
+ // packages/shared/src/presence.ts
83
84
  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),
91
- source: eventSourceSchema,
92
- payload: z4.unknown()
93
- });
85
+ var presenceSchema = z4.enum(["listening", "working", "away"]);
94
86
 
95
- // packages/shared/src/presence.ts
87
+ // packages/shared/src/session.ts
96
88
  import { z as z5 } from "zod";
97
- var presenceSchema = z5.enum(["listening", "working", "away"]);
98
-
99
- // packages/shared/src/spec.ts
100
- import { z as z6 } from "zod";
101
- var QUESTION_CARD = "question-card";
102
- var DECISION = "decision";
103
- var ARTIFACT = "artifact";
104
- var questionOptionSchema = z6.object({
105
- label: z6.string().min(1),
106
- description: z6.string().min(1),
107
- recommended: z6.boolean().optional()
108
- });
109
- var questionCardPayloadSchema = z6.object({
110
- cardId: z6.string().uuid(),
111
- question: z6.string().min(1),
112
- options: z6.array(questionOptionSchema).min(2).max(6),
113
- /** Optional custom wording for the stop-anytime affordance. */
114
- stopHint: z6.string().min(1).optional()
115
- });
116
- var decisionStatusSchema = z6.enum(["confirmed", "assumed"]);
117
- var decisionPayloadSchema = z6.object({
118
- summary: z6.string().min(1),
119
- status: decisionStatusSchema,
120
- detail: z6.string().min(1).optional()
121
- });
122
- var artifactKindSchema = z6.enum(["draft", "mock", "diagram"]);
123
- var artifactRefSchema = z6.object({
124
- kind: artifactKindSchema,
125
- name: z6.string().min(1),
126
- relPath: z6.string().min(1),
127
- contentType: z6.string().min(1),
128
- size: z6.number().int().nonnegative()
129
- });
130
- var artifactPayloadSchema = z6.object({
131
- artifact: artifactRefSchema
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()
132
99
  });
133
100
 
134
101
  // packages/shared/src/api.ts
135
- var cursorSchema = z7.number().int().nonnegative();
136
- var createSessionReqSchema = z7.object({
137
- kind: sessionKindSchema.optional(),
138
- title: z7.string().min(1).optional()
139
- });
140
- var setSessionKindReqSchema = z7.object({
141
- kind: sessionKindSchema
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()
142
109
  });
143
- var messageBodySchema = z7.object({
144
- text: z7.string().min(1).optional(),
145
- attachmentIds: z7.array(z7.string().min(1)).optional()
146
- }).refine((r) => (r.text?.length ?? 0) > 0 || (r.attachmentIds?.length ?? 0) > 0, {
147
- message: "a message needs text or at least one attachment"
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()
148
114
  });
149
- var postMessageReqSchema = messageBodySchema;
150
- var replyReqSchema = messageBodySchema;
151
- var annotationItemReqSchema = z7.object({
152
- note: z7.string().min(1).optional(),
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(),
153
121
  target: annotationTargetSchema,
154
- screenshotAttachmentId: z7.string().min(1).optional()
122
+ screenshotAttachmentId: z6.string().min(1).optional()
155
123
  });
156
- var postAnnotationsReqSchema = z7.object({
157
- items: z7.array(annotationItemReqSchema).min(1)
124
+ var postAnnotationsReqSchema = z6.object({
125
+ items: z6.array(annotationItemReqSchema).min(1)
158
126
  });
159
- var moveAwayReqSchema = z7.object({
160
- to: z7.string().min(1).optional()
161
- });
162
- var postQuestionCardReqSchema = questionCardPayloadSchema;
163
- var postDecisionReqSchema = decisionPayloadSchema;
164
- var postArtifactReqSchema = z7.object({
165
- kind: artifactKindSchema,
166
- name: z7.string().min(1),
167
- content: z7.string().min(1)
168
- });
169
- var attachmentResSchema = z7.object({
127
+ var attachmentResSchema = z6.object({
170
128
  attachment: attachmentSchema
171
129
  });
172
- var eventResSchema = z7.object({
130
+ var eventResSchema = z6.object({
173
131
  event: eventEnvelopeSchema
174
132
  });
175
- var pollResSchema = z7.object({
176
- events: z7.array(eventEnvelopeSchema),
133
+ var pollResSchema = z6.object({
134
+ events: z6.array(eventEnvelopeSchema),
177
135
  cursor: cursorSchema,
178
- ended: z7.boolean().optional()
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()
179
140
  });
180
- var fetchMoreSchema = z7.object({
181
- hasMore: z7.boolean(),
141
+ var fetchMoreSchema = z6.object({
142
+ hasMore: z6.boolean(),
182
143
  earliestLoadedSeq: cursorSchema,
183
- hint: z7.string()
144
+ hint: z6.string()
184
145
  });
185
- var attachDigestSchema = z7.object({
146
+ var attachDigestSchema = z6.object({
186
147
  meta: sessionMetaSchema,
187
148
  presence: presenceSchema,
188
- tail: z7.array(eventEnvelopeSchema),
149
+ tail: z6.array(eventEnvelopeSchema),
189
150
  cursor: cursorSchema,
190
151
  fetchMore: fetchMoreSchema
191
152
  });
192
- var sessionSummarySchema = z7.object({
153
+ var sessionSummarySchema = z6.object({
193
154
  meta: sessionMetaSchema,
194
155
  presence: presenceSchema
195
156
  });
196
- var listResSchema = z7.object({
197
- sessions: z7.array(sessionSummarySchema)
157
+ var listResSchema = z6.object({
158
+ sessions: z6.array(sessionSummarySchema)
198
159
  });
199
- var sessionResSchema = z7.object({
160
+ var sessionResSchema = z6.object({
200
161
  session: sessionSummarySchema
201
162
  });
202
- var healthResSchema = z7.object({
203
- ok: z7.boolean(),
163
+ var healthResSchema = z6.object({
164
+ ok: z6.boolean(),
204
165
  sessions: cursorSchema,
205
- home: z7.string().min(1).optional(),
206
- port: z7.number().int().positive().optional(),
207
- version: z7.string().min(1).optional()
166
+ home: z6.string().min(1).optional(),
167
+ port: z6.number().int().positive().optional(),
168
+ version: z6.string().min(1).optional()
208
169
  });
209
170
 
210
171
  // packages/shared/src/issues.ts
211
- import { z as z8 } from "zod";
212
- var issueTypeSchema = z8.enum(["bug", "feature"]);
213
- var postIssueReqSchema = z8.object({
172
+ import { z as z7 } from "zod";
173
+ var issueTypeSchema = z7.enum(["bug", "feature"]);
174
+ var postIssueReqSchema = z7.object({
214
175
  type: issueTypeSchema,
215
- text: z8.string().min(1),
216
- screenshotAttachmentIds: z8.array(z8.string().min(1)).optional()
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()
217
192
  });
218
- var issueResSchema = z8.object({
219
- id: z8.string().min(1),
220
- path: z8.string().min(1)
193
+ var pagePayloadSchema = z8.object({
194
+ page: pageRefSchema
221
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
+ }
222
250
 
223
251
  // packages/shared/src/ws.ts
224
252
  import { z as z9 } from "zod";
@@ -236,10 +264,6 @@ var wsServerMessageSchema = z9.discriminatedUnion("t", [
236
264
  t: z9.literal("presence"),
237
265
  state: presenceSchema
238
266
  }),
239
- z9.object({
240
- t: z9.literal("kind"),
241
- meta: sessionMetaSchema
242
- }),
243
267
  z9.object({
244
268
  t: z9.literal("ended"),
245
269
  meta: sessionMetaSchema
@@ -251,99 +275,18 @@ import { createNodeWebSocket } from "@hono/node-ws";
251
275
  import { Hono } from "hono";
252
276
 
253
277
  // packages/server/src/inject.ts
254
- import { existsSync } from "node:fs";
255
- import { readFile } from "node:fs/promises";
256
- import path from "node:path";
257
- import { fileURLToPath } from "node:url";
258
- function resolveInjectDist() {
259
- const here = path.dirname(fileURLToPath(import.meta.url));
260
- const dev = path.resolve(here, "../../inject/dist");
261
- const packaged = path.resolve(here, "../inject");
262
- return [dev, packaged].find((dir) => existsSync(path.join(dir, "annotate.js"))) ?? dev;
263
- }
264
- function createInjectHandler(injectDist) {
265
- const file = path.join(injectDist, "annotate.js");
266
- return async (c) => {
267
- let bytes;
268
- try {
269
- const raw = await readFile(file);
270
- bytes = new Uint8Array(raw.byteLength);
271
- bytes.set(raw);
272
- } catch {
273
- return c.text("annotation client not built - run `pnpm --filter @chamba/inject build`", 404);
274
- }
275
- return c.body(bytes, 200, {
276
- "content-type": "text/javascript; charset=utf-8",
277
- "cache-control": "no-cache",
278
- "access-control-allow-origin": "*"
279
- });
280
- };
281
- }
282
-
283
- // packages/server/src/render.ts
284
- import { marked } from "marked";
285
- var INJECT_TAG = '<script src="/inject/annotate.js"></script>';
286
- async function renderArtifactHtml(file, content) {
287
- const lower = file.toLowerCase();
288
- if (lower.endsWith(".html") || lower.endsWith(".htm")) {
289
- return injectInto(content);
290
- }
291
- if (lower.endsWith(".md") || lower.endsWith(".markdown")) {
292
- const body = await marked.parse(content, { async: false });
293
- return draftDocument(body);
294
- }
295
- return draftDocument(`<pre>${escapeHtml(content)}</pre>`);
296
- }
297
- function injectInto(html) {
298
- const idx = html.toLowerCase().lastIndexOf("</body>");
299
- if (idx === -1) return `${html}
300
- ${INJECT_TAG}`;
301
- return `${html.slice(0, idx)}${INJECT_TAG}
302
- ${html.slice(idx)}`;
303
- }
304
- function draftDocument(body) {
305
- return `<!doctype html>
306
- <html lang="en">
307
- <head>
308
- <meta charset="utf-8" />
309
- <meta name="viewport" content="width=device-width, initial-scale=1" />
310
- <style>
311
- :root { color-scheme: light dark; }
312
- body {
313
- margin: 0 auto; max-width: 46rem; padding: 2.5rem 1.5rem;
314
- font: 16px/1.65 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
315
- color: #1a1a1a; background: #fff;
316
- }
317
- @media (prefers-color-scheme: dark) { body { color: #e6e6e6; background: #0d0d0d; } }
318
- h1, h2, h3 { line-height: 1.25; margin-top: 1.8em; }
319
- h1 { font-size: 1.8rem; } h2 { font-size: 1.4rem; } h3 { font-size: 1.15rem; }
320
- code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 0.9em; }
321
- pre { background: rgba(127,127,127,0.12); padding: 1rem; border-radius: 8px; overflow-x: auto; }
322
- pre code { background: none; padding: 0; }
323
- :not(pre) > code { background: rgba(127,127,127,0.14); padding: 0.15em 0.35em; border-radius: 4px; }
324
- blockquote { margin: 1em 0; padding-left: 1em; border-left: 3px solid rgba(127,127,127,0.35); color: inherit; opacity: 0.85; }
325
- table { border-collapse: collapse; } th, td { border: 1px solid rgba(127,127,127,0.3); padding: 0.4em 0.7em; }
326
- a { color: #2563eb; } @media (prefers-color-scheme: dark) { a { color: #60a5fa; } }
327
- </style>
328
- </head>
329
- <body>
330
- ${body}
331
- ${INJECT_TAG}
332
- </body>
333
- </html>`;
334
- }
335
- function escapeHtml(text) {
336
- return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
337
- }
338
-
339
- // packages/server/src/static.ts
340
278
  import { existsSync as existsSync2 } from "node:fs";
341
- import { readFile as readFile2 } from "node:fs/promises";
342
279
  import path3 from "node:path";
343
280
  import { fileURLToPath as fileURLToPath2 } from "node:url";
344
281
 
345
- // packages/server/src/mime.ts
282
+ // packages/server/src/static.ts
283
+ import { existsSync } from "node:fs";
284
+ import { readFile } from "node:fs/promises";
346
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";
347
290
  var CONTENT_TYPES = {
348
291
  ".html": "text/html; charset=utf-8",
349
292
  ".js": "text/javascript; charset=utf-8",
@@ -364,15 +307,24 @@ var CONTENT_TYPES = {
364
307
  ".md": "text/markdown; charset=utf-8"
365
308
  };
366
309
  function contentTypeFor(filePath) {
367
- return CONTENT_TYPES[path2.extname(filePath).toLowerCase()] ?? "application/octet-stream";
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";
368
320
  }
369
321
 
370
322
  // packages/server/src/static.ts
371
323
  function resolveWebDist() {
372
- const here = path3.dirname(fileURLToPath2(import.meta.url));
373
- const dev = path3.resolve(here, "../../web/dist");
374
- const packaged = path3.resolve(here, "../web");
375
- return [dev, packaged].find((dir) => existsSync2(path3.join(dir, "index.html"))) ?? dev;
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;
376
328
  }
377
329
  function createStaticHandler(webDist) {
378
330
  return async (c) => {
@@ -386,15 +338,15 @@ function createStaticHandler(webDist) {
386
338
  return c.json({ error: "not found" }, 404);
387
339
  }
388
340
  const relative = pathname === "/" ? "index.html" : pathname.replace(/^\/+/, "");
389
- const filePath = path3.resolve(webDist, relative);
390
- if (filePath !== webDist && !filePath.startsWith(webDist + path3.sep)) {
341
+ const filePath = path2.resolve(webDist, relative);
342
+ if (filePath !== webDist && !filePath.startsWith(webDist + path2.sep)) {
391
343
  return c.json({ error: "not found" }, 404);
392
344
  }
393
345
  const direct = await readFileOrNull(filePath);
394
346
  if (direct) {
395
347
  return c.body(direct, 200, { "content-type": contentTypeFor(filePath) });
396
348
  }
397
- const indexHtml = await readFileOrNull(path3.join(webDist, "index.html"));
349
+ const indexHtml = await readFileOrNull(path2.join(webDist, "index.html"));
398
350
  if (indexHtml) {
399
351
  return c.body(indexHtml, 200, { "content-type": "text/html; charset=utf-8" });
400
352
  }
@@ -403,7 +355,7 @@ function createStaticHandler(webDist) {
403
355
  }
404
356
  async function readFileOrNull(filePath) {
405
357
  try {
406
- const raw = await readFile2(filePath);
358
+ const raw = await readFile(filePath);
407
359
  const bytes = new Uint8Array(raw.byteLength);
408
360
  bytes.set(raw);
409
361
  return bytes;
@@ -412,894 +364,265 @@ async function readFileOrNull(filePath) {
412
364
  }
413
365
  }
414
366
 
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;
424
- function createApp(ctx) {
425
- const app = new Hono();
426
- const { injectWebSocket, upgradeWebSocket } = createNodeWebSocket({ app });
427
- ctx.presence.onChange((id, state) => ctx.hub.broadcast(id, { t: "presence", state }));
428
- const summaryOf = (meta) => ({
429
- meta,
430
- presence: ctx.presence.get(meta.id)
431
- });
432
- app.get("/api/health", async (c) => {
433
- const all = await ctx.sessions.list();
434
- const active = all.filter((meta) => meta.status === "active").length;
435
- return c.json({
436
- ok: true,
437
- sessions: active,
438
- home: ctx.home,
439
- port: ctx.port,
440
- version: ctx.version
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": "*"
441
385
  });
442
- });
443
- app.post("/api/sessions", async (c) => {
444
- const parsed = createSessionReqSchema.safeParse(await readJson(c));
445
- if (!parsed.success) return badRequest(c, parsed.error.issues);
446
- const meta = await ctx.sessions.create(parsed.data);
447
- return c.json({ session: summaryOf(meta) }, 201);
448
- });
449
- app.get("/api/sessions", async (c) => {
450
- const all = await ctx.sessions.list();
451
- return c.json({ sessions: all.map(summaryOf) });
452
- });
453
- app.get("/api/sessions/:id", async (c) => {
454
- const meta = await ctx.sessions.getMeta(c.req.param("id"));
455
- if (!meta) return notFound(c);
456
- return c.json({ session: summaryOf(meta) });
457
- });
458
- app.post("/api/sessions/:id/messages", async (c) => {
459
- const id = c.req.param("id");
460
- const meta = await ctx.sessions.getMeta(id);
461
- if (!meta) return notFound(c);
462
- if (meta.status === "ended") return c.json({ error: "session ended" }, 409);
463
- const parsed = postMessageReqSchema.safeParse(await readJson(c));
464
- if (!parsed.success) return badRequest(c, parsed.error.issues);
465
- const attachments = await resolveAttachments(ctx, id, parsed.data.attachmentIds);
466
- if (attachments === null) return c.json({ error: "unknown attachment" }, 400);
467
- const event = await ctx.sessions.postMessage(id, { text: parsed.data.text, attachments });
468
- return c.json({ event }, 201);
469
- });
470
- app.post("/api/sessions/:id/reply", async (c) => {
471
- const id = c.req.param("id");
472
- const meta = await ctx.sessions.getMeta(id);
473
- if (!meta) return notFound(c);
474
- if (meta.status === "ended") return c.json({ error: "session ended" }, 409);
475
- const parsed = replyReqSchema.safeParse(await readJson(c));
476
- if (!parsed.success) return badRequest(c, parsed.error.issues);
477
- const attachments = await resolveAttachments(ctx, id, parsed.data.attachmentIds);
478
- if (attachments === null) return c.json({ error: "unknown attachment" }, 400);
479
- const event = await ctx.sessions.reply(id, { text: parsed.data.text, attachments });
480
- ctx.presence.beat(id);
481
- return c.json({ event }, 201);
482
- });
483
- app.post("/api/sessions/:id/attachments", async (c) => {
484
- const id = c.req.param("id");
485
- const meta = await ctx.sessions.getMeta(id);
486
- if (!meta) return notFound(c);
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);
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
+ }
491
419
  }
492
- const body = await c.req.parseBody();
493
- const file = body.file;
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);
496
- const bytes = new Uint8Array(await file.arrayBuffer());
497
- if (bytes.byteLength === 0) return c.json({ error: "empty file" }, 400);
498
- const attachment = await ctx.sessions.saveAttachment(id, file.name || "file", bytes);
499
- return c.json({ attachment }, 201);
500
- });
501
- app.get("/api/sessions/:id/attachments/:attId", async (c) => {
502
- const file = await ctx.sessions.readAttachment(c.req.param("id"), c.req.param("attId"));
503
- if (!file) return notFound(c);
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);
511
- });
512
- app.post("/api/sessions/:id/annotations", async (c) => {
513
- const id = c.req.param("id");
514
- const meta = await ctx.sessions.getMeta(id);
515
- if (!meta) return notFound(c);
516
- if (meta.status === "ended") return c.json({ error: "session ended" }, 409);
517
- const parsed = postAnnotationsReqSchema.safeParse(await readJson(c));
518
- if (!parsed.success) return badRequest(c, parsed.error.issues);
519
- const items = await resolveAnnotationItems(ctx, id, parsed.data.items);
520
- if (items === null) return c.json({ error: "unknown attachment" }, 400);
521
- const event = await ctx.sessions.postAnnotations(id, items);
522
- return c.json({ event }, 201);
523
- });
524
- app.post("/api/sessions/:id/cards", async (c) => {
525
- const guard = await requireSpecActive(ctx, c);
526
- if (guard.res) return guard.res;
527
- const parsed = postQuestionCardReqSchema.safeParse(await readJson(c));
528
- if (!parsed.success) return badRequest(c, parsed.error.issues);
529
- const event = await ctx.sessions.postQuestionCard(guard.id, parsed.data);
530
- ctx.presence.beat(guard.id);
531
- return c.json({ event }, 201);
532
- });
533
- app.post("/api/sessions/:id/decisions", async (c) => {
534
- const guard = await requireSpecActive(ctx, c);
535
- if (guard.res) return guard.res;
536
- const parsed = postDecisionReqSchema.safeParse(await readJson(c));
537
- if (!parsed.success) return badRequest(c, parsed.error.issues);
538
- const event = await ctx.sessions.postDecision(guard.id, parsed.data);
539
- ctx.presence.beat(guard.id);
540
- return c.json({ event }, 201);
541
- });
542
- app.post("/api/sessions/:id/artifacts", async (c) => {
543
- const guard = await requireSpecActive(ctx, c);
544
- if (guard.res) return guard.res;
545
- const parsed = postArtifactReqSchema.safeParse(await readJson(c));
546
- if (!parsed.success) return badRequest(c, parsed.error.issues);
547
- const ref = await ctx.sessions.saveArtifact(
548
- guard.id,
549
- parsed.data.kind,
550
- parsed.data.name,
551
- parsed.data.content
552
- );
553
- const event = await ctx.sessions.postArtifact(guard.id, ref);
554
- ctx.presence.beat(guard.id);
555
- return c.json({ event }, 201);
556
- });
557
- app.post("/api/sessions/:id/issues", async (c) => {
558
- const id = c.req.param("id");
559
- const meta = await ctx.sessions.getMeta(id);
560
- if (!meta) return notFound(c);
561
- const parsed = postIssueReqSchema.safeParse(await readJson(c));
562
- if (!parsed.success) return badRequest(c, parsed.error.issues);
563
- const saved = await ctx.sessions.saveIssue(meta, parsed.data);
564
- if (!saved) return c.json({ error: "unknown attachment" }, 400);
565
- return c.json(saved, 201);
566
- });
567
- app.post("/api/sessions/:id/move-away", async (c) => {
568
- const id = c.req.param("id");
569
- const meta = await ctx.sessions.getMeta(id);
570
- if (!meta) return notFound(c);
571
- if (meta.status === "ended") return c.json({ error: "session ended" }, 409);
572
- const parsed = moveAwayReqSchema.safeParse(await readJson(c));
573
- if (!parsed.success) return badRequest(c, parsed.error.issues);
574
- const event = await ctx.sessions.moveAway(id, parsed.data.to);
575
- return c.json({ event }, 201);
576
- });
577
- app.get("/api/sessions/:id/poll", async (c) => {
578
- const id = c.req.param("id");
579
- const meta = await ctx.sessions.getMeta(id);
580
- if (!meta) return notFound(c);
581
- const since = parseNonNeg(c.req.query("since"), 0);
582
- const wait = meta.status === "ended" ? 0 : clamp(parseNonNeg(c.req.query("wait"), 25e3), 0, 3e4);
583
- ctx.presence.enter(id);
584
- let delivered = false;
585
- try {
586
- const res = await ctx.sessions.poll(id, since, wait, c.req.raw.signal);
587
- delivered = res.events.length > 0;
588
- return c.json({ ...res, ended: meta.status === "ended" });
589
- } finally {
590
- ctx.presence.leave(id, c.req.raw.signal.aborted, delivered);
591
- }
592
- });
593
- app.get("/api/sessions/:id/events", async (c) => {
594
- const id = c.req.param("id");
595
- const meta = await ctx.sessions.getMeta(id);
596
- if (!meta) return notFound(c);
597
- const since = parseNonNeg(c.req.query("since"), 0);
598
- const limitRaw = c.req.query("limit");
599
- const limit = limitRaw === void 0 ? void 0 : parseNonNeg(limitRaw, 0);
600
- return c.json(await ctx.sessions.events(id, since, limit));
601
- });
602
- app.get("/api/sessions/:id/attach", async (c) => {
603
- const id = c.req.param("id");
604
- const tail = clamp(parseNonNeg(c.req.query("tail"), 20), 0, 500);
605
- const digest = await ctx.sessions.attach(id, tail, ctx.presence.get(id));
606
- if (!digest) return notFound(c);
607
- return c.json(digest);
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
- });
622
- app.post("/api/sessions/:id/end", async (c) => {
623
- const id = c.req.param("id");
624
- const meta = await ctx.sessions.end(id);
625
- if (!meta) return notFound(c);
626
- ctx.hub.broadcast(id, { t: "ended", meta });
627
- return c.json({ session: summaryOf(meta) });
628
- });
629
- app.get(
630
- "/ws",
631
- upgradeWebSocket((c) => {
632
- const id = new URL(c.req.url).searchParams.get("s") ?? "";
633
- return {
634
- onOpen: (_evt, ws) => {
635
- openSocket(ctx, id, ws).catch(() => ws.close(1011, "internal error"));
636
- },
637
- onClose: (_evt, ws) => {
638
- ctx.hub.remove(id, ws);
639
- }
640
- };
641
- })
642
- );
643
- app.get("/api/sessions/:id/artifacts/:name", async (c) => {
644
- const id = c.req.param("id");
645
- const meta = await ctx.sessions.getMeta(id);
646
- if (!meta) return notFound(c);
647
- const name = c.req.param("name");
648
- const artifact = await ctx.sessions.readArtifact(id, name);
649
- if (!artifact) return notFound(c);
650
- const raw = c.req.query("raw");
651
- if (raw === "1" || raw === "true") {
652
- const type = name.toLowerCase().endsWith(".html") ? "text/html; charset=utf-8" : "text/plain; charset=utf-8";
653
- return c.body(artifact.content, 200, { "content-type": type, "cache-control": "no-cache" });
654
- }
655
- const html = await renderArtifactHtml(artifact.file, artifact.content);
656
- return c.body(html, 200, {
657
- "content-type": "text/html; charset=utf-8",
658
- "cache-control": "no-cache"
659
- });
660
- });
661
- app.get("/inject/annotate.js", createInjectHandler(ctx.injectDist));
662
- app.get("*", createStaticHandler(ctx.webDist));
663
- return { app, injectWebSocket };
664
- }
665
- async function openSocket(ctx, id, ws) {
666
- const meta = await ctx.sessions.getMeta(id);
667
- if (!meta) {
668
- ws.close(1008, "unknown session");
669
- return;
670
- }
671
- const first = ctx.hub.add(id, ws);
672
- if (first) {
673
- const log = await ctx.sessions.logFor(id);
674
- const unsubscribe = log.subscribe((event) => ctx.hub.broadcast(id, { t: "append", event }));
675
- ctx.hub.setForwarder(id, unsubscribe);
420
+ const parent = path4.dirname(dir);
421
+ if (parent === dir) return "0.0.0-dev";
422
+ dir = parent;
676
423
  }
677
- const hello = { t: "hello", meta, presence: ctx.presence.get(id) };
678
- ws.send(JSON.stringify(hello));
679
424
  }
680
- async function resolveAttachments(ctx, id, ids) {
681
- if (!ids || ids.length === 0) return [];
682
- const refs = [];
683
- for (const attId of ids) {
684
- const ref = await ctx.sessions.resolveAttachment(id, attId);
685
- if (!ref) return null;
686
- refs.push(ref);
687
- }
688
- return refs;
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
+ };
689
448
  }
690
- async function resolveAnnotationItems(ctx, id, items) {
691
- const resolved = [];
692
- for (const item of items) {
693
- let screenshot;
694
- if (item.screenshotAttachmentId) {
695
- const ref = await ctx.sessions.resolveAttachment(id, item.screenshotAttachmentId);
696
- if (!ref) return null;
697
- screenshot = ref;
698
- }
699
- resolved.push({
700
- ...item.note ? { note: item.note } : {},
701
- target: item.target,
702
- ...screenshot ? { screenshot } : {}
703
- });
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}`);
704
467
  }
705
- return resolved;
706
- }
707
- async function requireSpecActive(ctx, c) {
708
- const id = c.req.param("id") ?? "";
709
- const meta = await ctx.sessions.getMeta(id);
710
- if (!meta) return { id, res: notFound(c) };
711
- if (meta.status === "ended") return { id, res: c.json({ error: "session ended" }, 409) };
712
- if (meta.kind !== "spec") return { id, res: c.json({ error: "not a spec session" }, 400) };
713
- return { id, res: null };
714
- }
715
- async function readJson(c) {
716
- try {
717
- return await c.req.json();
718
- } catch {
719
- return {};
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
+ }
720
474
  }
475
+ return `${lines.join("\n")}
476
+ `;
721
477
  }
722
- function notFound(c) {
723
- return c.json({ error: "unknown session" }, 404);
724
- }
725
- function badRequest(c, issues) {
726
- return c.json({ error: "invalid request", issues }, 400);
727
- }
728
- function parseNonNeg(value, fallback) {
729
- if (value === void 0) return fallback;
730
- const n = Number(value);
731
- return Number.isFinite(n) && n >= 0 ? Math.floor(n) : fallback;
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
+ };
732
486
  }
733
- function clamp(n, min, max) {
734
- return Math.min(max, Math.max(min, n));
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;
735
490
  }
736
491
 
737
- // packages/server/src/lifecycle.ts
738
- import { unlinkSync } from "node:fs";
739
- import { open, readFile as readFile4, rm } from "node:fs/promises";
740
-
741
- // packages/server/src/sessions/paths.ts
742
- import { existsSync as existsSync3, realpathSync } from "node:fs";
743
- import { appendFile, mkdir, readFile as readFile3 } from "node:fs/promises";
744
- import path4 from "node:path";
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;
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";
786
498
  }
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;
499
+ var SessionLog = class {
500
+ constructor(id, file) {
501
+ this.id = id;
502
+ this.file = file;
796
503
  }
797
- return path4.join(realpathSync(existing), ...tail);
798
- }
799
- function sessionsDir(home) {
800
- return path4.join(home, "sessions");
801
- }
802
- function sessionDir(home, id) {
803
- return path4.join(sessionsDir(home), id);
804
- }
805
- function logPath(home, id) {
806
- return path4.join(sessionDir(home, id), "log.ndjson");
807
- }
808
- function attachmentsDir(home, id) {
809
- return path4.join(sessionDir(home, id), "attachments");
810
- }
811
- function artifactsDir(home, id) {
812
- return path4.join(sessionDir(home, id), "artifacts");
813
- }
814
- function issuesDir(home) {
815
- return path4.join(home, "issues");
816
- }
817
- function issueDir(home, id) {
818
- return path4.join(issuesDir(home), id);
819
- }
820
- function metaPath(home, id) {
821
- return path4.join(sessionDir(home, id), "meta.json");
822
- }
823
- function pidfilePath(home) {
824
- return path4.join(home, "server.json");
825
- }
826
- async function ensureChambaHome(home) {
827
- await mkdir(sessionsDir(home), { recursive: true });
828
- await ensureGitignored(home);
829
- }
830
- async function ensureGitignored(home) {
831
- if (path4.basename(home) !== ".chamba") return;
832
- const gitignore = path4.join(path4.dirname(home), ".gitignore");
833
- const entry = ".chamba/";
834
- try {
835
- let content = "";
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();
836
517
  try {
837
- content = await readFile3(gitignore, "utf8");
838
- } catch (err) {
839
- if (err.code !== "ENOENT") return;
518
+ await this.loading;
519
+ } finally {
520
+ this.loading = null;
840
521
  }
841
- const already = content.split(/\r?\n/).some((line) => {
842
- const trimmed = line.trim();
843
- return trimmed === entry || trimmed === ".chamba";
844
- });
845
- if (already) return;
846
- const prefix = content.length > 0 && !content.endsWith("\n") ? "\n" : "";
847
- await appendFile(gitignore, `${prefix}${entry}
848
- `, "utf8");
849
- } catch {
850
- }
851
- }
852
-
853
- // packages/server/src/lifecycle.ts
854
- var ServerConflictError = class extends Error {
855
- constructor(existing) {
856
- super(`chamba server already running (pid ${existing.pid}, port ${existing.port})`);
857
- this.existing = existing;
858
- this.name = "ServerConflictError";
859
522
  }
860
- };
861
- async function acquirePidfile(home, port) {
862
- const file = pidfilePath(home);
863
- const info = { pid: process.pid, port, startedAt: (/* @__PURE__ */ new Date()).toISOString() };
864
- for (let attempt = 0; attempt < 3; attempt++) {
523
+ async replay() {
524
+ await mkdir(path5.dirname(this.file), { recursive: true });
525
+ this.events = [];
526
+ let content = "";
865
527
  try {
866
- const handle = await open(file, "wx");
867
- await handle.writeFile(`${JSON.stringify(info, null, 2)}
868
- `);
869
- await handle.close();
870
- return makeHandle(file, info.pid);
528
+ content = await readFile2(this.file, "utf8");
871
529
  } catch (err) {
872
- if (err.code !== "EEXIST") throw err;
873
- const existing = await readPidfile(file);
874
- if (existing && existing.pid !== process.pid && await isServerLive(existing)) {
875
- throw new ServerConflictError(existing);
876
- }
877
- await rm(file, { force: true });
878
- }
879
- }
880
- throw new Error("could not acquire chamba pidfile");
881
- }
882
- function makeHandle(file, ownerPid) {
883
- let released = false;
884
- const releaseSync = () => {
885
- if (released) return;
886
- released = true;
887
- try {
888
- unlinkSync(file);
889
- } catch {
530
+ if (err.code !== "ENOENT") throw err;
890
531
  }
891
- };
892
- process.once("exit", releaseSync);
893
- return {
894
- release: async () => {
895
- if (released) return;
896
- released = true;
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
+ }
897
543
  try {
898
- const current = await readPidfile(file);
899
- if (!current || current.pid === ownerPid) await rm(file, { force: true });
900
- } catch {
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;
901
553
  }
902
554
  }
903
- };
904
- }
905
- async function readPidfile(file) {
906
- try {
907
- const raw = await readFile4(file, "utf8");
908
- const parsed = JSON.parse(raw);
909
- if (typeof parsed.pid === "number" && typeof parsed.port === "number") return parsed;
910
- return null;
911
- } catch {
912
- return null;
555
+ if (truncateTo !== null) await truncate3(this.file, truncateTo);
556
+ this.handle = await open(this.file, "a");
557
+ this.loaded = true;
913
558
  }
914
- }
915
- function isPidAlive(pid) {
916
- try {
917
- process.kill(pid, 0);
918
- return true;
919
- } catch (err) {
920
- return err.code === "EPERM";
559
+ /** Total events == next seq to be assigned == the "caught up" cursor. */
560
+ get nextSeq() {
561
+ return this.events.length;
921
562
  }
922
- }
923
- async function isServerLive(info) {
924
- if (!isPidAlive(info.pid)) return false;
925
- try {
926
- const controller = new AbortController();
927
- const timer = setTimeout(() => controller.abort(), 500);
928
- const res = await fetch(`http://127.0.0.1:${info.port}/api/health`, {
929
- signal: controller.signal
930
- });
931
- clearTimeout(timer);
932
- return res.ok;
933
- } catch {
934
- return false;
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];
935
567
  }
936
- }
937
- function startIdleWatchdog(opts) {
938
- if (opts.idleMs <= 0) return () => void 0;
939
- const interval = opts.intervalMs ?? Math.min(opts.idleMs, 5e3);
940
- let idleSince = Date.now();
941
- const timer = setInterval(() => {
942
- if (opts.isBusy()) {
943
- idleSince = Date.now();
944
- return;
945
- }
946
- if (Date.now() - idleSince >= opts.idleMs) opts.onIdle();
947
- }, interval);
948
- timer.unref?.();
949
- return () => clearInterval(timer);
950
- }
951
-
952
- // packages/server/src/port.ts
953
- var DEFAULT_PORT = 4319;
954
- var DEFAULT_BIND_HOST = "127.0.0.1";
955
- function resolveBindHost(argv = process.argv.slice(2)) {
956
- const flag = readFlag(argv, "--host");
957
- if (flag !== void 0 && flag.length > 0) return flag;
958
- const env = process.env.CHAMBA_HOST;
959
- if (env !== void 0 && env.length > 0) return env;
960
- return DEFAULT_BIND_HOST;
961
- }
962
- function resolvePort(argv = process.argv.slice(2), home) {
963
- const flag = readFlag(argv, "--port");
964
- if (flag !== void 0) return parsePort(flag, "--port");
965
- const env = process.env.CHAMBA_PORT;
966
- if (env !== void 0 && env.length > 0) return parsePort(env, "CHAMBA_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);
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);
974
572
  }
975
- return DEFAULT_PORT + Math.abs(hash) % 512;
976
- }
977
- function readFlag(argv, name) {
978
- for (let i = 0; i < argv.length; i++) {
979
- const arg = argv[i];
980
- if (arg === name) return argv[i + 1];
981
- if (arg?.startsWith(`${name}=`)) return arg.slice(name.length + 1);
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));
982
577
  }
983
- return void 0;
984
- }
985
- function parsePort(value, source) {
986
- const port = Number(value);
987
- if (!Number.isInteger(port) || port < 1 || port > 65535) {
988
- throw new Error(`invalid ${source}: ${value}`);
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;
989
585
  }
990
- return port;
991
- }
992
-
993
- // packages/server/src/presence.ts
994
- var PresenceTracker = class {
995
586
  /**
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.
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.
1001
592
  */
1002
- constructor(idleAwayMs = 30 * 6e4, dropAwayMs = 1e4) {
1003
- this.idleAwayMs = idleAwayMs;
1004
- this.dropAwayMs = dropAwayMs;
1005
- }
1006
- active = /* @__PURE__ */ new Map();
1007
- lastSeen = /* @__PURE__ */ new Map();
1008
- state = /* @__PURE__ */ new Map();
1009
- timers = /* @__PURE__ */ new Map();
1010
- listeners = /* @__PURE__ */ new Set();
1011
- get(id) {
1012
- return this.state.get(id) ?? "away";
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;
1013
616
  }
1014
- /** A poll has parked: the agent is definitely present. */
1015
- enter(id) {
1016
- this.active.set(id, (this.active.get(id) ?? 0) + 1);
1017
- this.lastSeen.set(id, Date.now());
1018
- this.clearTimer(id);
1019
- this.set(id, "listening");
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);
1020
621
  }
1021
622
  /**
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) {
1030
- const remaining = Math.max(0, (this.active.get(id) ?? 1) - 1);
1031
- this.active.set(id, remaining);
1032
- this.lastSeen.set(id, Date.now());
1033
- if (remaining > 0) return;
1034
- if (delivered && !dropped) this.set(id, "working");
1035
- this.scheduleAway(id, dropped ? this.dropAwayMs : this.idleAwayMs);
1036
- }
1037
- /** Any other sign of life (e.g. a reply) keeps the agent listening. */
1038
- beat(id) {
1039
- this.lastSeen.set(id, Date.now());
1040
- this.set(id, "listening");
1041
- if ((this.active.get(id) ?? 0) === 0) this.scheduleAway(id, this.idleAwayMs);
1042
- }
1043
- /** True while any session has a parked poll - used to hold off idle shutdown. */
1044
- hasActivePolls() {
1045
- for (const count of this.active.values()) {
1046
- if (count > 0) return true;
1047
- }
1048
- return false;
1049
- }
1050
- onChange(listener) {
1051
- this.listeners.add(listener);
1052
- return () => this.listeners.delete(listener);
1053
- }
1054
- scheduleAway(id, delayMs) {
1055
- this.clearTimer(id);
1056
- const timer = setTimeout(() => {
1057
- this.timers.delete(id);
1058
- if ((this.active.get(id) ?? 0) === 0) this.set(id, "away");
1059
- }, delayMs);
1060
- timer.unref?.();
1061
- this.timers.set(id, timer);
1062
- }
1063
- clearTimer(id) {
1064
- const timer = this.timers.get(id);
1065
- if (timer) {
1066
- clearTimeout(timer);
1067
- this.timers.delete(id);
1068
- }
1069
- }
1070
- set(id, next) {
1071
- if (this.state.get(id) === next) return;
1072
- this.state.set(id, next);
1073
- for (const listener of [...this.listeners]) listener(id, next);
1074
- }
1075
- };
1076
-
1077
- // packages/server/src/sessions/index.ts
1078
- import { randomBytes, randomUUID as randomUUID3 } from "node:crypto";
1079
- import { mkdir as mkdir3, readdir as readdir2, readFile as readFile7, stat, writeFile as writeFile2 } from "node:fs/promises";
1080
- import path7 from "node:path";
1081
-
1082
- // packages/server/src/version.ts
1083
- import { existsSync as existsSync4, readFileSync } from "node:fs";
1084
- import path5 from "node:path";
1085
- import { fileURLToPath as fileURLToPath3 } from "node:url";
1086
- function chambaVersion() {
1087
- let dir = path5.dirname(fileURLToPath3(import.meta.url));
1088
- for (; ; ) {
1089
- const manifest = path5.join(dir, "package.json");
1090
- if (existsSync4(manifest)) {
1091
- try {
1092
- const { version } = JSON.parse(readFileSync(manifest, "utf8"));
1093
- if (typeof version === "string" && version) return version;
1094
- } catch {
1095
- }
1096
- }
1097
- const parent = path5.dirname(dir);
1098
- if (parent === dir) return "0.0.0-dev";
1099
- dir = parent;
1100
- }
1101
- }
1102
-
1103
- // packages/server/src/issues.ts
1104
- function buildIssueRecord(input) {
1105
- return {
1106
- id: input.id,
1107
- type: input.type,
1108
- text: input.text,
1109
- filedAt: input.filedAt,
1110
- chambaVersion: chambaVersion(),
1111
- session: {
1112
- id: input.meta.id,
1113
- kind: input.meta.kind ?? "unset",
1114
- status: input.meta.status,
1115
- ...input.meta.title ? { title: input.meta.title } : {}
1116
- },
1117
- env: {
1118
- node: process.version,
1119
- platform: process.platform,
1120
- arch: process.arch,
1121
- cwd: process.cwd()
1122
- },
1123
- screenshots: input.screenshots,
1124
- recentEvents: input.tail.map(summarizeEvent)
1125
- };
1126
- }
1127
- function renderIssueMarkdown(record) {
1128
- const lines = [
1129
- `# ${record.type === "bug" ? "Bug" : "Feature request"}`,
1130
- "",
1131
- record.text,
1132
- "",
1133
- "## Context",
1134
- "",
1135
- `- chamba version: ${record.chambaVersion}`,
1136
- `- session: ${record.session.id} (${record.session.kind}, ${record.session.status})`,
1137
- ...record.session.title ? [`- session title: ${record.session.title}`] : [],
1138
- `- environment: node ${record.env.node} on ${record.env.platform}/${record.env.arch}`,
1139
- `- cwd: ${record.env.cwd}`,
1140
- `- filed: ${record.filedAt}`
1141
- ];
1142
- if (record.screenshots.length > 0) {
1143
- lines.push("", "### Screenshots", "");
1144
- for (const name of record.screenshots) lines.push(`- ${name}`);
1145
- }
1146
- if (record.recentEvents.length > 0) {
1147
- lines.push("", "### Recent session events", "");
1148
- for (const event of record.recentEvents) {
1149
- const text = event.text ? `: ${event.text}` : "";
1150
- lines.push(`- [${event.seq}] ${event.ts} ${event.source}/${event.type}${text}`);
1151
- }
1152
- }
1153
- return `${lines.join("\n")}
1154
- `;
1155
- }
1156
- function summarizeEvent(event) {
1157
- return {
1158
- seq: event.seq,
1159
- ts: event.ts,
1160
- source: event.source,
1161
- type: event.type,
1162
- ...eventText(event) ? { text: truncate(eventText(event), 200) } : {}
1163
- };
1164
- }
1165
- function eventText(event) {
1166
- const payload = event.payload;
1167
- if (payload && typeof payload === "object") {
1168
- const record = payload;
1169
- if (typeof record.text === "string") return record.text;
1170
- if (typeof record.reason === "string") return record.reason;
1171
- }
1172
- return "";
1173
- }
1174
- function truncate(text, max) {
1175
- const oneLine = text.replace(/\s+/g, " ").trim();
1176
- return oneLine.length > max ? `${oneLine.slice(0, max - 1)}\u2026` : oneLine;
1177
- }
1178
-
1179
- // packages/server/src/sessions/log.ts
1180
- import { randomUUID } from "node:crypto";
1181
- import { mkdir as mkdir2, open as open2, readFile as readFile5, truncate as truncate2 } from "node:fs/promises";
1182
- import path6 from "node:path";
1183
- var SessionLog = class {
1184
- constructor(id, file) {
1185
- this.id = id;
1186
- this.file = file;
1187
- }
1188
- events = [];
1189
- handle = null;
1190
- loaded = false;
1191
- loading = null;
1192
- mutex = Promise.resolve();
1193
- listeners = /* @__PURE__ */ new Set();
1194
- /** Replay the ndjson file into memory and open the append handle. Idempotent. */
1195
- async load() {
1196
- if (this.loaded) return;
1197
- if (this.loading) return this.loading;
1198
- this.loading = this.replay();
1199
- try {
1200
- await this.loading;
1201
- } finally {
1202
- this.loading = null;
1203
- }
1204
- }
1205
- async replay() {
1206
- await mkdir2(path6.dirname(this.file), { recursive: true });
1207
- this.events = [];
1208
- let content = "";
1209
- try {
1210
- content = await readFile5(this.file, "utf8");
1211
- } catch (err) {
1212
- if (err.code !== "ENOENT") throw err;
1213
- }
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
- }
1236
- }
1237
- if (truncateTo !== null) await truncate2(this.file, truncateTo);
1238
- this.handle = await open2(this.file, "a");
1239
- this.loaded = true;
1240
- }
1241
- /** Total events == next seq to be assigned == the "caught up" cursor. */
1242
- get nextSeq() {
1243
- return this.events.length;
1244
- }
1245
- /** Every event with `seq >= since`, in order. Pure read from memory. */
1246
- readSince(since) {
1247
- if (since <= 0) return this.events.slice();
1248
- return this.events.slice(since);
1249
- }
1250
- /** The last `n` events (for the catch-up digest). */
1251
- tail(n) {
1252
- if (n <= 0) return [];
1253
- return this.events.slice(Math.max(0, this.events.length - n));
1254
- }
1255
- /** True if a qualifying (non-agent) event exists at or beyond `since`. */
1256
- hasIncoming(since) {
1257
- for (let i = Math.max(0, since); i < this.events.length; i++) {
1258
- const event = this.events[i];
1259
- if (event && event.source !== "agent") return true;
1260
- }
1261
- return false;
1262
- }
1263
- /**
1264
- * Append one event durably. Serialized so seq assignment and the write can
1265
- * never interleave; resolves only after `fsync`. Listeners (WS broadcast,
1266
- * presence, parked polls) fire synchronously right after the in-memory push,
1267
- * with no `await` in between - so a poller that just read `events` and is about
1268
- * to register cannot miss the wakeup.
1269
- */
1270
- async append(input) {
1271
- await this.load();
1272
- const run = this.mutex.then(async () => {
1273
- const handle = this.handle;
1274
- if (!handle) throw new Error("session log not open");
1275
- const event = {
1276
- id: randomUUID(),
1277
- seq: this.events.length,
1278
- sessionId: this.id,
1279
- ts: (/* @__PURE__ */ new Date()).toISOString(),
1280
- type: input.type,
1281
- source: input.source,
1282
- payload: input.payload
1283
- };
1284
- await handle.write(`${JSON.stringify(event)}
1285
- `);
1286
- await handle.sync();
1287
- this.events.push(event);
1288
- this.emit(event);
1289
- return event;
1290
- });
1291
- this.mutex = run.catch(() => void 0);
1292
- return run;
1293
- }
1294
- /** Subscribe to every future append (WS/presence). Returns an unsubscribe. */
1295
- subscribe(listener) {
1296
- this.listeners.add(listener);
1297
- return () => this.listeners.delete(listener);
1298
- }
1299
- /**
1300
- * Resolve on the next append or when `timeoutMs`/`signal` fires. Registration
1301
- * is synchronous (in the Promise executor), so callers can qualify-check then
1302
- * await this with no yield between - no lost wakeup.
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.
1303
626
  */
1304
627
  waitForAppend(timeoutMs, signal) {
1305
628
  return new Promise((resolve) => {
@@ -1324,451 +647,1037 @@ var SessionLog = class {
1324
647
  emit(event) {
1325
648
  for (const listener of [...this.listeners]) listener(event);
1326
649
  }
1327
- async close() {
1328
- const handle = this.handle;
1329
- this.handle = null;
1330
- this.loaded = false;
1331
- await handle?.close();
1332
- }
1333
650
  };
1334
651
 
1335
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
1336
656
  import { randomUUID as randomUUID2 } from "node:crypto";
1337
- import { readdir, readFile as readFile6, rename, rm as rm2, writeFile } from "node:fs/promises";
1338
- async function readMeta(home, id) {
1339
- try {
1340
- const raw = await readFile6(metaPath(home, id), "utf8");
1341
- return sessionMetaSchema.parse(JSON.parse(raw));
1342
- } catch (err) {
1343
- if (err.code === "ENOENT") return null;
1344
- throw err;
1345
- }
1346
- }
1347
- async function writeMeta(home, meta) {
1348
- const target = metaPath(home, meta.id);
1349
- const tmp = `${target}.${randomUUID2()}.tmp`;
657
+ import { rename, rm, writeFile } from "node:fs/promises";
658
+ async function writeJsonAtomic(file, value) {
659
+ const tmp = `${file}.${randomUUID2()}.tmp`;
1350
660
  try {
1351
- await writeFile(tmp, `${JSON.stringify(meta, null, 2)}
661
+ await writeFile(tmp, `${JSON.stringify(value, null, 2)}
1352
662
  `, "utf8");
1353
- await rename(tmp, target);
663
+ await rename(tmp, file);
1354
664
  } catch (err) {
1355
- await rm2(tmp, { force: true }).catch(() => void 0);
665
+ await rm(tmp, { force: true }).catch(() => void 0);
1356
666
  throw err;
1357
667
  }
1358
668
  }
1359
- async function listSessionIds(home) {
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);
1360
1393
  try {
1361
- const entries = await readdir(sessionsDir(home), { withFileTypes: true });
1362
- return entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name);
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);
1363
1401
  } catch (err) {
1364
- if (err.code === "ENOENT") return [];
1402
+ if (err instanceof ReplyTargetError) return c.json({ error: err.message }, 400);
1365
1403
  throw err;
1366
1404
  }
1367
1405
  }
1368
-
1369
- // packages/server/src/sessions/index.ts
1370
- var Sessions = class {
1371
- constructor(home) {
1372
- this.home = home;
1373
- }
1374
- logs = /* @__PURE__ */ new Map();
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
- }
1396
- async init() {
1397
- await ensureChambaHome(this.home);
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);
1398
1413
  }
1399
- async create(req) {
1400
- await ensureChambaHome(this.home);
1401
- const id = await this.generateId();
1402
- const now = (/* @__PURE__ */ new Date()).toISOString();
1403
- const meta = {
1404
- id,
1405
- ...req.kind ? { kind: req.kind } : {},
1406
- ...req.title ? { title: req.title } : {},
1407
- status: "active",
1408
- createdAt: now,
1409
- updatedAt: now
1410
- };
1411
- await this.logFor(id);
1412
- await writeMeta(this.home, meta);
1413
- this.metaCache.set(id, meta);
1414
- await this.append(id, {
1415
- type: "system",
1416
- source: "system",
1417
- payload: { reason: "session-created" }
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 } : {}
1418
1431
  });
1419
- return await this.getMeta(id) ?? meta;
1420
1432
  }
1421
- async getMeta(id) {
1422
- const cached = this.metaCache.get(id);
1423
- if (cached) return cached;
1424
- const meta = await readMeta(this.home, id);
1425
- if (meta) this.metaCache.set(id, meta);
1426
- return meta;
1433
+ return resolved;
1434
+ }
1435
+ async function readJson(c) {
1436
+ try {
1437
+ return await c.req.json();
1438
+ } catch {
1439
+ return {};
1427
1440
  }
1428
- async list() {
1429
- const ids = await listSessionIds(this.home);
1430
- const metas = await Promise.all(ids.map((id) => this.getMeta(id)));
1431
- return metas.filter((meta) => meta !== null).sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
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";
1432
1465
  }
1433
- /** Get (or lazily create + replay) the in-memory log for a session. */
1434
- async logFor(id) {
1435
- let log = this.logs.get(id);
1436
- if (!log) {
1437
- log = new SessionLog(id, logPath(this.home, id));
1438
- this.logs.set(id, log);
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 });
1439
1485
  }
1440
- await log.load();
1441
- return log;
1442
- }
1443
- /** Durable append; also bumps the session's `updatedAt`. */
1444
- async append(id, input) {
1445
- const log = await this.logFor(id);
1446
- const event = await log.append(input);
1447
- await this.touch(id);
1448
- return event;
1449
- }
1450
- postMessage(id, body) {
1451
- return this.append(id, { type: "message", source: "user", payload: messagePayload(body) });
1452
1486
  }
1453
- reply(id, body) {
1454
- return this.append(id, { type: "message", source: "agent", payload: messagePayload(body) });
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;
1455
1520
  }
1456
- /** Append a batch of element comments as one `annotation-batch` event (source
1457
- * `user`, so the agent's poll delivers it like any incoming line). */
1458
- postAnnotations(id, items) {
1459
- return this.append(id, { type: ANNOTATION_BATCH, source: "user", payload: { items } });
1521
+ }
1522
+ function isPidAlive(pid) {
1523
+ try {
1524
+ process.kill(pid, 0);
1525
+ return true;
1526
+ } catch (err) {
1527
+ return err.code === "EPERM";
1460
1528
  }
1461
- /** A structured interview question, rendered as a card in the conversation. The
1462
- * agent authors these, so the source is `agent` (like a reply); the user's
1463
- * answer comes back as a normal `message`. */
1464
- postQuestionCard(id, payload) {
1465
- return this.append(id, { type: QUESTION_CARD, source: "agent", payload });
1466
- }
1467
- /** A decision-log entry (confirmed-by-user vs. assumed-by-agent). Agent-sourced. */
1468
- postDecision(id, payload) {
1469
- return this.append(id, { type: DECISION, source: "agent", payload });
1470
- }
1471
- /** Announce an authored/revised artifact. The bytes were written by
1472
- * `saveArtifact`; this records the reference as an event so the surface tab and
1473
- * the thread learn about the revision. Agent-sourced. */
1474
- postArtifact(id, artifact) {
1475
- return this.append(id, { type: ARTIFACT, source: "agent", payload: { artifact } });
1476
- }
1477
- /** Record that the user activated another session, so the agent's poll on this
1478
- * one learns where to hand off (attach the target, digest, keep attending). */
1479
- moveAway(id, to) {
1480
- return this.append(id, {
1481
- type: "system",
1482
- source: "system",
1483
- payload: { reason: "user-moved-away", ...to ? { to } : {} }
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
1484
1537
  });
1538
+ clearTimeout(timer);
1539
+ return res.ok;
1540
+ } catch {
1541
+ return false;
1485
1542
  }
1486
- /**
1487
- * Persist an uploaded file into the session's attachments directory and return
1488
- * its durable reference. The stored name embeds a generated id and the sanitized
1489
- * original (`<attId>__<name>`), so the file is self-describing on disk and
1490
- * resolvable later without a sidecar; the recorded path stays session-relative.
1491
- */
1492
- async saveAttachment(id, name, bytes) {
1493
- const dir = attachmentsDir(this.home, id);
1494
- await mkdir3(dir, { recursive: true });
1495
- const attId = randomUUID3();
1496
- const safeName = sanitizeName(name);
1497
- const stored = `${attId}__${safeName}`;
1498
- await writeFile2(path7.join(dir, stored), bytes);
1499
- return {
1500
- id: attId,
1501
- name: safeName,
1502
- relPath: `attachments/${stored}`,
1503
- contentType: contentTypeFor(safeName),
1504
- size: bytes.byteLength
1505
- };
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);
1506
1581
  }
1507
- /** Rebuild an attachment reference from disk by its id (null if absent). */
1508
- async resolveAttachment(id, attId) {
1509
- const stored = await this.storedAttachment(id, attId);
1510
- if (!stored) return null;
1511
- const info = await stat(path7.join(attachmentsDir(this.home, id), stored));
1512
- return {
1513
- id: attId,
1514
- name: stored.slice(attId.length + 2),
1515
- relPath: `attachments/${stored}`,
1516
- contentType: contentTypeFor(stored),
1517
- size: info.size
1518
- };
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);
1519
1589
  }
1520
- /** Read an attachment's bytes and content type for serving (null if absent).
1521
- * The bytes are copied into a plain `ArrayBuffer`-backed view so the response
1522
- * layer can stream them directly (a `Buffer` widens to `ArrayBufferLike`). */
1523
- async readAttachment(id, attId) {
1524
- const stored = await this.storedAttachment(id, attId);
1525
- if (!stored) return null;
1526
- const raw = await readFile7(path7.join(attachmentsDir(this.home, id), stored));
1527
- const bytes = new Uint8Array(new ArrayBuffer(raw.byteLength));
1528
- bytes.set(raw);
1529
- return { bytes, contentType: contentTypeFor(stored) };
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}`);
1530
1596
  }
1597
+ return port;
1598
+ }
1599
+
1600
+ // packages/server/src/presence.ts
1601
+ var PresenceTracker = class {
1531
1602
  /**
1532
- * Persist an artifact's source text under the session's `artifacts/` directory
1533
- * and return its durable reference. Re-posting the same (kind, name) overwrites
1534
- * the file - the latest content is what the surface tab and export use, while
1535
- * the event log preserves that a revision happened. The stored filename embeds
1536
- * the kind and a kind-specific extension (`draft-*.md`, `mock-*.html`,
1537
- * `diagram-*.mmd`), so a file is self-describing and the serve route can infer
1538
- * how to render it without a sidecar.
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.
1539
1608
  */
1540
- async saveArtifact(id, kind, name, content) {
1541
- const dir = artifactsDir(this.home, id);
1542
- await mkdir3(dir, { recursive: true });
1543
- const ext = ARTIFACT_EXT[kind];
1544
- const base = sanitizeName(name).replace(/\.(md|markdown|html?|mmd|mermaid)$/i, "");
1545
- const stored = `${kind}-${base || "spec"}${ext}`;
1546
- const bytes = Buffer.from(content, "utf8");
1547
- await writeFile2(path7.join(dir, stored), bytes);
1548
- return {
1549
- kind,
1550
- name,
1551
- relPath: `artifacts/${stored}`,
1552
- contentType: ARTIFACT_CONTENT_TYPE[kind],
1553
- size: bytes.byteLength
1554
- };
1555
- }
1556
- /** Read a stored artifact by its on-disk filename (basename of a ref's
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. */
1559
- async readArtifact(id, file) {
1560
- if (!isSafeId(id)) return null;
1561
- if (!/^[A-Za-z0-9._-]+$/.test(file) || file.includes("..")) return null;
1562
- try {
1563
- const content = await readFile7(path7.join(artifactsDir(this.home, id), file), "utf8");
1564
- return { content, file };
1565
- } catch {
1566
- return null;
1567
- }
1609
+ constructor(idleAwayMs = 30 * 6e4, dropAwayMs = 1e4) {
1610
+ this.idleAwayMs = idleAwayMs;
1611
+ this.dropAwayMs = dropAwayMs;
1568
1612
  }
1569
- /**
1570
- * File an offline issue report under `.chamba/issues/<id>/`. Referenced
1571
- * screenshots are copied in as self-contained files, then `report.md` and
1572
- * `context.json` capture the report plus auto-collected context (chamba
1573
- * version, the session, a tail of recent events, basic env). Returns the new
1574
- * id and the absolute directory, or `null` if a referenced screenshot id is
1575
- * unknown for this session. Deliberately never appends to the log - an issue is
1576
- * an on-disk artifact, not a conversation event.
1577
- */
1578
- async saveIssue(meta, req) {
1579
- const resolved = [];
1580
- for (const attId of req.screenshotAttachmentIds ?? []) {
1581
- const ref = await this.resolveAttachment(meta.id, attId);
1582
- const file = await this.readAttachment(meta.id, attId);
1583
- if (!ref || !file) return null;
1584
- const ext = path7.extname(ref.name) || extFromContentType(file.contentType);
1585
- resolved.push({ name: `screenshot-${resolved.length + 1}${ext}`, bytes: file.bytes });
1586
- }
1587
- const id = await this.generateId();
1588
- const dir = issueDir(this.home, id);
1589
- await mkdir3(dir, { recursive: true });
1590
- for (const shot of resolved) await writeFile2(path7.join(dir, shot.name), shot.bytes);
1591
- const tail = (await this.logFor(meta.id)).tail(ISSUE_TAIL);
1592
- const record = buildIssueRecord({
1593
- id,
1594
- type: req.type,
1595
- text: req.text,
1596
- filedAt: (/* @__PURE__ */ new Date()).toISOString(),
1597
- meta,
1598
- tail,
1599
- screenshots: resolved.map((shot) => shot.name)
1600
- });
1601
- await writeFile2(path7.join(dir, "report.md"), renderIssueMarkdown(record), "utf8");
1602
- await writeFile2(path7.join(dir, "context.json"), `${JSON.stringify(record, null, 2)}
1603
- `, "utf8");
1604
- return { id, path: dir };
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";
1605
1621
  }
1606
- /** The on-disk filename for an attachment id, or null. The id is a uuid (no
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. */
1610
- async storedAttachment(id, attId) {
1611
- if (!isSafeId(id)) return null;
1612
- if (!/^[0-9a-f-]+$/i.test(attId)) return null;
1613
- let entries;
1614
- try {
1615
- entries = await readdir2(attachmentsDir(this.home, id));
1616
- } catch {
1617
- return null;
1618
- }
1619
- return entries.find((entry) => entry.startsWith(`${attId}__`)) ?? null;
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");
1620
1627
  }
1621
1628
  /**
1622
- * The agent long-poll: return every incoming (non-agent) event at or beyond
1623
- * `since`, parking until one arrives or the wait elapses. The sync
1624
- * qualify-check and waiter registration have no `await` between them, so an
1625
- * append concurrent with a poll can never be missed.
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.
1626
1635
  */
1627
- async poll(id, since, waitMs, signal) {
1628
- const log = await this.logFor(id);
1629
- const deadline = Date.now() + waitMs;
1630
- while (true) {
1631
- if (log.hasIncoming(since)) {
1632
- const events = log.readSince(since).filter((event) => event.source !== "agent");
1633
- return { events, cursor: log.nextSeq };
1634
- }
1635
- const remaining = deadline - Date.now();
1636
- if (remaining <= 0 || signal?.aborted) return { events: [], cursor: log.nextSeq };
1637
- await log.waitForAppend(remaining, signal);
1638
- }
1639
- }
1640
- /** Non-blocking history read (all sources), optionally capped. */
1641
- async events(id, since, limit) {
1642
- const log = await this.logFor(id);
1643
- const all = log.readSince(since);
1644
- const events = limit && limit > 0 ? all.slice(0, limit) : all;
1645
- return { events, cursor: log.nextSeq };
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);
1646
1642
  }
1647
- /** The catch-up digest: metadata, a recent tail, the poll cursor, how to get more. */
1648
- async attach(id, tailN, presence) {
1649
- const meta = await this.getMeta(id);
1650
- if (!meta) return null;
1651
- const log = await this.logFor(id);
1652
- const tail = log.tail(tailN);
1653
- const earliest = tail.length > 0 ? tail[0]?.seq ?? 0 : log.nextSeq;
1654
- return {
1655
- meta,
1656
- presence,
1657
- tail,
1658
- cursor: log.nextSeq,
1659
- fetchMore: {
1660
- hasMore: earliest > 0,
1661
- earliestLoadedSeq: earliest,
1662
- hint: earliest > 0 ? `chamba events --since 0 (older history before seq ${earliest})` : "none"
1663
- }
1664
- };
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);
1665
1647
  }
1666
- /** End a session: append the lifecycle marker, flip status to `ended`. */
1667
- async end(id) {
1668
- const meta = await this.getMeta(id);
1669
- if (!meta) return null;
1670
- if (meta.status === "ended") return meta;
1671
- await this.append(id, {
1672
- type: "system",
1673
- source: "system",
1674
- payload: { reason: "session-ended" }
1675
- });
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
- });
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;
1687
1654
  }
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) {
1696
- const meta = await this.getMeta(id);
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
- });
1655
+ onChange(listener) {
1656
+ this.listeners.add(listener);
1657
+ return () => this.listeners.delete(listener);
1712
1658
  }
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
- });
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);
1721
1667
  }
1722
- async generateId() {
1723
- for (let attempt = 0; attempt < 5; attempt++) {
1724
- const id = `${Date.now().toString(36)}${randomBytes(3).toString("hex")}`;
1725
- if (await readMeta(this.home, id) === null) return id;
1668
+ clearTimer(id) {
1669
+ const timer = this.timers.get(id);
1670
+ if (timer) {
1671
+ clearTimeout(timer);
1672
+ this.timers.delete(id);
1726
1673
  }
1727
- return randomUUID3();
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);
1728
1679
  }
1729
1680
  };
1730
- function isSafeId(id) {
1731
- return /^[A-Za-z0-9_-]+$/.test(id);
1732
- }
1733
- var ISSUE_TAIL = 20;
1734
- function extFromContentType(contentType) {
1735
- const type = contentType.split(";")[0]?.trim().toLowerCase();
1736
- switch (type) {
1737
- case "image/png":
1738
- return ".png";
1739
- case "image/jpeg":
1740
- return ".jpg";
1741
- case "image/gif":
1742
- return ".gif";
1743
- case "image/webp":
1744
- return ".webp";
1745
- case "image/svg+xml":
1746
- return ".svg";
1747
- default:
1748
- return ".bin";
1749
- }
1750
- }
1751
- var ARTIFACT_EXT = {
1752
- draft: ".md",
1753
- mock: ".html",
1754
- diagram: ".mmd"
1755
- };
1756
- var ARTIFACT_CONTENT_TYPE = {
1757
- draft: "text/markdown; charset=utf-8",
1758
- mock: "text/html; charset=utf-8",
1759
- diagram: "text/plain; charset=utf-8"
1760
- };
1761
- function messagePayload(body) {
1762
- return {
1763
- ...body.text ? { text: body.text } : {},
1764
- ...body.attachments && body.attachments.length > 0 ? { attachments: body.attachments } : {}
1765
- };
1766
- }
1767
- function sanitizeName(name) {
1768
- const base = name.split(/[\\/]/).pop() ?? name;
1769
- const cleaned = base.replace(/[^A-Za-z0-9._-]/g, "_").replace(/^\.+/, "");
1770
- return cleaned.length > 0 ? cleaned.slice(0, 120) : "file";
1771
- }
1772
1681
 
1773
1682
  // packages/server/src/ws.ts
1774
1683
  var WsHub = class {
@@ -1828,6 +1737,7 @@ async function serve(options = {}) {
1828
1737
  await sessions.init();
1829
1738
  const presence = new PresenceTracker();
1830
1739
  const hub = new WsHub();
1740
+ const stopping = new AbortController();
1831
1741
  const { app, injectWebSocket } = createApp({
1832
1742
  sessions,
1833
1743
  presence,
@@ -1836,7 +1746,8 @@ async function serve(options = {}) {
1836
1746
  injectDist,
1837
1747
  home,
1838
1748
  port,
1839
- version: chambaVersion()
1749
+ version: chambaVersion(),
1750
+ stopping: stopping.signal
1840
1751
  });
1841
1752
  const pid = await acquirePidfile(home, port);
1842
1753
  const server = honoServe({ fetch: app.fetch, hostname: host, port });
@@ -1853,6 +1764,7 @@ async function serve(options = {}) {
1853
1764
  if (closing) return;
1854
1765
  closing = true;
1855
1766
  stopWatchdog();
1767
+ stopping.abort();
1856
1768
  await closeServer(server);
1857
1769
  await pid.release();
1858
1770
  };