@supanadit/morse-web 0.2.1 → 0.3.1

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/LICENSE CHANGED
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2026 supanadit
3
+ Copyright (c) 2026 Supan Adit Pratama
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
package/dist/server.mjs CHANGED
@@ -74,7 +74,8 @@ function hasKnownType(value, allowed) {
74
74
  }
75
75
 
76
76
  // ../protocol/dist/version.js
77
- var PROTOCOL_VERSION = 15;
77
+ var PROTOCOL_VERSION = 17;
78
+ var FRONTEND_MANIFEST_FILE = "webview.manifest.json";
78
79
  var DEFAULT_WS_PATH = "/ws";
79
80
 
80
81
  // ../protocol/dist/frontend-manifest.js
@@ -96,6 +97,9 @@ function parseFrontendManifest(raw) {
96
97
  return void 0;
97
98
  }
98
99
  }
100
+ function frontendIdentity(manifest) {
101
+ return manifest ? { name: manifest.name, version: manifest.version } : void 0;
102
+ }
99
103
 
100
104
  // ../server/src/app.module.ts
101
105
  import { Module } from "@nestjs/common";
@@ -121,8 +125,16 @@ var MorseError = class extends Error {
121
125
  }
122
126
  };
123
127
  var AgentUnavailableError = class extends MorseError {
128
+ /**
129
+ * The next step, in a form a frontend can render (see `SessionViewState
130
+ * .agentFailure`). The message stays human-readable on its own; this is the
131
+ * machine-readable half, and only the layer that knows pi's package name can
132
+ * fill it in.
133
+ */
134
+ remedy;
124
135
  constructor(message, options) {
125
136
  super(message, "agent-unavailable", options);
137
+ this.remedy = options?.remedy;
126
138
  }
127
139
  };
128
140
  var AgentProtocolError = class extends MorseError {
@@ -1580,6 +1592,7 @@ ${mentions}` : mentions;
1580
1592
  import { accessSync, constants } from "node:fs";
1581
1593
  import { homedir } from "node:os";
1582
1594
  import { delimiter, isAbsolute, join } from "node:path";
1595
+ var PI_INSTALL_COMMAND = "npm install -g @earendil-works/pi-coding-agent";
1583
1596
  function buildRpcArgs(options) {
1584
1597
  const args = ["--mode", "rpc"];
1585
1598
  if (options.noSession) {
@@ -1611,10 +1624,10 @@ function resolvePi(options = {}) {
1611
1624
  }
1612
1625
  throw new AgentUnavailableError([
1613
1626
  "The pi coding agent was not found.",
1614
- "Install it (`npm install -g @earendil-works/pi-coding-agent`) or point Morse at it:",
1627
+ `Install it (\`${PI_INSTALL_COMMAND}\`) or point Morse at it:`,
1615
1628
  '- VS Code: setting "morse.pi.path"',
1616
1629
  "- NestJS host: env MORSE_PI_PATH (or MORSE_PI_ENTRY for a bundled rpc-entry.js)"
1617
- ].join("\n"));
1630
+ ].join("\n"), { remedy: { install: PI_INSTALL_COMMAND } });
1618
1631
  }
