portless-studio 0.1.2 → 0.1.4

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 (30) hide show
  1. package/README.md +1 -1
  2. package/dist/cli/cli-args.js +41 -12
  3. package/dist/cli/env-command.js +34 -0
  4. package/dist/cli/index.js +19 -0
  5. package/dist/cli/mcp/studio-tools.js +86 -2
  6. package/dist/cli/studio-items.js +2 -0
  7. package/dist/core/api/contract.js +6 -1
  8. package/dist/core/config/studio-config.js +85 -0
  9. package/dist/core/ports/helper-ports.js +86 -0
  10. package/dist/core/routes/env-lines.js +72 -0
  11. package/dist/core/terminals/terminal-history.js +53 -0
  12. package/dist/core/terminals/terminal.js +0 -4
  13. package/dist/server/application/port-discovery.js +37 -4
  14. package/dist/server/application/studio-runtime.js +37 -17
  15. package/dist/server/application/terminals.js +221 -49
  16. package/dist/server/infrastructure/config/json-terminal-history-store.js +30 -0
  17. package/dist/server/infrastructure/system/ephemeral-ports.js +62 -0
  18. package/dist/server/infrastructure/system/linux-ports.js +31 -1
  19. package/dist/server/infrastructure/system/macos-ports.js +31 -2
  20. package/dist/server/infrastructure/system/system-port-scanner.js +71 -8
  21. package/dist/server/infrastructure/system/windows-ports.js +9 -1
  22. package/dist/server/interface/http/http-server.js +4 -0
  23. package/dist/server/interface/http/static-files.js +6 -2
  24. package/dist/server/main.js +10 -0
  25. package/dist/web/assets/index-CZVCj1cX.css +1 -0
  26. package/dist/web/assets/index-DODZ4DTe.js +23 -0
  27. package/dist/web/index.html +2 -2
  28. package/package.json +1 -1
  29. package/dist/web/assets/index-BDEwEBPS.js +0 -15
  30. package/dist/web/assets/index-C9wkL1J8.css +0 -1
@@ -0,0 +1,30 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import { parseTerminalHistory, } from "../../../core/terminals/terminal-history.js";
4
+ import { writeFileAtomic } from "./atomic-write.js";
5
+ const FILE_NAME = "terminals.json";
6
+ /** `terminals.json` in the config file's folder. */
7
+ export function terminalHistoryPathFor(configPath) {
8
+ return path.join(path.dirname(configPath), FILE_NAME);
9
+ }
10
+ /**
11
+ * The terminals running when Studio stopped and the recent commands per folder, as
12
+ * `{ "running": [...], "recentCommands": { "<folder>": [...] } }`. Studio writes it on its own.
13
+ */
14
+ export class JsonTerminalHistoryStore {
15
+ path;
16
+ constructor(filePath) {
17
+ this.path = filePath;
18
+ }
19
+ read() {
20
+ try {
21
+ return parseTerminalHistory(JSON.parse(fs.readFileSync(this.path, "utf8")));
22
+ }
23
+ catch {
24
+ return parseTerminalHistory(null);
25
+ }
26
+ }
27
+ write(history) {
28
+ writeFileAtomic(this.path, `${JSON.stringify(history, null, 2)}\n`);
29
+ }
30
+ }
@@ -0,0 +1,62 @@
1
+ import { DEFAULT_EPHEMERAL_RANGES } from "../../../core/ports/helper-ports.js";
2
+ /** Reads `/proc/sys/net/ipv4/ip_local_port_range`: two numbers separated by whitespace. */
3
+ export function parseLinuxPortRange(text) {
4
+ const [first, last] = text.trim().split(/\s+/).map(Number);
5
+ return validRange(first, last);
6
+ }
7
+ /** Reads `sysctl -n net.inet.ip.portrange.first net.inet.ip.portrange.last`: one number per line. */
8
+ export function parseSysctlPortRange(text) {
9
+ return parseLinuxPortRange(text);
10
+ }
11
+ /** Reads `netsh int ipv4 show dynamicport tcp`: a start port and a number of ports. */
12
+ export function parseNetshDynamicPorts(text) {
13
+ const numbers = [...text.matchAll(/:\s*(\d+)/g)].map((match) => Number(match[1]));
14
+ const [start, count] = numbers;
15
+ if (start === undefined || count === undefined || count < 1)
16
+ return null;
17
+ return validRange(start, start + count - 1);
18
+ }
19
+ function validRange(first, last) {
20
+ if (first === undefined || last === undefined)
21
+ return null;
22
+ if (!Number.isInteger(first) || !Number.isInteger(last))
23
+ return null;
24
+ if (first < 1 || last > 65535 || first > last)
25
+ return null;
26
+ return { first, last };
27
+ }
28
+ /**
29
+ * The range the system hands out for port 0, read once and kept: it only changes when someone
30
+ * edits a kernel setting. Falls back to the platform default when it cannot be read.
31
+ */
32
+ export function ephemeralRangeReader(platform, io) {
33
+ let reading = null;
34
+ return () => {
35
+ reading ??= readEphemeralRange(platform, io).catch(() => fallbackRange(platform));
36
+ return reading;
37
+ };
38
+ }
39
+ async function readEphemeralRange(platform, io) {
40
+ let range = null;
41
+ if (platform === "linux") {
42
+ range = parseLinuxPortRange(io.readFile("/proc/sys/net/ipv4/ip_local_port_range") ?? "");
43
+ }
44
+ else if (platform === "darwin") {
45
+ const out = await io.run("sysctl", [
46
+ "-n",
47
+ "net.inet.ip.portrange.first",
48
+ "net.inet.ip.portrange.last",
49
+ ]);
50
+ range = parseSysctlPortRange(out.stdout);
51
+ }
52
+ else if (platform === "win32") {
53
+ const out = await io.run("netsh", ["int", "ipv4", "show", "dynamicport", "tcp"]);
54
+ range = parseNetshDynamicPorts(out.stdout);
55
+ }
56
+ return range ?? fallbackRange(platform);
57
+ }
58
+ function fallbackRange(platform) {
59
+ if (platform === "darwin" || platform === "win32")
60
+ return DEFAULT_EPHEMERAL_RANGES[platform];
61
+ return DEFAULT_EPHEMERAL_RANGES.linux;
62
+ }
@@ -19,22 +19,51 @@ export function parseProcNetTcp(text) {
19
19
  }
