@workerdeck/server 0.7.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/build/index.mjs CHANGED
@@ -1,13 +1,12 @@
1
- import { createHash } from "node:crypto";
2
- import { closeSync, constants, existsSync, fstatSync, ftruncateSync, lstatSync, mkdirSync, openSync, readFileSync, readdirSync, realpathSync, renameSync, writeFileSync } from "node:fs";
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { closeSync, constants, createReadStream, existsSync, fstatSync, ftruncateSync, lstatSync, mkdirSync, openSync, readFileSync, readdirSync, realpathSync, renameSync, statSync, writeFileSync } from "node:fs";
3
3
  import { createServer } from "node:http";
4
4
  import { homedir } from "node:os";
5
5
  import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
6
6
  import { WebSocketServer } from "ws";
7
- import { listSessions } from "@anthropic-ai/claude-agent-sdk";
8
- import { BrowserBridgeExecutor, SessionRunner, checkClaudeAuth } from "@workerdeck/core";
7
+ import { BrowserBridgeExecutor, SessionRunner, attachmentKind, checkClaudeAuth, getEngineAdapter, normalizeMediaType } from "@workerdeck/core";
9
8
  import { JobQueue } from "@workerdeck/queue";
10
- import { PROTOCOL_VERSION, PROVIDER_PERMISSION_MODES, supportsPermissionMode } from "@workerdeck/protocol";
9
+ import { ENGINE_CAPABILITIES, PROTOCOL_VERSION, supportsPermissionMode } from "@workerdeck/protocol";
11
10
  import { mkdir, readFile, readdir, rename, rm, writeFile } from "node:fs/promises";
12
11
  //#region src/host-files.ts
