@linxin666/dsh-ssh 0.4.2 → 0.4.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/lib/client.js +303 -459
  2. package/lib/client.js.map +1 -1
  3. package/lib/index.js +707 -466
  4. package/lib/types/client/api.d.ts +27 -4
  5. package/lib/types/client/native-panel.d.ts +70 -0
  6. package/lib/types/client/panel/SshPanel.d.ts +1 -3
  7. package/lib/types/client/panel/TerminalTab.d.ts +6 -1
  8. package/lib/types/client/panel/controller.d.ts +71 -4
  9. package/lib/types/engine/terminal-sessions.d.ts +67 -0
  10. package/lib/types/mount-once.d.ts +38 -7
  11. package/lib/types/protocol.d.ts +12 -1
  12. package/lib/types/routes.d.ts +2 -0
  13. package/package.json +5 -2
  14. package/src/client/api.ts +52 -22
  15. package/src/client/index.ts +30 -8
  16. package/src/client/native-panel.tsx +141 -0
  17. package/src/client/panel/SshPanel.tsx +25 -28
  18. package/src/client/panel/TerminalTab.tsx +54 -14
  19. package/src/client/panel/controller.ts +126 -7
  20. package/src/client/panel/panel.module.css +8 -128
  21. package/src/engine/terminal-sessions.ts +280 -0
  22. package/src/index.ts +4 -1
  23. package/src/mount-once.ts +107 -13
  24. package/src/protocol.ts +6 -1
  25. package/src/routes.ts +26 -107
  26. package/lib/types/client/body-mutations.d.ts +0 -45
  27. package/lib/types/client/mount.d.ts +0 -27
  28. package/lib/types/client/panel-mount-core.d.ts +0 -56
  29. package/lib/types/client/sidebar-entry-core.d.ts +0 -55
  30. package/lib/types/client/sidebar-entry.d.ts +0 -26
  31. package/src/client/body-mutations.ts +0 -133
  32. package/src/client/mount.tsx +0 -47
  33. package/src/client/panel-mount-core.ts +0 -155
  34. package/src/client/sidebar-entry-core.ts +0 -237
  35. package/src/client/sidebar-entry.ts +0 -51
package/lib/index.js CHANGED
@@ -1673,6 +1673,265 @@ var SshEngine = class {
1673
1673
  }
1674
1674
  };
1675
1675
  //#endregion
