@workerdeck/server 2.12.0 → 2.14.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,14 +1,14 @@
1
+ import { createHash, randomBytes, randomUUID } from "node:crypto";
1
2
  import { createServer } from "node:http";
2
3
  import { WebSocketServer } from "ws";
3
- import { BrowserBridgeExecutor, PEER_MESSAGE_MAX_CHARS, PEER_RECENT_DEFAULT, PEER_RECENT_MAX, SessionRunner, attachmentKind, checkClaudeAuth, createEngineSession, getEngineAdapter, installPeerDirectory, isSlashCommand, normalizeMediaType, peerDirectoryHandle, peerSummary, recentLines } from "@workerdeck/core";
4
+ import { BrowserBridgeExecutor, PEER_MESSAGE_MAX_CHARS, PEER_RECENT_DEFAULT, PEER_RECENT_MAX, SHELL_REFUSAL, SessionRunner, attachmentKind, checkClaudeAuth, clampShellTail, createEngineSession, getEngineAdapter, installPeerDirectory, installShellDirectory, isSlashCommand, normalizeMediaType, peerDirectoryHandle, peerSummary, recentLines, shellDirectoryHandle, shellSummary, shellTail, ttyText } from "@workerdeck/core";
4
5
  import { JobQueue } from "@workerdeck/queue";
5
- import { ENGINE_CAPABILITIES, PEER_MENTION_MAX, PROTOCOL_VERSION, SHELL_COMMAND_MAX, costOfByModel, imagePartRef, isJobRun, mergeByModel, peerMentionKey, peerMentionSlug, scanPeerMentions, sessionState, setPricingOverrides, subscriptionComparison, supportsPermissionMode } from "@workerdeck/protocol";
6
- import { createHash, randomUUID } from "node:crypto";
6
+ import { ENGINE_CAPABILITIES, PEER_MENTION_MAX, PROTOCOL_VERSION, SHELL_ARTIFACT_MAX_BYTES, SHELL_ARTIFACT_TTL_MS, SHELL_ATTACH_REPLAY_BYTES, SHELL_COLS, SHELL_COMMAND_MAX, SHELL_INPUT_MAX, SHELL_LABEL_MAX, SHELL_LINGER_MS, SHELL_MAX_COLS, SHELL_MAX_ROWS, SHELL_MAX_RUNNING_PER_SESSION, SHELL_MAX_RUNNING_TOTAL, SHELL_MIN_COLS, SHELL_MIN_ROWS, SHELL_ROWS, SHELL_SPILL_BYTES, SHELL_TAIL_FLUSH_MS, SHELL_TAIL_RING_BYTES, costOfByModel, imagePartRef, isJobRun, mergeByModel, peerMentionKey, peerMentionSlug, pickCreateSessionRequest, scanPeerMentions, sessionState, setPricingOverrides, subscriptionComparison, supportsPermissionMode } from "@workerdeck/protocol";
7
+ import { arch, homedir, hostname, platform, tmpdir } from "node:os";
7
8
  import { closeSync, constants, createReadStream, existsSync, fstatSync, ftruncateSync, lstatSync, mkdirSync, openSync, readFileSync, readdirSync, realpathSync, renameSync, statSync, writeFileSync } from "node:fs";
8
- import { constants as constants$1, homedir } from "node:os";
9
9
  import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
