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