1676
+ //#region src/engine/terminal-sessions.ts
1677
+ /**
1678
+ * Host-side terminal sessions for the web terminal.
1679
+ *
1680
+ * The PTY shell is owned here, not by the browser view: a view can detach
1681
+ * (panel switch, page unmount, a dropped socket) and reattach by session id
1682
+ * without tearing the remote shell down, which is what makes the terminal
1683
+ * survival property the old DOM takeover used to provide. A session with no
1684
+ * attached socket is reaped after an idle window, and an exited one is kept
1685
+ * for a short grace window so a reattaching view still sees the final screen.
1686
+ */
1687
+ /** Pause the shell when an attached socket's send buffer exceeds this… */
1688
+ const BACKPRESSURE_HIGH_WATER = 1024 * 1024;
1689
+ /** …and resume once every attached socket drains below this. */
1690
+ const BACKPRESSURE_LOW_WATER = 512 * 1024;
1691
+ /** Scrollback retained for a reattaching view (bytes of UTF-8 output). */
1692
+ const DEFAULT_MAX_BUFFER_BYTES = 256 * 1024;
1693
+ /** A detached session is reaped after this long. */
1694
+ const DEFAULT_IDLE_MS = 600 * 1e3;
1695
+ /** An exited session is kept this long for a reattaching view. */
1696
+ const DEFAULT_EXIT_GRACE_MS = 60 * 1e3;
1697
+ /**
1698
+ * The host's terminal session table: open one session per connect, attach any
1699
+ * number of sockets, and keep the shell alive across detach.
1700
+ */
1701
+ var TerminalSessionRegistry = class {
1702
+ sessions = /* @__PURE__ */ new Map();
1703
+ deps;
1704
+ maxBufferBytes;
1705
+ idleMs;
1706
+ exitGraceMs;
1707
+ constructor(deps) {
1708
+ this.deps = deps;
1709
+ this.maxBufferBytes = deps.maxBufferBytes ?? DEFAULT_MAX_BUFFER_BYTES;
1710
+ this.idleMs = deps.idleMs ?? DEFAULT_IDLE_MS;
1711
+ this.exitGraceMs = deps.exitGraceMs ?? DEFAULT_EXIT_GRACE_MS;
1712
+ }
1713
+ /** Open a new session for the alias and attach the requesting socket to it. */
1714
+ async open(ws, alias, size) {
1715
+ const session = {
1716
+ id: randomBytes(9).toString("hex"),
1717
+ alias,
1718
+ sockets: /* @__PURE__ */ new Set(),
1719
+ buffer: [],
1720
+ bufferBytes: 0,
1721
+ exited: false
1722
+ };
1723
+ this.sessions.set(session.id, session);
1724
+ this.bind(ws, session);
1725
+ try {
1726
+ const shell = await this.deps.openShell(alias, size, this.keyboardInteractive(session));
1727
+ if (!this.sessions.has(session.id)) {
1728
+ shell.close();
1729
+ return;
1730
+ }
1731
+ session.shell = shell;
1732
+ shell.onData = (data) => {
1733
+ this.pushOutput(session, data.toString("utf8"));
1734
+ };
1735
+ shell.onExit = (code, error) => {
1736
+ this.finish(session, code, error);
1737
+ };
1738
+ this.send(session, {
1739
+ type: "ready",
1740
+ alias,
1741
+ sessionId: session.id
1742
+ });
1743
+ } catch (error) {
1744
+ this.sessions.delete(session.id);
1745
+ this.send(session, {
1746
+ type: "exit",
1747
+ code: null,
1748
+ error: error instanceof Error ? error.message : String(error)
1749
+ });
1750
+ }
1751
+ }
1752
+ /**
1753
+ * Attach a socket to an existing session, replaying its scrollback first.
1754
+ * @returns false when the session is unknown (expired or already reaped).
1755
+ */
1756
+ attach(ws, sessionId, size) {
1757
+ const session = this.sessions.get(sessionId);
1758
+ if (session === void 0) return false;
1759
+ this.bind(ws, session);
1760
+ this.clearReap(session);
1761
+ for (const chunk of session.buffer) this.sendTo(session, ws, {
1762
+ type: "output",
1763
+ data: chunk
1764
+ });
1765
+ this.sendTo(session, ws, {
1766
+ type: "ready",
1767
+ alias: session.alias,
1768
+ sessionId: session.id
1769
+ });
1770
+ if (session.exited) {
1771
+ this.sendTo(session, ws, {
1772
+ type: "exit",
1773
+ code: session.exit?.code ?? null,
1774
+ error: session.exit?.error
1775
+ });
1776
+ this.scheduleReap(session, this.exitGraceMs);
1777
+ } else {
1778
+ session.shell?.resize(Math.max(2, size.cols), Math.max(1, size.rows));
1779
+ session.shell?.resume();
1780
+ }
1781
+ return true;
1782
+ }
1783
+ /** End every session (host teardown, plugin dispose). */
1784
+ dispose() {
1785
+ for (const session of [...this.sessions.values()]) this.end(session, false);
1786
+ this.sessions.clear();
1787
+ }
1788
+ /** Number of live sessions (introspection for tests and diagnostics). */
1789
+ size() {
1790
+ return this.sessions.size;
1791
+ }
1792
+ /** Wire one socket's frames and lifecycle onto a session. */
1793
+ bind(ws, session) {
1794
+ session.sockets.add(ws);
1795
+ ws.on("message", (data) => {
1796
+ let frame;
1797
+ try {
1798
+ frame = JSON.parse(String(data));
1799
+ } catch {
1800
+ return;
1801
+ }
1802
+ if (frame.type === "input") session.shell?.send(frame.data ?? "");
1803
+ else if (frame.type === "resize") session.shell?.resize(Math.max(2, frame.cols ?? 80), Math.max(1, frame.rows ?? 24));
1804
+ else if (frame.type === "auth_response") {
1805
+ const finish = session.pendingAuthFinish;
1806
+ session.pendingAuthFinish = void 0;
1807
+ try {
1808
+ finish?.(frame.responses ?? []);
1809
+ } catch {}
1810
+ } else if (frame.type === "close") this.end(session, true);
1811
+ else if (frame.type === "detach") this.detach(ws);
1812
+ });
1813
+ ws.on("close", () => {
1814
+ this.detach(ws);
1815
+ });
1816
+ ws.on("error", () => {
1817
+ this.detach(ws);
1818
+ });
1819
+ }
1820
+ /** Remove a socket from its session, keeping the shell alive. */
1821
+ detach(ws) {
1822
+ for (const session of this.sessions.values()) {
1823
+ if (!session.sockets.delete(ws)) continue;
1824
+ if (session.sockets.size === 0) this.scheduleReap(session, session.exited ? this.exitGraceMs : this.idleMs);
1825
+ return;
1826
+ }
1827
+ }
1828
+ /** End the session a socket is attached to (explicit close from the view). */
1829
+ end(session, notify) {
1830
+ this.clearReap(session);
1831
+ if (this.sessions.delete(session.id) && notify) {
1832
+ if (session.pendingAuthFinish !== void 0) {
1833
+ const finish = session.pendingAuthFinish;
1834
+ session.pendingAuthFinish = void 0;
1835
+ try {
1836
+ finish([]);
1837
+ } catch {}
1838
+ }
1839
+ }
1840
+ try {
1841
+ session.shell?.close();
1842
+ } catch {}
1843
+ }
1844
+ /** Mark a session exited and keep it briefly so a reattaching view sees it. */
1845
+ finish(session, code, error) {
1846
+ if (session.exited) return;
1847
+ session.exited = true;
1848
+ session.exit = {
1849
+ code,
1850
+ error
1851
+ };
1852
+ this.send(session, {
1853
+ type: "exit",
1854
+ code,
1855
+ error
1856
+ });
1857
+ for (const ws of [...session.sockets]) {
1858
+ session.sockets.delete(ws);
1859
+ try {
1860
+ ws.close(1e3);
1861
+ } catch {}
1862
+ }
1863
+ this.scheduleReap(session, this.exitGraceMs);
1864
+ }
1865
+ /** Append output to the replay buffer, trimmed to the byte cap. */
1866
+ pushOutput(session, data) {
1867
+ session.buffer.push(data);
1868
+ session.bufferBytes += Buffer.byteLength(data);
1869
+ while (session.bufferBytes > this.maxBufferBytes && session.buffer.length > 1) {
1870
+ const dropped = session.buffer.shift();
1871
+ session.bufferBytes -= Buffer.byteLength(dropped ?? "");
1872
+ }
1873
+ this.send(session, {
1874
+ type: "output",
1875
+ data
1876
+ });
1877
+ }
1878
+ /** Broadcast one frame to every attached socket, honoring backpressure. */
1879
+ send(session, frame) {
1880
+ for (const ws of session.sockets) this.sendTo(session, ws, frame);
1881
+ this.applyBackpressure(session);
1882
+ }
1883
+ sendTo(session, ws, frame) {
1884
+ if (ws.readyState !== WebSocket.OPEN) return;
1885
+ try {
1886
+ ws.send(JSON.stringify(frame));
1887
+ } catch {}
1888
+ }
1889
+ /** Pause while any attached socket is backed up; resume once all drained. */
1890
+ applyBackpressure(session) {
1891
+ const shell = session.shell;
1892
+ if (shell === void 0) return;
1893
+ let high = false;
1894
+ let low = true;
1895
+ for (const ws of session.sockets) {
1896
+ if (ws.bufferedAmount > BACKPRESSURE_HIGH_WATER) high = true;
1897
+ if (ws.bufferedAmount > BACKPRESSURE_LOW_WATER) low = false;
1898
+ }
1899
+ if (high) shell.pause();
1900
+ else if (low) shell.resume();
1901
+ }
1902
+ /** Answer a keyboard-interactive challenge through whoever is attached. */
1903
+ keyboardInteractive(session) {
1904
+ return (name, instructions, _lang, prompts, finish) => {
1905
+ session.pendingAuthFinish = finish;
1906
+ this.send(session, {
1907
+ type: "auth_prompt",
1908
+ name,
1909
+ instructions,
1910
+ prompts: prompts.map((p) => ({
1911
+ prompt: p.prompt,
1912
+ echo: p.echo
1913
+ }))
1914
+ });
1915
+ };
1916
+ }
1917
+ scheduleReap(session, delayMs) {
1918
+ this.clearReap(session);
1919
+ const timer = setTimeout(() => {
1920
+ session.reapTimer = void 0;
1921
+ if (session.sockets.size > 0) return;
1922
+ this.end(session, false);
1923
+ }, delayMs);
1924
+ timer.unref?.();
1925
+ session.reapTimer = timer;
1926
+ }
1927
+ clearReap(session) {
1928
+ if (session.reapTimer !== void 0) {
1929
+ clearTimeout(session.reapTimer);
1930
+ session.reapTimer = void 0;
1931
+ }
1932
+ }
1933
+ };
1934
+ //#endregion
1676
1935
  //#region src/http.ts
1677
1936
  /** Default body cap for readJsonBody: 64 KiB. */
1678
1937
  const DEFAULT_JSON_BODY_MAX_BYTES = 64 * 1024;
@@ -1806,10 +2065,6 @@ const MAX_UPLOAD_BYTES = 4 * 1024 * 1024 * 1024;
1806
2065
  * frames (the webserver hands us the raw upgraded socket).
1807
2066
  */
1808
2067
  const terminalWss = new WebSocketServer({ noServer: true });
1809
- /** Pause the shell when the socket's send buffer exceeds this… */
1810
- const BACKPRESSURE_HIGH_WATER = 1024 * 1024;
1811
- /** …and resume once it drains below this. */
1812
- const BACKPRESSURE_LOW_WATER = 512 * 1024;
1813
2068
  /** URL query helper (first value, decoded). */
1814
2069
  function queryParam(url, name) {
1815
2070
  const value = url.searchParams.get(name);
@@ -1840,391 +2095,393 @@ function makeRoutes(deps) {
1840
2095
  }
1841
2096
  return true;
1842
2097
  };