10
- import { mkdir, readFile, readdir, rename, rm, writeFile } from "node:fs/promises";
11
- import { spawn } from "node:child_process";
10
+ import { mkdir, open, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
11
+ import { spawnSync } from "node:child_process";
12
12
  //#region src/lib/http.ts
13
13
  const CONTENT_TYPES = {
14
14
  json: "application/json; charset=utf-8",
@@ -61,6 +61,18 @@ function contentTypeFor(filename) {
61
61
  return CONTENT_TYPES[ext] ?? "text/plain; charset=utf-8";
62
62
  }
63
63
  //#endregion
64
+ //#region src/lib/machine-id.ts
65
+ let cached;
66
+ function machineId() {
67
+ cached ??= createHash("sha256").update([
68
+ hostname(),
69
+ platform(),
70
+ arch(),
71
+ homedir()
72
+ ].join("\0")).digest("hex").slice(0, 32);
73
+ return cached;
74
+ }
75
+ //#endregion
64
76
  //#region src/lib/profile-env.ts
65
77
  function isProviderProfile(profile) {
66
78
  return profile.engine === "provider";
@@ -183,6 +195,16 @@ function parseSessionRoute(basePath, url) {
183
195
  produced: true,
184
196
  producedFileId: parts[2] === void 0 ? void 0 : decodeURIComponent(parts[2])
185
197
  };
198
+ if (parts[1] === "shells" && parts.length <= 4) {
199
+ const action = parts[3];
200
+ if (action !== void 0 && action !== "output" && action !== "kill") return null;
201
+ return {
202
+ id: decodeURIComponent(parts[0]),
203
+ shells: true,
204
+ shellId: parts[2] === void 0 ? void 0 : decodeURIComponent(parts[2]),
205
+ shellAction: action
206
+ };
207
+ }
186
208
  if (parts.length === 3 && parts[1] === "project" && parts[2] === "icon") return {
187
209
  id: decodeURIComponent(parts[0]),
188
210
  projectIcon: true
@@ -751,34 +773,90 @@ async function handleHostFiles(ctx, req, res, pathname) {
751
773
  }
752
774
  json(res, 404, { error: "not found" });
753
775
  }
754
- //#endregion
755
- //#region src/routes/create-vet.ts
756
- function vetCreateRequest(ctx, req, auth) {
776
+ const HOST_ONLY_KEYS = new Set(Object.keys({
777
+ epoch: true,
778
+ queryFn: true,
779
+ env: true,
780
+ pathToClaudeCodeExecutable: true,
781
+ extraOptions: true,
782
+ instructions: true,
783
+ defaultApprovalTimeoutMs: true,
784
+ backfillHistory: true,
785
+ historyFn: true,
786
+ sessionInfoFn: true,
787
+ peers: true,
788
+ shells: true,
789
+ connectFn: true,
790
+ codexHome: true,
791
+ codexPathOverride: true,
792
+ languageModel: true,
793
+ tools: true,
794
+ maxSteps: true,
795
+ executor: true,
796
+ executableTools: true,
797
+ vfs: true,
798
+ executionLimits: true,
799
+ executionBackend: true,
800
+ toolTitles: true,
801
+ shouldApprove: true,
802
+ resolveModel: true,
803
+ reportMcpServers: true,
804
+ onClose: true,
805
+ restore: true
806
+ }));
807
+ function vetCreateRequest(ctx, body, auth) {
808
+ if (typeof body !== "object" || body === null || Array.isArray(body)) return {
809
+ ok: false,
810
+ status: 400,
811
+ error: "request body must be a JSON object"
812
+ };
813
+ const hostOnly = Object.keys(body).filter((key) => HOST_ONLY_KEYS.has(key));
814
+ if (hostOnly.length > 0) return {
815
+ ok: false,
816
+ status: 400,
817
+ error: `host-only runner config is not accepted on a session request: ${hostOnly.join(", ")} (set it from the gateway's buildRunnerConfig hook)`
818
+ };
819
+ const req = pickCreateSessionRequest(body);
757
820
  const { availability, factory } = ctx;
758
821
  const refusedScope = factory.applyScope(req, auth);
759
- if (refusedScope) return refusedScope;
822
+ if (refusedScope) return {
823
+ ok: false,
824
+ ...refusedScope
825
+ };
760
826
  const refused = factory.applyBypassPolicy(req);
761
827
  if (refused) return {
828
+ ok: false,
762
829
  status: 403,
763
830
  error: refused
764
831
  };
765
832
  const resolved = factory.resolveProfile(req.profile, auth.allowedProfiles);
766
833
  if (!resolved.ok) return {
834
+ ok: false,
767
835
  status: resolved.status,
768
836
  error: resolved.error
769
837
  };
770
838
  const unavailable = availability.checkAvailable(resolved.profile);
771
- if (unavailable) return unavailable;
839
+ if (unavailable) return {
840
+ ok: false,
841
+ ...unavailable
842
+ };
772
843
  const refusedCwd = factory.checkCwd(req, resolved.profile);
773
- if (refusedCwd) return refusedCwd;
844
+ if (refusedCwd) return {
845
+ ok: false,
846
+ ...refusedCwd
847
+ };
774
848
  const badRequest = factory.checkPermissionMode(req.permissionMode, resolved.profile) ?? factory.checkEngineGrants(req, resolved.profile);
775
849
  if (badRequest) return {
850
+ ok: false,
776
851
  status: 400,
777
852
  error: badRequest
778
853
  };
779
854
  factory.stripInertFields(req, resolved.profile);
780
855
  req.profile = resolved.profile?.name;
781
- return null;
856
+ return {
857
+ ok: true,
858
+ request: req
859
+ };
782
860
  }
783
861
  //#endregion
784
862
  //#region src/routes/jobs.ts
@@ -808,21 +886,28 @@ async function handleJobs(ctx, req, res, pathname, auth) {
808
886
  }
809
887
  if (req.method === "POST") {
810
888
  const body = await readJsonBody(req, ctx.maxBodyBytes);
811
- if (!body.session || typeof body.session !== "object") {
889
+ if (!body?.session || typeof body.session !== "object") {
812
890
  json(res, 400, { error: "session is required" });
813
891
  return;
814
892
  }
815
- if (!body.session.prompt || typeof body.session.prompt !== "string") {
893
+ const prompt = body.session.prompt;
894
+ if (!prompt || typeof prompt !== "string") {
816
895
  json(res, 400, { error: "session.prompt is required" });
817
896
  return;
818
897
  }
819
- const refusal = vetCreateRequest(ctx, body.session, auth);
820
- if (refusal) {
821
- json(res, refusal.status, { error: refusal.error });
898
+ const vetted = vetCreateRequest(ctx, body.session, auth);
899
+ if (!vetted.ok) {
900
+ json(res, vetted.status, { error: vetted.error });
822
901
  return;
823
902
  }
824
903
  try {
825
- json(res, 201, { job: await queue.submit(body) });
904
+ json(res, 201, { job: await queue.submit({
905
+ ...body,
906
+ session: {
907
+ ...vetted.request,
908
+ prompt
909
+ }
910
+ }) });
826
911
  } catch (error) {
827
912
  json(res, 400, { error: error instanceof Error ? error.message : "invalid job" });
828
913
  }
@@ -1028,7 +1113,8 @@ const EPHEMERAL_CONFIG_KEYS = [
1028
1113
  "historyFn",
1029
1114
  "extraOptions",
1030
1115
  "env",
1031
- "peers"
1116
+ "peers",
1117
+ "instructions"
1032
1118
  ];
1033
1119
  function toDurableRecord(record) {
1034
1120
  const config = { ...record.config };
@@ -1047,7 +1133,7 @@ function createFileSessionStore(options = {}) {
1047
1133
  try {
1048
1134
  raw = await readFile(path, "utf8");
1049
1135
  } catch (error) {
1050
- if (isMissing(error)) return null;
1136
+ if (isMissing$1(error)) return null;
1051
1137
  options.onError?.(error, {
1052
1138
  path,
1053
1139
  op: "read"
@@ -1102,7 +1188,7 @@ function createFileSessionStore(options = {}) {
1102
1188
  try {
1103
1189
  names = await readdir(dir);
1104
1190
  } catch (error) {
1105
- if (isMissing(error)) return [];
1191
+ if (isMissing$1(error)) return [];
1106
1192
  options.onError?.(error, {
1107
1193
  path: dir,
1108
1194
  op: "read"
@@ -1126,7 +1212,7 @@ function createFileSessionStore(options = {}) {
1126
1212
  await rm(path);
1127
1213
  return true;
1128
1214
  } catch (error) {
1129
- if (isMissing(error)) return false;
1215
+ if (isMissing$1(error)) return false;
1130
1216
  options.onError?.(error, {
1131
1217
  path,
1132
1218
  op: "delete"
@@ -1136,7 +1222,7 @@ function createFileSessionStore(options = {}) {
1136
1222
  }
1137
1223
  };
1138
1224
  }
1139
- function isMissing(error) {
1225
+ function isMissing$1(error) {
1140
1226
  return error.code === "ENOENT";
1141
1227
  }
1142
1228
  function parseRecord(value) {
@@ -1296,17 +1382,19 @@ const DEFAULT_TTL_MS = 3e4;
1296
1382
  const SWEEP_ABOVE = 256;
1297
1383
  var ProjectInfoService = class {
1298
1384
  #ttlMs;
1385
+ #decorate;
1299
1386
  #byCwd = /* @__PURE__ */ new Map();
1300
1387
  constructor(options = {}) {
1301
1388
  this.#ttlMs = options.ttlMs ?? DEFAULT_TTL_MS;
1389
+ this.#decorate = options.decorate ?? ((info) => info);
1302
1390
  }
1303
1391
  withProject(info) {
1304
- if (!info.cwd) return info;
1392
+ if (!info.cwd) return this.#decorate(info);
1305
1393
  const project = this.#resolve(info.cwd).project;
1306
- return project ? {
1394
+ return this.#decorate(project ? {
1307
1395
  ...info,
1308
1396
  project
1309
- } : info;
1397
+ } : info);
1310
1398
  }
1311
1399
  iconFor(cwd) {
1312
1400
  if (!cwd) return;
@@ -1455,6 +1543,1062 @@ function handleProjectIcon(projects, req, res, cwd) {
1455
1543
  res.end(read.data);
1456
1544
  }
1457
1545
  //#endregion
1546
+ //#region src/services/process-tree.ts
1547
+ const PS_ARGS = ["-eo", "pid=,ppid=,pgid="];
1548
+ const PS_TIMEOUT_MS = 2e3;
1549
+ const PS_MAX_BUFFER = 67108864;
1550
+ const MAX_ROUNDS = 5;
1551
+ function readProcessTable() {
1552
+ const result = spawnSync("ps", PS_ARGS, {
1553
+ encoding: "utf8",
1554
+ timeout: PS_TIMEOUT_MS,
1555
+ maxBuffer: PS_MAX_BUFFER,
1556
+ stdio: [
1557
+ "ignore",
1558
+ "pipe",
1559
+ "ignore"
1560
+ ]
1561
+ });
1562
+ if (result.error || result.status !== 0 || typeof result.stdout !== "string") return null;
1563
+ return parseProcessTable(result.stdout);
1564
+ }
1565
+ function parseProcessTable(text) {
1566
+ const rows = [];
1567
+ for (const line of text.split("\n")) {
1568
+ const fields = line.trim().split(/\s+/);
1569
+ if (fields.length !== 3) continue;
1570
+ const [pid, ppid, pgid] = fields.map(Number);
1571
+ if (Number.isInteger(pid) && Number.isInteger(ppid) && Number.isInteger(pgid)) rows.push({
1572
+ pid,
1573
+ ppid,
1574
+ pgid
1575
+ });
1576
+ }
1577
+ return rows;
1578
+ }
1579
+ function killProcessTrees(roots, deps) {
1580
+ const { self } = deps;
1581
+ const signal = (pid, name) => {
1582
+ try {
1583
+ deps.signal(pid, name);
1584
+ return true;
1585
+ } catch {
1586
+ return false;
1587
+ }
1588
+ };
1589
+ const owned = (pid) => Number.isInteger(pid) && pid > 1 && pid !== self;
1590
+ const live = new Set(roots.filter(owned));
1591
+ const closure = /* @__PURE__ */ new Map();
1592
+ const rootRows = /* @__PURE__ */ new Map();
1593
+ const foreign = [];
1594
+ let scanned = false;
1595
+ let selfPgid;
1596
+ for (let round = 0; round < MAX_ROUNDS; round++) {
1597
+ const table = readTable(deps.table);
1598
+ if (table === null) break;
1599
+ const byPid = /* @__PURE__ */ new Map();
1600
+ const byParent = /* @__PURE__ */ new Map();
1601
+ for (const row of table) {
1602
+ byPid.set(row.pid, row);
1603
+ const siblings = byParent.get(row.ppid);
1604
+ if (siblings) siblings.push(row);
1605
+ else byParent.set(row.ppid, [row]);
1606
+ }
1607
+ let grew = false;
1608
+ if (!scanned) {
1609
+ scanned = true;
1610
+ selfPgid = byPid.get(self)?.pgid;
1611
+ for (const root of live) {
1612
+ const row = byPid.get(root);
1613
+ if (row === void 0) continue;
1614
+ if (row.ppid !== self) {
1615
+ foreign.push(root);
1616
+ live.delete(root);
1617
+ continue;
1618
+ }
1619
+ rootRows.set(root, row);
1620
+ signal(-root, "SIGSTOP");
1621
+ grew = true;
1622
+ }
1623
+ }
1624
+ for (const root of rootRows.keys()) {
1625
+ const queue = [root];
1626
+ const seen = /* @__PURE__ */ new Set([root]);
1627
+ while (queue.length > 0) {
1628
+ const parent = queue.shift();
1629
+ for (const child of byParent.get(parent) ?? []) {
1630
+ if (!owned(child.pid) || seen.has(child.pid)) continue;
1631
+ seen.add(child.pid);
1632
+ queue.push(child.pid);
1633
+ if (!closure.has(child.pid)) {
1634
+ closure.set(child.pid, child);
1635
+ signal(child.pid, "SIGSTOP");
1636
+ grew = true;
1637
+ }
1638
+ }
1639
+ }
1640
+ }
1641
+ if (!grew) break;
1642
+ }
1643
+ const unreached = [];
1644
+ const groups = /* @__PURE__ */ new Set();
1645
+ for (const root of live) {
1646
+ groups.add(root);
1647
+ if (!signal(-root, "SIGKILL")) unreached.push(root);
1648
+ }
1649
+ for (const pid of closure.keys()) signal(pid, "SIGKILL");
1650
+ if (selfPgid !== void 0) for (const row of [...rootRows.values(), ...closure.values()]) {
1651
+ const group = row.pgid;
1652
+ if (group <= 1 || group === self || group === selfPgid || groups.has(group)) continue;
1653
+ groups.add(group);
1654
+ signal(-group, "SIGKILL");
1655
+ }
1656
+ return {
1657
+ scanned,
1658
+ descendants: closure.size,
1659
+ foreign,
1660
+ unreached
1661
+ };
1662
+ }
1663
+ function readTable(table) {
1664
+ try {
1665
+ return table();
1666
+ } catch {
1667
+ return null;
1668
+ }
1669
+ }
1670
+ //#endregion
1671
+ //#region src/services/shells.ts
1672
+ const NOTIFY_MS = 250;
1673
+ const SWEEP_INTERVAL_MS = 36e5;
1674
+ const ID_ALPHABET = "abcdefghijklmnopqrstuvwxyz234567";
1675
+ const ID_CHARS = 12;
1676
+ const TERM = "xterm-256color";
1677
+ const INDEX_SUFFIX = ".json";
1678
+ const INDEX_VERSION = 1;
1679
+ const CTTY_WRAPPER = "true <>/dev/tty 2>/dev/null; exec \"$0\" -c \"$1\"";
1680
+ let ptyModule;
1681
+ async function loadPty() {
1682
+ if (ptyModule !== void 0) return ptyModule;
1683
+ try {
1684
+ ptyModule = await import("@lydell/node-pty");
1685
+ } catch {
1686
+ ptyModule = null;
1687
+ }
1688
+ return ptyModule;
1689
+ }
1690
+ function createShellDirectory(registry) {
1691
+ return {
1692
+ list: async (from) => registry.list(from).map(shellSummary),
1693
+ read: async (from, shellId, options) => {
1694
+ const info = registry.get(from, shellId);
1695
+ if (!info) return;
1696
+ const text = await registry.output(from, shellId, { view: "text" });
1697
+ if (text === void 0) return;
1698
+ return {
1699
+ shell: shellSummary(info),
1700
+ ...shellTail(text, clampShellTail(options?.tail))
1701
+ };
1702
+ }
1703
+ };
1704
+ }
1705
+ function shellPermitted(shells, runner, operator) {
1706
+ if (shells === null || !operator) return false;
1707
+ const info = runner.info();
1708
+ return (info.capabilities ?? (info.engine ? ENGINE_CAPABILITIES[info.engine] : void 0))?.hostCwd === true;
1709
+ }
1710
+ function shellChildEnv(base) {
1711
+ const env = {};
1712
+ for (const [key, value] of Object.entries(base)) if (value !== void 0) env[key] = value;
1713
+ return env;
1714
+ }
1715
+ function clampSize(size) {
1716
+ return {
1717
+ cols: clamp(size.cols, SHELL_MIN_COLS, SHELL_MAX_COLS),
1718
+ rows: clamp(size.rows, SHELL_MIN_ROWS, SHELL_MAX_ROWS)
1719
+ };
1720
+ }
1721
+ function loginShell(env) {
1722
+ const shell = env.SHELL;
1723
+ return shell && shell.startsWith("/") ? shell : "/bin/sh";
1724
+ }
1725
+ function createShellRegistry(options) {
1726
+ const { generation } = options;
1727
+ const dir = options.artifactDir;
1728
+ const base = dir ?? join(tmpdir(), `workerdeck-shells-${process.pid}`);
1729
+ const limits = {
1730
+ spill: options.spillBytes ?? SHELL_SPILL_BYTES,
1731
+ cap: options.artifactMaxBytes ?? SHELL_ARTIFACT_MAX_BYTES,
1732
+ ring: options.tailRingBytes ?? SHELL_TAIL_RING_BYTES
1733
+ };
1734
+ const tailFlushMs = options.tailFlushMs ?? SHELL_TAIL_FLUSH_MS;
1735
+ const ttlMs = options.artifactTtlMs ?? SHELL_ARTIFACT_TTL_MS;
1736
+ const perSession = options.maxRunningPerSession ?? SHELL_MAX_RUNNING_PER_SESSION;
1737
+ const total = options.maxRunningTotal ?? SHELL_MAX_RUNNING_TOTAL;
1738
+ const sweepIntervalMs = options.sweepIntervalMs ?? SWEEP_INTERVAL_MS;
1739
+ const sessions = /* @__PURE__ */ new Map();
1740
+ const loading = /* @__PURE__ */ new Map();
1741
+ let sweeper;
1742
+ let stopped = false;
1743
+ const report = (error, context) => options.onError?.(error, context);
1744
+ const indexPath = (sessionId) => join(base, `${encodeURIComponent(sessionId)}${INDEX_SUFFIX}`);
1745
+ const pathsFor = (sessionId, shellId) => {
1746
+ const encoded = encodeURIComponent(sessionId);
1747
+ const sessionDir = join(base, encoded);
1748
+ return {
1749
+ dir: sessionDir,
1750
+ raw: join(sessionDir, `${shellId}.raw`),
1751
+ tail: join(sessionDir, `${shellId}.tail.raw`),
1752
+ rel: `${encoded}/${shellId}.raw`
1753
+ };
1754
+ };
1755
+ const find = (sessionId, id) => sessions.get(sessionId)?.entries.get(id);
1756
+ const runningAll = () => [...sessions.values()].flatMap(runningIn);
1757
+ const persist = (state) => {
1758
+ if (!dir || state.pending) return;
1759
+ state.pending = true;
1760
+ state.chain = state.chain.then(async () => {
1761
+ state.pending = false;
1762
+ const payload = JSON.stringify(serialize(state));
1763
+ await mkdir(dir, {
1764
+ recursive: true,
1765
+ mode: 448
1766
+ });
1767
+ const path = indexPath(state.sessionId);
1768
+ const temp = `${path}.${process.pid}.tmp`;
1769
+ await writeFile(temp, payload, { mode: 384 });
1770
+ await rename(temp, path);
1771
+ }).catch((error) => report(error, {
1772
+ op: "index",
1773
+ sessionId: state.sessionId
1774
+ }));
1775
+ };
1776
+ const readIndex = async (sessionId) => {
1777
+ const state = freshSession(sessionId);
1778
+ if (!dir) return state;
1779
+ let raw;
1780
+ try {
1781
+ raw = await readFile(indexPath(sessionId), "utf8");
1782
+ } catch (error) {
1783
+ if (!isMissing(error)) report(error, {
1784
+ op: "index",
1785
+ sessionId
1786
+ });
1787
+ return state;
1788
+ }
1789
+ let index;
1790
+ try {
1791
+ index = parseIndex(JSON.parse(raw));
1792
+ } catch (error) {
1793
+ report(error, {
1794
+ op: "index",
1795
+ sessionId
1796
+ });
1797
+ return state;
1798
+ }
1799
+ state.nextOrdinal = index.nextOrdinal;
1800
+ const now = Date.now();
1801
+ let dirty = false;
1802
+ for (const record of index.shells) {
1803
+ const { generation: recordGeneration, output, artifact, ...info } = record;
1804
+ if (info.status === "running" && recordGeneration !== generation) {
1805
+ info.status = "exited";
1806
+ info.endReason = "server_restarted";
1807
+ info.endedAt = now;
1808
+ delete info.exitCode;
1809
+ delete info.signal;
1810
+ dirty = true;
1811
+ }
1812
+ state.entries.set(info.id, {
1813
+ info,
1814
+ generation: recordGeneration,
1815
+ artifact: new StoredArtifact(pathsFor(sessionId, info.id), info, {
1816
+ output,
1817
+ artifact
1818
+ }),
1819
+ sinks: /* @__PURE__ */ new Set(),
1820
+ listeners: /* @__PURE__ */ new Set()
1821
+ });
1822
+ }
1823
+ if (dirty) persist(state);
1824
+ return state;
1825
+ };
1826
+ const load = (sessionId) => {
1827
+ const held = sessions.get(sessionId);
1828
+ if (held) return Promise.resolve(held);
1829
+ let inflight = loading.get(sessionId);
1830
+ if (!inflight) {
1831
+ inflight = readIndex(sessionId).then((state) => {
1832
+ sessions.set(sessionId, state);
1833
+ loading.delete(sessionId);
1834
+ return state;
1835
+ });
1836
+ loading.set(sessionId, inflight);
1837
+ }
1838
+ return inflight;
1839
+ };
1840
+ const fire = (entry) => {
1841
+ for (const listener of entry.listeners) try {
1842
+ listener();
1843
+ } catch (error) {
1844
+ report(error, {
1845
+ op: "listener",
1846
+ sessionId: entry.info.sessionId,
1847
+ shellId: entry.info.id
1848
+ });
1849
+ }
1850
+ };
1851
+ const scheduleNotify = (entry) => {
1852
+ if (entry.notify) return;
1853
+ entry.notify = setTimeout(() => {
1854
+ entry.notify = void 0;
1855
+ if (entry.info.status === "running") fire(entry);
1856
+ }, NOTIFY_MS);
1857
+ entry.notify.unref();
1858
+ };
1859
+ const settle = (state, entry, reason) => {
1860
+ if (entry.info.status !== "running") return;
1861
+ entry.info.status = "exited";
1862
+ entry.info.endedAt = Date.now();
1863
+ entry.info.endReason = reason;
1864
+ clearTimeout(entry.clock);
1865
+ clearTimeout(entry.notify);
1866
+ clearInterval(entry.tailTimer);
1867
+ entry.clock = entry.notify = entry.tailTimer = void 0;
1868
+ entry.child = void 0;
1869
+ entry.pid = void 0;
1870
+ if (entry.artifact instanceof LiveArtifact) entry.artifact.close();
1871
+ for (const sink of entry.sinks) try {
1872
+ sink.end(reason);
1873
+ } catch {}
1874
+ entry.sinks.clear();
1875
+ persist(state);
1876
+ fire(entry);
1877
+ };
1878
+ const treeDeps = {
1879
+ table: options.processTable ?? readProcessTable,
1880
+ signal: (pid, name) => void process.kill(pid, name),
1881
+ self: process.pid
1882
+ };
1883
+ const killTrees = (entries) => {
1884
+ const live = entries.flatMap((entry) => entry.pid === void 0 ? [] : [{
1885
+ entry,
1886
+ pid: entry.pid
1887
+ }]);
1888
+ if (live.length === 0) return;
1889
+ const result = killProcessTrees(live.map(({ pid }) => pid), treeDeps);
1890
+ for (const { entry, pid } of live) {
1891
+ if (result.unreached.includes(pid)) try {
1892
+ entry.child?.kill("SIGKILL");
1893
+ } catch {}
1894
+ const context = {
1895
+ op: "kill",
1896
+ sessionId: entry.info.sessionId,
1897
+ shellId: entry.info.id
1898
+ };
1899
+ if (!result.scanned) report(/* @__PURE__ */ new Error("no process table (ps), only the process group was signalled"), context);
1900
+ else if (result.foreign.includes(pid)) report(/* @__PURE__ */ new Error(`pid ${pid} is no longer this shell's child and was left alone`), context);
1901
+ }
1902
+ };
1903
+ const killEntries = (targets, reason) => {
1904
+ const running = targets.filter(({ entry }) => entry.info.status === "running");
1905
+ killTrees(running.map(({ entry }) => entry));
1906
+ for (const { state, entry } of running) settle(state, entry, reason);
1907
+ return running.length;
1908
+ };
1909
+ const kill = (state, entry, reason) => {
1910
+ killEntries([{
1911
+ state,
1912
+ entry
1913
+ }], reason);
1914
+ };
1915
+ const killSession = (sessionId, reason) => {
1916
+ const state = sessions.get(sessionId);
1917
+ if (!state) return;
1918
+ killEntries(runningIn(state).map((entry) => ({
1919
+ state,
1920
+ entry
1921
+ })), reason);
1922
+ };
1923
+ const spawn = async ({ runner, command, owner }) => {
1924
+ if (stopped) throw new Error("the gateway is shutting down");
1925
+ const sessionId = runner.id;
1926
+ const state = await load(sessionId);
1927
+ const pty = await loadPty();
1928
+ if (!pty) throw new Error(SHELL_REFUSAL);
1929
+ const cwd = runner.info().cwd;
1930
+ if (!(await stat(cwd).catch(() => void 0))?.isDirectory()) throw new Error(`the session's cwd is not a directory: ${cwd}`);
1931
+ const inSession = runningIn(state).length;
1932
+ if (inSession >= perSession) throw new Error(`this session already has ${inSession} shells running (the limit is ${perSession})`);
1933
+ const overall = runningAll().length;
1934
+ if (overall >= total) throw new Error(`the gateway already has ${overall} shells running (the limit is ${total})`);
1935
+ const id = mintShellId();
1936
+ const info = {
1937
+ id,
1938
+ sessionId,
1939
+ ordinal: state.nextOrdinal++,
1940
+ command,
1941
+ label: shellLabel(command),
1942
+ cwd,
1943
+ owner,
1944
+ status: "running",
1945
+ startedAt: Date.now(),
1946
+ bytes: 0,
1947
+ cols: SHELL_COLS,
1948
+ rows: SHELL_ROWS
1949
+ };
1950
+ const artifact = new LiveArtifact(pathsFor(sessionId, id), limits, (error) => report(error, {
1951
+ op: "artifact",
1952
+ sessionId,
1953
+ shellId: id
1954
+ }));
1955
+ const entry = {
1956
+ info,
1957
+ generation,
1958
+ artifact,
1959
+ sinks: /* @__PURE__ */ new Set(),
1960
+ listeners: /* @__PURE__ */ new Set()
1961
+ };
1962
+ state.entries.set(id, entry);
1963
+ const env = shellChildEnv(process.env);
1964
+ env.TERM = TERM;
1965
+ env.COLORTERM = "truecolor";
1966
+ env.PWD = cwd;
1967
+ let child;
1968
+ try {
1969
+ child = pty.spawn("/bin/sh", [
1970
+ "-c",
1971
+ CTTY_WRAPPER,
1972
+ loginShell(process.env),
1973
+ command
1974
+ ], {
1975
+ name: TERM,
1976
+ cols: SHELL_COLS,
1977
+ rows: SHELL_ROWS,
1978
+ cwd,
1979
+ env
1980
+ });
1981
+ } catch (error) {
1982
+ settle(state, entry, "spawn_failed");
1983
+ throw error instanceof Error ? error : /* @__PURE__ */ new Error("failed to start the shell");
1984
+ }
1985
+ entry.child = child;
1986
+ entry.pid = child.pid;
1987
+ child.onData((data) => {
1988
+ if (entry.info.status !== "running") return;
1989
+ const changed = artifact.append(data);
1990
+ entry.info.bytes = artifact.bytes;
1991
+ if (changed) {
1992
+ if (artifact.capped && !entry.info.capped) {
1993
+ entry.info.capped = true;
1994
+ entry.tailTimer = setInterval(() => artifact.writeTail(), tailFlushMs);
1995
+ entry.tailTimer.unref();
1996
+ }
1997
+ persist(state);
1998
+ }
1999
+ for (const sink of entry.sinks) try {
2000
+ sink.write(data);
2001
+ } catch {
2002
+ entry.sinks.delete(sink);
2003
+ }
2004
+ scheduleNotify(entry);
2005
+ });
2006
+ child.onExit(({ exitCode, signal }) => {
2007
+ if (entry.info.status !== "running") return;
2008
+ if (signal) entry.info.signal = signal;
2009
+ else entry.info.exitCode = exitCode;
2010
+ settle(state, entry, "exit");
2011
+ });
2012
+ if (options.timeoutMs !== void 0 && options.timeoutMs > 0) {
2013
+ entry.clock = setTimeout(() => kill(state, entry, "timeout"), options.timeoutMs);
2014
+ entry.clock.unref();
2015
+ }
2016
+ persist(state);
2017
+ return {
2018
+ shell: { ...info },
2019
+ source: sourceFor(entry, artifact)
2020
+ };
2021
+ };
2022
+ const sweep = async () => {
2023
+ const now = Date.now();
2024
+ for (const state of sessions.values()) {
2025
+ let dirty = false;
2026
+ for (const entry of state.entries.values()) {
2027
+ const { status, endedAt } = entry.info;
2028
+ if (status !== "exited" || endedAt === void 0 || now - endedAt < ttlMs) continue;
2029
+ state.entries.delete(entry.info.id);
2030
+ dirty = true;
2031
+ try {
2032
+ await entry.artifact.remove();
2033
+ } catch (error) {
2034
+ report(error, {
2035
+ op: "sweep",
2036
+ sessionId: state.sessionId,
2037
+ shellId: entry.info.id
2038
+ });
2039
+ }
2040
+ }
2041
+ if (dirty) persist(state);
2042
+ }
2043
+ };
2044
+ return {
2045
+ spawn,
2046
+ get: (sessionId, shellId) => {
2047
+ const entry = find(sessionId, shellId);
2048
+ return entry ? { ...entry.info } : void 0;
2049
+ },
2050
+ list: (sessionId) => {
2051
+ const state = sessions.get(sessionId);
2052
+ if (!state) return [];
2053
+ return [...state.entries.values()].map((entry) => ({ ...entry.info })).sort((a, b) => b.ordinal - a.ordinal);
2054
+ },
2055
+ running: () => runningAll().map((entry) => ({ ...entry.info })),
2056
+ kill: (sessionId, shellId, reason = "killed") => {
2057
+ const state = sessions.get(sessionId);
2058
+ const entry = state?.entries.get(shellId);
2059
+ if (!state || !entry) return;
2060
+ kill(state, entry, reason);
2061
+ return { ...entry.info };
2062
+ },
2063
+ killAll: (reason) => {
2064
+ stopped = true;
2065
+ if (sweeper) {
2066
+ clearInterval(sweeper);
2067
+ sweeper = void 0;
2068
+ }
2069
+ return killEntries([...sessions.values()].flatMap((state) => runningIn(state).map((entry) => ({
2070
+ state,
2071
+ entry
2072
+ }))), reason);
2073
+ },
2074
+ killAllSync: () => {
2075
+ killTrees(runningAll());
2076
+ },
2077
+ attach: async (sessionId, shellId, sink) => {
2078
+ const entry = find(sessionId, shellId);
2079
+ if (!entry) throw new Error("unknown shell");
2080
+ const pending = [];
2081
+ let live = false;
2082
+ let ended;
2083
+ const proxy = {
2084
+ write: (data) => {
2085
+ if (live) sink.write(data);
2086
+ else pending.push(data);
2087
+ },
2088
+ end: (reason) => {
2089
+ if (live) sink.end(reason);
2090
+ else ended = reason;
2091
+ }
2092
+ };
2093
+ if (entry.info.status === "running") entry.sinks.add(proxy);
2094
+ let replay;
2095
+ try {
2096
+ replay = await entry.artifact.replay(SHELL_ATTACH_REPLAY_BYTES);
2097
+ } catch (error) {
2098
+ entry.sinks.delete(proxy);
2099
+ throw error;
2100
+ }
2101
+ live = true;
2102
+ if (ended !== void 0) {
2103
+ const reason = ended;
2104
+ queueMicrotask(() => sink.end(reason));
2105
+ }
2106
+ return {
2107
+ shell: { ...entry.info },
2108
+ scrollback: replay.toString("utf8") + pending.join(""),
2109
+ detach: () => {
2110
+ entry.sinks.delete(proxy);
2111
+ }
2112
+ };
2113
+ },
2114
+ write: (sessionId, shellId, data) => {
2115
+ const entry = find(sessionId, shellId);
2116
+ if (!entry) throw new Error("unknown shell");
2117
+ if (typeof data !== "string" || data.length > SHELL_INPUT_MAX) throw new Error(`shell input must be a string of at most ${SHELL_INPUT_MAX} characters`);
2118
+ if (!entry.child) throw new Error("the shell has exited");
2119
+ entry.child.write(data);
2120
+ },
2121
+ resize: (sessionId, shellId, size) => {
2122
+ const entry = find(sessionId, shellId);
2123
+ if (!entry) throw new Error("unknown shell");
2124
+ const next = clampSize(size);
2125
+ if (entry.child) {
2126
+ try {
2127
+ entry.child.resize(next.cols, next.rows);
2128
+ } catch {}
2129
+ entry.info.cols = next.cols;
2130
+ entry.info.rows = next.rows;
2131
+ }
2132
+ return next;
2133
+ },
2134
+ output: async (sessionId, shellId, query) => {
2135
+ const entry = find(sessionId, shellId);
2136
+ if (!entry) return;
2137
+ if (query.tail === void 0 && query.view === "text") return entry.artifact.textView();
2138
+ const text = (await entry.artifact.read(query.tail)).toString("utf8");
2139
+ return query.view === "raw" ? text : ttyText(text);
2140
+ },
2141
+ hydrate: async () => {
2142
+ if (dir) {
2143
+ let names = [];
2144
+ try {
2145
+ names = await readdir(dir);
2146
+ } catch (error) {
2147
+ if (!isMissing(error)) report(error, { op: "index" });
2148
+ }
2149
+ for (const name of names) {
2150
+ if (!name.endsWith(INDEX_SUFFIX)) continue;
2151
+ let sessionId;
2152
+ try {
2153
+ sessionId = decodeURIComponent(name.slice(0, -5));
2154
+ } catch {
2155
+ continue;
2156
+ }
2157
+ await load(sessionId);
2158
+ }
2159
+ }
2160
+ await sweep();
2161
+ if (!sweeper && !stopped) {
2162
+ sweeper = setInterval(() => void sweep(), sweepIntervalMs);
2163
+ sweeper.unref();
2164
+ }
2165
+ },
2166
+ sweep,
2167
+ flush: async () => {
2168
+ const states = [...sessions.values()];
2169
+ await Promise.all(states.flatMap((state) => [...state.entries.values()].map((entry) => entry.artifact.idle())));
2170
+ await Promise.all(states.map((state) => state.chain));
2171
+ if (!dir && stopped) await rm(base, {
2172
+ recursive: true,
2173
+ force: true
2174
+ }).catch(() => {});
2175
+ },
2176
+ decorate: (info) => {
2177
+ const state = sessions.get(info.id);
2178
+ if (!state) return info;
2179
+ const now = Date.now();
2180
+ const shells = [...state.entries.values()].filter((entry) => {
2181
+ const { status, exitCode, endedAt } = entry.info;
2182
+ return status === "running" || typeof exitCode === "number" && exitCode !== 0 && endedAt !== void 0 && now - endedAt < SHELL_LINGER_MS;
2183
+ }).map((entry) => ({ ...entry.info }));
2184
+ return shells.length > 0 ? {
2185
+ ...info,
2186
+ shells
2187
+ } : info;
2188
+ },
2189
+ watch: (runner) => runner.subscribe((event) => {
2190
+ if (event.type === "session_closed" || event.type === "status_changed" && event.status === "parked") killSession(runner.id, "killed");
2191
+ }, runner.info().lastSeq)
2192
+ };
2193
+ }
2194
+ var LiveArtifact = class {
2195
+ #paths;
2196
+ #limits;
2197
+ #onError;
2198
+ #ring;
2199
+ #ringAt = 0;
2200
+ #ringLen = 0;
2201
+ #chunks = [];
2202
+ #chunkBytes = 0;
2203
+ #bytes = 0;
2204
+ #spilled = false;
2205
+ #capped = false;
2206
+ #closed = false;
2207
+ #broken = false;
2208
+ #io = Promise.resolve();
2209
+ #handle;
2210
+ #done = "";
2211
+ #pending = "";
2212
+ #textCache;
2213
+ constructor(paths, limits, onError) {
2214
+ this.#paths = paths;
2215
+ this.#limits = limits;
2216
+ this.#onError = onError;
2217
+ this.#ring = Buffer.alloc(Math.max(0, limits.ring));
2218
+ }
2219
+ get bytes() {
2220
+ return this.#bytes;
2221
+ }
2222
+ get capped() {
2223
+ return this.#capped;
2224
+ }
2225
+ append(data) {
2226
+ const chunk = Buffer.from(data, "utf8");
2227
+ const before = this.#bytes;
2228
+ this.#bytes += chunk.length;
2229
+ this.#ringPush(chunk);
2230
+ this.#textCache = void 0;
2231
+ if (this.#capped) return false;
2232
+ const room = Math.max(0, this.#limits.cap - before);
2233
+ const kept = chunk.length <= room ? chunk : chunk.subarray(0, room);
2234
+ this.#textAppend(kept === chunk ? data : kept.toString("utf8"));
2235
+ let changed = false;
2236
+ if (this.#spilled) this.#write(kept);
2237
+ else {
2238
+ this.#chunks.push(kept);
2239
+ this.#chunkBytes += kept.length;
2240
+ if (this.#chunkBytes > this.#limits.spill) {
2241
+ this.#spill();
2242
+ changed = true;
2243
+ }
2244
+ }
2245
+ if (this.#bytes > this.#limits.cap) {
2246
+ this.#capped = true;
2247
+ changed = true;
2248
+ }
2249
+ return changed;
2250
+ }
2251
+ text() {
2252
+ if (this.#textCache === void 0) {
2253
+ let text = this.#done + ttyText(this.#pending);
2254
+ if (this.#capped) {
2255
+ const after = this.#bytes - this.#limits.cap;
2256
+ const kept = Math.min(after, this.#ringLen);
2257
+ if (kept > 0) text += (after > kept ? "\n" : "") + ttyText(this.tail(kept).toString("utf8"));
2258
+ }
2259
+ this.#textCache = text;
2260
+ }
2261
+ return this.#textCache;
2262
+ }
2263
+ textView() {
2264
+ return Promise.resolve(this.text());
2265
+ }
2266
+ tail(n = this.#ringLen) {
2267
+ const size = this.#ring.length;
2268
+ const take = Math.max(0, Math.min(n, this.#ringLen));
2269
+ const out = Buffer.allocUnsafe(take);
2270
+ if (take === 0) return out;
2271
+ const start = (this.#ringAt - take + size) % size;
2272
+ const first = Math.min(take, size - start);
2273
+ this.#ring.copy(out, 0, start, start + first);
2274
+ if (first < take) this.#ring.copy(out, first, 0, take - first);
2275
+ return out;
2276
+ }
2277
+ replay(max) {
2278
+ const want = Math.max(0, Math.floor(max));
2279
+ if (!this.#spilled) {
2280
+ const all = Buffer.concat(this.#chunks, this.#chunkBytes);
2281
+ return Promise.resolve(all.subarray(Math.max(0, all.length - want)));
2282
+ }
2283
+ if (this.#capped || want <= this.#ringLen) return Promise.resolve(this.tail(want));
2284
+ const end = this.#bytes;
2285
+ return this.#io.then(() => readRange(this.#paths.raw, Math.max(0, end - want), end));
2286
+ }
2287
+ async read(tail) {
2288
+ if (tail !== void 0) return this.replay(tail);
2289
+ if (!this.#spilled) return Buffer.concat(this.#chunks, this.#chunkBytes);
2290
+ await this.#io;
2291
+ return readWhole(this.#paths.raw);
2292
+ }
2293
+ stored() {
2294
+ return this.#spilled ? { artifact: this.#paths.rel } : { output: Buffer.concat(this.#chunks, this.#chunkBytes).toString("utf8") };
2295
+ }
2296
+ writeTail() {
2297
+ const tail = this.tail();
2298
+ this.#queue(async () => {
2299
+ await mkdir(this.#paths.dir, {
2300
+ recursive: true,
2301
+ mode: 448
2302
+ });
2303
+ const temp = `${this.#paths.tail}.${process.pid}.tmp`;
2304
+ await writeFile(temp, tail, { mode: 384 });
2305
+ await rename(temp, this.#paths.tail);
2306
+ });
2307
+ }
2308
+ close() {
2309
+ if (this.#closed) return;
2310
+ this.#closed = true;
2311
+ if (this.#capped) this.writeTail();
2312
+ this.#io = this.#io.then(async () => {
2313
+ const handle = this.#handle;
2314
+ this.#handle = void 0;
2315
+ await handle?.close().catch(() => {});
2316
+ });
2317
+ }
2318
+ idle() {
2319
+ return this.#io;
2320
+ }
2321
+ async remove() {
2322
+ this.close();
2323
+ await this.#io;
2324
+ await rm(this.#paths.raw, { force: true });
2325
+ await rm(this.#paths.tail, { force: true });
2326
+ }
2327
+ #textAppend(data) {
2328
+ const newline = data.lastIndexOf("\n");
2329
+ if (newline === -1) {
2330
+ this.#pending += data;
2331
+ return;
2332
+ }
2333
+ this.#done += ttyText(this.#pending + data.slice(0, newline + 1));
2334
+ this.#pending = data.slice(newline + 1);
2335
+ }
2336
+ #ringPush(chunk) {
2337
+ const size = this.#ring.length;
2338
+ if (size === 0 || chunk.length === 0) return;
2339
+ if (chunk.length >= size) {
2340
+ chunk.copy(this.#ring, 0, chunk.length - size);
2341
+ this.#ringAt = 0;
2342
+ this.#ringLen = size;
2343
+ return;
2344
+ }
2345
+ const first = Math.min(chunk.length, size - this.#ringAt);
2346
+ chunk.copy(this.#ring, this.#ringAt, 0, first);
2347
+ if (first < chunk.length) chunk.copy(this.#ring, 0, first);
2348
+ this.#ringAt = (this.#ringAt + chunk.length) % size;
2349
+ this.#ringLen = Math.min(size, this.#ringLen + chunk.length);
2350
+ }
2351
+ #spill() {
2352
+ this.#spilled = true;
2353
+ const buffered = Buffer.concat(this.#chunks, this.#chunkBytes);
2354
+ this.#chunks = [];
2355
+ this.#chunkBytes = 0;
2356
+ this.#queue(async () => {
2357
+ await mkdir(this.#paths.dir, {
2358
+ recursive: true,
2359
+ mode: 448
2360
+ });
2361
+ this.#handle = await open(this.#paths.raw, "w", 384);
2362
+ await this.#handle.write(buffered);
2363
+ });
2364
+ }
2365
+ #write(chunk) {
2366
+ if (chunk.length === 0) return;
2367
+ this.#queue(async () => {
2368
+ await this.#handle?.write(chunk);
2369
+ });
2370
+ }
2371
+ #queue(task) {
2372
+ this.#io = this.#io.then(async () => {
2373
+ if (this.#broken) return;
2374
+ try {
2375
+ await task();
2376
+ } catch (error) {
2377
+ this.#broken = true;
2378
+ this.#onError(error);
2379
+ }
2380
+ });
2381
+ }
2382
+ };
2383
+ var StoredArtifact = class {
2384
+ #paths;
2385
+ #bytes;
2386
+ #capped;
2387
+ #stored;
2388
+ constructor(paths, info, stored) {
2389
+ this.#paths = paths;
2390
+ this.#bytes = info.bytes;
2391
+ this.#capped = info.capped === true;
2392
+ this.#stored = stored.artifact !== void 0 ? { artifact: stored.artifact } : { output: stored.output ?? "" };
2393
+ }
2394
+ async read(tail) {
2395
+ if (tail !== void 0) return this.replay(tail);
2396
+ return this.#head();
2397
+ }
2398
+ async replay(max) {
2399
+ const want = Math.max(0, Math.floor(max));
2400
+ if (this.#capped) {
2401
+ const ring = await readWhole(this.#paths.tail);
2402
+ return ring.subarray(Math.max(0, ring.length - want));
2403
+ }
2404
+ if (this.#stored.output !== void 0) {
2405
+ const all = Buffer.from(this.#stored.output, "utf8");
2406
+ return all.subarray(Math.max(0, all.length - want));
2407
+ }
2408
+ const size = await stat(this.#paths.raw).then((s) => s.size, () => 0);
2409
+ return readRange(this.#paths.raw, Math.max(0, size - want), size);
2410
+ }
2411
+ async textView() {
2412
+ const head = await this.#head();
2413
+ let text = ttyText(head.toString("utf8"));
2414
+ if (this.#capped) {
2415
+ const ring = await readWhole(this.#paths.tail);
2416
+ const after = this.#bytes - head.length;
2417
+ const kept = Math.min(after, ring.length);
2418
+ if (kept > 0) text += (after > kept ? "\n" : "") + ttyText(ring.subarray(ring.length - kept).toString("utf8"));
2419
+ }
2420
+ return text;
2421
+ }
2422
+ stored() {
2423
+ return this.#stored;
2424
+ }
2425
+ idle() {
2426
+ return Promise.resolve();
2427
+ }
2428
+ async remove() {
2429
+ await rm(this.#paths.raw, { force: true });
2430
+ await rm(this.#paths.tail, { force: true });
2431
+ }
2432
+ #head() {
2433
+ return this.#stored.output !== void 0 ? Promise.resolve(Buffer.from(this.#stored.output, "utf8")) : readWhole(this.#paths.raw);
2434
+ }
2435
+ };
2436
+ function serialize(state) {
2437
+ return {
2438
+ version: INDEX_VERSION,
2439
+ sessionId: state.sessionId,
2440
+ nextOrdinal: state.nextOrdinal,
2441
+ shells: [...state.entries.values()].map((entry) => ({
2442
+ ...entry.info,
2443
+ generation: entry.generation,
2444
+ ...entry.artifact.stored()
2445
+ }))
2446
+ };
2447
+ }
2448
+ function parseIndex(value) {
2449
+ const index = value;
2450
+ if (!index || index.version !== INDEX_VERSION || typeof index.sessionId !== "string" || !Array.isArray(index.shells)) throw new Error("unrecognised shell index");
2451
+ const shells = index.shells.filter((record) => typeof record === "object" && record !== null && typeof record.id === "string");
2452
+ const nextOrdinal = typeof index.nextOrdinal === "number" && index.nextOrdinal > 0 ? index.nextOrdinal : 1;
2453
+ return {
2454
+ version: INDEX_VERSION,
2455
+ sessionId: index.sessionId,
2456
+ nextOrdinal,
2457
+ shells
2458
+ };
2459
+ }
2460
+ function freshSession(sessionId) {
2461
+ return {
2462
+ sessionId,
2463
+ nextOrdinal: 1,
2464
+ entries: /* @__PURE__ */ new Map(),
2465
+ chain: Promise.resolve(),
2466
+ pending: false
2467
+ };
2468
+ }
2469
+ function runningIn(state) {
2470
+ return [...state.entries.values()].filter((entry) => entry.info.status === "running");
2471
+ }
2472
+ function sourceFor(entry, artifact) {
2473
+ return {
2474
+ info: () => entry.info,
2475
+ text: () => artifact.text(),
2476
+ subscribe: (listener) => {
2477
+ entry.listeners.add(listener);
2478
+ return () => {
2479
+ entry.listeners.delete(listener);
2480
+ };
2481
+ }
2482
+ };
2483
+ }
2484
+ function mintShellId() {
2485
+ const bytes = randomBytes(ID_CHARS);
2486
+ let id = "sh_";
2487
+ for (let i = 0; i < ID_CHARS; i++) id += ID_ALPHABET[bytes[i] & 31];
2488
+ return id;
2489
+ }
2490
+ function shellLabel(command) {
2491
+ return (command.split("\n")[0] ?? "").trim().slice(0, SHELL_LABEL_MAX);
2492
+ }
2493
+ function clamp(value, min, max) {
2494
+ return Number.isFinite(value) ? Math.min(max, Math.max(min, Math.trunc(value))) : min;
2495
+ }
2496
+ function isMissing(error) {
2497
+ return error?.code === "ENOENT";
2498
+ }
2499
+ async function readWhole(path) {
2500
+ try {
2501
+ return await readFile(path);
2502
+ } catch (error) {
2503
+ if (isMissing(error)) return Buffer.alloc(0);
2504
+ throw error;
2505
+ }
2506
+ }
2507
+ async function readRange(path, start, end) {
2508
+ const length = Math.max(0, end - start);
2509
+ if (length === 0) return Buffer.alloc(0);
2510
+ let handle;
2511
+ try {
2512
+ handle = await open(path, "r");
2513
+ } catch (error) {
2514
+ if (isMissing(error)) return Buffer.alloc(0);
2515
+ throw error;
2516
+ }
2517
+ try {
2518
+ const out = Buffer.allocUnsafe(length);
2519
+ let got = 0;
2520
+ while (got < length) {
2521
+ const { bytesRead } = await handle.read(out, got, length - got, start + got);
2522
+ if (bytesRead === 0) break;
2523
+ got += bytesRead;
2524
+ }
2525
+ return got === length ? out : out.subarray(0, got);
2526
+ } finally {
2527
+ await handle.close();
2528
+ }
2529
+ }
2530
+ //#endregion
2531
+ //#region src/routes/shells.ts
2532
+ async function handleShells(ctx, req, res, route, runner, operator) {
2533
+ const { shells } = ctx;
2534
+ const sessionId = route.id;
2535
+ if (!shells) {
2536
+ json(res, 404, { error: SHELL_REFUSAL });
2537
+ return;
2538
+ }
2539
+ if (!operator) {
2540
+ json(res, 403, { error: SHELL_REFUSAL });
2541
+ return;
2542
+ }
2543
+ if (route.shellId === void 0) {
2544
+ if (req.method !== "GET") {
2545
+ json(res, 405, { error: "method not allowed" });
2546
+ return;
2547
+ }
2548
+ json(res, 200, { shells: shells.list(sessionId) });
2549
+ return;
2550
+ }
2551
+ if (route.shellAction === "kill") {
2552
+ if (req.method !== "POST") {
2553
+ json(res, 405, { error: "method not allowed" });
2554
+ return;
2555
+ }
2556
+ if (!runner || !shellPermitted(shells, runner, operator)) {
2557
+ json(res, 403, { error: SHELL_REFUSAL });
2558
+ return;
2559
+ }
2560
+ const shell = shells.kill(sessionId, route.shellId);
2561
+ if (!shell) {
2562
+ json(res, 404, { error: "shell not found" });
2563
+ return;
2564
+ }
2565
+ json(res, 200, { shell });
2566
+ return;
2567
+ }
2568
+ if (req.method !== "GET") {
2569
+ json(res, 405, { error: "method not allowed" });
2570
+ return;
2571
+ }
2572
+ if (route.shellAction === "output") {
2573
+ const url = new URL(req.url ?? "/", "http://internal");
2574
+ const view = url.searchParams.get("view") === "raw" ? "raw" : "text";
2575
+ const tailParam = url.searchParams.get("tail");
2576
+ const tail = tailParam === null ? void 0 : Number(tailParam);
2577
+ if (tail !== void 0 && (!Number.isInteger(tail) || tail <= 0)) {
2578
+ json(res, 400, { error: "tail must be a positive integer" });
2579
+ return;
2580
+ }
2581
+ const output = await shells.output(sessionId, route.shellId, {
2582
+ view,
2583
+ tail
2584
+ });
2585
+ if (output === void 0) {
2586
+ json(res, 404, { error: "shell not found" });
2587
+ return;
2588
+ }
2589
+ const body = Buffer.from(output, "utf8");
2590
+ res.writeHead(200, untrustedDownloadHeaders(`${route.shellId}.txt`, "text/plain; charset=utf-8", body.length));
2591
+ res.end(body);
2592
+ return;
2593
+ }
2594
+ const shell = shells.get(sessionId, route.shellId);
2595
+ if (!shell) {
2596
+ json(res, 404, { error: "shell not found" });
2597
+ return;
2598
+ }
2599
+ json(res, 200, { shell });
2600
+ }
2601
+ //#endregion
1458
2602
  //#region src/routes/tool-results.ts
1459
2603
  function handleToolResult(req, res, lookup, seq) {
1460
2604
  if (req.method !== "GET") {
@@ -1517,13 +2661,12 @@ async function handleSessions(ctx, req, res, route, auth) {
1517
2661
  return;
1518
2662
  }
1519
2663
  if (req.method === "POST") {
1520
- const body = await readJsonBody(req, ctx.maxBodyBytes);
1521
- const refusal = vetCreateRequest(ctx, body, auth);
1522
- if (refusal) {
1523
- json(res, refusal.status, { error: refusal.error });
2664
+ const vetted = vetCreateRequest(ctx, await readJsonBody(req, ctx.maxBodyBytes), auth);
2665
+ if (!vetted.ok) {
2666
+ json(res, vetted.status, { error: vetted.error });
1524
2667
  return;
1525
2668
  }
1526
- const runner = await factory.createRunner(factory.buildRunnerConfig(body));
2669
+ const runner = await factory.createRunner(factory.buildRunnerConfig(vetted.request));
1527
2670
  factory.watchAuthSource(runner);
1528
2671
  json(res, 201, { session: projects.withProject(runner.info()) });
1529
2672
  return;
@@ -1588,6 +2731,10 @@ async function handleSessions(ctx, req, res, route, auth) {
1588
2731
  await handleProducedFiles(ctx, req, res, route.id, route.producedFileId);
1589
2732
  return;
1590
2733
  }
2734
+ if (route.shells) {
2735
+ await handleShells(ctx, req, res, route, runner ?? null, authSvc.isOperator(auth));
2736
+ return;
2737
+ }
1591
2738
  if (route.projectIcon) {
1592
2739
  handleProjectIcon(projects, req, res, (runner?.info() ?? parked.info).cwd);
1593
2740
  return;
@@ -1611,7 +2758,15 @@ async function handleSessions(ctx, req, res, route, auth) {
1611
2758
  json(res, 409, { error: "session is parked (it has no pending permission requests)" });
1612
2759
  return;
1613
2760
  }
1614
- if (!runner.resolvePermission(route.permissionId, body)) {
2761
+ const decision = body.behavior === "allow" ? {
2762
+ behavior: "allow",
2763
+ updatedInput: body.updatedInput
2764
+ } : {
2765
+ behavior: "deny",
2766
+ message: body.message,
2767
+ interrupt: body.interrupt
2768
+ };
2769
+ if (!runner.resolvePermission(route.permissionId, decision)) {
1615
2770
  json(res, 404, { error: "permission request not found (already resolved or expired)" });
1616
2771
  return;
1617
2772
  }
@@ -1654,151 +2809,7 @@ async function handleSessions(ctx, req, res, route, auth) {
1654
2809
  }
1655
2810
  json(res, 405, { error: "method not allowed" });
1656
2811
  }
1657
- const TIMEOUT_EXIT_CODE = 124;
1658
- const SPAWN_FAILURE_EXIT_CODE = 127;
1659
- const KILLED_EXIT_CODE = 128 + constants$1.signals.SIGKILL;
1660
- function shellPermitted(shell, runner, operator) {
1661
- if (shell === null || !operator) return false;
1662
- const info = runner.info();
1663
- return (info.capabilities ?? (info.engine ? ENGINE_CAPABILITIES[info.engine] : void 0))?.hostCwd === true;
1664
- }
1665
- function shellChildEnv(base) {
1666
- const env = {};
1667
- for (const [key, value] of Object.entries(base)) if (value !== void 0) env[key] = value;
1668
- return env;
1669
- }
1670
- function createShellService(options = {}) {
1671
- const timeoutMs = options.timeoutMs ?? 12e4;
1672
- const maxOutputBytes = options.maxOutputBytes ?? 32768;
1673
- const active = /* @__PURE__ */ new Map();
1674
- const run = async (sessionId, cwd, command) => {
1675
- if (active.has(sessionId)) throw new Error("a shell command is still running on this session");
1676
- const child = spawnShell({
1677
- command,
1678
- cwd,
1679
- env: shellChildEnv(process.env),
1680
- timeoutMs,
1681
- maxOutputBytes
1682
- });
1683
- active.set(sessionId, child);
1684
- try {
1685
- return await child.result;
1686
- } finally {
1687
- if (active.get(sessionId) === child) active.delete(sessionId);
1688
- }
1689
- };
1690
- const kill = (sessionId, reason = "session closed") => {
1691
- active.get(sessionId)?.kill(reason);
1692
- };
1693
- const watch = (runner) => runner.subscribe((event) => {
1694
- if (event.type === "session_closed") kill(runner.id, "session closed");
1695
- else if (event.type === "status_changed" && event.status === "parked") kill(runner.id, "session parked");
1696
- }, runner.info().lastSeq);
1697
- const killAll = () => {
1698
- for (const sessionId of active.keys()) kill(sessionId, "server shutting down");
1699
- };
1700
- const running = (sessionId) => active.has(sessionId);
1701
- return {
1702
- run,
1703
- kill,
1704
- watch,
1705
- killAll,
1706
- running,
1707
- timeoutMs,
1708
- maxOutputBytes
1709
- };
1710
- }
1711
- var BoundedOutput = class {
1712
- #chunks = [];
1713
- #kept = 0;
1714
- #dropped = 0;
1715
- #budget;
1716
- constructor(budget) {
1717
- this.#budget = budget;
1718
- }
1719
- push(chunk) {
1720
- const take = Math.min(chunk.length, this.#budget.remaining);
1721
- if (take > 0) {
1722
- this.#chunks.push(take === chunk.length ? chunk : chunk.subarray(0, take));
1723
- this.#kept += take;
1724
- this.#budget.remaining -= take;
1725
- }
1726
- this.#dropped += chunk.length - take;
1727
- }
1728
- text() {
1729
- const head = Buffer.concat(this.#chunks, this.#kept).toString("utf8");
1730
- return this.#dropped > 0 ? `${head}\n[output truncated: ${this.#dropped} more bytes dropped]` : head;
1731
- }
1732
- };
1733
- function spawnShell(input) {
1734
- const startedAt = Date.now();
1735
- const budget = { remaining: input.maxOutputBytes };
1736
- const stdout = new BoundedOutput(budget);
1737
- const stderr = new BoundedOutput(budget);
1738
- let settle;
1739
- const result = new Promise((resolve) => {
1740
- settle = resolve;
1741
- });
1742
- let done = false;
1743
- const finish = (exitCode, note) => {
1744
- if (done) return;
1745
- done = true;
1746
- clearTimeout(timer);
1747
- const captured = stderr.text();
1748
- const stderrText = note ? captured ? `${captured}\n[${note}]` : `[${note}]` : captured;
1749
- settle?.({
1750
- command: input.command,
1751
- stdout: stdout.text(),
1752
- stderr: stderrText,
1753
- exitCode,
1754
- durationMs: Date.now() - startedAt
1755
- });
1756
- };
1757
- const child = spawn("/bin/sh", ["-c", input.command], {
1758
- cwd: input.cwd,
1759
- env: {
1760
- ...input.env,
1761
- PWD: input.cwd
1762
- },
1763
- stdio: [
1764
- "ignore",
1765
- "pipe",
1766
- "pipe"
1767
- ],
1768
- detached: true
1769
- });
1770
- child.stdout.on("data", (chunk) => stdout.push(chunk));
1771
- child.stderr.on("data", (chunk) => stderr.push(chunk));
1772
- const killGroup = () => {
1773
- if (child.pid === void 0) return;
1774
- try {
1775
- process.kill(-child.pid, "SIGKILL");
1776
- } catch {
1777
- try {
1778
- child.kill("SIGKILL");
1779
- } catch {}
1780
- }
1781
- child.stdout.destroy();
1782
- child.stderr.destroy();
1783
- };
1784
- const kill = (reason, exitCode = KILLED_EXIT_CODE) => {
1785
- if (done) return;
1786
- killGroup();
1787
- finish(exitCode, `killed: ${reason}`);
1788
- };
1789
- const timer = setTimeout(() => kill(`timed out after ${Math.round(input.timeoutMs / 1e3)}s`, TIMEOUT_EXIT_CODE), input.timeoutMs);
1790
- child.on("error", (error) => finish(SPAWN_FAILURE_EXIT_CODE, error.message));
1791
- child.on("close", (code, signal) => {
1792
- const signalCode = signal ? constants$1.signals[signal] ?? 0 : 0;
1793
- finish(code ?? (signalCode ? 128 + signalCode : 1), signal ? `killed by ${signal}` : void 0);
1794
- });
1795
- return {
1796
- result,
1797
- kill: (reason) => kill(reason)
1798
- };
1799
- }
1800
- //#endregion
1801
- //#region src/routes/ws.ts
2812
+ const SHELL_DETACHED_BACKPRESSURE = "backpressure";
1802
2813
  function attachClient(ctx, ws, runner, req, access) {
1803
2814
  const { bridge, parking } = ctx;
1804
2815
  const url = new URL(req.url ?? "/", "http://internal");
@@ -1808,12 +2819,18 @@ function attachClient(ctx, ws, runner, req, access) {
1808
2819
  const send = (frame) => {
1809
2820
  if (ws.readyState === ws.OPEN) ws.send(JSON.stringify(frame));
1810
2821
  };
2822
+ const client = {
2823
+ send,
2824
+ open: () => ws.readyState === ws.OPEN,
2825
+ buffered: () => ws.bufferedAmount,
2826
+ shells: /* @__PURE__ */ new Map()
2827
+ };
1811
2828
  send({
1812
2829
  type: "attached",
1813
2830
  protocolVersion: PROTOCOL_VERSION,
1814
2831
  session: ctx.projects.withProject(runner.info()),
1815
2832
  replayingFrom: afterSeq,
1816
- ...shellPermitted(ctx.shell, runner, access.operator) ? { shell: true } : {},
2833
+ ...shellPermitted(ctx.shells, runner, access.operator) ? { shell: true } : {},
1817
2834
  ...ctx.pricingOverrides ? { pricingOverrides: ctx.pricingOverrides } : {}
1818
2835
  });
1819
2836
  const unsubscribe = runner.subscribe((event) => send({
@@ -1837,7 +2854,7 @@ function attachClient(ctx, ws, runner, req, access) {
1837
2854
  });
1838
2855
  return;
1839
2856
  }
1840
- handleCommand(ctx, frame, runner, access).catch((error) => {
2857
+ handleCommand(ctx, frame, runner, access, client).catch((error) => {
1841
2858
  send({
1842
2859
  type: "protocol_error",
1843
2860
  message: error instanceof Error ? error.message : "command failed"
@@ -1845,12 +2862,13 @@ function attachClient(ctx, ws, runner, req, access) {
1845
2862
  });
1846
2863
  });
1847
2864
  ws.on("close", () => {
2865
+ detachShells(client);
1848
2866
  unsubscribe();
1849
2867
  detachBridge();
1850
2868
  parking.onDetach(runner.id);
1851
2869
  });
1852
2870
  }
1853
- async function handleCommand(ctx, frame, runner, access) {
2871
+ async function handleCommand(ctx, frame, runner, access, client) {
1854
2872
  const { attachmentStore, bridge } = ctx;
1855
2873
  switch (frame.type) {
1856
2874
  case "user_message": {
@@ -1904,21 +2922,107 @@ async function handleCommand(ctx, frame, runner, access) {
1904
2922
  });
1905
2923
  return;
1906
2924
  case "shell_command": {
1907
- if (!shellPermitted(ctx.shell, runner, access.operator) || ctx.shell === null) throw new Error("shell commands are not available on this session");
2925
+ const shells = permittedShells(ctx, runner, access);
1908
2926
  if (typeof frame.command !== "string" || frame.command.includes("\0")) throw new Error("shell command must be a string");
1909
2927
  if (frame.command.length > SHELL_COMMAND_MAX) throw new Error(`shell command exceeds ${SHELL_COMMAND_MAX} characters`);
1910
2928
  if (frame.command.trim() === "") throw new Error("shell command is empty");
1911
2929
  if (!runner.queueLocalCommand) throw new Error(`the ${runner.info().engine ?? "claude"} engine cannot take shell output`);
1912
- const result = await ctx.shell.run(runner.id, runner.info().cwd, frame.command);
1913
- runner.queueLocalCommand(result);
2930
+ const { shell, source } = await shells.spawn({
2931
+ runner,
2932
+ command: frame.command,
2933
+ owner: "user"
2934
+ });
2935
+ try {
2936
+ runner.queueLocalCommand(source);
2937
+ } catch (error) {
2938
+ shells.kill(runner.id, shell.id);
2939
+ throw error;
2940
+ }
1914
2941
  return;
1915
2942
  }
2943
+ case "shell_attach":
2944
+ await attachShell(permittedShells(ctx, runner, access), client, runner.id, frame.shellId, {
2945
+ cols: frame.cols,
2946
+ rows: frame.rows
2947
+ });
2948
+ return;
2949
+ case "shell_input":
2950
+ permittedShells(ctx, runner, access).write(runner.id, frame.shellId, frame.data);
2951
+ return;
2952
+ case "shell_resize":
2953
+ permittedShells(ctx, runner, access).resize(runner.id, frame.shellId, {
2954
+ cols: frame.cols,
2955
+ rows: frame.rows
2956
+ });
2957
+ return;
2958
+ case "shell_detach":
2959
+ detachShell(client, frame.shellId);
2960
+ return;
1916
2961
  case "close":
1917
2962
  runner.close("client");
1918
2963
  return;
1919
2964
  default: throw new Error(`unknown command: ${frame.type}`);
1920
2965
  }
1921
2966
  }
2967
+ function permittedShells(ctx, runner, access) {
2968
+ if (ctx.shells === null || !shellPermitted(ctx.shells, runner, access.operator)) throw new Error(SHELL_REFUSAL);
2969
+ return ctx.shells;
2970
+ }
2971
+ async function attachShell(shells, client, sessionId, shellId, size) {
2972
+ shells.resize(sessionId, shellId, size);
2973
+ let detach;
2974
+ const drop = (reason) => {
2975
+ if (detach === void 0 || client.shells.get(shellId) !== detach) return;
2976
+ client.shells.delete(shellId);
2977
+ detach();
2978
+ client.send({
2979
+ type: "shell_detached",
2980
+ shellId,
2981
+ reason
2982
+ });
2983
+ };
2984
+ const sink = {
2985
+ write: (data) => {
2986
+ if (client.buffered() > 4194304) {
2987
+ drop(SHELL_DETACHED_BACKPRESSURE);
2988
+ return;
2989
+ }
2990
+ client.send({
2991
+ type: "shell_output",
2992
+ shellId,
2993
+ data
2994
+ });
2995
+ },
2996
+ end: drop
2997
+ };
2998
+ const attached = await shells.attach(sessionId, shellId, sink);
2999
+ if (!client.open()) {
3000
+ attached.detach();
3001
+ return;
3002
+ }
3003
+ detachShell(client, shellId);
3004
+ if (attached.shell.status === "running") {
3005
+ detach = attached.detach;
3006
+ client.shells.set(shellId, detach);
3007
+ }
3008
+ const { shell, scrollback } = attached;
3009
+ client.send({
3010
+ type: "shell_attached",
3011
+ shellId,
3012
+ shell,
3013
+ cols: shell.cols,
3014
+ rows: shell.rows,
3015
+ scrollback
3016
+ });
3017
+ }
3018
+ function detachShell(client, shellId) {
3019
+ client.shells.get(shellId)?.();
3020
+ client.shells.delete(shellId);
3021
+ }
3022
+ function detachShells(client) {
3023
+ for (const detach of client.shells.values()) detach();
3024
+ client.shells.clear();
3025
+ }
1922
3026
  //#endregion
1923
3027
  //#region src/services/attachments.ts
1924
3028
  const DEFAULT_MAX_FILE_BYTES = 10485760;
@@ -2742,7 +3846,6 @@ var ProfileService = class {
2742
3846
  if (!hasEngineRunnerFactory) return `profile '${p.name}' uses engine 'provider' but no \`createEngineRunner\` was provided to build one`;
2743
3847
  } else if (p.engine === "codex") {
2744
3848
  if (p.codexHome && !existsSync(p.codexHome)) return `profile '${p.name}' codexHome does not exist: ${p.codexHome}`;
2745
- 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`;
2746
3849
  } else if (!p.configDir || !existsSync(p.configDir)) return `profile '${p.name}' configDir does not exist: ${p.configDir}`;
2747
3850
  if (disableBypassPermissions && p.defaults?.permissionMode === "bypassPermissions") return `profile '${p.name}' defaults to bypassPermissions but disableBypassPermissions is set`;
2748
3851
  const fallbackMode = p.defaults?.permissionMode;
@@ -3396,13 +4499,16 @@ function createSessionFactory(deps) {
3396
4499
  }
3397
4500
  };
3398
4501
  const buildRunner = async (built, restore, id) => {
3399
- const config = deps.peers ? {
3400
- ...built,
3401
- peers: deps.peers
3402
- } : built;
3403
- const name = config.profile;
4502
+ const name = built.profile;
3404
4503
  const profile = name !== void 0 ? profiles.get(name) : void 0;
3405
4504
  if (name !== void 0 && !profile) throw new Error(`unknown profile: ${name}`);
4505
+ const capabilities = profile?.capabilities ?? adapterFor(profile?.engine).capabilities;
4506
+ const config = {
4507
+ ...built,
4508
+ ...deps.peers ? { peers: deps.peers } : {},
4509
+ ...deps.shells && capabilities.hostCwd === true ? { shells: deps.shells } : {}
4510
+ };
4511
+ if (config.instructions !== void 0 && capabilities.systemInstructions === false) throw new Error(`the ${engineOf(profile)} engine cannot deliver system instructions`);
3406
4512
  const runner = profile && isProviderProfile(profile) ? await deps.createEngineRunner({
3407
4513
  config,
3408
4514
  profile,
@@ -3496,7 +4602,7 @@ function createSessionFactory(deps) {
3496
4602
  const SOCKET_CLOSE_GRACE_MS = 250;
3497
4603
  const CLOSE_DEADLINE_MS = 1e3;
3498
4604
  const USAGE_STALE_MS = 3e5;
3499
- function surveyDrain(registry) {
4605
+ function surveyDrain(registry, shells) {
3500
4606
  const working = [];
3501
4607
  const awaitingHuman = [];
3502
4608
  for (const info of registry.list()) {
@@ -3507,7 +4613,12 @@ function surveyDrain(registry) {
3507
4613
  return {
3508
4614
  working,
3509
4615
  awaitingHuman,
3510
- timedOut: false
4616
+ timedOut: false,
4617
+ shells: (shells?.running() ?? []).map(({ sessionId, id, label }) => ({
4618
+ sessionId,
4619
+ id,
4620
+ label
4621
+ }))
3511
4622
  };
3512
4623
  }
3513
4624
  function sameDrain(a, b) {
@@ -3567,13 +4678,24 @@ function createWorkerServer(options = {}) {
3567
4678
  if (invalid) throw new Error(`createWorkerServer: ${invalid}`);
3568
4679
  }
3569
4680
  const refs = {};
3570
- const projects = new ProjectInfoService();
4681
+ const generation = randomUUID();
4682
+ const shells = options.shell?.enabled === true ? createShellRegistry({
4683
+ generation,
4684
+ artifactDir: options.shell.artifactDir ?? null,
4685
+ timeoutMs: options.shell.timeoutMs,
4686
+ artifactMaxBytes: options.shell.artifactMaxBytes,
4687
+ artifactTtlMs: options.shell.artifactTtlMs,
4688
+ maxRunningPerSession: options.shell.maxRunningPerSession,
4689
+ onError: (error, context) => console.warn(`[workerdeck] shell ${context.op} error (${context.shellId ?? context.sessionId ?? "-"}): ${String(error)}`)
4690
+ }) : null;
4691
+ const projects = new ProjectInfoService({ decorate: (info) => shells ? shells.decorate(info) : info });
3571
4692
  const peers = options.peers?.enabled === false ? void 0 : createPeerService({
3572
4693
  refs,
3573
4694
  projects,
3574
4695
  options: options.peers
3575
4696
  });
3576
4697
  if (peers) installPeerDirectory(peers);
4698
+ installShellDirectory(shells ? createShellDirectory(shells) : void 0);
3577
4699
  const factory = createSessionFactory({
3578
4700
  adapterFor,
3579
4701
  profiles,
@@ -3584,6 +4706,7 @@ function createWorkerServer(options = {}) {
3584
4706
  approvalTimeoutMs: options.approvalTimeoutMs,
3585
4707
  requireApiKey: options.requireApiKey,
3586
4708
  peers: peers ? peerDirectoryHandle() : void 0,
4709
+ shells: shells ? shellDirectoryHandle() : void 0,
3587
4710
  refs
3588
4711
  });
3589
4712
  const availability = new AvailabilityTracker({
@@ -3597,14 +4720,13 @@ function createWorkerServer(options = {}) {
3597
4720
  decorateInfo: (info) => projects.withProject(info)
3598
4721
  });
3599
4722
  const producedFiles = new ProducedFileStore();
3600
- const shell = options.shell?.enabled === true ? createShellService(options.shell) : null;
3601
4723
  const registry = new SessionRegistry({ onRegister: (runner) => {
3602
4724
  const detachers = [
3603
4725
  notifier.watch(runner),
3604
4726
  producedFiles.watch(runner),
3605
4727
  profileUsage.watch(runner),
3606
4728
  spendLedger.watch(runner),
3607
- shell?.watch(runner),
4729
+ shells?.watch(runner),
3608
4730
  peers?.watch(runner)
3609
4731
  ];
3610
4732
  const profile = runner.info().profile;
@@ -3715,7 +4837,8 @@ function createWorkerServer(options = {}) {
3715
4837
  hostFilesWritable: options.hostFiles?.write === true,
3716
4838
  maxHostFileBytes: options.hostFiles?.maxFileBytes ?? 1048576,
3717
4839
  maxHostDirEntries: options.hostFiles?.maxEntries ?? 5e3,
3718
- shell,
4840
+ shells,
4841
+ generation,
3719
4842
  pricingOverrides: Object.keys(pricing.overrides).length > 0 ? pricing.overrides : void 0
3720
4843
  };
3721
4844
  const handleRequest = async (req, res) => {
@@ -3784,6 +4907,18 @@ function createWorkerServer(options = {}) {
3784
4907
  await handleSdkSessions(ctx, req, res, authCtx);
3785
4908
  return;
3786
4909
  }
4910
+ if (pathname === basePath + "/meta") {
4911
+ const authCtx = await auth.authenticate(req);
4912
+ if (!authCtx.ok) {
4913
+ json(res, 401, { error: "unauthorized" });
4914
+ return;
4915
+ }
4916
+ json(res, 200, {
4917
+ protocolVersion: PROTOCOL_VERSION,
4918
+ ...auth.isOperator(authCtx) ? { machineId: machineId() } : {}
4919
+ });
4920
+ return;
4921
+ }
3787
4922
  if (pathname.startsWith(basePath + "/fs/")) {
3788
4923
  const authCtx = await auth.authenticate(req);
3789
4924
  if (!authCtx.ok) {
@@ -3884,10 +5019,12 @@ function createWorkerServer(options = {}) {
3884
5019
  queue,
3885
5020
  bridge,
3886
5021
  parking,
5022
+ shells,
3887
5023
  listen: async (port, host) => {
3888
5024
  await profiles.refreshStored();
3889
5025
  await profiles.seedStore();
3890
5026
  await parking.hydrate();
5027
+ await shells?.hydrate();
3891
5028
  return new Promise((resolve, reject) => {
3892
5029
  server.once("error", reject);
3893
5030
  server.listen(port, host, () => {
@@ -3919,16 +5056,16 @@ function createWorkerServer(options = {}) {
3919
5056
  const { timeoutMs = 3e4, pollMs = 250, onProgress } = drainOptions;
3920
5057
  draining = true;
3921
5058
  const deadline = Date.now() + timeoutMs;
3922
- let report = surveyDrain(registry);
5059
+ let report = surveyDrain(registry, shells);
3923
5060
  onProgress?.(report);
3924
5061
  while (report.working.length > 0 && Date.now() < deadline) {
3925
5062
  await new Promise((resolve) => setTimeout(resolve, pollMs));
3926
- const next = surveyDrain(registry);
5063
+ const next = surveyDrain(registry, shells);
3927
5064
  if (!sameDrain(next, report)) onProgress?.(next);
3928
5065
  report = next;
3929
5066
  }
3930
5067
  report = {
3931
- ...surveyDrain(registry),
5068
+ ...surveyDrain(registry, shells),
3932
5069
  timedOut: false
3933
5070
  };
3934
5071
  report.timedOut = report.working.length > 0;
@@ -3938,23 +5075,25 @@ function createWorkerServer(options = {}) {
3938
5075
  close: () => {
3939
5076
  closing ??= new Promise((resolve) => {
3940
5077
  queue?.close();
5078
+ shells?.killAll("server_stopped");
5079
+ const flushed = shells?.flush().catch(() => {}) ?? Promise.resolve();
3941
5080
  parking.close();
3942
5081
  registry.closeAll();
3943
- shell?.killAll();
3944
5082
  for (const ws of wss.clients) ws.close(1001, "server shutting down");
3945
5083
  queueSockets.clear();
3946
5084
  const force = setTimeout(() => {
3947
5085
  for (const ws of wss.clients) ws.terminate();
3948
5086
  }, SOCKET_CLOSE_GRACE_MS);
3949
5087
  force.unref();
3950
- const deadline = setTimeout(() => resolve(), CLOSE_DEADLINE_MS);
5088
+ const finish = () => void flushed.then(() => resolve());
5089
+ const deadline = setTimeout(finish, CLOSE_DEADLINE_MS);
3951
5090
  deadline.unref();
3952
5091
  const drained = Promise.all([new Promise((done) => wss.close(() => done())), new Promise((done) => server.close(() => done()))]);
3953
5092
  server.closeAllConnections();
3954
5093
  drained.then(() => {
3955
5094
  clearTimeout(force);
3956
5095
  clearTimeout(deadline);
3957
- resolve();
5096
+ finish();
3958
5097
  });
3959
5098
  });
3960
5099
  return closing;
@@ -4052,6 +5191,6 @@ function createFileProfileStore(path = join(process.cwd(), ".workerdeck", "profi
4052
5191
  };
4053
5192
  }
4054
5193
  //#endregion
4055
- export { AttachmentStore, BridgeHub, MemorySessionStore, ProducedFileStore, ProfileUsageTracker, SessionNotifier, SessionParkManager, SessionRegistry, SpendLedger, createFileProfileStore, createFileSessionStore, createFileSpendStore, createMemoryProfileStore, createProviderRunner, createWorkerServer, dayKey, isDormant, reloadPlan, sandboxedProviderProfile, toDurableRecord };
5194
+ export { AttachmentStore, BridgeHub, MemorySessionStore, ProducedFileStore, ProfileUsageTracker, SHELL_REFUSAL, SessionNotifier, SessionParkManager, SessionRegistry, SpendLedger, createFileProfileStore, createFileSessionStore, createFileSpendStore, createMemoryProfileStore, createProviderRunner, createShellRegistry, createWorkerServer, dayKey, isDormant, reloadPlan, sandboxedProviderProfile, shellPermitted, toDurableRecord };
4056
5195
 
4057
5196
  //# sourceMappingURL=index.mjs.map