@gtc6244/immediacy-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.
Files changed (133) hide show
  1. package/dist/src/actions.d.ts +22 -0
  2. package/dist/src/actions.js +44 -0
  3. package/dist/src/actions.js.map +1 -0
  4. package/dist/src/agent/agent.d.ts +89 -0
  5. package/dist/src/agent/agent.js +22 -0
  6. package/dist/src/agent/agent.js.map +1 -0
  7. package/dist/src/agent/claude.d.ts +70 -0
  8. package/dist/src/agent/claude.js +404 -0
  9. package/dist/src/agent/claude.js.map +1 -0
  10. package/dist/src/agent/codex.d.ts +44 -0
  11. package/dist/src/agent/codex.js +303 -0
  12. package/dist/src/agent/codex.js.map +1 -0
  13. package/dist/src/agent/mock.d.ts +31 -0
  14. package/dist/src/agent/mock.js +155 -0
  15. package/dist/src/agent/mock.js.map +1 -0
  16. package/dist/src/agent/model-error.d.ts +8 -0
  17. package/dist/src/agent/model-error.js +25 -0
  18. package/dist/src/agent/model-error.js.map +1 -0
  19. package/dist/src/agent/openrouter.d.ts +38 -0
  20. package/dist/src/agent/openrouter.js +60 -0
  21. package/dist/src/agent/openrouter.js.map +1 -0
  22. package/dist/src/agent/workspace-scope.d.ts +25 -0
  23. package/dist/src/agent/workspace-scope.js +71 -0
  24. package/dist/src/agent/workspace-scope.js.map +1 -0
  25. package/dist/src/allowlist.d.ts +56 -0
  26. package/dist/src/allowlist.js +141 -0
  27. package/dist/src/allowlist.js.map +1 -0
  28. package/dist/src/chatlog.d.ts +50 -0
  29. package/dist/src/chatlog.js +179 -0
  30. package/dist/src/chatlog.js.map +1 -0
  31. package/dist/src/cli/paths.d.ts +40 -0
  32. package/dist/src/cli/paths.js +76 -0
  33. package/dist/src/cli/paths.js.map +1 -0
  34. package/dist/src/cli/service.d.ts +20 -0
  35. package/dist/src/cli/service.js +245 -0
  36. package/dist/src/cli/service.js.map +1 -0
  37. package/dist/src/cli.d.ts +2 -0
  38. package/dist/src/cli.js +227 -0
  39. package/dist/src/cli.js.map +1 -0
  40. package/dist/src/config.d.ts +33 -0
  41. package/dist/src/config.js +122 -0
  42. package/dist/src/config.js.map +1 -0
  43. package/dist/src/credentials.d.ts +38 -0
  44. package/dist/src/credentials.js +120 -0
  45. package/dist/src/credentials.js.map +1 -0
  46. package/dist/src/crypto.d.ts +49 -0
  47. package/dist/src/crypto.js +202 -0
  48. package/dist/src/crypto.js.map +1 -0
  49. package/dist/src/daemon-identity.d.ts +31 -0
  50. package/dist/src/daemon-identity.js +97 -0
  51. package/dist/src/daemon-identity.js.map +1 -0
  52. package/dist/src/envelope.d.ts +14 -0
  53. package/dist/src/envelope.js +28 -0
  54. package/dist/src/envelope.js.map +1 -0
  55. package/dist/src/events.d.ts +40 -0
  56. package/dist/src/events.js +24 -0
  57. package/dist/src/events.js.map +1 -0
  58. package/dist/src/forward.d.ts +51 -0
  59. package/dist/src/forward.js +170 -0
  60. package/dist/src/forward.js.map +1 -0
  61. package/dist/src/fs-scope.d.ts +17 -0
  62. package/dist/src/fs-scope.js +47 -0
  63. package/dist/src/fs-scope.js.map +1 -0
  64. package/dist/src/github.d.ts +70 -0
  65. package/dist/src/github.js +178 -0
  66. package/dist/src/github.js.map +1 -0
  67. package/dist/src/gitops.d.ts +113 -0
  68. package/dist/src/gitops.js +557 -0
  69. package/dist/src/gitops.js.map +1 -0
  70. package/dist/src/hub.d.ts +50 -0
  71. package/dist/src/hub.js +92 -0
  72. package/dist/src/hub.js.map +1 -0
  73. package/dist/src/index.d.ts +22 -0
  74. package/dist/src/index.js +56 -0
  75. package/dist/src/index.js.map +1 -0
  76. package/dist/src/jobs/scheduler.d.ts +94 -0
  77. package/dist/src/jobs/scheduler.js +373 -0
  78. package/dist/src/jobs/scheduler.js.map +1 -0
  79. package/dist/src/jobs/store.d.ts +33 -0
  80. package/dist/src/jobs/store.js +111 -0
  81. package/dist/src/jobs/store.js.map +1 -0
  82. package/dist/src/log.d.ts +61 -0
  83. package/dist/src/log.js +166 -0
  84. package/dist/src/log.js.map +1 -0
  85. package/dist/src/migrate-state-dir.d.ts +1 -0
  86. package/dist/src/migrate-state-dir.js +47 -0
  87. package/dist/src/migrate-state-dir.js.map +1 -0
  88. package/dist/src/models.d.ts +36 -0
  89. package/dist/src/models.js +71 -0
  90. package/dist/src/models.js.map +1 -0
  91. package/dist/src/permission-gate.d.ts +48 -0
  92. package/dist/src/permission-gate.js +93 -0
  93. package/dist/src/permission-gate.js.map +1 -0
  94. package/dist/src/permission-scope.d.ts +77 -0
  95. package/dist/src/permission-scope.js +244 -0
  96. package/dist/src/permission-scope.js.map +1 -0
  97. package/dist/src/ports.d.ts +71 -0
  98. package/dist/src/ports.js +216 -0
  99. package/dist/src/ports.js.map +1 -0
  100. package/dist/src/projects.d.ts +83 -0
  101. package/dist/src/projects.js +205 -0
  102. package/dist/src/projects.js.map +1 -0
  103. package/dist/src/protocol.d.ts +1226 -0
  104. package/dist/src/protocol.js +16 -0
  105. package/dist/src/protocol.js.map +1 -0
  106. package/dist/src/repo-sync.d.ts +49 -0
  107. package/dist/src/repo-sync.js +83 -0
  108. package/dist/src/repo-sync.js.map +1 -0
  109. package/dist/src/server.d.ts +33 -0
  110. package/dist/src/server.js +973 -0
  111. package/dist/src/server.js.map +1 -0
  112. package/dist/src/terminal.d.ts +32 -0
  113. package/dist/src/terminal.js +92 -0
  114. package/dist/src/terminal.js.map +1 -0
  115. package/dist/src/titler.d.ts +15 -0
  116. package/dist/src/titler.js +72 -0
  117. package/dist/src/titler.js.map +1 -0
  118. package/dist/src/types.d.ts +99 -0
  119. package/dist/src/types.js +3 -0
  120. package/dist/src/types.js.map +1 -0
  121. package/dist/src/usage-limit.d.ts +49 -0
  122. package/dist/src/usage-limit.js +83 -0
  123. package/dist/src/usage-limit.js.map +1 -0
  124. package/dist/src/usage.d.ts +23 -0
  125. package/dist/src/usage.js +63 -0
  126. package/dist/src/usage.js.map +1 -0
  127. package/dist/src/workspace.d.ts +303 -0
  128. package/dist/src/workspace.js +1086 -0
  129. package/dist/src/workspace.js.map +1 -0
  130. package/dist/src/worktree.d.ts +133 -0
  131. package/dist/src/worktree.js +561 -0
  132. package/dist/src/worktree.js.map +1 -0
  133. package/package.json +67 -0