20
20
  return sockets;
21
21
  }
22
+ /** Clock ticks per second, the unit of `/proc/<pid>/stat` times; 100 on every common kernel. */
23
+ export const DEFAULT_CLOCK_TICKS = 100;
24
+ /**
25
+ * When a process started, in epoch milliseconds, from field 22 of `/proc/<pid>/stat` (clock ticks
26
+ * since boot) and `btime` in `/proc/stat` (boot time in epoch seconds). Null when unreadable.
27
+ */
28
+ export function processStartTime(stat, procStat, ticksPerSecond) {
29
+ if (stat === null || procStat === null || ticksPerSecond <= 0)
30
+ return null;
31
+ // The command name in field 2 may hold spaces and parentheses, so count fields after the last ")".
32
+ const fields = stat
33
+ .slice(stat.lastIndexOf(")") + 1)
34
+ .trim()
35
+ .split(/\s+/);
36
+ const ticks = Number(fields[19]);
37
+ const boot = Number(/^btime\s+(\d+)$/m.exec(procStat)?.[1]);
38
+ if (!Number.isFinite(ticks) || !Number.isFinite(boot) || boot <= 0)
39
+ return null;
40
+ return Math.round(boot * 1000 + (ticks * 1000) / ticksPerSecond);
41
+ }
42
+ /** `getconf CLK_TCK`, or the usual 100 when it cannot run. */
43
+ export async function readClockTicks(io) {
44
+ const answer = await io.run("getconf", ["CLK_TCK"]).catch(() => null);
45
+ const ticks = Number(answer?.stdout.trim());
46
+ return Number.isInteger(ticks) && ticks > 0 ? ticks : DEFAULT_CLOCK_TICKS;
47
+ }
22
48
  /**
23
49
  * Lists listening ports from `/proc`. A socket is linked to its process through the
24
50
  * `/proc/<pid>/fd/*` links; processes of other users cannot be read and stay unknown.
25
51
  */