1619
1632
  function findOnPath(binary, env = process.env) {
1620
1633
  const pathValue = env.PATH ?? env.Path ?? "";
@@ -1706,27 +1719,71 @@ var PiRpcAgentFactory = class {
1706
1719
  };
1707
1720
 
1708
1721
  // ../adapter-pi-rpc/dist/pi-rpc-session-catalog.js
1709
- import { readdir, readFile, stat, unlink } from "node:fs/promises";
1710
- import { basename, join as join2, resolve, sep } from "node:path";
1722
+ import { open, readdir, readFile, stat, unlink } from "node:fs/promises";
1723
+ import { join as join2, resolve, sep } from "node:path";
1724
+ var TYPE_PREFIX = '{"type":"';
1725
+ function typeAt(raw, start, end) {
1726
+ if (!raw.startsWith(TYPE_PREFIX, start)) {
1727
+ return void 0;
1728
+ }
1729
+ const close = raw.indexOf('"', start + TYPE_PREFIX.length);
1730
+ return close === -1 || close > end ? void 0 : raw.slice(start + TYPE_PREFIX.length, close);
1731
+ }
1711
1732
  var PiRpcSessionCatalog = class {
1712
1733
  directory;
1713
1734
  maxSessions;
1714
1735
  removeFile;
1736
+ readSummary;
1737
+ /**
1738
+ * Summaries survive between calls, keyed by path and validated by size + mtime.
1739
+ * The list is asked for far more often than sessions change — every reconnect,
1740
+ * every new session, every rename — and re-reading 50 transcripts each time was
1741
+ * the single most expensive thing the host did.
1742
+ */
1743
+ cache = /* @__PURE__ */ new Map();
1715
1744
  constructor(options = {}) {
1716
1745
  this.directory = resolveSessionDir(options.sessionDir, options.env);
1717
1746
  this.maxSessions = options.maxSessions ?? 50;
1718
1747
  this.removeFile = options.removeFile ?? unlink;
1748
+ this.readSummary = options.readSummary ?? readSessionSummary;
1719
1749
  }
1720
1750
  get sessionDirectory() {
1721
1751
  return this.directory;
1722
1752
  }
1753
+ /**
1754
+ * A list already being built is handed to every caller instead of being started
1755
+ * again: two frontends connecting at the same moment, or a refresh landing while
1756
+ * a push is in flight, used to mean two full scans of the whole catalog.
1757
+ */
1758
+ inFlight;
1723
1759
  async list() {
1760
+ this.inFlight ??= this.buildList().finally(() => {
1761
+ this.inFlight = void 0;
1762
+ });
1763
+ return this.inFlight;
1764
+ }
1765
+ async buildList() {
1724
1766
  const files = await this.collectSessionFiles();
1725
1767
  const summaries = [];
1768
+ const seen = /* @__PURE__ */ new Set();
1726
1769
  for (const file of files.slice(0, this.maxSessions)) {
1727
- const summary = await readSessionSummary(file.path, file.updatedAt);
1728
- if (summary) {
1729
- summaries.push(summary);
1770
+ seen.add(file.path);
1771
+ const cached = this.cache.get(file.path);
1772
+ if (cached !== void 0 && cached.size === file.size && cached.updatedAt === file.updatedAt) {
1773
+ summaries.push(cached.summary);
1774
+ continue;
1775
+ }
1776
+ const scan = await this.readSummary(file.path, file.updatedAt, cached);
1777
+ if (scan === void 0) {
1778
+ this.cache.delete(file.path);
1779
+ continue;
1780
+ }
1781
+ this.cache.set(file.path, { size: file.size, updatedAt: file.updatedAt, ...scan });
1782
+ summaries.push(scan.summary);
1783
+ }
1784
+ for (const path of [...this.cache.keys()]) {
1785
+ if (!seen.has(path)) {
1786
+ this.cache.delete(path);
1730
1787
  }
1731
1788
  }
1732
1789
  return summaries;
@@ -1759,61 +1816,108 @@ var PiRpcSessionCatalog = class {
1759
1816
  }
1760
1817
  const path = join2(bucketPath, entry.name);
1761
1818
  const info = await stat(path).catch(() => void 0);
1762
- files.push({ path, updatedAt: info?.mtimeMs ?? 0 });
1819
+ files.push({ path, size: info?.size ?? 0, updatedAt: info?.mtimeMs ?? 0 });
1763
1820
  }
1764
1821
  }
1765
1822
  files.sort((left, right) => right.updatedAt - left.updatedAt);
1766
1823
  return files;
1767
1824
  }
1768
1825
  };
