chamba 0.3.1 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +47 -44
- package/bin/chamba.js +212 -0
- package/dist/commands/advanced.js +278 -0
- package/dist/commands/dev.js +612 -0
- package/dist/commands/doctor.js +29 -0
- package/dist/commands/menu.js +80 -0
- package/dist/commands/onboard.js +229 -0
- package/dist/commands/settings.js +349 -0
- package/dist/lib/agent-context.js +177 -0
- package/dist/lib/browser.js +40 -0
- package/dist/lib/chamba-yaml.js +191 -0
- package/dist/lib/constants.js +125 -0
- package/dist/lib/dockerfile-builder.js +267 -0
- package/dist/lib/env.js +78 -0
- package/dist/lib/global-config.js +66 -0
- package/dist/lib/pnpm-store.js +19 -0
- package/dist/lib/ports.js +210 -0
- package/dist/lib/safe-rm.js +26 -0
- package/dist/lib/sessions.js +34 -0
- package/dist/lib/shadows.js +174 -0
- package/dist/lib/webterm.js +305 -0
- package/dist/lib/workspace-identity.js +260 -0
- package/package.json +61 -23
- package/schema/chamba.schema.json +65 -0
- package/templates/.dockerignore +3 -0
- package/templates/Dockerfile +173 -0
- package/templates/claude-statusline.sh +120 -0
- package/templates/context/baseline.md +13 -0
- package/templates/context/context-usage.md +1 -0
- package/templates/context/git-mode-local.md +1 -0
- package/templates/context/git-mode-strict.md +1 -0
- package/templates/context/git-mode-unrestricted.md +1 -0
- package/templates/context/git-unavailable.md +1 -0
- package/templates/context/shadow-paths.md +3 -0
- package/templates/context-usage.sh +249 -0
- package/templates/git-readonly-wrapper.mjs +309 -0
- package/templates/npmrc +2 -0
- package/templates/pnpm-config.yaml +9 -0
- package/templates/runtime-constants.mjs +18 -0
- package/templates/skills/chamba-statusline/SKILL.md +79 -0
- package/templates/skills/context-usage/SKILL.md +53 -0
- package/templates/skills/web-pane/SKILL.md +62 -0
- package/templates/startup-git-mode.mjs +145 -0
- package/templates/startup.mjs +333 -0
- package/templates/webpane.sh +126 -0
- package/templates/webterm/README.md +160 -0
- package/templates/webterm/artifacts.js +583 -0
- package/templates/webterm/config.js +270 -0
- package/templates/webterm/context/claude.md +14 -0
- package/templates/webterm/conversation.js +253 -0
- package/templates/webterm/package-lock.json +884 -0
- package/templates/webterm/package.json +17 -0
- package/templates/webterm/pane.js +156 -0
- package/templates/webterm/proc.js +89 -0
- package/templates/webterm/public/app/alerts.js +472 -0
- package/templates/webterm/public/app/cards.js +123 -0
- package/templates/webterm/public/app/clipboard.js +229 -0
- package/templates/webterm/public/app/composer.js +226 -0
- package/templates/webterm/public/app/connection.js +342 -0
- package/templates/webterm/public/app/dictation.js +98 -0
- package/templates/webterm/public/app/dom.js +37 -0
- package/templates/webterm/public/app/drafts.js +244 -0
- package/templates/webterm/public/app/frames.js +166 -0
- package/templates/webterm/public/app/main.js +82 -0
- package/templates/webterm/public/app/new-session.js +188 -0
- package/templates/webterm/public/app/note.js +24 -0
- package/templates/webterm/public/app/pane-frame.js +166 -0
- package/templates/webterm/public/app/pane.js +353 -0
- package/templates/webterm/public/app/state.js +51 -0
- package/templates/webterm/public/app/status-strip.js +170 -0
- package/templates/webterm/public/app/tabs.js +475 -0
- package/templates/webterm/public/app/terminal.js +102 -0
- package/templates/webterm/public/app/theme.js +46 -0
- package/templates/webterm/public/favicon.svg +21 -0
- package/templates/webterm/public/index.html +105 -0
- package/templates/webterm/public/styles.css +1193 -0
- package/templates/webterm/resume.js +145 -0
- package/templates/webterm/server.js +1181 -0
- package/templates/webterm/sessions.js +515 -0
- package/templates/webterm/snapshot.js +135 -0
- package/templates/webterm.sh +164 -0
- package/dist/cli.js +0 -1582
- package/dist/server.js +0 -1831
- package/inject/annotate.js +0 -18
- package/skill/README.md +0 -12
- package/skill/SKILL.md +0 -93
- package/web/assets/highlighted-body-OFNGDK62-Bn4Eu7CG.js +0 -1
- package/web/assets/index-B9DI4F1Z.js +0 -202
- package/web/assets/index-DK_n6CTo.css +0 -2
- package/web/assets/mermaid-GHXKKRXX-CEMduc-U.js +0 -1
- package/web/index.html +0 -28
package/dist/cli.js
DELETED
|
@@ -1,1582 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
|
|
3
|
-
// packages/cli/src/index.ts
|
|
4
|
-
import { realpathSync as realpathSync2 } from "node:fs";
|
|
5
|
-
import { fileURLToPath as fileURLToPath6 } from "node:url";
|
|
6
|
-
|
|
7
|
-
// packages/server/src/index.ts
|
|
8
|
-
import { serve as honoServe } from "@hono/node-server";
|
|
9
|
-
|
|
10
|
-
// packages/shared/src/annotations.ts
|
|
11
|
-
import { z as z3 } from "zod";
|
|
12
|
-
|
|
13
|
-
// packages/shared/src/messages.ts
|
|
14
|
-
import { z as z2 } from "zod";
|
|
15
|
-
|
|
16
|
-
// packages/shared/src/events.ts
|
|
17
|
-
import { z } from "zod";
|
|
18
|
-
var eventSourceSchema = z.enum(["user", "agent", "system"]);
|
|
19
|
-
var eventEnvelopeSchema = z.object({
|
|
20
|
-
id: z.uuid(),
|
|
21
|
-
seq: z.number().int().nonnegative(),
|
|
22
|
-
sessionId: z.string().min(1),
|
|
23
|
-
ts: z.iso.datetime(),
|
|
24
|
-
type: z.string().min(1),
|
|
25
|
-
source: eventSourceSchema,
|
|
26
|
-
payload: z.unknown()
|
|
27
|
-
});
|
|
28
|
-
|
|
29
|
-
// packages/shared/src/messages.ts
|
|
30
|
-
var attachmentSchema = z2.object({
|
|
31
|
-
id: z2.string().min(1),
|
|
32
|
-
name: z2.string().min(1),
|
|
33
|
-
relPath: z2.string().min(1),
|
|
34
|
-
contentType: z2.string().min(1),
|
|
35
|
-
size: z2.number().int().nonnegative()
|
|
36
|
-
});
|
|
37
|
-
var replyRefSchema = z2.object({
|
|
38
|
-
seq: z2.number().int().nonnegative(),
|
|
39
|
-
id: z2.string().min(1),
|
|
40
|
-
source: eventSourceSchema,
|
|
41
|
-
snippet: z2.string()
|
|
42
|
-
});
|
|
43
|
-
var messagePayloadSchema = z2.object({
|
|
44
|
-
text: z2.string().min(1).optional(),
|
|
45
|
-
attachments: z2.array(attachmentSchema).optional(),
|
|
46
|
-
replyTo: replyRefSchema.optional(),
|
|
47
|
-
answerIn: z2.literal("terminal").optional()
|
|
48
|
-
}).refine((p) => (p.text?.length ?? 0) > 0 || (p.attachments?.length ?? 0) > 0, {
|
|
49
|
-
error: "a message needs text or at least one attachment"
|
|
50
|
-
});
|
|
51
|
-
var systemReasonSchema = z2.enum(["session-created", "session-ended"]);
|
|
52
|
-
var systemPayloadSchema = z2.object({
|
|
53
|
-
reason: systemReasonSchema
|
|
54
|
-
});
|
|
55
|
-
|
|
56
|
-
// packages/shared/src/annotations.ts
|
|
57
|
-
var ANNOTATION_BATCH = "annotation-batch";
|
|
58
|
-
var annotationSourceSchema = z3.object({
|
|
59
|
-
file: z3.string().min(1),
|
|
60
|
-
line: z3.number().int().positive().optional(),
|
|
61
|
-
column: z3.number().int().positive().optional()
|
|
62
|
-
});
|
|
63
|
-
var annotationComponentSchema = z3.object({
|
|
64
|
-
name: z3.string().min(1),
|
|
65
|
-
source: annotationSourceSchema.optional()
|
|
66
|
-
});
|
|
67
|
-
var annotationTargetSchema = z3.object({
|
|
68
|
-
selector: z3.string().min(1),
|
|
69
|
-
pageUrl: z3.string().min(1),
|
|
70
|
-
viewport: z3.object({
|
|
71
|
-
width: z3.number().int().positive(),
|
|
72
|
-
height: z3.number().int().positive()
|
|
73
|
-
}),
|
|
74
|
-
label: z3.string().optional(),
|
|
75
|
-
component: annotationComponentSchema.optional()
|
|
76
|
-
});
|
|
77
|
-
var annotationItemSchema = z3.object({
|
|
78
|
-
note: z3.string().min(1).optional(),
|
|
79
|
-
target: annotationTargetSchema,
|
|
80
|
-
screenshot: attachmentSchema.optional()
|
|
81
|
-
});
|
|
82
|
-
var annotationBatchPayloadSchema = z3.object({
|
|
83
|
-
items: z3.array(annotationItemSchema).min(1)
|
|
84
|
-
});
|
|
85
|
-
|
|
86
|
-
// packages/shared/src/api.ts
|
|
87
|
-
import { z as z6 } from "zod";
|
|
88
|
-
|
|
89
|
-
// packages/shared/src/presence.ts
|
|
90
|
-
import { z as z4 } from "zod";
|
|
91
|
-
var presenceSchema = z4.enum(["listening", "working", "away"]);
|
|
92
|
-
|
|
93
|
-
// packages/shared/src/session.ts
|
|
94
|
-
import { z as z5 } from "zod";
|
|
95
|
-
var sessionStatusSchema = z5.enum(["active", "ended"]);
|
|
96
|
-
var sessionMetaSchema = z5.object({
|
|
97
|
-
id: z5.string().min(1),
|
|
98
|
-
/** The agent session that created this session (e.g. a Claude Code session id).
|
|
99
|
-
* `open` uses it to reconnect the same agent session to its existing workspace. */
|
|
100
|
-
agentKey: z5.string().min(1).optional(),
|
|
101
|
-
title: z5.string().optional(),
|
|
102
|
-
status: sessionStatusSchema,
|
|
103
|
-
createdAt: z5.string().datetime(),
|
|
104
|
-
updatedAt: z5.string().datetime()
|
|
105
|
-
});
|
|
106
|
-
|
|
107
|
-
// packages/shared/src/api.ts
|
|
108
|
-
var cursorSchema = z6.number().int().nonnegative();
|
|
109
|
-
var POLL_DEFAULT_WAIT_MS = 25e3;
|
|
110
|
-
var POLL_MAX_WAIT_MS = 3e4;
|
|
111
|
-
var SESSION_QUERY_PARAM = "s";
|
|
112
|
-
function sessionPageUrl(baseUrl, id) {
|
|
113
|
-
return `${baseUrl.replace(/\/+$/, "")}/?${SESSION_QUERY_PARAM}=${encodeURIComponent(id)}`;
|
|
114
|
-
}
|
|
115
|
-
async function errorBodyMessage(res) {
|
|
116
|
-
try {
|
|
117
|
-
const body = await res.json();
|
|
118
|
-
return typeof body.error === "string" ? body.error : null;
|
|
119
|
-
} catch {
|
|
120
|
-
return null;
|
|
121
|
-
}
|
|
122
|
-
}
|
|
123
|
-
var createSessionReqSchema = z6.object({
|
|
124
|
-
title: z6.string().min(1).optional(),
|
|
125
|
-
agentKey: z6.string().min(1).optional()
|
|
126
|
-
});
|
|
127
|
-
var messageBodyBase = z6.object({
|
|
128
|
-
text: z6.string().min(1).optional(),
|
|
129
|
-
attachmentIds: z6.array(z6.string().min(1)).optional(),
|
|
130
|
-
replyTo: z6.number().int().nonnegative().optional()
|
|
131
|
-
});
|
|
132
|
-
var needsContent = (r) => (r.text?.length ?? 0) > 0 || (r.attachmentIds?.length ?? 0) > 0;
|
|
133
|
-
var needsContentMessage = { error: "a message needs text or at least one attachment" };
|
|
134
|
-
var postMessageReqSchema = messageBodyBase.extend({ answerIn: z6.literal("terminal").optional() }).refine(needsContent, needsContentMessage);
|
|
135
|
-
var replyReqSchema = messageBodyBase.refine(needsContent, needsContentMessage);
|
|
136
|
-
var annotationItemReqSchema = z6.object({
|
|
137
|
-
note: z6.string().min(1).optional(),
|
|
138
|
-
target: annotationTargetSchema,
|
|
139
|
-
screenshotAttachmentId: z6.string().min(1).optional()
|
|
140
|
-
});
|
|
141
|
-
var postAnnotationsReqSchema = z6.object({
|
|
142
|
-
items: z6.array(annotationItemReqSchema).min(1)
|
|
143
|
-
});
|
|
144
|
-
var attachmentResSchema = z6.object({
|
|
145
|
-
attachment: attachmentSchema
|
|
146
|
-
});
|
|
147
|
-
var eventResSchema = z6.object({
|
|
148
|
-
event: eventEnvelopeSchema
|
|
149
|
-
});
|
|
150
|
-
var pollResSchema = z6.object({
|
|
151
|
-
events: z6.array(eventEnvelopeSchema),
|
|
152
|
-
cursor: cursorSchema,
|
|
153
|
-
ended: z6.boolean().optional(),
|
|
154
|
-
/** Set when the server is shutting down on purpose and woke this poll to say
|
|
155
|
-
* so; the caller should exit its loop cleanly instead of retrying. */
|
|
156
|
-
stopping: z6.boolean().optional()
|
|
157
|
-
});
|
|
158
|
-
var fetchMoreSchema = z6.object({
|
|
159
|
-
hasMore: z6.boolean(),
|
|
160
|
-
earliestLoadedSeq: cursorSchema,
|
|
161
|
-
hint: z6.string()
|
|
162
|
-
});
|
|
163
|
-
var attachDigestSchema = z6.object({
|
|
164
|
-
meta: sessionMetaSchema,
|
|
165
|
-
presence: presenceSchema,
|
|
166
|
-
tail: z6.array(eventEnvelopeSchema),
|
|
167
|
-
cursor: cursorSchema,
|
|
168
|
-
fetchMore: fetchMoreSchema
|
|
169
|
-
});
|
|
170
|
-
var sessionSummarySchema = z6.object({
|
|
171
|
-
meta: sessionMetaSchema,
|
|
172
|
-
presence: presenceSchema
|
|
173
|
-
});
|
|
174
|
-
var listResSchema = z6.object({
|
|
175
|
-
sessions: z6.array(sessionSummarySchema)
|
|
176
|
-
});
|
|
177
|
-
var sessionResSchema = z6.object({
|
|
178
|
-
session: sessionSummarySchema
|
|
179
|
-
});
|
|
180
|
-
var healthResSchema = z6.object({
|
|
181
|
-
ok: z6.boolean(),
|
|
182
|
-
sessions: cursorSchema,
|
|
183
|
-
home: z6.string().min(1).optional(),
|
|
184
|
-
port: z6.number().int().positive().optional(),
|
|
185
|
-
version: z6.string().min(1).optional()
|
|
186
|
-
});
|
|
187
|
-
|
|
188
|
-
// packages/shared/src/issues.ts
|
|
189
|
-
import { z as z7 } from "zod";
|
|
190
|
-
var issueTypeSchema = z7.enum(["bug", "feature"]);
|
|
191
|
-
var postIssueReqSchema = z7.object({
|
|
192
|
-
type: issueTypeSchema,
|
|
193
|
-
text: z7.string().min(1),
|
|
194
|
-
screenshotAttachmentIds: z7.array(z7.string().min(1)).optional()
|
|
195
|
-
});
|
|
196
|
-
var issueResSchema = z7.object({
|
|
197
|
-
id: z7.string().min(1),
|
|
198
|
-
path: z7.string().min(1)
|
|
199
|
-
});
|
|
200
|
-
|
|
201
|
-
// packages/shared/src/page.ts
|
|
202
|
-
import { z as z8 } from "zod";
|
|
203
|
-
var PAGE = "page";
|
|
204
|
-
var pageRefSchema = z8.object({
|
|
205
|
-
name: z8.string().min(1),
|
|
206
|
-
relPath: z8.string().min(1),
|
|
207
|
-
contentType: z8.string().min(1),
|
|
208
|
-
size: z8.number().int().nonnegative()
|
|
209
|
-
});
|
|
210
|
-
var pagePayloadSchema = z8.object({
|
|
211
|
-
page: pageRefSchema
|
|
212
|
-
});
|
|
213
|
-
var postPageReqSchema = z8.object({
|
|
214
|
-
name: z8.string().min(1).max(120),
|
|
215
|
-
content: z8.string().min(1)
|
|
216
|
-
});
|
|
217
|
-
|
|
218
|
-
// packages/shared/src/payload.ts
|
|
219
|
-
var attachmentsSchema = attachmentSchema.array();
|
|
220
|
-
function eventText(event) {
|
|
221
|
-
const payload = event.payload;
|
|
222
|
-
if (payload && typeof payload === "object") {
|
|
223
|
-
const record = payload;
|
|
224
|
-
if (typeof record.text === "string") return record.text;
|
|
225
|
-
if (typeof record.reason === "string") return record.reason;
|
|
226
|
-
}
|
|
227
|
-
return "";
|
|
228
|
-
}
|
|
229
|
-
function eventAttachments(event) {
|
|
230
|
-
if (!event.payload || typeof event.payload !== "object") return [];
|
|
231
|
-
const parsed = attachmentsSchema.safeParse(
|
|
232
|
-
event.payload.attachments
|
|
233
|
-
);
|
|
234
|
-
return parsed.success ? parsed.data : [];
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
// packages/shared/src/reference.ts
|
|
238
|
-
function parseRef(input) {
|
|
239
|
-
let ref = input.trim();
|
|
240
|
-
const token = ref.match(/^\[?\s*chamba\s+([^\s\]]+)/i);
|
|
241
|
-
if (token?.[1]) ref = token[1];
|
|
242
|
-
const match = ref.match(/^(?:([A-Za-z0-9_-]+)#)?#?(\d+)$/);
|
|
243
|
-
if (!match?.[2]) return null;
|
|
244
|
-
return { ...match[1] ? { session: match[1] } : {}, seq: Number(match[2]) };
|
|
245
|
-
}
|
|
246
|
-
function sourceLabel(source) {
|
|
247
|
-
return source === "user" ? "you" : source;
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
// packages/shared/src/ws.ts
|
|
251
|
-
import { z as z9 } from "zod";
|
|
252
|
-
var wsServerMessageSchema = z9.discriminatedUnion("t", [
|
|
253
|
-
z9.object({
|
|
254
|
-
t: z9.literal("hello"),
|
|
255
|
-
meta: sessionMetaSchema,
|
|
256
|
-
presence: presenceSchema
|
|
257
|
-
}),
|
|
258
|
-
z9.object({
|
|
259
|
-
t: z9.literal("append"),
|
|
260
|
-
event: eventEnvelopeSchema
|
|
261
|
-
}),
|
|
262
|
-
z9.object({
|
|
263
|
-
t: z9.literal("presence"),
|
|
264
|
-
state: presenceSchema
|
|
265
|
-
}),
|
|
266
|
-
z9.object({
|
|
267
|
-
t: z9.literal("ended"),
|
|
268
|
-
meta: sessionMetaSchema
|
|
269
|
-
})
|
|
270
|
-
]);
|
|
271
|
-
|
|
272
|
-
// packages/server/src/app.ts
|
|
273
|
-
import { createNodeWebSocket } from "@hono/node-ws";
|
|
274
|
-
import { Hono } from "hono";
|
|
275
|
-
|
|
276
|
-
// packages/server/src/inject.ts
|
|
277
|
-
import { existsSync as existsSync2 } from "node:fs";
|
|
278
|
-
import path2 from "node:path";
|
|
279
|
-
import { fileURLToPath as fileURLToPath2 } from "node:url";
|
|
280
|
-
|
|
281
|
-
// packages/server/src/static.ts
|
|
282
|
-
import { existsSync } from "node:fs";
|
|
283
|
-
import path from "node:path";
|
|
284
|
-
import { fileURLToPath } from "node:url";
|
|
285
|
-
|
|
286
|
-
// packages/server/src/mime.ts
|
|
287
|
-
var CONTENT_TYPES = {
|
|
288
|
-
".html": "text/html; charset=utf-8",
|
|
289
|
-
".js": "text/javascript; charset=utf-8",
|
|
290
|
-
".mjs": "text/javascript; charset=utf-8",
|
|
291
|
-
".css": "text/css; charset=utf-8",
|
|
292
|
-
".json": "application/json; charset=utf-8",
|
|
293
|
-
".svg": "image/svg+xml",
|
|
294
|
-
".png": "image/png",
|
|
295
|
-
".jpg": "image/jpeg",
|
|
296
|
-
".jpeg": "image/jpeg",
|
|
297
|
-
".gif": "image/gif",
|
|
298
|
-
".webp": "image/webp",
|
|
299
|
-
".ico": "image/x-icon",
|
|
300
|
-
".woff2": "font/woff2",
|
|
301
|
-
".map": "application/json; charset=utf-8",
|
|
302
|
-
".pdf": "application/pdf",
|
|
303
|
-
".txt": "text/plain; charset=utf-8",
|
|
304
|
-
".md": "text/markdown; charset=utf-8"
|
|
305
|
-
};
|
|
306
|
-
var EXTENSIONS = {};
|
|
307
|
-
for (const [ext, type] of Object.entries(CONTENT_TYPES)) {
|
|
308
|
-
const base = type.split(";")[0] ?? type;
|
|
309
|
-
if (!(base in EXTENSIONS)) EXTENSIONS[base] = ext;
|
|
310
|
-
}
|
|
311
|
-
|
|
312
|
-
// packages/server/src/static.ts
|
|
313
|
-
function resolveWebDist() {
|
|
314
|
-
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
315
|
-
const dev = path.resolve(here, "../../web/dist");
|
|
316
|
-
const packaged = path.resolve(here, "../web");
|
|
317
|
-
return [dev, packaged].find((dir) => existsSync(path.join(dir, "index.html"))) ?? dev;
|
|
318
|
-
}
|
|
319
|
-
|
|
320
|
-
// packages/server/src/inject.ts
|
|
321
|
-
function resolveInjectDist() {
|
|
322
|
-
const here = path2.dirname(fileURLToPath2(import.meta.url));
|
|
323
|
-
const dev = path2.resolve(here, "../../inject/dist");
|
|
324
|
-
const packaged = path2.resolve(here, "../inject");
|
|
325
|
-
return [dev, packaged].find((dir) => existsSync2(path2.join(dir, "annotate.js"))) ?? dev;
|
|
326
|
-
}
|
|
327
|
-
|
|
328
|
-
// packages/server/src/version.ts
|
|
329
|
-
import { existsSync as existsSync3, readFileSync } from "node:fs";
|
|
330
|
-
import path3 from "node:path";
|
|
331
|
-
import { fileURLToPath as fileURLToPath3 } from "node:url";
|
|
332
|
-
function chambaVersion() {
|
|
333
|
-
let dir = path3.dirname(fileURLToPath3(import.meta.url));
|
|
334
|
-
for (; ; ) {
|
|
335
|
-
const manifest = path3.join(dir, "package.json");
|
|
336
|
-
if (existsSync3(manifest)) {
|
|
337
|
-
try {
|
|
338
|
-
const { version } = JSON.parse(readFileSync(manifest, "utf8"));
|
|
339
|
-
if (typeof version === "string" && version) return version;
|
|
340
|
-
} catch {
|
|
341
|
-
}
|
|
342
|
-
}
|
|
343
|
-
const parent = path3.dirname(dir);
|
|
344
|
-
if (parent === dir) return "0.0.0-dev";
|
|
345
|
-
dir = parent;
|
|
346
|
-
}
|
|
347
|
-
}
|
|
348
|
-
|
|
349
|
-
// packages/server/src/sessions/meta.ts
|
|
350
|
-
import { readdir, readFile as readFile2 } from "node:fs/promises";
|
|
351
|
-
|
|
352
|
-
// packages/server/src/sessions/atomic.ts
|
|
353
|
-
import { randomUUID } from "node:crypto";
|
|
354
|
-
import { rename, rm, writeFile } from "node:fs/promises";
|
|
355
|
-
async function writeJsonAtomic(file, value) {
|
|
356
|
-
const tmp = `${file}.${randomUUID()}.tmp`;
|
|
357
|
-
try {
|
|
358
|
-
await writeFile(tmp, `${JSON.stringify(value, null, 2)}
|
|
359
|
-
`, "utf8");
|
|
360
|
-
await rename(tmp, file);
|
|
361
|
-
} catch (err) {
|
|
362
|
-
await rm(tmp, { force: true }).catch(() => void 0);
|
|
363
|
-
throw err;
|
|
364
|
-
}
|
|
365
|
-
}
|
|
366
|
-
|
|
367
|
-
// packages/server/src/sessions/paths.ts
|
|
368
|
-
import { existsSync as existsSync4, realpathSync } from "node:fs";
|
|
369
|
-
import { appendFile, mkdir, readFile } from "node:fs/promises";
|
|
370
|
-
import path4 from "node:path";
|
|
371
|
-
function defaultContext() {
|
|
372
|
-
return { cwd: process.cwd(), env: process.env };
|
|
373
|
-
}
|
|
374
|
-
function resolveHome(ctx = defaultContext()) {
|
|
375
|
-
return findExistingHome(ctx) ?? plannedHome(ctx);
|
|
376
|
-
}
|
|
377
|
-
function findExistingHome(ctx = defaultContext()) {
|
|
378
|
-
const dir = ctx.env.CHAMBA_DIR;
|
|
379
|
-
if (dir && dir.length > 0) return canonicalize(dir);
|
|
380
|
-
const inside = enclosingChamba(ctx.cwd);
|
|
381
|
-
if (inside) return canonicalize(inside);
|
|
382
|
-
const root = gitRoot(ctx.cwd);
|
|
383
|
-
let cursor = path4.resolve(ctx.cwd);
|
|
384
|
-
while (true) {
|
|
385
|
-
if (existsSync4(path4.join(cursor, ".chamba"))) return canonicalize(path4.join(cursor, ".chamba"));
|
|
386
|
-
if (!root) break;
|
|
387
|
-
if (cursor === root) break;
|
|
388
|
-
const parent = path4.dirname(cursor);
|
|
389
|
-
if (parent === cursor) break;
|
|
390
|
-
cursor = parent;
|
|
391
|
-
}
|
|
392
|
-
return null;
|
|
393
|
-
}
|
|
394
|
-
function plannedHome(ctx = defaultContext()) {
|
|
395
|
-
const root = gitRoot(ctx.cwd) ?? path4.resolve(ctx.cwd);
|
|
396
|
-
return canonicalize(path4.join(root, ".chamba"));
|
|
397
|
-
}
|
|
398
|
-
function gitRoot(cwd) {
|
|
399
|
-
let cursor = path4.resolve(cwd);
|
|
400
|
-
while (true) {
|
|
401
|
-
if (existsSync4(path4.join(cursor, ".git"))) return cursor;
|
|
402
|
-
const parent = path4.dirname(cursor);
|
|
403
|
-
if (parent === cursor) return null;
|
|
404
|
-
cursor = parent;
|
|
405
|
-
}
|
|
406
|
-
}
|
|
407
|
-
function enclosingChamba(cwd) {
|
|
408
|
-
const parts = path4.resolve(cwd).split(path4.sep);
|
|
409
|
-
const index = parts.lastIndexOf(".chamba");
|
|
410
|
-
if (index < 0) return null;
|
|
411
|
-
return parts.slice(0, index + 1).join(path4.sep) || path4.sep;
|
|
412
|
-
}
|
|
413
|
-
function canonicalize(p) {
|
|
414
|
-
const resolved = path4.resolve(p);
|
|
415
|
-
let existing = resolved;
|
|
416
|
-
const tail = [];
|
|
417
|
-
while (!existsSync4(existing)) {
|
|
418
|
-
tail.unshift(path4.basename(existing));
|
|
419
|
-
const parent = path4.dirname(existing);
|
|
420
|
-
if (parent === existing) return resolved;
|
|
421
|
-
existing = parent;
|
|
422
|
-
}
|
|
423
|
-
return path4.join(realpathSync(existing), ...tail);
|
|
424
|
-
}
|
|
425
|
-
function sessionsDir(home) {
|
|
426
|
-
return path4.join(home, "sessions");
|
|
427
|
-
}
|
|
428
|
-
function sessionDir(home, id) {
|
|
429
|
-
return path4.join(sessionsDir(home), id);
|
|
430
|
-
}
|
|
431
|
-
function metaPath(home, id) {
|
|
432
|
-
return path4.join(sessionDir(home, id), "meta.json");
|
|
433
|
-
}
|
|
434
|
-
function pidfilePath(home) {
|
|
435
|
-
return path4.join(home, "server.json");
|
|
436
|
-
}
|
|
437
|
-
function agentStatePath(home) {
|
|
438
|
-
return path4.join(home, "agent-state.json");
|
|
439
|
-
}
|
|
440
|
-
async function ensureChambaHome(home) {
|
|
441
|
-
await mkdir(sessionsDir(home), { recursive: true });
|
|
442
|
-
await ensureGitignored(home);
|
|
443
|
-
}
|
|
444
|
-
async function ensureGitignored(home) {
|
|
445
|
-
if (path4.basename(home) !== ".chamba") return;
|
|
446
|
-
const gitignore = path4.join(path4.dirname(home), ".gitignore");
|
|
447
|
-
const entry = ".chamba/";
|
|
448
|
-
try {
|
|
449
|
-
let content = "";
|
|
450
|
-
try {
|
|
451
|
-
content = await readFile(gitignore, "utf8");
|
|
452
|
-
} catch (err) {
|
|
453
|
-
if (err.code !== "ENOENT") return;
|
|
454
|
-
}
|
|
455
|
-
const already = content.split(/\r?\n/).some((line) => {
|
|
456
|
-
const trimmed = line.trim();
|
|
457
|
-
return trimmed === entry || trimmed === ".chamba";
|
|
458
|
-
});
|
|
459
|
-
if (already) return;
|
|
460
|
-
const prefix = content.length > 0 && !content.endsWith("\n") ? "\n" : "";
|
|
461
|
-
await appendFile(gitignore, `${prefix}${entry}
|
|
462
|
-
`, "utf8");
|
|
463
|
-
} catch {
|
|
464
|
-
}
|
|
465
|
-
}
|
|
466
|
-
|
|
467
|
-
// packages/server/src/sessions/meta.ts
|
|
468
|
-
async function readMeta(home, id) {
|
|
469
|
-
try {
|
|
470
|
-
const raw = await readFile2(metaPath(home, id), "utf8");
|
|
471
|
-
return sessionMetaSchema.parse(JSON.parse(raw));
|
|
472
|
-
} catch (err) {
|
|
473
|
-
if (err.code === "ENOENT") return null;
|
|
474
|
-
throw err;
|
|
475
|
-
}
|
|
476
|
-
}
|
|
477
|
-
async function listSessionIds(home) {
|
|
478
|
-
try {
|
|
479
|
-
const entries = await readdir(sessionsDir(home), { withFileTypes: true });
|
|
480
|
-
return entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name);
|
|
481
|
-
} catch (err) {
|
|
482
|
-
if (err.code === "ENOENT") return [];
|
|
483
|
-
throw err;
|
|
484
|
-
}
|
|
485
|
-
}
|
|
486
|
-
|
|
487
|
-
// packages/server/src/app.ts
|
|
488
|
-
var MAX_UPLOAD_BYTES = 25 * 1024 * 1024;
|
|
489
|
-
|
|
490
|
-
// packages/server/src/lifecycle.ts
|
|
491
|
-
import { open, readFile as readFile3, rm as rm2 } from "node:fs/promises";
|
|
492
|
-
function readServerInfo(home) {
|
|
493
|
-
return readPidfile(pidfilePath(home));
|
|
494
|
-
}
|
|
495
|
-
async function readPidfile(file) {
|
|
496
|
-
try {
|
|
497
|
-
const raw = await readFile3(file, "utf8");
|
|
498
|
-
const parsed = JSON.parse(raw);
|
|
499
|
-
if (typeof parsed.pid === "number" && typeof parsed.port === "number") return parsed;
|
|
500
|
-
return null;
|
|
501
|
-
} catch {
|
|
502
|
-
return null;
|
|
503
|
-
}
|
|
504
|
-
}
|
|
505
|
-
|
|
506
|
-
// packages/server/src/port.ts
|
|
507
|
-
var DEFAULT_PORT = 4319;
|
|
508
|
-
var DEFAULT_BIND_HOST = "127.0.0.1";
|
|
509
|
-
function resolveBindHost(argv = process.argv.slice(2)) {
|
|
510
|
-
const flag = readFlag(argv, "--host");
|
|
511
|
-
if (flag !== void 0 && flag.length > 0) return flag;
|
|
512
|
-
const env = process.env.CHAMBA_HOST;
|
|
513
|
-
if (env !== void 0 && env.length > 0) return env;
|
|
514
|
-
return DEFAULT_BIND_HOST;
|
|
515
|
-
}
|
|
516
|
-
function resolvePort(argv = process.argv.slice(2), home) {
|
|
517
|
-
const flag = readFlag(argv, "--port");
|
|
518
|
-
if (flag !== void 0) return parsePort(flag, "--port");
|
|
519
|
-
const env = process.env.CHAMBA_PORT;
|
|
520
|
-
if (env !== void 0 && env.length > 0) return parsePort(env, "CHAMBA_PORT");
|
|
521
|
-
return home !== void 0 ? derivePort(home) : DEFAULT_PORT;
|
|
522
|
-
}
|
|
523
|
-
function derivePort(home) {
|
|
524
|
-
let hash = 2166136261;
|
|
525
|
-
for (let i = 0; i < home.length; i++) {
|
|
526
|
-
hash ^= home.charCodeAt(i);
|
|
527
|
-
hash = Math.imul(hash, 16777619);
|
|
528
|
-
}
|
|
529
|
-
return DEFAULT_PORT + Math.abs(hash) % 512;
|
|
530
|
-
}
|
|
531
|
-
function readFlag(argv, name) {
|
|
532
|
-
for (let i = 0; i < argv.length; i++) {
|
|
533
|
-
const arg = argv[i];
|
|
534
|
-
if (arg === name) return argv[i + 1];
|
|
535
|
-
if (arg?.startsWith(`${name}=`)) return arg.slice(name.length + 1);
|
|
536
|
-
}
|
|
537
|
-
return void 0;
|
|
538
|
-
}
|
|
539
|
-
function parsePort(value, source) {
|
|
540
|
-
const port = Number(value);
|
|
541
|
-
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
542
|
-
throw new Error(`invalid ${source}: ${value}`);
|
|
543
|
-
}
|
|
544
|
-
return port;
|
|
545
|
-
}
|
|
546
|
-
|
|
547
|
-
// packages/server/src/index.ts
|
|
548
|
-
var DEFAULT_IDLE_MS = 5 * 6e4;
|
|
549
|
-
|
|
550
|
-
// packages/cli/src/args.ts
|
|
551
|
-
function parseArgs(argv) {
|
|
552
|
-
const positionals = [];
|
|
553
|
-
const flags = {};
|
|
554
|
-
for (let i = 0; i < argv.length; i++) {
|
|
555
|
-
const arg = argv[i];
|
|
556
|
-
if (arg === void 0) continue;
|
|
557
|
-
if (!arg.startsWith("--")) {
|
|
558
|
-
positionals.push(arg);
|
|
559
|
-
continue;
|
|
560
|
-
}
|
|
561
|
-
const eq = arg.indexOf("=");
|
|
562
|
-
if (eq !== -1) {
|
|
563
|
-
flags[arg.slice(2, eq)] = arg.slice(eq + 1);
|
|
564
|
-
continue;
|
|
565
|
-
}
|
|
566
|
-
const next = argv[i + 1];
|
|
567
|
-
if (next !== void 0 && !next.startsWith("-")) {
|
|
568
|
-
flags[arg.slice(2)] = next;
|
|
569
|
-
i++;
|
|
570
|
-
} else {
|
|
571
|
-
flags[arg.slice(2)] = "true";
|
|
572
|
-
}
|
|
573
|
-
}
|
|
574
|
-
return { positionals, flags };
|
|
575
|
-
}
|
|
576
|
-
|
|
577
|
-
// packages/cli/src/client.ts
|
|
578
|
-
import { readFile as readFile4 } from "node:fs/promises";
|
|
579
|
-
import path5 from "node:path";
|
|
580
|
-
var ChannelError = class extends Error {
|
|
581
|
-
constructor(status2, message) {
|
|
582
|
-
super(message);
|
|
583
|
-
this.status = status2;
|
|
584
|
-
this.name = "ChannelError";
|
|
585
|
-
}
|
|
586
|
-
status;
|
|
587
|
-
};
|
|
588
|
-
var Client = class {
|
|
589
|
-
constructor(baseUrl) {
|
|
590
|
-
this.baseUrl = baseUrl;
|
|
591
|
-
}
|
|
592
|
-
baseUrl;
|
|
593
|
-
/** Liveness probe with a short deadline; never throws - `false` means "spawn it". */
|
|
594
|
-
async isUp(timeoutMs = 800) {
|
|
595
|
-
try {
|
|
596
|
-
const res = await this.fetch("/api/health", { method: "GET", timeoutMs });
|
|
597
|
-
return res.ok;
|
|
598
|
-
} catch {
|
|
599
|
-
return false;
|
|
600
|
-
}
|
|
601
|
-
}
|
|
602
|
-
async health() {
|
|
603
|
-
return this.json("/api/health", { method: "GET", timeoutMs: 2e3 }, healthResSchema);
|
|
604
|
-
}
|
|
605
|
-
/** True when a server answers here *and* reports `home` as the workspace it
|
|
606
|
-
* owns - the identity check that keeps commands off a foreign server sharing
|
|
607
|
-
* the port (derived ports can collide across projects). Never throws. */
|
|
608
|
-
async ownsHome(home) {
|
|
609
|
-
if (!await this.isUp()) return false;
|
|
610
|
-
try {
|
|
611
|
-
return (await this.health()).home === home;
|
|
612
|
-
} catch {
|
|
613
|
-
return false;
|
|
614
|
-
}
|
|
615
|
-
}
|
|
616
|
-
async create(req) {
|
|
617
|
-
const res = await this.json(
|
|
618
|
-
"/api/sessions",
|
|
619
|
-
{ method: "POST", body: req, timeoutMs: 5e3 },
|
|
620
|
-
sessionResSchema
|
|
621
|
-
);
|
|
622
|
-
return res.session;
|
|
623
|
-
}
|
|
624
|
-
async list() {
|
|
625
|
-
const res = await this.json("/api/sessions", { method: "GET", timeoutMs: 5e3 }, listResSchema);
|
|
626
|
-
return res.sessions;
|
|
627
|
-
}
|
|
628
|
-
async get(id) {
|
|
629
|
-
const res = await this.json(
|
|
630
|
-
`/api/sessions/${encodeURIComponent(id)}`,
|
|
631
|
-
{ method: "GET", timeoutMs: 5e3 },
|
|
632
|
-
sessionResSchema
|
|
633
|
-
);
|
|
634
|
-
return res.session;
|
|
635
|
-
}
|
|
636
|
-
async postMessage(id, body) {
|
|
637
|
-
const res = await this.json(
|
|
638
|
-
`/api/sessions/${encodeURIComponent(id)}/messages`,
|
|
639
|
-
{ method: "POST", body, timeoutMs: 5e3 },
|
|
640
|
-
eventResSchema
|
|
641
|
-
);
|
|
642
|
-
return res.event;
|
|
643
|
-
}
|
|
644
|
-
async reply(id, body) {
|
|
645
|
-
const res = await this.json(
|
|
646
|
-
`/api/sessions/${encodeURIComponent(id)}/reply`,
|
|
647
|
-
{ method: "POST", body, timeoutMs: 5e3 },
|
|
648
|
-
eventResSchema
|
|
649
|
-
);
|
|
650
|
-
return res.event;
|
|
651
|
-
}
|
|
652
|
-
/** Show or revise a page (self-contained HTML for the Illustration tab). */
|
|
653
|
-
async showPage(id, body) {
|
|
654
|
-
const res = await this.json(
|
|
655
|
-
`/api/sessions/${encodeURIComponent(id)}/pages`,
|
|
656
|
-
{ method: "POST", body, timeoutMs: 1e4 },
|
|
657
|
-
eventResSchema
|
|
658
|
-
);
|
|
659
|
-
return res.event;
|
|
660
|
-
}
|
|
661
|
-
/**
|
|
662
|
-
* Upload a local file into the session, returning its stored reference. The
|
|
663
|
-
* agent uses this to send an image back (`reply --image`); the returned id is
|
|
664
|
-
* then referenced from a `reply`. Multipart, so the server persists the raw
|
|
665
|
-
* bytes into the session directory rather than smuggling them through JSON.
|
|
666
|
-
*/
|
|
667
|
-
async uploadAttachment(id, filePath) {
|
|
668
|
-
const bytes = await readFile4(filePath);
|
|
669
|
-
const form = new FormData();
|
|
670
|
-
form.append("file", new Blob([bytes]), path5.basename(filePath));
|
|
671
|
-
const res = await this.json(
|
|
672
|
-
`/api/sessions/${encodeURIComponent(id)}/attachments`,
|
|
673
|
-
{ method: "POST", body: form, timeoutMs: 3e4 },
|
|
674
|
-
attachmentResSchema
|
|
675
|
-
);
|
|
676
|
-
return res.attachment;
|
|
677
|
-
}
|
|
678
|
-
/**
|
|
679
|
-
* The blocking long-poll. The server parks for up to `waitMs`; the client
|
|
680
|
-
* deadline is a little longer so a healthy server always answers before the
|
|
681
|
-
* client gives up (a client timeout would look like a lost message).
|
|
682
|
-
*/
|
|
683
|
-
async poll(id, since, waitMs) {
|
|
684
|
-
return this.json(
|
|
685
|
-
`/api/sessions/${encodeURIComponent(id)}/poll?since=${since}&wait=${waitMs}`,
|
|
686
|
-
{ method: "GET", timeoutMs: waitMs + 5e3 },
|
|
687
|
-
pollResSchema
|
|
688
|
-
);
|
|
689
|
-
}
|
|
690
|
-
async events(id, since, limit) {
|
|
691
|
-
const query = limit === void 0 ? `since=${since}` : `since=${since}&limit=${limit}`;
|
|
692
|
-
return this.json(
|
|
693
|
-
`/api/sessions/${encodeURIComponent(id)}/events?${query}`,
|
|
694
|
-
{ method: "GET", timeoutMs: 5e3 },
|
|
695
|
-
pollResSchema
|
|
696
|
-
);
|
|
697
|
-
}
|
|
698
|
-
async end(id) {
|
|
699
|
-
const res = await this.json(
|
|
700
|
-
`/api/sessions/${encodeURIComponent(id)}/end`,
|
|
701
|
-
{ method: "POST", timeoutMs: 5e3 },
|
|
702
|
-
sessionResSchema
|
|
703
|
-
);
|
|
704
|
-
return res.session;
|
|
705
|
-
}
|
|
706
|
-
async json(path12, options, schema) {
|
|
707
|
-
const res = await this.fetch(path12, options);
|
|
708
|
-
if (!res.ok) throw await toError(res);
|
|
709
|
-
return schema.parse(await res.json());
|
|
710
|
-
}
|
|
711
|
-
async fetch(path12, options) {
|
|
712
|
-
const { method = "GET", body, timeoutMs } = options;
|
|
713
|
-
const controller = timeoutMs !== void 0 ? new AbortController() : void 0;
|
|
714
|
-
const timer = controller && timeoutMs !== void 0 ? setTimeout(() => controller.abort(), timeoutMs) : void 0;
|
|
715
|
-
try {
|
|
716
|
-
return await fetch(new URL(path12, this.baseUrl), {
|
|
717
|
-
method,
|
|
718
|
-
signal: controller?.signal,
|
|
719
|
-
...bodyInit(body)
|
|
720
|
-
});
|
|
721
|
-
} finally {
|
|
722
|
-
if (timer) clearTimeout(timer);
|
|
723
|
-
}
|
|
724
|
-
}
|
|
725
|
-
};
|
|
726
|
-
function bodyInit(body) {
|
|
727
|
-
if (body === void 0) return {};
|
|
728
|
-
if (body instanceof FormData) return { body };
|
|
729
|
-
return { headers: { "content-type": "application/json" }, body: JSON.stringify(body) };
|
|
730
|
-
}
|
|
731
|
-
async function toError(res) {
|
|
732
|
-
const message = await errorBodyMessage(res) ?? `${res.status} ${res.statusText}`;
|
|
733
|
-
return new ChannelError(res.status, message);
|
|
734
|
-
}
|
|
735
|
-
|
|
736
|
-
// packages/cli/src/output.ts
|
|
737
|
-
import path6 from "node:path";
|
|
738
|
-
function out(message) {
|
|
739
|
-
process.stdout.write(`${message}
|
|
740
|
-
`);
|
|
741
|
-
}
|
|
742
|
-
function note(message) {
|
|
743
|
-
process.stderr.write(`${message}
|
|
744
|
-
`);
|
|
745
|
-
}
|
|
746
|
-
function formatSystemEvent(event) {
|
|
747
|
-
const parsed = systemPayloadSchema.safeParse(event.payload);
|
|
748
|
-
if (!parsed.success) return `\xB7 ${eventText(event)}`;
|
|
749
|
-
switch (parsed.data.reason) {
|
|
750
|
-
case "session-ended":
|
|
751
|
-
return "\xB7 Session ended";
|
|
752
|
-
case "session-created":
|
|
753
|
-
return "\xB7 Session created";
|
|
754
|
-
}
|
|
755
|
-
}
|
|
756
|
-
function eventMarkers(event) {
|
|
757
|
-
if (!event.payload || typeof event.payload !== "object") return "";
|
|
758
|
-
const record = event.payload;
|
|
759
|
-
const parts = [];
|
|
760
|
-
const ref = replyRefSchema.safeParse(record.replyTo);
|
|
761
|
-
if (ref.success) {
|
|
762
|
-
const who = sourceLabel(ref.data.source);
|
|
763
|
-
const snippet = ref.data.snippet ? `: "${ref.data.snippet}"` : "";
|
|
764
|
-
parts.push(`[re #${ref.data.seq} ${who}${snippet}]`);
|
|
765
|
-
}
|
|
766
|
-
if (record.answerIn === "terminal") parts.push("[answer in terminal]");
|
|
767
|
-
return parts.length > 0 ? ` ${parts.join(" ")}` : "";
|
|
768
|
-
}
|
|
769
|
-
function formatEvent(event) {
|
|
770
|
-
if (event.source === "system") return formatSystemEvent(event);
|
|
771
|
-
const who = sourceLabel(event.source);
|
|
772
|
-
const markers = eventMarkers(event);
|
|
773
|
-
const text = eventText(event);
|
|
774
|
-
return text ? `${who}${markers}: ${text}` : `${who}${markers}:`;
|
|
775
|
-
}
|
|
776
|
-
function annotationItems(event) {
|
|
777
|
-
const parsed = annotationBatchPayloadSchema.safeParse(event.payload);
|
|
778
|
-
return parsed.success ? parsed.data.items : [];
|
|
779
|
-
}
|
|
780
|
-
function formatAnnotationBatch(event, home) {
|
|
781
|
-
const items = annotationItems(event);
|
|
782
|
-
const who = sourceLabel(event.source);
|
|
783
|
-
const lines = [`${who} [${items.length} annotation${items.length === 1 ? "" : "s"}]:`];
|
|
784
|
-
items.forEach((item, index) => {
|
|
785
|
-
const { target } = item;
|
|
786
|
-
lines.push(` ${index + 1}. ${item.note ?? "(no comment)"}`);
|
|
787
|
-
lines.push(` element: ${target.label ?? target.selector}`);
|
|
788
|
-
lines.push(` selector: ${target.selector}`);
|
|
789
|
-
lines.push(
|
|
790
|
-
` page: ${target.pageUrl} viewport: ${target.viewport.width}x${target.viewport.height}`
|
|
791
|
-
);
|
|
792
|
-
if (target.component) {
|
|
793
|
-
const source = target.component.source;
|
|
794
|
-
const at = source ? ` (${source.file}${source.line ? `:${source.line}` : ""})` : "";
|
|
795
|
-
lines.push(` component: ${target.component.name}${at}`);
|
|
796
|
-
}
|
|
797
|
-
if (item.screenshot) {
|
|
798
|
-
const abs = path6.join(sessionDir(home, event.sessionId), item.screenshot.relPath);
|
|
799
|
-
lines.push(` [shot] ${item.screenshot.name} -> ${abs}`);
|
|
800
|
-
}
|
|
801
|
-
});
|
|
802
|
-
return lines;
|
|
803
|
-
}
|
|
804
|
-
function formatPage(event, home) {
|
|
805
|
-
const parsed = pageRefSchema.safeParse(event.payload?.page);
|
|
806
|
-
if (!parsed.success) return formatEvent(event);
|
|
807
|
-
const ref = parsed.data;
|
|
808
|
-
const abs = path6.join(sessionDir(home, event.sessionId), ref.relPath);
|
|
809
|
-
return `agent [page "${ref.name}"] -> ${abs}`;
|
|
810
|
-
}
|
|
811
|
-
function printEvents(events, home) {
|
|
812
|
-
for (const event of events) {
|
|
813
|
-
if (event.type === ANNOTATION_BATCH) {
|
|
814
|
-
for (const line of formatAnnotationBatch(event, home)) out(line);
|
|
815
|
-
continue;
|
|
816
|
-
}
|
|
817
|
-
if (event.type === PAGE) {
|
|
818
|
-
out(formatPage(event, home));
|
|
819
|
-
continue;
|
|
820
|
-
}
|
|
821
|
-
out(formatEvent(event));
|
|
822
|
-
for (const att of eventAttachments(event)) {
|
|
823
|
-
out(` [file] ${att.name} -> ${path6.join(sessionDir(home, event.sessionId), att.relPath)}`);
|
|
824
|
-
}
|
|
825
|
-
}
|
|
826
|
-
}
|
|
827
|
-
function formatSession(meta, presence, current) {
|
|
828
|
-
const marker = current ? "*" : " ";
|
|
829
|
-
const title = meta.title ? ` "${meta.title}"` : "";
|
|
830
|
-
const live = presence ? ` ${presence}` : "";
|
|
831
|
-
return `${marker} ${meta.id} ${meta.status}${live}${title}`;
|
|
832
|
-
}
|
|
833
|
-
|
|
834
|
-
// packages/cli/src/state.ts
|
|
835
|
-
import { mkdir as mkdir2, readFile as readFile5 } from "node:fs/promises";
|
|
836
|
-
import path7 from "node:path";
|
|
837
|
-
import { z as z10 } from "zod";
|
|
838
|
-
var agentStateSchema = z10.object({
|
|
839
|
-
currentSessionId: z10.string().optional(),
|
|
840
|
-
bindings: z10.record(z10.string(), z10.string()).default({}),
|
|
841
|
-
cursors: z10.record(z10.string(), z10.number().int().nonnegative()).default({})
|
|
842
|
-
});
|
|
843
|
-
var EMPTY = { bindings: {}, cursors: {} };
|
|
844
|
-
async function readState(home = resolveHome()) {
|
|
845
|
-
try {
|
|
846
|
-
const raw = await readFile5(agentStatePath(home), "utf8");
|
|
847
|
-
return agentStateSchema.parse(JSON.parse(raw));
|
|
848
|
-
} catch {
|
|
849
|
-
return EMPTY;
|
|
850
|
-
}
|
|
851
|
-
}
|
|
852
|
-
async function writeState(state, home = resolveHome()) {
|
|
853
|
-
const file = agentStatePath(home);
|
|
854
|
-
await mkdir2(path7.dirname(file), { recursive: true });
|
|
855
|
-
await writeJsonAtomic(file, state);
|
|
856
|
-
}
|
|
857
|
-
function cursorFor(state, id) {
|
|
858
|
-
return state.cursors[id] ?? 0;
|
|
859
|
-
}
|
|
860
|
-
function withSession(state, id, cursor, key) {
|
|
861
|
-
return {
|
|
862
|
-
currentSessionId: id,
|
|
863
|
-
bindings: key ? { ...state.bindings, [key]: id } : state.bindings,
|
|
864
|
-
cursors: { ...state.cursors, [id]: cursor }
|
|
865
|
-
};
|
|
866
|
-
}
|
|
867
|
-
|
|
868
|
-
// packages/cli/src/commands/context.ts
|
|
869
|
-
var UsageError = class extends Error {
|
|
870
|
-
constructor(message) {
|
|
871
|
-
super(message);
|
|
872
|
-
this.name = "UsageError";
|
|
873
|
-
}
|
|
874
|
-
};
|
|
875
|
-
function agentKey(env = process.env) {
|
|
876
|
-
return env.CHAMBA_SESSION_KEY || env.CLAUDE_CODE_SESSION_ID || void 0;
|
|
877
|
-
}
|
|
878
|
-
async function resolveSessionId(ctx, env = process.env) {
|
|
879
|
-
const flag = ctx.flags.session;
|
|
880
|
-
if (flag === "true" || flag === "") {
|
|
881
|
-
throw new UsageError("`--session` needs a session id, e.g. --session <id>");
|
|
882
|
-
}
|
|
883
|
-
if (flag) return flag;
|
|
884
|
-
const state = await readState(ctx.home);
|
|
885
|
-
const key = agentKey(env);
|
|
886
|
-
if (key) {
|
|
887
|
-
const bound = state.bindings[key];
|
|
888
|
-
if (bound) return bound;
|
|
889
|
-
throw new UsageError("no session bound to this agent - run `chamba open`");
|
|
890
|
-
}
|
|
891
|
-
if (state.currentSessionId) return state.currentSessionId;
|
|
892
|
-
throw new UsageError("no current session - run `chamba open` or pass --session <id>");
|
|
893
|
-
}
|
|
894
|
-
function intFlag(value, fallback) {
|
|
895
|
-
if (value === void 0) return fallback;
|
|
896
|
-
const n = Number(value);
|
|
897
|
-
return Number.isInteger(n) && n >= 0 ? n : fallback;
|
|
898
|
-
}
|
|
899
|
-
|
|
900
|
-
// packages/cli/src/commands/end.ts
|
|
901
|
-
async function end(ctx) {
|
|
902
|
-
const id = await resolveSessionId(ctx);
|
|
903
|
-
const summary = await ctx.client.end(id);
|
|
904
|
-
note(`session ${summary.meta.id} ended`);
|
|
905
|
-
return 0;
|
|
906
|
-
}
|
|
907
|
-
|
|
908
|
-
// packages/cli/src/commands/get.ts
|
|
909
|
-
async function get(ctx) {
|
|
910
|
-
const input = ctx.positionals.join(" ").trim();
|
|
911
|
-
if (!input) {
|
|
912
|
-
throw new UsageError("usage: chamba get <ref> (42, <session>#42, or a pasted token)");
|
|
913
|
-
}
|
|
914
|
-
const ref = parseRef(input);
|
|
915
|
-
if (!ref) throw new UsageError(`not a message reference: ${input}`);
|
|
916
|
-
const id = ref.session ?? await resolveSessionId(ctx);
|
|
917
|
-
const res = await ctx.client.events(id, ref.seq, 1);
|
|
918
|
-
const event = res.events[0];
|
|
919
|
-
if (!event || event.seq !== ref.seq) {
|
|
920
|
-
note(`no event #${ref.seq} in session ${id} on this server`);
|
|
921
|
-
return 1;
|
|
922
|
-
}
|
|
923
|
-
printEvents([event], ctx.home);
|
|
924
|
-
return 0;
|
|
925
|
-
}
|
|
926
|
-
|
|
927
|
-
// packages/cli/src/commands/help.ts
|
|
928
|
-
var USAGE = `chamba - a browser companion for your terminal agent
|
|
929
|
-
|
|
930
|
-
Usage:
|
|
931
|
-
chamba list sessions and show which is current
|
|
932
|
-
chamba init [dir] create the .chamba workspace (at the git root, or
|
|
933
|
-
[--here] <dir>, or the current dir with --here); prints its
|
|
934
|
-
location and per-project port
|
|
935
|
-
chamba open [--title T] open a session and print its URL (creates the
|
|
936
|
-
[--new] workspace at the repo root if none yet). Reconnects
|
|
937
|
-
[--last] to this agent's bound session when one is active;
|
|
938
|
-
--new always creates, --last rebinds to the newest
|
|
939
|
-
active session
|
|
940
|
-
chamba poll wait for the next human/system events, blocking
|
|
941
|
-
[--wait ms] indefinitely until a batch arrives (zero cost while
|
|
942
|
-
[--for ms] quiet). --for bounds the wait: after a quiet stretch
|
|
943
|
-
[--once] it prints a heartbeat and exits 0 - just re-run.
|
|
944
|
-
--once does one bounded check instead (scripts/
|
|
945
|
-
tests); --wait is the per-request window (max 30s)
|
|
946
|
-
chamba reply <text> send a line from the agent into the session
|
|
947
|
-
[--to <seq>] mark it as answering a specific earlier event
|
|
948
|
-
[--file a,b] attach files by path (comma-separated); --image is
|
|
949
|
-
[--image a,b] an alias. text is optional when files are attached
|
|
950
|
-
chamba get <ref> print one event by reference: 42, <session>#42, or
|
|
951
|
-
a pasted [chamba <session>#42 "..."] token
|
|
952
|
-
chamba show <file.html> render a self-contained HTML page in the browser's
|
|
953
|
-
[--name N] Illustration tab. Re-show the same name to revise
|
|
954
|
-
chamba end end the current session
|
|
955
|
-
chamba stop stop this project's server (it otherwise self-stops
|
|
956
|
-
after an idle spell); the next open starts a fresh one
|
|
957
|
-
|
|
958
|
-
chamba skill install install the chamba skill (/chamba) into
|
|
959
|
-
[--global] ./.claude/skills, or ~/.claude/skills with --global
|
|
960
|
-
chamba --version print the chamba version
|
|
961
|
-
chamba help show this help
|
|
962
|
-
|
|
963
|
-
Global:
|
|
964
|
-
--session <id> act on a specific session instead of the bound one
|
|
965
|
-
--port <n> server port (default: CHAMBA_PORT, else a per-project
|
|
966
|
-
port derived from the workspace location)`;
|
|
967
|
-
function help(_ctx) {
|
|
968
|
-
note(USAGE);
|
|
969
|
-
return Promise.resolve(0);
|
|
970
|
-
}
|
|
971
|
-
|
|
972
|
-
// packages/cli/src/commands/init.ts
|
|
973
|
-
import path8 from "node:path";
|
|
974
|
-
async function init(ctx) {
|
|
975
|
-
const target = resolveTarget(ctx);
|
|
976
|
-
const existing = findExistingHome({ cwd: target, env: {} });
|
|
977
|
-
if (existing && !ctx.flags.here) {
|
|
978
|
-
report("chamba workspace already exists", existing);
|
|
979
|
-
return 0;
|
|
980
|
-
}
|
|
981
|
-
if (existing && ctx.flags.here) {
|
|
982
|
-
note(`note: shadowing an existing workspace at ${existing}`);
|
|
983
|
-
}
|
|
984
|
-
await ensureChambaHome(path8.join(target, ".chamba"));
|
|
985
|
-
const home = findExistingHome({ cwd: target, env: {} }) ?? path8.join(target, ".chamba");
|
|
986
|
-
report("created chamba workspace", home);
|
|
987
|
-
if (path8.basename(home) !== ".chamba") {
|
|
988
|
-
note("warning: home is not a .chamba directory, so it will not be auto-gitignored");
|
|
989
|
-
}
|
|
990
|
-
return 0;
|
|
991
|
-
}
|
|
992
|
-
function resolveTarget(ctx) {
|
|
993
|
-
const explicit = ctx.positionals[0];
|
|
994
|
-
if (explicit) return path8.resolve(explicit);
|
|
995
|
-
const root = gitRoot(process.cwd());
|
|
996
|
-
if (root) return root;
|
|
997
|
-
if (ctx.flags.here) return path8.resolve(process.cwd());
|
|
998
|
-
throw new UsageError(
|
|
999
|
-
"no .git found here - pass a directory (`chamba init <dir>`) or `chamba init --here` to choose where .chamba lives"
|
|
1000
|
-
);
|
|
1001
|
-
}
|
|
1002
|
-
function report(headline, home) {
|
|
1003
|
-
note(headline);
|
|
1004
|
-
note(`home: ${home} port: ${derivePort(home)}`);
|
|
1005
|
-
note("each project gets its own server on its own port; other projects are unaffected");
|
|
1006
|
-
}
|
|
1007
|
-
|
|
1008
|
-
// packages/cli/src/launch.ts
|
|
1009
|
-
import { spawn, spawnSync } from "node:child_process";
|
|
1010
|
-
import { existsSync as existsSync5 } from "node:fs";
|
|
1011
|
-
import { createRequire } from "node:module";
|
|
1012
|
-
import path9 from "node:path";
|
|
1013
|
-
import { fileURLToPath as fileURLToPath4, pathToFileURL } from "node:url";
|
|
1014
|
-
var require2 = createRequire(import.meta.url);
|
|
1015
|
-
async function ensureServer(client, target) {
|
|
1016
|
-
if (await client.isUp()) {
|
|
1017
|
-
await verifyHome(client, target);
|
|
1018
|
-
return { spawned: false };
|
|
1019
|
-
}
|
|
1020
|
-
ensureDevBuilds();
|
|
1021
|
-
spawnServer(target);
|
|
1022
|
-
await waitUntilUp(client, target);
|
|
1023
|
-
return { spawned: true };
|
|
1024
|
-
}
|
|
1025
|
-
async function verifyHome(client, target) {
|
|
1026
|
-
const health = await client.health();
|
|
1027
|
-
if (health.home === target.home) return;
|
|
1028
|
-
if (health.home === void 0) {
|
|
1029
|
-
throw new Error(
|
|
1030
|
-
`a chamba server already owns port ${target.port} but predates workspace isolation - stop that process and retry`
|
|
1031
|
-
);
|
|
1032
|
-
}
|
|
1033
|
-
throw new Error(
|
|
1034
|
-
`port ${target.port} is serving a different chamba workspace (${health.home}); run from that project, or set --port / CHAMBA_PORT to run a separate server`
|
|
1035
|
-
);
|
|
1036
|
-
}
|
|
1037
|
-
function spawnServer(target) {
|
|
1038
|
-
const child = spawn(process.execPath, serverLaunchArgs(), {
|
|
1039
|
-
detached: true,
|
|
1040
|
-
stdio: "ignore",
|
|
1041
|
-
env: { ...process.env, CHAMBA_DIR: target.home, CHAMBA_PORT: String(target.port) }
|
|
1042
|
-
});
|
|
1043
|
-
child.unref();
|
|
1044
|
-
}
|
|
1045
|
-
function serverLaunchArgs() {
|
|
1046
|
-
const here = path9.dirname(fileURLToPath4(import.meta.url));
|
|
1047
|
-
const packagedServer = path9.join(here, "server.js");
|
|
1048
|
-
if (existsSync5(packagedServer)) return [packagedServer];
|
|
1049
|
-
const serverBin = require2.resolve("@chamba/server/bin");
|
|
1050
|
-
const tsxLoader = pathToFileURL(require2.resolve("tsx")).href;
|
|
1051
|
-
return ["--import", tsxLoader, serverBin];
|
|
1052
|
-
}
|
|
1053
|
-
function ensureDevBuilds() {
|
|
1054
|
-
const here = path9.dirname(fileURLToPath4(import.meta.url));
|
|
1055
|
-
if (existsSync5(path9.join(here, "server.js"))) return;
|
|
1056
|
-
const repoRoot = path9.resolve(path9.dirname(require2.resolve("@chamba/server/bin")), "../../..");
|
|
1057
|
-
const deps = [
|
|
1058
|
-
{ label: "web UI", filter: "@chamba/web", dist: resolveWebDist(), marker: "index.html" },
|
|
1059
|
-
{
|
|
1060
|
-
label: "annotation client",
|
|
1061
|
-
filter: "@chamba/inject",
|
|
1062
|
-
dist: resolveInjectDist(),
|
|
1063
|
-
marker: "annotate.js"
|
|
1064
|
-
}
|
|
1065
|
-
];
|
|
1066
|
-
for (const dep of deps) {
|
|
1067
|
-
if (existsSync5(path9.join(dep.dist, dep.marker))) continue;
|
|
1068
|
-
note(`building chamba ${dep.label} (first run in this environment)...`);
|
|
1069
|
-
const result = spawnSync("pnpm", ["--filter", dep.filter, "build"], {
|
|
1070
|
-
cwd: repoRoot,
|
|
1071
|
-
encoding: "utf8"
|
|
1072
|
-
});
|
|
1073
|
-
if (result.error || result.status !== 0) {
|
|
1074
|
-
const detail = `${result.stderr ?? ""}${result.stdout ?? ""}`.trim();
|
|
1075
|
-
throw new Error(
|
|
1076
|
-
`could not build ${dep.filter} (needed to serve the ${dep.label})${detail ? `:
|
|
1077
|
-
${detail}` : ""}`
|
|
1078
|
-
);
|
|
1079
|
-
}
|
|
1080
|
-
}
|
|
1081
|
-
}
|
|
1082
|
-
async function waitUntilUp(client, target, timeoutMs = 1e4) {
|
|
1083
|
-
const deadline = Date.now() + timeoutMs;
|
|
1084
|
-
while (Date.now() < deadline) {
|
|
1085
|
-
if (await client.isUp()) {
|
|
1086
|
-
const health = await client.health();
|
|
1087
|
-
if (health.home === target.home) return;
|
|
1088
|
-
throw new Error(
|
|
1089
|
-
`port ${target.port} was taken by a different chamba workspace (${health.home ?? "unknown"})`
|
|
1090
|
-
);
|
|
1091
|
-
}
|
|
1092
|
-
await delay(150);
|
|
1093
|
-
}
|
|
1094
|
-
throw new Error("server did not become healthy in time");
|
|
1095
|
-
}
|
|
1096
|
-
function delay(ms) {
|
|
1097
|
-
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
1098
|
-
}
|
|
1099
|
-
|
|
1100
|
-
// packages/cli/src/commands/open.ts
|
|
1101
|
-
async function open2(ctx) {
|
|
1102
|
-
if (!ctx.workspaceExists) {
|
|
1103
|
-
if (!gitRoot(process.cwd())) {
|
|
1104
|
-
throw new UsageError(
|
|
1105
|
-
"no chamba workspace, and no .git here to anchor one - run `chamba init <dir>` or `chamba init --here` to choose where .chamba lives"
|
|
1106
|
-
);
|
|
1107
|
-
}
|
|
1108
|
-
await ensureChambaHome(ctx.home);
|
|
1109
|
-
note(`created chamba workspace at ${ctx.home}`);
|
|
1110
|
-
}
|
|
1111
|
-
note(`home: ${ctx.home} port: ${ctx.port}`);
|
|
1112
|
-
const { spawned } = await ensureServer(ctx.client, { home: ctx.home, port: ctx.port });
|
|
1113
|
-
if (spawned) note(`started chamba server on :${ctx.port}`);
|
|
1114
|
-
const key = agentKey();
|
|
1115
|
-
const state = await readState(ctx.home);
|
|
1116
|
-
const existing = await resolveExisting(ctx, key, state);
|
|
1117
|
-
const meta = existing ?? await create(ctx, key);
|
|
1118
|
-
const cursor = state.cursors[meta.id] ?? (existing ? 0 : 1);
|
|
1119
|
-
await writeState(withSession(state, meta.id, cursor, key), ctx.home);
|
|
1120
|
-
out(sessionPageUrl(`http://localhost:${ctx.port}`, meta.id));
|
|
1121
|
-
note(" in a container? if this link is unreachable from your host:");
|
|
1122
|
-
note(
|
|
1123
|
-
` - the server bound ${resolveBindHost([])}; set CHAMBA_HOST=0.0.0.0 so the host can reach it`
|
|
1124
|
-
);
|
|
1125
|
-
note(` - forward port ${ctx.port} to the host (1:1); pin it with CHAMBA_PORT to keep it stable`);
|
|
1126
|
-
if (existing) {
|
|
1127
|
-
note(`reconnected to session ${meta.id} - open the URL, then run \`chamba poll\``);
|
|
1128
|
-
} else {
|
|
1129
|
-
note(
|
|
1130
|
-
`session ${meta.id} created - open the URL, then run \`chamba poll\` to wait for messages`
|
|
1131
|
-
);
|
|
1132
|
-
}
|
|
1133
|
-
return 0;
|
|
1134
|
-
}
|
|
1135
|
-
async function resolveExisting(ctx, key, state) {
|
|
1136
|
-
if (ctx.flags.new !== void 0) return null;
|
|
1137
|
-
const wantLast = ctx.flags.last !== void 0;
|
|
1138
|
-
if (!wantLast && !key) return null;
|
|
1139
|
-
const active = (await ctx.client.list()).filter((s) => s.meta.status === "active").map((s) => s.meta);
|
|
1140
|
-
if (wantLast) {
|
|
1141
|
-
const found = active[0];
|
|
1142
|
-
if (!found)
|
|
1143
|
-
throw new UsageError("no active session to rebind - run `chamba open` to create one");
|
|
1144
|
-
return found;
|
|
1145
|
-
}
|
|
1146
|
-
const bound = key ? state.bindings[key] : void 0;
|
|
1147
|
-
return active.find((m) => m.id === bound) ?? active.find((m) => m.agentKey === key) ?? null;
|
|
1148
|
-
}
|
|
1149
|
-
async function create(ctx, key) {
|
|
1150
|
-
const req = {
|
|
1151
|
-
...ctx.flags.title ? { title: ctx.flags.title } : {},
|
|
1152
|
-
...key ? { agentKey: key } : {}
|
|
1153
|
-
};
|
|
1154
|
-
const summary = await ctx.client.create(req);
|
|
1155
|
-
return summary.meta;
|
|
1156
|
-
}
|
|
1157
|
-
|
|
1158
|
-
// packages/cli/src/errors.ts
|
|
1159
|
-
function errorCode(err) {
|
|
1160
|
-
const seen = /* @__PURE__ */ new Set();
|
|
1161
|
-
const queue = [err];
|
|
1162
|
-
while (queue.length > 0) {
|
|
1163
|
-
const current = queue.shift();
|
|
1164
|
-
if (!current || typeof current !== "object" || seen.has(current)) continue;
|
|
1165
|
-
seen.add(current);
|
|
1166
|
-
const code = current.code;
|
|
1167
|
-
if (typeof code === "string") return code;
|
|
1168
|
-
const cause = current.cause;
|
|
1169
|
-
if (cause) queue.push(cause);
|
|
1170
|
-
const errors = current.errors;
|
|
1171
|
-
if (Array.isArray(errors)) queue.push(...errors);
|
|
1172
|
-
}
|
|
1173
|
-
return void 0;
|
|
1174
|
-
}
|
|
1175
|
-
function isConnectionRefused(err) {
|
|
1176
|
-
return errorCode(err) === "ECONNREFUSED";
|
|
1177
|
-
}
|
|
1178
|
-
|
|
1179
|
-
// packages/cli/src/commands/poll.ts
|
|
1180
|
-
var DEFAULT_FOLLOW_MS = 4 * 6e4;
|
|
1181
|
-
var BACKOFF_START_MS = 500;
|
|
1182
|
-
var BACKOFF_MAX_MS = 5e3;
|
|
1183
|
-
var RESPAWN_BUDGET = 3;
|
|
1184
|
-
var IDLE_GUARD_MS = 1e3;
|
|
1185
|
-
var realClock = {
|
|
1186
|
-
now: () => Date.now(),
|
|
1187
|
-
sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms))
|
|
1188
|
-
};
|
|
1189
|
-
async function poll(ctx, clock = realClock) {
|
|
1190
|
-
const id = await resolveSessionId(ctx);
|
|
1191
|
-
const wait = Math.min(intFlag(ctx.flags.wait, POLL_DEFAULT_WAIT_MS), POLL_MAX_WAIT_MS);
|
|
1192
|
-
if (ctx.flags.once !== void 0) return pollOnce(ctx, id, wait);
|
|
1193
|
-
const followMs = ctx.flags.for !== void 0 ? intFlag(ctx.flags.for, DEFAULT_FOLLOW_MS) : null;
|
|
1194
|
-
let respawnsLeft = RESPAWN_BUDGET;
|
|
1195
|
-
return follow(
|
|
1196
|
-
{
|
|
1197
|
-
now: clock.now,
|
|
1198
|
-
sleep: clock.sleep,
|
|
1199
|
-
cursor: async () => cursorFor(await readState(ctx.home), id),
|
|
1200
|
-
poll: async (since) => {
|
|
1201
|
-
const res = await ctx.client.poll(id, since, wait);
|
|
1202
|
-
respawnsLeft = RESPAWN_BUDGET;
|
|
1203
|
-
return res;
|
|
1204
|
-
},
|
|
1205
|
-
deliver: async (res) => {
|
|
1206
|
-
printEvents(res.events, ctx.home);
|
|
1207
|
-
await writeState(withSession(await readState(ctx.home), id, res.cursor), ctx.home);
|
|
1208
|
-
},
|
|
1209
|
-
recover: async (err) => {
|
|
1210
|
-
if (!isConnectionRefused(err)) return "not-mine";
|
|
1211
|
-
if (!await readServerInfo(ctx.home)) return "server-stopped";
|
|
1212
|
-
if (respawnsLeft <= 0) return "unrecoverable";
|
|
1213
|
-
respawnsLeft -= 1;
|
|
1214
|
-
note("chamba server was down - restarting it");
|
|
1215
|
-
try {
|
|
1216
|
-
await ensureServer(ctx.client, { home: ctx.home, port: ctx.port });
|
|
1217
|
-
return "respawned";
|
|
1218
|
-
} catch (e) {
|
|
1219
|
-
note(`could not restart the server: ${e.message}`);
|
|
1220
|
-
return "unrecoverable";
|
|
1221
|
-
}
|
|
1222
|
-
},
|
|
1223
|
-
onError: (err, giveUp) => note(pollErrorMessage(err, giveUp)),
|
|
1224
|
-
onHeartbeat: () => note("still listening - re-run chamba poll to keep waiting"),
|
|
1225
|
-
onEnded: () => note("session has ended - nothing more to wait for"),
|
|
1226
|
-
onServerStopped: () => note("chamba server stopped - run `chamba open` to continue"),
|
|
1227
|
-
isFatal
|
|
1228
|
-
},
|
|
1229
|
-
// The park floor never drops below the guard sleep, so a small (or zero) `--wait`
|
|
1230
|
-
// - which makes the server answer empty at once - cannot spin the loop hot.
|
|
1231
|
-
{
|
|
1232
|
-
deadline: followMs === null ? null : clock.now() + followMs,
|
|
1233
|
-
parkFloorMs: Math.max(IDLE_GUARD_MS, Math.floor(wait / 2))
|
|
1234
|
-
}
|
|
1235
|
-
);
|
|
1236
|
-
}
|
|
1237
|
-
async function pollOnce(ctx, id, wait) {
|
|
1238
|
-
const state = await readState(ctx.home);
|
|
1239
|
-
const since = cursorFor(state, id);
|
|
1240
|
-
const res = await ctx.client.poll(id, since, wait);
|
|
1241
|
-
if (res.events.length === 0) {
|
|
1242
|
-
note("(no new messages)");
|
|
1243
|
-
return 0;
|
|
1244
|
-
}
|
|
1245
|
-
printEvents(res.events, ctx.home);
|
|
1246
|
-
await writeState(withSession(state, id, res.cursor), ctx.home);
|
|
1247
|
-
return 0;
|
|
1248
|
-
}
|
|
1249
|
-
async function follow(io, cfg) {
|
|
1250
|
-
let backoff = BACKOFF_START_MS;
|
|
1251
|
-
while (true) {
|
|
1252
|
-
const since = await io.cursor();
|
|
1253
|
-
const start = io.now();
|
|
1254
|
-
let res;
|
|
1255
|
-
try {
|
|
1256
|
-
res = await io.poll(since);
|
|
1257
|
-
} catch (err) {
|
|
1258
|
-
if (io.isFatal(err)) {
|
|
1259
|
-
io.onError(err, true);
|
|
1260
|
-
return 1;
|
|
1261
|
-
}
|
|
1262
|
-
const outcome = await io.recover?.(err) ?? "not-mine";
|
|
1263
|
-
if (outcome === "respawned") {
|
|
1264
|
-
backoff = BACKOFF_START_MS;
|
|
1265
|
-
continue;
|
|
1266
|
-
}
|
|
1267
|
-
if (outcome === "server-stopped") {
|
|
1268
|
-
io.onServerStopped?.();
|
|
1269
|
-
return 0;
|
|
1270
|
-
}
|
|
1271
|
-
if (outcome === "unrecoverable") {
|
|
1272
|
-
io.onError(err, true);
|
|
1273
|
-
return 1;
|
|
1274
|
-
}
|
|
1275
|
-
const giveUp = cfg.deadline !== null && io.now() >= cfg.deadline;
|
|
1276
|
-
io.onError(err, giveUp);
|
|
1277
|
-
if (giveUp) return 1;
|
|
1278
|
-
await io.sleep(backoff);
|
|
1279
|
-
backoff = Math.min(backoff * 2, BACKOFF_MAX_MS);
|
|
1280
|
-
continue;
|
|
1281
|
-
}
|
|
1282
|
-
backoff = BACKOFF_START_MS;
|
|
1283
|
-
if (res.events.length > 0) {
|
|
1284
|
-
await io.deliver(res);
|
|
1285
|
-
return 0;
|
|
1286
|
-
}
|
|
1287
|
-
if (res.ended) {
|
|
1288
|
-
io.onEnded?.();
|
|
1289
|
-
return 0;
|
|
1290
|
-
}
|
|
1291
|
-
if (res.stopping) {
|
|
1292
|
-
io.onServerStopped?.();
|
|
1293
|
-
return 0;
|
|
1294
|
-
}
|
|
1295
|
-
if (cfg.deadline !== null && io.now() >= cfg.deadline) {
|
|
1296
|
-
io.onHeartbeat();
|
|
1297
|
-
return 0;
|
|
1298
|
-
}
|
|
1299
|
-
if (io.now() - start < cfg.parkFloorMs) await io.sleep(IDLE_GUARD_MS);
|
|
1300
|
-
}
|
|
1301
|
-
}
|
|
1302
|
-
function isFatal(err) {
|
|
1303
|
-
if (err instanceof ChannelError) return err.status === 404;
|
|
1304
|
-
if (err instanceof SyntaxError) return true;
|
|
1305
|
-
return err instanceof Error && err.name === "ZodError";
|
|
1306
|
-
}
|
|
1307
|
-
function pollErrorMessage(err, giveUp) {
|
|
1308
|
-
if (err instanceof ChannelError && err.status === 404) {
|
|
1309
|
-
return "session not found - it may have been removed";
|
|
1310
|
-
}
|
|
1311
|
-
const detail = err instanceof Error ? err.message : String(err);
|
|
1312
|
-
return giveUp ? `poll failed: ${detail}` : `poll failed, retrying: ${detail}`;
|
|
1313
|
-
}
|
|
1314
|
-
|
|
1315
|
-
// packages/cli/src/commands/reply.ts
|
|
1316
|
-
import { stat } from "node:fs/promises";
|
|
1317
|
-
async function reply(ctx) {
|
|
1318
|
-
const text = ctx.positionals.join(" ").trim();
|
|
1319
|
-
const files = filePaths(ctx.flags);
|
|
1320
|
-
if (!text && files.length === 0) {
|
|
1321
|
-
throw new UsageError("usage: chamba reply <text> [--to <seq>] [--file a,b] [--image a,b]");
|
|
1322
|
-
}
|
|
1323
|
-
const replyTo = replyToSeq(ctx.flags);
|
|
1324
|
-
const id = await resolveSessionId(ctx);
|
|
1325
|
-
for (const file of files) {
|
|
1326
|
-
try {
|
|
1327
|
-
const info = await stat(file);
|
|
1328
|
-
if (!info.isFile()) throw new UsageError(`not a file: ${file}`);
|
|
1329
|
-
} catch (err) {
|
|
1330
|
-
if (err instanceof UsageError) throw err;
|
|
1331
|
-
throw new UsageError(`file not found: ${file}`);
|
|
1332
|
-
}
|
|
1333
|
-
}
|
|
1334
|
-
const attachmentIds = [];
|
|
1335
|
-
for (const file of files) {
|
|
1336
|
-
const attachment = await ctx.client.uploadAttachment(id, file);
|
|
1337
|
-
attachmentIds.push(attachment.id);
|
|
1338
|
-
note(`uploaded ${attachment.name}`);
|
|
1339
|
-
}
|
|
1340
|
-
await ctx.client.reply(id, {
|
|
1341
|
-
...text ? { text } : {},
|
|
1342
|
-
...attachmentIds.length > 0 ? { attachmentIds } : {},
|
|
1343
|
-
...replyTo !== void 0 ? { replyTo } : {}
|
|
1344
|
-
});
|
|
1345
|
-
note(`sent to ${id}`);
|
|
1346
|
-
return 0;
|
|
1347
|
-
}
|
|
1348
|
-
function replyToSeq(flags) {
|
|
1349
|
-
const raw = flags.to;
|
|
1350
|
-
if (raw === void 0) return void 0;
|
|
1351
|
-
const n = Number(raw);
|
|
1352
|
-
if (raw === "true" || !Number.isInteger(n) || n < 0) {
|
|
1353
|
-
throw new UsageError("`--to` needs an event seq, e.g. --to 42");
|
|
1354
|
-
}
|
|
1355
|
-
return n;
|
|
1356
|
-
}
|
|
1357
|
-
function filePaths(flags) {
|
|
1358
|
-
return [flags.file, flags.image].filter((value) => typeof value === "string" && value !== "true").flatMap((value) => value.split(",")).map((value) => value.trim()).filter((value) => value.length > 0);
|
|
1359
|
-
}
|
|
1360
|
-
|
|
1361
|
-
// packages/cli/src/commands/show.ts
|
|
1362
|
-
import { readFile as readFile6 } from "node:fs/promises";
|
|
1363
|
-
import path10 from "node:path";
|
|
1364
|
-
async function show(ctx) {
|
|
1365
|
-
const file = ctx.positionals[0];
|
|
1366
|
-
if (!file) throw new UsageError("usage: chamba show <file.html> [--name <name>]");
|
|
1367
|
-
if (!/\.html?$/i.test(file)) {
|
|
1368
|
-
throw new UsageError(`chamba show takes a self-contained HTML file, got: ${file}`);
|
|
1369
|
-
}
|
|
1370
|
-
let content;
|
|
1371
|
-
try {
|
|
1372
|
-
content = await readFile6(file, "utf8");
|
|
1373
|
-
} catch (err) {
|
|
1374
|
-
throw new UsageError(`could not read ${file}: ${err.message}`);
|
|
1375
|
-
}
|
|
1376
|
-
const flagName = ctx.flags.name?.trim();
|
|
1377
|
-
const name = flagName && flagName !== "true" ? flagName : path10.basename(file).replace(/\.html?$/i, "");
|
|
1378
|
-
const result = postPageReqSchema.safeParse({ name, content });
|
|
1379
|
-
if (!result.success) {
|
|
1380
|
-
throw new UsageError(`invalid page: ${result.error.issues.map((i) => i.message).join("; ")}`);
|
|
1381
|
-
}
|
|
1382
|
-
const id = await resolveSessionId(ctx);
|
|
1383
|
-
const event = await ctx.client.showPage(id, result.data);
|
|
1384
|
-
const ref = pageRefSchema.safeParse(event.payload?.page);
|
|
1385
|
-
const shownAs = ref.success ? ref.data.name : name;
|
|
1386
|
-
note(`showing "${shownAs}" in session ${id}${ref.success ? ` (${ref.data.relPath})` : ""}`);
|
|
1387
|
-
return 0;
|
|
1388
|
-
}
|
|
1389
|
-
|
|
1390
|
-
// packages/cli/src/commands/skill.ts
|
|
1391
|
-
import { cpSync, existsSync as existsSync6, rmSync } from "node:fs";
|
|
1392
|
-
import { homedir } from "node:os";
|
|
1393
|
-
import path11 from "node:path";
|
|
1394
|
-
import { fileURLToPath as fileURLToPath5 } from "node:url";
|
|
1395
|
-
async function skill(ctx) {
|
|
1396
|
-
if (ctx.positionals[0] !== "install") {
|
|
1397
|
-
throw new UsageError("usage: chamba skill install [--global]");
|
|
1398
|
-
}
|
|
1399
|
-
const source = resolveSkillSource();
|
|
1400
|
-
if (!source) {
|
|
1401
|
-
note("bundled skill not found");
|
|
1402
|
-
return 1;
|
|
1403
|
-
}
|
|
1404
|
-
const base = ctx.flags.global ? homedir() : process.cwd();
|
|
1405
|
-
const target = path11.join(base, ".claude", "skills", "chamba");
|
|
1406
|
-
rmSync(target, { recursive: true, force: true });
|
|
1407
|
-
cpSync(source, target, { recursive: true });
|
|
1408
|
-
note(`installed the chamba skill to ${target}`);
|
|
1409
|
-
note("reload your agent's skills, then use /chamba");
|
|
1410
|
-
return 0;
|
|
1411
|
-
}
|
|
1412
|
-
function resolveSkillSource() {
|
|
1413
|
-
const here = path11.dirname(fileURLToPath5(import.meta.url));
|
|
1414
|
-
const candidates = [
|
|
1415
|
-
path11.resolve(here, "../skill"),
|
|
1416
|
-
// packaged: dist/cli.js -> <pkg>/skill
|
|
1417
|
-
path11.resolve(here, "../../../../skill")
|
|
1418
|
-
// dev: packages/cli/src/commands -> <repo>/skill
|
|
1419
|
-
];
|
|
1420
|
-
return candidates.find((dir) => existsSync6(path11.join(dir, "SKILL.md"))) ?? null;
|
|
1421
|
-
}
|
|
1422
|
-
|
|
1423
|
-
// packages/cli/src/commands/status.ts
|
|
1424
|
-
async function status(ctx) {
|
|
1425
|
-
if (!ctx.workspaceExists) {
|
|
1426
|
-
note(
|
|
1427
|
-
"no chamba workspace here - run `chamba open` to start one (creates .chamba at the repo root), or `chamba init` to choose where"
|
|
1428
|
-
);
|
|
1429
|
-
return 0;
|
|
1430
|
-
}
|
|
1431
|
-
const state = await readState(ctx.home);
|
|
1432
|
-
const up = await ctx.client.ownsHome(ctx.home);
|
|
1433
|
-
const rows = up ? await rowsFromServer(ctx) : await rowsFromDisk(ctx);
|
|
1434
|
-
note(`home: ${ctx.home}`);
|
|
1435
|
-
if (rows.length === 0) {
|
|
1436
|
-
note("no sessions yet - run `chamba open` to start one");
|
|
1437
|
-
return 0;
|
|
1438
|
-
}
|
|
1439
|
-
note(up ? `chamba server up on :${ctx.port}` : "chamba server not running (disk state)");
|
|
1440
|
-
for (const row of rows) {
|
|
1441
|
-
out(formatSession(row.meta, row.presence, row.meta.id === state.currentSessionId));
|
|
1442
|
-
}
|
|
1443
|
-
return 0;
|
|
1444
|
-
}
|
|
1445
|
-
async function rowsFromServer(ctx) {
|
|
1446
|
-
const summaries = await ctx.client.list();
|
|
1447
|
-
return summaries.map((s) => ({ meta: s.meta, presence: s.presence }));
|
|
1448
|
-
}
|
|
1449
|
-
async function rowsFromDisk(ctx) {
|
|
1450
|
-
const ids = await listSessionIds(ctx.home);
|
|
1451
|
-
const metas = await Promise.all(ids.map((id) => readMeta(ctx.home, id)));
|
|
1452
|
-
return metas.filter((meta) => meta !== null).sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)).map((meta) => ({ meta }));
|
|
1453
|
-
}
|
|
1454
|
-
|
|
1455
|
-
// packages/cli/src/commands/stop.ts
|
|
1456
|
-
async function stop(ctx) {
|
|
1457
|
-
const info = await readServerInfo(ctx.home);
|
|
1458
|
-
if (!info) {
|
|
1459
|
-
note("no chamba server running here");
|
|
1460
|
-
return 0;
|
|
1461
|
-
}
|
|
1462
|
-
const client = new Client(`http://127.0.0.1:${info.port}/`);
|
|
1463
|
-
if (!await client.ownsHome(ctx.home)) {
|
|
1464
|
-
note("no chamba server running here");
|
|
1465
|
-
return 0;
|
|
1466
|
-
}
|
|
1467
|
-
try {
|
|
1468
|
-
process.kill(info.pid, "SIGTERM");
|
|
1469
|
-
} catch {
|
|
1470
|
-
note("no chamba server running here");
|
|
1471
|
-
return 0;
|
|
1472
|
-
}
|
|
1473
|
-
if (await waitUntilDown(client)) {
|
|
1474
|
-
note(`stopped chamba server on :${info.port}`);
|
|
1475
|
-
return 0;
|
|
1476
|
-
}
|
|
1477
|
-
note(`sent stop to the chamba server (pid ${info.pid}) but it did not exit in time`);
|
|
1478
|
-
return 1;
|
|
1479
|
-
}
|
|
1480
|
-
async function waitUntilDown(client, timeoutMs = 5e3) {
|
|
1481
|
-
const deadline = Date.now() + timeoutMs;
|
|
1482
|
-
while (Date.now() < deadline) {
|
|
1483
|
-
if (!await client.isUp()) return true;
|
|
1484
|
-
await delay(150);
|
|
1485
|
-
}
|
|
1486
|
-
return false;
|
|
1487
|
-
}
|
|
1488
|
-
|
|
1489
|
-
// packages/cli/src/commands/index.ts
|
|
1490
|
-
var COMMANDS = {
|
|
1491
|
-
init,
|
|
1492
|
-
open: open2,
|
|
1493
|
-
poll,
|
|
1494
|
-
reply,
|
|
1495
|
-
get,
|
|
1496
|
-
show,
|
|
1497
|
-
end,
|
|
1498
|
-
status,
|
|
1499
|
-
stop,
|
|
1500
|
-
// Distribution: install the bundled skill into a Claude skills directory.
|
|
1501
|
-
skill,
|
|
1502
|
-
help,
|
|
1503
|
-
"-h": help
|
|
1504
|
-
};
|
|
1505
|
-
function runCommand(name, ctx) {
|
|
1506
|
-
const handler = COMMANDS[name];
|
|
1507
|
-
if (!handler) {
|
|
1508
|
-
note(`unknown command: ${name}`);
|
|
1509
|
-
return help(ctx).then(() => 2);
|
|
1510
|
-
}
|
|
1511
|
-
return handler(ctx);
|
|
1512
|
-
}
|
|
1513
|
-
|
|
1514
|
-
// packages/cli/src/index.ts
|
|
1515
|
-
var REQUIRES_WORKSPACE = /* @__PURE__ */ new Set(["poll", "reply", "get", "show", "end"]);
|
|
1516
|
-
async function main(argv = process.argv.slice(2)) {
|
|
1517
|
-
const { positionals, flags } = parseArgs(argv);
|
|
1518
|
-
if (flags.version && positionals.length === 0) {
|
|
1519
|
-
out(chambaVersion());
|
|
1520
|
-
return 0;
|
|
1521
|
-
}
|
|
1522
|
-
const command = positionals[0] ?? (flags.help ? "help" : "status");
|
|
1523
|
-
const existing = findExistingHome();
|
|
1524
|
-
const home = existing ?? plannedHome();
|
|
1525
|
-
const port = resolvePort(argv, home);
|
|
1526
|
-
const ctx = {
|
|
1527
|
-
client: new Client(`http://127.0.0.1:${port}/`),
|
|
1528
|
-
home,
|
|
1529
|
-
port,
|
|
1530
|
-
flags,
|
|
1531
|
-
positionals: positionals.slice(1),
|
|
1532
|
-
workspaceExists: existing !== null
|
|
1533
|
-
};
|
|
1534
|
-
if (!ctx.workspaceExists && REQUIRES_WORKSPACE.has(command)) {
|
|
1535
|
-
note(
|
|
1536
|
-
"no chamba workspace here - run `chamba open` to start one (creates .chamba at the repo root), or `chamba init` to choose where"
|
|
1537
|
-
);
|
|
1538
|
-
return 2;
|
|
1539
|
-
}
|
|
1540
|
-
try {
|
|
1541
|
-
return await runCommand(command, ctx);
|
|
1542
|
-
} catch (err) {
|
|
1543
|
-
return reportError(err);
|
|
1544
|
-
}
|
|
1545
|
-
}
|
|
1546
|
-
function reportError(err) {
|
|
1547
|
-
if (err instanceof UsageError) {
|
|
1548
|
-
note(err.message);
|
|
1549
|
-
return 2;
|
|
1550
|
-
}
|
|
1551
|
-
if (err instanceof ChannelError) {
|
|
1552
|
-
note(`error: ${err.message}`);
|
|
1553
|
-
return 1;
|
|
1554
|
-
}
|
|
1555
|
-
if (isConnectionRefused(err)) {
|
|
1556
|
-
note("cannot reach the chamba server - run `chamba open` first");
|
|
1557
|
-
return 1;
|
|
1558
|
-
}
|
|
1559
|
-
note(`error: ${err.message}`);
|
|
1560
|
-
return 1;
|
|
1561
|
-
}
|
|
1562
|
-
function invokedAsEntry() {
|
|
1563
|
-
const entry = process.argv[1];
|
|
1564
|
-
if (entry === void 0) return false;
|
|
1565
|
-
try {
|
|
1566
|
-
return realpathSync2(entry) === realpathSync2(fileURLToPath6(import.meta.url));
|
|
1567
|
-
} catch {
|
|
1568
|
-
return false;
|
|
1569
|
-
}
|
|
1570
|
-
}
|
|
1571
|
-
if (invokedAsEntry()) {
|
|
1572
|
-
main().then(
|
|
1573
|
-
(code) => process.exit(code),
|
|
1574
|
-
(err) => {
|
|
1575
|
-
note(`fatal: ${err.message}`);
|
|
1576
|
-
process.exit(1);
|
|
1577
|
-
}
|
|
1578
|
-
);
|
|
1579
|
-
}
|
|
1580
|
-
export {
|
|
1581
|
-
main
|
|
1582
|
-
};
|