13
12
  /**
@@ -267,7 +266,8 @@ const DEFAULT_IGNORED_DIRS = [
267
266
  ".pytest_cache",
268
267
  ".gradle",
269
268
  "Pods",
270
- "DerivedData"
269
+ "DerivedData",
270
+ ".build"
271
271
  ];
272
272
  /**
273
273
  * Breadth-first so shallow files rank first before scoring even runs — for a bare
@@ -364,6 +364,193 @@ function subsequenceScore(haystack, needle) {
364
364
  return score - haystack.length / 100;
365
365
  }
366
366
  //#endregion
367
+ //#region src/attachments.ts
368
+ const DEFAULT_MAX_FILE_BYTES = 10 * 1024 * 1024;
369
+ const DEFAULT_MAX_SESSION_BYTES = 64 * 1024 * 1024;
370
+ /**
371
+ * Per-session hold for files the user attached to a message.
372
+ *
373
+ * In memory, and deliberately so. An attachment is only *needed* for the instant
374
+ * between the upload and the message that names it; everything after that is
375
+ * convenience (a client re-rendering a thumbnail after a reattach). That is the
376
+ * same bargain `GET /sessions/:id/files` makes — the session's lifetime, no
377
+ * durability tier — and it keeps the gateway from accumulating a photo library
378
+ * on disk that nobody asked it to look after.
379
+ *
380
+ * Both caps are enforced here rather than at the route, so a host embedding the
381
+ * server cannot forget one: a single file that is too big is a 413, and so is a
382
+ * session whose total would go over.
383
+ */
384
+ var AttachmentStore = class {
385
+ #bySession = /* @__PURE__ */ new Map();
386
+ #maxFileBytes;
387
+ #maxSessionBytes;
388
+ constructor(options = {}) {
389
+ this.#maxFileBytes = options.maxFileBytes ?? DEFAULT_MAX_FILE_BYTES;
390
+ this.#maxSessionBytes = options.maxSessionBytes ?? DEFAULT_MAX_SESSION_BYTES;
391
+ }
392
+ get maxFileBytes() {
393
+ return this.#maxFileBytes;
394
+ }
395
+ put(sessionId, name, mediaType, body) {
396
+ if (body.length === 0) return {
397
+ ok: false,
398
+ error: {
399
+ code: "empty",
400
+ message: "attachment is empty"
401
+ }
402
+ };
403
+ if (body.length > this.#maxFileBytes) return {
404
+ ok: false,
405
+ error: {
406
+ code: "too_large",
407
+ message: `attachment is larger than the ${this.#maxFileBytes}-byte limit`
408
+ }
409
+ };
410
+ const type = normalizeMediaType(mediaType);
411
+ if (!attachmentKind(type)) return {
412
+ ok: false,
413
+ error: {
414
+ code: "unsupported_type",
415
+ message: `unsupported media type: ${type}`
416
+ }
417
+ };
418
+ const held = this.#bySession.get(sessionId) ?? /* @__PURE__ */ new Map();
419
+ const heldBytes = [...held.values()].reduce((sum, a) => sum + a.bytes, 0);
420
+ if (heldBytes + body.length > this.#maxSessionBytes) return {
421
+ ok: false,
422
+ error: {
423
+ code: "session_full",
424
+ message: `session is already holding ${heldBytes} bytes of attachments (limit ${this.#maxSessionBytes})`
425
+ }
426
+ };
427
+ const attachment = {
428
+ id: randomUUID(),
429
+ name: safeName(name),
430
+ mediaType: type,
431
+ bytes: body.length,
432
+ data: body.toString("base64")
433
+ };
434
+ held.set(attachment.id, attachment);
435
+ this.#bySession.set(sessionId, held);
436
+ return {
437
+ ok: true,
438
+ attachment: ref(attachment)
439
+ };
440
+ }
441
+ /** The stored record, bytes included — for the download route and for the send
442
+ * path that turns ids into content blocks. */
443
+ get(sessionId, id) {
444
+ return this.#bySession.get(sessionId)?.get(id);
445
+ }
446
+ /**
447
+ * Resolve the ids a `user_message` named, in the order given.
448
+ *
449
+ * Missing ids are reported rather than skipped: a message that quietly lost its
450
+ * picture reads as the model ignoring it, which is a far worse failure than a
451
+ * command that errors.
452
+ */
453
+ resolve(sessionId, ids) {
454
+ const held = this.#bySession.get(sessionId);
455
+ const attachments = [];
456
+ const missing = [];
457
+ for (const id of ids) {
458
+ const found = held?.get(id);
459
+ if (found) attachments.push(found);
460
+ else missing.push(id);
461
+ }
462
+ return missing.length ? {
463
+ ok: false,
464
+ missing
465
+ } : {
466
+ ok: true,
467
+ attachments
468
+ };
469
+ }
470
+ drop(sessionId) {
471
+ this.#bySession.delete(sessionId);
472
+ }
473
+ };
474
+ function ref(attachment) {
475
+ return {
476
+ id: attachment.id,
477
+ name: attachment.name,
478
+ mediaType: attachment.mediaType,
479
+ bytes: attachment.bytes
480
+ };
481
+ }
482
+ /**
483
+ * A display name, not a path. The name is echoed back to clients and put in front
484
+ * of the model in the text-attachment envelope, so directory separators, control
485
+ * characters and unbounded length all come off here.
486
+ */
487
+ function safeName(name) {
488
+ const cleaned = (name.split(/[/\\]/).pop() ?? "").replace(/[\u0000-\u001f\u007f"<>]/g, "").trim();
489
+ if (cleaned === "" || cleaned === "." || cleaned === "..") return "attachment";
490
+ return cleaned.length > 120 ? cleaned.slice(0, 120) : cleaned;
491
+ }
492
+ //#endregion
493
+ //#region src/produced-files.ts
494
+ /**
495
+ * The paths this gateway will serve from `GET /sessions/:id/produced/:fileId`.
496
+ *
497
+ * **This is the whole access-control model, so it is worth being precise about
498
+ * what it is.** The store is an allowlist built from one source and one only:
499
+ * `file_produced` events, which a runner emits about a file its own engine just
500
+ * wrote. It is not a directory grant. Nothing else can add to it — not a
501
+ * request, not a config, and in particular not the agent, whose own path claims
502
+ * go through `/fs/*` and that route's root allowlist.
503
+ *
504
+ * That is why the route needs neither `hostFiles.roots` nor `maxFileBytes`:
505
+ * "somewhere under a root the operator declared" is a guess about which paths
506
+ * are safe, while "the exact path this session's runner reported producing" is
507
+ * a fact about one file. A 2 MB generated PNG is the common case, and making
508
+ * the operator raise a byte cap to see their own picture was the bug this
509
+ * replaces.
510
+ *
511
+ * Lifetime is the session's, like `AttachmentStore`'s: in memory, dropped when
512
+ * the session is removed. The bytes are never held here — only the path, so a
513
+ * gateway serving a long session accumulates a few hundred bytes per picture
514
+ * rather than the pictures.
515
+ */
516
+ var ProducedFileStore = class {
517
+ #bySession = /* @__PURE__ */ new Map();
518
+ /**
519
+ * Register a runner's produced files for its lifetime.
520
+ *
521
+ * Subscribes from seq 0, which is the opposite of what `SessionNotifier` wants
522
+ * and correct for the same reason: registration is idempotent (a `fileId` is
523
+ * derived from its path, so re-registering overwrites with itself), and a
524
+ * session rebuilt from a park must re-learn every file it produced before the
525
+ * park — otherwise a client's transcript keeps rendering image cards whose
526
+ * bytes have quietly become unreachable.
527
+ */
528
+ watch(runner) {
529
+ runner.subscribe((event) => {
530
+ if (event.type !== "file_produced") return;
531
+ const held = this.#bySession.get(runner.id) ?? /* @__PURE__ */ new Map();
532
+ held.set(event.fileId, {
533
+ fileId: event.fileId,
534
+ path: event.path,
535
+ ...event.mediaType ? { mediaType: event.mediaType } : {},
536
+ ...event.bytes !== void 0 ? { bytes: event.bytes } : {},
537
+ sessionId: runner.id
538
+ });
539
+ this.#bySession.set(runner.id, held);
540
+ }, 0);
541
+ }
542
+ get(sessionId, fileId) {
543
+ return this.#bySession.get(sessionId)?.get(fileId);
544
+ }
545
+ /** Everything one session has produced, newest registration last. */
546
+ list(sessionId) {
547
+ return [...this.#bySession.get(sessionId)?.values() ?? []];
548
+ }
549
+ drop(sessionId) {
550
+ this.#bySession.delete(sessionId);
551
+ }
552
+ };
553
+ //#endregion
367
554
  //#region src/registry.ts
368
555
  /** In-memory session table. Terminal sessions stay listed until removed or the process exits. */
369
556
  var SessionRegistry = class {
@@ -1092,18 +1279,6 @@ function parseRecord(value) {
1092
1279
  }
1093
1280
  //#endregion
1094
1281
  //#region src/server.ts
1095
- const defaultSdkSessionLister = async (options) => {
1096
- return (await listSessions(options)).map((s) => ({
1097
- sessionId: s.sessionId,
1098
- summary: s.summary,
1099
- lastModified: s.lastModified,
1100
- createdAt: s.createdAt,
1101
- customTitle: s.customTitle,
1102
- firstPrompt: s.firstPrompt,
1103
- gitBranch: s.gitBranch,
1104
- cwd: s.cwd
1105
- }));
1106
- };
1107
1282
  function json(res, status, body) {
1108
1283
  const payload = JSON.stringify(body);
1109
1284
  res.writeHead(status, {
@@ -1123,6 +1298,18 @@ async function readJsonBody(req, maxBytes) {
1123
1298
  if (size === 0) return {};
1124
1299
  return JSON.parse(Buffer.concat(chunks).toString("utf8"));
1125
1300
  }
1301
+ /** Body as bytes, refusing anything over `maxBytes`. Attachments are the one
1302
+ * thing this server takes that isn't JSON. */
1303
+ async function readRawBody(req, maxBytes) {
1304
+ const chunks = [];
1305
+ let size = 0;
1306
+ for await (const chunk of req) {
1307
+ size += chunk.length;
1308
+ if (size > maxBytes) throw new Error("request body too large");
1309
+ chunks.push(chunk);
1310
+ }
1311
+ return Buffer.concat(chunks);
1312
+ }
1126
1313
  /**
1127
1314
  * Curated, view-only snapshot of a profile's config dir for GET /profiles/:name.
1128
1315
  * Best-effort: a missing or unparseable settings.json just omits the settings block.
@@ -1210,6 +1397,10 @@ function contentTypeFor(filename) {
1210
1397
  function isProviderProfile(profile) {
1211
1398
  return profile.engine === "provider";
1212
1399
  }
1400
+ /** The engine a profile runs, absent meaning 'claude' (pre-provider profiles). */
1401
+ function engineOf(profile) {
1402
+ return profile?.engine ?? "claude";
1403
+ }
1213
1404
  /** Where the CLI's own resolution lands for a given environment: an explicit
1214
1405
  * CLAUDE_CONFIG_DIR, else ~/.claude. */
1215
1406
  function cliConfigDir(env) {
@@ -1264,6 +1455,8 @@ function createWorkerServer(options = {}) {
1264
1455
  const basePath = options.basePath ?? "/v1";
1265
1456
  const fallback = options.fallback;
1266
1457
  const maxBodyBytes = options.maxBodyBytes ?? 1024 * 1024;
1458
+ /** The engine's adapter, honoring the test-only `engines` override. */
1459
+ const adapterFor = (engine) => options.engines?.[engine ?? "claude"] ?? getEngineAdapter(engine);
1267
1460
  const hostBuildRunnerConfig = options.buildRunnerConfig ?? ((req) => req);
1268
1461
  const declared = options.profiles ?? detectDefaultProfiles();
1269
1462
  const declaredByName = new Map(declared.map((p) => [p.name, p]));
@@ -1277,10 +1470,13 @@ function createWorkerServer(options = {}) {
1277
1470
  if (isProviderProfile(p)) {
1278
1471
  if (!p.provider?.id) return `provider profile '${p.name}' is missing provider.id`;
1279
1472
  if (!options.createEngineRunner) return `profile '${p.name}' uses engine 'provider' but no \`createEngineRunner\` was provided to build one`;
1473
+ } else if (p.engine === "codex") {
1474
+ if (p.codexHome && !existsSync(p.codexHome)) return `profile '${p.name}' codexHome does not exist: ${p.codexHome}`;
1475
+ if (p.session?.instructions) return `profile '${p.name}' declares session.instructions, which the codex engine cannot deliver — put instructions in the target repo’s AGENTS.md instead`;
1280
1476
  } else if (!p.configDir || !existsSync(p.configDir)) return `profile '${p.name}' configDir does not exist: ${p.configDir}`;
1281
1477
  if (options.disableBypassPermissions && p.defaults?.permissionMode === "bypassPermissions") return `profile '${p.name}' defaults to bypassPermissions but disableBypassPermissions is set`;
1282
1478
  const fallbackMode = p.defaults?.permissionMode;
1283
- 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(", ")})`;
1479
+ if (fallbackMode && !supportsPermissionMode(p.engine, fallbackMode)) return `profile '${p.name}' defaults to permission mode '${fallbackMode}', which engine '${engineOf(p)}' does not support (supported: ${adapterFor(engineOf(p)).capabilities.permissionModes.join(", ")})`;
1284
1480
  return null;
1285
1481
  };
1286
1482
  for (const p of options.profiles ?? []) {
@@ -1304,6 +1500,31 @@ function createWorkerServer(options = {}) {
1304
1500
  ...p,
1305
1501
  managed: true
1306
1502
  };
1503
+ /**
1504
+ * Response shape for a profile: the managed marker, the engine's capability
1505
+ * record, its static model catalog (correct from the first request — no
1506
+ * warm-up session, no process spawned), the availability verdict when one
1507
+ * has been probed, and the learned default model (the one thing a static
1508
+ * catalog cannot know: a claude profile's default is the operator's CLI
1509
+ * config, so it stays absent until a session on the profile reports it).
1510
+ * Read-only decoration — never persisted.
1511
+ */
1512
+ const forResponse = (p) => {
1513
+ const adapter = adapterFor(p.engine);
1514
+ const base = {
1515
+ ...withManagedFlag(p),
1516
+ capabilities: adapter.capabilities
1517
+ };
1518
+ if (adapter.catalog.models.length > 0) base.models = adapter.catalog.models;
1519
+ const defaultModel = profileDefaultModels.get(p.name);
1520
+ if (defaultModel) base.defaultModel = defaultModel;
1521
+ const probed = availability.get(p.name)?.verdict;
1522
+ if (probed && probed.available !== "unknown") {
1523
+ base.available = probed.available;
1524
+ if (probed.available === false) base.unavailableReason = probed.reason;
1525
+ }
1526
+ return base;
1527
+ };
1307
1528
  /** Declared profiles first: a name collision means the code wins, and the stored
1308
1529
  * one is unreachable rather than silently overriding server options. */
1309
1530
  const allProfiles = () => [...declared, ...[...stored.values()].filter((p) => !declaredByName.has(p.name))];
@@ -1377,28 +1598,42 @@ function createWorkerServer(options = {}) {
1377
1598
  * into 'default' by whatever assembles its runner. Returns an error message. */
1378
1599
  const checkPermissionMode = (mode, profile) => {
1379
1600
  if (mode === void 0 || supportsPermissionMode(profile?.engine, mode)) return null;
1380
- return `permission mode '${mode}' is not supported by profile '${profile.name}' (engine '${profile.engine}') — supported: ${PROVIDER_PERMISSION_MODES.join(", ")}`;
1601
+ return `permission mode '${mode}' is not supported by profile '${profile.name}' (engine '${engineOf(profile)}') — supported: ` + adapterFor(profile?.engine).capabilities.permissionModes.join(", ");
1381
1602
  };
1382
1603
  /**
1383
- * Enforce the provider engine's grant rules on a create request. Two of them:
1384
- *
1385
- * - Capabilities narrow, never widen. A request may run with fewer than the
1386
- * profile grants; naming one it doesn't is refused rather than quietly
1387
- * downgraded, so a caller learns instead of wondering where the tool went.
1388
- * - MCP servers are the profile's to declare. MCP tools are authoritative —
1389
- * server-side, with server credentials, never bridged so honoring a
1390
- * client-supplied server would let a caller point an authoritative tool
1391
- * anywhere it liked. The profile names servers; the host holds their configs.
1604
+ * Refuse the request fields the resolved profile's engine cannot honor
1605
+ * read off its capability record, so the create form's filtering and the
1606
+ * API boundary can never disagree. Refusing beats coercing: a caller who
1607
+ * asked for something the engine has no meaning for should be told, not
1608
+ * left wondering where the option went. Also enforces the provider grant
1609
+ * rules (capabilities narrow, never widen; MCP servers are the profile's to
1610
+ * declare MCP tools are authoritative, server-side, with server
1611
+ * credentials, so honoring a client-supplied server would let a caller
1612
+ * point an authoritative tool anywhere it liked).
1392
1613
  */
1393
1614
  const checkEngineGrants = (req, profile) => {
1615
+ const engine = engineOf(profile);
1616
+ const caps = adapterFor(profile?.engine).capabilities;
1617
+ const name = profile?.name ?? "default";
1618
+ if (!caps.sessionMcpServers && req.mcpServers && Object.keys(req.mcpServers).length > 0) return `profile '${name}' runs the ${engine} engine, whose MCP servers are declared outside the session request — a request cannot add its own`;
1619
+ if (!caps.budgets && (req.maxTurns !== void 0 || req.maxBudgetUsd !== void 0)) return `the ${engine} engine does not honor maxTurns/maxBudgetUsd`;
1620
+ if (!caps.settingSources && req.settingSources !== void 0) return `the ${engine} engine does not load settingSources`;
1621
+ if (!caps.resume && req.resume !== void 0) return `the ${engine} engine cannot resume a session`;
1622
+ if (req.forkSession && engine !== "claude") return `the ${engine} engine cannot fork a resumed session`;
1623
+ if (req.reasoningEffort !== void 0 && (!caps.reasoningEfforts || caps.reasoningEfforts.length === 0)) return `the ${engine} engine does not take a reasoningEffort`;
1394
1624
  if (!profile || !isProviderProfile(profile)) return null;
1395
- 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`;
1396
1625
  const granted = profile.session?.capabilities;
1397
1626
  if (!req.capabilities || !granted) return null;
1398
1627
  const ungranted = req.capabilities.filter((c) => !granted.includes(c));
1399
1628
  if (ungranted.length === 0) return null;
1400
1629
  return `profile '${profile.name}' does not grant: ${ungranted.join(", ")} (granted: ${granted.join(", ") || "none"}) — a request may narrow capabilities, not widen them`;
1401
1630
  };
1631
+ /** Drop request fields that are meaningless (not wrong) for the engine —
1632
+ * today just `questionBehavior` where no approval channel exists, so job
1633
+ * webhooks never grow phantom permission_requested expectations. */
1634
+ const stripInertFields = (req, profile) => {
1635
+ if (!adapterFor(profile?.engine).capabilities.interactiveApprovals) delete req.questionBehavior;
1636
+ };
1402
1637
  /** Profile-aware config hook: fill the profile's defaults into unset request fields,
1403
1638
  * run the host hook, then pin CLAUDE_CONFIG_DIR — the profile wins even when the
1404
1639
  * host hook set its own env (see `claudeSessionEnv` for the one case the pin is
@@ -1411,7 +1646,7 @@ function createWorkerServer(options = {}) {
1411
1646
  model: req.model ?? profile.defaults?.model ?? profile.provider?.model,
1412
1647
  permissionMode: req.permissionMode ?? profile.defaults?.permissionMode
1413
1648
  });
1414
- if (isProviderProfile(profile)) return config;
1649
+ if (engineOf(profile) !== "claude") return config;
1415
1650
  const base = config.env ?? process.env;
1416
1651
  const env = claudeSessionEnv(profile, base);
1417
1652
  return env === base ? config : {
@@ -1435,8 +1670,11 @@ function createWorkerServer(options = {}) {
1435
1670
  bridge,
1436
1671
  restore
1437
1672
  });
1438
- if (restore) throw new Error("the Claude engine cannot rebuild a parked session");
1439
- return new Promise((resolve) => resolve(registry.prepare(config)));
1673
+ return adapterFor(profile?.engine).createRunner({
1674
+ config,
1675
+ profile,
1676
+ restore
1677
+ });
1440
1678
  };
1441
1679
  const createRunner = async (config) => {
1442
1680
  const runner = registry.register(await buildRunner(config));
@@ -1483,7 +1721,26 @@ function createWorkerServer(options = {}) {
1483
1721
  };
1484
1722
  };
1485
1723
  const notifier = new SessionNotifier(options.notifications ?? {});
1486
- const registry = new SessionRegistry({ onRegister: (runner) => notifier.watch(runner) });
1724
+ /**
1725
+ * What each claude profile's *default* model resolves to, learned from the
1726
+ * `capabilities` events of sessions that ran on it. The model *list* is the
1727
+ * adapter's static catalog now; the default is the one thing a catalog
1728
+ * cannot know (it is the operator's CLI config), so it alone is still
1729
+ * learned — and still absent on a cold server, the accepted regression.
1730
+ */
1731
+ const profileDefaultModels = /* @__PURE__ */ new Map();
1732
+ const producedFiles = new ProducedFileStore();
1733
+ const registry = new SessionRegistry({ onRegister: (runner) => {
1734
+ notifier.watch(runner);
1735
+ producedFiles.watch(runner);
1736
+ const profile = runner.info().profile;
1737
+ if (!profile) return;
1738
+ runner.subscribe((event) => {
1739
+ if (event.type !== "capabilities" || !event.defaultModel) return;
1740
+ profileDefaultModels.set(profile, event.defaultModel);
1741
+ });
1742
+ } });
1743
+ const attachmentStore = new AttachmentStore(options.attachments);
1487
1744
  const bridge = new BridgeHub({
1488
1745
  ...options.bridge,
1489
1746
  onResult: (sessionId, executionId, result) => {
@@ -1556,31 +1813,70 @@ function createWorkerServer(options = {}) {
1556
1813
  });
1557
1814
  };
1558
1815
  /**
1559
- * Probe each Claude profile's credentials the way its sessions will actually
1560
- * experience them: the env the real assembly path produces, so anything the
1561
- * host hook injects (a CLAUDE_CODE_OAUTH_TOKEN, say) counts as logged in.
1562
- * Provider profiles resolve credentials in the engine factory and are not
1563
- * probed. Fire-and-forget by design — see the `checkCredentials` option doc.
1816
+ * Availability, per profile: the adapter's probe run over the env the real
1817
+ * assembly path produces (so anything the host hook injects — a
1818
+ * CLAUDE_CODE_OAUTH_TOKEN, say counts as logged in). Cached, and served on
1819
+ * `GET /profiles` as `available`/`unavailableReason`.
1820
+ *
1821
+ * Gated on `checkCredentials` like the old claude-only preflight (this is a
1822
+ * library; `pnpm test` must spawn nothing unless a test injects fake
1823
+ * adapters or probes). 'unknown' stays out of the cache's answers: a probe
1824
+ * that couldn't run is not evidence of a missing login. **Display-only**
1825
+ * downstream — session create against an unavailable profile still proceeds
1826
+ * and fails with the engine's own error, because the probe can be stale in
1827
+ * both directions and refusing on it would turn a probe bug into an outage.
1564
1828
  */
1565
- const preflightCredentials = () => {
1829
+ const availability = /* @__PURE__ */ new Map();
1830
+ const AVAILABILITY_TTL_MS = 6e4;
1831
+ /** Profiles already warned about on the console, so re-probes don't spam. */
1832
+ const availabilityWarned = /* @__PURE__ */ new Set();
1833
+ const sessionEnvFor = (profile) => {
1834
+ try {
1835
+ return buildRunnerConfig({
1836
+ cwd: process.cwd(),
1837
+ profile: profile.name
1838
+ }).env ?? process.env;
1839
+ } catch {
1840
+ return engineOf(profile) === "claude" ? claudeSessionEnv(profile, process.env) : process.env;
1841
+ }
1842
+ };
1843
+ const probeProfile = (profile) => {
1566
1844
  if (!options.checkCredentials) return;
1567
1845
  const conf = options.checkCredentials === true ? {} : options.checkCredentials;
1568
- const probe = conf.probe ?? ((env) => checkClaudeAuth(env, { timeoutMs: conf.timeoutMs }));
1569
- for (const profile of allProfiles()) {
1570
- if (isProviderProfile(profile)) continue;
1571
- let env;
1572
- try {
1573
- env = buildRunnerConfig({
1574
- cwd: process.cwd(),
1575
- profile: profile.name
1576
- }).env ?? process.env;
1577
- } catch {
1578
- env = claudeSessionEnv(profile, process.env);
1846
+ availability.set(profile.name, {
1847
+ verdict: availability.get(profile.name)?.verdict ?? { available: "unknown" },
1848
+ at: Date.now()
1849
+ });
1850
+ const adapter = adapterFor(profile.engine);
1851
+ const claudeProbe = engineOf(profile) !== "claude" ? void 0 : conf.probe ?? (conf.timeoutMs !== void 0 ? (env) => checkClaudeAuth(env, { timeoutMs: conf.timeoutMs }) : void 0);
1852
+ (claudeProbe ? claudeProbe(sessionEnvFor(profile)).then((status) => status === "logged_in" ? { available: true } : status === "logged_out" ? {
1853
+ available: false,
1854
+ reason: "no usable Claude credentials for this profile"
1855
+ } : { available: "unknown" }) : adapter.checkAvailability(profile, sessionEnvFor(profile))).then((verdict) => {
1856
+ availability.set(profile.name, {
1857
+ verdict,
1858
+ at: Date.now()
1859
+ });
1860
+ if (verdict.available === false && !availabilityWarned.has(profile.name)) {
1861
+ availabilityWarned.add(profile.name);
1862
+ console.warn(`[workerdeck] Profile '${profile.name}' is unavailable: ${verdict.reason} (\`checkCredentials: false\` disables this check)`);
1579
1863
  }
1580
- probe(env).then((status) => {
1581
- if (status !== "logged_out") return;
1582
- 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.`);
1583
- }).catch(() => {});
1864
+ if (verdict.available === true) availabilityWarned.delete(profile.name);
1865
+ }).catch(() => {});
1866
+ };
1867
+ /** Launch-time sweep, concurrent and fire-and-forget. */
1868
+ const preflightCredentials = () => {
1869
+ for (const profile of allProfiles()) probeProfile(profile);
1870
+ };
1871
+ /** Lazy re-probe on reads, so an operator who just ran `codex login` (or
1872
+ * exported a key) sees the profile go green without a restart. Serves the
1873
+ * cached verdict now; the refreshed one lands on the next request. */
1874
+ const refreshAvailability = (profiles) => {
1875
+ if (!options.checkCredentials) return;
1876
+ const now = Date.now();
1877
+ for (const profile of profiles) {
1878
+ const cached = availability.get(profile.name);
1879
+ if (!cached || now - cached.at > AVAILABILITY_TTL_MS) probeProfile(profile);
1584
1880
  }
1585
1881
  };
1586
1882
  const authenticate = async (req) => {
@@ -1609,6 +1905,21 @@ function createWorkerServer(options = {}) {
1609
1905
  id: decodeURIComponent(parts[0]),
1610
1906
  permissionId: decodeURIComponent(parts[2])
1611
1907
  };
1908
+ if (parts.length <= 3 && parts[1] === "attachments") return {
1909
+ id: decodeURIComponent(parts[0]),
1910
+ attachments: true,
1911
+ attachmentId: parts[2] === void 0 ? void 0 : decodeURIComponent(parts[2])
1912
+ };
1913
+ if (parts.length <= 3 && parts[1] === "produced") return {
1914
+ id: decodeURIComponent(parts[0]),
1915
+ produced: true,
1916
+ producedFileId: parts[2] === void 0 ? void 0 : decodeURIComponent(parts[2])
1917
+ };
1918
+ if (parts.length <= 3 && parts[1] === "mcp") return {
1919
+ id: decodeURIComponent(parts[0]),
1920
+ mcp: true,
1921
+ mcpServer: parts[2] === void 0 ? void 0 : decodeURIComponent(parts[2])
1922
+ };
1612
1923
  if (parts.length >= 2 && parts[1] === "files") {
1613
1924
  const filePath = parts.slice(2).map(decodeURIComponent).join("/");
1614
1925
  return {
@@ -1625,6 +1936,172 @@ function createWorkerServer(options = {}) {
1625
1936
  const maxHostFileBytes = options.hostFiles?.maxFileBytes ?? 1024 * 1024;
1626
1937
  const maxHostDirEntries = options.hostFiles?.maxEntries ?? 5e3;
1627
1938
  /**
1939
+ * `{basePath}/sessions/:id/attachments` — the files a client sends with a message.
1940
+ *
1941
+ * `POST ?name=<name>` takes the raw bytes as the body and the media type from
1942
+ * the `content-type` header; there is no multipart parsing here on purpose, so
1943
+ * a phone and a browser both upload with one plain request and this file stays
1944
+ * dependency-free. `GET /:attachmentId` hands the bytes back for thumbnails.
1945
+ *
1946
+ * The download always answers `content-disposition: attachment` and `nosniff`,
1947
+ * the same as `/files`: an upload is client-supplied content served from the
1948
+ * gateway's own origin, and it must never render as a document there. (An
1949
+ * `<img src>` is unaffected — disposition does not apply to subresources.)
1950
+ */
1951
+ const handleAttachments = async (req, res, sessionId, session, attachmentId) => {
1952
+ if (req.method === "POST" && attachmentId === void 0) {
1953
+ const url = new URL(req.url ?? "/", "http://internal");
1954
+ const mediaType = req.headers["content-type"];
1955
+ if (!mediaType) {
1956
+ json(res, 400, { error: "content-type header is required" });
1957
+ return;
1958
+ }
1959
+ const accepted = (session.capabilities ?? ENGINE_CAPABILITIES[session.engine ?? "claude"]).attachments;
1960
+ const kind = attachmentKind(mediaType);
1961
+ if (kind && !accepted.includes(kind === "document" ? "pdf" : kind)) {
1962
+ json(res, 415, { error: `the ${session.engine ?? "claude"} engine does not accept ${kind} attachments` });
1963
+ return;
1964
+ }
1965
+ let body;
1966
+ try {
1967
+ body = await readRawBody(req, attachmentStore.maxFileBytes);
1968
+ } catch {
1969
+ json(res, 413, { error: "attachment is larger than the limit" });
1970
+ return;
1971
+ }
1972
+ const result = attachmentStore.put(sessionId, url.searchParams.get("name") ?? "attachment", mediaType, body);
1973
+ if (!result.ok) {
1974
+ json(res, result.error.code === "unsupported_type" ? 415 : result.error.code === "empty" ? 400 : 413, { error: result.error.message });
1975
+ return;
1976
+ }
1977
+ json(res, 201, { attachment: result.attachment });
1978
+ return;
1979
+ }
1980
+ if (req.method === "GET" && attachmentId !== void 0) {
1981
+ const found = attachmentStore.get(sessionId, attachmentId);
1982
+ if (!found) {
1983
+ json(res, 404, { error: "attachment not found" });
1984
+ return;
1985
+ }
1986
+ const bytes = Buffer.from(found.data, "base64");
1987
+ res.writeHead(200, {
1988
+ "content-type": found.mediaType,
1989
+ "content-length": bytes.length,
1990
+ "content-disposition": `attachment; filename*=UTF-8''${encodeURIComponent(found.name)}`,
1991
+ "x-content-type-options": "nosniff"
1992
+ });
1993
+ res.end(bytes);
1994
+ return;
1995
+ }
1996
+ json(res, 405, { error: "method not allowed" });
1997
+ };
1998
+ /**
1999
+ * `{basePath}/sessions/:id/mcp` — the session's MCP servers, and the three
2000
+ * things the CLI's own `/mcp` screen can do to one (reconnect, enable, disable).
2001
+ *
2002
+ * Every answer goes through `mcpStatusInfo`, which is where the servers' `env`
2003
+ * and `headers` are dropped: reading this route must not be a way to read the
2004
+ * operator's API tokens.
2005
+ */
2006
+ const handleMcp = async (req, res, runner, serverName) => {
2007
+ const listServers = async () => {
2008
+ const servers = await runner.mcpServers?.();
2009
+ if (!servers) {
2010
+ json(res, 501, { error: "this session does not report MCP servers" });
2011
+ return false;
2012
+ }
2013
+ json(res, 200, { servers });
2014
+ return true;
2015
+ };
2016
+ if (req.method === "GET" && serverName === void 0) {
2017
+ await listServers();
2018
+ return;
2019
+ }
2020
+ if (req.method === "POST" && serverName !== void 0) {
2021
+ const body = await readJsonBody(req, maxBodyBytes);
2022
+ if (body?.action !== "reconnect" && body?.action !== "enable" && body?.action !== "disable") {
2023
+ json(res, 400, { error: "action must be 'reconnect', 'enable' or 'disable'" });
2024
+ return;
2025
+ }
2026
+ if (!(body.action === "reconnect" ? typeof runner.reconnectMcpServer === "function" : typeof runner.setMcpServerEnabled === "function")) {
2027
+ json(res, 501, { error: `this session's engine cannot ${body.action} an MCP server` });
2028
+ return;
2029
+ }
2030
+ try {
2031
+ if (body.action === "reconnect") await runner.reconnectMcpServer?.(serverName);
2032
+ else await runner.setMcpServerEnabled?.(serverName, body.action === "enable");
2033
+ } catch (error) {
2034
+ json(res, 400, { error: error instanceof Error ? error.message : "MCP action failed" });
2035
+ return;
2036
+ }
2037
+ await listServers();
2038
+ return;
2039
+ }
2040
+ json(res, 405, { error: "method not allowed" });
2041
+ };
2042
+ /**
2043
+ * `{basePath}/sessions/:id/produced[/:fileId]` — files this session's ENGINE
2044
+ * wrote on the host (codex's generated images), listed and served.
2045
+ *
2046
+ * The one route here with no root allowlist and no byte cap, and the comment
2047
+ * on {@link ProducedFileStore} is the argument for why that is right rather
2048
+ * than lax: the allowlist is the exact set of paths this session's own runner
2049
+ * announced producing. It is emphatically NOT a hole in `/fs/*` — a path the
2050
+ * *agent* named is not a produced file and never enters this store.
2051
+ *
2052
+ * Everything else matches the attachment download: `nosniff` and an attachment
2053
+ * disposition, because these bytes are model-authored and must not render as a
2054
+ * document on the gateway's origin. (`<img src>` is unaffected — disposition
2055
+ * does not apply to subresources, which is the whole point.)
2056
+ */
2057
+ const handleProducedFiles = async (req, res, sessionId, fileId) => {
2058
+ if (req.method !== "GET") {
2059
+ json(res, 405, { error: "method not allowed" });
2060
+ return;
2061
+ }
2062
+ if (fileId === void 0) {
2063
+ json(res, 200, { files: producedFiles.list(sessionId).map(({ fileId: id, path, mediaType, bytes }) => ({
2064
+ fileId: id,
2065
+ path,
2066
+ ...mediaType ? { mediaType } : {},
2067
+ ...bytes !== void 0 ? { bytes } : {}
2068
+ })) });
2069
+ return;
2070
+ }
2071
+ const found = producedFiles.get(sessionId, fileId);
2072
+ if (!found) {
2073
+ json(res, 404, { error: "no such produced file" });
2074
+ return;
2075
+ }
2076
+ let stat;
2077
+ try {
2078
+ stat = statSync(found.path);
2079
+ } catch {
2080
+ json(res, 404, { error: "produced file is no longer on disk" });
2081
+ return;
2082
+ }
2083
+ if (!stat.isFile()) {
2084
+ json(res, 404, { error: "produced file is not a regular file" });
2085
+ return;
2086
+ }
2087
+ const filename = basename(found.path) || "file";
2088
+ res.writeHead(200, {
2089
+ "content-type": found.mediaType ?? contentTypeFor(filename),
2090
+ "content-length": stat.size,
2091
+ "content-disposition": `attachment; filename*=UTF-8''${encodeURIComponent(filename)}`,
2092
+ "x-content-type-options": "nosniff"
2093
+ });
2094
+ await new Promise((done) => {
2095
+ const stream = createReadStream(found.path);
2096
+ stream.on("error", () => {
2097
+ res.destroy();
2098
+ done();
2099
+ });
2100
+ stream.on("close", () => done());
2101
+ stream.pipe(res);
2102
+ });
2103
+ };
2104
+ /**
1628
2105
  * `{basePath}/fs/*` — the operator's real tree. Authorized by the auth key alone
1629
2106
  * and deliberately outside the agent permission flow: the caller is the operator.
1630
2107
  *
@@ -1847,8 +2324,19 @@ function createWorkerServer(options = {}) {
1847
2324
  }
1848
2325
  json(res, 404, { error: "not found" });
1849
2326
  };
1850
- const listSdkSessions = options.listSdkSessions ?? defaultSdkSessionLister;
1851
- const handleSdkSessions = async (req, res) => {
2327
+ /**
2328
+ * `GET /sdk-sessions`, engine-aware: `?profile=` names whose on-disk store to
2329
+ * list, and the profile's engine adapter answers (for codex, over a
2330
+ * short-lived `thread/list` child — no live session involved). Absent
2331
+ * `profile`, the choice is implicit when the server declares exactly one
2332
+ * profile (the resolveProfile rule); with several, the Claude engine's
2333
+ * global store is listed — the pre-engine-aware behavior every existing
2334
+ * caller already gets, kept because old clients cannot answer a new 400.
2335
+ * The injectable `listSdkSessions` option predates the adapter layer and is
2336
+ * honored for the claude engine only (existing tests and hosts wire it),
2337
+ * exactly like the injectable claude auth probe.
2338
+ */
2339
+ const handleSdkSessions = async (req, res, auth) => {
1852
2340
  if (req.method !== "GET") {
1853
2341
  json(res, 405, { error: "method not allowed" });
1854
2342
  return;
@@ -1856,21 +2344,58 @@ function createWorkerServer(options = {}) {
1856
2344
  const url = new URL(req.url ?? "/", "http://internal");
1857
2345
  const dir = url.searchParams.get("dir") ?? void 0;
1858
2346
  const roots = options.allowedCwdRoots;
1859
- if (roots && roots.length > 0) {
1860
- if (!dir) {
1861
- json(res, 400, { error: "dir is required when allowedCwdRoots is set" });
2347
+ const limit = Number(url.searchParams.get("limit") ?? "") || void 0;
2348
+ const offset = Number(url.searchParams.get("offset") ?? "") || void 0;
2349
+ const requested = url.searchParams.get("profile") ?? void 0;
2350
+ let profile;
2351
+ if (requested !== void 0) {
2352
+ const resolved = resolveProfile(requested, auth.allowedProfiles);
2353
+ if (!resolved.ok) {
2354
+ json(res, resolved.status, { error: resolved.error });
1862
2355
  return;
1863
2356
  }
1864
- if (!cwdAllowed(dir, roots)) {
1865
- json(res, 403, { error: "dir is outside the allowed roots" });
2357
+ profile = resolved.profile;
2358
+ } else {
2359
+ const all = allProfiles();
2360
+ if (all.length === 1 && (!auth.allowedProfiles || auth.allowedProfiles.includes(all[0].name))) profile = all[0];
2361
+ }
2362
+ const adapter = adapterFor(profile?.engine);
2363
+ if (!adapter.capabilities.listSessions) {
2364
+ json(res, 400, { error: `profile '${profile?.name ?? "default"}' runs the ${engineOf(profile)} engine, which has no browsable session store` });
2365
+ return;
2366
+ }
2367
+ const lister = engineOf(profile) === "claude" && options.listSdkSessions ? options.listSdkSessions : (params) => {
2368
+ if (!adapter.listSessions) throw new Error(`the ${engineOf(profile)} engine does not implement session listing`);
2369
+ return adapter.listSessions({
2370
+ ...params,
2371
+ profile,
2372
+ env: profile ? sessionEnvFor(profile) : process.env
2373
+ });
2374
+ };
2375
+ try {
2376
+ if (roots && roots.length > 0) if (dir) {
2377
+ if (!cwdAllowed(dir, roots)) {
2378
+ json(res, 403, { error: "dir is outside the allowed roots" });
2379
+ return;
2380
+ }
2381
+ } else {
2382
+ json(res, 200, { sdkSessions: withinRoots(await lister({}), roots, limit, offset) });
1866
2383
  return;
1867
2384
  }
2385
+ json(res, 200, { sdkSessions: await lister({
2386
+ dir,
2387
+ limit,
2388
+ offset
2389
+ }) });
2390
+ } catch (error) {
2391
+ json(res, 500, { error: error instanceof Error ? error.message : "failed to list sessions" });
1868
2392
  }
1869
- json(res, 200, { sdkSessions: await listSdkSessions({
1870
- dir,
1871
- limit: Number(url.searchParams.get("limit") ?? "") || void 0,
1872
- offset: Number(url.searchParams.get("offset") ?? "") || void 0
1873
- }) });
2393
+ };
2394
+ /** The sessions whose `cwd` is inside the roots, newest first, then paged. A
2395
+ * summary with no `cwd` cannot be shown to be inside them, so it is dropped. */
2396
+ const withinRoots = (sessions, roots, limit, offset = 0) => {
2397
+ const allowed = sessions.filter((s) => s.cwd !== void 0 && cwdAllowed(s.cwd, roots)).sort((a, b) => b.lastModified - a.lastModified);
2398
+ return limit === void 0 ? allowed.slice(offset) : allowed.slice(offset, offset + limit);
1874
2399
  };
1875
2400
  const handleJobs = async (req, res, pathname, auth) => {
1876
2401
  if (!queue) {
@@ -1924,6 +2449,7 @@ function createWorkerServer(options = {}) {
1924
2449
  json(res, 400, { error: badRequest });
1925
2450
  return;
1926
2451
  }
2452
+ stripInertFields(body.session, resolved.profile);
1927
2453
  body.session.profile = resolved.profile?.name;
1928
2454
  try {
1929
2455
  json(res, 201, { job: await queue.submit(body) });
@@ -2041,8 +2567,10 @@ function createWorkerServer(options = {}) {
2041
2567
  const rest = pathname.slice((basePath + "/profiles").length).replace(/^\//, "");
2042
2568
  if (rest === "") {
2043
2569
  if (req.method === "GET") {
2570
+ const visible = auth.allowedProfiles ? allProfiles().filter((p) => auth.allowedProfiles.includes(p.name)) : allProfiles();
2571
+ refreshAvailability(visible);
2044
2572
  json(res, 200, {
2045
- profiles: (auth.allowedProfiles ? allProfiles().filter((p) => auth.allowedProfiles.includes(p.name)) : allProfiles()).map(withManagedFlag),
2573
+ profiles: visible.map(forResponse),
2046
2574
  canManage: manageGuard(auth) === null
2047
2575
  });
2048
2576
  return;
@@ -2118,11 +2646,12 @@ function createWorkerServer(options = {}) {
2118
2646
  return;
2119
2647
  }
2120
2648
  if (pathname === basePath + "/sdk-sessions") {
2121
- if (!(await authenticate(req)).ok) {
2649
+ const auth = await authenticate(req);
2650
+ if (!auth.ok) {
2122
2651
  json(res, 401, { error: "unauthorized" });
2123
2652
  return;
2124
2653
  }
2125
- await handleSdkSessions(req, res);
2654
+ await handleSdkSessions(req, res, auth);
2126
2655
  return;
2127
2656
  }
2128
2657
  if (pathname.startsWith(basePath + "/fs/")) {
@@ -2173,6 +2702,7 @@ function createWorkerServer(options = {}) {
2173
2702
  json(res, 400, { error: badRequest });
2174
2703
  return;
2175
2704
  }
2705
+ stripInertFields(body, resolved.profile);
2176
2706
  body.profile = resolved.profile?.name;
2177
2707
  const runner = await createRunner(buildRunnerConfig(body));
2178
2708
  watchAuthSource(runner);
@@ -2188,6 +2718,18 @@ function createWorkerServer(options = {}) {
2188
2718
  json(res, 404, { error: "session not found" });
2189
2719
  return;
2190
2720
  }
2721
+ if (route.attachments) {
2722
+ await handleAttachments(req, res, route.id, runner?.info() ?? parked.info, route.attachmentId);
2723
+ return;
2724
+ }
2725
+ if (route.mcp) {
2726
+ if (!runner) {
2727
+ json(res, 409, { error: "session is parked (wake it before asking about MCP)" });
2728
+ return;
2729
+ }
2730
+ await handleMcp(req, res, runner, route.mcpServer);
2731
+ return;
2732
+ }
2191
2733
  if (route.files) {
2192
2734
  if (req.method !== "GET") {
2193
2735
  json(res, 405, { error: "method not allowed" });
@@ -2224,6 +2766,10 @@ function createWorkerServer(options = {}) {
2224
2766
  res.end(content);
2225
2767
  return;
2226
2768
  }
2769
+ if (route.produced) {
2770
+ await handleProducedFiles(req, res, route.id, route.producedFileId);
2771
+ return;
2772
+ }
2227
2773
  if (route.permissionId) {
2228
2774
  if (req.method !== "POST") {
2229
2775
  json(res, 405, { error: "method not allowed" });
@@ -2249,10 +2795,29 @@ function createWorkerServer(options = {}) {
2249
2795
  json(res, 200, { session: runner?.info() ?? parked.info });
2250
2796
  return;
2251
2797
  }
2798
+ if (req.method === "PATCH") {
2799
+ if (!runner) {
2800
+ json(res, 409, { error: "session is parked (wake it before renaming)" });
2801
+ return;
2802
+ }
2803
+ const body = await readJsonBody(req, maxBodyBytes);
2804
+ if (body?.title !== void 0) {
2805
+ if (body.title !== null && typeof body.title !== "string") {
2806
+ json(res, 400, { error: "title must be a string or null" });
2807
+ return;
2808
+ }
2809
+ const title = typeof body.title === "string" ? body.title.trim() : "";
2810
+ runner.setTitle(title || void 0);
2811
+ }
2812
+ json(res, 200, { session: runner.info() });
2813
+ return;
2814
+ }
2252
2815
  if (req.method === "DELETE") {
2253
2816
  registry.remove(route.id);
2254
2817
  bridge.remove(route.id);
2255
2818
  await parking.discard(route.id);
2819
+ attachmentStore.drop(route.id);
2820
+ producedFiles.drop(route.id);
2256
2821
  json(res, 200, { session: runner?.info() ?? {
2257
2822
  ...parked.info,
2258
2823
  status: "closed"
@@ -2356,9 +2921,16 @@ function createWorkerServer(options = {}) {
2356
2921
  };
2357
2922
  const handleCommand = async (frame, runner) => {
2358
2923
  switch (frame.type) {
2359
- case "user_message":
2360
- runner.sendMessage(frame.text);
2924
+ case "user_message": {
2925
+ if (!frame.attachmentIds?.length) {
2926
+ runner.sendMessage(frame.text);
2927
+ return;
2928
+ }
2929
+ const resolved = attachmentStore.resolve(runner.id, frame.attachmentIds);
2930
+ if (!resolved.ok) throw new Error(`unknown attachment(s): ${resolved.missing.join(", ")}`);
2931
+ runner.sendMessage(frame.text, resolved.attachments);
2361
2932
  return;
2933
+ }
2362
2934
  case "permission_decision":
2363
2935
  if (frame.behavior === "allow") runner.resolvePermission(frame.requestId, {
2364
2936
  behavior: "allow",
@@ -2478,6 +3050,6 @@ function createFileProfileStore(path = join(process.cwd(), ".workerdeck", "profi
2478
3050
  };
2479
3051
  }
2480
3052
  //#endregion
2481
- export { BridgeHub, MemorySessionStore, SessionNotifier, SessionParkManager, SessionRegistry, createFileProfileStore, createFileSessionStore, createMemoryProfileStore, createWorkerServer, toDurableRecord };
3053
+ export { AttachmentStore, BridgeHub, MemorySessionStore, ProducedFileStore, SessionNotifier, SessionParkManager, SessionRegistry, createFileProfileStore, createFileSessionStore, createMemoryProfileStore, createWorkerServer, toDurableRecord };
2482
3054
 
2483
3055
  //# sourceMappingURL=index.mjs.map