@@ -0,0 +1,973 @@
1
+ /**
2
+ * WSS server + message dispatch.
3
+ *
4
+ * Speaks the envelope contract only; knows nothing about Cloudflare
5
+ * (cloudflared dials this local port). A pluggable Codec decides whether
6
+ * payloads are cleartext (slices 1–4 / dev) or sealed NaCl boxes (slice 5).
7
+ */
8
+ import { randomUUID } from "node:crypto";
9
+ import { createServer } from "node:http";
10
+ import { createReadStream } from "node:fs";
11
+ import { stat } from "node:fs/promises";
12
+ import { fileURLToPath } from "node:url";
13
+ import { dirname, extname, join, normalize, resolve, sep } from "node:path";
14
+ import { WebSocketServer } from "ws";
15
+ import { make, ProtocolError } from "./envelope.js";
16
+ import { addPeer, boxCodec, isValidPublicKeyB64 } from "./crypto.js";
17
+ import { Hub } from "./hub.js";
18
+ import { WorkspaceManager } from "./workspace.js";
19
+ import { PortScanner } from "./ports.js";
20
+ import { ForwardManager } from "./forward.js";
21
+ import { AppConfigStore } from "./config.js";
22
+ import { CredentialsStore } from "./credentials.js";
23
+ import { DaemonIdentity } from "./daemon-identity.js";
24
+ import { originUrl } from "./gitops.js";
25
+ import { AllowlistStore } from "./allowlist.js";
26
+ import { SystemEvents } from "./events.js";
27
+ import { JobStore } from "./jobs/store.js";
28
+ import { JobScheduler } from "./jobs/scheduler.js";
29
+ import { status as githubStatus, listRepos } from "./github.js";
30
+ import { MODELS, DEFAULT_MODEL } from "./models.js";
31
+ import { logger } from "./log.js";
32
+ const startedAt = Date.now();
33
+ /**
34
+ * Web-client static root. Resolves to the repo-level `webApp/` directory (this
35
+ * module lives at daemon/src/server.ts, so ../../webApp from here). Overridable
36
+ * via WEB_ROOT for a packaged build. When the directory is absent, serving is
37
+ * simply skipped — the daemon still answers `/healthz` and WS upgrades.
38
+ *
39
+ * Serving the browser client from the same origin cloudflared dials means the
40
+ * Cloudflare Access cookie (the browser login) also authorizes the WebSocket —
41
+ * so the web app needs no service-token header (browsers can't set one on a WS
42
+ * upgrade). Locally it just makes http://127.0.0.1:8787/ open the UI.
43
+ */
44
+ const WEB_ROOT = resolve(process.env.WEB_ROOT ?? join(dirname(fileURLToPath(import.meta.url)), "..", "..", "webApp"));
45
+ const MIME = {
46
+ ".html": "text/html; charset=utf-8",
47
+ ".js": "text/javascript; charset=utf-8",
48
+ ".mjs": "text/javascript; charset=utf-8",
49
+ ".css": "text/css; charset=utf-8",
50
+ ".json": "application/json; charset=utf-8",
51
+ ".webmanifest": "application/manifest+json; charset=utf-8",
52
+ ".svg": "image/svg+xml",
53
+ ".png": "image/png",
54
+ ".jpg": "image/jpeg",
55
+ ".jpeg": "image/jpeg",
56
+ ".gif": "image/gif",
57
+ ".ico": "image/x-icon",
58
+ ".woff": "font/woff",
59
+ ".woff2": "font/woff2",
60
+ ".map": "application/json; charset=utf-8",
61
+ };
62
+ /**
63
+ * Serve a file from {@link WEB_ROOT} for a GET/HEAD request. Returns true if the
64
+ * request was handled (either a file was streamed or a 404 was sent for a path
65
+ * under the web root); false if the caller should fall through (e.g. traversal
66
+ * attempt) so the default 426 upgrade response applies. Path traversal outside
67
+ * the root is refused.
68
+ */
69
+ async function serveStatic(reqUrl, method, res) {
70
+ let pathname;
71
+ try {
72
+ pathname = decodeURIComponent(new URL(reqUrl, "http://localhost").pathname);
73
+ }
74
+ catch {
75
+ return false;
76
+ }
77
+ if (pathname === "/" || pathname === "")
78
+ pathname = "/index.html";
79
+ // Normalize and keep the request inside the web root (defeat ../ traversal).
80
+ const target = normalize(join(WEB_ROOT, pathname));
81
+ if (target !== WEB_ROOT && !target.startsWith(WEB_ROOT + sep))
82
+ return false;
83
+ try {
84
+ const st = await stat(target);
85
+ if (!st.isFile())
86
+ return false;
87
+ const type = MIME[extname(target).toLowerCase()] ?? "application/octet-stream";
88
+ res.writeHead(200, {
89
+ "content-type": type,
90
+ "content-length": st.size,
91
+ "cache-control": "no-cache",
92
+ });
93
+ if (method === "HEAD") {
94
+ res.end();
95
+ return true;
96
+ }
97
+ createReadStream(target).pipe(res);
98
+ return true;
99
+ }
100
+ catch {
101
+ return false;
102
+ }
103
+ }
104
+ export async function startServer(opts) {
105
+ // E2E on ⇒ require a sealed channel before fanning broadcasts to a socket, so
106
+ // an un-handshaked device can't be sent cleartext payloads over the tunnel.
107
+ const hub = new Hub(Boolean(opts.crypto));
108
+ // Bring the structured log up first: rotate an oversized prior file, then
109
+ // fan every entry out to any client following the log (the Logs pane).
110
+ await logger.start();
111
+ logger.setSink((entry) => hub.broadcastLog("log.event", entry.ws ?? null, { entry }));
112
+ const events = new SystemEvents();
113
+ // Provider API keys (app-managed, env-seeded). Loaded before the manager so
114
+ // the first agent spawn resolves keys from a hydrated store.
115
+ const credentials = new CredentialsStore();
116
+ await credentials.load();
117
+ // Daemon-owned app config (prompts + git behavior). Loaded before the manager
118
+ // so the first spawn/worktree-create reads a hydrated store.
119
+ const appConfig = new AppConfigStore();
120
+ await appConfig.load();
121
+ // This daemon's stable id + human name (default = machine hostname). A client
122
+ // connected to several daemons labels each by this; sent as `daemon.info`.
123
+ const identity = new DaemonIdentity();
124
+ await identity.load();
125
+ // Daemon-global "Allow all" list. Loaded before the manager so the first agent
126
+ // spawn already sees any prior grants (auto-approving matching calls).
127
+ const allowlist = new AllowlistStore();
128
+ await allowlist.load();
129
+ // Default chatlog (2nd arg undefined → its default) + event bus + credentials
130
+ // + app config + allowlist.
131
+ const workspaces = new WorkspaceManager(hub, undefined, events, undefined, credentials, appConfig, allowlist);
132
+ await workspaces.load();
133
+ // Cron/scheduled jobs. The scheduler drives runs through the WorkspaceManager
134
+ // (which structurally satisfies its JobRunnerHost) and listens on the same
135
+ // event bus the manager emits to, so reactive ("N min after X") triggers work.
136
+ const jobStore = new JobStore();
137
+ await jobStore.load();
138
+ const jobs = new JobScheduler(jobStore, workspaces, events, (run) => hub.broadcast("event.job_run", run.ws ?? null, { run }));
139
+ jobs.start();
140
+ // Dev-server preview: discover the TCP ports agents open inside their
141
+ // worktrees and let a client forward one to a local browser. The scanner polls
142
+ // lsof (worktree-scoped by cwd) and broadcasts the owned set on every change;
143
+ // the manager bridges each forwarded stream to loopback on this box. Both are
144
+ // idle until at least one client is attached (started/stopped below).
145
+ const forwardScanner = new PortScanner(() => workspaces.list().map((r) => ({ ws: r.id, worktreePath: r.worktreePath })), (ports) => hub.broadcast("forward.available", null, { ports }));
146
+ const forwards = new ForwardManager(hub, (ws, port) => forwardScanner.isOwned(ws, port));
147
+ let clientCount = 0;
148
+ // Own the HTTP server explicitly so we can serve a plaintext /healthz probe
149
+ // on the same port cloudflared dials. launchd (KeepAlive) restarts the daemon
150
+ // if it exits; /healthz lets the tunnel's ingress and any external monitor see
151
+ // that the *origin* is actually up — the failure mode in the cloudflared logs
152
+ // was "connection refused" to this port, not the tunnel itself dropping.
153
+ const http = createServer((req, res) => {
154
+ if (req.method === "GET" && req.url === "/healthz") {
155
+ const info = identity.info();
156
+ const body = JSON.stringify({
157
+ status: "ok",
158
+ daemonId: info.daemonId,
159
+ name: info.name,
160
+ hostname: info.hostname,
161
+ version: info.version,
162
+ uptimeSeconds: Math.round((Date.now() - startedAt) / 1000),
163
+ pid: process.pid,
164
+ workspaces: workspaces.list().length,
165
+ });
166
+ res.writeHead(200, {
167
+ "content-type": "application/json",
168
+ "cache-control": "no-store",
169
+ });
170
+ res.end(body);
171
+ return;
172
+ }
173
+ // Serve the browser client (webApp/) on GET/HEAD from the same origin the
174
+ // tunnel dials, so the Access cookie authorizes both the page and its WS.
175
+ if ((req.method === "GET" || req.method === "HEAD") && req.url) {
176
+ void serveStatic(req.url, req.method, res).then((handled) => {
177
+ if (handled)
178
+ return;
179
+ // Unknown non-upgrade GET → fall back to the SPA entry so deep links work.
180
+ void serveStatic("/index.html", req.method, res).then((servedIndex) => {
181
+ if (servedIndex)
182
+ return;
183
+ res.writeHead(426, { "content-type": "text/plain" });
184
+ res.end("Upgrade Required\n");
185
+ });
186
+ });
187
+ return;
188
+ }
189
+ // Everything else on this port is meant to be a WebSocket upgrade.
190
+ res.writeHead(426, { "content-type": "text/plain" });
191
+ res.end("Upgrade Required\n");
192
+ });
193
+ const wss = new WebSocketServer({ server: http });
194
+ wss.on("connection", (socket, req) => {
195
+ const peer = req.socket.remoteAddress ?? "unknown";
196
+ logger.info("conn", `client connected from ${peer}`);
197
+ hub.add(socket);
198
+ // Announce this daemon's identity so a client connected to several daemons can
199
+ // label this connection (name defaults to the machine hostname). Only pushed
200
+ // unsolicited on a CLEARTEXT socket — with E2E on, the socket starts cleartext
201
+ // and the client flips to sealed-only right after its `hello`, so a cleartext
202
+ // push would race and be rejected as unsealed. Those clients fetch it instead
203
+ // via a sealed `daemon.get` after the handshake.
204
+ if (!opts.crypto)
205
+ hub.sendTo(socket, "daemon.info", null, identity.info());
206
+ // First client attaching turns on port discovery; a fresh scan runs now so
207
+ // this client sees any already-running dev servers without waiting a tick.
208
+ if (clientCount++ === 0)
209
+ forwardScanner.start();
210
+ else
211
+ void forwardScanner.refresh();
212
+ // Fallback device identity for clients that don't send a stable deviceId:
213
+ // isolates this connection's terminal, though it won't survive a reconnect.
214
+ const connId = randomUUID();
215
+ socket.on("message", (data) => handleFrame(socket, data.toString(), connId, opts.crypto, hub, workspaces, jobs, credentials, appConfig, identity, forwards, forwardScanner));
216
+ socket.on("close", (code) => {
217
+ hub.remove(socket);
218
+ // Reap the per-connection terminal (keyed by this connection's fallback
219
+ // connId) so a client that sends no stable deviceId doesn't leak a PTY on
220
+ // every reconnect. Stable-deviceId terminals are left for reconnect.
221
+ workspaces.reapDevice(connId);
222
+ // Drop every forwarded stream this socket owned, and stop scanning once the
223
+ // last client leaves (lsof polling is pointless with nobody to notify).
224
+ forwards.reapClient(socket);
225
+ if (--clientCount <= 0) {
226
+ clientCount = 0;
227
+ forwardScanner.stop();
228
+ }
229
+ logger.info("conn", `client ${peer} disconnected`, { data: { closeCode: code } });
230
+ });
231
+ socket.on("error", (err) => logger.error("conn", "socket error", {
232
+ data: { peer, message: err instanceof Error ? err.message : String(err) },
233
+ }));
234
+ });
235
+ http.listen(opts.port, opts.host, () => logger.info("system", `listening on ws://${opts.host}:${opts.port} (health: http://${opts.host}:${opts.port}/healthz)`));
236
+ return { http, wss, hub, workspaces, jobs };
237
+ }
238
+ async function projects(workspaces) {
239
+ return Promise.all(workspaces.listProjects().map(async (p) => {
240
+ // Origin URL lets a multi-daemon client match this project to the "same"
241
+ // project on another daemon (same remote) when moving a workspace.
242
+ const origin = await originUrl(p.repo);
243
+ return {
244
+ id: p.id,
245
+ name: p.name,
246
+ repo: p.repo,
247
+ ...(p.github ? { github: p.github } : {}),
248
+ ...(origin ? { originUrl: origin } : {}),
249
+ };
250
+ }));
251
+ }
252
+ const WORKSPACE_ACTION_KINDS = [
253
+ "code_review",
254
+ "create_pr",
255
+ "fix_errors",
256
+ "resolve_merge_conflicts",
257
+ "rename_branch",
258
+ ];
259
+ function isWorkspaceActionKind(k) {
260
+ return typeof k === "string" && WORKSPACE_ACTION_KINDS.includes(k);
261
+ }
262
+ /** At most this many attachments per prompt, and this many base64 chars each. */
263
+ const MAX_ATTACHMENTS = 6;
264
+ const MAX_ATTACHMENT_B64 = 12 * 1024 * 1024; // ~9 MB of raw bytes per file
265
+ /**
266
+ * Validate + bound the `attachments` a client sent with a prompt. Anything that
267
+ * isn't a well-formed {name, mediaType, data} triple is dropped; oversized files
268
+ * and excess count are trimmed so a malformed or hostile frame can't wedge the
269
+ * single multiplexed connection. Returns undefined when nothing valid remains.
270
+ */
271
+ function sanitizeAttachments(raw) {
272
+ if (!Array.isArray(raw))
273
+ return undefined;
274
+ const out = [];
275
+ for (const item of raw) {
276
+ if (out.length >= MAX_ATTACHMENTS)
277
+ break;
278
+ if (!item || typeof item !== "object")
279
+ continue;
280
+ const a = item;
281
+ if (typeof a.name !== "string" ||
282
+ typeof a.mediaType !== "string" ||
283
+ typeof a.data !== "string" ||
284
+ a.data.length === 0 ||
285
+ a.data.length > MAX_ATTACHMENT_B64) {
286
+ continue;
287
+ }
288
+ out.push({ name: a.name, mediaType: a.mediaType, data: a.data });
289
+ }
290
+ return out.length > 0 ? out : undefined;
291
+ }
292
+ async function handleFrame(socket, raw, connId, crypto, hub, workspaces, jobs, credentials, appConfig, identity, forwards, forwardScanner) {
293
+ let env;
294
+ try {
295
+ // Decode with this socket's own codec — cleartext until it has completed the
296
+ // `hello` handshake, then the NaCl box sealed to that device's key.
297
+ env = hub.codecFor(socket).decode(raw);
298
+ }
299
+ catch (err) {
300
+ const pe = err instanceof ProtocolError ? err : null;
301
+ logger.warn("conn", "frame decode failed", {
302
+ data: { code: pe?.code ?? "decode_error" },
303
+ });
304
+ hub.send(socket, make("error", null, {
305
+ code: pe?.code ?? "decode_error",
306
+ message: pe?.message ?? "could not decode frame",
307
+ }));
308
+ return;
309
+ }
310
+ // DEBUG_WIRE=1 logs every inbound frame type — handy when bringing up a new
311
+ // client against the protocol.
312
+ if (process.env.DEBUG_WIRE)
313
+ console.log(`[wire] <- ${env.type}`);
314
+ const reply = (env2) => hub.send(socket, env2);
315
+ try {
316
+ switch (env.type) {
317
+ case "hello": {
318
+ // Multi-device pairing handshake. The client announces its public key
319
+ // (cleartext, but already inside the Access-authenticated tunnel — the
320
+ // Access token IS the pairing secret, so this is trust-on-first-use).
321
+ // We bind THIS socket to a box sealed to that key; other devices keep
322
+ // their own keys and codecs, so no device clobbers another.
323
+ const p = env.payload;
324
+ if (!crypto) {
325
+ // E2E off: acknowledge but stay cleartext.
326
+ reply(make("hello.ok", null, { pub: "", sealed: false }));
327
+ return;
328
+ }
329
+ if (typeof p.pub !== "string" || !isValidPublicKeyB64(p.pub)) {
330
+ reply(make("error", null, { code: "bad_pubkey", message: "hello.pub is not a valid public key" }));
331
+ return;
332
+ }
333
+ const label = typeof p.label === "string" ? p.label : undefined;
334
+ // Switch this socket to a sealed codec SYNCHRONOUSLY — before any await —
335
+ // so a sealed frame the client sends right after hello can't race in and
336
+ // get decoded with the stale cleartext codec. Persistence is best-effort
337
+ // and runs after (its own writes are serialized in addPeer).
338
+ hub.setCodec(socket, boxCodec(crypto.self, Buffer.from(p.pub, "base64")));
339
+ // Sealed from here on: the client enabled its box right after sending
340
+ // hello, so it can open this reply.
341
+ reply(make("hello.ok", null, { pub: crypto.self.publicKey.toString("base64"), sealed: true }));
342
+ void addPeer(p.pub, label).catch((err) => logger.error("conn", "failed to persist peer", {
343
+ data: { message: err instanceof Error ? err.message : String(err) },
344
+ }));
345
+ // Never log the device public key — only that a labelled device paired.
346
+ logger.info("conn", "paired device", { data: { label: label ?? "no label" } });
347
+ return;
348
+ }
349
+ case "pair.info":
350
+ reply(make("pair.info.result", null, { pub: crypto?.self.publicKey.toString("base64") ?? "" }));
351
+ return;
352
+ case "daemon.get":
353
+ reply(make("daemon.info", null, identity.info()));
354
+ return;
355
+ case "daemon.rename": {
356
+ const p = env.payload;
357
+ if (typeof p.name !== "string" || !p.name.trim()) {
358
+ reply(make("error", null, { code: "bad_args", message: "daemon name is empty" }));
359
+ return;
360
+ }
361
+ await identity.rename(p.name);
362
+ // Broadcast the new identity so every connected client converges.
363
+ hub.broadcast("daemon.info", null, identity.info());
364
+ return;
365
+ }
366
+ case "ping":
367
+ reply(make("pong", env.ws, {}));
368
+ return;
369
+ case "echo": {
370
+ const p = env.payload;
371
+ reply(make("echo.result", env.ws, { text: typeof p.text === "string" ? p.text : "" }));
372
+ return;
373
+ }
374
+ case "project.list":
375
+ reply(make("project.list.result", null, { projects: await projects(workspaces) }));
376
+ return;
377
+ case "project.create": {
378
+ const p = env.payload;
379
+ if (!p.blank && !p.repo && !p.github?.fullName) {
380
+ reply(make("error", null, { code: "bad_args", message: "blank, repo, or github is required" }));
381
+ return;
382
+ }
383
+ await workspaces.createProject({ blank: p.blank, repo: p.repo, github: p.github, name: p.name });
384
+ hub.broadcast("project.list.result", null, { projects: await projects(workspaces) });
385
+ return;
386
+ }
387
+ case "project.delete": {
388
+ const p = env.payload;
389
+ if (!p.id) {
390
+ reply(make("error", null, { code: "bad_args", message: "id is required" }));
391
+ return;
392
+ }
393
+ await workspaces.deleteProject(p.id);
394
+ hub.broadcast("project.list.result", null, { projects: await projects(workspaces) });
395
+ return;
396
+ }
397
+ case "project.reorder": {
398
+ const p = env.payload;
399
+ const ids = Array.isArray(p.ids)
400
+ ? p.ids.filter((x) => typeof x === "string")
401
+ : [];
402
+ if (ids.length === 0) {
403
+ reply(make("error", null, { code: "bad_args", message: "ids is required" }));
404
+ return;
405
+ }
406
+ await workspaces.reorderProjects(ids);
407
+ hub.broadcast("project.list.result", null, { projects: await projects(workspaces) });
408
+ return;
409
+ }
410
+ case "project.branches": {
411
+ const p = env.payload;
412
+ if (!p.projectId) {
413
+ reply(make("error", null, { code: "bad_args", message: "projectId is required" }));
414
+ return;
415
+ }
416
+ try {
417
+ const { branches, defaultBranch } = await workspaces.listBranches(p.projectId);
418
+ reply(make("project.branches.result", null, { projectId: p.projectId, branches, defaultBranch }));
419
+ }
420
+ catch (err) {
421
+ reply(make("error", null, {
422
+ code: "branches_error",
423
+ message: err instanceof Error ? err.message : "listing branches failed",
424
+ }));
425
+ }
426
+ return;
427
+ }
428
+ case "project.pulls": {
429
+ const p = env.payload;
430
+ if (!p.projectId) {
431
+ reply(make("error", null, { code: "bad_args", message: "projectId is required" }));
432
+ return;
433
+ }
434
+ try {
435
+ const pulls = await workspaces.listPullRequests(p.projectId);
436
+ reply(make("project.pulls.result", null, { projectId: p.projectId, pulls }));
437
+ }
438
+ catch (err) {
439
+ reply(make("error", null, {
440
+ code: "pulls_error",
441
+ message: err instanceof Error ? err.message : "listing pull requests failed",
442
+ }));
443
+ }
444
+ return;
445
+ }
446
+ case "project.issues": {
447
+ const p = env.payload;
448
+ if (!p.projectId) {
449
+ reply(make("error", null, { code: "bad_args", message: "projectId is required" }));
450
+ return;
451
+ }
452
+ try {
453
+ const issues = await workspaces.listIssues(p.projectId);
454
+ reply(make("project.issues.result", null, { projectId: p.projectId, issues }));
455
+ }
456
+ catch (err) {
457
+ reply(make("error", null, {
458
+ code: "issues_error",
459
+ message: err instanceof Error ? err.message : "listing issues failed",
460
+ }));
461
+ }
462
+ return;
463
+ }
464
+ case "workspace.list":
465
+ // Reply immediately from local git state so the sidebar paints fast,
466
+ // then — since every app sends this on connect — fetch fresh remote refs
467
+ // for each base repo and re-broadcast if the freshened base moved any
468
+ // diff-stat. Without this the list path never fetched, so a base that
469
+ // advanced on origin while the app was closed stayed stale until the
470
+ // user happened to open a workspace (and Flutter fetched nothing at all).
471
+ reply(make("workspace.list.result", null, { workspaces: await workspaces.summaries() }));
472
+ void workspaces.syncAllReposAndRefresh();
473
+ return;
474
+ case "models.list":
475
+ reply(make("models.list.result", null, {
476
+ models: MODELS,
477
+ defaultModel: DEFAULT_MODEL.id,
478
+ }));
479
+ return;
480
+ case "workspace.create": {
481
+ const p = env.payload;
482
+ // A chat is repo-less: no project/repo/github required — the daemon
483
+ // spins up a private scratch dir instead of a worktree.
484
+ if (p.kind === "chat") {
485
+ await workspaces.createChat({ model: p.model });
486
+ await workspaces.broadcastWorkspaceList();
487
+ return;
488
+ }
489
+ if (!p.projectId && !p.repo && !p.github?.fullName) {
490
+ reply(make("error", null, { code: "bad_args", message: "projectId, repo, or github is required" }));
491
+ return;
492
+ }
493
+ await workspaces.create({
494
+ projectId: p.projectId,
495
+ repo: p.repo,
496
+ github: p.github,
497
+ name: p.name,
498
+ branchBase: p.branchBase,
499
+ sourceBranch: p.sourceBranch,
500
+ sourcePr: p.sourcePr,
501
+ initialPrompt: p.initialPrompt,
502
+ model: p.model,
503
+ });
504
+ // Broadcast fresh lists so every client converges. Projects too: the
505
+ // legacy bare-repo path may have just registered a new one.
506
+ hub.broadcast("project.list.result", null, { projects: await projects(workspaces) });
507
+ await workspaces.broadcastWorkspaceList();
508
+ return;
509
+ }
510
+ case "github.status": {
511
+ reply(make("github.status.result", null, await githubStatus()));
512
+ return;
513
+ }
514
+ case "github.repos": {
515
+ const p = env.payload;
516
+ try {
517
+ const repos = await listRepos(p.limit ?? 100);
518
+ reply(make("github.repos.result", null, { repos }));
519
+ }
520
+ catch (err) {
521
+ reply(make("error", null, {
522
+ code: "github_error",
523
+ message: err instanceof Error ? err.message : "listing repos failed",
524
+ }));
525
+ }
526
+ return;
527
+ }
528
+ case "workspace.delete": {
529
+ const p = env.payload;
530
+ if (p.ws)
531
+ await workspaces.delete(p.ws);
532
+ await workspaces.broadcastWorkspaceList();
533
+ return;
534
+ }
535
+ case "workspace.archive": {
536
+ const p = env.payload;
537
+ if (p.ws)
538
+ await workspaces.archive(p.ws);
539
+ await workspaces.broadcastWorkspaceList();
540
+ return;
541
+ }
542
+ case "workspace.action": {
543
+ const p = env.payload;
544
+ if (!p.ws || !isWorkspaceActionKind(p.kind)) {
545
+ reply(make("error", p.ws ?? null, { code: "bad_args", message: "unknown workspace action" }));
546
+ return;
547
+ }
548
+ // Seeds the workspace's agent; its events stream over the normal chat
549
+ // flow, so no dedicated result frame is needed here.
550
+ workspaces.runAction(p.ws, p.kind);
551
+ return;
552
+ }
553
+ case "delete.batch": {
554
+ const p = env.payload;
555
+ // Keep only string ids, and cap how much one frame can drive: the fold
556
+ // + sequential git work is synchronous on the event loop, so an
557
+ // authenticated client shouldn't be able to wedge the daemon with a
558
+ // giant id blob (mirrors the settings.set length guard).
559
+ const asIds = (v) => Array.isArray(v) ? v.filter((x) => typeof x === "string") : [];
560
+ const wsIds = asIds(p.workspaces);
561
+ const projIds = asIds(p.projects);
562
+ if (wsIds.length + projIds.length > 1000) {
563
+ reply(make("error", null, { code: "bad_args", message: "delete.batch accepts at most 1000 ids" }));
564
+ return;
565
+ }
566
+ const result = await workspaces.deleteBatch({ workspaces: wsIds, projects: projIds });
567
+ // Refresh both catalogs once (either may have shrunk), then tell the
568
+ // requester exactly what landed and what refused.
569
+ hub.broadcast("project.list.result", null, { projects: await projects(workspaces) });
570
+ await workspaces.broadcastWorkspaceList();
571
+ reply(make("delete.batch.result", null, result));
572
+ return;
573
+ }
574
+ case "prompt.send": {
575
+ const p = env.payload;
576
+ if (p.ws && typeof p.text === "string") {
577
+ workspaces.prompt(p.ws, p.text, sanitizeAttachments(p.attachments));
578
+ }
579
+ return;
580
+ }
581
+ case "agent.stop": {
582
+ const p = env.payload;
583
+ if (p.ws)
584
+ await workspaces.stopAgent(p.ws);
585
+ return;
586
+ }
587
+ case "permission.respond": {
588
+ const p = env.payload;
589
+ if (p.ws &&
590
+ p.requestId &&
591
+ (p.decision === "allow" || p.decision === "deny" || p.decision === "allow_all")) {
592
+ workspaces.respondPermission(p.ws, p.requestId, p.decision);
593
+ // A grant may have changed the allowlist — fan the fresh list out so
594
+ // every client's admin view converges (no-op for allow/deny).
595
+ if (p.decision === "allow_all") {
596
+ hub.broadcast("allowlist.result", null, {
597
+ entries: workspaces.allowlistStore().list(),
598
+ });
599
+ }
600
+ }
601
+ return;
602
+ }
603
+ case "question.respond": {
604
+ const p = env.payload;
605
+ // Require actual answer content: an empty `{}` answers object is truthy
606
+ // but carries nothing, so it must not mark the question as answered.
607
+ const hasAnswers = p.answers != null && Object.keys(p.answers).length > 0;
608
+ const hasResponse = typeof p.response === "string" && p.response.trim() !== "";
609
+ if (p.ws && p.requestId && (hasAnswers || hasResponse)) {
610
+ workspaces.answerQuestion(p.ws, p.requestId, p.answers ?? {}, p.response);
611
+ }
612
+ return;
613
+ }
614
+ case "allowlist.list": {
615
+ reply(make("allowlist.result", null, { entries: workspaces.allowlistStore().list() }));
616
+ return;
617
+ }
618
+ case "allowlist.revoke": {
619
+ const p = env.payload;
620
+ if (p.key) {
621
+ await workspaces.allowlistStore().revoke(p.key);
622
+ hub.broadcast("allowlist.result", null, {
623
+ entries: workspaces.allowlistStore().list(),
624
+ });
625
+ }
626
+ return;
627
+ }
628
+ case "allowlist.clear": {
629
+ await workspaces.allowlistStore().clear();
630
+ hub.broadcast("allowlist.result", null, {
631
+ entries: workspaces.allowlistStore().list(),
632
+ });
633
+ return;
634
+ }
635
+ case "terminal.input": {
636
+ const p = env.payload;
637
+ if (p.ws && typeof p.data === "string") {
638
+ workspaces.terminalInput(p.ws, p.deviceId || connId, p.data, socket);
639
+ }
640
+ return;
641
+ }
642
+ case "terminal.resize": {
643
+ const p = env.payload;
644
+ if (p.ws && typeof p.cols === "number" && typeof p.rows === "number") {
645
+ workspaces.terminalResize(p.ws, p.deviceId || connId, Math.floor(p.cols), Math.floor(p.rows), socket);
646
+ }
647
+ return;
648
+ }
649
+ case "terminal.snapshot": {
650
+ const p = env.payload;
651
+ if (!p.ws)
652
+ return;
653
+ const data = workspaces.terminalSnapshot(p.ws, p.deviceId || connId, socket);
654
+ if (data === null) {
655
+ reply(make("error", p.ws, { code: "no_such_ws", message: "unknown workspace", ws: p.ws }));
656
+ return;
657
+ }
658
+ reply(make("terminal.snapshot.result", p.ws, { ws: p.ws, data }));
659
+ return;
660
+ }
661
+ case "forward.list": {
662
+ // Point-to-point: answer just the requester with the current owned set.
663
+ reply(make("forward.available", null, { ports: forwardScanner.current }));
664
+ return;
665
+ }
666
+ case "forward.open": {
667
+ const p = env.payload;
668
+ if (p.ws && typeof p.streamId === "string" && typeof p.port === "number") {
669
+ forwards.open(socket, p.ws, p.streamId, Math.floor(p.port));
670
+ }
671
+ return;
672
+ }
673
+ case "forward.data": {
674
+ const p = env.payload;
675
+ if (typeof p.streamId === "string" && typeof p.data === "string") {
676
+ forwards.data(p.streamId, p.data);
677
+ }
678
+ return;
679
+ }
680
+ case "forward.close": {
681
+ const p = env.payload;
682
+ if (typeof p.streamId === "string")
683
+ forwards.close(p.streamId);
684
+ return;
685
+ }
686
+ case "git.status": {
687
+ const p = env.payload;
688
+ if (!p.ws)
689
+ return;
690
+ // `sync` (deliberate open/refresh) forces a fetch past the per-repo
691
+ // throttle; a plain poll omits it and stays throttled.
692
+ const result = await workspaces.gitStatus(p.ws, p.sync === true);
693
+ if (!result) {
694
+ // Unknown ws, or the workspace was just auto-archived on merge — push
695
+ // the fresh list so every client drops it, then report it gone.
696
+ await workspaces.broadcastWorkspaceList();
697
+ reply(make("error", p.ws, { code: "no_such_ws", message: "unknown workspace", ws: p.ws }));
698
+ return;
699
+ }
700
+ reply(make("git.status.result", p.ws, result));
701
+ return;
702
+ }
703
+ case "git.commit": {
704
+ const p = env.payload;
705
+ if (!p.ws || typeof p.message !== "string")
706
+ return;
707
+ const result = await workspaces.gitCommit(p.ws, p.message);
708
+ if (result)
709
+ reply(make("git.result", p.ws, result));
710
+ return;
711
+ }
712
+ case "git.push": {
713
+ const p = env.payload;
714
+ if (!p.ws)
715
+ return;
716
+ const result = await workspaces.gitPush(p.ws);
717
+ if (result)
718
+ reply(make("git.result", p.ws, result));
719
+ return;
720
+ }
721
+ case "git.resolve": {
722
+ const p = env.payload;
723
+ if (!p.ws)
724
+ return;
725
+ const result = await workspaces.gitResolve(p.ws);
726
+ if (result)
727
+ reply(make("git.result", p.ws, result));
728
+ return;
729
+ }
730
+ case "git.merge": {
731
+ const p = env.payload;
732
+ if (!p.ws)
733
+ return;
734
+ if (p.action !== "ours" && p.action !== "theirs" && p.action !== "commit" && p.action !== "abort")
735
+ return;
736
+ const paths = Array.isArray(p.paths) ? p.paths.filter((x) => typeof x === "string") : undefined;
737
+ const result = await workspaces.gitMerge(p.ws, p.action, paths);
738
+ if (result)
739
+ reply(make("git.result", p.ws, result));
740
+ return;
741
+ }
742
+ case "pr.create": {
743
+ const p = env.payload;
744
+ if (!p.ws || typeof p.title !== "string")
745
+ return;
746
+ const result = await workspaces.prCreate(p.ws, p.title, p.body);
747
+ if (result)
748
+ reply(make("git.result", p.ws, result));
749
+ return;
750
+ }
751
+ case "pr.merge": {
752
+ const p = env.payload;
753
+ if (!p.ws)
754
+ return;
755
+ const result = await workspaces.prMerge(p.ws);
756
+ if (result)
757
+ reply(make("git.result", p.ws, result));
758
+ return;
759
+ }
760
+ case "issue.create": {
761
+ const p = env.payload;
762
+ if (!p.ws || typeof p.title !== "string")
763
+ return;
764
+ const result = await workspaces.issueCreate(p.ws, p.title, p.body);
765
+ if (result)
766
+ reply(make("git.result", p.ws, result));
767
+ return;
768
+ }
769
+ case "workspace.open": {
770
+ const p = env.payload;
771
+ const target = p.target;
772
+ if (p.ws && (target === "finder" || target === "terminal" || target === "editor")) {
773
+ await workspaces.open(p.ws, target);
774
+ }
775
+ return;
776
+ }
777
+ case "diff.get": {
778
+ const p = env.payload;
779
+ if (!p.ws)
780
+ return;
781
+ const files = (await workspaces.diff(p.ws)) ?? [];
782
+ reply(make("diff.result", p.ws, { ws: p.ws, files }));
783
+ return;
784
+ }
785
+ case "tree.get": {
786
+ const p = env.payload;
787
+ if (!p.ws)
788
+ return;
789
+ const files = (await workspaces.tree(p.ws)) ?? [];
790
+ reply(make("tree.result", p.ws, { ws: p.ws, files }));
791
+ return;
792
+ }
793
+ case "chat.history": {
794
+ const p = env.payload;
795
+ if (!p.ws)
796
+ return;
797
+ reply(make("chat.history.result", p.ws, { ws: p.ws, entries: workspaces.history(p.ws) }));
798
+ return;
799
+ }
800
+ case "file.get": {
801
+ const p = env.payload;
802
+ if (!p.ws || typeof p.path !== "string")
803
+ return;
804
+ const file = await workspaces.readFile(p.ws, p.path);
805
+ if (!file) {
806
+ reply(make("error", p.ws, { code: "no_such_ws", message: "unknown workspace", ws: p.ws }));
807
+ return;
808
+ }
809
+ reply(make("file.result", p.ws, { ws: p.ws, ...file }));
810
+ return;
811
+ }
812
+ case "file.set": {
813
+ const p = env.payload;
814
+ if (!p.ws || typeof p.path !== "string" || typeof p.content !== "string")
815
+ return;
816
+ try {
817
+ const bytes = await workspaces.writeFile(p.ws, p.path, p.content);
818
+ if (bytes === null) {
819
+ reply(make("error", p.ws, { code: "no_such_ws", message: "unknown workspace", ws: p.ws }));
820
+ return;
821
+ }
822
+ reply(make("file.saved", p.ws, { ws: p.ws, path: p.path, bytes }));
823
+ }
824
+ catch (err) {
825
+ reply(make("error", p.ws, {
826
+ code: "file_write_failed",
827
+ message: err instanceof Error ? err.message : "file.set failed",
828
+ ws: p.ws,
829
+ }));
830
+ }
831
+ return;
832
+ }
833
+ case "job.list": {
834
+ reply(make("job.list.result", null, { jobs: jobs.list() }));
835
+ return;
836
+ }
837
+ case "job.create": {
838
+ const p = env.payload;
839
+ if (!p.job) {
840
+ reply(make("error", null, { code: "bad_args", message: "job is required" }));
841
+ return;
842
+ }
843
+ try {
844
+ await jobs.create(p.job);
845
+ hub.broadcast("job.list.result", null, { jobs: jobs.list() });
846
+ }
847
+ catch (err) {
848
+ reply(make("error", null, {
849
+ code: "job_error",
850
+ message: err instanceof Error ? err.message : "job.create failed",
851
+ }));
852
+ }
853
+ return;
854
+ }
855
+ case "job.update": {
856
+ const p = env.payload;
857
+ if (!p.id || !p.patch) {
858
+ reply(make("error", null, { code: "bad_args", message: "id and patch are required" }));
859
+ return;
860
+ }
861
+ try {
862
+ await jobs.update(p.id, p.patch);
863
+ hub.broadcast("job.list.result", null, { jobs: jobs.list() });
864
+ }
865
+ catch (err) {
866
+ reply(make("error", null, {
867
+ code: "job_error",
868
+ message: err instanceof Error ? err.message : "job.update failed",
869
+ }));
870
+ }
871
+ return;
872
+ }
873
+ case "job.delete": {
874
+ const p = env.payload;
875
+ if (!p.id) {
876
+ reply(make("error", null, { code: "bad_args", message: "id is required" }));
877
+ return;
878
+ }
879
+ await jobs.delete(p.id);
880
+ hub.broadcast("job.list.result", null, { jobs: jobs.list() });
881
+ return;
882
+ }
883
+ case "job.run": {
884
+ const p = env.payload;
885
+ if (!p.id)
886
+ return;
887
+ try {
888
+ await jobs.runNow(p.id);
889
+ }
890
+ catch (err) {
891
+ reply(make("error", null, {
892
+ code: "job_error",
893
+ message: err instanceof Error ? err.message : "job.run failed",
894
+ }));
895
+ }
896
+ return;
897
+ }
898
+ case "job.runs": {
899
+ const p = env.payload;
900
+ reply(make("job.runs.result", null, { runs: jobs.runsFor(p.jobId, p.limit) }));
901
+ return;
902
+ }
903
+ case "settings.get":
904
+ // Credential presence (never the raw keys — status() masks them) plus
905
+ // the daemon-owned app config; both ride this one convergence event.
906
+ reply(make("settings.result", null, {
907
+ credentials: credentials.status(),
908
+ config: appConfig.get(),
909
+ }));
910
+ return;
911
+ case "settings.set": {
912
+ const p = env.payload;
913
+ if (!CredentialsStore.isProvider(p.provider)) {
914
+ reply(make("error", null, { code: "bad_args", message: "unknown credential provider" }));
915
+ return;
916
+ }
917
+ const apiKey = typeof p.apiKey === "string" ? p.apiKey : "";
918
+ // No real provider key approaches this; the cap just stops a malicious
919
+ // client persisting a multi-MB blob to disk. Never log the value.
920
+ if (apiKey.length > 1024) {
921
+ reply(make("error", null, { code: "bad_args", message: "apiKey too long" }));
922
+ return;
923
+ }
924
+ // Empty string clears the key (→ SSO/none).
925
+ await credentials.setKey(p.provider, apiKey);
926
+ // Broadcast the fresh status so every client's indicator converges.
927
+ hub.broadcast("settings.result", null, {
928
+ credentials: credentials.status(),
929
+ config: appConfig.get(),
930
+ });
931
+ return;
932
+ }
933
+ case "config.set": {
934
+ // Full-object replacement (last-write-wins). The store sanitizes/clamps
935
+ // every field, so an untrusted or partial payload can't corrupt state.
936
+ const p = env.payload;
937
+ await appConfig.set(p.config);
938
+ // Broadcast the fresh, sanitized config (alongside credential status) so
939
+ // every client converges on what the daemon actually stored.
940
+ hub.broadcast("settings.result", null, {
941
+ credentials: credentials.status(),
942
+ config: appConfig.get(),
943
+ });
944
+ return;
945
+ }
946
+ case "log.tail": {
947
+ const p = env.payload;
948
+ // Snapshot from the ring, then follow live entries on this socket.
949
+ reply(make("log.tail.result", null, { entries: logger.tail(p.limit, p.sinceSeq) }));
950
+ hub.subscribeLog(socket);
951
+ return;
952
+ }
953
+ default:
954
+ reply(make("error", env.ws, {
955
+ code: "not_implemented",
956
+ message: `type "${env.type}" is not handled`,
957
+ }));
958
+ return;
959
+ }
960
+ }
961
+ catch (err) {
962
+ logger.error("error", `handler failed for "${env.type}"`, {
963
+ ws: env.ws ?? undefined,
964
+ data: { message: err instanceof Error ? err.message : String(err) },
965
+ });
966
+ reply(make("error", env.ws, {
967
+ code: "handler_error",
968
+ message: err instanceof Error ? err.message : String(err),
969
+ ws: env.ws ?? undefined,
970
+ }));
971
+ }
972
+ }
973
+ //# sourceMappingURL=server.js.map