1769
- async function readSessionSummary(path, updatedAt) {
1770
- const raw = await readFile(path, "utf8").catch(() => void 0);
1826
+ async function readSessionSummary(path, updatedAt, previous) {
1827
+ let resumable = previous !== void 0 && previous.scanned > 0 && updatedAt >= previous.updatedAt;
1828
+ if (resumable && previous !== void 0) {
1829
+ const info = await stat(path).catch(() => void 0);
1830
+ resumable = info !== void 0 && info.size > previous.scanned;
1831
+ }
1832
+ const from = resumable && previous !== void 0 ? previous.scanned : 0;
1833
+ const raw = resumable ? await readFrom(path, from).catch(() => void 0) : await readFile(path, "utf8").catch(() => void 0);
1771
1834
  if (raw === void 0) {
1772
1835
  return void 0;
1773
1836
  }
1774
- let id = basename(path, ".jsonl");
1775
- let cwd = "";
1776
- let name;
1777
- let firstUserText;
1778
- let messageCount = 0;
1779
- for (const line of raw.split("\n")) {
1780
- if (!line.startsWith("{")) {
1781
- continue;
1782
- }
1783
- let entry;
1784
- try {
1785
- entry = JSON.parse(line);
1786
- } catch {
1787
- continue;
1788
- }
1789
- const type = entry["type"];
1790
- if (type === "session" && cwd.length === 0) {
1791
- id = asString(entry["id"]) ?? id;
1792
- cwd = asString(entry["cwd"]) ?? "";
1793
- continue;
1794
- }
1795
- if (type === "message") {
1796
- messageCount += 1;
1797
- if (!firstUserText) {
1798
- const message = asRecord(entry["message"]);
1799
- if (message?.["role"] === "user") {
1800
- firstUserText = textOf(message["content"]);
1837
+ let cwd = resumable && previous !== void 0 ? previous.summary.cwd : "";
1838
+ let name = resumable && previous !== void 0 ? previous.name : void 0;
1839
+ let firstUserText = resumable && previous !== void 0 ? previous.firstUserText : void 0;
1840
+ let messageCount = resumable && previous !== void 0 ? previous.summary.messageCount : 0;
1841
+ let scanned = from;
1842
+ let consumed = 0;
1843
+ const parseAt = (start, end) => parseLine(raw.slice(start, end));
1844
+ for (let start = 0; start < raw.length; ) {
1845
+ const newline = raw.indexOf("\n", start);
1846
+ const end = newline === -1 ? raw.length : newline;
1847
+ const complete = newline !== -1;
1848
+ let type;
1849
+ if (raw.charCodeAt(start) === 123) {
1850
+ type = complete ? typeAt(raw, start, end) ?? asString(parseAt(start, end)?.["type"]) : asString(parseAt(start, end)?.["type"]);
1851
+ if (type === "message") {
1852
+ messageCount += 1;
1853
+ if (firstUserText === void 0) {
1854
+ const message = asRecord(parseAt(start, end)?.["message"]);
1855
+ if (message?.["role"] === "user") {
1856
+ firstUserText = textOf(message["content"]);
1857
+ }
1858
+ }
1859
+ } else if (type === "session") {
1860
+ if (cwd.length === 0) {
1861
+ cwd = asString(parseAt(start, end)?.["cwd"]) ?? "";
1801
1862
  }
1863
+ } else if ((type === "session_info" || type === "session_name") && !name) {
1864
+ name = asString(parseAt(start, end)?.["name"]);
1802
1865
  }
1803
- continue;
1804
1866
  }
1805
- if ((type === "session_info" || type === "session_name") && !name) {
1806
- name = asString(entry["name"]);
1867
+ if (!complete) {
1868
+ if (type !== void 0) {
1869
+ consumed = raw.length;
1870
+ }
1871
+ break;
1807
1872
  }
1873
+ consumed = newline + 1;
1874
+ start = newline + 1;
1875
+ }
1876
+ if (consumed > 0) {
1877
+ scanned = from + Buffer.byteLength(raw.slice(0, consumed));
1808
1878
  }
1809
1879
  return {
1810
- id: path,
1811
- title: name ?? titleFromText(firstUserText) ?? "Untitled session",
1812
- cwd,
1813
- updatedAt,
1814
- messageCount
1880
+ summary: {
1881
+ id: path,
1882
+ title: name ?? titleFromText(firstUserText) ?? "Untitled session",
1883
+ cwd,
1884
+ updatedAt,
1885
+ messageCount
1886
+ },
1887
+ scanned,
1888
+ name,
1889
+ firstUserText
1815
1890
  };
1816
1891
  }
1892
+ async function readFrom(path, from) {
1893
+ const handle = await open(path, "r");
1894
+ try {
1895
+ const info = await handle.stat();
1896
+ if (info.size <= from) {
1897
+ return "";
1898
+ }
1899
+ const length = info.size - from;
1900
+ const buffer = Buffer.alloc(length);
1901
+ let filled = 0;
1902
+ while (filled < length) {
1903
+ const { bytesRead } = await handle.read(buffer, filled, length - filled, from + filled);
1904
+ if (bytesRead === 0) {
1905
+ break;
1906
+ }
1907
+ filled += bytesRead;
1908
+ }
1909
+ return buffer.subarray(0, filled).toString("utf8");
1910
+ } finally {
1911
+ await handle.close();
1912
+ }
1913
+ }
1914
+ function parseLine(line) {
1915
+ try {
1916
+ return JSON.parse(line);
1917
+ } catch {
1918
+ return void 0;
1919
+ }
1920
+ }
1817
1921
  function textOf(content) {
1818
1922
  const direct = asString(content);
1819
1923
  if (direct !== void 0) {
@@ -2204,7 +2308,9 @@ var SessionTranscriptStore = class {
2204
2308
  this.emit({
2205
2309
  sessionKey,
2206
2310
  message,
2207
- items: projector?.snapshot() ?? []
2311
+ get items() {
2312
+ return projector?.snapshot() ?? [];
2313
+ }
2208
2314
  });
2209
2315
  }
2210
2316
  });
@@ -2232,6 +2338,7 @@ function emptySessionViewState(workspace, meta) {
2232
2338
  agentReady: meta.agentReady,
2233
2339
  agentStarting: meta.agentStarting,
2234
2340
  agentError: meta.agentError,
2341
+ agentFailure: meta.agentFailure,
2235
2342
  hasOlderHistory: meta.hasOlderHistory,
2236
2343
  loadingOlderHistory: meta.loadingOlderHistory
2237
2344
  };
@@ -2270,6 +2377,7 @@ function toSessionViewState(state, meta) {
2270
2377
  agentReady: meta.agentReady,
2271
2378
  agentStarting: meta.agentStarting,
2272
2379
  agentError: meta.agentError,
2380
+ agentFailure: meta.agentFailure,
2273
2381
  hasOlderHistory: meta.hasOlderHistory,
2274
2382
  loadingOlderHistory: meta.loadingOlderHistory
2275
2383
  };
@@ -2362,6 +2470,8 @@ var HostSessionController = class {
2362
2470
  agentReady = false;
2363
2471
  agentStarting = false;
2364
2472
  agentError;
2473
+ /** The machine-readable half of `agentError` (see `AgentFailure`). */
2474
+ agentFailure;
2365
2475
  busy = false;
2366
2476
  disposed = false;
2367
2477
  hasOpenedOnce = false;
@@ -2537,7 +2647,16 @@ var HostSessionController = class {
2537
2647
  this.loadingOlderHistory = false;
2538
2648
  this.agentReady = false;
2539
2649
  this.agentStarting = false;
2650
+ this.clearAgentFailure();
2651
+ }
2652
+ /**
2653
+ * Forgets the agent failure, message and all. Called wherever the agent comes
2654
+ * back (a ready event, an adopted session), so a stale screen never outlives
2655
+ * the problem it described.
2656
+ */
2657
+ clearAgentFailure() {
2540
2658
  this.agentError = void 0;
2659
+ this.agentFailure = void 0;
2541
2660
  }
2542
2661
  /** Fills the draft pickers from the registry's session-less probe. */
2543
2662
  async warmDraft() {
@@ -2597,7 +2716,7 @@ var HostSessionController = class {
2597
2716
  this.hasOlderHistory = false;
2598
2717
  this.loadingOlderHistory = false;
2599
2718
  this.agentReady = false;
2600
- this.agentError = void 0;
2719
+ this.clearAgentFailure();
2601
2720
  const next = this.options.services.registry.hotKeys().at(-1);
2602
2721
  if (next) {
2603
2722
  await this.runSessionChange(() => this.options.services.registry.activate(next));
@@ -2622,6 +2741,7 @@ var HostSessionController = class {
2622
2741
  } catch (error) {
2623
2742
  this.agentReady = false;
2624
2743
  this.agentError = describeError(error);
2744
+ this.agentFailure = describeAgentFailure(error, this.options.agentHint);
2625
2745
  this.options.logger.warn("Could not start the Morse agent", error);
2626
2746
  this.fail(`Could not start the Morse agent: ${this.agentError}`, this.options.agentHint);
2627
2747
  } finally {
@@ -2645,7 +2765,7 @@ var HostSessionController = class {
2645
2765
  this.hasOlderHistory = false;
2646
2766
  this.loadingOlderHistory = false;
2647
2767
  this.agentReady = true;
2648
- this.agentError = void 0;
2768
+ this.clearAgentFailure();
2649
2769
  this.emitTranscript(opened.key);
2650
2770
  this.emitState();
2651
2771
  void this.publishSessions();
@@ -2932,7 +3052,7 @@ var HostSessionController = class {
2932
3052
  switch (event.type) {
2933
3053
  case "agent/ready":
2934
3054
  this.agentReady = true;
2935
- this.agentError = void 0;
3055
+ this.clearAgentFailure();
2936
3056
  this.emitState();
2937
3057
  return;
2938
3058
  case "agent/state":
@@ -2948,6 +3068,7 @@ var HostSessionController = class {
2948
3068
  case "agent/fatal":
2949
3069
  this.agentReady = false;
2950
3070
  this.agentError = event.message;
3071
+ this.agentFailure = hintOnly(this.options.agentHint);
2951
3072
  this.options.transcripts.error(sessionKey, event.message, event.detail);
2952
3073
  this.emitState();
2953
3074
  return;
@@ -3098,6 +3219,7 @@ var HostSessionController = class {
3098
3219
  agentReady: this.agentReady,
3099
3220
  agentStarting: this.agentStarting,
3100
3221
  agentError: this.agentError,
3222
+ agentFailure: this.agentFailure,
3101
3223
  busy: this.busy,
3102
3224
  hasOlderHistory: this.hasOlderHistory,
3103
3225
  loadingOlderHistory: this.loadingOlderHistory
@@ -3209,6 +3331,25 @@ function describeError(error) {
3209
3331
  }
3210
3332
  return typeof error === "string" ? error : JSON.stringify(error);
3211
3333
  }
3334
+ function describeAgentFailure(error, hint) {
3335
+ const code = agentErrorCode(error);
3336
+ const install = error instanceof AgentUnavailableError ? error.remedy?.install : void 0;
3337
+ const failure = {
3338
+ ...code ? { code } : {},
3339
+ ...install ? { install } : {},
3340
+ ...hint ? { hint } : {}
3341
+ };
3342
+ return Object.keys(failure).length > 0 ? failure : void 0;
3343
+ }
3344
+ function agentErrorCode(error) {
3345
+ if (!(error instanceof MorseError)) {
3346
+ return void 0;
3347
+ }
3348
+ return error.code === "agent-unavailable" || error.code === "agent-protocol" ? error.code : void 0;
3349
+ }
3350
+ function hintOnly(hint) {
3351
+ return hint ? { hint } : void 0;
3352
+ }
3212
3353
 
3213
3354
  // ../server/src/internal/http/health.controller.ts
3214
3355
  import { Controller, Get } from "@nestjs/common";
@@ -3350,7 +3491,7 @@ import { Inject as Inject3, Injectable as Injectable2 } from "@nestjs/common";
3350
3491
  // ../server/src/internal/uploads/upload-store.ts
3351
3492
  import { existsSync } from "node:fs";
3352
3493
  import { mkdir, writeFile } from "node:fs/promises";
3353
- import { basename as basename2, isAbsolute as isAbsolute3, join as join4, relative, sep as sep3 } from "node:path";
3494
+ import { basename, isAbsolute as isAbsolute3, join as join4, relative, sep as sep3 } from "node:path";
3354
3495
  var MAX_UPLOAD_BYTES = 10 * 1024 * 1024;
3355
3496
  var DEFAULT_UPLOAD_DIR = ".morse/uploads";
3356
3497
  var UploadError = class extends Error {
@@ -3374,11 +3515,11 @@ async function saveUpload(cwd, request, uploadDir = DEFAULT_UPLOAD_DIR) {
3374
3515
  await mkdir(directory, { recursive: true });
3375
3516
  const target = uniquePath(directory, name);
3376
3517
  await writeFile(target, bytes);
3377
- return { path: mentionPath(cwd, target), name: basename2(target), bytes: bytes.length };
3518
+ return { path: mentionPath(cwd, target), name: basename(target), bytes: bytes.length };
3378
3519
  }
3379
3520
  function safeFileName(raw) {
3380
3521
  const value = typeof raw === "string" ? raw : "";
3381
- const base = basename2(value.replace(/\\/g, "/")).trim();
3522
+ const base = basename(value.replace(/\\/g, "/")).trim();
3382
3523
  const cleaned = base.replace(/[\u0000-\u001f<>:"|?*]/g, "_").replace(/^\.+/, "").trim();
3383
3524
  if (cleaned.length === 0) {
3384
3525
  throw new UploadError("That upload had no usable file name.");
@@ -3409,7 +3550,7 @@ function formatMb(bytes) {
3409
3550
  // ../server/src/internal/workspace/directory-browser.ts
3410
3551
  import { readdir as readdir2, stat as stat2 } from "node:fs/promises";
3411
3552
  import { homedir as homedir2 } from "node:os";
3412
- import { basename as basename3, dirname, isAbsolute as isAbsolute4, join as join5, resolve as resolve3 } from "node:path";
3553
+ import { basename as basename2, dirname, isAbsolute as isAbsolute4, join as join5, resolve as resolve3 } from "node:path";
3413
3554
  var SKIPPED_DIRECTORIES = /* @__PURE__ */ new Set([
3414
3555
  "node_modules",
3415
3556
  "dist",
@@ -3466,7 +3607,7 @@ function shortcutRoots(options) {
3466
3607
  continue;
3467
3608
  }
3468
3609
  seen.add(path);
3469
- roots.push({ name: basename3(path) || path, path });
3610
+ roots.push({ name: basename2(path) || path, path });
3470
3611
  }
3471
3612
  return roots;
3472
3613
  }
@@ -3636,7 +3777,11 @@ var MorseSessionFactory = class {
3636
3777
  // pi's fork re-parents the conversation, so a past prompt can be edited.
3637
3778
  editMessage: true,
3638
3779
  // The same fork can branch a new session and hand the prompt back instead.
3639
- forkMessage: true
3780
+ forkMessage: true,
3781
+ // A page served by this host may ask the registry for the latest release, so
3782
+ // the sidebar can say when a newer Morse is out. A host without it stays
3783
+ // quiet; this one is on unless `MORSE_UPDATE_CHECK=0` says otherwise.
3784
+ updateCheck: this.config.updateCheck
3640
3785
  };
3641
3786
  }
3642
3787
  createFor(emit) {
@@ -3652,6 +3797,8 @@ var MorseSessionFactory = class {
3652
3797
  transcripts: this.transcripts,
3653
3798
  policy: this.policy,
3654
3799
  scope: { kind: "global" },
3800
+ // The bundle this host serves, so the panel can name its own version.
3801
+ ...this.config.frontend ? { frontend: this.config.frontend } : {},
3655
3802
  // The registry outlives this connection on purpose.
3656
3803
  ownsRegistry: false,
3657
3804
  // A page load must not create a session; the first prompt opens one.
@@ -3774,7 +3921,7 @@ MorseGateway = __decorateClass([
3774
3921
  ], MorseGateway);
3775
3922
 
3776
3923
  // ../server/src/app/config.ts
3777
- import { basename as basename4, dirname as dirname2, isAbsolute as isAbsolute5, join as join7 } from "node:path";
3924
+ import { basename as basename3, dirname as dirname2, isAbsolute as isAbsolute5, join as join7 } from "node:path";
3778
3925
  import { existsSync as existsSync2, readFileSync } from "node:fs";
3779
3926
  import { fileURLToPath } from "node:url";
3780
3927
  function loadConfig(env = process.env) {
@@ -3785,11 +3932,13 @@ function loadConfig(env = process.env) {
3785
3932
  const monorepoUiDir = join7(moduleDir, "..", "..", "..", "ui-angular", "dist");
3786
3933
  const defaultUiDir = existsSync2(join7(bundledUiDir, "index.html")) ? bundledUiDir : monorepoUiDir;
3787
3934
  const defaultHost = "127.0.0.1";
3935
+ const uiDir = configuredUiDir ? isAbsolute5(configuredUiDir) ? configuredUiDir : join7(process.cwd(), configuredUiDir) : defaultUiDir;
3788
3936
  return {
3789
3937
  host: env.MORSE_HOST ?? defaultHost,
3790
3938
  port: parsePositiveInt(env.MORSE_PORT, 4399),
3791
- uiDir: configuredUiDir ? isAbsolute5(configuredUiDir) ? configuredUiDir : join7(process.cwd(), configuredUiDir) : defaultUiDir,
3792
- workspace: { cwd: workspacePath, name: basename4(workspacePath) || workspacePath },
3939
+ uiDir,
3940
+ frontend: readFrontendIdentity(uiDir),
3941
+ workspace: { cwd: workspacePath, name: basename3(workspacePath) || workspacePath },
3793
3942
  piPath: env.MORSE_PI_PATH,
3794
3943
  nodeEntryPath: env.MORSE_PI_ENTRY,
3795
3944
  sessionDir: env.MORSE_SESSION_DIR,
@@ -3797,9 +3946,19 @@ function loadConfig(env = process.env) {
3797
3946
  requestTimeoutMs: parsePositiveInt(env.MORSE_REQUEST_TIMEOUT_MS, 3e4),
3798
3947
  hotSessions: parsePositiveInt(env.MORSE_HOT_SESSIONS, 4),
3799
3948
  projects: parseList(env.MORSE_PROJECTS),
3800
- uploadDir: env.MORSE_UPLOAD_DIR?.trim() || DEFAULT_UPLOAD_DIR
3949
+ uploadDir: env.MORSE_UPLOAD_DIR?.trim() || DEFAULT_UPLOAD_DIR,
3950
+ updateCheck: !isOff(env.MORSE_UPDATE_CHECK)
3801
3951
  };
3802
3952
  }
3953
+ function readFrontendIdentity(uiDir) {
3954
+ try {
3955
+ return frontendIdentity(
3956
+ parseFrontendManifest(readFileSync(join7(uiDir, FRONTEND_MANIFEST_FILE), "utf8"))
3957
+ );
3958
+ } catch {
3959
+ return void 0;
3960
+ }
3961
+ }
3803
3962
  function parsePositiveInt(value, fallback) {
3804
3963
  if (!value) {
3805
3964
  return fallback;
@@ -3807,6 +3966,10 @@ function parsePositiveInt(value, fallback) {
3807
3966
  const parsed = Number.parseInt(value, 10);
3808
3967
  return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback;
3809
3968
  }
3969
+ function isOff(value) {
3970
+ const normalized = value?.trim().toLowerCase();
3971
+ return normalized === "0" || normalized === "false" || normalized === "off";
3972
+ }
3810
3973
  function parseList(value) {
3811
3974
  if (!value) {
3812
3975
  return [];