@supanadit/morse-web 0.11.0 → 0.13.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
@@ -79,7 +79,7 @@ function hasKnownType(value, allowed) {
79
79
  }
80
80
 
81
81
  // ../protocol/dist/version.js
82
- var PROTOCOL_VERSION = 20;
82
+ var PROTOCOL_VERSION = 21;
83
83
  var FRONTEND_MANIFEST_FILE = "webview.manifest.json";
84
84
  var DEFAULT_WS_PATH = "/ws";
85
85
 
@@ -1710,11 +1710,11 @@ function promptDescription(raw) {
1710
1710
  }
1711
1711
  body = raw.slice(frontmatter[0].length);
1712
1712
  }
1713
- const firstLine2 = body.split("\n").find((line) => line.trim().length > 0);
1714
- if (firstLine2 === void 0) {
1713
+ const firstLine = body.split("\n").find((line) => line.trim().length > 0);
1714
+ if (firstLine === void 0) {
1715
1715
  return void 0;
1716
1716
  }
1717
- const trimmed = firstLine2.trim();
1717
+ const trimmed = firstLine.trim();
1718
1718
  return trimmed.length > 60 ? `${trimmed.slice(0, 60)}...` : trimmed;
1719
1719
  }
1720
1720
  function unquote(value) {
@@ -2145,6 +2145,17 @@ function createPiRpcAdapter(config, logger) {
2145
2145
  };
2146
2146
  }
2147
2147
 
2148
+ // ../host-runtime/dist/terminal-buffer.js
2149
+ var TERMINAL_BUFFER_CHARS = 256 * 1024;
2150
+ function trimTail(text, maxChars = TERMINAL_BUFFER_CHARS) {
2151
+ if (text.length <= maxChars) {
2152
+ return text;
2153
+ }
2154
+ const tail = text.slice(-maxChars);
2155
+ const newline = tail.indexOf("\n");
2156
+ return newline === -1 ? tail : tail.slice(newline + 1);
2157
+ }
2158
+
2148
2159
  // ../host-runtime/dist/transcript-projector.js
