portless-studio 0.1.3 → 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.
package/README.md CHANGED
@@ -16,7 +16,7 @@ portless replaces port numbers with names: `portless api pnpm dev` serves your a
16
16
  with ten dev servers across several projects you have to remember which names exist and which
17
17
  ports have none. Studio shows both, live, and names a port in one click.
18
18
 
19
- ![Naming port 8000, searching routes with Ctrl+K, filtering with the Filter popover and running pnpm dev in a Studio terminal](docs/images/demo.gif)
19
+ ![Naming port 8000, searching routes with Ctrl+K, filtering with the Filter popover and opening a route's terminal in the right-hand pane](docs/images/demo.gif)
20
20
 
21
21
  The screenshots use the built-in sample data (`?demo`), not a real machine.
22
22
 
@@ -423,7 +423,9 @@ function terminalTools(client) {
423
423
  name: "list_terminals",
424
424
  title: "List Studio terminals",
425
425
  description: "Lists the terminals Studio runs: id, title, folder, command, project, whether it still " +
426
- "runs, exit code and the port it named.",
426
+ "runs, exit code, the port it named and who started it. Terminals that were running when " +
427
+ "Studio last stopped are listed with stopped_by_restart: true; restart_terminal runs them " +
428
+ "again.",
427
429
  inputSchema: NO_INPUT,
428
430
  call: async () => {
429
431
  const result = await client.readPrivate(API_PATHS.terminals);
@@ -431,8 +433,12 @@ function terminalTools(client) {
431
433
  return failure(result.error);
432
434
  const { terminals } = result.value;
433
435
  const running = terminals.filter((terminal) => terminal.status === "running").length;
436
+ const stopped = terminals.filter((terminal) => terminal.stoppedByRestart === true).length;
437
+ const restorable = stopped > 0
438
+ ? `, ${stopped} stopped by a Studio restart: run ${stopped === 1 ? "it" : "them"} again with restart_terminal`
439
+ : "";
434
440
  return {
435
- summary: `${plural(terminals.length, "terminal")} (${running} running).`,
441
+ summary: `${plural(terminals.length, "terminal")} (${running} running${restorable}).`,
436
442
  data: terminals.map(terminalItem),
437
443
  };
438
444
  },
@@ -556,6 +562,32 @@ function terminalTools(client) {
556
562
  return { summary: `Stopping ${id.value}. list_terminals shows when it has exited.` };
557
563
  },
558
564
  },
565
+ {
566
+ name: "restart_terminal",
567
+ title: "Run a terminal's command again",
568
+ description: "Runs a terminal's command again in the same terminal: stops it first when it still runs. " +
569
+ "Use it for a terminal stopped by a Studio restart (stopped_by_restart in list_terminals) " +
570
+ "or one whose command ended.",
571
+ inputSchema: {
572
+ type: "object",
573
+ properties: { id: TERMINAL_ID_SCHEMA },
574
+ required: ["id"],
575
+ additionalProperties: false,
576
+ },
577
+ call: async (args) => {
578
+ const id = readString(args, "id");
579
+ if (!id.ok)
580
+ return invalid(id.error);
581
+ const result = await client.write("POST", API_PATHS.terminalRestart(id.value));
582
+ if (!result.ok)
583
+ return failure(result.error);
584
+ const terminal = result.value;
585
+ return {
586
+ summary: `Running ${terminal.id} again in ${terminal.cwd}: ${terminal.command || "shell"}. Read its output with read_terminal {"id": "${terminal.id}"}.`,
587
+ data: terminalItem(terminal),
588
+ };
589
+ },
590
+ },
559
591
  {
560
592
  name: "focus_studio",
561
593
  title: "Show something in Studio",
@@ -611,6 +643,8 @@ function terminalItem(terminal) {
611
643
  pid: terminal.pid,
612
644
  named: terminal.naming?.name ?? null,
613
645
  port: terminal.naming?.port ?? null,
646
+ started_by: terminal.startedBy ?? null,
647
+ stopped_by_restart: terminal.stoppedByRestart === true,
614
648
  };
615
649
  }
616
650
  async function readSnapshot(client, includeStudio = false) {
@@ -8,6 +8,7 @@ export function routeItem(route) {
8
8
  project: route.project?.name ?? null,
9
9
  worktree: route.project?.worktree ?? null,
10
10
  pid: route.listenerPid ?? route.pid,
11
+ startedAt: route.startedAt ?? null,
11
12
  };
12
13
  }
13
14
  export function detectedItem(port) {
@@ -19,6 +20,7 @@ export function detectedItem(port) {
19
20
  suggestedName: port.suggestedName,
20
21
  project: port.project?.name ?? null,
21
22
  worktree: port.project?.worktree ?? null,
23
+ startedAt: port.startedAt ?? null,
22
24
  };
23
25
  }
24
26
  export function proxyItem(proxy) {
@@ -51,6 +51,8 @@ export const API_PATHS = {
51
51
  * Studio shows.
52
52
  */
53
53
  terminalsInFolder: "/api/terminals/in-folder",
54
+ /** GET returns `RecentCommandsResponse`: the last commands run in each folder. */
55
+ terminalsRecentCommands: "/api/terminals/recent-commands",
54
56
  /**
55
57
  * GET returns `TerminalInfo`; PATCH `RenameTerminalRequest` renames it; DELETE stops the command
56
58
  * if it runs and closes the terminal.
@@ -67,7 +69,10 @@ export const API_PATHS = {
67
69
  terminalInput: (id) => `/api/terminals/${encodeURIComponent(id)}/input`,
68
70
  /** POST: Ctrl+C, then SIGTERM, then SIGKILL until the command ends. */
69
71
  terminalStop: (id) => `/api/terminals/${encodeURIComponent(id)}/stop`,
70
- /** POST: runs the same command again in the same terminal. */
72
+ /**
73
+ * POST: runs the same command again in the same terminal; also starts a terminal that was
74
+ * stopped by a Studio restart.
75
+ */
71
76
  terminalRestart: (id) => `/api/terminals/${encodeURIComponent(id)}/restart`,
72
77
  /** POST `FocusRequest`: brings a Studio page to the front showing a route, port or terminal. */
73
78
  focus: "/api/focus",
@@ -2,6 +2,14 @@ import { DEFAULT_NAME_TEMPLATE, parseNameTemplate } from "../ports/name-template
2
2
  import { projectIconKind } from "../projects/project-icon.js";
3
3
  import { err, ok } from "../result.js";
4
4
  import { DEFAULT_VIEW, isItemKey, parseViews } from "../views/view.js";
5
+ export const DEFAULT_TERMINAL_APPEARANCE = {
6
+ fontSize: 12,
7
+ lineHeight: 1.2,
8
+ cursorStyle: "block",
9
+ cursorBlink: true,
10
+ scrollback: 10_000,
11
+ theme: "studio",
12
+ };
5
13
  export const DEFAULT_CONFIG = {
6
14
  port: 4949,
7
15
  autoName: false,
@@ -39,6 +47,9 @@ export const DEFAULT_CONFIG = {
39
47
  shortUrls: false,
40
48
  showStudioRoute: false,
41
49
  showEphemeralPorts: false,
50
+ terminal: DEFAULT_TERMINAL_APPEARANCE,
51
+ restoreTerminals: "ask",
52
+ defaultEditor: null,
42
53
  };
43
54
  const MIN_SCAN_INTERVAL_MS = 1000;
44
55
  /**
@@ -147,8 +158,80 @@ export function parseConfig(input) {
147
158
  return err(`"nameTemplate" ${parsed.error}`);
148
159
  config.nameTemplate = template;
149
160
  }
161
+ if (raw.terminal !== undefined) {
162
+ const terminal = parseTerminalAppearance(raw.terminal);
163
+ if (!terminal.ok)
164
+ return terminal;
165
+ config.terminal = terminal.value;
166
+ }
167
+ if (raw.restoreTerminals !== undefined) {
168
+ const value = raw.restoreTerminals;
169
+ if (value !== "ask" && value !== "always" && value !== "never") {
170
+ return err(`"restoreTerminals" must be "ask", "always" or "never".`);
171
+ }
172
+ config.restoreTerminals = value;
173
+ }
174
+ if (raw.defaultEditor !== undefined) {
175
+ const value = raw.defaultEditor;
176
+ if (value !== null && (typeof value !== "string" || !APP_ID.test(value))) {
177
+ return err(`"defaultEditor" must be the id of an app in Open with, like "vscode", or null.`);
178
+ }
179
+ config.defaultEditor = value;
180
+ }
150
181
  return ok(config);
151
182
  }
183
+ /** The shape of open-with app ids: `vscode`, `vscode-insiders`, `custom-1`. */
184
+ const APP_ID = /^[a-z0-9][a-z0-9-]{0,39}$/;
185
+ function parseTerminalAppearance(input) {
186
+ if (typeof input !== "object" || input === null || Array.isArray(input)) {
187
+ return err(`"terminal" must be an object.`);
188
+ }
189
+ const raw = input;
190
+ const terminal = { ...DEFAULT_TERMINAL_APPEARANCE };
191
+ if (raw.fontSize !== undefined) {
192
+ if (!isWholeBetween(raw.fontSize, 10, 24)) {
193
+ return err(`"terminal.fontSize" must be a whole number from 10 to 24.`);
194
+ }
195
+ terminal.fontSize = raw.fontSize;
196
+ }
197
+ if (raw.lineHeight !== undefined) {
198
+ const value = raw.lineHeight;
199
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 1 || value > 2) {
200
+ return err(`"terminal.lineHeight" must be a number from 1 to 2.`);
201
+ }
202
+ terminal.lineHeight = value;
203
+ }
204
+ if (raw.cursorStyle !== undefined) {
205
+ const value = raw.cursorStyle;
206
+ if (value !== "block" && value !== "bar" && value !== "underline") {
207
+ return err(`"terminal.cursorStyle" must be "block", "bar" or "underline".`);
208
+ }
209
+ terminal.cursorStyle = value;
210
+ }
211
+ if (raw.cursorBlink !== undefined) {
212
+ if (typeof raw.cursorBlink !== "boolean") {
213
+ return err(`"terminal.cursorBlink" must be true or false.`);
214
+ }
215
+ terminal.cursorBlink = raw.cursorBlink;
216
+ }
217
+ if (raw.scrollback !== undefined) {
218
+ if (!isWholeBetween(raw.scrollback, 1000, 100_000)) {
219
+ return err(`"terminal.scrollback" must be a whole number from 1000 to 100000.`);
220
+ }
221
+ terminal.scrollback = raw.scrollback;
222
+ }
223
+ if (raw.theme !== undefined) {
224
+ const value = raw.theme;
225
+ if (value !== "studio" && value !== "dark" && value !== "light") {
226
+ return err(`"terminal.theme" must be "studio", "dark" or "light".`);
227
+ }
228
+ terminal.theme = value;
229
+ }
230
+ return ok(terminal);
231
+ }
232
+ function isWholeBetween(value, min, max) {
233
+ return typeof value === "number" && Number.isInteger(value) && value >= min && value <= max;
234
+ }
152
235
  /** Empty names and values are dropped, so clearing a field in the panel removes its icon. */
153
236
  function parseProjectIcons(value) {
154
237
  if (typeof value !== "object" || value === null || Array.isArray(value))
@@ -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-?]*[ -/]*[@-~]`,
@@ -143,5 +143,6 @@ function toDetected(port, projects, nameTemplate) {
143
143
  cwd: port.cwd,
144
144
  suggestedName: info === null ? null : suggestName(info, nameTemplate, port.port),
145
145
  ...(project === null ? {} : { project }),
146
+ ...(port.startedAt === undefined ? {} : { startedAt: port.startedAt }),
146
147
  };
147
148
  }
@@ -403,6 +403,7 @@ export class StudioRuntime {
403
403
  return {
404
404
  ...route,
405
405
  ...(listener?.pid == null ? {} : { listenerPid: listener.pid }),
406
+ ...(listener?.startedAt === undefined ? {} : { startedAt: listener.startedAt }),
406
407
  ...(project === null ? {} : { project }),
407
408
  ...(health === undefined ? {} : { health }),
408
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();