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,53 @@
1
+ /**
2
+ * What Studio keeps about terminals between runs, in `terminals.json` next to the config: the
3
+ * terminals that were running when it stopped, so they can run again, and the last commands run
4
+ * in each folder, offered when a new terminal opens there. See docs/terminals.md.
5
+ */
6
+ export const EMPTY_TERMINAL_HISTORY = { running: [], recentCommands: {} };
7
+ /** Commands remembered per folder. */
8
+ export const MAX_RECENT_COMMANDS = 20;
9
+ /** Reads `terminals.json`; anything it cannot read is left out rather than failing. */
10
+ export function parseTerminalHistory(input) {
11
+ if (typeof input !== "object" || input === null)
12
+ return { running: [], recentCommands: {} };
13
+ const raw = input;
14
+ const running = Array.isArray(raw.running) ? raw.running.filter(isSavedTerminal) : [];
15
+ const recentCommands = {};
16
+ if (typeof raw.recentCommands === "object" && raw.recentCommands !== null) {
17
+ for (const [folder, list] of Object.entries(raw.recentCommands)) {
18
+ if (folder === "__proto__" || !Array.isArray(list))
19
+ continue;
20
+ const commands = list.filter((item) => typeof item === "string" && item.trim() !== "");
21
+ if (commands.length > 0)
22
+ recentCommands[folder] = commands.slice(0, MAX_RECENT_COMMANDS);
23
+ }
24
+ }
25
+ return { running, recentCommands };
26
+ }
27
+ /** Puts `command` first in the folder's list, once, and keeps the newest `MAX_RECENT_COMMANDS`. */
28
+ export function rememberCommand(recent, cwd, command) {
29
+ const wanted = command.trim();
30
+ const copy = {};
31
+ for (const [folder, list] of Object.entries(recent))
32
+ copy[folder] = [...list];
33
+ if (wanted === "")
34
+ return copy;
35
+ const list = (copy[cwd] ?? []).filter((item) => item !== wanted);
36
+ copy[cwd] = [wanted, ...list].slice(0, MAX_RECENT_COMMANDS);
37
+ return copy;
38
+ }
39
+ function isSavedTerminal(value) {
40
+ if (typeof value !== "object" || value === null)
41
+ return false;
42
+ const item = value;
43
+ const optionalText = (field) => field === null || typeof field === "string";
44
+ return (typeof item.command === "string" &&
45
+ typeof item.cwd === "string" &&
46
+ item.cwd !== "" &&
47
+ typeof item.title === "string" &&
48
+ typeof item.startedAt === "number" &&
49
+ (item.startedBy === "program" || item.startedBy === "person") &&
50
+ optionalText(item.project) &&
51
+ optionalText(item.worktree) &&
52
+ optionalText(item.branch));
53
+ }
@@ -1,7 +1,3 @@
1
- /**
2
- * A terminal Studio runs: one command in one project folder, with its output kept so the page,
3
- * the CLI and agents can read it. See docs/terminals.md.
4
- */
5
1
  // Control sequences: CSI (colors, cursor), OSC (window title) and two-character escapes.