2149
2160
  var TranscriptProjector = class {
2150
2161
  options;
@@ -2671,8 +2682,15 @@ var HostSessionController = class {
2671
2682
  loadingOlderHistory = false;
2672
2683
  /** Discriminates history item ids across pages (see `historyItems`). */
2673
2684
  historyPageSeq = 0;
2674
- /** The shells this client has open, keyed by the terminal id it named. */
2685
+ /** This client's live attachments to shells, keyed by the terminal id it named. */
2675
2686
  terminals = /* @__PURE__ */ new Map();
2687
+ /**
2688
+ * The ids this connection has already opened. It separates a *first* open — a
2689
+ * page reattaching to a shell the host still runs — from a repeat open (the
2690
+ * Restart button), which must replace the shell and clear its scrollback
2691
+ * instead of replaying it into the emulator that already shows it.
2692
+ */
2693
+ openedTerminals = /* @__PURE__ */ new Set();
2676
2694
  /**
2677
2695
  * Bumped whenever a terminal id is opened or closed. A shell's late `exit`
2678
2696
  * (a replaced one, a killed one) compares its generation and stays silent, so
@@ -2721,11 +2739,12 @@ var HostSessionController = class {
2721
2739
  return;
2722
2740
  }
2723
2741
  this.disposed = true;
2724
- for (const process2 of this.terminals.values()) {
2725
- process2.kill();
2742
+ for (const session of this.terminals.values()) {
2743
+ session.detach();
2726
2744
  }
2727
2745
  this.terminals.clear();
2728
2746
  this.terminalGenerations.clear();
2747
+ this.openedTerminals.clear();
2729
2748
  this.detachRegistry?.();
2730
2749
  this.detachRegistry = void 0;
2731
2750
  this.detachTranscripts?.();
@@ -2830,23 +2849,38 @@ var HostSessionController = class {
2830
2849
  }
2831
2850
  }
2832
2851
  /**
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.
2852
+ * Attaches this client to a shell for the bottom panel. The directory is the
2853
+ * viewing session's (or the draft's), never an arbitrary path the client
2854
+ * names — the backend still re-checks it against the host policy before
2855
+ * spawning.
2856
+ *
2857
+ * A *first* open on this connection may land on a shell that is already running
2858
+ * (a reloaded page reattaching); a repeat open (the Restart button) replaces
2859
+ * it, scrollback and all, so the fresh shell does not replay the old one.
2836
2860
  */
2837
2861
  async openTerminal(payload) {
2838
- if (this.options.terminal === void 0) {
2862
+ const backend = this.options.terminal;
2863
+ if (backend === void 0) {
2839
2864
  this.emitTerminalExit(payload.terminalId, void 0, "This host has no terminal.");
2840
2865
  return;
2841
2866
  }
2842
- this.closeTerminal(payload.terminalId);
2843
- const generation = this.terminalGenerations.get(payload.terminalId) ?? 0;
2867
+ const previous = this.terminals.get(payload.terminalId);
2868
+ if (previous !== void 0) {
2869
+ previous.detach();
2870
+ this.terminals.delete(payload.terminalId);
2871
+ backend.close(payload.terminalId);
2872
+ } else if (this.openedTerminals.has(payload.terminalId)) {
2873
+ backend.close(payload.terminalId);
2874
+ }
2875
+ this.openedTerminals.add(payload.terminalId);
2876
+ const generation = (this.terminalGenerations.get(payload.terminalId) ?? 0) + 1;
2877
+ this.terminalGenerations.set(payload.terminalId, generation);
2844
2878
  const cwd = payload.cwd ?? this.currentState().workspace.cwd;
2845
2879
  const cols = clampGeometry(payload.cols, 80);
2846
2880
  const rows = clampGeometry(payload.rows, 24);
2847
- let process2;
2881
+ let session;
2848
2882
  try {
2849
- process2 = await this.options.terminal.open({ cwd, cols, rows }, {
2883
+ session = await backend.attach(payload.terminalId, { cwd, cols, rows }, {
2850
2884
  output: (data) => {
2851
2885
  if (this.terminalGenerations.get(payload.terminalId) === generation) {
2852
2886
  this.emitTerminalOutput(payload.terminalId, data);
@@ -2866,19 +2900,25 @@ var HostSessionController = class {
2866
2900
  return;
2867
2901
  }
2868
2902
  if (!this.disposed && this.terminalGenerations.get(payload.terminalId) === generation) {
2869
- this.terminals.set(payload.terminalId, process2);
2903
+ this.terminals.set(payload.terminalId, session);
2904
+ if (session.replay.length > 0) {
2905
+ this.emitTerminalOutput(payload.terminalId, session.replay);
2906
+ }
2870
2907
  } else {
2871
- process2.kill();
2908
+ session.detach();
2872
2909
  }
2873
2910
  }
2911
+ /**
2912
+ * Ends a shell for good: the reader closed the pane. Bumps the generation first,
2913
+ * so a late `exit` for the shell being replaced is not reported as news.
2914
+ */
2874
2915
  closeTerminal(terminalId) {
2875
2916
  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
- }
2917
+ const session = this.terminals.get(terminalId);
2880
2918
  this.terminals.delete(terminalId);
2881
- process2.kill();
2919
+ this.openedTerminals.delete(terminalId);
2920
+ session?.detach();
2921
+ this.options.terminal?.close(terminalId);
2882
2922
  }
2883
2923
  emitTerminalOutput(terminalId, data) {
2884
2924
  if (data.length > 0 && !this.disposed) {
@@ -3769,7 +3809,50 @@ var ServerProjectPolicy = class {
3769
3809
  // ../server/src/internal/terminal/terminal.service.ts
3770
3810
  import { spawn as spawnPty } from "node-pty";
3771
3811
  import { Inject as Inject2, Injectable } from "@nestjs/common";
3812
+
3813
+ // ../server/src/internal/terminal/terminal-log.ts
3814
+ import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
3815
+ import { join as join5 } from "node:path";
3816
+ var LOG_DIR = "terminals";
3817
+ var LOG_MAX_CHARS = TERMINAL_BUFFER_CHARS * 4;
3818
+ function readTerminalLog(dataDir, terminalId) {
3819
+ try {
3820
+ const raw = readFileSync(logPath(dataDir, terminalId), "utf8");
3821
+ return trimTail(raw, LOG_MAX_CHARS);
3822
+ } catch {
3823
+ return "";
3824
+ }
3825
+ }
3826
+ function writeTerminalLog(dataDir, terminalId, text) {
3827
+ const trimmed = trimTail(text, LOG_MAX_CHARS);
3828
+ const target = logPath(dataDir, terminalId);
3829
+ const temp = `${target}.${process.pid}.tmp`;
3830
+ try {
3831
+ mkdirSync(join5(dataDir, LOG_DIR), { recursive: true });
3832
+ writeFileSync(temp, trimmed);
3833
+ renameSync(temp, target);
3834
+ } catch {
3835
+ try {
3836
+ writeFileSync(target, trimmed);
3837
+ } catch {
3838
+ rmSync(temp, { force: true });
3839
+ }
3840
+ }
3841
+ }
3842
+ function removeTerminalLog(dataDir, terminalId) {
3843
+ rmSync(logPath(dataDir, terminalId), { force: true });
3844
+ }
3845
+ function safeTerminalName(terminalId) {
3846
+ const safe = terminalId.replace(/[^A-Za-z0-9._-]/g, "_").slice(0, 100);
3847
+ return safe.length > 0 ? safe : "terminal";
3848
+ }
3849
+ function logPath(dataDir, terminalId) {
3850
+ return join5(dataDir, LOG_DIR, `${safeTerminalName(terminalId)}.log`);
3851
+ }
3852
+
3853
+ // ../server/src/internal/terminal/terminal.service.ts
3772
3854
  var SIGKILL_AFTER_MS = 1500;
3855
+ var FLUSH_DEBOUNCE_MS = 2e3;
3773
3856
  function defaultShell() {
3774
3857
  const configured = process.env.SHELL?.trim();
3775
3858
  if (configured !== void 0 && configured.length > 0) {
@@ -3778,17 +3861,95 @@ function defaultShell() {
3778
3861
  return process.platform === "win32" ? "powershell.exe" : "/bin/bash";
3779
3862
  }
3780
3863
  var ServerTerminalBackend = class {
3781
- constructor(policy, logger) {
3864
+ constructor(policy, logger, config) {
3782
3865
  this.policy = policy;
3783
3866
  this.logger = logger;
3867
+ this.config = config;
3868
+ this.entries = /* @__PURE__ */ new Map();
3784
3869
  }
3785
- open(options, sink) {
3870
+ attach(terminalId, options, sink) {
3871
+ const entry = this.entryFor(terminalId);
3872
+ if (entry.pty === void 0) {
3873
+ this.spawn(terminalId, entry, options);
3874
+ } else {
3875
+ this.resize(entry, options.cols, options.rows);
3876
+ }
3877
+ entry.sinks.add(sink);
3878
+ this.cancelIdle(entry);
3879
+ return {
3880
+ replay: entry.output,
3881
+ write: (data) => entry.pty?.write(data),
3882
+ resize: (cols, rows) => this.resize(entry, cols, rows),
3883
+ detach: () => this.detach(terminalId, entry, sink)
3884
+ };
3885
+ }
3886
+ /** Ends a shell for good, drops its scrollback, and tells any other viewer. */
3887
+ close(terminalId) {
3888
+ const entry = this.entries.get(terminalId);
3889
+ this.entries.delete(terminalId);
3890
+ if (entry !== void 0) {
3891
+ this.stopFlush(entry);
3892
+ this.cancelIdle(entry);
3893
+ entry.generation += 1;
3894
+ const sinks = [...entry.sinks];
3895
+ entry.sinks.clear();
3896
+ this.killPty(entry.pty);
3897
+ entry.pty = void 0;
3898
+ for (const sink of sinks) {
3899
+ sink.exit();
3900
+ }
3901
+ }
3902
+ removeTerminalLog(this.config.dataDir, terminalId);
3903
+ }
3904
+ onModuleDestroy() {
3905
+ for (const [terminalId, entry] of this.entries) {
3906
+ entry.generation += 1;
3907
+ this.persist(terminalId, entry);
3908
+ this.killPty(entry.pty);
3909
+ entry.pty = void 0;
3910
+ this.stopFlush(entry);
3911
+ this.cancelIdle(entry);
3912
+ }
3913
+ this.entries.clear();
3914
+ }
3915
+ /** SIGTERM, then SIGKILL for a shell that ignores it (a hung process group). */
3916
+ killPty(pty) {
3917
+ if (pty === void 0) {
3918
+ return;
3919
+ }
3920
+ try {
3921
+ pty.kill();
3922
+ } catch {
3923
+ return;
3924
+ }
3925
+ const timer = setTimeout(() => {
3926
+ try {
3927
+ pty.kill("SIGKILL");
3928
+ } catch {
3929
+ }
3930
+ }, SIGKILL_AFTER_MS);
3931
+ timer.unref?.();
3932
+ }
3933
+ entryFor(terminalId) {
3934
+ const existing = this.entries.get(terminalId);
3935
+ if (existing !== void 0) {
3936
+ return existing;
3937
+ }
3938
+ const entry = {
3939
+ sinks: /* @__PURE__ */ new Set(),
3940
+ output: readTerminalLog(this.config.dataDir, terminalId),
3941
+ dirty: false,
3942
+ generation: 0
3943
+ };
3944
+ this.entries.set(terminalId, entry);
3945
+ return entry;
3946
+ }
3947
+ spawn(terminalId, entry, options) {
3786
3948
  if (!this.policy.canOpen(options.cwd)) {
3787
- throw new UnsupportedByHostError(
3788
- `Morse is not allowed to run a terminal in ${options.cwd}.`
3789
- );
3949
+ throw new UnsupportedByHostError(`Morse is not allowed to run a terminal in ${options.cwd}.`);
3790
3950
  }
3791
3951
  const shell = defaultShell();
3952
+ const generation = ++entry.generation;
3792
3953
  const pty = spawnPty(shell, [], {
3793
3954
  name: "xterm-256color",
3794
3955
  cols: options.cols,
@@ -3796,41 +3957,105 @@ var ServerTerminalBackend = class {
3796
3957
  cwd: options.cwd,
3797
3958
  env: { ...process.env, TERM: "xterm-256color", MORSE_TERMINAL: "1" }
3798
3959
  });
3960
+ entry.pty = pty;
3799
3961
  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));
3962
+ pty.onData((chunk) => {
3963
+ if (entry.generation !== generation) {
3964
+ return;
3965
+ }
3966
+ entry.output += chunk;
3967
+ if (entry.output.length > TERMINAL_BUFFER_CHARS * 1.5) {
3968
+ entry.output = trimTail(entry.output, TERMINAL_BUFFER_CHARS);
3969
+ }
3970
+ entry.dirty = true;
3971
+ this.scheduleFlush(terminalId, entry);
3972
+ for (const sink of entry.sinks) {
3973
+ sink.output(chunk);
3974
+ }
3975
+ });
3802
3976
  pty.onExit(({ exitCode }) => {
3803
- exited = true;
3804
- data.dispose();
3805
- sink.exit(exitCode);
3977
+ if (entry.generation !== generation) {
3978
+ return;
3979
+ }
3980
+ entry.pty = void 0;
3981
+ this.persist(terminalId, entry);
3982
+ for (const sink of [...entry.sinks]) {
3983
+ sink.exit(exitCode);
3984
+ }
3806
3985
  });
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?.();
3986
+ }
3987
+ resize(entry, cols, rows) {
3988
+ try {
3989
+ entry.pty?.resize(cols, rows);
3990
+ } catch {
3991
+ }
3992
+ }
3993
+ detach(terminalId, entry, sink) {
3994
+ entry.sinks.delete(sink);
3995
+ if (entry.sinks.size > 0) {
3996
+ return;
3997
+ }
3998
+ this.persist(terminalId, entry);
3999
+ this.scheduleIdle(terminalId, entry);
4000
+ }
4001
+ /**
4002
+ * Reclaims a shell nobody has reattached to. The log stays (the output should
4003
+ * still be there next time); only the process goes.
4004
+ */
4005
+ scheduleIdle(terminalId, entry) {
4006
+ if (this.config.terminalIdleMs <= 0) {
4007
+ return;
4008
+ }
4009
+ this.cancelIdle(entry);
4010
+ entry.idleTimer = setTimeout(() => {
4011
+ entry.idleTimer = void 0;
4012
+ if (entry.sinks.size > 0) {
4013
+ return;
3826
4014
  }
3827
- };
4015
+ entry.generation += 1;
4016
+ this.killPty(entry.pty);
4017
+ entry.pty = void 0;
4018
+ this.persist(terminalId, entry);
4019
+ this.entries.delete(terminalId);
4020
+ }, this.config.terminalIdleMs);
4021
+ entry.idleTimer.unref?.();
4022
+ }
4023
+ cancelIdle(entry) {
4024
+ if (entry.idleTimer !== void 0) {
4025
+ clearTimeout(entry.idleTimer);
4026
+ entry.idleTimer = void 0;
4027
+ }
4028
+ }
4029
+ scheduleFlush(terminalId, entry) {
4030
+ if (entry.flushTimer !== void 0) {
4031
+ return;
4032
+ }
4033
+ entry.flushTimer = setTimeout(() => {
4034
+ entry.flushTimer = void 0;
4035
+ this.persist(terminalId, entry);
4036
+ }, FLUSH_DEBOUNCE_MS);
4037
+ entry.flushTimer.unref?.();
4038
+ }
4039
+ stopFlush(entry) {
4040
+ if (entry.flushTimer !== void 0) {
4041
+ clearTimeout(entry.flushTimer);
4042
+ entry.flushTimer = void 0;
4043
+ }
4044
+ }
4045
+ persist(terminalId, entry) {
4046
+ this.stopFlush(entry);
4047
+ if (!entry.dirty) {
4048
+ return;
4049
+ }
4050
+ entry.dirty = false;
4051
+ writeTerminalLog(this.config.dataDir, terminalId, entry.output);
3828
4052
  }
3829
4053
  };
3830
4054
  ServerTerminalBackend = __decorateClass([
3831
4055
  Injectable(),
3832
4056
  __decorateParam(0, Inject2(MORSE_PROJECT_POLICY)),
3833
- __decorateParam(1, Inject2(MORSE_LOGGER))
4057
+ __decorateParam(1, Inject2(MORSE_LOGGER)),
4058
+ __decorateParam(2, Inject2(MORSE_CONFIG))
3834
4059
  ], ServerTerminalBackend);
3835
4060
 
3836
4061
  // ../server/src/internal/registry.ts
@@ -3872,7 +4097,7 @@ import { Inject as Inject4, Injectable as Injectable3 } from "@nestjs/common";
3872
4097
  // ../server/src/internal/uploads/upload-store.ts
3873
4098
  import { existsSync } from "node:fs";
3874
4099
  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";
4100
+ import { basename as basename2, isAbsolute as isAbsolute3, join as join6, relative, sep as sep3 } from "node:path";
3876
4101
  var MAX_UPLOAD_BYTES = 10 * 1024 * 1024;
3877
4102
  var DEFAULT_UPLOAD_DIR = ".morse/uploads";
3878
4103
  var UploadError = class extends Error {
@@ -3892,7 +4117,7 @@ async function saveUpload(cwd, request, uploadDir = DEFAULT_UPLOAD_DIR) {
3892
4117
  `That file is ${formatMb(bytes.length)} MB; the limit is ${formatMb(MAX_UPLOAD_BYTES)} MB.`
3893
4118
  );
3894
4119
  }
3895
- const directory = isAbsolute3(uploadDir) ? uploadDir : join5(cwd, uploadDir);
4120
+ const directory = isAbsolute3(uploadDir) ? uploadDir : join6(cwd, uploadDir);
3896
4121
  await mkdir(directory, { recursive: true });
3897
4122
  const target = uniquePath(directory, name);
3898
4123
  await writeFile(target, bytes);
@@ -3911,10 +4136,10 @@ function uniquePath(directory, name) {
3911
4136
  const dot = name.lastIndexOf(".");
3912
4137
  const stem = dot > 0 ? name.slice(0, dot) : name;
3913
4138
  const extension = dot > 0 ? name.slice(dot) : "";
3914
- let candidate = join5(directory, name);
4139
+ let candidate = join6(directory, name);
3915
4140
  let suffix = 1;
3916
4141
  while (existsSync(candidate)) {
3917
- candidate = join5(directory, `${stem}-${suffix}${extension}`);
4142
+ candidate = join6(directory, `${stem}-${suffix}${extension}`);
3918
4143
  suffix += 1;
3919
4144
  }
3920
4145
  return candidate;
@@ -3931,7 +4156,7 @@ function formatMb(bytes) {
3931
4156
  // ../server/src/internal/workspace/directory-browser.ts
3932
4157
  import { readdir as readdir3, stat as stat3 } from "node:fs/promises";
3933
4158
  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";
4159
+ import { basename as basename3, dirname, isAbsolute as isAbsolute4, join as join7, resolve as resolve4 } from "node:path";
3935
4160
  var SKIPPED_DIRECTORIES = /* @__PURE__ */ new Set([
3936
4161
  "node_modules",
3937
4162
  "dist",
@@ -3955,7 +4180,7 @@ async function browseDirectory(path, options) {
3955
4180
  throw new Error(`A folder path must be absolute: ${requested}`);
3956
4181
  }
3957
4182
  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));
4183
+ const directories = entries.filter((entry) => entry.isDirectory()).filter((entry) => !shouldSkip(entry.name)).map((entry) => ({ name: entry.name, path: join7(start, entry.name) })).sort((left, right) => left.name.localeCompare(right.name));
3959
4184
  const parent = dirname(start);
3960
4185
  return {
3961
4186
  path: start,
@@ -3970,7 +4195,7 @@ function shouldSkip(name) {
3970
4195
  }
3971
4196
  async function isGitRepo(path) {
3972
4197
  try {
3973
- await stat3(join6(path, ".git"));
4198
+ await stat3(join7(path, ".git"));
3974
4199
  return true;
3975
4200
  } catch {
3976
4201
  return false;
@@ -4253,7 +4478,10 @@ async function commitGit(cwd, message) {
4253
4478
  return { ok: false, message: "This project is not a git repository." };
4254
4479
  }
4255
4480
  const result = await gitExec(["commit", "-m", text], cwd, 6e4);
4256
- return { ok: result.ok, message: result.ok ? void 0 : firstLine(result.output) };
4481
+ if (result.ok) {
4482
+ return { ok: true };
4483
+ }
4484
+ return { ok: false, message: describeCommitFailure(result.output) };
4257
4485
  }
4258
4486
  async function checkoutGit(cwd, branch, create) {
4259
4487
  const name = branch.trim();
@@ -4269,13 +4497,21 @@ async function checkoutGit(cwd, branch, create) {
4269
4497
  cwd,
4270
4498
  6e4
4271
4499
  );
4272
- return { ok: result.ok, message: result.ok ? void 0 : firstLine(result.output) };
4500
+ return { ok: result.ok, message: result.ok ? void 0 : gitRefusal(result.output) };
4273
4501
  }
4274
4502
  function isBranchName(name) {
4275
4503
  return name.length > 0 && !name.startsWith("-") && !name.includes("..") && !name.includes("@{") && !name.endsWith("/") && !/\s/.test(name) && !/[~^:?*\[\\]/.test(name);
4276
4504
  }
4277
- function firstLine(output) {
4278
- const line = output.split("\n").find((candidate) => candidate.trim().length > 0)?.trim();
4505
+ function describeCommitFailure(output) {
4506
+ if (/nothing to commit|no changes added to commit|nothing added to commit/i.test(output)) {
4507
+ return "Nothing is staged to commit. Stage a change first.";
4508
+ }
4509
+ return gitRefusal(output);
4510
+ }
4511
+ function gitRefusal(output) {
4512
+ const line = output.split("\n").map((candidate) => candidate.trim()).find(
4513
+ (candidate) => candidate.length > 0 && !candidate.startsWith("Command failed:") && !/^On branch /.test(candidate) && !/^Your branch /.test(candidate)
4514
+ );
4279
4515
  if (line === void 0) {
4280
4516
  return void 0;
4281
4517
  }
@@ -4359,9 +4595,12 @@ function gitExec(args, cwd, timeout = 5e3) {
4359
4595
  }
4360
4596
  },
4361
4597
  (error, stdout, stderr) => {
4362
- resolve6(
4363
- error === null ? { ok: true, output: stdout.trim() } : { ok: false, output: (stderr || error.message || "").trim() }
4364
- );
4598
+ if (error === null) {
4599
+ resolve6({ ok: true, output: stdout.trim() });
4600
+ return;
4601
+ }
4602
+ const output = [stderr.trim(), stdout.trim()].filter((part) => part.length > 0).join("\n");
4603
+ resolve6({ ok: false, output: output.length > 0 ? output : error.message.trim() });
4365
4604
  }
4366
4605
  );
4367
4606
  });
@@ -4370,7 +4609,7 @@ function gitExec(args, cwd, timeout = 5e3) {
4370
4609
  // ../server/src/internal/workspace/workspace-index.ts
4371
4610
  import { execFile as execFile2 } from "node:child_process";
4372
4611
  import { readdir as readdir4 } from "node:fs/promises";
4373
- import { join as join7, relative as relative4 } from "node:path";
4612
+ import { join as join8, relative as relative4 } from "node:path";
4374
4613
  var SKIPPED_DIRECTORIES2 = /* @__PURE__ */ new Set([
4375
4614
  "node_modules",
4376
4615
  "dist",
@@ -4481,7 +4720,7 @@ async function walk(root, directory, files) {
4481
4720
  if (files.length >= MAX_INDEXED_FILES) {
4482
4721
  return;
4483
4722
  }
4484
- const full = join7(directory, entry.name);
4723
+ const full = join8(directory, entry.name);
4485
4724
  if (entry.isDirectory()) {
4486
4725
  if (SKIPPED_DIRECTORIES2.has(entry.name) || entry.name.startsWith(".")) {
4487
4726
  continue;
@@ -4498,6 +4737,81 @@ function toPosix3(path) {
4498
4737
  return path.split("\\").join("/");
4499
4738
  }
4500
4739
 
4740
+ // ../server/src/internal/workspace/workbench-store.ts
4741
+ import { mkdirSync as mkdirSync2, readFileSync as readFileSync2, renameSync as renameSync2, rmSync as rmSync2, writeFileSync as writeFileSync2 } from "node:fs";
4742
+ import { join as join9 } from "node:path";
4743
+ var LAYOUT_FILE = "workbench.json";
4744
+ var DRAFTS_FILE = "drafts.json";
4745
+ var LAYOUT_MAX_BYTES = 256 * 1024;
4746
+ var DRAFTS_MAX_BYTES = 32 * 1024 * 1024;
4747
+ function readWorkbench(dataDir) {
4748
+ return readSlot(join9(dataDir, LAYOUT_FILE), LAYOUT_MAX_BYTES);
4749
+ }
4750
+ function saveWorkbench(dataDir, snapshot) {
4751
+ return saveSlot(dataDir, LAYOUT_FILE, snapshot, LAYOUT_MAX_BYTES);
4752
+ }
4753
+ function readDrafts(dataDir) {
4754
+ return readSlot(join9(dataDir, DRAFTS_FILE), DRAFTS_MAX_BYTES);
4755
+ }
4756
+ function saveDrafts(dataDir, snapshot) {
4757
+ return saveSlot(dataDir, DRAFTS_FILE, snapshot, DRAFTS_MAX_BYTES);
4758
+ }
4759
+ function readSlot(path, maxBytes) {
4760
+ let raw;
4761
+ try {
4762
+ raw = readFileSync2(path, "utf8");
4763
+ } catch {
4764
+ return void 0;
4765
+ }
4766
+ if (raw.length > maxBytes) {
4767
+ return void 0;
4768
+ }
4769
+ let parsed;
4770
+ try {
4771
+ parsed = JSON.parse(raw);
4772
+ } catch {
4773
+ return void 0;
4774
+ }
4775
+ return asSnapshot(parsed);
4776
+ }
4777
+ function saveSlot(dataDir, file, snapshot, maxBytes) {
4778
+ const valid = asSnapshot(snapshot);
4779
+ if (valid === void 0) {
4780
+ throw new UnsupportedByHostError(`Saving ${file} needs a { version, data } snapshot.`);
4781
+ }
4782
+ const serialized = JSON.stringify(valid);
4783
+ if (Buffer.byteLength(serialized, "utf8") > maxBytes) {
4784
+ throw new UnsupportedByHostError(`That ${file} snapshot is too large to save.`);
4785
+ }
4786
+ const target = join9(dataDir, file);
4787
+ const temp = `${target}.${process.pid}.tmp`;
4788
+ try {
4789
+ mkdirSync2(dataDir, { recursive: true });
4790
+ writeFileSync2(temp, serialized);
4791
+ renameSync2(temp, target);
4792
+ return true;
4793
+ } catch {
4794
+ try {
4795
+ writeFileSync2(target, serialized);
4796
+ return true;
4797
+ } catch {
4798
+ rmSync2(temp, { force: true });
4799
+ return false;
4800
+ }
4801
+ }
4802
+ }
4803
+ function asSnapshot(value) {
4804
+ if (typeof value !== "object" || value === null) {
4805
+ return void 0;
4806
+ }
4807
+ const candidate = value;
4808
+ const version = candidate["version"];
4809
+ if (typeof version !== "number" || !Number.isInteger(version) || version < 1) {
4810
+ return void 0;
4811
+ }
4812
+ return { version, data: candidate["data"] };
4813
+ }
4814
+
4501
4815
  // ../server/src/internal/ws/session-factory.service.ts
4502
4816
  var MorseSessionFactory = class {
4503
4817
  constructor(registry, transcripts, policy, logger, config, terminal) {
@@ -4532,6 +4846,10 @@ var MorseSessionFactory = class {
4532
4846
  // the bottom panel's terminal is this host's. It runs in the viewing
4533
4847
  // session's directory and is gated by `ProjectPolicy`, like a session.
4534
4848
  terminal: true,
4849
+ // The browser host restores the tabs, panel, terminals and half-written
4850
+ // prompts the reader left open, kept under `<MORSE_HOME>`. VS Code has its
4851
+ // own tab restoration and leaves this off.
4852
+ workbench: true,
4535
4853
  // A browser cannot hand a dragged file's path to pi, so the host takes the
4536
4854
  // bytes and writes them next to the session; the frontend then `@mentions`
4537
4855
  // the path it gets back.
@@ -4595,13 +4913,25 @@ var MorseSessionFactory = class {
4595
4913
  });
4596
4914
  return { ...listing, canOpen: this.policy.canOpen(listing.path) };
4597
4915
  }
4916
+ case "readWorkbench": {
4917
+ return readWorkbench(this.config.dataDir) ?? null;
4918
+ }
4919
+ case "saveWorkbench": {
4920
+ return { ok: saveWorkbench(this.config.dataDir, args) };
4921
+ }
4922
+ case "readDrafts": {
4923
+ return readDrafts(this.config.dataDir) ?? null;
4924
+ }
4925
+ case "saveDrafts": {
4926
+ return { ok: saveDrafts(this.config.dataDir, args) };
4927
+ }
4598
4928
  case "listFiles": {
4599
4929
  const cwd = this.requireWritableCwd(context);
4600
4930
  const files = await workspaceFiles(cwd, this.logger, { fresh: args?.fresh === true });
4601
4931
  return { files };
4602
4932
  }
4603
4933
  case "readFile": {
4604
- const cwd = this.requireWritableCwd(context);
4934
+ const cwd = this.commandCwd(context, args);
4605
4935
  const path = typeof args?.path === "string" ? args.path : "";
4606
4936
  if (path.length === 0) {
4607
4937
  throw new UnsupportedByHostError('readFile needs a "path" argument.');
@@ -4625,7 +4955,7 @@ var MorseSessionFactory = class {
4625
4955
  return readGitStatus(cwd);
4626
4956
  }
4627
4957
  case "gitDiff": {
4628
- const cwd = this.requireWritableCwd(context);
4958
+ const cwd = this.commandCwd(context, args);
4629
4959
  const path = typeof args?.path === "string" ? args.path : "";
4630
4960
  if (path.length === 0) {
4631
4961
  throw new UnsupportedByHostError('gitDiff needs a "path" argument.');
@@ -4646,7 +4976,7 @@ var MorseSessionFactory = class {
4646
4976
  return readCommitFiles(cwd, hash);
4647
4977
  }
4648
4978
  case "gitCommitDiff": {
4649
- const cwd = this.requireWritableCwd(context);
4979
+ const cwd = this.commandCwd(context, args);
4650
4980
  const hash = typeof args?.hash === "string" ? args.hash : "";
4651
4981
  const path = typeof args?.path === "string" ? args.path : "";
4652
4982
  if (path.length === 0) {
@@ -4699,6 +5029,22 @@ var MorseSessionFactory = class {
4699
5029
  }
4700
5030
  return cwd;
4701
5031
  }
5032
+ /**
5033
+ * The directory a file command works in. A restored preview tab carries the
5034
+ * project it was read from (`args.cwd`), because the host may still be on
5035
+ * another session when that tab is loaded; the policy checks it exactly like a
5036
+ * terminal's directory. Without one, the viewing session's directory is used.
5037
+ */
5038
+ commandCwd(context, args) {
5039
+ const requested = args?.cwd;
5040
+ if (typeof requested === "string" && requested.length > 0) {
5041
+ if (!this.policy.canOpen(requested)) {
5042
+ throw new UnsupportedByHostError(`Morse is not allowed to work in ${requested}.`);
5043
+ }
5044
+ return requested;
5045
+ }
5046
+ return this.requireWritableCwd(context);
5047
+ }
4702
5048
  /** The cwd of the session this connection is showing. */
4703
5049
  activeCwd() {
4704
5050
  const key = this.registry.activeKeyOf();
@@ -4771,18 +5117,19 @@ MorseGateway = __decorateClass([
4771
5117
  ], MorseGateway);
4772
5118
 
4773
5119
  // ../server/src/app/config.ts
4774
- import { basename as basename4, dirname as dirname2, isAbsolute as isAbsolute6, join as join8 } from "node:path";
4775
- import { existsSync as existsSync2, readFileSync } from "node:fs";
5120
+ import { basename as basename4, dirname as dirname2, isAbsolute as isAbsolute6, join as join10 } from "node:path";
5121
+ import { existsSync as existsSync2, readFileSync as readFileSync3 } from "node:fs";
5122
+ import { homedir as homedir4 } from "node:os";
4776
5123
  import { fileURLToPath } from "node:url";
4777
5124
  function loadConfig(env = process.env) {
4778
5125
  const workspacePath = resolveWorkspacePath(env);
4779
5126
  const configuredUiDir = env.MORSE_UI_DIR;
4780
5127
  const moduleDir = dirname2(fileURLToPath(import.meta.url));
4781
- const bundledUiDir = join8(moduleDir, "ui");
4782
- const monorepoUiDir = join8(moduleDir, "..", "..", "..", "ui-angular", "dist");
4783
- const defaultUiDir = existsSync2(join8(bundledUiDir, "index.html")) ? bundledUiDir : monorepoUiDir;
5128
+ const bundledUiDir = join10(moduleDir, "ui");
5129
+ const monorepoUiDir = join10(moduleDir, "..", "..", "..", "ui-angular", "dist");
5130
+ const defaultUiDir = existsSync2(join10(bundledUiDir, "index.html")) ? bundledUiDir : monorepoUiDir;
4784
5131
  const defaultHost = "127.0.0.1";
4785
- const uiDir = configuredUiDir ? isAbsolute6(configuredUiDir) ? configuredUiDir : join8(process.cwd(), configuredUiDir) : defaultUiDir;
5132
+ const uiDir = configuredUiDir ? isAbsolute6(configuredUiDir) ? configuredUiDir : join10(process.cwd(), configuredUiDir) : defaultUiDir;
4786
5133
  return {
4787
5134
  host: env.MORSE_HOST ?? defaultHost,
4788
5135
  port: parsePositiveInt(env.MORSE_PORT, 4399),
@@ -4797,6 +5144,8 @@ function loadConfig(env = process.env) {
4797
5144
  hotSessions: parsePositiveInt(env.MORSE_HOT_SESSIONS, 4),
4798
5145
  projects: parseList(env.MORSE_PROJECTS),
4799
5146
  uploadDir: env.MORSE_UPLOAD_DIR?.trim() || DEFAULT_UPLOAD_DIR,
5147
+ dataDir: resolveDataDir(env),
5148
+ terminalIdleMs: parsePositiveInt(env.MORSE_TERMINAL_IDLE_MS, 30 * 6e4),
4800
5149
  updateCheck: !isOff(env.MORSE_UPDATE_CHECK),
4801
5150
  instance: env.MORSE_INSTANCE?.trim() || void 0
4802
5151
  };
@@ -4804,12 +5153,16 @@ function loadConfig(env = process.env) {
4804
5153
  function readFrontendIdentity(uiDir) {
4805
5154
  try {
4806
5155
  return frontendIdentity(
4807
- parseFrontendManifest(readFileSync(join8(uiDir, FRONTEND_MANIFEST_FILE), "utf8"))
5156
+ parseFrontendManifest(readFileSync3(join10(uiDir, FRONTEND_MANIFEST_FILE), "utf8"))
4808
5157
  );
4809
5158
  } catch {
4810
5159
  return void 0;
4811
5160
  }
4812
5161
  }
5162
+ function resolveDataDir(env) {
5163
+ const explicit = env.MORSE_HOME?.trim();
5164
+ return explicit && explicit.length > 0 ? explicit : join10(homedir4(), ".morse");
5165
+ }
4813
5166
  function parsePositiveInt(value, fallback) {
4814
5167
  if (!value) {
4815
5168
  return fallback;
@@ -4835,7 +5188,7 @@ function resolveWorkspacePath(env) {
4835
5188
  const start = process.cwd();
4836
5189
  let directory = start;
4837
5190
  for (; ; ) {
4838
- const manifest = join8(directory, "package.json");
5191
+ const manifest = join10(directory, "package.json");
4839
5192
  if (existsSync2(manifest) && declaresWorkspaces(manifest)) {
4840
5193
  return directory;
4841
5194
  }
@@ -4848,7 +5201,7 @@ function resolveWorkspacePath(env) {
4848
5201
  }
4849
5202
  function declaresWorkspaces(manifestPath) {
4850
5203
  try {
4851
- const manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
5204
+ const manifest = JSON.parse(readFileSync3(manifestPath, "utf8"));
4852
5205
  return manifest.workspaces !== void 0;
4853
5206
  } catch {
4854
5207
  return false;