@workerdeck/server 0.6.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.
@@ -0,0 +1,1876 @@
1
+ import { existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, writeFileSync } from "node:fs";
2
+ import { createServer } from "node:http";
3
+ import { homedir } from "node:os";
4
+ import { dirname, join, resolve, sep } from "node:path";
5
+ import { WebSocketServer } from "ws";
6
+ import { listSessions } from "@anthropic-ai/claude-agent-sdk";
7
+ import { BrowserBridgeExecutor, SessionRunner, checkClaudeAuth } from "@workerdeck/core";
8
+ import { JobQueue } from "@workerdeck/queue";
9
+ import { PROTOCOL_VERSION, PROVIDER_PERMISSION_MODES, supportsPermissionMode } from "@workerdeck/protocol";
10
+ import { mkdir, readFile, readdir, rename, rm, writeFile } from "node:fs/promises";
11
+ //#region src/registry.ts
12
+ /** In-memory session table. Terminal sessions stay listed until removed or the process exits. */
13
+ var SessionRegistry = class {
14
+ #sessions = /* @__PURE__ */ new Map();
15
+ #options;
16
+ constructor(options = {}) {
17
+ this.#options = options;
18
+ }
19
+ create(config) {
20
+ return this.adopt(new SessionRunner(config));
21
+ }
22
+ /** Build and list a Claude-engine runner without starting it, so watchers can
23
+ * subscribe first. Call `start()` once they have. */
24
+ prepare(config) {
25
+ return this.register(new SessionRunner(config));
26
+ }
27
+ /** Register an already-built runner (a non-Claude engine) and start it. */
28
+ adopt(runner) {
29
+ this.register(runner);
30
+ runner.start();
31
+ return runner;
32
+ }
33
+ /** List a runner without starting it — for a rehydrated session, whose watchers
34
+ * must be subscribed before it comes back up. */
35
+ register(runner) {
36
+ const existing = this.#sessions.get(runner.id);
37
+ this.#sessions.set(runner.id, runner);
38
+ if (existing !== runner) this.#options.onRegister?.(runner);
39
+ return runner;
40
+ }
41
+ get(id) {
42
+ return this.#sessions.get(id);
43
+ }
44
+ list() {
45
+ return [...this.#sessions.values()].map((r) => r.info());
46
+ }
47
+ remove(id) {
48
+ const runner = this.#sessions.get(id);
49
+ if (!runner) return false;
50
+ runner.close("server");
51
+ return this.#sessions.delete(id);
52
+ }
53
+ /** Drop a runner WITHOUT closing it: the session isn't ending, it parked and
54
+ * lives on in its snapshot. Closing here would tell every client it was over. */
55
+ evict(id) {
56
+ return this.#sessions.delete(id);
57
+ }
58
+ closeAll() {
59
+ for (const runner of this.#sessions.values()) runner.close("server");
60
+ }
61
+ };
62
+ //#endregion
63
+ //#region src/notifications.ts
64
+ /**
65
+ * Turns session events into the handful of notifications a human away from the
66
+ * screen cares about, and delivers them to a webhook and/or a local observer.
67
+ *
68
+ * This is the *primitive*, deliberately transport-agnostic: the server stays
69
+ * credential-free and knows nothing about APNs, Slack or email. Turning a
70
+ * notification into a push is a forwarder's job (the turnkey CLI's), and one that
71
+ * needs credentials, so it does not live here.
72
+ *
73
+ * Delivery is best-effort and ordered per session, mirroring the job queue's
74
+ * webhook behaviour — a consumer that missed one can always attach to the session
75
+ * WS with `afterSeq` and see the truth.
76
+ */
77
+ var SessionNotifier = class {
78
+ #options;
79
+ /** Per-session delivery chain, so a session's notifications arrive in order. */
80
+ #chains = /* @__PURE__ */ new Map();
81
+ constructor(options) {
82
+ this.#options = options;
83
+ }
84
+ /** True when nothing is listening — lets the caller skip subscribing at all. */
85
+ get idle() {
86
+ return !this.#options.webhook && !this.#options.onNotification;
87
+ }
88
+ /**
89
+ * Subscribe to a runner for its lifetime.
90
+ *
91
+ * `afterSeq` defaults to whatever the runner has already emitted, which is what
92
+ * makes this safe on a *rehydrated* session: `subscribe` replays the log from
93
+ * `afterSeq`, so subscribing at 0 to a session rebuilt from a park would
94
+ * re-announce every permission request it ever made.
95
+ */
96
+ watch(runner, afterSeq = runner.info().lastSeq) {
97
+ if (this.idle) return;
98
+ runner.subscribe((event) => {
99
+ switch (event.type) {
100
+ case "permission_requested":
101
+ this.#emit(runner, event.seq, event.ts, {
102
+ type: "permission_requested",
103
+ preview: event.request.title ?? event.request.toolName,
104
+ request: event.request
105
+ });
106
+ return;
107
+ case "turn_result":
108
+ this.#emit(runner, event.seq, event.ts, {
109
+ type: "turn_completed",
110
+ preview: event.isError ? event.errors?.join("\n") : event.result,
111
+ result: {
112
+ isError: event.isError,
113
+ durationMs: event.durationMs,
114
+ numTurns: event.numTurns,
115
+ totalCostUsd: event.totalCostUsd
116
+ }
117
+ });
118
+ return;
119
+ case "session_error":
120
+ this.#emit(runner, event.seq, event.ts, {
121
+ type: "session_error",
122
+ preview: event.message
123
+ });
124
+ return;
125
+ case "session_closed":
126
+ this.#emit(runner, event.seq, event.ts, {
127
+ type: "session_closed",
128
+ reason: event.reason
129
+ });
130
+ return;
131
+ default: return;
132
+ }
133
+ }, afterSeq);
134
+ }
135
+ #emit(runner, seq, ts, body) {
136
+ queueMicrotask(() => this.#send(runner, seq, ts, body));
137
+ }
138
+ #send(runner, seq, ts, body) {
139
+ const webhook = this.#options.webhook;
140
+ const wanted = !webhook?.events || webhook.events.includes(body.type);
141
+ if (!webhook && !this.#options.onNotification) return;
142
+ const notification = {
143
+ ...body,
144
+ sessionId: runner.id,
145
+ session: runner.info(),
146
+ seq,
147
+ ts
148
+ };
149
+ try {
150
+ this.#options.onNotification?.(notification);
151
+ } catch {}
152
+ if (!webhook || !wanted) return;
153
+ const next = (this.#chains.get(runner.id) ?? Promise.resolve()).then(() => this.#deliver(webhook, notification));
154
+ this.#chains.set(runner.id, next);
155
+ next.then(() => {
156
+ if (this.#chains.get(runner.id) === next) this.#chains.delete(runner.id);
157
+ });
158
+ }
159
+ /**
160
+ * Best-effort POST with exponential backoff. Deliberately a near-copy of the
161
+ * queue's job-webhook delivery rather than a shared helper: the two channels
162
+ * have different payloads and different consumers, and coupling them would mean
163
+ * a change to job deliveries silently changing session deliveries.
164
+ */
165
+ async #deliver(webhook, notification) {
166
+ const attempts = this.#options.attempts ?? 3;
167
+ const baseDelay = this.#options.retryDelayMs ?? 500;
168
+ for (let attempt = 0; attempt < attempts; attempt++) {
169
+ try {
170
+ if ((await fetch(webhook.url, {
171
+ method: "POST",
172
+ headers: {
173
+ "content-type": "application/json",
174
+ ...webhook.headers
175
+ },
176
+ body: JSON.stringify(notification)
177
+ })).ok) return;
178
+ } catch {}
179
+ if (attempt < attempts - 1) await new Promise((resolve) => setTimeout(resolve, baseDelay * 2 ** attempt));
180
+ }
181
+ }
182
+ };
183
+ //#endregion
184
+ //#region src/bridge.ts
185
+ /**
186
+ * Routes tool executions between a session and the browser tabs attached to it.
187
+ *
188
+ * A session may have several clients attached (dashboard plus embedded panel);
189
+ * the bridge asks the **first attached** one, which is the closest thing to "the
190
+ * client driving this session". If none is attached, dispatch fails fast rather
191
+ * than hanging — an autonomous job simply never bridges, it uses the server
192
+ * executor instead.
193
+ */
194
+ var BridgeHub = class {
195
+ #sessions = /* @__PURE__ */ new Map();
196
+ #options;
197
+ constructor(options = {}) {
198
+ this.#options = options;
199
+ }
200
+ /** The executor to hand a runner for this session. Created on first use and
201
+ * reused, so results routed back always reach the same pending table. */
202
+ executorFor(sessionId) {
203
+ return this.#bridge(sessionId).executor;
204
+ }
205
+ /** How many clients are watching this session. Parking consults it: a session
206
+ * someone is watching stays live. */
207
+ attachedCount(sessionId) {
208
+ return this.#sessions.get(sessionId)?.sockets.length ?? 0;
209
+ }
210
+ /** Register an attached client. Returns a detach function. */
211
+ attach(sessionId, send) {
212
+ const bridge = this.#bridge(sessionId);
213
+ bridge.sockets.push(send);
214
+ return () => {
215
+ const index = bridge.sockets.indexOf(send);
216
+ if (index >= 0) bridge.sockets.splice(index, 1);
217
+ };
218
+ }
219
+ /**
220
+ * Deliver a client's answer to a bridged call. Returns false when the id is
221
+ * unknown or already settled — late and duplicate answers are ignored.
222
+ */
223
+ resolve(sessionId, executionId, answer) {
224
+ return this.#sessions.get(sessionId)?.executor.resolve(executionId, answer) ?? false;
225
+ }
226
+ /** Drop a session's bridge, failing anything still in flight. */
227
+ remove(sessionId) {
228
+ const bridge = this.#sessions.get(sessionId);
229
+ if (!bridge) return;
230
+ bridge.executor.registry.cancelAll("session_closed", "the session was closed");
231
+ this.#sessions.delete(sessionId);
232
+ }
233
+ #bridge(sessionId) {
234
+ const existing = this.#sessions.get(sessionId);
235
+ if (existing) return existing;
236
+ const bridge = {
237
+ sockets: [],
238
+ executor: new BrowserBridgeExecutor({
239
+ timeoutMs: this.#options.timeoutMs,
240
+ send: (frame) => {
241
+ const target = bridge.sockets[0];
242
+ if (!target) return false;
243
+ target(frame);
244
+ return true;
245
+ },
246
+ cancel: (executionId, reason) => {
247
+ for (const send of bridge.sockets) send({
248
+ type: "tool_call_canceled",
249
+ executionId,
250
+ reason
251
+ });
252
+ },
253
+ onResult: (executionId, result) => {
254
+ this.#options.onResult?.(sessionId, executionId, result);
255
+ }
256
+ })
257
+ };
258
+ this.#sessions.set(sessionId, bridge);
259
+ return bridge;
260
+ }
261
+ };
262
+ //#endregion
263
+ //#region src/parking.ts
264
+ /**
265
+ * Deferred execution's other half: parking a session that is waiting on work no
266
+ * process in this server is doing.
267
+ *
268
+ * The runner announces the moment with `status_changed: 'parked'` — emitted only
269
+ * once every dispatch of the batch has been handed over, so the snapshot can never
270
+ * miss a call that was still being dispatched. From there this class snapshots,
271
+ * evicts, and persists; delivering a result rebuilds the runner under the same id
272
+ * and hands the result to it. The session's identity, event log, and seq numbering
273
+ * survive intact, so a client reattaching with `afterSeq` sees one unbroken stream.
274
+ */
275
+ var SessionParkManager = class {
276
+ #options;
277
+ /** executionId → sessionId, for routing a result to its session. Kept in memory
278
+ * across the park; rebuilt from the store by {@link hydrate}. */
279
+ #owners = /* @__PURE__ */ new Map();
280
+ /** Executions already settled, kept until their session ends so a late or
281
+ * duplicate delivery answers "already settled" instead of "never heard of it". */
282
+ #settled = /* @__PURE__ */ new Map();
283
+ #timers = /* @__PURE__ */ new Map();
284
+ /** One resume per session, ever: two results arriving together must not build
285
+ * two runners under the same id (the second would orphan the first, leaking the
286
+ * MCP connection the park existed to release). */
287
+ #resuming = /* @__PURE__ */ new Map();
288
+ #detachTimers = /* @__PURE__ */ new Map();
289
+ /** The config each live session was built from — what a rebuild needs, and the
290
+ * one thing a runner doesn't carry on its public surface. */
291
+ #configs = /* @__PURE__ */ new Map();
292
+ /**
293
+ * In-flight store work per session, so operations on one record run in order.
294
+ *
295
+ * Load-bearing with any store whose writes are real I/O. `#park` must evict the
296
+ * runner *before* the save completes (an attach between `park()` and `evict()`
297
+ * would bind a client to an inert runner), which leaves a window where the
298
+ * session is in neither the registry nor the store. A delivery arriving inside it
299
+ * would read past the write: `store.get` misses, the result is answered 404, the
300
+ * execution is filed as settled with its watchdog cleared — and then the record
301
+ * lands on disk with nothing left alive that could ever wake it. A `discard`
302
+ * inside the same window would delete nothing and leave the save to resurrect a
303
+ * session the caller was told was closed.
304
+ */
305
+ #storeOps = /* @__PURE__ */ new Map();
306
+ #closed = false;
307
+ constructor(options) {
308
+ this.#options = options;
309
+ }
310
+ /** Record the config a session was created with. Only sessions the host
311
+ * remembers can be parked — there is no way to rebuild the others. */
312
+ remember(sessionId, config) {
313
+ this.#configs.set(sessionId, config);
314
+ }
315
+ /** Adopt the store's contents (a durable store after a restart): re-index the
316
+ * executions and re-arm their watchdogs, no deadline sooner than the grace
317
+ * window — nothing could have been delivered while the process was down. */
318
+ async hydrate() {
319
+ const floor = Date.now() + (this.#options.expiredGraceMs ?? 6e4);
320
+ for (const record of await this.#options.store.list()) for (const execution of record.executions) this.#track(record.id, execution, floor);
321
+ }
322
+ /**
323
+ * Follow a session's lifecycle: index its deferred executions, park it when the
324
+ * engine says the turn has come to rest on them, and clean up when it ends.
325
+ * `afterSeq` skips a rehydrated runner's replayed history (re-arming a watchdog
326
+ * from an event whose deadline already passed would fail the execution instantly).
327
+ */
328
+ watch(runner, afterSeq = 0) {
329
+ return runner.subscribe((event) => {
330
+ switch (event.type) {
331
+ case "execution_dispatched":
332
+ if (!event.deferred) return;
333
+ this.#track(runner.id, {
334
+ executionId: event.executionId,
335
+ toolName: event.toolName,
336
+ expiresAt: event.expiresAt
337
+ });
338
+ return;
339
+ case "execution_result":
340
+ case "execution_failed":
341
+ this.#forget(event.executionId);
342
+ if (runner.info().status === "parked") this.#park(runner);
343
+ return;
344
+ case "status_changed":
345
+ if (event.status === "parked") this.#park(runner);
346
+ return;
347
+ case "session_closed":
348
+ this.discard(runner.id);
349
+ return;
350
+ default: return;
351
+ }
352
+ }, afterSeq);
353
+ }
354
+ /** A client detached: park the session if that was the last one watching. */
355
+ onDetach(sessionId) {
356
+ if (this.#closed) return;
357
+ const runner = this.#options.registry.get(sessionId);
358
+ if (!runner || runner.info().status !== "parked") return;
359
+ clearTimeout(this.#detachTimers.get(sessionId));
360
+ const timer = setTimeout(() => {
361
+ this.#detachTimers.delete(sessionId);
362
+ this.#park(runner);
363
+ }, this.#options.parkDelayMs ?? 2e3);
364
+ timer.unref?.();
365
+ this.#detachTimers.set(sessionId, timer);
366
+ }
367
+ /** Which session this execution belongs to — still waiting, or already settled. */
368
+ sessionFor(executionId) {
369
+ return this.#owners.get(executionId) ?? this.#settled.get(executionId);
370
+ }
371
+ /** The parked session's record, for the read paths (GET, list, attach). */
372
+ get(id) {
373
+ return this.#queue(id, () => this.#options.store.get(id));
374
+ }
375
+ /** Every parked session's info, to merge into `GET {basePath}/sessions`. */
376
+ async listInfo() {
377
+ await Promise.all(this.#storeOps.values());
378
+ return (await this.#options.store.list()).map((record) => record.info);
379
+ }
380
+ /** The live runner for a session, rehydrating a parked one on demand. Undefined
381
+ * when the session is neither live nor parked. */
382
+ async ensureLive(id) {
383
+ const live = this.#options.registry.get(id);
384
+ if (live) return live;
385
+ return this.#resume(id);
386
+ }
387
+ /**
388
+ * Deliver a deferred execution's result. Rehydrates the session if needed and
389
+ * folds the result into its agent loop.
390
+ *
391
+ * Undefined = no session is waiting on that id. `applied: false` = it was already
392
+ * settled: a duplicate delivery, or one racing the watchdog. Both are expected,
393
+ * neither is an error.
394
+ */
395
+ async submitResult(executionId, result) {
396
+ const sessionId = this.#owners.get(executionId);
397
+ if (sessionId === void 0) {
398
+ const settled = this.#settled.get(executionId);
399
+ return settled === void 0 ? void 0 : {
400
+ applied: false,
401
+ sessionId: settled
402
+ };
403
+ }
404
+ const runner = await this.ensureLive(sessionId);
405
+ if (!runner) {
406
+ this.#forget(executionId);
407
+ return;
408
+ }
409
+ this.#clearTimer(executionId);
410
+ const applied = runner.settleExecution?.(executionId, result) ?? false;
411
+ if (applied) this.#forget(executionId);
412
+ return {
413
+ applied,
414
+ sessionId
415
+ };
416
+ }
417
+ /** Drop a parked session for good: the run is over (closed, canceled, killed). */
418
+ async discard(sessionId) {
419
+ clearTimeout(this.#detachTimers.get(sessionId));
420
+ this.#detachTimers.delete(sessionId);
421
+ this.#configs.delete(sessionId);
422
+ for (const [executionId, owner] of this.#owners) if (owner === sessionId) this.#forget(executionId);
423
+ for (const [executionId, owner] of this.#settled) if (owner === sessionId) this.#settled.delete(executionId);
424
+ await this.#queue(sessionId, () => this.#options.store.delete(sessionId));
425
+ }
426
+ close() {
427
+ this.#closed = true;
428
+ for (const timer of this.#timers.values()) clearTimeout(timer);
429
+ for (const timer of this.#detachTimers.values()) clearTimeout(timer);
430
+ this.#timers.clear();
431
+ this.#detachTimers.clear();
432
+ }
433
+ async #park(runner) {
434
+ if (this.#closed || !runner.park) return;
435
+ const id = runner.id;
436
+ if (this.#options.registry.get(id) !== runner) return;
437
+ if (runner.info().status !== "parked") return;
438
+ if (this.#options.attachedCount(id) > 0) return;
439
+ const executions = [...this.#owners].filter(([, owner]) => owner === id).map(([e]) => e);
440
+ if (executions.length === 0) return;
441
+ const config = this.#configs.get(id);
442
+ if (!config) return;
443
+ if (this.#options.onParking && !this.#options.onParking(id, executions[0])) return;
444
+ const snapshot = runner.park();
445
+ if (!snapshot) return;
446
+ const info = {
447
+ ...runner.info(),
448
+ status: "parked"
449
+ };
450
+ this.#options.registry.evict(id);
451
+ const record = {
452
+ id,
453
+ info,
454
+ profile: info.profile,
455
+ config,
456
+ snapshot,
457
+ executions: snapshot.parked,
458
+ parkedAt: Date.now()
459
+ };
460
+ for (const execution of snapshot.parked) this.#track(id, execution);
461
+ try {
462
+ await this.#queue(id, () => this.#options.store.save(record));
463
+ } catch (error) {
464
+ this.#options.onError?.(error, {
465
+ sessionId: id,
466
+ phase: "park"
467
+ });
468
+ }
469
+ }
470
+ async #resume(id) {
471
+ const inFlight = this.#resuming.get(id);
472
+ if (inFlight) return inFlight;
473
+ const attempt = this.#rebuild(id);
474
+ this.#resuming.set(id, attempt);
475
+ try {
476
+ return await attempt;
477
+ } finally {
478
+ this.#resuming.delete(id);
479
+ }
480
+ }
481
+ async #rebuild(id) {
482
+ const record = await this.#queue(id, () => this.#options.store.get(id));
483
+ if (!record) return void 0;
484
+ let runner;
485
+ try {
486
+ runner = await this.#options.rebuild(record);
487
+ } catch (error) {
488
+ this.#options.onError?.(error, {
489
+ sessionId: id,
490
+ phase: "resume"
491
+ });
492
+ throw error;
493
+ }
494
+ if (runner.id !== id) {
495
+ runner.close("error");
496
+ const error = /* @__PURE__ */ new Error(`rebuilt session has id '${runner.id}', expected '${id}' — the engine factory must forward EngineRunnerContext.restore to the runner config`);
497
+ this.#options.onError?.(error, {
498
+ sessionId: id,
499
+ phase: "resume"
500
+ });
501
+ throw error;
502
+ }
503
+ this.#options.registry.register(runner);
504
+ this.remember(id, record.config);
505
+ this.watch(runner, record.snapshot.seq);
506
+ this.#options.onResumed?.(id, runner);
507
+ await this.#queue(id, () => this.#options.store.delete(id));
508
+ runner.start();
509
+ return runner;
510
+ }
511
+ /** Run a store operation after whatever is already in flight for this session.
512
+ * The chain is per session and drops itself once idle; a failed operation never
513
+ * poisons the ones behind it (each caller handles its own). */
514
+ #queue(sessionId, op) {
515
+ const result = (this.#storeOps.get(sessionId) ?? Promise.resolve()).then(op);
516
+ const settled = result.then(() => {}, () => {});
517
+ this.#storeOps.set(sessionId, settled);
518
+ settled.then(() => {
519
+ if (this.#storeOps.get(sessionId) === settled) this.#storeOps.delete(sessionId);
520
+ });
521
+ return result;
522
+ }
523
+ #track(sessionId, execution, notBefore = 0) {
524
+ this.#owners.set(execution.executionId, sessionId);
525
+ if (execution.expiresAt === void 0 || this.#timers.has(execution.executionId)) return;
526
+ const expiresAt = Math.max(execution.expiresAt, notBefore);
527
+ const timer = setTimeout(() => {
528
+ this.#timers.delete(execution.executionId);
529
+ this.submitResult(execution.executionId, {
530
+ status: "failed",
531
+ reason: "timeout",
532
+ error: `deferred execution '${execution.toolName}' produced no result before its deadline`
533
+ }).catch((error) => {
534
+ this.#options.onError?.(error, {
535
+ sessionId,
536
+ phase: "resume"
537
+ });
538
+ });
539
+ }, Math.max(0, expiresAt - Date.now()));
540
+ timer.unref?.();
541
+ this.#timers.set(execution.executionId, timer);
542
+ }
543
+ #forget(executionId) {
544
+ this.#clearTimer(executionId);
545
+ const owner = this.#owners.get(executionId);
546
+ if (owner !== void 0) this.#settled.set(executionId, owner);
547
+ this.#owners.delete(executionId);
548
+ }
549
+ #clearTimer(executionId) {
550
+ const timer = this.#timers.get(executionId);
551
+ if (timer === void 0) return;
552
+ clearTimeout(timer);
553
+ this.#timers.delete(executionId);
554
+ }
555
+ };
556
+ //#endregion
557
+ //#region src/session-store.ts
558
+ /** Single-process, no persistence: parks survive a client disconnect, not a restart. */
559
+ var MemorySessionStore = class {
560
+ #records = /* @__PURE__ */ new Map();
561
+ save(record) {
562
+ this.#records.set(record.id, record);
563
+ return Promise.resolve();
564
+ }
565
+ get(id) {
566
+ return Promise.resolve(this.#records.get(id) ?? null);
567
+ }
568
+ list() {
569
+ return Promise.resolve([...this.#records.values()]);
570
+ }
571
+ delete(id) {
572
+ return Promise.resolve(this.#records.delete(id));
573
+ }
574
+ };
575
+ /**
576
+ * Config fields that must not be written to durable storage: two are functions
577
+ * (JSON drops them silently), `extraOptions` is SDK `Options` and may hold hooks
578
+ * and callbacks, and `env` is a credential-bearing map — the same rule
579
+ * `profile-store.ts` follows, for the same reason.
580
+ *
581
+ * Dropping them costs a rehydrated session nothing: all four are consumed by the
582
+ * Claude engine alone, and the Claude engine cannot park (the CLI owns its process
583
+ * state — `buildRunner` refuses a `restore` for it). A provider session's
584
+ * credentials are resolved by `createEngineRunner` from the operator's environment
585
+ * on every build, wake included.
586
+ */
587
+ const EPHEMERAL_CONFIG_KEYS = [
588
+ "queryFn",
589
+ "historyFn",
590
+ "extraOptions",
591
+ "env"
592
+ ];
593
+ /** The record as it may be persisted: same session, config narrowed to what is
594
+ * safe and meaningful to keep (see {@link EPHEMERAL_CONFIG_KEYS}). */
595
+ function toDurableRecord(record) {
596
+ const config = { ...record.config };
597
+ for (const key of EPHEMERAL_CONFIG_KEYS) delete config[key];
598
+ return {
599
+ ...record,
600
+ config
601
+ };
602
+ }
603
+ /** Bump when the on-disk shape changes incompatibly; records written by another
604
+ * version are ignored rather than half-read into a broken session. */
605
+ const FORMAT_VERSION = 1;
606
+ /**
607
+ * Durable single-host store: one JSON file per parked session under `dir`, written
608
+ * through a temp file and a rename so a crash mid-write cannot truncate a session.
609
+ * `hydrate()` at `listen()` picks them up, re-indexes their executions, and re-arms
610
+ * the watchdogs, so a restart no longer loses parked work.
611
+ *
612
+ * Know what is on that disk: **the record holds the session's entire transcript** —
613
+ * prompts, model output, and tool I/O — in plaintext. Put it somewhere with the same
614
+ * protection as the SDK's own transcripts (`~/.claude/projects`), not in a directory
615
+ * that gets served, synced, or backed up somewhere looser.
616
+ *
617
+ * Single-process by design, exactly like the bundled queue adapter and profile
618
+ * store: two servers sharing one directory would both hydrate the same records and
619
+ * race to rebuild them. That is what the seam is for.
620
+ *
621
+ * Nothing here reaps: a record leaves only when its session wakes or is deleted.
622
+ * An execution dispatched without a deadline (a `DeferredExecutor` with no
623
+ * `timeoutMs`) has no watchdog to end the wait, so its record — and its transcript
624
+ * — stays until `DELETE /sessions/:id`. Give deferred calls a deadline, or sweep.
625
+ */
626
+ function createFileSessionStore(options = {}) {
627
+ const dir = options.dir ?? join(process.cwd(), ".workerdeck", "parked");
628
+ const fileFor = (id) => join(dir, `${encodeURIComponent(id)}.json`);
629
+ const read = async (path) => {
630
+ let raw;
631
+ try {
632
+ raw = await readFile(path, "utf8");
633
+ } catch (error) {
634
+ if (isMissing(error)) return null;
635
+ options.onError?.(error, {
636
+ path,
637
+ op: "read"
638
+ });
639
+ return null;
640
+ }
641
+ try {
642
+ return parseRecord(JSON.parse(raw));
643
+ } catch (error) {
644
+ options.onError?.(error, {
645
+ path,
646
+ op: "read"
647
+ });
648
+ return null;
649
+ }
650
+ };
651
+ return {
652
+ save: async (record) => {
653
+ const path = fileFor(record.id);
654
+ let payload;
655
+ try {
656
+ payload = JSON.stringify({
657
+ version: FORMAT_VERSION,
658
+ record: toDurableRecord(record)
659
+ });
660
+ } catch (error) {
661
+ options.onError?.(error, {
662
+ path,
663
+ op: "save"
664
+ });
665
+ throw new Error(`parked session '${record.id}' is not JSON-serializable — a host-injected value reached its config or snapshot: ${String(error)}`);
666
+ }
667
+ try {
668
+ await mkdir(dir, {
669
+ recursive: true,
670
+ mode: 448
671
+ });
672
+ const temp = `${path}.${process.pid}.tmp`;
673
+ await writeFile(temp, payload, { mode: 384 });
674
+ await rename(temp, path);
675
+ } catch (error) {
676
+ options.onError?.(error, {
677
+ path,
678
+ op: "save"
679
+ });
680
+ throw error;
681
+ }
682
+ },
683
+ get: (id) => read(fileFor(id)),
684
+ list: async () => {
685
+ let names;
686
+ try {
687
+ names = await readdir(dir);
688
+ } catch (error) {
689
+ if (isMissing(error)) return [];
690
+ options.onError?.(error, {
691
+ path: dir,
692
+ op: "read"
693
+ });
694
+ throw error;
695
+ }
696
+ return (await Promise.all(names.filter((name) => name.endsWith(".json")).map(async (name) => {
697
+ const record = await read(join(dir, name));
698
+ if (!record) return null;
699
+ if (`${encodeURIComponent(record.id)}.json` === name) return record;
700
+ options.onError?.(/* @__PURE__ */ new Error(`parked record '${record.id}' is stored as '${name}' and cannot be read back by id`), {
701
+ path: join(dir, name),
702
+ op: "read"
703
+ });
704
+ return null;
705
+ }))).filter((record) => record !== null);
706
+ },
707
+ delete: async (id) => {
708
+ const path = fileFor(id);
709
+ try {
710
+ await rm(path);
711
+ return true;
712
+ } catch (error) {
713
+ if (isMissing(error)) return false;
714
+ options.onError?.(error, {
715
+ path,
716
+ op: "delete"
717
+ });
718
+ return false;
719
+ }
720
+ }
721
+ };
722
+ }
723
+ const isMissing = (error) => error.code === "ENOENT";
724
+ /** Shape-check a parsed file. A record missing any of these could not be rebuilt,
725
+ * and half-restoring one is worse than skipping it. */
726
+ function parseRecord(value) {
727
+ if (!value || typeof value !== "object") return null;
728
+ const envelope = value;
729
+ if (envelope.version !== FORMAT_VERSION) return null;
730
+ const record = envelope.record;
731
+ if (!record || typeof record !== "object") return null;
732
+ if (typeof record.id !== "string" || typeof record.parkedAt !== "number") return null;
733
+ if (!record.info || !record.config || !record.snapshot) return null;
734
+ if (!Array.isArray(record.executions)) return null;
735
+ return record;
736
+ }
737
+ //#endregion
738
+ //#region src/server.ts
739
+ const defaultSdkSessionLister = async (options) => {
740
+ return (await listSessions(options)).map((s) => ({
741
+ sessionId: s.sessionId,
742
+ summary: s.summary,
743
+ lastModified: s.lastModified,
744
+ createdAt: s.createdAt,
745
+ customTitle: s.customTitle,
746
+ firstPrompt: s.firstPrompt,
747
+ gitBranch: s.gitBranch,
748
+ cwd: s.cwd
749
+ }));
750
+ };
751
+ function json(res, status, body) {
752
+ const payload = JSON.stringify(body);
753
+ res.writeHead(status, {
754
+ "content-type": "application/json",
755
+ "content-length": Buffer.byteLength(payload)
756
+ });
757
+ res.end(payload);
758
+ }
759
+ async function readJsonBody(req, maxBytes) {
760
+ const chunks = [];
761
+ let size = 0;
762
+ for await (const chunk of req) {
763
+ size += chunk.length;
764
+ if (size > maxBytes) throw new Error("request body too large");
765
+ chunks.push(chunk);
766
+ }
767
+ if (size === 0) return {};
768
+ return JSON.parse(Buffer.concat(chunks).toString("utf8"));
769
+ }
770
+ /**
771
+ * Curated, view-only snapshot of a profile's config dir for GET /profiles/:name.
772
+ * Best-effort: a missing or unparseable settings.json just omits the settings block.
773
+ * Env var VALUES are never read into the response — names only.
774
+ *
775
+ * Provider profiles have no config dir, so the snapshot is empty for them: their
776
+ * configuration is the `provider` block already on ProfileInfo.
777
+ */
778
+ function readProfileConfig(profile) {
779
+ const dir = profile.configDir;
780
+ if (!dir) return {
781
+ hasUserMemory: false,
782
+ skills: [],
783
+ agents: [],
784
+ commands: []
785
+ };
786
+ const listDirs = (path) => {
787
+ try {
788
+ return readdirSync(path, { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => entry.name).sort();
789
+ } catch {
790
+ return [];
791
+ }
792
+ };
793
+ const listMd = (path) => {
794
+ try {
795
+ return readdirSync(path).filter((file) => file.endsWith(".md")).map((file) => file.slice(0, -3)).sort();
796
+ } catch {
797
+ return [];
798
+ }
799
+ };
800
+ const snapshot = {
801
+ hasUserMemory: existsSync(join(dir, "CLAUDE.md")),
802
+ skills: listDirs(join(dir, "skills")),
803
+ agents: listMd(join(dir, "agents")),
804
+ commands: listMd(join(dir, "commands"))
805
+ };
806
+ try {
807
+ const raw = JSON.parse(readFileSync(join(dir, "settings.json"), "utf8"));
808
+ const permissions = raw.permissions ?? {};
809
+ const count = (rules) => Array.isArray(rules) ? rules.length : 0;
810
+ snapshot.settings = {
811
+ model: typeof raw.model === "string" ? raw.model : void 0,
812
+ defaultPermissionMode: typeof permissions.defaultMode === "string" ? permissions.defaultMode : void 0,
813
+ permissionRules: {
814
+ allow: count(permissions.allow),
815
+ ask: count(permissions.ask),
816
+ deny: count(permissions.deny)
817
+ },
818
+ envKeys: raw.env && typeof raw.env === "object" ? Object.keys(raw.env).sort() : void 0,
819
+ hooks: raw.hooks && typeof raw.hooks === "object" ? Object.keys(raw.hooks).sort() : void 0
820
+ };
821
+ } catch {}
822
+ return snapshot;
823
+ }
824
+ /** Conservative content types for VFS downloads: text formats the agent actually
825
+ * produces; anything unrecognized ships as plain text (the VFS is string-backed). */
826
+ const CONTENT_TYPES = {
827
+ json: "application/json; charset=utf-8",
828
+ md: "text/markdown; charset=utf-8",
829
+ html: "text/html; charset=utf-8",
830
+ csv: "text/csv; charset=utf-8",
831
+ xml: "application/xml; charset=utf-8",
832
+ svg: "image/svg+xml; charset=utf-8"
833
+ };
834
+ function contentTypeFor(filename) {
835
+ return CONTENT_TYPES[filename.includes(".") ? filename.split(".").pop().toLowerCase() : ""] ?? "text/plain; charset=utf-8";
836
+ }
837
+ /** A profile runs the model-agnostic engine rather than Claude Code. `engine` is
838
+ * optional so profiles written before provider support keep meaning 'claude'. */
839
+ function isProviderProfile(profile) {
840
+ return profile.engine === "provider";
841
+ }
842
+ /** Where the CLI's own resolution lands for a given environment: an explicit
843
+ * CLAUDE_CONFIG_DIR, else ~/.claude. */
844
+ function cliConfigDir(env) {
845
+ return env.CLAUDE_CONFIG_DIR ?? join(homedir(), ".claude");
846
+ }
847
+ /** Auto-created profile when none are declared: the operator's own config dir. */
848
+ function detectDefaultProfiles() {
849
+ const dir = cliConfigDir(process.env);
850
+ return existsSync(dir) ? [{
851
+ name: "default",
852
+ configDir: dir
853
+ }] : [];
854
+ }
855
+ /** Compare config dirs by what they name on disk: declared paths arrive with
856
+ * trailing slashes or symlinked prefixes (`/var` vs `/private/var` on macOS); a
857
+ * path that doesn't exist falls back to plain normalization. */
858
+ function canonicalDir(path) {
859
+ try {
860
+ return realpathSync(path);
861
+ } catch {
862
+ return resolve(path);
863
+ }
864
+ }
865
+ /**
866
+ * The env a Claude session under `profile` is spawned with, starting from
867
+ * `base` (the host hook's env, else the server's own). The pin is skipped when
868
+ * `base` would already land the CLI in the profile's dir, and that skip is
869
+ * load-bearing, not an optimisation: CLAUDE_CONFIG_DIR *set at all* switches
870
+ * the CLI's credential source to `<dir>/.credentials.json` — on macOS a
871
+ * claude.ai login lives in the login Keychain, consulted only while the
872
+ * variable is UNSET, so pinning even the CLI's own default `~/.claude` turns a
873
+ * working login into "Not logged in". When `base` names a *different* dir than
874
+ * the profile, the pin stands: the profile must win over hook- or operator-set
875
+ * env, or sessions under two profiles quietly collapse into one identity.
876
+ */
877
+ function claudeSessionEnv(profile, base) {
878
+ return canonicalDir(profile.configDir) === canonicalDir(cliConfigDir(base)) ? base : {
879
+ ...base,
880
+ CLAUDE_CONFIG_DIR: profile.configDir
881
+ };
882
+ }
883
+ function cwdAllowed(cwd, roots) {
884
+ if (!roots || roots.length === 0) return true;
885
+ const resolved = resolve(cwd);
886
+ return roots.some((root) => {
887
+ const r = resolve(root);
888
+ return resolved === r || resolved.startsWith(r + sep);
889
+ });
890
+ }
891
+ function createWorkerServer(options = {}) {
892
+ if (!options.authenticate && !options.allowUnauthenticated) throw new Error("createWorkerServer: provide `authenticate` or explicitly set `allowUnauthenticated: true`");
893
+ const basePath = options.basePath ?? "/v1";
894
+ const fallback = options.fallback;
895
+ const maxBodyBytes = options.maxBodyBytes ?? 1024 * 1024;
896
+ const hostBuildRunnerConfig = options.buildRunnerConfig ?? ((req) => req);
897
+ const declared = options.profiles ?? detectDefaultProfiles();
898
+ const declaredByName = new Map(declared.map((p) => [p.name, p]));
899
+ if (declaredByName.size !== declared.length) throw new Error("createWorkerServer: duplicate profile names in `profiles`");
900
+ /**
901
+ * Everything wrong with a profile that the server can tell without running it.
902
+ * Shared by startup (where it throws) and the management routes (where it 400s),
903
+ * so a profile created over HTTP can never be one startup would have refused.
904
+ */
905
+ const validateProfile = (p) => {
906
+ if (isProviderProfile(p)) {
907
+ if (!p.provider?.id) return `provider profile '${p.name}' is missing provider.id`;
908
+ if (!options.createEngineRunner) return `profile '${p.name}' uses engine 'provider' but no \`createEngineRunner\` was provided to build one`;
909
+ } else if (!p.configDir || !existsSync(p.configDir)) return `profile '${p.name}' configDir does not exist: ${p.configDir}`;
910
+ if (options.disableBypassPermissions && p.defaults?.permissionMode === "bypassPermissions") return `profile '${p.name}' defaults to bypassPermissions but disableBypassPermissions is set`;
911
+ const fallbackMode = p.defaults?.permissionMode;
912
+ if (fallbackMode && !supportsPermissionMode(p.engine, fallbackMode)) return `profile '${p.name}' defaults to permission mode '${fallbackMode}', which engine '${p.engine}' does not support (supported: ${PROVIDER_PERMISSION_MODES.join(", ")})`;
913
+ return null;
914
+ };
915
+ for (const p of options.profiles ?? []) {
916
+ const invalid = validateProfile(p);
917
+ if (invalid) throw new Error(`createWorkerServer: ${invalid}`);
918
+ }
919
+ /**
920
+ * Store-managed profiles, mirrored in memory so every lookup on the request path
921
+ * stays synchronous. Loaded once at `listen()` and refreshed after each mutation
922
+ * — single-process, exactly like the bundled queue adapter.
923
+ */
924
+ const stored = /* @__PURE__ */ new Map();
925
+ const refreshStored = async () => {
926
+ if (!options.profileStore) return;
927
+ stored.clear();
928
+ for (const p of await options.profileStore.list()) stored.set(p.name, p);
929
+ };
930
+ /** Response-only marker so a UI knows which rows it may edit. Declared profiles
931
+ * are code; only store-backed ones can be changed over the API. */
932
+ const withManagedFlag = (p) => declaredByName.has(p.name) ? p : {
933
+ ...p,
934
+ managed: true
935
+ };
936
+ /** Declared profiles first: a name collision means the code wins, and the stored
937
+ * one is unreachable rather than silently overriding server options. */
938
+ const allProfiles = () => [...declared, ...[...stored.values()].filter((p) => !declaredByName.has(p.name))];
939
+ const profileFor = (name) => declaredByName.get(name) ?? stored.get(name);
940
+ /** Profile management is doubly opt-in: the operator wires a store, and the host
941
+ * marks the principal. Neither on its own is enough. */
942
+ const manageGuard = (auth) => {
943
+ if (!options.profileStore) return {
944
+ status: 404,
945
+ error: "profile management is not enabled on this server"
946
+ };
947
+ if (!auth.canManageProfiles) return {
948
+ status: 403,
949
+ error: "not allowed to manage profiles"
950
+ };
951
+ return null;
952
+ };
953
+ /** Startup-declared profiles are code. Editing one over HTTP would make the
954
+ * server options lie about what is actually running. */
955
+ const declaredGuard = (profile) => declaredByName.has(profile.name) ? {
956
+ status: 403,
957
+ error: `profile '${profile.name}' is declared in server options and cannot be changed over the API — edit the \`profiles\` option instead`
958
+ } : null;
959
+ /**
960
+ * A managed Claude profile names a config directory, and that directory is a
961
+ * credential store. Bound it to operator-declared roots; unset roots means the
962
+ * management routes create provider profiles only.
963
+ */
964
+ const configDirGuard = (profile) => {
965
+ if (isProviderProfile(profile)) return null;
966
+ const roots = options.allowedConfigDirRoots;
967
+ if (!roots || roots.length === 0) return {
968
+ status: 403,
969
+ error: "managed Claude profiles are disabled: set `allowedConfigDirRoots` to the directories they may point at"
970
+ };
971
+ return profile.configDir && cwdAllowed(profile.configDir, roots) ? null : {
972
+ status: 403,
973
+ error: "configDir is outside the allowed roots"
974
+ };
975
+ };
976
+ /** Validate, persist, and re-read: shared by create and update so a PATCH can
977
+ * never leave behind a profile a POST would have refused. */
978
+ const saveManagedProfile = async (res, incoming) => {
979
+ const { managed: _clientClaim, ...profile } = incoming;
980
+ const refused = configDirGuard(profile);
981
+ if (refused) {
982
+ json(res, refused.status, { error: refused.error });
983
+ return;
984
+ }
985
+ const invalid = validateProfile(profile);
986
+ if (invalid) {
987
+ json(res, 400, { error: invalid });
988
+ return;
989
+ }
990
+ await options.profileStore.save(profile);
991
+ await refreshStored();
992
+ json(res, 200, { profile: withManagedFlag(profile) });
993
+ };
994
+ /** Enforce the server's bypass policy on a create request. Returns a 403 message
995
+ * for an explicit bypass-mode request; strips the pre-authorization capability
996
+ * silently (see the option's doc for why). */
997
+ const applyBypassPolicy = (req) => {
998
+ if (!options.disableBypassPermissions) return null;
999
+ if (req.permissionMode === "bypassPermissions") return "bypassPermissions is disabled on this server (disableBypassPermissions)";
1000
+ delete req.allowDangerouslySkipPermissions;
1001
+ return null;
1002
+ };
1003
+ /** Reject a permission mode the resolved profile's engine has no meaning for.
1004
+ * The create form already filters what it offers, but the API is the boundary:
1005
+ * a provider session asked for 'plan' should be told so, not silently coerced
1006
+ * into 'default' by whatever assembles its runner. Returns an error message. */
1007
+ const checkPermissionMode = (mode, profile) => {
1008
+ if (mode === void 0 || supportsPermissionMode(profile?.engine, mode)) return null;
1009
+ return `permission mode '${mode}' is not supported by profile '${profile.name}' (engine '${profile.engine}') — supported: ${PROVIDER_PERMISSION_MODES.join(", ")}`;
1010
+ };
1011
+ /**
1012
+ * Enforce the provider engine's grant rules on a create request. Two of them:
1013
+ *
1014
+ * - Capabilities narrow, never widen. A request may run with fewer than the
1015
+ * profile grants; naming one it doesn't is refused rather than quietly
1016
+ * downgraded, so a caller learns instead of wondering where the tool went.
1017
+ * - MCP servers are the profile's to declare. MCP tools are authoritative —
1018
+ * server-side, with server credentials, never bridged — so honoring a
1019
+ * client-supplied server would let a caller point an authoritative tool
1020
+ * anywhere it liked. The profile names servers; the host holds their configs.
1021
+ */
1022
+ const checkEngineGrants = (req, profile) => {
1023
+ if (!profile || !isProviderProfile(profile)) return null;
1024
+ if (req.mcpServers && Object.keys(req.mcpServers).length > 0) return `profile '${profile.name}' runs the provider engine, whose MCP servers are declared on the profile (session.mcpServers) — a session request cannot add its own`;
1025
+ const granted = profile.session?.capabilities;
1026
+ if (!req.capabilities || !granted) return null;
1027
+ const ungranted = req.capabilities.filter((c) => !granted.includes(c));
1028
+ if (ungranted.length === 0) return null;
1029
+ return `profile '${profile.name}' does not grant: ${ungranted.join(", ")} (granted: ${granted.join(", ") || "none"}) — a request may narrow capabilities, not widen them`;
1030
+ };
1031
+ /** Profile-aware config hook: fill the profile's defaults into unset request fields,
1032
+ * run the host hook, then pin CLAUDE_CONFIG_DIR — the profile wins even when the
1033
+ * host hook set its own env (see `claudeSessionEnv` for the one case the pin is
1034
+ * skipped, and why). Handed to the queue too, so jobs inherit profiles. */
1035
+ const buildRunnerConfig = (req) => {
1036
+ const profile = req.profile !== void 0 ? profileFor(req.profile) : void 0;
1037
+ if (!profile) return hostBuildRunnerConfig(req);
1038
+ const config = hostBuildRunnerConfig({
1039
+ ...req,
1040
+ model: req.model ?? profile.defaults?.model ?? profile.provider?.model,
1041
+ permissionMode: req.permissionMode ?? profile.defaults?.permissionMode
1042
+ });
1043
+ if (isProviderProfile(profile)) return config;
1044
+ const base = config.env ?? process.env;
1045
+ const env = claudeSessionEnv(profile, base);
1046
+ return env === base ? config : {
1047
+ ...config,
1048
+ env
1049
+ };
1050
+ };
1051
+ /** Build a runner for a session, choosing the engine from its profile. Async
1052
+ * because the engine factory may be: a provider session can need an awaited
1053
+ * assembly step (per-session MCP connect) before it has a runner at all.
1054
+ *
1055
+ * `restore` rebuilds a parked session rather than creating a new one — same id,
1056
+ * same log, mid-task. */
1057
+ const buildRunner = async (config, restore) => {
1058
+ const name = config.profile;
1059
+ const profile = name !== void 0 ? profileFor(name) : void 0;
1060
+ if (name !== void 0 && !profile) throw new Error(`unknown profile: ${name}`);
1061
+ if (profile && isProviderProfile(profile)) return options.createEngineRunner({
1062
+ config,
1063
+ profile,
1064
+ bridge,
1065
+ restore
1066
+ });
1067
+ if (restore) throw new Error("the Claude engine cannot rebuild a parked session");
1068
+ return new Promise((resolve) => resolve(registry.prepare(config)));
1069
+ };
1070
+ const createRunner = async (config) => {
1071
+ const runner = registry.register(await buildRunner(config));
1072
+ parking.remember(runner.id, config);
1073
+ parking.watch(runner);
1074
+ runner.start();
1075
+ return runner;
1076
+ };
1077
+ /** Resolve a request's profile: required when several are declared, implicit with
1078
+ * exactly one, scoped by the principal's allowedProfiles. Returns the resolved
1079
+ * profile (undefined when the server declares none) or a response-ready error. */
1080
+ const resolveProfile = (name, allowedProfiles) => {
1081
+ if (name !== void 0 && typeof name !== "string") return {
1082
+ ok: false,
1083
+ status: 400,
1084
+ error: "profile must be a string"
1085
+ };
1086
+ const profiles = allProfiles();
1087
+ if (profiles.length === 0) return name !== void 0 ? {
1088
+ ok: false,
1089
+ status: 400,
1090
+ error: "no profiles are configured on this server"
1091
+ } : { ok: true };
1092
+ const effective = name ?? (profiles.length === 1 ? profiles[0].name : void 0);
1093
+ if (effective === void 0) return {
1094
+ ok: false,
1095
+ status: 400,
1096
+ error: `profile is required (available: ${profiles.map((p) => p.name).join(", ")})`
1097
+ };
1098
+ const profile = profileFor(effective);
1099
+ if (!profile) return {
1100
+ ok: false,
1101
+ status: 400,
1102
+ error: `unknown profile: ${effective}`
1103
+ };
1104
+ if (allowedProfiles && !allowedProfiles.includes(profile.name)) return {
1105
+ ok: false,
1106
+ status: 403,
1107
+ error: `profile not allowed: ${profile.name}`
1108
+ };
1109
+ return {
1110
+ ok: true,
1111
+ profile
1112
+ };
1113
+ };
1114
+ const notifier = new SessionNotifier(options.notifications ?? {});
1115
+ const registry = new SessionRegistry({ onRegister: (runner) => notifier.watch(runner) });
1116
+ const bridge = new BridgeHub({
1117
+ ...options.bridge,
1118
+ onResult: (sessionId, executionId, result) => {
1119
+ registry.get(sessionId)?.settleExecution?.(executionId, result);
1120
+ options.bridge?.onResult?.(sessionId, executionId, result);
1121
+ }
1122
+ });
1123
+ const parking = new SessionParkManager({
1124
+ registry,
1125
+ store: options.parking?.store ?? new MemorySessionStore(),
1126
+ parkDelayMs: options.parking?.parkDelayMs,
1127
+ expiredGraceMs: options.parking?.expiredGraceMs,
1128
+ onError: options.parking?.onError,
1129
+ rebuild: (record) => buildRunner(record.config, record.snapshot),
1130
+ attachedCount: (sessionId) => bridge.attachedCount(sessionId),
1131
+ onParking: (sessionId, executionId) => queue?.onSessionParking(sessionId, executionId) ?? true,
1132
+ onResumed: (sessionId, runner) => queue?.onSessionResumed(sessionId, runner)
1133
+ });
1134
+ const wss = new WebSocketServer({ noServer: true });
1135
+ /** Profiles (by name; '' = none) whose oauth notice has been logged. */
1136
+ const subscriptionNoticeShown = /* @__PURE__ */ new Set();
1137
+ const queueSockets = /* @__PURE__ */ new Set();
1138
+ const sendQueueFrame = (ws, frame) => {
1139
+ if (ws.readyState === ws.OPEN) ws.send(JSON.stringify(frame));
1140
+ };
1141
+ const broadcastJobEvent = (event) => {
1142
+ if (queueSockets.size === 0) return;
1143
+ for (const ws of queueSockets) sendQueueFrame(ws, {
1144
+ type: "job_event",
1145
+ event
1146
+ });
1147
+ if (event.type !== "job_progress") queue?.stats().then((stats) => {
1148
+ for (const ws of queueSockets) sendQueueFrame(ws, {
1149
+ type: "queue_stats",
1150
+ stats
1151
+ });
1152
+ }).catch(() => {});
1153
+ };
1154
+ const queue = options.queue ? new JobQueue({
1155
+ ...options.queue,
1156
+ onEvent: (event) => {
1157
+ try {
1158
+ options.queue?.onEvent?.(event);
1159
+ } finally {
1160
+ broadcastJobEvent(event);
1161
+ }
1162
+ },
1163
+ createRunner: async (config) => {
1164
+ const runner = await createRunner(config);
1165
+ watchAuthSource(runner);
1166
+ return runner;
1167
+ },
1168
+ buildRunnerConfig,
1169
+ discardSession: (sessionId) => parking.discard(sessionId)
1170
+ }) : void 0;
1171
+ const watchAuthSource = (runner) => {
1172
+ let seen = false;
1173
+ runner.subscribe((event) => {
1174
+ if (seen || event.type !== "system_init") return;
1175
+ seen = true;
1176
+ if (event.apiKeySource !== "oauth") return;
1177
+ if (options.requireApiKey) runner.fail("This server requires API-key auth (requireApiKey), but the session initialized with claude.ai subscription credentials (apiKeySource 'oauth'). Set ANTHROPIC_API_KEY (or Bedrock/Vertex auth) in the server environment.");
1178
+ else {
1179
+ const profileName = runner.info().profile ?? "";
1180
+ if (subscriptionNoticeShown.has(profileName)) return;
1181
+ subscriptionNoticeShown.add(profileName);
1182
+ const scope = profileName ? `Sessions under profile '${profileName}'` : "Sessions";
1183
+ console.warn(`[workerdeck] ${scope} are using claude.ai subscription credentials (apiKeySource 'oauth'), not an API key. That is only appropriate for personal, single-user use of your own account. Unattended/scheduled or multi-user use requires an API key under Anthropic's terms — set ANTHROPIC_API_KEY in the server environment, or set requireApiKey: true to fail closed.`);
1184
+ }
1185
+ });
1186
+ };
1187
+ /**
1188
+ * Probe each Claude profile's credentials the way its sessions will actually
1189
+ * experience them: the env the real assembly path produces, so anything the
1190
+ * host hook injects (a CLAUDE_CODE_OAUTH_TOKEN, say) counts as logged in.
1191
+ * Provider profiles resolve credentials in the engine factory and are not
1192
+ * probed. Fire-and-forget by design — see the `checkCredentials` option doc.
1193
+ */
1194
+ const preflightCredentials = () => {
1195
+ if (!options.checkCredentials) return;
1196
+ const conf = options.checkCredentials === true ? {} : options.checkCredentials;
1197
+ const probe = conf.probe ?? ((env) => checkClaudeAuth(env, { timeoutMs: conf.timeoutMs }));
1198
+ for (const profile of allProfiles()) {
1199
+ if (isProviderProfile(profile)) continue;
1200
+ let env;
1201
+ try {
1202
+ env = buildRunnerConfig({
1203
+ cwd: process.cwd(),
1204
+ profile: profile.name
1205
+ }).env ?? process.env;
1206
+ } catch {
1207
+ env = claudeSessionEnv(profile, process.env);
1208
+ }
1209
+ probe(env).then((status) => {
1210
+ if (status !== "logged_out") return;
1211
+ console.warn(`[workerdeck] Profile '${profile.name}' (${profile.configDir}) has no usable Claude credentials: \`claude auth status\` reports logged out for the environment its sessions run with, so they will fail with "Not logged in". Log in under that dir (CLAUDE_CONFIG_DIR=${profile.configDir} claude auth login), inject a long-lived token via buildRunnerConfig (CLAUDE_CODE_OAUTH_TOKEN), or set ANTHROPIC_API_KEY. \`checkCredentials: false\` disables this check.`);
1212
+ }).catch(() => {});
1213
+ }
1214
+ };
1215
+ const authenticate = async (req) => {
1216
+ if (!options.authenticate) return { ok: true };
1217
+ const principal = await options.authenticate(req);
1218
+ if (principal === null || principal === void 0 || principal === false) return { ok: false };
1219
+ const allowed = principal.allowedProfiles;
1220
+ return {
1221
+ ok: true,
1222
+ allowedProfiles: Array.isArray(allowed) && allowed.every((p) => typeof p === "string") ? allowed : void 0,
1223
+ canManageProfiles: principal.canManageProfiles === true
1224
+ };
1225
+ };
1226
+ const parseRoute = (url) => {
1227
+ const pathname = new URL(url, "http://internal").pathname;
1228
+ if (!pathname.startsWith(basePath + "/sessions")) return null;
1229
+ const rest = pathname.slice((basePath + "/sessions").length);
1230
+ if (rest === "" || rest === "/") return {};
1231
+ const parts = rest.replace(/^\//, "").split("/");
1232
+ if (parts.length === 1) return { id: decodeURIComponent(parts[0]) };
1233
+ if (parts.length === 2 && parts[1] === "ws") return {
1234
+ id: decodeURIComponent(parts[0]),
1235
+ ws: true
1236
+ };
1237
+ if (parts.length === 3 && parts[1] === "permissions") return {
1238
+ id: decodeURIComponent(parts[0]),
1239
+ permissionId: decodeURIComponent(parts[2])
1240
+ };
1241
+ if (parts.length >= 2 && parts[1] === "files") {
1242
+ const filePath = parts.slice(2).map(decodeURIComponent).join("/");
1243
+ return {
1244
+ id: decodeURIComponent(parts[0]),
1245
+ files: true,
1246
+ filePath: filePath === "" ? void 0 : "/" + filePath
1247
+ };
1248
+ }
1249
+ return null;
1250
+ };
1251
+ const listSdkSessions = options.listSdkSessions ?? defaultSdkSessionLister;
1252
+ const handleSdkSessions = async (req, res) => {
1253
+ if (req.method !== "GET") {
1254
+ json(res, 405, { error: "method not allowed" });
1255
+ return;
1256
+ }
1257
+ const url = new URL(req.url ?? "/", "http://internal");
1258
+ const dir = url.searchParams.get("dir") ?? void 0;
1259
+ const roots = options.allowedCwdRoots;
1260
+ if (roots && roots.length > 0) {
1261
+ if (!dir) {
1262
+ json(res, 400, { error: "dir is required when allowedCwdRoots is set" });
1263
+ return;
1264
+ }
1265
+ if (!cwdAllowed(dir, roots)) {
1266
+ json(res, 403, { error: "dir is outside the allowed roots" });
1267
+ return;
1268
+ }
1269
+ }
1270
+ json(res, 200, { sdkSessions: await listSdkSessions({
1271
+ dir,
1272
+ limit: Number(url.searchParams.get("limit") ?? "") || void 0,
1273
+ offset: Number(url.searchParams.get("offset") ?? "") || void 0
1274
+ }) });
1275
+ };
1276
+ const handleJobs = async (req, res, pathname, auth) => {
1277
+ if (!queue) {
1278
+ json(res, 404, { error: "job queue not configured" });
1279
+ return;
1280
+ }
1281
+ if (pathname === basePath + "/queue") {
1282
+ if (req.method !== "GET") {
1283
+ json(res, 405, { error: "method not allowed" });
1284
+ return;
1285
+ }
1286
+ json(res, 200, { stats: await queue.stats() });
1287
+ return;
1288
+ }
1289
+ const rest = pathname.slice((basePath + "/jobs").length).replace(/^\//, "");
1290
+ if (rest === "") {
1291
+ if (req.method === "GET") {
1292
+ json(res, 200, { jobs: await queue.list() });
1293
+ return;
1294
+ }
1295
+ if (req.method === "POST") {
1296
+ const body = await readJsonBody(req, maxBodyBytes);
1297
+ if (!body.session || typeof body.session !== "object") {
1298
+ json(res, 400, { error: "session is required" });
1299
+ return;
1300
+ }
1301
+ if (!body.session.cwd || typeof body.session.cwd !== "string") {
1302
+ json(res, 400, { error: "session.cwd is required" });
1303
+ return;
1304
+ }
1305
+ if (!body.session.prompt || typeof body.session.prompt !== "string") {
1306
+ json(res, 400, { error: "session.prompt is required" });
1307
+ return;
1308
+ }
1309
+ if (!cwdAllowed(body.session.cwd, options.allowedCwdRoots)) {
1310
+ json(res, 403, { error: "cwd is outside the allowed roots" });
1311
+ return;
1312
+ }
1313
+ const refused = applyBypassPolicy(body.session);
1314
+ if (refused) {
1315
+ json(res, 403, { error: refused });
1316
+ return;
1317
+ }
1318
+ const resolved = resolveProfile(body.session.profile, auth.allowedProfiles);
1319
+ if (!resolved.ok) {
1320
+ json(res, resolved.status, { error: resolved.error });
1321
+ return;
1322
+ }
1323
+ const badRequest = checkPermissionMode(body.session.permissionMode, resolved.profile) ?? checkEngineGrants(body.session, resolved.profile);
1324
+ if (badRequest) {
1325
+ json(res, 400, { error: badRequest });
1326
+ return;
1327
+ }
1328
+ body.session.profile = resolved.profile?.name;
1329
+ try {
1330
+ json(res, 201, { job: await queue.submit(body) });
1331
+ } catch (error) {
1332
+ json(res, 400, { error: error instanceof Error ? error.message : "invalid job" });
1333
+ }
1334
+ return;
1335
+ }
1336
+ json(res, 405, { error: "method not allowed" });
1337
+ return;
1338
+ }
1339
+ const id = decodeURIComponent(rest);
1340
+ if (id.includes("/")) {
1341
+ json(res, 404, { error: "not found" });
1342
+ return;
1343
+ }
1344
+ if (req.method === "GET") {
1345
+ const job = await queue.get(id);
1346
+ if (job) json(res, 200, { job });
1347
+ else json(res, 404, { error: "job not found" });
1348
+ return;
1349
+ }
1350
+ if (req.method === "DELETE") {
1351
+ const job = await queue.cancel(id);
1352
+ if (job) json(res, 200, { job });
1353
+ else json(res, 404, { error: "job not found" });
1354
+ return;
1355
+ }
1356
+ json(res, 405, { error: "method not allowed" });
1357
+ };
1358
+ /**
1359
+ * `POST {basePath}/executions/:executionId/result` — a deferred executor
1360
+ * delivering its outcome. Wakes the parked session, applies the result to its
1361
+ * agent loop, and lets the run continue.
1362
+ *
1363
+ * Scoped like every other session route: a principal restricted to certain
1364
+ * profiles cannot settle an execution belonging to a session outside them —
1365
+ * a result is trusted tool input, and injecting one into another tenant's loop
1366
+ * would be a way to steer it.
1367
+ */
1368
+ const handleExecutionResult = async (req, res, pathname, auth) => {
1369
+ const rest = pathname.slice((basePath + "/executions/").length).split("/");
1370
+ if (rest.length !== 2 || rest[1] !== "result" || !rest[0]) {
1371
+ json(res, 404, { error: "not found" });
1372
+ return;
1373
+ }
1374
+ if (req.method !== "POST") {
1375
+ json(res, 405, { error: "method not allowed" });
1376
+ return;
1377
+ }
1378
+ const executionId = decodeURIComponent(rest[0]);
1379
+ const body = await readJsonBody(req, maxBodyBytes);
1380
+ let result;
1381
+ if (body?.status === "ok") {
1382
+ if (!body.output || typeof body.output !== "object") {
1383
+ json(res, 400, { error: "output is required for status 'ok'" });
1384
+ return;
1385
+ }
1386
+ result = {
1387
+ status: "ok",
1388
+ output: body.output.value,
1389
+ logs: body.logs
1390
+ };
1391
+ } else if (body?.status === "failed") {
1392
+ if (typeof body.reason !== "string" || typeof body.error !== "string") {
1393
+ json(res, 400, { error: "reason and error are required for status 'failed'" });
1394
+ return;
1395
+ }
1396
+ result = {
1397
+ status: "failed",
1398
+ reason: body.reason,
1399
+ error: body.error,
1400
+ logs: body.logs
1401
+ };
1402
+ } else {
1403
+ json(res, 400, { error: "status must be 'ok' or 'failed'" });
1404
+ return;
1405
+ }
1406
+ if (auth.allowedProfiles) {
1407
+ const owner = parking.sessionFor(executionId);
1408
+ const profile = owner === void 0 ? void 0 : registry.get(owner)?.info().profile ?? (await parking.get(owner))?.profile;
1409
+ if (owner === void 0 || profile !== void 0 && !auth.allowedProfiles.includes(profile)) {
1410
+ json(res, 404, { error: "execution not found" });
1411
+ return;
1412
+ }
1413
+ }
1414
+ const applied = await parking.submitResult(executionId, result);
1415
+ if (!applied) {
1416
+ json(res, 404, { error: "execution not found (unknown id, or its session has ended)" });
1417
+ return;
1418
+ }
1419
+ json(res, 200, applied);
1420
+ };
1421
+ const handleRequest = async (req, res) => {
1422
+ const pathname = new URL(req.url ?? "/", "http://internal").pathname;
1423
+ if (fallback && pathname !== basePath && !pathname.startsWith(basePath + "/")) {
1424
+ await fallback(req, res);
1425
+ return;
1426
+ }
1427
+ if (pathname === basePath + "/jobs" || pathname.startsWith(basePath + "/jobs/") || pathname === basePath + "/queue") {
1428
+ const auth = await authenticate(req);
1429
+ if (!auth.ok) {
1430
+ json(res, 401, { error: "unauthorized" });
1431
+ return;
1432
+ }
1433
+ await handleJobs(req, res, pathname, auth);
1434
+ return;
1435
+ }
1436
+ if (pathname === basePath + "/profiles" || pathname.startsWith(basePath + "/profiles/")) {
1437
+ const auth = await authenticate(req);
1438
+ if (!auth.ok) {
1439
+ json(res, 401, { error: "unauthorized" });
1440
+ return;
1441
+ }
1442
+ const rest = pathname.slice((basePath + "/profiles").length).replace(/^\//, "");
1443
+ if (rest === "") {
1444
+ if (req.method === "GET") {
1445
+ json(res, 200, {
1446
+ profiles: (auth.allowedProfiles ? allProfiles().filter((p) => auth.allowedProfiles.includes(p.name)) : allProfiles()).map(withManagedFlag),
1447
+ canManage: manageGuard(auth) === null
1448
+ });
1449
+ return;
1450
+ }
1451
+ if (req.method === "POST") {
1452
+ const refused = manageGuard(auth);
1453
+ if (refused) {
1454
+ json(res, refused.status, { error: refused.error });
1455
+ return;
1456
+ }
1457
+ const body = await readJsonBody(req, maxBodyBytes);
1458
+ if (!body.name || typeof body.name !== "string") {
1459
+ json(res, 400, { error: "name is required" });
1460
+ return;
1461
+ }
1462
+ if (profileFor(body.name)) {
1463
+ json(res, 409, { error: `profile already exists: ${body.name}` });
1464
+ return;
1465
+ }
1466
+ await saveManagedProfile(res, body);
1467
+ return;
1468
+ }
1469
+ json(res, 405, { error: "method not allowed" });
1470
+ return;
1471
+ }
1472
+ const name = decodeURIComponent(rest);
1473
+ const profile = name.includes("/") ? void 0 : profileFor(name);
1474
+ if (!profile) {
1475
+ json(res, 404, { error: "profile not found" });
1476
+ return;
1477
+ }
1478
+ if (auth.allowedProfiles && !auth.allowedProfiles.includes(profile.name)) {
1479
+ json(res, 403, { error: `profile not allowed: ${profile.name}` });
1480
+ return;
1481
+ }
1482
+ if (req.method === "GET") {
1483
+ json(res, 200, {
1484
+ profile: withManagedFlag(profile),
1485
+ config: readProfileConfig(profile)
1486
+ });
1487
+ return;
1488
+ }
1489
+ if (req.method === "PATCH" || req.method === "DELETE") {
1490
+ const refused = manageGuard(auth) ?? declaredGuard(profile);
1491
+ if (refused) {
1492
+ json(res, refused.status, { error: refused.error });
1493
+ return;
1494
+ }
1495
+ if (req.method === "DELETE") {
1496
+ await options.profileStore.delete(profile.name);
1497
+ await refreshStored();
1498
+ res.writeHead(204).end();
1499
+ return;
1500
+ }
1501
+ const patch = await readJsonBody(req, maxBodyBytes);
1502
+ await saveManagedProfile(res, {
1503
+ ...profile,
1504
+ ...patch,
1505
+ name: profile.name
1506
+ });
1507
+ return;
1508
+ }
1509
+ json(res, 405, { error: "method not allowed" });
1510
+ return;
1511
+ }
1512
+ if (pathname.startsWith(basePath + "/executions/")) {
1513
+ const auth = await authenticate(req);
1514
+ if (!auth.ok) {
1515
+ json(res, 401, { error: "unauthorized" });
1516
+ return;
1517
+ }
1518
+ await handleExecutionResult(req, res, pathname, auth);
1519
+ return;
1520
+ }
1521
+ if (pathname === basePath + "/sdk-sessions") {
1522
+ if (!(await authenticate(req)).ok) {
1523
+ json(res, 401, { error: "unauthorized" });
1524
+ return;
1525
+ }
1526
+ await handleSdkSessions(req, res);
1527
+ return;
1528
+ }
1529
+ const route = parseRoute(req.url ?? "/");
1530
+ if (!route || route.ws) {
1531
+ json(res, 404, { error: "not found" });
1532
+ return;
1533
+ }
1534
+ const auth = await authenticate(req);
1535
+ if (!auth.ok) {
1536
+ json(res, 401, { error: "unauthorized" });
1537
+ return;
1538
+ }
1539
+ if (!route.id) {
1540
+ if (req.method === "GET") {
1541
+ json(res, 200, { sessions: [...registry.list(), ...await parking.listInfo()] });
1542
+ return;
1543
+ }
1544
+ if (req.method === "POST") {
1545
+ const body = await readJsonBody(req, maxBodyBytes);
1546
+ if (!body.cwd || typeof body.cwd !== "string") {
1547
+ json(res, 400, { error: "cwd is required" });
1548
+ return;
1549
+ }
1550
+ if (!cwdAllowed(body.cwd, options.allowedCwdRoots)) {
1551
+ json(res, 403, { error: "cwd is outside the allowed roots" });
1552
+ return;
1553
+ }
1554
+ const refused = applyBypassPolicy(body);
1555
+ if (refused) {
1556
+ json(res, 403, { error: refused });
1557
+ return;
1558
+ }
1559
+ const resolved = resolveProfile(body.profile, auth.allowedProfiles);
1560
+ if (!resolved.ok) {
1561
+ json(res, resolved.status, { error: resolved.error });
1562
+ return;
1563
+ }
1564
+ const badRequest = checkPermissionMode(body.permissionMode, resolved.profile) ?? checkEngineGrants(body, resolved.profile);
1565
+ if (badRequest) {
1566
+ json(res, 400, { error: badRequest });
1567
+ return;
1568
+ }
1569
+ body.profile = resolved.profile?.name;
1570
+ const runner = await createRunner(buildRunnerConfig(body));
1571
+ watchAuthSource(runner);
1572
+ json(res, 201, { session: runner.info() });
1573
+ return;
1574
+ }
1575
+ json(res, 405, { error: "method not allowed" });
1576
+ return;
1577
+ }
1578
+ const runner = registry.get(route.id);
1579
+ const parked = runner ? null : await parking.get(route.id);
1580
+ if (!runner && !parked) {
1581
+ json(res, 404, { error: "session not found" });
1582
+ return;
1583
+ }
1584
+ if (route.files) {
1585
+ if (req.method !== "GET") {
1586
+ json(res, 405, { error: "method not allowed" });
1587
+ return;
1588
+ }
1589
+ const snapshotFiles = parked?.snapshot.vfs;
1590
+ const vfs = runner?.vfs ?? (snapshotFiles && {
1591
+ list: () => Object.keys(snapshotFiles).sort(),
1592
+ read: (path) => snapshotFiles[path]
1593
+ });
1594
+ if (!vfs) {
1595
+ json(res, 404, { error: "session has no file store" });
1596
+ return;
1597
+ }
1598
+ if (route.filePath === void 0) {
1599
+ json(res, 200, { files: vfs.list().map((path) => ({
1600
+ path,
1601
+ bytes: vfs.read(path)?.length ?? 0
1602
+ })) });
1603
+ return;
1604
+ }
1605
+ const content = vfs.read(route.filePath);
1606
+ if (content === void 0) {
1607
+ json(res, 404, { error: `no such file: ${route.filePath}` });
1608
+ return;
1609
+ }
1610
+ const filename = route.filePath.split("/").pop() || "file";
1611
+ res.writeHead(200, {
1612
+ "content-type": contentTypeFor(filename),
1613
+ "content-length": Buffer.byteLength(content),
1614
+ "content-disposition": `attachment; filename*=UTF-8''${encodeURIComponent(filename)}`,
1615
+ "x-content-type-options": "nosniff"
1616
+ });
1617
+ res.end(content);
1618
+ return;
1619
+ }
1620
+ if (route.permissionId) {
1621
+ if (req.method !== "POST") {
1622
+ json(res, 405, { error: "method not allowed" });
1623
+ return;
1624
+ }
1625
+ const body = await readJsonBody(req, maxBodyBytes);
1626
+ if (body?.behavior !== "allow" && body?.behavior !== "deny") {
1627
+ json(res, 400, { error: "behavior must be 'allow' or 'deny'" });
1628
+ return;
1629
+ }
1630
+ if (!runner) {
1631
+ json(res, 409, { error: "session is parked (it has no pending permission requests)" });
1632
+ return;
1633
+ }
1634
+ if (!runner.resolvePermission(route.permissionId, body)) {
1635
+ json(res, 404, { error: "permission request not found (already resolved or expired)" });
1636
+ return;
1637
+ }
1638
+ json(res, 200, { resolved: true });
1639
+ return;
1640
+ }
1641
+ if (req.method === "GET") {
1642
+ json(res, 200, { session: runner?.info() ?? parked.info });
1643
+ return;
1644
+ }
1645
+ if (req.method === "DELETE") {
1646
+ registry.remove(route.id);
1647
+ bridge.remove(route.id);
1648
+ await parking.discard(route.id);
1649
+ json(res, 200, { session: runner?.info() ?? {
1650
+ ...parked.info,
1651
+ status: "closed"
1652
+ } });
1653
+ return;
1654
+ }
1655
+ json(res, 405, { error: "method not allowed" });
1656
+ };
1657
+ const server = createServer((req, res) => {
1658
+ handleRequest(req, res).catch((error) => {
1659
+ const message = error instanceof Error ? error.message : "internal error";
1660
+ if (!res.headersSent) json(res, error instanceof SyntaxError ? 400 : 500, { error: message });
1661
+ else res.end();
1662
+ });
1663
+ });
1664
+ server.on("upgrade", (req, socket, head) => {
1665
+ (async () => {
1666
+ if (new URL(req.url ?? "/", "http://internal").pathname === basePath + "/queue/ws") {
1667
+ if (!queue) {
1668
+ socket.write("HTTP/1.1 404 Not Found\r\n\r\n");
1669
+ socket.destroy();
1670
+ return;
1671
+ }
1672
+ if (!(await authenticate(req)).ok) {
1673
+ socket.write("HTTP/1.1 401 Unauthorized\r\n\r\n");
1674
+ socket.destroy();
1675
+ return;
1676
+ }
1677
+ wss.handleUpgrade(req, socket, head, (ws) => {
1678
+ queueSockets.add(ws);
1679
+ ws.on("close", () => queueSockets.delete(ws));
1680
+ queue.stats().then((stats) => sendQueueFrame(ws, {
1681
+ type: "queue_attached",
1682
+ protocolVersion: PROTOCOL_VERSION,
1683
+ stats
1684
+ })).catch(() => {});
1685
+ });
1686
+ return;
1687
+ }
1688
+ const route = parseRoute(req.url ?? "/");
1689
+ if (!route?.ws || !route.id) {
1690
+ socket.destroy();
1691
+ return;
1692
+ }
1693
+ if (!(await authenticate(req)).ok) {
1694
+ socket.write("HTTP/1.1 401 Unauthorized\r\n\r\n");
1695
+ socket.destroy();
1696
+ return;
1697
+ }
1698
+ const runner = await parking.ensureLive(route.id).catch(() => void 0);
1699
+ if (!runner) {
1700
+ socket.write("HTTP/1.1 404 Not Found\r\n\r\n");
1701
+ socket.destroy();
1702
+ return;
1703
+ }
1704
+ wss.handleUpgrade(req, socket, head, (ws) => {
1705
+ attachClient(ws, runner, req);
1706
+ });
1707
+ })().catch(() => socket.destroy());
1708
+ });
1709
+ const attachClient = (ws, runner, req) => {
1710
+ const url = new URL(req.url ?? "/", "http://internal");
1711
+ const afterSeq = Number(url.searchParams.get("afterSeq") ?? "0") || 0;
1712
+ const send = (frame) => {
1713
+ if (ws.readyState === ws.OPEN) ws.send(JSON.stringify(frame));
1714
+ };
1715
+ send({
1716
+ type: "attached",
1717
+ protocolVersion: PROTOCOL_VERSION,
1718
+ session: runner.info(),
1719
+ replayingFrom: afterSeq
1720
+ });
1721
+ const unsubscribe = runner.subscribe((event) => send({
1722
+ type: "event",
1723
+ event
1724
+ }), afterSeq);
1725
+ const detachBridge = bridge.attach(runner.id, send);
1726
+ ws.on("message", (data) => {
1727
+ let frame;
1728
+ try {
1729
+ frame = JSON.parse(data.toString("utf8"));
1730
+ } catch {
1731
+ send({
1732
+ type: "protocol_error",
1733
+ message: "invalid JSON frame"
1734
+ });
1735
+ return;
1736
+ }
1737
+ handleCommand(frame, runner).catch((error) => {
1738
+ send({
1739
+ type: "protocol_error",
1740
+ message: error instanceof Error ? error.message : "command failed"
1741
+ });
1742
+ });
1743
+ });
1744
+ ws.on("close", () => {
1745
+ unsubscribe();
1746
+ detachBridge();
1747
+ parking.onDetach(runner.id);
1748
+ });
1749
+ };
1750
+ const handleCommand = async (frame, runner) => {
1751
+ switch (frame.type) {
1752
+ case "user_message":
1753
+ runner.sendMessage(frame.text);
1754
+ return;
1755
+ case "permission_decision":
1756
+ if (frame.behavior === "allow") runner.resolvePermission(frame.requestId, {
1757
+ behavior: "allow",
1758
+ updatedInput: frame.updatedInput
1759
+ });
1760
+ else runner.resolvePermission(frame.requestId, {
1761
+ behavior: "deny",
1762
+ message: frame.message,
1763
+ interrupt: frame.interrupt
1764
+ });
1765
+ return;
1766
+ case "interrupt":
1767
+ await runner.interrupt();
1768
+ return;
1769
+ case "set_permission_mode":
1770
+ if (frame.mode === "bypassPermissions" && options.disableBypassPermissions) throw new Error("bypassPermissions is disabled on this server (disableBypassPermissions)");
1771
+ await runner.setPermissionMode(frame.mode);
1772
+ return;
1773
+ case "set_model":
1774
+ await runner.setModel(frame.model);
1775
+ return;
1776
+ case "tool_call_result":
1777
+ bridge.resolve(runner.id, frame.executionId, {
1778
+ output: frame.output,
1779
+ logs: frame.logs
1780
+ });
1781
+ return;
1782
+ case "tool_call_error":
1783
+ bridge.resolve(runner.id, frame.executionId, {
1784
+ reason: frame.reason,
1785
+ error: frame.error,
1786
+ logs: frame.logs
1787
+ });
1788
+ return;
1789
+ case "close":
1790
+ runner.close("client");
1791
+ return;
1792
+ default: throw new Error(`unknown command: ${frame.type}`);
1793
+ }
1794
+ };
1795
+ return {
1796
+ server,
1797
+ registry,
1798
+ queue,
1799
+ bridge,
1800
+ parking,
1801
+ listen: async (port, host) => {
1802
+ await refreshStored();
1803
+ await parking.hydrate();
1804
+ return new Promise((resolve, reject) => {
1805
+ server.once("error", reject);
1806
+ server.listen(port, host, () => {
1807
+ preflightCredentials();
1808
+ const address = server.address();
1809
+ resolve({ port: typeof address === "object" && address ? address.port : port });
1810
+ });
1811
+ });
1812
+ },
1813
+ close: () => new Promise((resolve) => {
1814
+ queue?.close();
1815
+ parking.close();
1816
+ registry.closeAll();
1817
+ for (const ws of queueSockets) ws.close();
1818
+ queueSockets.clear();
1819
+ wss.close();
1820
+ server.close(() => resolve());
1821
+ server.closeAllConnections();
1822
+ })
1823
+ };
1824
+ }
1825
+ //#endregion
1826
+ //#region src/profile-store.ts
1827
+ /** Non-durable store for tests and ephemeral deployments. */
1828
+ function createMemoryProfileStore(seed = []) {
1829
+ const profiles = new Map(seed.map((p) => [p.name, p]));
1830
+ return {
1831
+ list: () => [...profiles.values()],
1832
+ save: (profile) => void profiles.set(profile.name, profile),
1833
+ delete: (name) => void profiles.delete(name)
1834
+ };
1835
+ }
1836
+ /**
1837
+ * JSON-file store: one array of profiles at `path` (default
1838
+ * `<cwd>/.workerdeck/profiles.json`). Writes go through a temp file and a
1839
+ * rename so a crash mid-write cannot truncate the operator's profile list.
1840
+ *
1841
+ * Single-process by design, exactly like the bundled queue adapter — two servers
1842
+ * sharing one file would race. That is what the seam is for.
1843
+ */
1844
+ function createFileProfileStore(path = join(process.cwd(), ".workerdeck", "profiles.json")) {
1845
+ const read = () => {
1846
+ try {
1847
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
1848
+ if (!Array.isArray(parsed)) return /* @__PURE__ */ new Map();
1849
+ return new Map(parsed.filter((p) => p && typeof p.name === "string").map((p) => [p.name, p]));
1850
+ } catch {
1851
+ return /* @__PURE__ */ new Map();
1852
+ }
1853
+ };
1854
+ const write = (profiles) => {
1855
+ mkdirSync(dirname(path), { recursive: true });
1856
+ const temp = `${path}.${process.pid}.tmp`;
1857
+ writeFileSync(temp, JSON.stringify([...profiles.values()], null, 2));
1858
+ renameSync(temp, path);
1859
+ };
1860
+ return {
1861
+ list: () => [...read().values()],
1862
+ save: (profile) => {
1863
+ const profiles = read();
1864
+ profiles.set(profile.name, profile);
1865
+ write(profiles);
1866
+ },
1867
+ delete: (name) => {
1868
+ const profiles = read();
1869
+ if (profiles.delete(name)) write(profiles);
1870
+ }
1871
+ };
1872
+ }
1873
+ //#endregion
1874
+ export { BridgeHub, MemorySessionStore, SessionNotifier, SessionParkManager, SessionRegistry, createFileProfileStore, createFileSessionStore, createMemoryProfileStore, createWorkerServer, toDurableRecord };
1875
+
1876
+ //# sourceMappingURL=index.mjs.map