chamba 0.2.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.
- package/README.md +6 -7
- package/dist/cli.js +534 -643
- package/dist/server.js +1395 -1483
- package/inject/annotate.js +18 -49
- package/package.json +5 -7
- package/skill/SKILL.md +67 -259
- package/web/assets/highlighted-body-OFNGDK62-Bn4Eu7CG.js +1 -0
- package/web/assets/index-B9DI4F1Z.js +202 -0
- package/web/assets/index-DK_n6CTo.css +2 -0
- package/web/assets/mermaid-GHXKKRXX-CEMduc-U.js +1 -0
- package/web/index.html +2 -2
- package/web/assets/abnfDiagram-VRR7QNED-C5V2hrtE.js +0 -1
- package/web/assets/arc-d7OTmxZy.js +0 -1
- package/web/assets/architectureDiagram-ZJ3FMSHR--CztaeAS.js +0 -36
- package/web/assets/blockDiagram-677ZJIJ3-DkA5Z_uK.js +0 -132
- package/web/assets/c4Diagram-LMCZKHZV-C78dI0l-.js +0 -10
- package/web/assets/channel-D9AxU8kk.js +0 -1
- package/web/assets/chunk-2Q5K7J3B-LBNP12XV.js +0 -1
- package/web/assets/chunk-32BRIVSS-DZoXrhCs.js +0 -1
- package/web/assets/chunk-5VM5RSS4-CPjoRxF_.js +0 -15
- package/web/assets/chunk-EX3LRPZG-DSlL8FE9.js +0 -231
- package/web/assets/chunk-JWPE2WC7-BjbBLYge.js +0 -1
- package/web/assets/chunk-MOJQB5TN-DCKqdaDr.js +0 -88
- package/web/assets/chunk-RYQCIY6F-D5yAURhE.js +0 -1
- package/web/assets/chunk-V7JOEXUC-DKY2sn06.js +0 -206
- package/web/assets/chunk-VR4S4FIN-DGiVMHh6.js +0 -1
- package/web/assets/chunk-XXDRQBXY-CUI1Tkrr.js +0 -1
- package/web/assets/classDiagram-OUVF2IWQ-D7gWuzIF.js +0 -1
- package/web/assets/classDiagram-v2-EOCWNBFH-D7gWuzIF.js +0 -1
- package/web/assets/cose-bilkent-JH36ORCC-Ao3LzalV.js +0 -1
- package/web/assets/cynefin-VYW2F7L2-7JQwpRwH.js +0 -178
- package/web/assets/cynefinDiagram-TSTJHNR4-BfuANy_V.js +0 -62
- package/web/assets/cytoscape.esm-DTSO7Bv0.js +0 -331
- package/web/assets/dagre-VKFMJZFB-DHVnVJCL.js +0 -4
- package/web/assets/defaultLocale-DX6XiGOO.js +0 -1
- package/web/assets/diagram-FQU43EPY-OYrtwA2f.js +0 -3
- package/web/assets/diagram-G47NLZAW-CbkLZxYp.js +0 -24
- package/web/assets/diagram-NH7WQ7WH-B0z_jTdi.js +0 -24
- package/web/assets/diagram-OA4YK3LP-BRu_2nwx.js +0 -30
- package/web/assets/diagram-WEI45ONY-B9cpw86D.js +0 -41
- package/web/assets/ebnfDiagram-CCIWWBDH-DaBQ7T4R.js +0 -1
- package/web/assets/erDiagram-Q63AITRT-D6PQiFj3.js +0 -85
- package/web/assets/flowDiagram-23GEKE2U-p_8Wj7It.js +0 -156
- package/web/assets/ganttDiagram-NO4QXBWP-BZBwuqu3.js +0 -292
- package/web/assets/gitGraphDiagram-IHSO6WYX-DyPcQ-91.js +0 -106
- package/web/assets/graph-C9eacEi8.js +0 -1
- package/web/assets/highlighted-body-OFNGDK62-Cq7RJZcn.js +0 -1
- package/web/assets/index-BN-QUr5b.js +0 -350
- package/web/assets/index-D6-LVQ5m.css +0 -1
- package/web/assets/infoDiagram-FWYZ7A6U-sAgBAeWC.js +0 -2
- package/web/assets/init-Gi6I4Gst.js +0 -1
- package/web/assets/ishikawaDiagram-FXEZZL3T-COEn9wCs.js +0 -70
- package/web/assets/journeyDiagram-5HDEW3XC-BPddPRXW.js +0 -139
- package/web/assets/kanban-definition-HUTT4EX6-B8TW97Oy.js +0 -89
- package/web/assets/katex-C5jXJg4s.js +0 -257
- package/web/assets/layout-DEXfKzaS.js +0 -1
- package/web/assets/linear-CnW5uQ_k.js +0 -1
- package/web/assets/map-Czzmt4hB.js +0 -1
- package/web/assets/mermaid.core-bOV5iNX_.js +0 -314
- package/web/assets/mindmap-definition-LN4V7U3C-Dtr4xW_-.js +0 -96
- package/web/assets/ordinal-Cboi1Yqb.js +0 -1
- package/web/assets/pegDiagram-2B236MQR-DqBITK5N.js +0 -1
- package/web/assets/pieDiagram-ENE6RG2P-DXjxZgy3.js +0 -39
- package/web/assets/quadrantDiagram-ABIIQ3AL-D4MXx3U7.js +0 -7
- package/web/assets/railroadDiagram-RFXS5EU6-LPfpHg4_.js +0 -1
- package/web/assets/requirementDiagram-TGXJPOKE-CiDswbjt.js +0 -84
- package/web/assets/sankeyDiagram-HTMAVEWB-VfN_qTEI.js +0 -40
- package/web/assets/sequenceDiagram-DBY2YBRQ-kJRIx4zm.js +0 -162
- package/web/assets/sizeCapture-X5ZJPWSS-D8vJbAt-.js +0 -1
- package/web/assets/stateDiagram-2N3HPSRC-9qku-qAj.js +0 -1
- package/web/assets/stateDiagram-v2-6OUMAXLB-DbOQICyv.js +0 -1
- package/web/assets/swimlanes-5IMT3BWC-Cdb5FDUM.js +0 -2
- package/web/assets/swimlanesDiagram-G3AALYLV-EDpLmqQv.js +0 -8
- package/web/assets/timeline-definition-FHXFAJF6-D0hyJCMm.js +0 -120
- package/web/assets/vennDiagram-L72KCM5P-KsCkGMtr.js +0 -34
- package/web/assets/wardleyDiagram-EHGQE667-B4jT_1ak.js +0 -78
- package/web/assets/xychartDiagram-FW5EYKEG-Bmy0rt8j.js +0 -7
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/
|
|
10
|
+
// packages/shared/src/events.ts
|
|
11
11
|
import { z } from "zod";
|
|
12
|
-
var
|
|
13
|
-
var
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
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
|
|
81
|
+
import { z as z6 } from "zod";
|
|
81
82
|
|
|
82
|
-
// packages/shared/src/
|
|
83
|
+
// packages/shared/src/presence.ts
|
|
83
84
|
import { z as z4 } from "zod";
|
|
84
|
-
var
|
|
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/
|
|
87
|
+
// packages/shared/src/session.ts
|
|
96
88
|
import { z as z5 } from "zod";
|
|
97
|
-
var
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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 =
|
|
136
|
-
var
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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
|
|
144
|
-
text:
|
|
145
|
-
attachmentIds:
|
|
146
|
-
|
|
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
|
|
150
|
-
var
|
|
151
|
-
var
|
|
152
|
-
|
|
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:
|
|
122
|
+
screenshotAttachmentId: z6.string().min(1).optional()
|
|
155
123
|
});
|
|
156
|
-
var postAnnotationsReqSchema =
|
|
157
|
-
items:
|
|
124
|
+
var postAnnotationsReqSchema = z6.object({
|
|
125
|
+
items: z6.array(annotationItemReqSchema).min(1)
|
|
158
126
|
});
|
|
159
|
-
var
|
|
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 =
|
|
130
|
+
var eventResSchema = z6.object({
|
|
173
131
|
event: eventEnvelopeSchema
|
|
174
132
|
});
|
|
175
|
-
var pollResSchema =
|
|
176
|
-
events:
|
|
133
|
+
var pollResSchema = z6.object({
|
|
134
|
+
events: z6.array(eventEnvelopeSchema),
|
|
177
135
|
cursor: cursorSchema,
|
|
178
|
-
ended:
|
|
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 =
|
|
181
|
-
hasMore:
|
|
141
|
+
var fetchMoreSchema = z6.object({
|
|
142
|
+
hasMore: z6.boolean(),
|
|
182
143
|
earliestLoadedSeq: cursorSchema,
|
|
183
|
-
hint:
|
|
144
|
+
hint: z6.string()
|
|
184
145
|
});
|
|
185
|
-
var attachDigestSchema =
|
|
146
|
+
var attachDigestSchema = z6.object({
|
|
186
147
|
meta: sessionMetaSchema,
|
|
187
148
|
presence: presenceSchema,
|
|
188
|
-
tail:
|
|
149
|
+
tail: z6.array(eventEnvelopeSchema),
|
|
189
150
|
cursor: cursorSchema,
|
|
190
151
|
fetchMore: fetchMoreSchema
|
|
191
152
|
});
|
|
192
|
-
var sessionSummarySchema =
|
|
153
|
+
var sessionSummarySchema = z6.object({
|
|
193
154
|
meta: sessionMetaSchema,
|
|
194
155
|
presence: presenceSchema
|
|
195
156
|
});
|
|
196
|
-
var listResSchema =
|
|
197
|
-
sessions:
|
|
157
|
+
var listResSchema = z6.object({
|
|
158
|
+
sessions: z6.array(sessionSummarySchema)
|
|
198
159
|
});
|
|
199
|
-
var sessionResSchema =
|
|
160
|
+
var sessionResSchema = z6.object({
|
|
200
161
|
session: sessionSummarySchema
|
|
201
162
|
});
|
|
202
|
-
var healthResSchema =
|
|
203
|
-
ok:
|
|
163
|
+
var healthResSchema = z6.object({
|
|
164
|
+
ok: z6.boolean(),
|
|
204
165
|
sessions: cursorSchema,
|
|
205
|
-
home:
|
|
206
|
-
port:
|
|
207
|
-
version:
|
|
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
|
|
212
|
-
var issueTypeSchema =
|
|
213
|
-
var postIssueReqSchema =
|
|
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:
|
|
216
|
-
screenshotAttachmentIds:
|
|
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
|
|
219
|
-
|
|
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, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
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/
|
|
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[
|
|
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 =
|
|
373
|
-
const dev =
|
|
374
|
-
const packaged =
|
|
375
|
-
return [dev, packaged].find((dir) =>
|
|
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 =
|
|
390
|
-
if (filePath !== webDist && !filePath.startsWith(webDist +
|
|
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(
|
|
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
|
|
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/
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
"
|
|
419
|
-
"
|
|
420
|
-
"
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
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
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
}
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
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
|
|
493
|
-
|
|
494
|
-
|
|
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
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
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
|
-
|
|
691
|
-
const
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
}
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
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
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
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
|
|
723
|
-
return
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
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
|
|
734
|
-
|
|
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/
|
|
738
|
-
import {
|
|
739
|
-
import { open, readFile as
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
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
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
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
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
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
|
-
|
|
838
|
-
}
|
|
839
|
-
|
|
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
|
-
|
|
862
|
-
|
|
863
|
-
|
|
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
|
-
|
|
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 !== "
|
|
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
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
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
|
-
|
|
899
|
-
|
|
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
|
-
|
|
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
|
-
|
|
916
|
-
|
|
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
|
-
|
|
924
|
-
|
|
925
|
-
|
|
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
|
-
|
|
938
|
-
|
|
939
|
-
|
|
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
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
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
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
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
|
-
*
|
|
997
|
-
*
|
|
998
|
-
*
|
|
999
|
-
*
|
|
1000
|
-
*
|
|
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
|
-
|
|
1003
|
-
this.
|
|
1004
|
-
this.
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
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
|
-
/**
|
|
1015
|
-
|
|
1016
|
-
this.
|
|
1017
|
-
this.
|
|
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
|
-
*
|
|
1023
|
-
*
|
|
1024
|
-
*
|
|
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 {
|
|
1338
|
-
async function
|
|
1339
|
-
|
|
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(
|
|
661
|
+
await writeFile(tmp, `${JSON.stringify(value, null, 2)}
|
|
1352
662
|
`, "utf8");
|
|
1353
|
-
await rename(tmp,
|
|
663
|
+
await rename(tmp, file);
|
|
1354
664
|
} catch (err) {
|
|
1355
|
-
await
|
|
665
|
+
await rm(tmp, { force: true }).catch(() => void 0);
|
|
1356
666
|
throw err;
|
|
1357
667
|
}
|
|
1358
668
|
}
|
|
1359
|
-
|
|
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
|
|
1362
|
-
|
|
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
|
|
1402
|
+
if (err instanceof ReplyTargetError) return c.json({ error: err.message }, 400);
|
|
1365
1403
|
throw err;
|
|
1366
1404
|
}
|
|
1367
1405
|
}
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
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
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
id,
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
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
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1433
|
+
return resolved;
|
|
1434
|
+
}
|
|
1435
|
+
async function readJson(c) {
|
|
1436
|
+
try {
|
|
1437
|
+
return await c.req.json();
|
|
1438
|
+
} catch {
|
|
1439
|
+
return {};
|
|
1427
1440
|
}
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
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
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
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
|
-
|
|
1454
|
-
|
|
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
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
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
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
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
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
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
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
const
|
|
1512
|
-
return
|
|
1513
|
-
|
|
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
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
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
|
-
*
|
|
1533
|
-
*
|
|
1534
|
-
*
|
|
1535
|
-
*
|
|
1536
|
-
*
|
|
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
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
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
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
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
|
-
/**
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
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
|
-
*
|
|
1623
|
-
*
|
|
1624
|
-
*
|
|
1625
|
-
*
|
|
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
|
-
|
|
1628
|
-
const
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
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
|
-
/**
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
if (
|
|
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
|
-
/**
|
|
1667
|
-
|
|
1668
|
-
const
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
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
|
-
|
|
1690
|
-
|
|
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
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
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
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1668
|
+
clearTimer(id) {
|
|
1669
|
+
const timer = this.timers.get(id);
|
|
1670
|
+
if (timer) {
|
|
1671
|
+
clearTimeout(timer);
|
|
1672
|
+
this.timers.delete(id);
|
|
1726
1673
|
}
|
|
1727
|
-
|
|
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
|
};
|