6
2
  const ESCAPES = new RegExp([
7
3
  String.raw `\u001b\[[0-?]*[ -/]*[@-~]`,
@@ -1,10 +1,16 @@
1
1
  import { projectOf, suggestName, } from "../../core/ports/detected-port.js";
2
2
  import { containerProjectInfo } from "../../core/ports/docker-container.js";
3
+ import { arrangeDetected, } from "../../core/ports/helper-ports.js";
4
+ /** Only the main Detected list of `discoverPortGroups`. */
5
+ export async function discoverPorts(scanner, projects, context) {
6
+ return (await discoverPortGroups(scanner, projects, context)).detected;
7
+ }
3
8
  /**
4
9
  * Listening ports a person would want to name: they answer HTTP, have no portless route, and are
5
- * not Studio, the proxy or something the config ignores. Sorted by port.
10
+ * not Studio, the proxy or something the config ignores. Debuggers, ephemeral ports and ports of a
11
+ * server that already has a name go to `otherPorts` (see `arrangeDetected`). Sorted by port.
6
12
  */
7
- export async function discoverPorts(scanner, projects, context) {
13
+ export async function discoverPortGroups(scanner, projects, context) {
8
14
  const skippedPorts = new Set([
9
15
  ...context.routes.map((route) => route.port),
10
16
  ...context.excludedPorts,
@@ -14,7 +20,8 @@ export async function discoverPorts(scanner, projects, context) {
14
20
  const published = containerPorts(context.containers ?? [], skippedPorts, ignored);
15
21
  // A port whose process we cannot read belongs to another user or the system (printing, DNS),
16
22
  // never to a dev server this person started. A container's port is shown as the container.
17
- const candidates = (await scanner.scan()).filter((port) => port.pid !== null &&
23
+ const scanned = await scanner.scan();
24
+ const candidates = scanned.filter((port) => port.pid !== null &&
18
25
  !skippedPorts.has(port.port) &&
19
26
  !published.has(port.port) &&
20
27
  !isIgnoredProcess(port.processName, ignored));
@@ -25,7 +32,7 @@ export async function discoverPorts(scanner, projects, context) {
25
32
  Promise.all(fromContainers.map(([port]) => scanner.answersHttp(port))),
26
33
  ]);
27
34
  const template = context.config.nameTemplate;
28
- return [
35
+ const found = [
29
36
  ...candidates
30
37
  .filter((port, index) => answers[index] && isDevServer(port, projects))
31
38
  .map((port) => toDetected(port, projects, template)),
@@ -33,6 +40,31 @@ export async function discoverPorts(scanner, projects, context) {
33
40
  .filter((_, index) => containerAnswers[index])
34
41
  .map(([port, container]) => containerDetected(port, container, template)),
35
42
  ].sort((a, b) => a.port - b.port);
43
+ return arrangeDetected({
44
+ detected: found,
45
+ debuggers: await debuggerProbes(scanner, found),
46
+ ephemeralRange: context.ephemeralRange ?? null,
47
+ showEphemeral: context.config.showEphemeralPorts,
48
+ routes: listeningRoutes(context.routes, scanned),
49
+ });
50
+ }
51
+ const UNKNOWN = "unknown";
52
+ async function debuggerProbes(scanner, found) {
53
+ const probe = scanner.probeDebugger?.bind(scanner);
54
+ if (!probe)
55
+ return new Map();
56
+ const answers = await Promise.all(found.map((item) => probe(item.port).catch(() => UNKNOWN)));
57
+ return new Map(found.map((item, index) => [item.port, answers[index] ?? UNKNOWN]));
58
+ }
59
+ /** Routes whose port a readable process listens on now; a closed route claims no siblings. */
60
+ function listeningRoutes(routes, scanned) {
61
+ const byPort = new Map(scanned.map((port) => [port.port, port]));
62
+ return routes.flatMap((route) => {
63
+ const listener = byPort.get(route.port);
64
+ if (!listener || listener.pid === null)
65
+ return [];
66
+ return [{ name: route.name, port: route.port, pid: listener.pid, cwd: listener.cwd }];
67
+ });
36
68
  }
37
69
  /** Published port -> container, without the ports the filters skip. */
38
70
  function containerPorts(containers, skippedPorts, ignored) {
@@ -111,5 +143,6 @@ function toDetected(port, projects, nameTemplate) {
111
143
  cwd: port.cwd,
112
144
  suggestedName: info === null ? null : suggestName(info, nameTemplate, port.port),
113
145
  ...(project === null ? {} : { project }),
146
+ ...(port.startedAt === undefined ? {} : { startedAt: port.startedAt }),
114
147
  };
115
148
  }
@@ -8,7 +8,7 @@ import { appError } from "./app-error.js";
8
8
  import { memoryAutoNamedStore, } from "./auto-named-store.js";
9
9
  import { AutoNamer } from "./auto-namer.js";
10
10
  import { IconResolver } from "./icon-resolver.js";
11
- import { discoverPorts } from "./port-discovery.js";
11
+ import { discoverPortGroups } from "./port-discovery.js";
12
12
  import { EventLog, eventsBetween } from "./studio-events.js";
13
13
  import { SYSTEM_NOTIFICATION_KINDS } from "./system-notifier.js";
14
14
  /** Keeps the projects in memory only; for tests and for `init`, which runs once. */
@@ -43,6 +43,7 @@ export class StudioRuntime {
43
43
  #icons;
44
44
  #routeProjectStore;
45
45
  #containers;
46
+ #ephemeralRange;
46
47
  #shortUrls;
47
48
  #ready = Promise.resolve();
48
49
  /** Last project seen per route name; null until read from the store. */
@@ -85,6 +86,7 @@ export class StudioRuntime {
85
86
  this.#notifier = options.notifier ?? null;
86
87
  this.#routeProjectStore = options.routeProjectStore ?? memoryRouteProjectStore();
87
88
  this.#containers = options.containers ?? null;
89
+ this.#ephemeralRange = options.ephemeralRange ?? null;
88
90
  this.#shortUrls = options.shortUrls ?? null;
89
91
  this.#shortUrls?.onChange(() => this.#notify());
90
92
  const noIcons = {
@@ -147,14 +149,15 @@ export class StudioRuntime {
147
149
  async readSnapshot(options = {}) {
148
150
  const state = await this.#registry.readSnapshot();
149
151
  const config = await this.#currentConfig();
150
- const detected = await this.#detect(state, config, false);
151
- this.#lastDetected = JSON.stringify(detected);
152
+ const found = await this.#detect(state, config, false);
153
+ this.#lastDetected = JSON.stringify(found);
152
154
  const routes = await this.#detailedRoutes(state.routes);
153
155
  const snapshot = {
154
156
  proxy: state.proxy,
155
157
  portless: state.portless,
156
158
  routes,
157
- detected,
159
+ detected: found.detected,
160
+ otherPorts: found.otherPorts,
158
161
  autoName: config.autoName,
159
162
  readAt: state.readAt,
160
163
  };
@@ -183,7 +186,7 @@ export class StudioRuntime {
183
186
  /** One fresh scan with the current config, filtered the same way as the snapshot. */
184
187
  async detectPorts() {
185
188
  const state = await this.#registry.readSnapshot();
186
- return this.#detect(state, await this.#currentConfig(), true);
189
+ return (await this.#detect(state, await this.#currentConfig(), true)).detected;
187
190
  }
188
191
  /** The config in effect: the file as last read, or the defaults while it is broken. */
189
192
  settings() {
@@ -195,11 +198,12 @@ export class StudioRuntime {
195
198
  */
196
199
  async stoppablePids() {
197
200
  const state = await this.#registry.readSnapshot();
198
- const detected = await this.#detect(state, await this.#currentConfig(), true);
201
+ const found = await this.#detect(state, await this.#currentConfig(), true);
199
202
  const pids = new Map();
200
- for (const item of detected)
203
+ for (const item of [...found.detected, ...found.otherPorts]) {
201
204
  if (item.pid !== null)
202
205
  pids.set(item.pid, item.port);
206
+ }
203
207
  const pidByPort = this.#pidByPort();
204
208
  for (const route of state.routes) {
205
209
  const pid = pidByPort.get(route.port);
@@ -215,7 +219,8 @@ export class StudioRuntime {
215
219
  */
216
220
  async openableFolders() {
217
221
  const state = await this.#registry.readSnapshot();
218
- const detected = await this.#detect(state, await this.#currentConfig(), false);
222
+ const found = await this.#detect(state, await this.#currentConfig(), false);
223
+ const detected = [...found.detected, ...found.otherPorts];
219
224
  const routes = await this.#detailedRoutes(state.routes);
220
225
  const folders = new Set();
221
226
  for (const item of [...routes, ...detected]) {
@@ -278,9 +283,11 @@ export class StudioRuntime {
278
283
  }
279
284
  async #detect(state, config, fresh) {
280
285
  const scan = await this.#scanPorts(fresh ? 0 : config.scanIntervalMs);
286
+ const probeDebugger = this.#scanner.probeDebugger?.bind(this.#scanner);
281
287
  const cached = {
282
288
  scan: async () => scan.ports,
283
289
  answersHttp: (port) => this.#scanner.answersHttp(port),
290
+ ...(probeDebugger ? { probeDebugger } : {}),
284
291
  };
285
292
  const forwarderPort = this.#shortUrls?.publicPort() ?? null;
286
293
  const excludedPorts = [
@@ -289,18 +296,23 @@ export class StudioRuntime {
289
296
  state.proxy.port,
290
297
  ...scan.proxyPorts,
291
298
  ].filter((port) => port !== null);
292
- const found = await discoverPorts(cached, this.#projects, {
299
+ const found = await discoverPortGroups(cached, this.#projects, {
293
300
  config,
294
301
  routes: state.routes,
295
302
  excludedPorts,
296
303
  containers: scan.containers,
304
+ ephemeralRange: await this.#readEphemeralRange(),
297
305
  });
298
- return found.map((item) => {
306
+ const withProject = (item) => {
299
307
  if (item.cwd === null || !item.project)
300
308
  return item;
301
309
  const project = itemProjectAt(this.#projects.read(item.cwd), item.cwd);
302
310
  return project === null ? item : { ...item, project };
303
- });
311
+ };
312
+ return {
313
+ detected: found.detected.map(withProject),
314
+ otherPorts: found.otherPorts.map(withProject),
315
+ };
304
316
  }
305
317
  /** Reuses a scan younger than `maxAgeMs`, and shares one scan between concurrent callers. */
306
318
  #scanPorts(maxAgeMs) {
@@ -314,6 +326,12 @@ export class StudioRuntime {
314
326
  });
315
327
  return this.#scanning;
316
328
  }
329
+ /** A range that cannot be read turns the ephemeral rule off rather than failing the scan. */
330
+ async #readEphemeralRange() {
331
+ if (!this.#ephemeralRange)
332
+ return null;
333
+ return this.#ephemeralRange().catch(() => null);
334
+ }
317
335
  /** Docker is optional: any failure to read it means no containers, never a failed scan. */
318
336
  async #readContainers() {
319
337
  if (!this.#containers)
@@ -340,19 +358,20 @@ export class StudioRuntime {
340
358
  try {
341
359
  let state = await this.#registry.readSnapshot();
342
360
  const config = await this.#currentConfig();
343
- let detected = await this.#detect(state, config, true);
344
- // Each step reads the routes again after the previous one changed them.
345
- if (config.autoName && (await this.#namer.name(detected)) > 0) {
361
+ const first = await this.#detect(state, config, true);
362
+ // Each step reads the routes again after the previous one changed them. Other ports are
363
+ // helpers of a named server, debuggers or temporary ports: auto mode never names them.
364
+ if (config.autoName && (await this.#namer.name(first.detected)) > 0) {
346
365
  state = await this.#registry.readSnapshot();
347
366
  }
348
367
  if (await this.#removeAbandonedAutoNames(state, config)) {
349
368
  state = await this.#registry.readSnapshot();
350
369
  }
351
- detected = await this.#detect(state, config, false);
370
+ const found = await this.#detect(state, config, false);
352
371
  const healthChanged = await this.#probeRoutes(state.routes);
353
372
  const routes = await this.#detailedRoutes(state.routes);
354
- this.#observe({ ...state, routes, detected }, config);
355
- if (healthChanged || JSON.stringify(detected) !== this.#lastDetected)
373
+ this.#observe({ ...state, routes, detected: found.detected }, config);
374
+ if (healthChanged || JSON.stringify(found) !== this.#lastDetected)
356
375
  this.#notify();
357
376
  }
358
377
  catch (error) {
@@ -384,6 +403,7 @@ export class StudioRuntime {
384
403
  return {
385
404
  ...route,
386
405
  ...(listener?.pid == null ? {} : { listenerPid: listener.pid }),
406
+ ...(listener?.startedAt === undefined ? {} : { startedAt: listener.startedAt }),
387
407
  ...(project === null ? {} : { project }),
388
408
  ...(health === undefined ? {} : { health }),
389
409
  };
@@ -1,5 +1,6 @@
1
1
  import { err, ok } from "../../core/result.js";
2
2
  import { OutputBuffer } from "../../core/terminals/output-buffer.js";
3
+ import { rememberCommand, } from "../../core/terminals/terminal-history.js";
3
4
  import { appError } from "./app-error.js";
4
5
  const DEFAULT_MAX_OUTPUT = 2 * 1024 * 1024;
5
6
  const DEFAULT_GRACE_MS = 3_000;
@@ -9,6 +10,13 @@ const DEFAULT_COLS = 120;
9
10
  const DEFAULT_ROWS = 30;
10
11
  const MAX_COMMAND_CHARS = 8 * 1024;
11
12
  const MAX_TITLE_CHARS = 60;
13
+ const DEFAULT_PERSIST_DELAY_MS = 300;
14
+ /**
15
+ * A command that ended this shortly before Studio stopped counts as running then: a service
16
+ * manager stops Studio's commands together with Studio, and their exits can arrive first.
17
+ */
18
+ const RECENT_EXIT_MS = 2_000;
19
+ const RESTART_NOTE = "\r\n[Studio] Stopped by a Studio restart. Run it again to start it.\r\n";
12
20
  /**
13
21
  * The terminals Studio runs. Each keeps its last `maxOutputChars` of output, numbered so a client
14
22
  * can resume where it stopped. From a page, commands run only in folders Studio shows; a local
@@ -25,6 +33,11 @@ export class TerminalManager {
25
33
  #namingTimeoutMs;
26
34
  #processTree;
27
35
  #homeDir;
36
+ #history;
37
+ #persistDelayMs;
38
+ #recent = null;
39
+ #persistTimer;
40
+ #closing = false;
28
41
  #entries = new Map();
29
42
  #watchers = new Set();
30
43
  /** Folders a local program ran a terminal in. */
@@ -45,6 +58,8 @@ export class TerminalManager {
45
58
  this.#namingTimeoutMs = options.namingTimeoutMs ?? DEFAULT_NAMING_TIMEOUT_MS;
46
59
  this.#processTree = options.processTree;
47
60
  this.#homeDir = options.homeDir;
61
+ this.#history = options.history;
62
+ this.#persistDelayMs = options.persistDelayMs ?? DEFAULT_PERSIST_DELAY_MS;
48
63
  }
49
64
  get mode() {
50
65
  return this.#spawner.mode;
@@ -73,6 +88,7 @@ export class TerminalManager {
73
88
  const wanted = title.trim().slice(0, MAX_TITLE_CHARS);
74
89
  entry.info = { ...entry.info, title: wanted || entry.defaultTitle };
75
90
  this.#publishStatus(entry);
91
+ this.#schedulePersist();
76
92
  return ok({ ...entry.info });
77
93
  }
78
94
  async create(request, origin) {
@@ -87,69 +103,97 @@ export class TerminalManager {
87
103
  return cwd;
88
104
  if (origin.trusted)
89
105
  this.#trustedFolders.add(cwd.value);
90
- const id = `t${this.#nextId++}`;
91
- const cols = readSize(request.cols, DEFAULT_COLS);
92
- const rows = readSize(request.rows, DEFAULT_ROWS);
93
106
  const naming = readNaming(request.name);
94
- const defaultTitle = (command.value || this.#spawner.shellName || "shell").slice(0, MAX_TITLE_CHARS);
95
- const title = request.title?.trim().slice(0, MAX_TITLE_CHARS) || defaultTitle;
96
107
  const project = await this.#projectOf(cwd.value);
97
- const entry = {
98
- defaultTitle,
99
- info: {
100
- id,
101
- title,
102
- cwd: cwd.value,
103
- command: command.value ?? "",
104
- project: project?.name ?? null,
105
- worktree: project?.worktree ?? null,
106
- branch: project?.branch ?? null,
107
- ports: [],
108
- status: "running",
109
- exitCode: null,
110
- startedAt: this.#now(),
111
- endedAt: null,
112
- pid: null,
113
- mode: this.#spawner.mode,
114
- cols,
115
- rows,
116
- nextSeq: 0,
117
- naming,
118
- },
119
- request: {
120
- command: command.value,
121
- cwd: cwd.value,
122
- cols,
123
- rows,
124
- env: {
125
- TERM: "xterm-256color",
126
- COLUMNS: String(cols),
127
- LINES: String(rows),
128
- PORTLESS_STUDIO_TERMINAL: id,
129
- },
130
- },
131
- buffer: new OutputBuffer(this.#maxOutput),
132
- process: null,
133
- stopTimers: [],
134
- watchers: new Set(),
135
- baseline: null,
136
- namingTimer: undefined,
137
- };
108
+ const entry = this.#newEntry({
109
+ id: `t${this.#nextId++}`,
110
+ command: command.value,
111
+ cwd: cwd.value,
112
+ title: request.title,
113
+ cols: readSize(request.cols, DEFAULT_COLS),
114
+ rows: readSize(request.rows, DEFAULT_ROWS),
115
+ naming,
116
+ project,
117
+ startedBy: origin.trusted ? "program" : "person",
118
+ startedAt: this.#now(),
119
+ });
138
120
  if (naming)
139
121
  entry.baseline = await this.#portsInUse();
140
122
  const started = await this.#spawn(entry);
141
123
  if (!started.ok)
142
124
  return started;
143
- this.#entries.set(id, entry);
125
+ this.#entries.set(entry.info.id, entry);
126
+ if (command.value) {
127
+ this.#recent = rememberCommand(this.#recentCommands(), cwd.value, command.value);
128
+ }
144
129
  if (naming)
145
130
  this.#startNaming(entry);
146
131
  else
147
132
  this.#updateWorkspaceWatch();
148
133
  this.#changed();
134
+ this.#schedulePersist();
149
135
  if (this.#watchers.size > 0)
150
136
  void this.#refreshPorts();
151
137
  return ok({ ...entry.info });
152
138
  }
139
+ /** The last commands run in each folder, newest first. */
140
+ recentCommands() {
141
+ return Object.fromEntries(Object.entries(this.#recentCommands()).map(([folder, list]) => [folder, [...list]]));
142
+ }
143
+ /**
144
+ * Brings back the terminals that were running when Studio last stopped: listed as stopped by a
145
+ * restart (`ask`), started again unless they are a plain shell (`always`), or forgotten
146
+ * (`never`). Folders that no longer exist are skipped. Call it once, on start.
147
+ */
148
+ async restore(mode) {
149
+ if (this.#closing)
150
+ return;
151
+ const saved = this.#history?.read().running ?? [];
152
+ if (mode === "never" || saved.length === 0) {
153
+ if (saved.length > 0)
154
+ this.#persistNow();
155
+ return;
156
+ }
157
+ const restored = [];
158
+ for (const item of saved) {
159
+ if (this.#entries.size >= this.#maxTerminals)
160
+ break;
161
+ const cwd = this.#folders.directory(item.cwd);
162
+ if (cwd === null)
163
+ continue;
164
+ if (item.startedBy === "program")
165
+ this.#trustedFolders.add(cwd);
166
+ const entry = this.#newEntry({
167
+ id: `t${this.#nextId++}`,
168
+ command: item.command || null,
169
+ cwd,
170
+ title: item.title,
171
+ cols: DEFAULT_COLS,
172
+ rows: DEFAULT_ROWS,
173
+ naming: null,
174
+ project: { name: item.project ?? "", worktree: item.worktree, branch: item.branch },
175
+ startedBy: item.startedBy,
176
+ startedAt: item.startedAt,
177
+ });
178
+ if (item.project === null) {
179
+ entry.info = { ...entry.info, project: null, worktree: null, branch: null };
180
+ }
181
+ entry.info = { ...entry.info, status: "exited", stoppedByRestart: true };
182
+ this.#append(entry, RESTART_NOTE);
183
+ this.#entries.set(entry.info.id, entry);
184
+ restored.push(entry);
185
+ }
186
+ this.#changed();
187
+ this.#persistNow();
188
+ if (mode !== "always")
189
+ return;
190
+ for (const entry of restored) {
191
+ if (this.#closing)
192
+ return;
193
+ if (entry.info.command !== "")
194
+ await this.restart(entry.info.id);
195
+ }
196
+ }
153
197
  input(id, text) {
154
198
  const entry = this.#entries.get(id);
155
199
  if (!entry)
@@ -166,6 +210,7 @@ export class TerminalManager {
166
210
  return notFound(id);
167
211
  if (!entry.process || entry.info.status !== "running")
168
212
  return ok({ ...entry.info });
213
+ entry.stoppedOnPurpose = true;
169
214
  this.#escalate(entry, entry.process);
170
215
  return ok({ ...entry.info });
171
216
  }
@@ -177,16 +222,36 @@ export class TerminalManager {
177
222
  const current = entry.process;
178
223
  if (current && entry.info.status === "running") {
179
224
  const ended = new Promise((resolve) => current.onExit(() => resolve()));
225
+ entry.stoppedOnPurpose = true;
180
226
  this.#escalate(entry, current);
181
227
  await ended;
182
228
  }
183
229
  this.#append(entry, `\r\n[Studio] restarted: ${entry.info.command || "shell"}\r\n`);
230
+ if (entry.info.project === null) {
231
+ const project = await this.#projectOf(entry.info.cwd);
232
+ if (project) {
233
+ entry.info = {
234
+ ...entry.info,
235
+ project: project.name,
236
+ worktree: project.worktree ?? null,
237
+ branch: project.branch ?? null,
238
+ };
239
+ }
240
+ }
184
241
  const started = await this.#spawn(entry);
185
242
  if (!started.ok)
186
243
  return started;
187
- entry.info = { ...entry.info, status: "running", exitCode: null, endedAt: null };
244
+ entry.stoppedOnPurpose = false;
245
+ entry.info = {
246
+ ...entry.info,
247
+ status: "running",
248
+ exitCode: null,
249
+ endedAt: null,
250
+ stoppedByRestart: false,
251
+ };
188
252
  entry.info.startedAt = this.#now();
189
253
  this.#publishStatus(entry);
254
+ this.#schedulePersist();
190
255
  this.#updateWorkspaceWatch();
191
256
  if (this.#watchers.size > 0)
192
257
  void this.#refreshPorts();
@@ -205,6 +270,7 @@ export class TerminalManager {
205
270
  entry.watchers.clear();
206
271
  this.#updateWorkspaceWatch();
207
272
  this.#changed();
273
+ this.#schedulePersist();
208
274
  return ok(undefined);
209
275
  }
210
276
  read(id, from, maxChars) {
@@ -225,8 +291,16 @@ export class TerminalManager {
225
291
  entry.watchers.add(watcher);
226
292
  return ok(() => entry.watchers.delete(watcher));
227
293
  }
228
- /** Studio is stopping: every command gets SIGTERM and SIGKILL right away. */
294
+ /**
295
+ * Studio is stopping: the terminals still running are written down for the next start, then
296
+ * every command gets SIGTERM and SIGKILL right away.
297
+ */
229
298
  closeAll() {
299
+ if (!this.#closing) {
300
+ this.#persistNow(true);
301
+ this.#closing = true;
302
+ clearTimeout(this.#persistTimer);
303
+ }
230
304
  for (const entry of this.#entries.values()) {
231
305
  this.#clearTimers(entry);
232
306
  if (entry.process && entry.info.status === "running") {
@@ -267,10 +341,108 @@ export class TerminalManager {
267
341
  }
268
342
  entry.info.ports = [];
269
343
  this.#publishStatus(entry);
344
+ this.#schedulePersist();
270
345
  this.#updateWorkspaceWatch();
271
346
  });
272
347
  return ok(undefined);
273
348
  }
349
+ #newEntry(fields) {
350
+ const defaultTitle = (fields.command || this.#spawner.shellName || "shell").slice(0, MAX_TITLE_CHARS);
351
+ const title = fields.title?.trim().slice(0, MAX_TITLE_CHARS) || defaultTitle;
352
+ const { id, cols, rows } = fields;
353
+ return {
354
+ defaultTitle,
355
+ info: {
356
+ id,
357
+ title,
358
+ cwd: fields.cwd,
359
+ command: fields.command ?? "",
360
+ project: fields.project?.name ?? null,
361
+ worktree: fields.project?.worktree ?? null,
362
+ branch: fields.project?.branch ?? null,
363
+ ports: [],
364
+ status: "running",
365
+ exitCode: null,
366
+ startedAt: fields.startedAt,
367
+ endedAt: null,
368
+ pid: null,
369
+ mode: this.#spawner.mode,
370
+ cols,
371
+ rows,
372
+ nextSeq: 0,
373
+ naming: fields.naming,
374
+ startedBy: fields.startedBy,
375
+ stoppedByRestart: false,
376
+ },
377
+ request: {
378
+ command: fields.command,
379
+ cwd: fields.cwd,
380
+ cols,
381
+ rows,
382
+ env: {
383
+ TERM: "xterm-256color",
384
+ COLUMNS: String(cols),
385
+ LINES: String(rows),
386
+ PORTLESS_STUDIO_TERMINAL: id,
387
+ },
388
+ },
389
+ buffer: new OutputBuffer(this.#maxOutput),
390
+ process: null,
391
+ stopTimers: [],
392
+ watchers: new Set(),
393
+ baseline: null,
394
+ namingTimer: undefined,
395
+ stoppedOnPurpose: false,
396
+ };
397
+ }
398
+ #recentCommands() {
399
+ this.#recent ??= this.#history?.read().recentCommands ?? {};
400
+ return this.#recent;
401
+ }
402
+ #schedulePersist() {
403
+ if (!this.#history || this.#closing || this.#persistTimer)
404
+ return;
405
+ this.#persistTimer = setTimeout(() => {
406
+ this.#persistTimer = undefined;
407
+ this.#persistNow();
408
+ }, this.#persistDelayMs);
409
+ this.#persistTimer.unref?.();
410
+ }
411
+ /**
412
+ * Writes the terminals that run and the recent commands. When Studio stops, commands that just
413
+ * ended without anyone asking count as running too.
414
+ */
415
+ #persistNow(stopping = false) {
416
+ const history = this.#history;
417
+ if (!history || this.#closing)
418
+ return;
419
+ clearTimeout(this.#persistTimer);
420
+ this.#persistTimer = undefined;
421
+ const now = this.#now();
422
+ const running = [...this.#entries.values()]
423
+ .filter((entry) => entry.info.status === "running" ||
424
+ entry.info.stoppedByRestart === true ||
425
+ (stopping &&
426
+ !entry.stoppedOnPurpose &&
427
+ entry.info.endedAt !== null &&
428
+ now - entry.info.endedAt <= RECENT_EXIT_MS))
429
+ .map(({ info }) => ({
430
+ command: info.command,
431
+ cwd: info.cwd,
432
+ title: info.title,
433
+ startedAt: info.startedAt,
434
+ startedBy: info.startedBy ?? "person",
435
+ project: info.project,
436
+ worktree: info.worktree,
437
+ branch: info.branch,
438
+ }));
439
+ try {
440
+ history.write({ running, recentCommands: this.#recentCommands() });
441
+ }
442
+ catch {
443
+ // A full disk or a read-only config folder: terminals keep working, they just are not kept.
444
+ }
445
+ }
274
446
  #escalate(entry, child) {
275
447
  this.#clearTimers(entry);
276
448
  child.interrupt();