@supanadit/morse-web 0.12.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/server.mjs CHANGED
@@ -33,6 +33,7 @@ var CLIENT_MESSAGE_TYPES = [
33
33
  "history/load",
34
34
  "session/list",
35
35
  "commands/refresh",
36
+ "models/refresh",
36
37
  "project/list",
37
38
  "project/open",
38
39
  "model/set",
@@ -79,7 +80,7 @@ function hasKnownType(value, allowed) {
79
80
  }
80
81
 
81
82
  // ../protocol/dist/version.js
82
- var PROTOCOL_VERSION = 20;
83
+ var PROTOCOL_VERSION = 22;
83
84
  var FRONTEND_MANIFEST_FILE = "webview.manifest.json";
84
85
  var DEFAULT_WS_PATH = "/ws";
85
86
 
@@ -169,9 +170,9 @@ var SessionRegistry = class {
169
170
  hotLimit;
170
171
  activeKey;
171
172
  syntheticKeys = 0;
172
- /** Cached snapshot of a never-recording spawn (see `draftDefaults`). */
173
- draftCache;
174
- draftProbe;
173
+ /** Cached snapshots of never-recording spawns, keyed by model (`''` = the default). */
174
+ draftCache = /* @__PURE__ */ new Map();
175
+ draftProbes = /* @__PURE__ */ new Map();
175
176
  constructor(deps) {
176
177
  this.deps = deps;
177
178
  this.hotLimit = Math.max(1, deps.hotLimit ?? DEFAULT_HOT_LIMIT);
@@ -204,24 +205,43 @@ var SessionRegistry = class {
204
205
  hotKeys() {
205
206
  return [...this.recency];
206
207
  }
208
+ /**
209
+ * Forgets the session-less probe caches, so the next `draftDefaults()` runs pi
210
+ * again. The model picker calls it while the panel is still on a draft, where
211
+ * spawning nothing would otherwise freeze the catalog for the host's lifetime.
212
+ */
213
+ refreshDraftDefaults() {
214
+ this.draftCache.clear();
215
+ this.draftProbes.clear();
216
+ }
207
217
  /**
208
218
  * What the backend offers before any session exists: the model catalog,
209
- * thinking levels and commands of an untouched spawn. Probed once per
210
- * registry — never per client — from a gateway that never records itself
211
- * (pi `--no-session`), so the empty state can ship a real model picker
212
- * without creating a session.
219
+ * thinking levels and commands of an untouched spawn. Probed once per model —
220
+ * never per client — from a gateway that never records itself (pi
221
+ * `--no-session`), so the empty state can ship a real model picker without
222
+ * creating a session. Passing `model` asks for that model's thinking levels,
223
+ * which pi reports per current model.
213
224
  */
214
- async draftDefaults() {
215
- if (this.draftCache) {
216
- return this.draftCache;
217
- }
218
- if (!this.draftProbe) {
219
- this.draftProbe = this.probeDraft().catch((error) => {
220
- this.draftProbe = void 0;
225
+ async draftDefaults(model) {
226
+ const key = model ? `${model.provider}/${model.id}` : "";
227
+ const cached = this.draftCache.get(key);
228
+ if (cached) {
229
+ return cached;
230
+ }
231
+ let probe = this.draftProbes.get(key);
232
+ if (!probe) {
233
+ probe = this.probeDraft(model).then((state) => {
234
+ if (state) {
235
+ this.draftCache.set(key, state);
236
+ }
237
+ return state;
238
+ }).catch((error) => {
239
+ this.draftProbes.delete(key);
221
240
  throw error;
222
241
  });
242
+ this.draftProbes.set(key, probe);
223
243
  }
224
- return this.draftProbe;
244
+ return probe;
225
245
  }
226
246
  /** Opens a new (or resumes an existing) session and makes it active. */
227
247
  async open(options = {}) {
@@ -372,8 +392,8 @@ var SessionRegistry = class {
372
392
  this.recency.length = 0;
373
393
  this.listeners.clear();
374
394
  this.activeKey = void 0;
375
- this.draftCache = void 0;
376
- this.draftProbe = void 0;
395
+ this.draftCache.clear();
396
+ this.draftProbes.clear();
377
397
  await Promise.all(sessions.map((session) => {
378
398
  session.detach();
379
399
  return session.gateway.dispose().catch((error) => {
@@ -439,13 +459,16 @@ var SessionRegistry = class {
439
459
  return `${workspace.cwd}#ephemeral-${this.syntheticKeys}`;
440
460
  }
441
461
  /** Asks the factory for a never-recording gateway and snapshots its state. */
442
- async probeDraft() {
462
+ async probeDraft(model) {
443
463
  const factory = this.deps.factory.probeDefaults;
444
464
  if (!factory) {
445
465
  return void 0;
446
466
  }
447
467
  try {
448
- const gateway = await factory.call(this.deps.factory, { workspace: this.deps.defaultWorkspace });
468
+ const gateway = await factory.call(this.deps.factory, {
469
+ workspace: this.deps.defaultWorkspace,
470
+ ...model ? { model } : {}
471
+ });
449
472
  const state = await gateway.state();
450
473
  await gateway.dispose();
451
474
  return state;
@@ -1036,6 +1059,14 @@ function isDefined(value) {
1036
1059
  // ../adapter-pi-rpc/dist/internal/rpc-client.js
1037
1060
  import { spawn } from "node:child_process";
1038
1061
 
1062
+ // ../adapter-pi-rpc/dist/internal/spawn-env.js
1063
+ function cleanSpawnEnv(env = process.env) {
1064
+ const copy = { ...env };
1065
+ delete copy.NODE_CHANNEL_FD;
1066
+ delete copy.NODE_CHANNEL_SERIALIZATION_MODE;
1067
+ return copy;
1068
+ }
1069
+
1039
1070
  // ../adapter-pi-rpc/dist/internal/jsonl-framer.js
1040
1071
  import { StringDecoder } from "node:string_decoder";
1041
1072
  var JsonlFramer = class {
@@ -1089,7 +1120,7 @@ var PiRpcClient = class {
1089
1120
  this.failed = void 0;
1090
1121
  const child = spawn(this.options.command, this.options.args, {
1091
1122
  cwd: this.options.cwd,
1092
- env: this.options.env ?? process.env,
1123
+ env: cleanSpawnEnv(this.options.env),
1093
1124
  stdio: ["pipe", "pipe", "pipe"],
1094
1125
  windowsHide: true
1095
1126
  });
@@ -1379,6 +1410,23 @@ var PiRpcAgent = class {
1379
1410
  modelId: model.id
1380
1411
  });
1381
1412
  this.patchState({ model: data ? toModelRef(data) : model });
1413
+ await this.refreshThinkingLevels();
1414
+ }
1415
+ /**
1416
+ * Re-read the levels the current model supports, and the level pi actually
1417
+ * settled the session on (a model without reasoning resets it to `off`).
1418
+ */
1419
+ async refreshThinkingLevels() {
1420
+ const [levels, state] = await Promise.all([
1421
+ this.client.request({ type: "get_available_thinking_levels" }).catch(() => void 0),
1422
+ this.client.request({ type: "get_state" }).catch(() => void 0)
1423
+ ]);
1424
+ const available = dedupeThinkingLevels((levels?.levels ?? []).map(toThinkingLevel).filter(isDefined2));
1425
+ const thinkingLevel = state ? toThinkingLevel(state.thinkingLevel) : void 0;
1426
+ this.patchState({
1427
+ availableThinkingLevels: available.length > 0 ? available : [...THINKING_LEVELS],
1428
+ ...thinkingLevel !== void 0 ? { thinkingLevel } : {}
1429
+ });
1382
1430
  }
1383
1431
  async setThinkingLevel(level) {
1384
1432
  await this.client.request({ type: "set_thinking_level", level });
@@ -1446,6 +1494,22 @@ var PiRpcAgent = class {
1446
1494
  commandContext() {
1447
1495
  return { cwd: this.options.workspace.cwd, env: this.options.env };
1448
1496
  }
1497
+ /**
1498
+ * Re-reads `get_available_models` and reports it the same way a fresh spawn
1499
+ * would. The model picker calls it the moment it opens, so a model added to
1500
+ * `models.json` while this session is warm is there without reopening the
1501
+ * session or restarting the host.
1502
+ */
1503
+ async refreshModels() {
1504
+ if (this.disposed || !this.ready) {
1505
+ return;
1506
+ }
1507
+ const models = await this.client.request({ type: "get_available_models" }).catch(() => void 0);
1508
+ if (models === void 0) {
1509
+ return;
1510
+ }
1511
+ this.patchState({ availableModels: (models.models ?? []).map(toModelRef) });
1512
+ }
1449
1513
  /**
1450
1514
  * Pulls the cumulative token/cost/context totals pi keeps for the session, so
1451
1515
  * the footer can show the same numbers the TUI does. Best-effort: an older pi
@@ -1577,7 +1641,7 @@ function toThinkingLevel(value) {
1577
1641
  if (!value) {
1578
1642
  return void 0;
1579
1643
  }
1580
- return THINKING_LEVELS.find((level) => level === value);
1644
+ return THINKING_LEVELS.find((level) => level === value) ?? value.trim();
1581
1645
  }
1582
1646
  function dedupeThinkingLevels(levels) {
1583
1647
  return [...new Set(levels)];
@@ -1756,7 +1820,7 @@ ${mentions}` : mentions;
1756
1820
  // ../adapter-pi-rpc/dist/internal/resolve-pi.js
1757
1821
  import { accessSync, constants } from "node:fs";
1758
1822
  import { homedir as homedir2 } from "node:os";
1759
- import { delimiter, isAbsolute, join as join2 } from "node:path";
1823
+ import { delimiter, dirname, isAbsolute, join as join2 } from "node:path";
1760
1824
  var PI_INSTALL_COMMAND = "npm install -g @earendil-works/pi-coding-agent";
1761
1825
  function buildRpcArgs(options) {
1762
1826
  const args = ["--mode", "rpc"];
@@ -1794,6 +1858,33 @@ function resolvePi(options = {}) {
1794
1858
  "- NestJS host: env MORSE_PI_PATH (or MORSE_PI_ENTRY for a bundled rpc-entry.js)"
1795
1859
  ].join("\n"), { remedy: { install: PI_INSTALL_COMMAND } });
1796
1860
  }
1861
+ function resolvePiCli(options = {}) {
1862
+ const env = options.env ?? process.env;
1863
+ const configured = options.piPath ?? env.MORSE_PI_PATH;
1864
+ if (configured) {
1865
+ return { command: configured, baseArgs: [], source: "configured" };
1866
+ }
1867
+ const onPath = findOnPath("pi", env);
1868
+ if (onPath) {
1869
+ return { command: onPath, baseArgs: [], source: "path" };
1870
+ }
1871
+ const entry = options.nodeEntryPath ?? env.MORSE_PI_ENTRY;
1872
+ const cli = entry ? siblingCliEntry(entry) : void 0;
1873
+ if (cli) {
1874
+ return { command: process.execPath, baseArgs: [cli], source: "node-entry" };
1875
+ }
1876
+ throw new AgentUnavailableError([
1877
+ "The pi CLI was not found, so Morse cannot run `pi mcp`.",
1878
+ `Install it (\`${PI_INSTALL_COMMAND}\`) or point Morse at it:`,
1879
+ '- VS Code: setting "morse.pi.path"',
1880
+ "- NestJS host: env MORSE_PI_PATH"
1881
+ ].join("\n"), { remedy: { install: PI_INSTALL_COMMAND } });
1882
+ }
1883
+ function siblingCliEntry(entry) {
1884
+ const directory = dirname(entry);
1885
+ const cli = join2(directory, "cli.js");
1886
+ return entry.endsWith("rpc-entry.js") && isExecutableEntry(cli) ? cli : void 0;
1887
+ }
1797
1888
  function findOnPath(binary, env = process.env) {
1798
1889
  const pathValue = env.PATH ?? env.Path ?? "";
1799
1890
  if (pathValue.length === 0) {
@@ -1865,7 +1956,13 @@ var PiRpcAgentFactory = class {
1865
1956
  extraArgs: this.options.extraArgs,
1866
1957
  env: this.options.env
1867
1958
  };
1868
- return this.spawnAgent(resolveOptions, input.workspace);
1959
+ const agent = await this.spawnAgent(resolveOptions, input.workspace);
1960
+ if (input.model) {
1961
+ await agent.setModel(input.model).catch((error) => {
1962
+ this.logger.warn("Could not select the model on the draft probe", error);
1963
+ });
1964
+ }
1965
+ return agent;
1869
1966
  }
1870
1967
  async spawnAgent(resolveOptions, workspace, sessionPath) {
1871
1968
  const spawn2 = resolvePi(resolveOptions);
@@ -1883,9 +1980,425 @@ var PiRpcAgentFactory = class {
1883
1980
  }
1884
1981
  };
1885
1982
 
1983
+ // ../adapter-pi-rpc/dist/pi-mcp.js
1984
+ import { execFile } from "node:child_process";
1985
+ import { homedir as homedir3 } from "node:os";
1986
+ import { join as join3 } from "node:path";
1987
+
1988
+ // ../adapter-pi-rpc/dist/internal/mcp-config.js
1989
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
1990
+ import { dirname as dirname2 } from "node:path";
1991
+ var SERVER_NAME = /^[A-Za-z0-9_-]+$/;
1992
+ function isValidMcpServerName(name) {
1993
+ return SERVER_NAME.test(name);
1994
+ }
1995
+ function readMcpFile(path) {
1996
+ if (!existsSync(path)) {
1997
+ return { mcpServers: {} };
1998
+ }
1999
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
2000
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
2001
+ throw new Error(`${path} is not a JSON object.`);
2002
+ }
2003
+ const servers = parsed.mcpServers;
2004
+ if (servers !== void 0 && (typeof servers !== "object" || servers === null || Array.isArray(servers))) {
2005
+ throw new Error(`${path}: "mcpServers" must be an object.`);
2006
+ }
2007
+ return {
2008
+ ...parsed,
2009
+ mcpServers: servers ?? {}
2010
+ };
2011
+ }
2012
+ function writeMcpFile(path, file) {
2013
+ mkdirSync(dirname2(path), { recursive: true });
2014
+ writeFileSync(path, `${JSON.stringify(file, null, 2)}
2015
+ `, "utf8");
2016
+ }
2017
+ function findMcpServer(path, name) {
2018
+ return readMcpFile(path).mcpServers[name];
2019
+ }
2020
+ function definesMcpServer(entry) {
2021
+ return entry !== void 0 && (entry.command !== void 0 || entry.url !== void 0 || entry.type !== void 0);
2022
+ }
2023
+ function setMcpServerOverrideEnabled(path, name, enabled) {
2024
+ const file = readMcpFile(path);
2025
+ const existing = file.mcpServers[name];
2026
+ if (enabled) {
2027
+ if (!existing) {
2028
+ return;
2029
+ }
2030
+ delete existing.enabled;
2031
+ if (Object.keys(existing).length === 0) {
2032
+ delete file.mcpServers[name];
2033
+ }
2034
+ } else {
2035
+ const entry = definesMcpServer(existing) ? {} : existing ?? {};
2036
+ entry.enabled = false;
2037
+ file.mcpServers[name] = entry;
2038
+ }
2039
+ writeMcpFile(path, file);
2040
+ }
2041
+ function upsertMcpServer(path, name, config) {
2042
+ if (!isValidMcpServerName(name)) {
2043
+ throw new Error(`"${name}" is not a valid MCP server name (letters, digits, "_" and "-").`);
2044
+ }
2045
+ const file = readMcpFile(path);
2046
+ file.mcpServers[name] = config;
2047
+ writeMcpFile(path, file);
2048
+ }
2049
+ function removeMcpServer(path, name) {
2050
+ const file = readMcpFile(path);
2051
+ if (!(name in file.mcpServers)) {
2052
+ return false;
2053
+ }
2054
+ delete file.mcpServers[name];
2055
+ writeMcpFile(path, file);
2056
+ return true;
2057
+ }
2058
+ function setMcpServerEnabled(path, name, enabled) {
2059
+ const file = readMcpFile(path);
2060
+ const server = file.mcpServers[name];
2061
+ if (!server) {
2062
+ return false;
2063
+ }
2064
+ if (enabled) {
2065
+ delete server.enabled;
2066
+ } else {
2067
+ server.enabled = false;
2068
+ }
2069
+ writeMcpFile(path, file);
2070
+ return true;
2071
+ }
2072
+
2073
+ // ../adapter-pi-rpc/dist/pi-mcp.js
2074
+ function parseMcpServerInput(raw) {
2075
+ const args = isRecord(raw) ? raw : {};
2076
+ const input = { name: asString2(args.name) };
2077
+ if (args.scope === "global" || args.scope === "project") {
2078
+ input.scope = args.scope;
2079
+ }
2080
+ if (args.type === "stdio" || args.type === "http") {
2081
+ input.type = args.type;
2082
+ }
2083
+ for (const key of ["command", "cwd", "url", "description"]) {
2084
+ const value = asString2(args[key]);
2085
+ if (value.length > 0) {
2086
+ input[key] = value;
2087
+ }
2088
+ }
2089
+ if (args.exposure === "codemode" || args.exposure === "deferred" || args.exposure === "direct" || args.exposure === "hidden") {
2090
+ input.exposure = args.exposure;
2091
+ }
2092
+ if (Array.isArray(args.args)) {
2093
+ input.args = args.args.filter(isString);
2094
+ }
2095
+ input.env = stringRecord(args.env);
2096
+ input.headers = stringRecord(args.headers);
2097
+ return input;
2098
+ }
2099
+ var DEFAULT_TIMEOUT_MS = 2e4;
2100
+ var PiMcp = class {
2101
+ options;
2102
+ constructor(options = {}) {
2103
+ this.options = options;
2104
+ }
2105
+ /** Connect every enabled server and report what happened. */
2106
+ async status(cwd) {
2107
+ const spawn2 = resolvePiCli(this.options);
2108
+ const { stdout, stderr, failed, timedOut } = await run(spawn2.command, [...spawn2.baseArgs, "mcp", "list", "--json"], cwd, cleanSpawnEnv(this.env()), this.options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
2109
+ const parsed = parseStatus(stdout);
2110
+ if (!parsed) {
2111
+ const seconds = Math.round((this.options.timeoutMs ?? DEFAULT_TIMEOUT_MS) / 1e3);
2112
+ const detail = timedOut ? `\`pi mcp list\` did not finish within ${seconds}s \u2014 a server is likely hanging (try \`pi mcp list\` in a terminal)` : stderr.trim() || (failed ? `\`pi mcp list\` exited with an error` : "no output");
2113
+ throw new Error(`Could not read the MCP server list: ${detail}`);
2114
+ }
2115
+ return parsed;
2116
+ }
2117
+ async add(input, cwd) {
2118
+ if (!isValidMcpServerName(input.name)) {
2119
+ return {
2120
+ ok: false,
2121
+ message: `"${input.name}" is not a valid server name (letters, digits, "_" and "-").`
2122
+ };
2123
+ }
2124
+ const isHttp = input.type === "http" || input.url !== void 0 && input.command === void 0;
2125
+ const config = this.toConfig(input, isHttp);
2126
+ if (typeof config === "string") {
2127
+ return { ok: false, message: config };
2128
+ }
2129
+ const path = this.configPath(cwd, input.scope);
2130
+ try {
2131
+ upsertMcpServer(path, input.name, config);
2132
+ } catch (error) {
2133
+ return { ok: false, message: describe(error) };
2134
+ }
2135
+ return { ok: true, path };
2136
+ }
2137
+ async remove(name, cwd, scope) {
2138
+ if (scope === "global") {
2139
+ return this.removeFrom(this.globalPath(), name, "global");
2140
+ }
2141
+ if (scope === "project") {
2142
+ return this.removeFrom(this.projectPath(cwd), name, "project");
2143
+ }
2144
+ for (const path of this.configPaths(cwd)) {
2145
+ try {
2146
+ if (removeMcpServer(path, name)) {
2147
+ return { ok: true, path };
2148
+ }
2149
+ } catch (error) {
2150
+ return { ok: false, message: describe(error) };
2151
+ }
2152
+ }
2153
+ return { ok: false, message: `No server named "${name}" is configured.` };
2154
+ }
2155
+ removeFrom(path, name, scope) {
2156
+ try {
2157
+ if (!removeMcpServer(path, name)) {
2158
+ return {
2159
+ ok: false,
2160
+ message: scope === "project" ? `"${name}" is not defined in this project's .pi/mcp.json.` : `"${name}" is not defined in the user mcp.json.`
2161
+ };
2162
+ }
2163
+ } catch (error) {
2164
+ return { ok: false, message: describe(error) };
2165
+ }
2166
+ return { ok: true, path, scope };
2167
+ }
2168
+ /**
2169
+ * Enable or disable a server in one scope.
2170
+ *
2171
+ * Global edits the user file. Project edits the project entry when the project
2172
+ * defines the server, and otherwise writes a project **override** — the way
2173
+ * pi's `/mcp` does "Disable in this project" — so a user-level server can be
2174
+ * turned off in one directory without touching every other project.
2175
+ */
2176
+ async setEnabled(name, enabled, cwd, scope) {
2177
+ if (scope === "global") {
2178
+ return this.setEnabledIn(this.globalPath(), name, enabled, "global");
2179
+ }
2180
+ if (scope === "project") {
2181
+ const projectPath = this.projectPath(cwd);
2182
+ if (definesMcpServer(findMcpServer(projectPath, name))) {
2183
+ return this.setEnabledIn(projectPath, name, enabled, "project");
2184
+ }
2185
+ const known = findMcpServer(projectPath, name) !== void 0 || findMcpServer(this.globalPath(), name) !== void 0;
2186
+ if (!known) {
2187
+ return {
2188
+ ok: false,
2189
+ message: `No server named "${name}" is configured.`
2190
+ };
2191
+ }
2192
+ try {
2193
+ setMcpServerOverrideEnabled(projectPath, name, enabled);
2194
+ } catch (error) {
2195
+ return { ok: false, message: describe(error) };
2196
+ }
2197
+ return { ok: true, path: projectPath, scope: "project", override: true };
2198
+ }
2199
+ for (const path of this.configPaths(cwd)) {
2200
+ try {
2201
+ if (findMcpServer(path, name) === void 0) {
2202
+ continue;
2203
+ }
2204
+ setMcpServerEnabled(path, name, enabled);
2205
+ return { ok: true, path };
2206
+ } catch (error) {
2207
+ return { ok: false, message: describe(error) };
2208
+ }
2209
+ }
2210
+ return { ok: false, message: `No server named "${name}" is configured.` };
2211
+ }
2212
+ setEnabledIn(path, name, enabled, scope) {
2213
+ try {
2214
+ if (findMcpServer(path, name) === void 0) {
2215
+ return {
2216
+ ok: false,
2217
+ message: scope === "project" ? `"${name}" is not defined in this project's .pi/mcp.json.` : `"${name}" is not defined in the user mcp.json.`
2218
+ };
2219
+ }
2220
+ setMcpServerEnabled(path, name, enabled);
2221
+ } catch (error) {
2222
+ return { ok: false, message: describe(error) };
2223
+ }
2224
+ return { ok: true, path, scope };
2225
+ }
2226
+ /** The user-level file first, then the project's — so a project definition wins. */
2227
+ configPaths(cwd) {
2228
+ return [this.projectPath(cwd), this.globalPath()];
2229
+ }
2230
+ configPath(cwd, scope) {
2231
+ return scope === "project" ? this.projectPath(cwd) : this.globalPath();
2232
+ }
2233
+ globalPath() {
2234
+ return join3(this.agentDir(), "mcp.json");
2235
+ }
2236
+ projectPath(cwd) {
2237
+ return join3(cwd, ".pi", "mcp.json");
2238
+ }
2239
+ agentDir() {
2240
+ if (this.options.agentDir) {
2241
+ return this.options.agentDir;
2242
+ }
2243
+ const fromEnv = this.env().PI_CODING_AGENT_DIR;
2244
+ return fromEnv && fromEnv.length > 0 ? fromEnv : join3(homedir3(), ".pi", "agent");
2245
+ }
2246
+ env() {
2247
+ return this.options.env ?? process.env;
2248
+ }
2249
+ /** The stored entry, or an error message when the input is incomplete. */
2250
+ toConfig(input, isHttp) {
2251
+ const config = {};
2252
+ if (isHttp) {
2253
+ const url = input.url?.trim();
2254
+ if (!url) {
2255
+ return 'An HTTP server needs a "url".';
2256
+ }
2257
+ config.type = "http";
2258
+ config.url = url;
2259
+ if (input.headers && Object.keys(input.headers).length > 0) {
2260
+ config.headers = input.headers;
2261
+ }
2262
+ } else {
2263
+ const command = input.command?.trim();
2264
+ if (!command) {
2265
+ return 'A stdio server needs a "command".';
2266
+ }
2267
+ config.type = "stdio";
2268
+ config.command = command;
2269
+ if (input.args && input.args.length > 0) {
2270
+ config.args = input.args;
2271
+ }
2272
+ if (input.cwd?.trim()) {
2273
+ config.cwd = input.cwd.trim();
2274
+ }
2275
+ if (input.env && Object.keys(input.env).length > 0) {
2276
+ config.env = input.env;
2277
+ }
2278
+ }
2279
+ if (input.exposure && input.exposure !== "codemode") {
2280
+ config.exposure = input.exposure;
2281
+ }
2282
+ if (input.description?.trim()) {
2283
+ config.description = input.description.trim();
2284
+ }
2285
+ if (input.enabled === false) {
2286
+ config.enabled = false;
2287
+ }
2288
+ return config;
2289
+ }
2290
+ };
2291
+ function run(command, args, cwd, env, timeoutMs) {
2292
+ return new Promise((resolve6, reject) => {
2293
+ execFile(command, args, { cwd, env, timeout: timeoutMs, maxBuffer: 16 * 1024 * 1024 }, (error, stdout, stderr) => {
2294
+ const err = error;
2295
+ const code = err?.code;
2296
+ if (typeof code === "string") {
2297
+ reject(new Error(`Could not run "${command}": ${stderr.trim() || code}`));
2298
+ return;
2299
+ }
2300
+ resolve6({ stdout, stderr, failed: error !== null, timedOut: err?.killed === true });
2301
+ });
2302
+ });
2303
+ }
2304
+ function parseStatus(stdout) {
2305
+ const trimmed = stdout.trim();
2306
+ if (trimmed.length === 0) {
2307
+ return void 0;
2308
+ }
2309
+ try {
2310
+ const parsed = JSON.parse(trimmed);
2311
+ if (!Array.isArray(parsed.servers)) {
2312
+ return void 0;
2313
+ }
2314
+ return {
2315
+ servers: parsed.servers.map(toServerStatus),
2316
+ errors: Array.isArray(parsed.errors) ? parsed.errors.filter(isString) : [],
2317
+ ...typeof parsed.note === "string" ? { note: parsed.note } : {}
2318
+ };
2319
+ } catch {
2320
+ return void 0;
2321
+ }
2322
+ }
2323
+ function toServerStatus(raw) {
2324
+ const row = typeof raw === "object" && raw !== null ? raw : {};
2325
+ const scope = row.scope;
2326
+ return {
2327
+ name: isString(row.name) ? row.name : "unknown",
2328
+ scope: scope === "project" || scope === "extension" ? scope : "global",
2329
+ source: isString(row.source) ? row.source : "",
2330
+ ...isString(row.override) ? { override: row.override } : {},
2331
+ enabled: row.enabled !== false,
2332
+ exposure: toExposure(row.exposure),
2333
+ transport: isString(row.transport) ? row.transport : "",
2334
+ state: toState(row.state),
2335
+ tools: Array.isArray(row.tools) ? row.tools.filter(isString) : [],
2336
+ ...isRecord(row.toolExposure) ? { toolExposure: toExposureMap(row.toolExposure) } : {},
2337
+ ...typeof row.resources === "number" ? { resources: row.resources } : {},
2338
+ ...typeof row.resourceTemplates === "number" ? { resourceTemplates: row.resourceTemplates } : {},
2339
+ ...isString(row.error) ? { error: row.error } : {}
2340
+ };
2341
+ }
2342
+ function toState(value) {
2343
+ switch (value) {
2344
+ case "connecting":
2345
+ case "connected":
2346
+ case "disconnected":
2347
+ case "needs-auth":
2348
+ case "failed":
2349
+ case "closed":
2350
+ case "disabled":
2351
+ return value;
2352
+ default:
2353
+ return "failed";
2354
+ }
2355
+ }
2356
+ function toExposure(value) {
2357
+ switch (value) {
2358
+ case "codemode":
2359
+ case "deferred":
2360
+ case "direct":
2361
+ case "hidden":
2362
+ return value;
2363
+ default:
2364
+ return "codemode";
2365
+ }
2366
+ }
2367
+ function toExposureMap(value) {
2368
+ const result = {};
2369
+ for (const [key, entry] of Object.entries(value)) {
2370
+ result[key] = toExposure(entry);
2371
+ }
2372
+ return result;
2373
+ }
2374
+ function isString(value) {
2375
+ return typeof value === "string";
2376
+ }
2377
+ function asString2(value) {
2378
+ return typeof value === "string" ? value : "";
2379
+ }
2380
+ function stringRecord(value) {
2381
+ if (!isRecord(value)) {
2382
+ return void 0;
2383
+ }
2384
+ const result = {};
2385
+ for (const [key, entry] of Object.entries(value)) {
2386
+ if (typeof entry === "string") {
2387
+ result[key] = entry;
2388
+ }
2389
+ }
2390
+ return Object.keys(result).length > 0 ? result : void 0;
2391
+ }
2392
+ function isRecord(value) {
2393
+ return typeof value === "object" && value !== null && !Array.isArray(value);
2394
+ }
2395
+ function describe(error) {
2396
+ return error instanceof Error ? error.message : String(error);
2397
+ }
2398
+
1886
2399
  // ../adapter-pi-rpc/dist/pi-rpc-session-catalog.js
1887
2400
  import { open, readdir as readdir2, readFile as readFile2, stat as stat2, unlink } from "node:fs/promises";
1888
- import { join as join3, resolve as resolve2, sep } from "node:path";
2401
+ import { join as join4, resolve as resolve2, sep } from "node:path";
1889
2402
  var TYPE_PREFIX = '{"type":"';
1890
2403
  function typeAt(raw, start, end) {
1891
2404
  if (!raw.startsWith(TYPE_PREFIX, start)) {
@@ -1974,12 +2487,12 @@ var PiRpcSessionCatalog = class {
1974
2487
  if (!bucket.isDirectory()) {
1975
2488
  continue;
1976
2489
  }
1977
- const bucketPath = join3(this.directory, bucket.name);
2490
+ const bucketPath = join4(this.directory, bucket.name);
1978
2491
  for (const entry of await safeReadDir(bucketPath)) {
1979
2492
  if (!entry.isFile() || !entry.name.endsWith(".jsonl")) {
1980
2493
  continue;
1981
2494
  }
1982
- const path = join3(bucketPath, entry.name);
2495
+ const path = join4(bucketPath, entry.name);
1983
2496
  const info = await stat2(path).catch(() => void 0);
1984
2497
  files.push({ path, size: info?.size ?? 0, updatedAt: info?.mtimeMs ?? 0 });
1985
2498
  }
@@ -2131,9 +2644,16 @@ function createPiRpcAdapter(config, logger) {
2131
2644
  env: config.env,
2132
2645
  removeFile: config.removeSessionFile
2133
2646
  });
2647
+ const cliOptions = {
2648
+ piPath: config.piPath,
2649
+ nodeEntryPath: config.nodeEntryPath,
2650
+ env: config.env
2651
+ };
2652
+ const mcp = new PiMcp(cliOptions);
2134
2653
  return {
2135
2654
  factory,
2136
2655
  catalog,
2656
+ mcp,
2137
2657
  describe: () => resolvePi({
2138
2658
  piPath: config.piPath,
2139
2659
  nodeEntryPath: config.nodeEntryPath,
@@ -2141,10 +2661,28 @@ function createPiRpcAdapter(config, logger) {
2141
2661
  noSession: config.noSession,
2142
2662
  extraArgs: config.extraArgs,
2143
2663
  env: config.env
2144
- })
2664
+ }),
2665
+ describeCli: () => {
2666
+ try {
2667
+ return resolvePiCli(cliOptions);
2668
+ } catch {
2669
+ return void 0;
2670
+ }
2671
+ }
2145
2672
  };
2146
2673
  }
2147
2674
 
2675
+ // ../host-runtime/dist/terminal-buffer.js
2676
+ var TERMINAL_BUFFER_CHARS = 256 * 1024;
2677
+ function trimTail(text, maxChars = TERMINAL_BUFFER_CHARS) {
2678
+ if (text.length <= maxChars) {
2679
+ return text;
2680
+ }
2681
+ const tail = text.slice(-maxChars);
2682
+ const newline = tail.indexOf("\n");
2683
+ return newline === -1 ? tail : tail.slice(newline + 1);
2684
+ }
2685
+
2148
2686
  // ../host-runtime/dist/transcript-projector.js
2149
2687
  var TranscriptProjector = class {
2150
2688
  options;
@@ -2671,8 +3209,15 @@ var HostSessionController = class {
2671
3209
  loadingOlderHistory = false;
2672
3210
  /** Discriminates history item ids across pages (see `historyItems`). */
2673
3211
  historyPageSeq = 0;
2674
- /** The shells this client has open, keyed by the terminal id it named. */
3212
+ /** This client's live attachments to shells, keyed by the terminal id it named. */
2675
3213
  terminals = /* @__PURE__ */ new Map();
3214
+ /**
3215
+ * The ids this connection has already opened. It separates a *first* open — a
3216
+ * page reattaching to a shell the host still runs — from a repeat open (the
3217
+ * Restart button), which must replace the shell and clear its scrollback
3218
+ * instead of replaying it into the emulator that already shows it.
3219
+ */
3220
+ openedTerminals = /* @__PURE__ */ new Set();
2676
3221
  /**
2677
3222
  * Bumped whenever a terminal id is opened or closed. A shell's late `exit`
2678
3223
  * (a replaced one, a killed one) compares its generation and stays silent, so
@@ -2704,6 +3249,7 @@ var HostSessionController = class {
2704
3249
  void this.warmDraft();
2705
3250
  } else {
2706
3251
  await this.runSessionChange(() => this.options.services.registry.activate(existingKey));
3252
+ void this.refreshModels();
2707
3253
  }
2708
3254
  }
2709
3255
  void this.publishLists();
@@ -2721,11 +3267,12 @@ var HostSessionController = class {
2721
3267
  return;
2722
3268
  }
2723
3269
  this.disposed = true;
2724
- for (const process2 of this.terminals.values()) {
2725
- process2.kill();
3270
+ for (const session of this.terminals.values()) {
3271
+ session.detach();
2726
3272
  }
2727
3273
  this.terminals.clear();
2728
3274
  this.terminalGenerations.clear();
3275
+ this.openedTerminals.clear();
2729
3276
  this.detachRegistry?.();
2730
3277
  this.detachRegistry = void 0;
2731
3278
  this.detachTranscripts?.();
@@ -2795,6 +3342,9 @@ var HostSessionController = class {
2795
3342
  case "commands/refresh":
2796
3343
  await this.refreshCommands();
2797
3344
  return;
3345
+ case "models/refresh":
3346
+ await this.refreshModels();
3347
+ return;
2798
3348
  case "project/list":
2799
3349
  await this.publishProjects();
2800
3350
  return;
@@ -2830,23 +3380,38 @@ var HostSessionController = class {
2830
3380
  }
2831
3381
  }
2832
3382
  /**
2833
- * Starts a shell for the bottom panel. The directory is the viewing session's
2834
- * (or the draft's), never an arbitrary path the client names — the backend
2835
- * still re-checks it against the host policy before spawning.
3383
+ * Attaches this client to a shell for the bottom panel. The directory is the
3384
+ * viewing session's (or the draft's), never an arbitrary path the client
3385
+ * names — the backend still re-checks it against the host policy before
3386
+ * spawning.
3387
+ *
3388
+ * A *first* open on this connection may land on a shell that is already running
3389
+ * (a reloaded page reattaching); a repeat open (the Restart button) replaces
3390
+ * it, scrollback and all, so the fresh shell does not replay the old one.
2836
3391
  */
2837
3392
  async openTerminal(payload) {
2838
- if (this.options.terminal === void 0) {
3393
+ const backend = this.options.terminal;
3394
+ if (backend === void 0) {
2839
3395
  this.emitTerminalExit(payload.terminalId, void 0, "This host has no terminal.");
2840
3396
  return;
2841
3397
  }
2842
- this.closeTerminal(payload.terminalId);
2843
- const generation = this.terminalGenerations.get(payload.terminalId) ?? 0;
3398
+ const previous = this.terminals.get(payload.terminalId);
3399
+ if (previous !== void 0) {
3400
+ previous.detach();
3401
+ this.terminals.delete(payload.terminalId);
3402
+ backend.close(payload.terminalId);
3403
+ } else if (this.openedTerminals.has(payload.terminalId)) {
3404
+ backend.close(payload.terminalId);
3405
+ }
3406
+ this.openedTerminals.add(payload.terminalId);
3407
+ const generation = (this.terminalGenerations.get(payload.terminalId) ?? 0) + 1;
3408
+ this.terminalGenerations.set(payload.terminalId, generation);
2844
3409
  const cwd = payload.cwd ?? this.currentState().workspace.cwd;
2845
3410
  const cols = clampGeometry(payload.cols, 80);
2846
3411
  const rows = clampGeometry(payload.rows, 24);
2847
- let process2;
3412
+ let session;
2848
3413
  try {
2849
- process2 = await this.options.terminal.open({ cwd, cols, rows }, {
3414
+ session = await backend.attach(payload.terminalId, { cwd, cols, rows }, {
2850
3415
  output: (data) => {
2851
3416
  if (this.terminalGenerations.get(payload.terminalId) === generation) {
2852
3417
  this.emitTerminalOutput(payload.terminalId, data);
@@ -2866,19 +3431,25 @@ var HostSessionController = class {
2866
3431
  return;
2867
3432
  }
2868
3433
  if (!this.disposed && this.terminalGenerations.get(payload.terminalId) === generation) {
2869
- this.terminals.set(payload.terminalId, process2);
3434
+ this.terminals.set(payload.terminalId, session);
3435
+ if (session.replay.length > 0) {
3436
+ this.emitTerminalOutput(payload.terminalId, session.replay);
3437
+ }
2870
3438
  } else {
2871
- process2.kill();
3439
+ session.detach();
2872
3440
  }
2873
3441
  }
3442
+ /**
3443
+ * Ends a shell for good: the reader closed the pane. Bumps the generation first,
3444
+ * so a late `exit` for the shell being replaced is not reported as news.
3445
+ */
2874
3446
  closeTerminal(terminalId) {
2875
3447
  this.terminalGenerations.set(terminalId, (this.terminalGenerations.get(terminalId) ?? 0) + 1);
2876
- const process2 = this.terminals.get(terminalId);
2877
- if (process2 === void 0) {
2878
- return;
2879
- }
3448
+ const session = this.terminals.get(terminalId);
2880
3449
  this.terminals.delete(terminalId);
2881
- process2.kill();
3450
+ this.openedTerminals.delete(terminalId);
3451
+ session?.detach();
3452
+ this.options.terminal?.close(terminalId);
2882
3453
  }
2883
3454
  emitTerminalOutput(terminalId, data) {
2884
3455
  if (data.length > 0 && !this.disposed) {
@@ -2938,7 +3509,7 @@ var HostSessionController = class {
2938
3509
  /** Fills the draft pickers from the registry's session-less probe. */
2939
3510
  async warmDraft() {
2940
3511
  try {
2941
- this.draftCatalog = await this.options.services.registry.draftDefaults();
3512
+ this.draftCatalog = await this.options.services.registry.draftDefaults(this.draftModel);
2942
3513
  } catch (error) {
2943
3514
  this.agentError = describeError(error);
2944
3515
  this.agentFailure = describeAgentFailure(error, this.options.agentHint);
@@ -3271,6 +3842,7 @@ var HostSessionController = class {
3271
3842
  const match = this.draftCatalog?.availableModels.find((model) => model.provider === provider && model.id === id);
3272
3843
  this.draftModel = match ?? { provider, id, name: id };
3273
3844
  this.emitState();
3845
+ void this.refreshDraftCatalog(this.draftModel);
3274
3846
  return;
3275
3847
  }
3276
3848
  const result = await this.guard(() => this.options.services.chat.setModel({ provider, id, name: id }));
@@ -3278,6 +3850,23 @@ var HostSessionController = class {
3278
3850
  this.emitState();
3279
3851
  }
3280
3852
  }
3853
+ /**
3854
+ * Re-reads the draft catalog for a model picked before any session exists, so
3855
+ * the thinking picker mirrors that model's levels instead of the default
3856
+ * model's. The request is dropped when the reader picks again or a session
3857
+ * opens while the probe is in flight.
3858
+ */
3859
+ async refreshDraftCatalog(model) {
3860
+ try {
3861
+ const catalog = await this.options.services.registry.draftDefaults(model);
3862
+ if (catalog && this.activeKey === void 0 && !this.disposed && this.draftModel === model) {
3863
+ this.draftCatalog = catalog;
3864
+ this.emitState();
3865
+ }
3866
+ } catch (error) {
3867
+ this.options.logger.warn("Could not probe the draft catalog for the picked model", error);
3868
+ }
3869
+ }
3281
3870
  async setThinkingLevel(level) {
3282
3871
  if (this.activeKey === void 0) {
3283
3872
  this.draftThinking = level;
@@ -3310,6 +3899,24 @@ var HostSessionController = class {
3310
3899
  }
3311
3900
  await this.guard(() => gateway.refreshCommands());
3312
3901
  }
3902
+ /**
3903
+ * Re-reads the model catalog on request. A warm session re-asks pi over RPC
3904
+ * (cheap); a draft re-runs the session-less probe, since that is where its
3905
+ * picker's catalog came from and it would otherwise be frozen for the host's
3906
+ * lifetime.
3907
+ */
3908
+ async refreshModels() {
3909
+ if (this.activeKey === void 0) {
3910
+ this.options.services.registry.refreshDraftDefaults();
3911
+ await this.warmDraft();
3912
+ return;
3913
+ }
3914
+ const gateway = this.options.services.registry.active();
3915
+ if (gateway?.refreshModels === void 0) {
3916
+ return;
3917
+ }
3918
+ await this.guard(() => gateway.refreshModels());
3919
+ }
3313
3920
  async runHostCommand(command, args, requestId) {
3314
3921
  const handler = this.options.onHostCommand;
3315
3922
  if (!handler) {
@@ -3665,7 +4272,7 @@ function hintOnly(hint) {
3665
4272
  // ../server/src/internal/http/health.controller.ts
3666
4273
  import { Controller, Get } from "@nestjs/common";
3667
4274
  import { readFile as readFile3 } from "node:fs/promises";
3668
- import { join as join4 } from "node:path";
4275
+ import { join as join5 } from "node:path";
3669
4276
  import { Inject } from "@nestjs/common";
3670
4277
 
3671
4278
  // ../server/src/app/tokens.ts
@@ -3693,7 +4300,7 @@ var HealthController = class {
3693
4300
  };
3694
4301
  }
3695
4302
  async manifest() {
3696
- const raw = await readFile3(join4(this.config.uiDir, "webview.manifest.json"), "utf8").catch(
4303
+ const raw = await readFile3(join5(this.config.uiDir, "webview.manifest.json"), "utf8").catch(
3697
4304
  () => void 0
3698
4305
  );
3699
4306
  return parseFrontendManifest(raw) ?? { name: "unknown", protocolVersion: PROTOCOL_VERSION };
@@ -3769,7 +4376,50 @@ var ServerProjectPolicy = class {
3769
4376
  // ../server/src/internal/terminal/terminal.service.ts
3770
4377
  import { spawn as spawnPty } from "node-pty";
3771
4378
  import { Inject as Inject2, Injectable } from "@nestjs/common";
4379
+
4380
+ // ../server/src/internal/terminal/terminal-log.ts
4381
+ import { mkdirSync as mkdirSync2, readFileSync as readFileSync2, renameSync, rmSync, writeFileSync as writeFileSync2 } from "node:fs";
4382
+ import { join as join6 } from "node:path";
4383
+ var LOG_DIR = "terminals";
4384
+ var LOG_MAX_CHARS = TERMINAL_BUFFER_CHARS * 4;
4385
+ function readTerminalLog(dataDir, terminalId) {
4386
+ try {
4387
+ const raw = readFileSync2(logPath(dataDir, terminalId), "utf8");
4388
+ return trimTail(raw, LOG_MAX_CHARS);
4389
+ } catch {
4390
+ return "";
4391
+ }
4392
+ }
4393
+ function writeTerminalLog(dataDir, terminalId, text) {
4394
+ const trimmed = trimTail(text, LOG_MAX_CHARS);
4395
+ const target = logPath(dataDir, terminalId);
4396
+ const temp = `${target}.${process.pid}.tmp`;
4397
+ try {
4398
+ mkdirSync2(join6(dataDir, LOG_DIR), { recursive: true });
4399
+ writeFileSync2(temp, trimmed);
4400
+ renameSync(temp, target);
4401
+ } catch {
4402
+ try {
4403
+ writeFileSync2(target, trimmed);
4404
+ } catch {
4405
+ rmSync(temp, { force: true });
4406
+ }
4407
+ }
4408
+ }
4409
+ function removeTerminalLog(dataDir, terminalId) {
4410
+ rmSync(logPath(dataDir, terminalId), { force: true });
4411
+ }
4412
+ function safeTerminalName(terminalId) {
4413
+ const safe = terminalId.replace(/[^A-Za-z0-9._-]/g, "_").slice(0, 100);
4414
+ return safe.length > 0 ? safe : "terminal";
4415
+ }
4416
+ function logPath(dataDir, terminalId) {
4417
+ return join6(dataDir, LOG_DIR, `${safeTerminalName(terminalId)}.log`);
4418
+ }
4419
+
4420
+ // ../server/src/internal/terminal/terminal.service.ts
3772
4421
  var SIGKILL_AFTER_MS = 1500;
4422
+ var FLUSH_DEBOUNCE_MS = 2e3;
3773
4423
  function defaultShell() {
3774
4424
  const configured = process.env.SHELL?.trim();
3775
4425
  if (configured !== void 0 && configured.length > 0) {
@@ -3778,17 +4428,95 @@ function defaultShell() {
3778
4428
  return process.platform === "win32" ? "powershell.exe" : "/bin/bash";
3779
4429
  }
3780
4430
  var ServerTerminalBackend = class {
3781
- constructor(policy, logger) {
4431
+ constructor(policy, logger, config) {
3782
4432
  this.policy = policy;
3783
4433
  this.logger = logger;
4434
+ this.config = config;
4435
+ this.entries = /* @__PURE__ */ new Map();
4436
+ }
4437
+ attach(terminalId, options, sink) {
4438
+ const entry = this.entryFor(terminalId);
4439
+ if (entry.pty === void 0) {
4440
+ this.spawn(terminalId, entry, options);
4441
+ } else {
4442
+ this.resize(entry, options.cols, options.rows);
4443
+ }
4444
+ entry.sinks.add(sink);
4445
+ this.cancelIdle(entry);
4446
+ return {
4447
+ replay: entry.output,
4448
+ write: (data) => entry.pty?.write(data),
4449
+ resize: (cols, rows) => this.resize(entry, cols, rows),
4450
+ detach: () => this.detach(terminalId, entry, sink)
4451
+ };
4452
+ }
4453
+ /** Ends a shell for good, drops its scrollback, and tells any other viewer. */
4454
+ close(terminalId) {
4455
+ const entry = this.entries.get(terminalId);
4456
+ this.entries.delete(terminalId);
4457
+ if (entry !== void 0) {
4458
+ this.stopFlush(entry);
4459
+ this.cancelIdle(entry);
4460
+ entry.generation += 1;
4461
+ const sinks = [...entry.sinks];
4462
+ entry.sinks.clear();
4463
+ this.killPty(entry.pty);
4464
+ entry.pty = void 0;
4465
+ for (const sink of sinks) {
4466
+ sink.exit();
4467
+ }
4468
+ }
4469
+ removeTerminalLog(this.config.dataDir, terminalId);
4470
+ }
4471
+ onModuleDestroy() {
4472
+ for (const [terminalId, entry] of this.entries) {
4473
+ entry.generation += 1;
4474
+ this.persist(terminalId, entry);
4475
+ this.killPty(entry.pty);
4476
+ entry.pty = void 0;
4477
+ this.stopFlush(entry);
4478
+ this.cancelIdle(entry);
4479
+ }
4480
+ this.entries.clear();
3784
4481
  }
3785
- open(options, sink) {
4482
+ /** SIGTERM, then SIGKILL for a shell that ignores it (a hung process group). */
4483
+ killPty(pty) {
4484
+ if (pty === void 0) {
4485
+ return;
4486
+ }
4487
+ try {
4488
+ pty.kill();
4489
+ } catch {
4490
+ return;
4491
+ }
4492
+ const timer = setTimeout(() => {
4493
+ try {
4494
+ pty.kill("SIGKILL");
4495
+ } catch {
4496
+ }
4497
+ }, SIGKILL_AFTER_MS);
4498
+ timer.unref?.();
4499
+ }
4500
+ entryFor(terminalId) {
4501
+ const existing = this.entries.get(terminalId);
4502
+ if (existing !== void 0) {
4503
+ return existing;
4504
+ }
4505
+ const entry = {
4506
+ sinks: /* @__PURE__ */ new Set(),
4507
+ output: readTerminalLog(this.config.dataDir, terminalId),
4508
+ dirty: false,
4509
+ generation: 0
4510
+ };
4511
+ this.entries.set(terminalId, entry);
4512
+ return entry;
4513
+ }
4514
+ spawn(terminalId, entry, options) {
3786
4515
  if (!this.policy.canOpen(options.cwd)) {
3787
- throw new UnsupportedByHostError(
3788
- `Morse is not allowed to run a terminal in ${options.cwd}.`
3789
- );
4516
+ throw new UnsupportedByHostError(`Morse is not allowed to run a terminal in ${options.cwd}.`);
3790
4517
  }
3791
4518
  const shell = defaultShell();
4519
+ const generation = ++entry.generation;
3792
4520
  const pty = spawnPty(shell, [], {
3793
4521
  name: "xterm-256color",
3794
4522
  cols: options.cols,
@@ -3796,41 +4524,105 @@ var ServerTerminalBackend = class {
3796
4524
  cwd: options.cwd,
3797
4525
  env: { ...process.env, TERM: "xterm-256color", MORSE_TERMINAL: "1" }
3798
4526
  });
4527
+ entry.pty = pty;
3799
4528
  this.logger.info(`Terminal opened in ${options.cwd} (${shell}, pid ${pty.pid})`);
3800
- let exited = false;
3801
- const data = pty.onData((chunk) => sink.output(chunk));
4529
+ pty.onData((chunk) => {
4530
+ if (entry.generation !== generation) {
4531
+ return;
4532
+ }
4533
+ entry.output += chunk;
4534
+ if (entry.output.length > TERMINAL_BUFFER_CHARS * 1.5) {
4535
+ entry.output = trimTail(entry.output, TERMINAL_BUFFER_CHARS);
4536
+ }
4537
+ entry.dirty = true;
4538
+ this.scheduleFlush(terminalId, entry);
4539
+ for (const sink of entry.sinks) {
4540
+ sink.output(chunk);
4541
+ }
4542
+ });
3802
4543
  pty.onExit(({ exitCode }) => {
3803
- exited = true;
3804
- data.dispose();
3805
- sink.exit(exitCode);
4544
+ if (entry.generation !== generation) {
4545
+ return;
4546
+ }
4547
+ entry.pty = void 0;
4548
+ this.persist(terminalId, entry);
4549
+ for (const sink of [...entry.sinks]) {
4550
+ sink.exit(exitCode);
4551
+ }
3806
4552
  });
3807
- return {
3808
- write: (input) => {
3809
- if (!exited) {
3810
- pty.write(input);
3811
- }
3812
- },
3813
- resize: (cols, rows) => {
3814
- if (!exited) {
3815
- pty.resize(cols, rows);
3816
- }
3817
- },
3818
- kill: () => {
3819
- if (exited) {
3820
- return;
3821
- }
3822
- exited = true;
3823
- pty.kill();
3824
- const timer = setTimeout(() => pty.kill("SIGKILL"), SIGKILL_AFTER_MS);
3825
- timer.unref?.();
4553
+ }
4554
+ resize(entry, cols, rows) {
4555
+ try {
4556
+ entry.pty?.resize(cols, rows);
4557
+ } catch {
4558
+ }
4559
+ }
4560
+ detach(terminalId, entry, sink) {
4561
+ entry.sinks.delete(sink);
4562
+ if (entry.sinks.size > 0) {
4563
+ return;
4564
+ }
4565
+ this.persist(terminalId, entry);
4566
+ this.scheduleIdle(terminalId, entry);
4567
+ }
4568
+ /**
4569
+ * Reclaims a shell nobody has reattached to. The log stays (the output should
4570
+ * still be there next time); only the process goes.
4571
+ */
4572
+ scheduleIdle(terminalId, entry) {
4573
+ if (this.config.terminalIdleMs <= 0) {
4574
+ return;
4575
+ }
4576
+ this.cancelIdle(entry);
4577
+ entry.idleTimer = setTimeout(() => {
4578
+ entry.idleTimer = void 0;
4579
+ if (entry.sinks.size > 0) {
4580
+ return;
3826
4581
  }
3827
- };
4582
+ entry.generation += 1;
4583
+ this.killPty(entry.pty);
4584
+ entry.pty = void 0;
4585
+ this.persist(terminalId, entry);
4586
+ this.entries.delete(terminalId);
4587
+ }, this.config.terminalIdleMs);
4588
+ entry.idleTimer.unref?.();
4589
+ }
4590
+ cancelIdle(entry) {
4591
+ if (entry.idleTimer !== void 0) {
4592
+ clearTimeout(entry.idleTimer);
4593
+ entry.idleTimer = void 0;
4594
+ }
4595
+ }
4596
+ scheduleFlush(terminalId, entry) {
4597
+ if (entry.flushTimer !== void 0) {
4598
+ return;
4599
+ }
4600
+ entry.flushTimer = setTimeout(() => {
4601
+ entry.flushTimer = void 0;
4602
+ this.persist(terminalId, entry);
4603
+ }, FLUSH_DEBOUNCE_MS);
4604
+ entry.flushTimer.unref?.();
4605
+ }
4606
+ stopFlush(entry) {
4607
+ if (entry.flushTimer !== void 0) {
4608
+ clearTimeout(entry.flushTimer);
4609
+ entry.flushTimer = void 0;
4610
+ }
4611
+ }
4612
+ persist(terminalId, entry) {
4613
+ this.stopFlush(entry);
4614
+ if (!entry.dirty) {
4615
+ return;
4616
+ }
4617
+ entry.dirty = false;
4618
+ writeTerminalLog(this.config.dataDir, terminalId, entry.output);
3828
4619
  }
3829
4620
  };
3830
4621
  ServerTerminalBackend = __decorateClass([
3831
4622
  Injectable(),
3832
4623
  __decorateParam(0, Inject2(MORSE_PROJECT_POLICY)),
3833
- __decorateParam(1, Inject2(MORSE_LOGGER))
4624
+ __decorateParam(1, Inject2(MORSE_LOGGER)),
4625
+ __decorateParam(2, Inject2(MORSE_CONFIG))
3834
4626
  ], ServerTerminalBackend);
3835
4627
 
3836
4628
  // ../server/src/internal/registry.ts
@@ -3870,9 +4662,9 @@ import { Inject as Inject5 } from "@nestjs/common";
3870
4662
  import { Inject as Inject4, Injectable as Injectable3 } from "@nestjs/common";
3871
4663
 
3872
4664
  // ../server/src/internal/uploads/upload-store.ts
3873
- import { existsSync } from "node:fs";
4665
+ import { existsSync as existsSync2 } from "node:fs";
3874
4666
  import { mkdir, writeFile } from "node:fs/promises";
3875
- import { basename as basename2, isAbsolute as isAbsolute3, join as join5, relative, sep as sep3 } from "node:path";
4667
+ import { basename as basename2, isAbsolute as isAbsolute3, join as join7, relative, sep as sep3 } from "node:path";
3876
4668
  var MAX_UPLOAD_BYTES = 10 * 1024 * 1024;
3877
4669
  var DEFAULT_UPLOAD_DIR = ".morse/uploads";
3878
4670
  var UploadError = class extends Error {
@@ -3892,7 +4684,7 @@ async function saveUpload(cwd, request, uploadDir = DEFAULT_UPLOAD_DIR) {
3892
4684
  `That file is ${formatMb(bytes.length)} MB; the limit is ${formatMb(MAX_UPLOAD_BYTES)} MB.`
3893
4685
  );
3894
4686
  }
3895
- const directory = isAbsolute3(uploadDir) ? uploadDir : join5(cwd, uploadDir);
4687
+ const directory = isAbsolute3(uploadDir) ? uploadDir : join7(cwd, uploadDir);
3896
4688
  await mkdir(directory, { recursive: true });
3897
4689
  const target = uniquePath(directory, name);
3898
4690
  await writeFile(target, bytes);
@@ -3911,10 +4703,10 @@ function uniquePath(directory, name) {
3911
4703
  const dot = name.lastIndexOf(".");
3912
4704
  const stem = dot > 0 ? name.slice(0, dot) : name;
3913
4705
  const extension = dot > 0 ? name.slice(dot) : "";
3914
- let candidate = join5(directory, name);
4706
+ let candidate = join7(directory, name);
3915
4707
  let suffix = 1;
3916
- while (existsSync(candidate)) {
3917
- candidate = join5(directory, `${stem}-${suffix}${extension}`);
4708
+ while (existsSync2(candidate)) {
4709
+ candidate = join7(directory, `${stem}-${suffix}${extension}`);
3918
4710
  suffix += 1;
3919
4711
  }
3920
4712
  return candidate;
@@ -3930,8 +4722,8 @@ function formatMb(bytes) {
3930
4722
 
3931
4723
  // ../server/src/internal/workspace/directory-browser.ts
3932
4724
  import { readdir as readdir3, stat as stat3 } from "node:fs/promises";
3933
- import { homedir as homedir3 } from "node:os";
3934
- import { basename as basename3, dirname, isAbsolute as isAbsolute4, join as join6, resolve as resolve4 } from "node:path";
4725
+ import { homedir as homedir4 } from "node:os";
4726
+ import { basename as basename3, dirname as dirname3, isAbsolute as isAbsolute4, join as join8, resolve as resolve4 } from "node:path";
3935
4727
  var SKIPPED_DIRECTORIES = /* @__PURE__ */ new Set([
3936
4728
  "node_modules",
3937
4729
  "dist",
@@ -3955,8 +4747,8 @@ async function browseDirectory(path, options) {
3955
4747
  throw new Error(`A folder path must be absolute: ${requested}`);
3956
4748
  }
3957
4749
  const entries = await readdir3(start, { withFileTypes: true });
3958
- const directories = entries.filter((entry) => entry.isDirectory()).filter((entry) => !shouldSkip(entry.name)).map((entry) => ({ name: entry.name, path: join6(start, entry.name) })).sort((left, right) => left.name.localeCompare(right.name));
3959
- const parent = dirname(start);
4750
+ const directories = entries.filter((entry) => entry.isDirectory()).filter((entry) => !shouldSkip(entry.name)).map((entry) => ({ name: entry.name, path: join8(start, entry.name) })).sort((left, right) => left.name.localeCompare(right.name));
4751
+ const parent = dirname3(start);
3960
4752
  return {
3961
4753
  path: start,
3962
4754
  parent: parent === start ? void 0 : parent,
@@ -3970,7 +4762,7 @@ function shouldSkip(name) {
3970
4762
  }
3971
4763
  async function isGitRepo(path) {
3972
4764
  try {
3973
- await stat3(join6(path, ".git"));
4765
+ await stat3(join8(path, ".git"));
3974
4766
  return true;
3975
4767
  } catch {
3976
4768
  return false;
@@ -3979,7 +4771,7 @@ async function isGitRepo(path) {
3979
4771
  function shortcutRoots(options) {
3980
4772
  const seen = /* @__PURE__ */ new Set();
3981
4773
  const roots = [];
3982
- for (const candidate of [...options.roots ?? [], homedir3(), options.defaultPath]) {
4774
+ for (const candidate of [...options.roots ?? [], homedir4(), options.defaultPath]) {
3983
4775
  if (!candidate || !isAbsolute4(candidate)) {
3984
4776
  continue;
3985
4777
  }
@@ -4041,7 +4833,7 @@ function toPosix(path) {
4041
4833
  }
4042
4834
 
4043
4835
  // ../server/src/internal/workspace/git-log.ts
4044
- import { execFile } from "node:child_process";
4836
+ import { execFile as execFile2 } from "node:child_process";
4045
4837
  import { relative as relative3 } from "node:path";
4046
4838
  var DEFAULT_MAX_COMMITS = 250;
4047
4839
  var HARD_MAX_COMMITS = 500;
@@ -4092,9 +4884,9 @@ async function readGitStatus(cwd) {
4092
4884
  gitText(["rev-parse", "--show-prefix"], cwd),
4093
4885
  gitText(["status", "--porcelain=v1", "-z", "--untracked-files=all"], cwd)
4094
4886
  ]);
4095
- return { isRepo: true, files: parseStatus(raw ?? "", prefix?.trim() ?? "") };
4887
+ return { isRepo: true, files: parseStatus2(raw ?? "", prefix?.trim() ?? "") };
4096
4888
  }
4097
- function parseStatus(raw, root) {
4889
+ function parseStatus2(raw, root) {
4098
4890
  const records = raw.split("\0");
4099
4891
  const files = [];
4100
4892
  for (let index = 0; index < records.length; index += 1) {
@@ -4338,7 +5130,7 @@ function parseRefs(raw) {
4338
5130
  }
4339
5131
  function gitText(args, cwd) {
4340
5132
  return new Promise((resolve6) => {
4341
- execFile(
5133
+ execFile2(
4342
5134
  "git",
4343
5135
  args,
4344
5136
  { cwd, timeout: 5e3, maxBuffer: 16 * 1024 * 1024, windowsHide: true },
@@ -4354,7 +5146,7 @@ async function gitRun(args, cwd, timeout = 5e3) {
4354
5146
  }
4355
5147
  function gitExec(args, cwd, timeout = 5e3) {
4356
5148
  return new Promise((resolve6) => {
4357
- execFile(
5149
+ execFile2(
4358
5150
  "git",
4359
5151
  args,
4360
5152
  {
@@ -4382,9 +5174,9 @@ function gitExec(args, cwd, timeout = 5e3) {
4382
5174
  }
4383
5175
 
4384
5176
  // ../server/src/internal/workspace/workspace-index.ts
4385
- import { execFile as execFile2 } from "node:child_process";
5177
+ import { execFile as execFile3 } from "node:child_process";
4386
5178
  import { readdir as readdir4 } from "node:fs/promises";
4387
- import { join as join7, relative as relative4 } from "node:path";
5179
+ import { join as join9, relative as relative4 } from "node:path";
4388
5180
  var SKIPPED_DIRECTORIES2 = /* @__PURE__ */ new Set([
4389
5181
  "node_modules",
4390
5182
  "dist",
@@ -4469,7 +5261,7 @@ async function gitFileList(cwd) {
4469
5261
  }
4470
5262
  function runGit(args, cwd) {
4471
5263
  return new Promise((resolve6) => {
4472
- execFile2(
5264
+ execFile3(
4473
5265
  "git",
4474
5266
  args,
4475
5267
  { cwd, timeout: 5e3, maxBuffer: 8 * 1024 * 1024, windowsHide: true },
@@ -4495,7 +5287,7 @@ async function walk(root, directory, files) {
4495
5287
  if (files.length >= MAX_INDEXED_FILES) {
4496
5288
  return;
4497
5289
  }
4498
- const full = join7(directory, entry.name);
5290
+ const full = join9(directory, entry.name);
4499
5291
  if (entry.isDirectory()) {
4500
5292
  if (SKIPPED_DIRECTORIES2.has(entry.name) || entry.name.startsWith(".")) {
4501
5293
  continue;
@@ -4513,20 +5305,20 @@ function toPosix3(path) {
4513
5305
  }
4514
5306
 
4515
5307
  // ../server/src/internal/workspace/workbench-store.ts
4516
- import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
4517
- import { join as join8 } from "node:path";
5308
+ import { mkdirSync as mkdirSync3, readFileSync as readFileSync3, renameSync as renameSync2, rmSync as rmSync2, writeFileSync as writeFileSync3 } from "node:fs";
5309
+ import { join as join10 } from "node:path";
4518
5310
  var LAYOUT_FILE = "workbench.json";
4519
5311
  var DRAFTS_FILE = "drafts.json";
4520
5312
  var LAYOUT_MAX_BYTES = 256 * 1024;
4521
5313
  var DRAFTS_MAX_BYTES = 32 * 1024 * 1024;
4522
5314
  function readWorkbench(dataDir) {
4523
- return readSlot(join8(dataDir, LAYOUT_FILE), LAYOUT_MAX_BYTES);
5315
+ return readSlot(join10(dataDir, LAYOUT_FILE), LAYOUT_MAX_BYTES);
4524
5316
  }
4525
5317
  function saveWorkbench(dataDir, snapshot) {
4526
5318
  return saveSlot(dataDir, LAYOUT_FILE, snapshot, LAYOUT_MAX_BYTES);
4527
5319
  }
4528
5320
  function readDrafts(dataDir) {
4529
- return readSlot(join8(dataDir, DRAFTS_FILE), DRAFTS_MAX_BYTES);
5321
+ return readSlot(join10(dataDir, DRAFTS_FILE), DRAFTS_MAX_BYTES);
4530
5322
  }
4531
5323
  function saveDrafts(dataDir, snapshot) {
4532
5324
  return saveSlot(dataDir, DRAFTS_FILE, snapshot, DRAFTS_MAX_BYTES);
@@ -4534,7 +5326,7 @@ function saveDrafts(dataDir, snapshot) {
4534
5326
  function readSlot(path, maxBytes) {
4535
5327
  let raw;
4536
5328
  try {
4537
- raw = readFileSync(path, "utf8");
5329
+ raw = readFileSync3(path, "utf8");
4538
5330
  } catch {
4539
5331
  return void 0;
4540
5332
  }
@@ -4558,19 +5350,19 @@ function saveSlot(dataDir, file, snapshot, maxBytes) {
4558
5350
  if (Buffer.byteLength(serialized, "utf8") > maxBytes) {
4559
5351
  throw new UnsupportedByHostError(`That ${file} snapshot is too large to save.`);
4560
5352
  }
4561
- const target = join8(dataDir, file);
5353
+ const target = join10(dataDir, file);
4562
5354
  const temp = `${target}.${process.pid}.tmp`;
4563
5355
  try {
4564
- mkdirSync(dataDir, { recursive: true });
4565
- writeFileSync(temp, serialized);
4566
- renameSync(temp, target);
5356
+ mkdirSync3(dataDir, { recursive: true });
5357
+ writeFileSync3(temp, serialized);
5358
+ renameSync2(temp, target);
4567
5359
  return true;
4568
5360
  } catch {
4569
5361
  try {
4570
- writeFileSync(target, serialized);
5362
+ writeFileSync3(target, serialized);
4571
5363
  return true;
4572
5364
  } catch {
4573
- rmSync(temp, { force: true });
5365
+ rmSync2(temp, { force: true });
4574
5366
  return false;
4575
5367
  }
4576
5368
  }
@@ -4589,13 +5381,14 @@ function asSnapshot(value) {
4589
5381
 
4590
5382
  // ../server/src/internal/ws/session-factory.service.ts
4591
5383
  var MorseSessionFactory = class {
4592
- constructor(registry, transcripts, policy, logger, config, terminal) {
5384
+ constructor(registry, transcripts, policy, logger, config, terminal, pi) {
4593
5385
  this.registry = registry;
4594
5386
  this.transcripts = transcripts;
4595
5387
  this.policy = policy;
4596
5388
  this.logger = logger;
4597
5389
  this.config = config;
4598
5390
  this.terminal = terminal;
5391
+ this.pi = pi;
4599
5392
  }
4600
5393
  capabilities() {
4601
5394
  return {
@@ -4640,7 +5433,10 @@ var MorseSessionFactory = class {
4640
5433
  // A page served by this host may ask the registry for the latest release, so
4641
5434
  // the sidebar can say when a newer Morse is out. A host without it stays
4642
5435
  // quiet; this one is on unless `MORSE_UPDATE_CHECK=0` says otherwise.
4643
- updateCheck: this.config.updateCheck
5436
+ updateCheck: this.config.updateCheck,
5437
+ // Managing MCP servers runs the `pi` CLI, so this host offers it only when
5438
+ // it found one. A host without it hides the affordance.
5439
+ mcp: this.pi.describeCli() !== void 0
4644
5440
  };
4645
5441
  }
4646
5442
  createFor(emit) {
@@ -4785,6 +5581,31 @@ var MorseSessionFactory = class {
4785
5581
  const branch = typeof args?.branch === "string" ? args.branch : "";
4786
5582
  return checkoutGit(cwd, branch, args?.create === true);
4787
5583
  }
5584
+ case "mcpStatus": {
5585
+ const cwd = this.requireWritableCwd(context);
5586
+ try {
5587
+ return await this.pi.mcp.status(cwd);
5588
+ } catch (error) {
5589
+ return { servers: [], errors: [describeError2(error)] };
5590
+ }
5591
+ }
5592
+ case "mcpAdd": {
5593
+ const cwd = this.requireWritableCwd(context);
5594
+ return this.pi.mcp.add(parseMcpServerInput(args), cwd);
5595
+ }
5596
+ case "mcpRemove": {
5597
+ const cwd = this.requireWritableCwd(context);
5598
+ return this.pi.mcp.remove(stringArg(args, "name"), cwd, mcpScope(args));
5599
+ }
5600
+ case "mcpSetEnabled": {
5601
+ const cwd = this.requireWritableCwd(context);
5602
+ return this.pi.mcp.setEnabled(
5603
+ stringArg(args, "name"),
5604
+ args?.enabled === true,
5605
+ cwd,
5606
+ mcpScope(args)
5607
+ );
5608
+ }
4788
5609
  default:
4789
5610
  throw new UnsupportedByHostError(`This host does not support "${command}".`);
4790
5611
  }
@@ -4834,7 +5655,8 @@ MorseSessionFactory = __decorateClass([
4834
5655
  __decorateParam(2, Inject4(MORSE_PROJECT_POLICY)),
4835
5656
  __decorateParam(3, Inject4(MORSE_LOGGER)),
4836
5657
  __decorateParam(4, Inject4(MORSE_CONFIG)),
4837
- __decorateParam(5, Inject4(ServerTerminalBackend))
5658
+ __decorateParam(5, Inject4(ServerTerminalBackend)),
5659
+ __decorateParam(6, Inject4(MORSE_PI_ADAPTER))
4838
5660
  ], MorseSessionFactory);
4839
5661
  function gitPaths(args) {
4840
5662
  const paths = args?.paths;
@@ -4843,6 +5665,16 @@ function gitPaths(args) {
4843
5665
  }
4844
5666
  return paths.filter((path) => typeof path === "string" && path.length > 0);
4845
5667
  }
5668
+ function stringArg(args, key) {
5669
+ const value = args?.[key];
5670
+ return typeof value === "string" ? value : "";
5671
+ }
5672
+ function mcpScope(args) {
5673
+ return args?.scope === "global" || args?.scope === "project" ? args.scope : void 0;
5674
+ }
5675
+ function describeError2(error) {
5676
+ return error instanceof Error ? error.message : String(error);
5677
+ }
4846
5678
 
4847
5679
  // ../server/src/internal/ws/morse.gateway.ts
4848
5680
  var MorseGateway = class {
@@ -4892,19 +5724,19 @@ MorseGateway = __decorateClass([
4892
5724
  ], MorseGateway);
4893
5725
 
4894
5726
  // ../server/src/app/config.ts
4895
- import { basename as basename4, dirname as dirname2, isAbsolute as isAbsolute6, join as join9 } from "node:path";
4896
- import { existsSync as existsSync2, readFileSync as readFileSync2 } from "node:fs";
4897
- import { homedir as homedir4 } from "node:os";
5727
+ import { basename as basename4, dirname as dirname4, isAbsolute as isAbsolute6, join as join11 } from "node:path";
5728
+ import { existsSync as existsSync3, readFileSync as readFileSync4 } from "node:fs";
5729
+ import { homedir as homedir5 } from "node:os";
4898
5730
  import { fileURLToPath } from "node:url";
4899
5731
  function loadConfig(env = process.env) {
4900
5732
  const workspacePath = resolveWorkspacePath(env);
4901
5733
  const configuredUiDir = env.MORSE_UI_DIR;
4902
- const moduleDir = dirname2(fileURLToPath(import.meta.url));
4903
- const bundledUiDir = join9(moduleDir, "ui");
4904
- const monorepoUiDir = join9(moduleDir, "..", "..", "..", "ui-angular", "dist");
4905
- const defaultUiDir = existsSync2(join9(bundledUiDir, "index.html")) ? bundledUiDir : monorepoUiDir;
5734
+ const moduleDir = dirname4(fileURLToPath(import.meta.url));
5735
+ const bundledUiDir = join11(moduleDir, "ui");
5736
+ const monorepoUiDir = join11(moduleDir, "..", "..", "..", "ui-angular", "dist");
5737
+ const defaultUiDir = existsSync3(join11(bundledUiDir, "index.html")) ? bundledUiDir : monorepoUiDir;
4906
5738
  const defaultHost = "127.0.0.1";
4907
- const uiDir = configuredUiDir ? isAbsolute6(configuredUiDir) ? configuredUiDir : join9(process.cwd(), configuredUiDir) : defaultUiDir;
5739
+ const uiDir = configuredUiDir ? isAbsolute6(configuredUiDir) ? configuredUiDir : join11(process.cwd(), configuredUiDir) : defaultUiDir;
4908
5740
  return {
4909
5741
  host: env.MORSE_HOST ?? defaultHost,
4910
5742
  port: parsePositiveInt(env.MORSE_PORT, 4399),
@@ -4920,6 +5752,7 @@ function loadConfig(env = process.env) {
4920
5752
  projects: parseList(env.MORSE_PROJECTS),
4921
5753
  uploadDir: env.MORSE_UPLOAD_DIR?.trim() || DEFAULT_UPLOAD_DIR,
4922
5754
  dataDir: resolveDataDir(env),
5755
+ terminalIdleMs: parsePositiveInt(env.MORSE_TERMINAL_IDLE_MS, 30 * 6e4),
4923
5756
  updateCheck: !isOff(env.MORSE_UPDATE_CHECK),
4924
5757
  instance: env.MORSE_INSTANCE?.trim() || void 0
4925
5758
  };
@@ -4927,7 +5760,7 @@ function loadConfig(env = process.env) {
4927
5760
  function readFrontendIdentity(uiDir) {
4928
5761
  try {
4929
5762
  return frontendIdentity(
4930
- parseFrontendManifest(readFileSync2(join9(uiDir, FRONTEND_MANIFEST_FILE), "utf8"))
5763
+ parseFrontendManifest(readFileSync4(join11(uiDir, FRONTEND_MANIFEST_FILE), "utf8"))
4931
5764
  );
4932
5765
  } catch {
4933
5766
  return void 0;
@@ -4935,7 +5768,7 @@ function readFrontendIdentity(uiDir) {
4935
5768
  }
4936
5769
  function resolveDataDir(env) {
4937
5770
  const explicit = env.MORSE_HOME?.trim();
4938
- return explicit && explicit.length > 0 ? explicit : join9(homedir4(), ".morse");
5771
+ return explicit && explicit.length > 0 ? explicit : join11(homedir5(), ".morse");
4939
5772
  }
4940
5773
  function parsePositiveInt(value, fallback) {
4941
5774
  if (!value) {
@@ -4962,11 +5795,11 @@ function resolveWorkspacePath(env) {
4962
5795
  const start = process.cwd();
4963
5796
  let directory = start;
4964
5797
  for (; ; ) {
4965
- const manifest = join9(directory, "package.json");
4966
- if (existsSync2(manifest) && declaresWorkspaces(manifest)) {
5798
+ const manifest = join11(directory, "package.json");
5799
+ if (existsSync3(manifest) && declaresWorkspaces(manifest)) {
4967
5800
  return directory;
4968
5801
  }
4969
- const parent = dirname2(directory);
5802
+ const parent = dirname4(directory);
4970
5803
  if (parent === directory) {
4971
5804
  return start;
4972
5805
  }
@@ -4975,7 +5808,7 @@ function resolveWorkspacePath(env) {
4975
5808
  }
4976
5809
  function declaresWorkspaces(manifestPath) {
4977
5810
  try {
4978
- const manifest = JSON.parse(readFileSync2(manifestPath, "utf8"));
5811
+ const manifest = JSON.parse(readFileSync4(manifestPath, "utf8"));
4979
5812
  return manifest.workspaces !== void 0;
4980
5813
  } catch {
4981
5814
  return false;