26
- export async function listLinuxPorts(io) {
52
+ export async function listLinuxPorts(io, ticksPerSecond = async () => DEFAULT_CLOCK_TICKS) {
27
53
  const sockets = [
28
54
  ...parseProcNetTcp(io.readFile("/proc/net/tcp") ?? ""),
29
55
  ...parseProcNetTcp(io.readFile("/proc/net/tcp6") ?? ""),
30
56
  ];
31
57
  const pids = pidsByInode(io, new Set(sockets.map((socket) => socket.inode)));
58
+ const ticks = pids.size > 0 ? await ticksPerSecond() : DEFAULT_CLOCK_TICKS;
59
+ const procStat = pids.size > 0 ? io.readFile("/proc/stat") : null;
32
60
  const byPort = new Map();
33
61
  for (const socket of sockets) {
34
62
  const known = byPort.get(socket.port);
35
63
  if (known?.pid != null)
36
64
  continue;
37
65
  const pid = pids.get(socket.inode) ?? null;
66
+ const startedAt = pid === null ? null : processStartTime(io.readFile(`/proc/${pid}/stat`), procStat, ticks);
38
67
  byPort.set(socket.port, {
39
68
  port: socket.port,
40
69
  pid,
@@ -43,6 +72,7 @@ export async function listLinuxPorts(io) {
43
72
  executable: pid === null
44
73
  ? null
45
74
  : (io.readLink(`/proc/${pid}/exe`)?.replace(DELETED_SUFFIX, "") ?? null),
75
+ ...(startedAt === null ? {} : { startedAt }),
46
76
  });
47
77
  }
48
78
  return [...byPort.values()].sort((a, b) => a.port - b.port);
@@ -48,28 +48,57 @@ export function parsePsExecutables(text) {
48
48
  }
49
49
  return executables;
50
50
  }
51
+ /**
52
+ * Reads `ps -o pid=,etime= -p ...` into pid -> start in epoch milliseconds. `etime` is the time
53
+ * since the process started as `[[dd-]hh:]mm:ss`, which unlike `lstart` reads the same in every
54
+ * locale.
55
+ */
56
+ export function parsePsStartTimes(text, now) {
57
+ const starts = new Map();
58
+ for (const line of text.split("\n")) {
59
+ const match = /^\s*(\d+)\s+(?:(\d+)-)?(?:(\d{1,2}):)?(\d{1,2}):(\d{2})\s*$/.exec(line);
60
+ if (!match)
61
+ continue;
62
+ const [, pid, days, hours, minutes, seconds] = match;
63
+ const elapsed = ((Number(days ?? 0) * 24 + Number(hours ?? 0)) * 60 + Number(minutes)) * 60 + Number(seconds);
64
+ starts.set(Number(pid), now - elapsed * 1000);
65
+ }
66
+ return starts;
67
+ }
51
68
  /** Lists listening ports with `lsof` and `ps`, which ship with macOS. */
52
- export async function listMacosPorts(io) {
69
+ export async function listMacosPorts(io, starts = new Map(), now = Date.now) {
53
70
  const listen = await io.run("lsof", ["-nP", "-iTCP", "-sTCP:LISTEN", "-F", "pcn"]);
54
71
  const rows = parseLsofListen(listen.stdout);
55
72
  if (rows.length === 0)
56
73
  return [];
57
74
  const pids = [...new Set(rows.map((row) => row.pid))];
58
- const [cwd, ps] = await Promise.all([
75
+ for (const pid of starts.keys())
76
+ if (!pids.includes(pid))
77
+ starts.delete(pid);
78
+ const unknownStarts = pids.filter((pid) => !starts.has(pid));
79
+ const [cwd, ps, elapsed] = await Promise.all([
59
80
  io.run("lsof", ["-a", "-d", "cwd", "-p", pids.join(","), "-F", "pn"]),
60
81
  // -ww: without it ps cuts long paths at the terminal width.
61
82
  io.run("ps", ["-ww", "-o", "pid=,comm=", "-p", pids.join(",")]),
83
+ unknownStarts.length === 0
84
+ ? null
85
+ : io.run("ps", ["-o", "pid=,etime=", "-p", unknownStarts.join(",")]),
62
86
  ]);
87
+ const read = parsePsStartTimes(elapsed?.stdout ?? "", now());
88
+ for (const pid of unknownStarts)
89
+ starts.set(pid, read.get(pid) ?? null);
63
90
  const cwds = parseLsofCwds(cwd.stdout);
64
91
  const executables = parsePsExecutables(ps.stdout);
65
92
  const byPort = new Map();
66
93
  for (const row of rows) {
67
94
  if (byPort.has(row.port))
68
95
  continue;
96
+ const startedAt = starts.get(row.pid) ?? null;
69
97
  byPort.set(row.port, {
70
98
  ...row,
71
99
  cwd: cwds.get(row.pid) ?? null,
72
100
  executable: executables.get(row.pid) ?? null,
101
+ ...(startedAt === null ? {} : { startedAt }),
73
102
  });
74
103
  }
75
104
  return [...byPort.values()].sort((a, b) => a.port - b.port);
@@ -1,6 +1,7 @@
1
1
  import { request } from "node:http";
2
2
  import { STUDIO_HEADER } from "../../../core/api/contract.js";
3
- import { listLinuxPorts } from "./linux-ports.js";
3
+ import { isDebuggerVersionInfo } from "../../../core/ports/helper-ports.js";
4
+ import { listLinuxPorts, readClockTicks } from "./linux-ports.js";
4
5
  import { listMacosPorts } from "./macos-ports.js";
5
6
  import { nodeSystemIo } from "./system-io.js";
6
7
  import { listWindowsPorts } from "./windows-ports.js";
@@ -17,9 +18,12 @@ export function listPortsFor(platform, io) {
17
18
  const cache = new Map();
18
19
  return () => listWindowsPorts(io, cache);
19
20
  }
20
- if (platform === "darwin")
21
- return () => listMacosPorts(io);
22
- return () => listLinuxPorts(io);
21
+ if (platform === "darwin") {
22
+ const starts = new Map();
23
+ return () => listMacosPorts(io, starts);
24
+ }
25
+ let ticks = null;
26
+ return () => listLinuxPorts(io, () => (ticks ??= readClockTicks(io)));
23
27
  }
24
28
  /**
25
29
  * Wraps a platform lister with the HTTP check. A port is probed once per process that owns it,
@@ -30,6 +34,7 @@ export class SystemPortScanner {
30
34
  #timeoutMs;
31
35
  #pidByPort = new Map();
32
36
  #answers = new Map();
37
+ #debuggers = new Map();
33
38
  constructor(list, timeoutMs = PROBE_TIMEOUT_MS) {
34
39
  this.#list = list;
35
40
  this.#timeoutMs = timeoutMs;
@@ -44,13 +49,28 @@ export class SystemPortScanner {
44
49
  }
45
50
  this.#pidByPort = new Map(ports.map((port) => [port.port, port.pid]));
46
51
  // Forget answers for processes that are gone, so the cache does not grow forever.
47
- for (const key of this.#answers.keys()) {
48
- const [port, pid] = key.split(":");
49
- if (String(this.#pidByPort.get(Number(port))) !== pid)
50
- this.#answers.delete(key);
52
+ for (const cache of [this.#answers, this.#debuggers]) {
53
+ for (const key of cache.keys()) {
54
+ const [port, pid] = key.split(":");
55
+ if (String(this.#pidByPort.get(Number(port))) !== pid)
56
+ cache.delete(key);
57
+ }
51
58
  }
52
59
  return ports;
53
60
  }
61
+ /** Asked once per port and process, like `answersHttp`. */
62
+ probeDebugger(port) {
63
+ const pid = this.#pidByPort.get(port) ?? null;
64
+ if (pid === null)
65
+ return probeDebuggerPort(port, this.#timeoutMs);
66
+ const key = `${port}:${pid}`;
67
+ let answer = this.#debuggers.get(key);
68
+ if (!answer) {
69
+ answer = probeDebuggerPort(port, this.#timeoutMs);
70
+ this.#debuggers.set(key, answer);
71
+ }
72
+ return answer;
73
+ }
54
74
  answersHttp(port) {
55
75
  const pid = this.#pidByPort.get(port) ?? null;
56
76
  // Without a pid we cannot tell a restarted process apart, so the answer is not kept.
@@ -65,6 +85,49 @@ export class SystemPortScanner {
65
85
  return answer;
66
86
  }
67
87
  }
88
+ /** A `/json/version` answer is a few hundred bytes; anything longer is not a debugger. */
89
+ const MAX_VERSION_BODY = 16_384;
90
+ /**
91
+ * Asks `GET /json/version`, which Node's inspector and Chrome DevTools answer with JSON naming
92
+ * the browser and protocol. `other` when the port answered something else, `unknown` when
93
+ * nothing answered in time.
94
+ */
95
+ export async function probeDebuggerPort(port, timeoutMs = PROBE_TIMEOUT_MS) {
96
+ for (const host of LOOPBACK_HOSTS) {
97
+ const body = await getText(host, port, "/json/version", timeoutMs);
98
+ if (body === null)
99
+ continue;
100
+ try {
101
+ return isDebuggerVersionInfo(JSON.parse(body)) ? "debugger" : "other";
102
+ }
103
+ catch {
104
+ return "other";
105
+ }
106
+ }
107
+ return "unknown";
108
+ }
109
+ function getText(host, port, path, timeoutMs) {
110
+ return new Promise((resolve) => {
111
+ const options = { host, port, method: "GET", path, timeout: timeoutMs, agent: false };
112
+ const req = request(options, (res) => {
113
+ let body = "";
114
+ res.setEncoding("utf8");
115
+ res.on("data", (chunk) => {
116
+ body += chunk;
117
+ if (body.length <= MAX_VERSION_BODY)
118
+ return;
119
+ resolve("");
120
+ req.destroy();
121
+ });
122
+ res.on("end", () => resolve(body));
123
+ res.on("error", () => resolve(body));
124
+ });
125
+ req.on("timeout", () => req.destroy());
126
+ req.on("error", () => resolve(null));
127
+ req.on("close", () => resolve(null));
128
+ req.end();
129
+ });
130
+ }
68
131
  /**
69
132
  * True when anything on loopback answers `HEAD /` with an HTTP response, whatever its status,
70
133
  * unless the response comes from a Portless Studio.
@@ -58,10 +58,12 @@ export function parseWin32Processes(text) {
58
58
  continue;
59
59
  const executable = Reflect.get(item, "ExecutablePath");
60
60
  const commandLine = Reflect.get(item, "CommandLine");
61
+ const startedAt = Reflect.get(item, "StartedAt");
61
62
  processes.set(pid, {
62
63
  name: name.replace(/\.exe$/i, ""),
63
64
  executable: typeof executable === "string" && executable !== "" ? executable : null,
64
65
  commandLine: typeof commandLine === "string" && commandLine !== "" ? commandLine : null,
66
+ ...(typeof startedAt === "number" && startedAt > 0 ? { startedAt } : {}),
65
67
  });
66
68
  }
67
69
  return processes;
@@ -159,6 +161,9 @@ export async function listWindowsPorts(io, cache = new Map(), now = Date.now) {
159
161
  processName: system ?? info?.name ?? "",
160
162
  cwd: folderFromCommandLine(info?.commandLine ?? null),
161
163
  executable: info?.executable ?? null,
164
+ ...(system === undefined && info?.startedAt !== undefined
165
+ ? { startedAt: info.startedAt }
166
+ : {}),
162
167
  });
163
168
  }
164
169
  return [...byPort.values()].sort((a, b) => a.port - b.port);
@@ -181,7 +186,10 @@ async function readProcesses(io, pids) {
181
186
  return { processes: new Map(), complete: true };
182
187
  const filter = pids.map((pid) => `ProcessId=${pid}`).join(" OR ");
183
188
  const script = `Get-CimInstance Win32_Process -Filter '${filter}' | ` +
184
- "Select-Object ProcessId, Name, ExecutablePath, CommandLine | ConvertTo-Json -Compress";
189
+ "Select-Object ProcessId, Name, ExecutablePath, CommandLine, " +
190
+ // A plain number, because ConvertTo-Json writes dates differently in each PowerShell version.
191
+ "@{n='StartedAt';e={if ($_.CreationDate) { [DateTimeOffset]::new($_.CreationDate).ToUnixTimeMilliseconds() }}} | " +
192
+ "ConvertTo-Json -Compress";
185
193
  const queried = await io.run("powershell", powershellArgs(script));
186
194
  const processes = queried.exitCode === 0 ? parseWin32Processes(queried.stdout) : new Map();
187
195
  if (processes.size > 0)
@@ -243,6 +243,10 @@ export function createHttpServer(options) {
243
243
  return sendAppError(res, created.error);
244
244
  return sendJson(res, 201, created.value);
245
245
  }
246
+ if (pathname === API_PATHS.terminalsRecentCommands && method === "GET") {
247
+ const body = { recentCommands: terminals.recentCommands() };
248
+ return sendJson(res, 200, body);
249
+ }
246
250
  if (pathname === API_PATHS.terminalsEvents && method === "GET") {
247
251
  terminalStreams.list(res);
248
252
  return;
@@ -23,9 +23,13 @@ const CONTENT_TYPES = {
23
23
  };
24
24
  const COMPRESSIBLE = new Set([".html", ".js", ".mjs", ".css", ".json", ".map", ".svg", ".txt"]);
25
25
  const MIN_COMPRESS_BYTES = 1024;
26
- /** No inline scripts, no third parties, never framed. Vite builds need nothing more. */
26
+ /**
27
+ * No inline scripts, no third parties, never framed. Inline styles are allowed because the
28
+ * terminal (xterm.js) paints ANSI colors through <style> elements it creates at runtime; scripts
29
+ * stay locked to the page's own files.
30
+ */
27
31
  const HTML_HEADERS = {
28
- "Content-Security-Policy": "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; " +
32
+ "Content-Security-Policy": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; " +
29
33
  "font-src 'self'; connect-src 'self'; object-src 'none'; base-uri 'none'; " +
30
34
  "form-action 'none'; frame-ancestors 'none'",
31
35
  "X-Frame-Options": "DENY",
@@ -18,12 +18,14 @@ import { createDetachedLauncher } from "./infrastructure/apps/detached-launcher.
18
18
  import { JsonCustomAppStore, JsonRouteProjectStore, openWithPathFor, routeProjectsPathFor, } from "./infrastructure/apps/json-stores.js";
19
19
  import { autoNamedPathFor, JsonAutoNamedStore, } from "./infrastructure/config/json-auto-named-store.js";
20
20
  import { defaultConfigPath, JsonConfigStore } from "./infrastructure/config/json-config-store.js";
21
+ import { JsonTerminalHistoryStore, terminalHistoryPathFor, } from "./infrastructure/config/json-terminal-history-store.js";
21
22
  import { JsonUpdateCache, updateCheckPathFor } from "./infrastructure/config/json-update-cache.js";
22
23
  import { proxyStartRecordPathFor, readProxyStartRecord, recordProxyStarts, } from "./infrastructure/config/proxy-start-record.js";
23
24
  import { PortlessRegistry } from "./infrastructure/portless/portless-registry.js";
24
25
  import { inspectRoutesFile, resolveStateDir } from "./infrastructure/portless/state-files.js";
25
26
  import { DockerContainers } from "./infrastructure/system/docker-containers.js";
26
27
  import { collectDoctorFacts, proxyPidsAmong, reachesPortless, testBind, } from "./infrastructure/system/doctor-facts.js";
28
+ import { ephemeralRangeReader } from "./infrastructure/system/ephemeral-ports.js";
27
29
  import { FaviconFetcher } from "./infrastructure/system/favicon-fetcher.js";
28
30
  import { fetchLatestVersion } from "./infrastructure/system/npm-registry.js";
29
31
  import { OsNotifier } from "./infrastructure/system/os-notifier.js";
@@ -82,6 +84,7 @@ export function createStudioServices(options = {}) {
82
84
  routeProjectStore: new JsonRouteProjectStore(routeProjectsPathFor(configPath)),
83
85
  containers,
84
86
  shortUrls,
87
+ ephemeralRange: ephemeralRangeReader(process.platform, nodeSystemIo),
85
88
  });
86
89
  const openWith = new OpenWith({
87
90
  catalog: systemAppCatalog(),
@@ -94,6 +97,7 @@ export function createStudioServices(options = {}) {
94
97
  folders: nodeTerminalFolders(),
95
98
  processTree: systemProcessTree(),
96
99
  homeDir: os.homedir(),
100
+ history: new JsonTerminalHistoryStore(terminalHistoryPathFor(configPath)),
97
101
  workspace: {
98
102
  openableFolders: () => runtime.openableFolders(),
99
103
  snapshot: () => runtime.readSnapshot(),
@@ -176,6 +180,7 @@ export function createUninstallServices(configPath = defaultConfigPath()) {
176
180
  routeProjectsPathFor(configPath),
177
181
  openWithPathFor(configPath),
178
182
  updateCheckPathFor(configPath),
183
+ terminalHistoryPathFor(configPath),
179
184
  ];
180
185
  const processes = new SystemProcessControl({
181
186
  platform: process.platform,
@@ -236,6 +241,11 @@ export async function startServer(options) {
236
241
  studio.start();
237
242
  // The short-URL forwarder settles before anyone prints or registers a URL.
238
243
  await studio.ready();
244
+ // Terminals that were running when Studio stopped; never holds up the start.
245
+ void studio
246
+ .settings()
247
+ .then((config) => services.terminals?.restore(config.restoreTerminals))
248
+ .catch(() => { });
239
249
  return {
240
250
  url: `http://${LOOPBACK}:${port}`,
241
251
  port,