@clicksmith/daemon 0.1.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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 ClickSmith contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,81 @@
1
+ # @clicksmith/daemon
2
+
3
+ The localhost brain of ClickSmith. A single Node process that owns sessions,
4
+ runs, and **safety**. It exposes:
5
+
6
+ - **HTTP** (Fastify) — capture, submit, apply, session reads.
7
+ - **WebSocket** (Fastify) — live run events streamed to the extension.
8
+ - **MCP** (stdio) — read-only tools for any MCP-capable coding agent.
9
+ - **Git sandbox orchestration** — throwaway worktrees, dirty-tree refusal, apply.
10
+ - **Config-driven agent launching** — spawns the selected agent via `execa`.
11
+
12
+ Everything stays local. No cloud, no telemetry.
13
+
14
+ ## Run it
15
+
16
+ ```bash
17
+ clicksmith daemon --port 8722 # HTTP + WS + state under .clicksmith/
18
+ clicksmith mcp # read-only MCP stdio server
19
+ clicksmith version
20
+ ```
21
+
22
+ ## HTTP surface
23
+
24
+ | Method & path | Purpose |
25
+ | --- | --- |
26
+ | `GET /health` | Liveness + daemon metadata. |
27
+ | `POST /capture` | Create/append the active session for an app/route. |
28
+ | `POST /submit` | Finalize a bundle and start a run. `409` if the tree is dirty. |
29
+ | `POST /apply/:runId` | Merge the sandbox back; reports conflicts. |
30
+ | `GET /session/:id` | Read a session. |
31
+ | `DELETE /element/:sessionId/:elementId` | Remove a captured mark. |
32
+
33
+ WebSocket (`/ws`) streams: `capture-ack`, `element-removed`, `agent-started`,
34
+ `agent-log`, `plan-ready`, `agent-done`, `agent-error`, `apply-started`,
35
+ `apply-done`, `apply-error`. Send `{ "type": "subscribe" }` to replay buffered
36
+ events on reconnect.
37
+
38
+ ## Safety model
39
+
40
+ `POST /submit` with the default `plan + worktree` options:
41
+
42
+ 1. Detects the repo root and base commit.
43
+ 2. **Refuses** (`409`) if the working tree has uncommitted changes (ignoring
44
+ `.clicksmith/` itself) — unless you explicitly choose `inplace`.
45
+ 3. Creates a throwaway worktree at `.clicksmith/worktrees/<runId>` on branch
46
+ `clicksmith/<runId>`. Falls back to a dedicated branch if worktrees are
47
+ unavailable.
48
+ 4. Launches the agent **in the sandbox** — your main tree is never touched.
49
+ 5. Captures the agent's stdout as `plan.md` and a binary-safe `diff.patch`.
50
+ 6. `POST /apply/:runId` 3-way-applies the patch onto the main tree, commits,
51
+ records revert metadata, and cleans up the worktree. Conflicts are reported,
52
+ not forced.
53
+
54
+ ## Persistence layout
55
+
56
+ ```
57
+ .clicksmith/ (or OS cache dir when not in a repo)
58
+ sessions/<id>.json
59
+ runs/<runId>/
60
+ bundle.json plan.md diff.patch agent.log run.json
61
+ worktrees/<runId>/ (throwaway git worktree)
62
+ agents.config.json (optional project agent overrides)
63
+ ```
64
+
65
+ Unsubmitted sessions expire after a configurable default of 24 hours.
66
+
67
+ ## Programmatic use
68
+
69
+ ```ts
70
+ import { DaemonService, buildServer, resolveDaemonConfig } from '@clicksmith/daemon';
71
+
72
+ const config = await resolveDaemonConfig({ port: 8722 });
73
+ const service = new DaemonService({ config });
74
+ await service.init();
75
+ const app = await buildServer(service);
76
+ await app.listen({ host: config.host, port: config.port });
77
+ ```
78
+
79
+ The `DaemonService` is framework-agnostic and fully unit-/integration-tested,
80
+ including the end-to-end acceptance flow (capture → plan in worktree → prove the
81
+ main tree is untouched → apply → cleanup).
@@ -0,0 +1,540 @@
1
+ import {
2
+ FileStore,
3
+ Git,
4
+ describeSandbox,
5
+ version
6
+ } from "./chunk-UVRW6O46.js";
7
+
8
+ // src/events.ts
9
+ var EventBus = class {
10
+ listeners = /* @__PURE__ */ new Set();
11
+ history = [];
12
+ maxHistory;
13
+ constructor(maxHistory = 500) {
14
+ this.maxHistory = maxHistory;
15
+ }
16
+ subscribe(listener) {
17
+ this.listeners.add(listener);
18
+ return () => this.listeners.delete(listener);
19
+ }
20
+ emit(event) {
21
+ this.history.push(event);
22
+ if (this.history.length > this.maxHistory) this.history.shift();
23
+ for (const listener of this.listeners) {
24
+ try {
25
+ listener(event);
26
+ } catch {
27
+ }
28
+ }
29
+ }
30
+ /** Replay buffered events, optionally filtered to a run id. */
31
+ replay(runId) {
32
+ if (!runId) return [...this.history];
33
+ return this.history.filter((e) => "runId" in e && e.runId === runId);
34
+ }
35
+ };
36
+
37
+ // src/launcher.ts
38
+ import { execa } from "execa";
39
+ async function launchAgent(spec, handlers) {
40
+ const subprocess = execa(spec.command, spec.args, {
41
+ cwd: spec.cwd,
42
+ env: { ...process.env, ...spec.env },
43
+ reject: false,
44
+ all: false,
45
+ cancelSignal: handlers.signal
46
+ });
47
+ let stdout = "";
48
+ subprocess.stdout?.on("data", (data) => {
49
+ const chunk = data.toString();
50
+ stdout += chunk;
51
+ handlers.onLog("stdout", chunk);
52
+ });
53
+ subprocess.stderr?.on("data", (data) => {
54
+ handlers.onLog("stderr", data.toString());
55
+ });
56
+ const result = await subprocess;
57
+ return {
58
+ exitCode: result.exitCode ?? (result.isCanceled ? 130 : 0),
59
+ stdout,
60
+ canceled: Boolean(result.isCanceled)
61
+ };
62
+ }
63
+
64
+ // src/enrichment.ts
65
+ async function enrichBundle(bundle, provider) {
66
+ if (!provider) return bundle;
67
+ try {
68
+ const enrichment = await provider.enrich(bundle);
69
+ if (!enrichment) return bundle;
70
+ return { ...bundle, enrichment };
71
+ } catch (err) {
72
+ const message = err instanceof Error ? err.message : String(err);
73
+ return {
74
+ ...bundle,
75
+ enrichment: {
76
+ source: "code-review-graph",
77
+ perElement: bundle.enrichment?.perElement ?? [],
78
+ warnings: [...bundle.enrichment?.warnings ?? [], `enrichment failed: ${message}`]
79
+ }
80
+ };
81
+ }
82
+ }
83
+
84
+ // src/run-manager.ts
85
+ import { mkdir } from "fs/promises";
86
+ import { join } from "path";
87
+ import {
88
+ configToAdapter,
89
+ defaultBinExists,
90
+ renderInstructionBody,
91
+ resolveAgent
92
+ } from "@clicksmith/agent-config";
93
+ import {
94
+ newRunId
95
+ } from "@clicksmith/core";
96
+ var RefusalError = class extends Error {
97
+ code = "DIRTY_TREE";
98
+ };
99
+ var COMMIT_PREFIX = "ClickSmith";
100
+ var RunManager = class {
101
+ constructor(deps) {
102
+ this.deps = deps;
103
+ }
104
+ deps;
105
+ /**
106
+ * Prepare a sandbox and start an agent run. The sandbox is prepared
107
+ * synchronously so a dirty-tree refusal surfaces as an error to the caller;
108
+ * the agent itself runs in the background, emitting WebSocket events.
109
+ */
110
+ async createRun(input) {
111
+ const { store, config, bus, logger } = this.deps;
112
+ const agentConfig = resolveAgent(config.agents, input.execution.agentId);
113
+ if (!agentConfig) {
114
+ throw new RefusalError(`No agent configured (requested: ${input.execution.agentId ?? "default"}).`);
115
+ }
116
+ const runId = newRunId();
117
+ const now = /* @__PURE__ */ new Date();
118
+ const repoRoot = config.repoRoot;
119
+ const isolation = repoRoot ? input.execution.isolation : "inplace";
120
+ let sandbox = null;
121
+ let baseCommit = null;
122
+ let baseBranch = null;
123
+ if (repoRoot) {
124
+ const git = new Git(repoRoot);
125
+ baseCommit = await git.headCommit();
126
+ baseBranch = await safe(() => git.currentBranch());
127
+ const baseRef = input.execution.baseRef ?? baseCommit;
128
+ if (isolation !== "inplace" && await git.isDirty({ exclude: [".clicksmith/", ".clicksmith"] })) {
129
+ throw new RefusalError(
130
+ `Refusing to run in ${isolation} isolation: the working tree has uncommitted changes. Commit or stash them, or use inplace isolation explicitly.`
131
+ );
132
+ }
133
+ sandbox = await this.prepareSandbox(git, runId, isolation, baseRef, repoRoot, baseCommit, logger);
134
+ }
135
+ const enriched = await enrichBundle(input, this.deps.enrichment);
136
+ await store.saveBundle(runId, enriched);
137
+ const run = {
138
+ runId,
139
+ sessionId: enriched.sessionId,
140
+ agentId: agentConfig.id,
141
+ status: "running",
142
+ createdAt: now.toISOString(),
143
+ updatedAt: now.toISOString(),
144
+ mode: enriched.execution.mode,
145
+ isolation,
146
+ prompt: enriched.prompt,
147
+ repoRoot,
148
+ baseCommit,
149
+ baseBranch,
150
+ sandbox,
151
+ revert: null
152
+ };
153
+ await store.saveRun(run);
154
+ bus.emit({ type: "agent-started", runId, sessionId: run.sessionId, agentId: run.agentId, sandbox });
155
+ void this.execute(run, enriched, agentConfig).catch((err) => {
156
+ logger.error(`run ${runId} crashed`, err);
157
+ });
158
+ return { run };
159
+ }
160
+ async prepareSandbox(git, runId, isolation, baseRef, repoRoot, baseCommit, logger) {
161
+ const branch = `clicksmith/${runId}`;
162
+ if (isolation === "inplace") {
163
+ return describeSandbox("inplace", repoRoot, null, baseCommit);
164
+ }
165
+ if (isolation === "worktree") {
166
+ if (await git.supportsWorktree()) {
167
+ const path = this.deps.store.paths.sandboxDir(runId);
168
+ await mkdir(join(path, ".."), { recursive: true });
169
+ await git.createWorktree(path, branch, baseRef);
170
+ return describeSandbox("worktree", path, branch, baseCommit);
171
+ }
172
+ logger.warn("git worktrees unavailable; falling back to a dedicated branch");
173
+ }
174
+ await git.createBranch(branch, baseRef);
175
+ return describeSandbox("branch", repoRoot, branch, baseCommit);
176
+ }
177
+ async execute(run, bundle, agentConfig) {
178
+ const { store, config, bus, logger } = this.deps;
179
+ const sandboxPath = run.sandbox?.path ?? config.cwd;
180
+ const instructionFile = await this.resolveInstructionFile(run, agentConfig);
181
+ const ctx = {
182
+ bundlePath: store.bundlePath(run.runId),
183
+ prompt: bundle.prompt,
184
+ instructionFile,
185
+ mode: bundle.execution.mode,
186
+ mcpServer: "clicksmith",
187
+ cwd: sandboxPath,
188
+ isolation: run.isolation,
189
+ agentId: agentConfig.id,
190
+ binExists: this.deps.binExists ?? defaultBinExists
191
+ };
192
+ const adapter = configToAdapter(agentConfig);
193
+ if (!await adapter.isAvailable(ctx)) {
194
+ await this.fail(run, `Agent "${agentConfig.id}" is not available on PATH.`);
195
+ return;
196
+ }
197
+ const spec = adapter.buildCommand(ctx);
198
+ logger.info(`run ${run.runId}: ${spec.command} ${spec.args.join(" ")}`);
199
+ let result;
200
+ try {
201
+ result = await launchAgent(spec, {
202
+ onLog: (stream, chunk) => {
203
+ void store.appendLog(run.runId, chunk);
204
+ bus.emit({ type: "agent-log", runId: run.runId, stream, chunk });
205
+ }
206
+ });
207
+ } catch (err) {
208
+ await this.fail(run, err instanceof Error ? err.message : String(err));
209
+ return;
210
+ }
211
+ const plan = result.stdout.trim();
212
+ let diff = "";
213
+ if (run.sandbox && run.repoRoot) {
214
+ diff = await Git.captureDiff(run.sandbox.path);
215
+ }
216
+ if (plan) await store.writeArtifact(run.runId, "plan.md", plan);
217
+ if (diff) await store.writeArtifact(run.runId, "diff.patch", diff);
218
+ run.exitCode = result.exitCode;
219
+ run.hasPlan = plan.length > 0;
220
+ run.hasDiff = diff.length > 0;
221
+ if (result.exitCode !== 0) {
222
+ await this.fail(run, `Agent exited with code ${result.exitCode}.`);
223
+ return;
224
+ }
225
+ run.status = "plan-ready";
226
+ run.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
227
+ await store.saveRun(run);
228
+ bus.emit({
229
+ type: "plan-ready",
230
+ runId: run.runId,
231
+ ...plan ? { plan } : {},
232
+ ...diff ? { diff } : {}
233
+ });
234
+ bus.emit({ type: "agent-done", runId: run.runId, exitCode: result.exitCode });
235
+ if (bundle.execution.autoApply) {
236
+ logger.info(`run ${run.runId}: autoApply enabled, applying`);
237
+ await this.apply(run.runId);
238
+ }
239
+ }
240
+ async fail(run, message) {
241
+ run.status = "error";
242
+ run.error = message;
243
+ run.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
244
+ await this.deps.store.saveRun(run);
245
+ this.deps.bus.emit({ type: "agent-error", runId: run.runId, message });
246
+ }
247
+ /**
248
+ * Merge a finished run's sandbox changes back into the working tree. Reports
249
+ * conflicts, commits on success, records revert metadata, and cleans up the
250
+ * sandbox.
251
+ */
252
+ async apply(runId) {
253
+ const { store, bus, logger } = this.deps;
254
+ const run = await store.getRun(runId);
255
+ if (!run) throw new Error(`Unknown run: ${runId}`);
256
+ if (!run.repoRoot || !run.sandbox) {
257
+ throw new Error(`Run ${runId} has no git sandbox to apply.`);
258
+ }
259
+ bus.emit({ type: "apply-started", runId });
260
+ const git = new Git(run.repoRoot);
261
+ const previousHead = await git.headCommit();
262
+ const message = `${COMMIT_PREFIX} run ${runId}: ${truncate(run.prompt, 72)}`;
263
+ try {
264
+ let commit;
265
+ if (run.sandbox.isolation === "worktree") {
266
+ const diff = await store.readArtifact(runId, "diff.patch") ?? "";
267
+ const applied = await git.applyPatch(diff);
268
+ if (!applied.ok) return await this.applyConflict(run, applied.conflicts);
269
+ commit = diff.trim() ? await git.commit(message) : previousHead;
270
+ await this.cleanupSandbox(run);
271
+ } else if (run.sandbox.isolation === "branch") {
272
+ if (await git.hasChanges()) await git.commit(message);
273
+ if (run.baseBranch) await git.switchTo(run.baseBranch);
274
+ const merged = await git.merge(run.sandbox.branch, message);
275
+ if (!merged.ok) return await this.applyConflict(run, merged.conflicts);
276
+ commit = await git.headCommit();
277
+ await git.deleteBranch(run.sandbox.branch);
278
+ } else {
279
+ commit = await git.hasChanges() ? await git.commit(message) : previousHead;
280
+ }
281
+ run.status = "applied";
282
+ run.applied = { ...commit ? { commit } : {}, at: (/* @__PURE__ */ new Date()).toISOString() };
283
+ run.revert = {
284
+ previousHead,
285
+ ...commit && commit !== previousHead ? { appliedCommit: commit } : {},
286
+ instructions: commit && commit !== previousHead ? `git revert ${commit} # or: git reset --hard ${previousHead}` : "No commit was created; nothing to revert."
287
+ };
288
+ run.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
289
+ await store.saveRun(run);
290
+ bus.emit({ type: "apply-done", runId, ...commit ? { commit } : {} });
291
+ return { applied: true, ...commit ? { commit } : {} };
292
+ } catch (err) {
293
+ const messageText = err instanceof Error ? err.message : String(err);
294
+ logger.error(`apply ${runId} failed`, messageText);
295
+ run.status = "apply-error";
296
+ run.error = messageText;
297
+ await store.saveRun(run);
298
+ bus.emit({ type: "apply-error", runId, message: messageText });
299
+ return { applied: false };
300
+ }
301
+ }
302
+ async applyConflict(run, conflicts) {
303
+ run.status = "apply-error";
304
+ run.error = `Apply conflicts in: ${conflicts.join(", ") || "unknown files"}`;
305
+ run.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
306
+ await this.deps.store.saveRun(run);
307
+ this.deps.bus.emit({
308
+ type: "apply-error",
309
+ runId: run.runId,
310
+ message: run.error,
311
+ conflicts
312
+ });
313
+ return { applied: false, conflicts };
314
+ }
315
+ async cleanupSandbox(run) {
316
+ if (!run.repoRoot || !run.sandbox) return;
317
+ if (run.sandbox.isolation === "worktree") {
318
+ const git = new Git(run.repoRoot);
319
+ await git.removeWorktree(run.sandbox.path, run.sandbox.branch ?? void 0);
320
+ }
321
+ }
322
+ /**
323
+ * Resolve the instruction file passed to the agent. Prefer the project's
324
+ * rendered file if it exists; otherwise write a run-local one from the shared
325
+ * template so every agent always has instructions.
326
+ */
327
+ async resolveInstructionFile(run, agentConfig) {
328
+ const { config, store } = this.deps;
329
+ if (config.repoRoot && agentConfig.instructions) {
330
+ const projectFile = join(config.repoRoot, agentConfig.instructions.file);
331
+ if (await fileExists(projectFile)) return projectFile;
332
+ }
333
+ const body = renderInstructionBody({ daemonPort: config.port });
334
+ return store.writeArtifact(run.runId, "AGENT_INSTRUCTIONS.md", body);
335
+ }
336
+ };
337
+ async function fileExists(path) {
338
+ const { access } = await import("fs/promises");
339
+ try {
340
+ await access(path);
341
+ return true;
342
+ } catch {
343
+ return false;
344
+ }
345
+ }
346
+ async function safe(fn) {
347
+ try {
348
+ return await fn();
349
+ } catch {
350
+ return null;
351
+ }
352
+ }
353
+ function truncate(s, n) {
354
+ return s.length <= n ? s : `${s.slice(0, n - 1)}\u2026`;
355
+ }
356
+
357
+ // src/daemon-service.ts
358
+ import {
359
+ appendElement,
360
+ createSession,
361
+ ExecutionOptionsSchema,
362
+ finalizeSession,
363
+ removeElement,
364
+ touchSession
365
+ } from "@clicksmith/core";
366
+ var DaemonService = class {
367
+ config;
368
+ store;
369
+ bus;
370
+ runs;
371
+ constructor(opts) {
372
+ this.config = opts.config;
373
+ this.store = new FileStore(opts.config.storageRoot);
374
+ this.bus = new EventBus();
375
+ this.runs = new RunManager({
376
+ store: this.store,
377
+ config: opts.config,
378
+ bus: this.bus,
379
+ logger: opts.config.logger,
380
+ ...opts.enrichment ? { enrichment: opts.enrichment } : {},
381
+ ...opts.binExists ? { binExists: opts.binExists } : {}
382
+ });
383
+ }
384
+ async init() {
385
+ await this.store.init();
386
+ await this.store.cleanupExpired();
387
+ }
388
+ /* ------------------------------- capture ------------------------------ */
389
+ /** Create or append to the active session for an app/route. */
390
+ async capture(req) {
391
+ const now = /* @__PURE__ */ new Date();
392
+ let session = req.sessionId ? await this.store.getSession(req.sessionId) : void 0;
393
+ if (!session) {
394
+ session = createSession({ app: req.app, now, ttlMs: this.config.ttlMs });
395
+ } else {
396
+ session = touchSession(session, now, this.config.ttlMs);
397
+ }
398
+ const { session: next, element } = appendElement(session, req.element, now);
399
+ await this.store.saveSession(next);
400
+ this.bus.emit({ type: "capture-ack", sessionId: next.id, element });
401
+ return { sessionId: next.id, element };
402
+ }
403
+ async removeElement(sessionId, elementId) {
404
+ const session = await this.store.getSession(sessionId);
405
+ if (!session) return { removed: false };
406
+ const { session: next, removed } = removeElement(session, elementId);
407
+ if (removed) {
408
+ await this.store.saveSession(next);
409
+ this.bus.emit({ type: "element-removed", sessionId, elementId });
410
+ }
411
+ return { removed };
412
+ }
413
+ async getSession(id) {
414
+ return this.store.getSession(id);
415
+ }
416
+ /* ------------------------------- submit ------------------------------- */
417
+ /** Finalize a session into a bundle and start a run. */
418
+ async submit(req) {
419
+ const session = await this.store.getSession(req.sessionId);
420
+ if (!session) throw new NotFoundError(`Unknown session: ${req.sessionId}`);
421
+ const execution = ExecutionOptionsSchema.parse(req.execution ?? {});
422
+ const bundle = finalizeSession(session, {
423
+ prompt: req.prompt,
424
+ execution,
425
+ ...req.enrichment ? { enrichment: req.enrichment } : {}
426
+ });
427
+ const submitted = { ...session, status: "submitted", prompt: req.prompt };
428
+ await this.store.saveSession(submitted);
429
+ const { run } = await this.runs.createRun(bundle);
430
+ return { runId: run.runId, bundle };
431
+ }
432
+ async apply(runId) {
433
+ return this.runs.apply(runId);
434
+ }
435
+ /* ------------------------------- health ------------------------------- */
436
+ async health() {
437
+ const sessions = await this.store.listSessions();
438
+ return {
439
+ ok: true,
440
+ name: "clicksmith-daemon",
441
+ version,
442
+ host: this.config.host,
443
+ port: this.config.port,
444
+ repoRoot: this.config.repoRoot,
445
+ activeSessions: sessions.filter((s) => s.status === "active").length
446
+ };
447
+ }
448
+ };
449
+ var NotFoundError = class extends Error {
450
+ code = "NOT_FOUND";
451
+ };
452
+
453
+ // src/server.ts
454
+ import Fastify from "fastify";
455
+ import websocket from "@fastify/websocket";
456
+ import {
457
+ CaptureRequestSchema,
458
+ SubmitRequestSchema
459
+ } from "@clicksmith/core";
460
+ async function buildServer(service) {
461
+ const app = Fastify({ logger: false });
462
+ app.addHook("onRequest", async (req, reply) => {
463
+ reply.header("Access-Control-Allow-Origin", "*");
464
+ reply.header("Access-Control-Allow-Methods", "GET,POST,DELETE,OPTIONS");
465
+ reply.header("Access-Control-Allow-Headers", "content-type");
466
+ if (req.method === "OPTIONS") {
467
+ reply.code(204).send();
468
+ }
469
+ });
470
+ await app.register(websocket);
471
+ app.get("/health", async () => service.health());
472
+ app.post("/capture", async (req, reply) => {
473
+ const parsed = CaptureRequestSchema.safeParse(req.body);
474
+ if (!parsed.success) return reply.code(400).send({ error: parsed.error.message });
475
+ return service.capture(parsed.data);
476
+ });
477
+ app.post("/submit", async (req, reply) => {
478
+ const parsed = SubmitRequestSchema.safeParse(req.body);
479
+ if (!parsed.success) return reply.code(400).send({ error: parsed.error.message });
480
+ try {
481
+ return await service.submit(parsed.data);
482
+ } catch (err) {
483
+ if (err instanceof NotFoundError) return reply.code(404).send({ error: err.message });
484
+ if (err instanceof RefusalError) return reply.code(409).send({ error: err.message, code: err.code });
485
+ throw err;
486
+ }
487
+ });
488
+ app.post("/apply/:runId", async (req, reply) => {
489
+ try {
490
+ return await service.apply(req.params.runId);
491
+ } catch (err) {
492
+ return reply.code(404).send({ error: err instanceof Error ? err.message : String(err) });
493
+ }
494
+ });
495
+ app.get("/session/:id", async (req, reply) => {
496
+ const session = await service.getSession(req.params.id);
497
+ if (!session) return reply.code(404).send({ error: `Unknown session: ${req.params.id}` });
498
+ return session;
499
+ });
500
+ app.delete(
501
+ "/element/:sessionId/:elementId",
502
+ async (req) => {
503
+ const elementId = Number.parseInt(req.params.elementId, 10);
504
+ return service.removeElement(req.params.sessionId, elementId);
505
+ }
506
+ );
507
+ app.get("/ws", { websocket: true }, (socket) => {
508
+ const send = (event) => {
509
+ if (socket.readyState === socket.OPEN) socket.send(JSON.stringify(event));
510
+ };
511
+ const unsubscribe = service.bus.subscribe(send);
512
+ socket.on("message", (raw) => {
513
+ let msg;
514
+ try {
515
+ msg = JSON.parse(raw.toString());
516
+ } catch {
517
+ return;
518
+ }
519
+ if (msg.type === "ping") socket.send(JSON.stringify({ type: "pong" }));
520
+ else if (msg.type === "subscribe") {
521
+ for (const event of service.bus.replay(msg.sessionId)) send(event);
522
+ }
523
+ });
524
+ socket.on("close", unsubscribe);
525
+ socket.on("error", unsubscribe);
526
+ });
527
+ return app;
528
+ }
529
+
530
+ export {
531
+ EventBus,
532
+ launchAgent,
533
+ enrichBundle,
534
+ RefusalError,
535
+ RunManager,
536
+ DaemonService,
537
+ NotFoundError,
538
+ buildServer
539
+ };
540
+ //# sourceMappingURL=chunk-FY7JGOX6.js.map