1843
- return {
1844
- routes: [
1845
- {
1846
- kind: "exact",
1847
- path: SSH_API.hosts,
1848
- handler: async (req, res) => {
1849
- const method = req.method ?? "GET";
1850
- if (!isLoopbackRequest(req)) {
1851
- writeJson(res, 403, { error: "forbidden: loopback-only" });
1852
- return;
1853
- }
1854
- const url = new URL(req.url ?? "/", "http://localhost");
1855
- if (method === "GET") {
1856
- writeJson(res, 200, { hosts: engine.list(queryParam(url, "query")) });
1857
- return;
1858
- }
1859
- if (method === "POST") {
1860
- const body = await readJsonBody(req);
1861
- if (body === null) {
1862
- writeJson(res, 400, { error: "invalid JSON body" });
1863
- return;
1864
- }
1865
- try {
1866
- const entry = store.create(body);
1867
- writeJson(res, 201, { host: store.summarize(entry) });
1868
- } catch (error) {
1869
- writeJson(res, 400, { error: error instanceof Error ? error.message : String(error) });
1870
- }
1871
- return;
1872
- }
1873
- if (method !== "PATCH" && method !== "DELETE") {
1874
- writeJson(res, 405, { error: `method not allowed: ${method}` });
1875
- return;
1876
- }
1877
- const alias = queryParam(url, "alias");
1878
- if (alias === void 0 || alias === "") {
1879
- writeJson(res, 400, { error: "alias query parameter is required" });
1880
- return;
1881
- }
1882
- if (method === "PATCH") {
1883
- const body = await readJsonBody(req);
1884
- if (body === null) {
1885
- writeJson(res, 400, { error: "invalid JSON body" });
1886
- return;
1887
- }
1888
- try {
1889
- const entry = store.update(alias, body);
1890
- const patch = body;
1891
- if ([
1892
- "host",
1893
- "port",
1894
- "user",
1895
- "auth",
1896
- "proxyJump",
1897
- "proxyCommand"
1898
- ].some((key) => patch[key] !== void 0)) engine.dropAlias(alias);
1899
- writeJson(res, 200, { host: store.summarize(entry) });
1900
- } catch (error) {
1901
- writeJson(res, 400, { error: error instanceof Error ? error.message : String(error) });
1902
- }
1903
- return;
1904
- }
1905
- if (method === "DELETE") {
1906
- try {
1907
- engine.dropAlias(alias);
1908
- store.delete(alias);
1909
- writeJson(res, 200, { ok: true });
1910
- } catch (error) {
1911
- writeJson(res, 400, { error: error instanceof Error ? error.message : String(error) });
1912
- }
1913
- return;
1914
- }
1915
- writeJson(res, 405, { error: `method not allowed: ${method}` });
2098
+ const routes = [
2099
+ {
2100
+ kind: "exact",
2101
+ path: SSH_API.hosts,
2102
+ handler: async (req, res) => {
2103
+ const method = req.method ?? "GET";
2104
+ if (!isLoopbackRequest(req)) {
2105
+ writeJson(res, 403, { error: "forbidden: loopback-only" });
2106
+ return;
1916
2107
  }
1917
- },
1918
- {
1919
- kind: "exact",
1920
- path: SSH_API.importSshConfig,
1921
- handler: async (req, res) => {
1922
- if (!guard(req, res, "POST")) return;
1923
- try {
1924
- writeJson(res, 200, { result: store.importFromSshConfig() });
1925
- } catch (error) {
1926
- writeJson(res, 500, { error: error instanceof Error ? error.message : String(error) });
1927
- }
2108
+ const url = new URL(req.url ?? "/", "http://localhost");
2109
+ if (method === "GET") {
2110
+ writeJson(res, 200, { hosts: engine.list(queryParam(url, "query")) });
2111
+ return;
1928
2112
  }
1929
- },
1930
- {
1931
- kind: "exact",
1932
- path: SSH_API.test,
1933
- handler: async (req, res) => {
1934
- if (!guard(req, res, "POST")) return;
2113
+ if (method === "POST") {
1935
2114
  const body = await readJsonBody(req);
1936
- const alias = typeof body?.alias === "string" ? body.alias : "";
1937
- if (alias === "") {
1938
- writeJson(res, 400, { error: "alias is required" });
2115
+ if (body === null) {
2116
+ writeJson(res, 400, { error: "invalid JSON body" });
1939
2117
  return;
1940
2118
  }
1941
2119
  try {
1942
- writeJson(res, 200, { result: await engine.test(alias) });
2120
+ const entry = store.create(body);
2121
+ writeJson(res, 201, { host: store.summarize(entry) });
1943
2122
  } catch (error) {
1944
- writeJson(res, 500, { error: error instanceof Error ? error.message : String(error) });
2123
+ writeJson(res, 400, { error: error instanceof Error ? error.message : String(error) });
1945
2124
  }
2125
+ return;
1946
2126
  }
1947
- },
1948
- {
1949
- kind: "exact",
1950
- path: SSH_API.exec,
1951
- handler: async (req, res) => {
1952
- if (!guard(req, res, "POST")) return;
2127
+ if (method !== "PATCH" && method !== "DELETE") {
2128
+ writeJson(res, 405, { error: `method not allowed: ${method}` });
2129
+ return;
2130
+ }
2131
+ const alias = queryParam(url, "alias");
2132
+ if (alias === void 0 || alias === "") {
2133
+ writeJson(res, 400, { error: "alias query parameter is required" });
2134
+ return;
2135
+ }
2136
+ if (method === "PATCH") {
1953
2137
  const body = await readJsonBody(req);
1954
- const alias = typeof body?.alias === "string" ? body.alias : "";
1955
- const command = typeof body?.command === "string" ? body.command : "";
1956
- if (alias === "" || command === "") {
1957
- writeJson(res, 400, { error: "alias and command are required" });
2138
+ if (body === null) {
2139
+ writeJson(res, 400, { error: "invalid JSON body" });
1958
2140
  return;
1959
2141
  }
1960
- const timeoutMs = typeof body?.timeoutMs === "number" ? body.timeoutMs : void 0;
1961
2142
  try {
1962
- writeJson(res, 200, { result: await engine.exec(alias, command, timeoutMs) });
2143
+ const entry = store.update(alias, body);
2144
+ const patch = body;
2145
+ if ([
2146
+ "host",
2147
+ "port",
2148
+ "user",
2149
+ "auth",
2150
+ "proxyJump",
2151
+ "proxyCommand"
2152
+ ].some((key) => patch[key] !== void 0)) engine.dropAlias(alias);
2153
+ writeJson(res, 200, { host: store.summarize(entry) });
1963
2154
  } catch (error) {
1964
- writeJson(res, 500, { error: error instanceof Error ? error.message : String(error) });
2155
+ writeJson(res, 400, { error: error instanceof Error ? error.message : String(error) });
1965
2156
  }
2157
+ return;
1966
2158
  }
1967
- },
1968
- {
1969
- kind: "exact",
1970
- path: SSH_API.cluster,
1971
- handler: async (req, res) => {
1972
- if (!guard(req, res, "POST")) return;
1973
- const body = await readJsonBody(req);
1974
- const command = typeof body?.command === "string" ? body.command : "";
1975
- if (command === "") {
1976
- writeJson(res, 400, { error: "command is required" });
1977
- return;
1978
- }
1979
- const aliases = Array.isArray(body?.aliases) ? body.aliases.filter((x) => typeof x === "string") : void 0;
1980
- const tags = Array.isArray(body?.tags) ? body.tags.filter((x) => typeof x === "string") : void 0;
1981
- const environment = typeof body?.environment === "string" ? body.environment : void 0;
1982
- const timeoutMs = typeof body?.timeoutMs === "number" ? body.timeoutMs : void 0;
1983
- const maxWorkers = typeof body?.maxWorkers === "number" ? body.maxWorkers : void 0;
1984
- const hasAliases = aliases?.some((alias) => alias.trim() !== "") === true;
1985
- const hasEnvironment = typeof environment === "string" && environment.trim() !== "";
1986
- const hasTags = tags?.some((tag) => tag.trim() !== "") === true;
1987
- if (!hasAliases && !hasEnvironment && !hasTags) {
1988
- writeJson(res, 400, { error: "ssh_cluster requires aliases, environment, or tags to limit the target set" });
1989
- return;
1990
- }
2159
+ if (method === "DELETE") {
1991
2160
  try {
1992
- writeJson(res, 200, { results: await engine.cluster({
1993
- command,
1994
- aliases,
1995
- environment,
1996
- tags,
1997
- timeoutMs,
1998
- maxWorkers
1999
- }) });
2161
+ engine.dropAlias(alias);
2162
+ store.delete(alias);
2163
+ writeJson(res, 200, { ok: true });
2000
2164
  } catch (error) {
2001
- writeJson(res, 500, { error: error instanceof Error ? error.message : String(error) });
2165
+ writeJson(res, 400, { error: error instanceof Error ? error.message : String(error) });
2002
2166
  }
2167
+ return;
2003
2168
  }
2004
- },
2005
- {
2006
- kind: "exact",
2007
- path: SSH_API.ls,
2008
- handler: async (req, res) => {
2009
- if (!guard(req, res, "GET")) return;
2010
- const url = new URL(req.url ?? "/", "http://localhost");
2011
- const alias = queryParam(url, "alias");
2012
- const path = queryParam(url, "path") ?? "/";
2013
- if (alias === void 0 || alias === "") {
2014
- writeJson(res, 400, { error: "alias query parameter is required" });
2169
+ writeJson(res, 405, { error: `method not allowed: ${method}` });
2170
+ }
2171
+ },
2172
+ {
2173
+ kind: "exact",
2174
+ path: SSH_API.importSshConfig,
2175
+ handler: async (req, res) => {
2176
+ if (!guard(req, res, "POST")) return;
2177
+ try {
2178
+ writeJson(res, 200, { result: store.importFromSshConfig() });
2179
+ } catch (error) {
2180
+ writeJson(res, 500, { error: error instanceof Error ? error.message : String(error) });
2181
+ }
2182
+ }
2183
+ },
2184
+ {
2185
+ kind: "exact",
2186
+ path: SSH_API.test,
2187
+ handler: async (req, res) => {
2188
+ if (!guard(req, res, "POST")) return;
2189
+ const body = await readJsonBody(req);
2190
+ const alias = typeof body?.alias === "string" ? body.alias : "";
2191
+ if (alias === "") {
2192
+ writeJson(res, 400, { error: "alias is required" });
2193
+ return;
2194
+ }
2195
+ try {
2196
+ writeJson(res, 200, { result: await engine.test(alias) });
2197
+ } catch (error) {
2198
+ writeJson(res, 500, { error: error instanceof Error ? error.message : String(error) });
2199
+ }
2200
+ }
2201
+ },
2202
+ {
2203
+ kind: "exact",
2204
+ path: SSH_API.exec,
2205
+ handler: async (req, res) => {
2206
+ if (!guard(req, res, "POST")) return;
2207
+ const body = await readJsonBody(req);
2208
+ const alias = typeof body?.alias === "string" ? body.alias : "";
2209
+ const command = typeof body?.command === "string" ? body.command : "";
2210
+ if (alias === "" || command === "") {
2211
+ writeJson(res, 400, { error: "alias and command are required" });
2212
+ return;
2213
+ }
2214
+ const timeoutMs = typeof body?.timeoutMs === "number" ? body.timeoutMs : void 0;
2215
+ try {
2216
+ writeJson(res, 200, { result: await engine.exec(alias, command, timeoutMs) });
2217
+ } catch (error) {
2218
+ writeJson(res, 500, { error: error instanceof Error ? error.message : String(error) });
2219
+ }
2220
+ }
2221
+ },
2222
+ {
2223
+ kind: "exact",
2224
+ path: SSH_API.cluster,
2225
+ handler: async (req, res) => {
2226
+ if (!guard(req, res, "POST")) return;
2227
+ const body = await readJsonBody(req);
2228
+ const command = typeof body?.command === "string" ? body.command : "";
2229
+ if (command === "") {
2230
+ writeJson(res, 400, { error: "command is required" });
2231
+ return;
2232
+ }
2233
+ const aliases = Array.isArray(body?.aliases) ? body.aliases.filter((x) => typeof x === "string") : void 0;
2234
+ const tags = Array.isArray(body?.tags) ? body.tags.filter((x) => typeof x === "string") : void 0;
2235
+ const environment = typeof body?.environment === "string" ? body.environment : void 0;
2236
+ const timeoutMs = typeof body?.timeoutMs === "number" ? body.timeoutMs : void 0;
2237
+ const maxWorkers = typeof body?.maxWorkers === "number" ? body.maxWorkers : void 0;
2238
+ const hasAliases = aliases?.some((alias) => alias.trim() !== "") === true;
2239
+ const hasEnvironment = typeof environment === "string" && environment.trim() !== "";
2240
+ const hasTags = tags?.some((tag) => tag.trim() !== "") === true;
2241
+ if (!hasAliases && !hasEnvironment && !hasTags) {
2242
+ writeJson(res, 400, { error: "ssh_cluster requires aliases, environment, or tags to limit the target set" });
2243
+ return;
2244
+ }
2245
+ try {
2246
+ writeJson(res, 200, { results: await engine.cluster({
2247
+ command,
2248
+ aliases,
2249
+ environment,
2250
+ tags,
2251
+ timeoutMs,
2252
+ maxWorkers
2253
+ }) });
2254
+ } catch (error) {
2255
+ writeJson(res, 500, { error: error instanceof Error ? error.message : String(error) });
2256
+ }
2257
+ }
2258
+ },
2259
+ {
2260
+ kind: "exact",
2261
+ path: SSH_API.ls,
2262
+ handler: async (req, res) => {
2263
+ if (!guard(req, res, "GET")) return;
2264
+ const url = new URL(req.url ?? "/", "http://localhost");
2265
+ const alias = queryParam(url, "alias");
2266
+ const path = queryParam(url, "path") ?? "/";
2267
+ if (alias === void 0 || alias === "") {
2268
+ writeJson(res, 400, { error: "alias query parameter is required" });
2269
+ return;
2270
+ }
2271
+ try {
2272
+ writeJson(res, 200, { entries: await engine.ls(alias, path) });
2273
+ } catch (error) {
2274
+ writeJson(res, 400, { error: error instanceof Error ? error.message : String(error) });
2275
+ }
2276
+ }
2277
+ },
2278
+ {
2279
+ kind: "exact",
2280
+ path: SSH_API.tunnel,
2281
+ handler: async (req, res) => {
2282
+ if (!guard(req, res, "POST")) return;
2283
+ const body = await readJsonBody(req);
2284
+ const action = typeof body?.action === "string" ? body.action : "";
2285
+ if (action === "list") {
2286
+ writeJson(res, 200, { tunnels: engine.listTunnels() });
2287
+ return;
2288
+ }
2289
+ if (action === "start") {
2290
+ const alias = typeof body?.alias === "string" ? body.alias : "";
2291
+ const remotePort = typeof body?.remotePort === "number" ? body.remotePort : void 0;
2292
+ if (alias === "" || remotePort === void 0) {
2293
+ writeJson(res, 400, { error: "alias and remotePort are required" });
2015
2294
  return;
2016
2295
  }
2017
2296
  try {
2018
- writeJson(res, 200, { entries: await engine.ls(alias, path) });
2297
+ writeJson(res, 200, { tunnel: await engine.startTunnel(alias, {
2298
+ remotePort,
2299
+ remoteHost: typeof body?.remoteHost === "string" && body.remoteHost !== "" ? body.remoteHost : void 0,
2300
+ localPort: typeof body?.localPort === "number" ? body.localPort : void 0
2301
+ }) });
2019
2302
  } catch (error) {
2020
- writeJson(res, 400, { error: error instanceof Error ? error.message : String(error) });
2303
+ writeJson(res, 500, { error: error instanceof Error ? error.message : String(error) });
2021
2304
  }
2305
+ return;
2022
2306
  }
2023
- },
2024
- {
2025
- kind: "exact",
2026
- path: SSH_API.tunnel,
2027
- handler: async (req, res) => {
2028
- if (!guard(req, res, "POST")) return;
2029
- const body = await readJsonBody(req);
2030
- const action = typeof body?.action === "string" ? body.action : "";
2031
- if (action === "list") {
2032
- writeJson(res, 200, { tunnels: engine.listTunnels() });
2307
+ if (action === "stop") {
2308
+ const id = typeof body?.tunnelId === "string" ? body.tunnelId : "";
2309
+ if (id === "") {
2310
+ writeJson(res, 400, { error: "tunnelId is required" });
2033
2311
  return;
2034
2312
  }
2035
- if (action === "start") {
2036
- const alias = typeof body?.alias === "string" ? body.alias : "";
2037
- const remotePort = typeof body?.remotePort === "number" ? body.remotePort : void 0;
2038
- if (alias === "" || remotePort === void 0) {
2039
- writeJson(res, 400, { error: "alias and remotePort are required" });
2040
- return;
2041
- }
2042
- try {
2043
- writeJson(res, 200, { tunnel: await engine.startTunnel(alias, {
2044
- remotePort,
2045
- remoteHost: typeof body?.remoteHost === "string" && body.remoteHost !== "" ? body.remoteHost : void 0,
2046
- localPort: typeof body?.localPort === "number" ? body.localPort : void 0
2047
- }) });
2048
- } catch (error) {
2049
- writeJson(res, 500, { error: error instanceof Error ? error.message : String(error) });
2050
- }
2051
- return;
2052
- }
2053
- if (action === "stop") {
2054
- const id = typeof body?.tunnelId === "string" ? body.tunnelId : "";
2055
- if (id === "") {
2056
- writeJson(res, 400, { error: "tunnelId is required" });
2057
- return;
2058
- }
2059
- writeJson(res, 200, { ok: engine.stopTunnel(id) });
2060
- return;
2061
- }
2062
- if (action === "stop-all") {
2063
- const alias = typeof body?.alias === "string" ? body.alias : void 0;
2064
- writeJson(res, 200, { stopped: engine.stopAllTunnels(alias === "" ? void 0 : alias) });
2065
- return;
2066
- }
2067
- writeJson(res, 400, { error: `unknown action '${action}'` });
2313
+ writeJson(res, 200, { ok: engine.stopTunnel(id) });
2314
+ return;
2068
2315
  }
2069
- },
2070
- {
2071
- kind: "exact",
2072
- path: SSH_API.upload,
2073
- handler: async (req, res) => {
2074
- if (!guard(req, res, "POST")) return;
2075
- const url = new URL(req.url ?? "/", "http://localhost");
2076
- const alias = queryParam(url, "alias");
2077
- const remotePath = queryParam(url, "remotePath");
2078
- if (alias === void 0 || remotePath === void 0) {
2079
- writeJson(res, 400, { error: "alias and remotePath query parameters are required" });
2080
- return;
2081
- }
2082
- const declared = Number(req.headers["content-length"]);
2083
- if (Number.isFinite(declared) && declared > maxUploadBytes) {
2084
- writeJson(res, 413, { error: "upload body too large" });
2085
- return;
2086
- }
2087
- res.writeHead(200, {
2088
- "content-type": "application/x-ndjson; charset=utf-8",
2089
- "cache-control": "no-cache",
2090
- "referrer-policy": "no-referrer"
2316
+ if (action === "stop-all") {
2317
+ const alias = typeof body?.alias === "string" ? body.alias : void 0;
2318
+ writeJson(res, 200, { stopped: engine.stopAllTunnels(alias === "" ? void 0 : alias) });
2319
+ return;
2320
+ }
2321
+ writeJson(res, 400, { error: `unknown action '${action}'` });
2322
+ }
2323
+ },
2324
+ {
2325
+ kind: "exact",
2326
+ path: SSH_API.upload,
2327
+ handler: async (req, res) => {
2328
+ if (!guard(req, res, "POST")) return;
2329
+ const url = new URL(req.url ?? "/", "http://localhost");
2330
+ const alias = queryParam(url, "alias");
2331
+ const remotePath = queryParam(url, "remotePath");
2332
+ if (alias === void 0 || remotePath === void 0) {
2333
+ writeJson(res, 400, { error: "alias and remotePath query parameters are required" });
2334
+ return;
2335
+ }
2336
+ const declared = Number(req.headers["content-length"]);
2337
+ if (Number.isFinite(declared) && declared > maxUploadBytes) {
2338
+ writeJson(res, 413, { error: "upload body too large" });
2339
+ return;
2340
+ }
2341
+ res.writeHead(200, {
2342
+ "content-type": "application/x-ndjson; charset=utf-8",
2343
+ "cache-control": "no-cache",
2344
+ "referrer-policy": "no-referrer"
2345
+ });
2346
+ const emit = (line) => {
2347
+ try {
2348
+ res.write(JSON.stringify(line) + "\n");
2349
+ } catch {}
2350
+ };
2351
+ const tmp = join(staging, `upload-${randomBytes(6).toString("hex")}`);
2352
+ const sink = createWriteStream(tmp, { mode: 384 });
2353
+ let settled = false;
2354
+ const fail = (error) => {
2355
+ if (settled) return;
2356
+ settled = true;
2357
+ emit({
2358
+ type: "result",
2359
+ ok: false,
2360
+ error: error instanceof Error ? error.message : String(error)
2091
2361
  });
2092
- const emit = (line) => {
2093
- try {
2094
- res.write(JSON.stringify(line) + "\n");
2095
- } catch {}
2096
- };
2097
- const tmp = join(staging, `upload-${randomBytes(6).toString("hex")}`);
2098
- const sink = createWriteStream(tmp, { mode: 384 });
2099
- let settled = false;
2100
- const fail = (error) => {
2101
- if (settled) return;
2102
- settled = true;
2103
- emit({
2104
- type: "result",
2105
- ok: false,
2106
- error: error instanceof Error ? error.message : String(error)
2107
- });
2108
- const cleanup = () => {
2109
- unlink(tmp).catch(() => void 0).finally(() => {
2110
- try {
2111
- res.end();
2112
- } catch {}
2113
- });
2114
- };
2115
- if (sink.destroyed) cleanup();
2116
- else {
2117
- sink.once("close", cleanup);
2362
+ const cleanup = () => {
2363
+ unlink(tmp).catch(() => void 0).finally(() => {
2118
2364
  try {
2119
- sink.destroy();
2120
- } catch {
2121
- cleanup();
2122
- }
2123
- }
2365
+ res.end();
2366
+ } catch {}
2367
+ });
2124
2368
  };
2125
- const done = () => {
2126
- if (settled) return;
2127
- settled = true;
2369
+ if (sink.destroyed) cleanup();
2370
+ else {
2371
+ sink.once("close", cleanup);
2128
2372
  try {
2129
- res.end();
2130
- } catch {}
2131
- };
2132
- sink.on("error", (error) => fail(error));
2133
- req.on("error", (error) => fail(error));
2134
- req.on("aborted", () => fail("upload aborted by the client"));
2135
- res.on("error", () => fail("response stream closed"));
2136
- res.on("close", () => {
2137
- if (!res.writableEnded) fail("connection closed");
2138
- });
2139
- let received = 0;
2140
- let capped = false;
2141
- req.on("data", (chunk) => {
2142
- received += chunk.byteLength;
2143
- if (received > maxUploadBytes && !capped) {
2144
- capped = true;
2145
- fail("upload body too large");
2146
- res.on("finish", () => {
2147
- try {
2148
- req.destroy();
2149
- } catch {}
2150
- });
2151
- req.resume();
2373
+ sink.destroy();
2374
+ } catch {
2375
+ cleanup();
2152
2376
  }
2153
- });
2154
- req.pipe(sink);
2155
- sink.on("finish", async () => {
2156
- if (settled) return;
2157
- emit({
2158
- type: "progress",
2159
- progress: {
2160
- phase: "connecting",
2161
- file: remotePath,
2162
- transferred: 0,
2163
- total: 0,
2164
- percent: 0
2165
- }
2377
+ }
2378
+ };
2379
+ const done = () => {
2380
+ if (settled) return;
2381
+ settled = true;
2382
+ try {
2383
+ res.end();
2384
+ } catch {}
2385
+ };
2386
+ sink.on("error", (error) => fail(error));
2387
+ req.on("error", (error) => fail(error));
2388
+ req.on("aborted", () => fail("upload aborted by the client"));
2389
+ res.on("error", () => fail("response stream closed"));
2390
+ res.on("close", () => {
2391
+ if (!res.writableEnded) fail("connection closed");
2392
+ });
2393
+ let received = 0;
2394
+ let capped = false;
2395
+ req.on("data", (chunk) => {
2396
+ received += chunk.byteLength;
2397
+ if (received > maxUploadBytes && !capped) {
2398
+ capped = true;
2399
+ fail("upload body too large");
2400
+ res.on("finish", () => {
2401
+ try {
2402
+ req.destroy();
2403
+ } catch {}
2166
2404
  });
2167
- try {
2168
- const outcome = await engine.upload(alias, tmp, remotePath, false, (progress) => emit({
2169
- type: "progress",
2170
- progress
2171
- }));
2172
- emit({
2173
- type: "result",
2174
- ok: true,
2175
- transferredBytes: outcome.bytes
2176
- });
2177
- } catch (error) {
2178
- emit({
2179
- type: "result",
2180
- ok: false,
2181
- error: error instanceof Error ? error.message : String(error)
2182
- });
2183
- } finally {
2184
- await unlink(tmp).catch(() => void 0);
2185
- done();
2405
+ req.resume();
2406
+ }
2407
+ });
2408
+ req.pipe(sink);
2409
+ sink.on("finish", async () => {
2410
+ if (settled) return;
2411
+ emit({
2412
+ type: "progress",
2413
+ progress: {
2414
+ phase: "connecting",
2415
+ file: remotePath,
2416
+ transferred: 0,
2417
+ total: 0,
2418
+ percent: 0
2186
2419
  }
2187
2420
  });
2188
- }
2189
- },
2190
- {
2191
- kind: "exact",
2192
- path: SSH_API.download,
2193
- handler: async (req, res) => {
2194
- if (!guard(req, res, "GET")) return;
2195
- const url = new URL(req.url ?? "/", "http://localhost");
2196
- const alias = queryParam(url, "alias");
2197
- const remotePath = queryParam(url, "remotePath");
2198
- if (alias === void 0 || remotePath === void 0) {
2199
- writeJson(res, 400, { error: "alias and remotePath query parameters are required" });
2200
- return;
2201
- }
2202
- const tmp = join(staging, `download-${randomBytes(6).toString("hex")}`);
2203
2421
  try {
2204
- closeSync(openSync(tmp, "w", 384));
2205
- const outcome = await engine.download(alias, remotePath, tmp);
2206
- res.writeHead(200, {
2207
- "content-type": "application/octet-stream",
2208
- "content-length": String(outcome.bytes),
2209
- "content-disposition": `attachment; filename="${basename(remotePath).replace(/"/g, "")}"`,
2210
- "referrer-policy": "no-referrer"
2211
- });
2212
- await new Promise((resolve, reject) => {
2213
- const source = createReadStream(tmp);
2214
- source.on("error", reject);
2215
- res.on("error", reject);
2216
- source.pipe(res);
2217
- source.on("end", resolve);
2422
+ const outcome = await engine.upload(alias, tmp, remotePath, false, (progress) => emit({
2423
+ type: "progress",
2424
+ progress
2425
+ }));
2426
+ emit({
2427
+ type: "result",
2428
+ ok: true,
2429
+ transferredBytes: outcome.bytes
2218
2430
  });
2219
2431
  } catch (error) {
2220
- if (!res.headersSent) writeJson(res, 400, { error: error instanceof Error ? error.message : String(error) });
2221
- else res.destroy();
2432
+ emit({
2433
+ type: "result",
2434
+ ok: false,
2435
+ error: error instanceof Error ? error.message : String(error)
2436
+ });
2222
2437
  } finally {
2223
2438
  await unlink(tmp).catch(() => void 0);
2439
+ done();
2224
2440
  }
2441
+ });
2442
+ }
2443
+ },
2444
+ {
2445
+ kind: "exact",
2446
+ path: SSH_API.download,
2447
+ handler: async (req, res) => {
2448
+ if (!guard(req, res, "GET")) return;
2449
+ const url = new URL(req.url ?? "/", "http://localhost");
2450
+ const alias = queryParam(url, "alias");
2451
+ const remotePath = queryParam(url, "remotePath");
2452
+ if (alias === void 0 || remotePath === void 0) {
2453
+ writeJson(res, 400, { error: "alias and remotePath query parameters are required" });
2454
+ return;
2455
+ }
2456
+ const tmp = join(staging, `download-${randomBytes(6).toString("hex")}`);
2457
+ try {
2458
+ closeSync(openSync(tmp, "w", 384));
2459
+ const outcome = await engine.download(alias, remotePath, tmp);
2460
+ res.writeHead(200, {
2461
+ "content-type": "application/octet-stream",
2462
+ "content-length": String(outcome.bytes),
2463
+ "content-disposition": `attachment; filename="${basename(remotePath).replace(/"/g, "")}"`,
2464
+ "referrer-policy": "no-referrer"
2465
+ });
2466
+ await new Promise((resolve, reject) => {
2467
+ const source = createReadStream(tmp);
2468
+ source.on("error", reject);
2469
+ res.on("error", reject);
2470
+ source.pipe(res);
2471
+ source.on("end", resolve);
2472
+ });
2473
+ } catch (error) {
2474
+ if (!res.headersSent) writeJson(res, 400, { error: error instanceof Error ? error.message : String(error) });
2475
+ else res.destroy();
2476
+ } finally {
2477
+ await unlink(tmp).catch(() => void 0);
2225
2478
  }
2226
2479
  }
2227
- ],
2480
+ }
2481
+ ];
2482
+ const terminalSessions = new TerminalSessionRegistry({ openShell: (alias, size, onKeyboardInteractive) => engine.openShell(alias, size, onKeyboardInteractive) });
2483
+ return {
2484
+ routes,
2228
2485
  upgrade: {
2229
2486
  path: SSH_API.terminal,
2230
2487
  handler: (req, socket, head) => {
@@ -2235,129 +2492,39 @@ function makeRoutes(deps) {
2235
2492
  }
2236
2493
  const url = new URL(req.url ?? "/", "http://localhost");
2237
2494
  const alias = queryParam(url, "alias");
2238
- if (alias === void 0) {
2495
+ const sessionId = queryParam(url, "session");
2496
+ if (alias === void 0 && sessionId === void 0) {
2239
2497
  socket.write("HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n");
2240
2498
  socket.destroy();
2241
2499
  return;
2242
2500
  }
2243
2501
  const cols = Number.parseInt(queryParam(url, "cols") ?? "80", 10);
2244
2502
  const rows = Number.parseInt(queryParam(url, "rows") ?? "24", 10);
2503
+ const size = {
2504
+ cols: Number.isFinite(cols) ? cols : 80,
2505
+ rows: Number.isFinite(rows) ? rows : 24
2506
+ };
2245
2507
  terminalWss.handleUpgrade(req, socket, head, (ws) => {
2246
- let session;
2247
- let closed = false;
2248
- let paused = false;
2249
- let pendingAuthFinish;
2250
- const resume = () => {
2251
- if (paused && ws.bufferedAmount < BACKPRESSURE_LOW_WATER) {
2252
- paused = false;
2253
- session?.resume();
2254
- }
2255
- };
2256
- const sendFrame = (frame) => {
2257
- if (closed || ws.readyState !== WebSocket.OPEN) return;
2258
- ws.send(JSON.stringify(frame), resume);
2259
- if (!paused && ws.bufferedAmount > BACKPRESSURE_HIGH_WATER) {
2260
- paused = true;
2261
- session?.pause();
2262
- }
2263
- };
2264
- const closeSession = () => {
2265
- if (pendingAuthFinish !== void 0) {
2266
- const fn = pendingAuthFinish;
2267
- pendingAuthFinish = void 0;
2268
- try {
2269
- fn([]);
2270
- } catch {}
2271
- }
2272
- const opened = session;
2273
- session = void 0;
2274
- if (opened !== void 0) opened.close();
2275
- };
2276
- const onKeyboardInteractive = (name, instructions, _lang, prompts, finish) => {
2277
- const entry = store.find(alias);
2278
- if (entry?.auth.kind === "password" && entry.auth.password !== void 0 && prompts.length > 0 && prompts.every((p) => /password/i.test(p.prompt))) {
2279
- const password = entry.auth.password;
2280
- finish(prompts.map(() => password));
2281
- return;
2282
- }
2283
- pendingAuthFinish = finish;
2284
- sendFrame({
2285
- type: "auth_prompt",
2286
- name,
2287
- instructions,
2288
- prompts: prompts.map((p) => ({
2289
- prompt: p.prompt,
2290
- echo: p.echo
2291
- }))
2292
- });
2293
- };
2294
- engine.openShell(alias, {
2295
- cols: Number.isFinite(cols) ? cols : 80,
2296
- rows: Number.isFinite(rows) ? rows : 24
2297
- }, onKeyboardInteractive).then((opened) => {
2298
- if (ws.readyState !== WebSocket.OPEN) {
2299
- opened.close();
2300
- return;
2301
- }
2302
- session = opened;
2303
- sendFrame({
2304
- type: "ready",
2305
- alias
2306
- });
2307
- opened.onData = (data) => sendFrame({
2308
- type: "output",
2309
- data: data.toString("utf8")
2310
- });
2311
- opened.onExit = (code, error) => {
2312
- sendFrame({
2313
- type: "exit",
2314
- code,
2315
- error
2316
- });
2317
- closed = true;
2318
- try {
2319
- ws.close(1e3);
2320
- } catch {}
2321
- };
2322
- }).catch((error) => {
2323
- sendFrame({
2508
+ if (sessionId !== void 0) {
2509
+ if (terminalSessions.attach(ws, sessionId, size)) return;
2510
+ const expired = {
2324
2511
  type: "exit",
2325
2512
  code: null,
2326
- error: error instanceof Error ? error.message : String(error)
2327
- });
2328
- closed = true;
2513
+ error: "terminal session expired"
2514
+ };
2329
2515
  try {
2330
- ws.close(1e3);
2516
+ ws.send(JSON.stringify(expired));
2331
2517
  } catch {}
2332
- });
2333
- ws.on("message", (data) => {
2334
- let frame;
2335
2518
  try {
2336
- frame = JSON.parse(String(data));
2337
- } catch {
2338
- return;
2339
- }
2340
- if (frame.type === "input") session?.send(frame.data);
2341
- else if (frame.type === "resize") session?.resize(Math.max(2, frame.cols), Math.max(1, frame.rows));
2342
- else if (frame.type === "auth_response") {
2343
- if (pendingAuthFinish !== void 0) {
2344
- const fn = pendingAuthFinish;
2345
- pendingAuthFinish = void 0;
2346
- fn(frame.responses);
2347
- }
2348
- }
2349
- });
2350
- ws.on("close", () => {
2351
- closed = true;
2352
- closeSession();
2353
- });
2354
- ws.on("error", () => {
2355
- closed = true;
2356
- closeSession();
2357
- });
2519
+ ws.close(1e3);
2520
+ } catch {}
2521
+ return;
2522
+ }
2523
+ terminalSessions.open(ws, alias, size);
2358
2524
  });
2359
2525
  }
2360
- }
2526
+ },
2527
+ terminalSessions
2361
2528
  };
2362
2529
  }
2363
2530
  //#endregion
@@ -2961,38 +3128,111 @@ function sshClusterTool(engine) {
2961
3128
  * the loader accepts a standalone install of the same package side by side;
2962
3129
  * without this guard the second instance would still re-register the same
2963
3130
  * webserver routes, tools, settings namespaces, and system-prompt sections
2964
- * and fail the boot. mountOnce makes the second host apply a no-op for the
2965
- * lifetime of the first instance (the browser half is already deduped by
2966
- * package name in the client module host).
3131
+ * and fail the boot. mountOnce makes a mount of an already-mounted package a
3132
+ * no-op for as long as the first instance lives (the browser half is already
3133
+ * deduped by package name in the client module host).
3134
+ *
3135
+ * A no-op is only safe while the holder is ALIVE. The holder can be disposed
3136
+ * long after the refused mount ran its course: the Host reloads a profile by
3137
+ * creating the new loader entries before the old ones are torn down (a
3138
+ * plugin-manager enable/disable/install write, a settings-driven row reload,
3139
+ * HMR), so the new aggregate shell entry mounts its family plugin while the
3140
+ * previous entry still owns the name. Dropping that refused mount lost the
3141
+ * plugin for good - the previous entry then disposed its own mount, releasing
3142
+ * the name with nobody left to take it, and the family row stayed listed as
3143
+ * active while its host routes 404ed (the task-board panel showed
3144
+ * "board.hostError.notMounted", no degraded record appeared, and only a Host
3145
+ * restart recovered it).
3146
+ *
3147
+ * The refused mount is therefore QUEUED, not dropped, and replayed the moment
3148
+ * the holder releases the name - if the waiting fiber is still alive then. The
3149
+ * single-instance guarantee is unchanged: exactly one mount is live per
3150
+ * package name, and the replay re-enters the guard so a later mount still
3151
+ * dedupes against it.
2967
3152
  *
2968
3153
  * The registry rides a global symbol so two module instances of the same
2969
- * package (npm copy vs repository link) still share one verdict. cordis
2970
- * `ctx.effect` runs its callback immediately and treats the callback's
3154
+ * package (npm copy vs repository link) still share one verdict. That symbol
3155
+ * is a CROSS-REPOSITORY contract, not this file's private state: the four
3156
+ * satellite packages (dsh-skins / dsh-pet / dsh-presets /
3157
+ * dsh-community-plugins) are separate repositories carrying their own copy of
3158
+ * this guard, rebuilt on their own schedule, so the value under `MOUNTED`
3159
+ * must keep the shape every published copy reads (a `Set` of package names
3160
+ * with `has`/`add`/`delete`). The wait queues this guard added therefore live
3161
+ * under their own additive key, and the registry reads back a `Set` even when
3162
+ * some other build left a different value there. Changing `MOUNTED`'s shape
3163
+ * in place broke that contract once: a satellite's legacy copy created a
3164
+ * `Set`, the family's new copy read it as a `Map`, and every family row
3165
+ * mounted after it failed with "claims.get is not a function".
3166
+ *
3167
+ * cordis `ctx.effect` runs its callback immediately and treats the callback's
2971
3168
  * return value as the fiber disposer, so the unmarker is returned, not run.
2972
3169
  */
3170
+ /** Published cross-repository contract: package names currently mounted. */
2973
3171
  const MOUNTED = Symbol.for("dsh-web.mounted-plugins");
3172
+ /** Additive key this guard owns: refused mounts waiting for the name. */
3173
+ const WAITERS = Symbol.for("dsh-web.mounted-plugins.waiters");
3174
+ /**
3175
+ * The shared name registry, always a `Set` whatever another build stored here:
3176
+ * a foreign value (an interim shape, a hand-written global) must not take every
3177
+ * family plugin down with it.
3178
+ * @returns the process-wide set of mounted package names.
3179
+ */
2974
3180
  function mountedSet() {
2975
3181
  const registry = globalThis;
2976
- return registry[MOUNTED] ??= /* @__PURE__ */ new Set();
3182
+ const existing = registry[MOUNTED];
3183
+ if (existing instanceof Set) return existing;
3184
+ const created = /* @__PURE__ */ new Set();
3185
+ registry[MOUNTED] = created;
3186
+ return created;
3187
+ }
3188
+ /** Queue per package name for mounts refused while a holder was alive. */
3189
+ function mountWaiters() {
3190
+ const registry = globalThis;
3191
+ return registry[WAITERS] ??= /* @__PURE__ */ new Map();
2977
3192
  }
2978
3193
  /**
2979
3194
  * Wrap a cordis plugin apply so the package runs at most once per process.
2980
- * The first mount registers normally and unmarks when its fiber disposes;
2981
- * any later mount of the same package name is a no-op.
3195
+ * The first mount registers normally and releases the name when its fiber
3196
+ * disposes; a mount refused while that name is held waits for the release and
3197
+ * then runs, unless its own fiber disposes first.
2982
3198
  * @param packageName - npm package identity shared by every install source.
2983
3199
  * @param fn - the original plugin apply.
2984
3200
  * @returns an apply of the same shape.
2985
3201
  */
2986
3202
  function mountOnce(packageName, fn) {
2987
- return ((...args) => {
3203
+ const mount = (...args) => {
2988
3204
  const mounted = mountedSet();
2989
- if (mounted.has(packageName)) return;
3205
+ const ctx = args[0];
3206
+ if (mounted.has(packageName)) {
3207
+ const waiters = mountWaiters();
3208
+ const queue = waiters.get(packageName) ?? [];
3209
+ let alive = true;
3210
+ const pending = { run: () => {
3211
+ if (alive) mount(...args);
3212
+ } };
3213
+ ctx?.effect?.(() => () => {
3214
+ alive = false;
3215
+ const index = queue.indexOf(pending);
3216
+ if (index >= 0) queue.splice(index, 1);
3217
+ });
3218
+ queue.push(pending);
3219
+ waiters.set(packageName, queue);
3220
+ return;
3221
+ }
2990
3222
  mounted.add(packageName);
2991
- args[0]?.effect?.(() => () => {
3223
+ ctx?.effect?.(() => () => {
2992
3224
  mounted.delete(packageName);
3225
+ const waiters = mountWaiters();
3226
+ const queue = waiters.get(packageName);
3227
+ if (queue === void 0) return;
3228
+ waiters.delete(packageName);
3229
+ for (const waiter of queue.splice(0)) queueMicrotask(() => {
3230
+ waiter.run();
3231
+ });
2993
3232
  });
2994
3233
  return fn(...args);
2995
- });
3234
+ };
3235
+ return mount;
2996
3236
  }
2997
3237
  //#endregion
2998
3238
  //#region src/index.ts
@@ -3059,7 +3299,7 @@ function applyImpl(ctx, config) {
3059
3299
  enabled
3060
3300
  };
3061
3301
  };
3062
- const { routes, upgrade } = makeRoutes({
3302
+ const { routes, upgrade, terminalSessions } = makeRoutes({
3063
3303
  store,
3064
3304
  engine
3065
3305
  });
@@ -3100,6 +3340,7 @@ function applyImpl(ctx, config) {
3100
3340
  return () => {
3101
3341
  for (const dispose of disposers) dispose();
3102
3342
  upgradeDisposer();
3343
+ terminalSessions.dispose();
3103
3344
  };
3104
3345
  }, "dsh-ssh: routes");
3105
3346
  disposeTools = ctx.effect(() => {