@supanadit/morse-web 0.10.0 → 0.12.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
@@ -38,7 +38,11 @@ var CLIENT_MESSAGE_TYPES = [
38
38
  "model/set",
39
39
  "thinking/set",
40
40
  "interaction/respond",
41
- "host/command"
41
+ "host/command",
42
+ "terminal/open",
43
+ "terminal/input",
44
+ "terminal/resize",
45
+ "terminal/close"
42
46
  ];
43
47
  function encodeWireMessage(message) {
44
48
  return JSON.stringify(message);
@@ -75,7 +79,7 @@ function hasKnownType(value, allowed) {
75
79
  }
76
80
 
77
81
  // ../protocol/dist/version.js
78
- var PROTOCOL_VERSION = 19;
82
+ var PROTOCOL_VERSION = 20;
79
83
  var FRONTEND_MANIFEST_FILE = "webview.manifest.json";
80
84
  var DEFAULT_WS_PATH = "/ws";
81
85
 
@@ -1706,11 +1710,11 @@ function promptDescription(raw) {
1706
1710
  }
1707
1711
  body = raw.slice(frontmatter[0].length);
1708
1712
  }
1709
- const firstLine2 = body.split("\n").find((line) => line.trim().length > 0);
1710
- if (firstLine2 === void 0) {
1713
+ const firstLine = body.split("\n").find((line) => line.trim().length > 0);
1714
+ if (firstLine === void 0) {
1711
1715
  return void 0;
1712
1716
  }
1713
- const trimmed = firstLine2.trim();
1717
+ const trimmed = firstLine.trim();
1714
1718
  return trimmed.length > 60 ? `${trimmed.slice(0, 60)}...` : trimmed;
1715
1719
  }
1716
1720
  function unquote(value) {
@@ -2667,6 +2671,14 @@ var HostSessionController = class {
2667
2671
  loadingOlderHistory = false;
2668
2672
  /** Discriminates history item ids across pages (see `historyItems`). */
2669
2673
  historyPageSeq = 0;
2674
+ /** The shells this client has open, keyed by the terminal id it named. */
2675
+ terminals = /* @__PURE__ */ new Map();
2676
+ /**
2677
+ * Bumped whenever a terminal id is opened or closed. A shell's late `exit`
2678
+ * (a replaced one, a killed one) compares its generation and stays silent, so
2679
+ * it cannot delete or report over the shell now holding the id.
2680
+ */
2681
+ terminalGenerations = /* @__PURE__ */ new Map();
2670
2682
  constructor(options) {
2671
2683
  this.options = options;
2672
2684
  }
@@ -2709,6 +2721,11 @@ var HostSessionController = class {
2709
2721
  return;
2710
2722
  }
2711
2723
  this.disposed = true;
2724
+ for (const process2 of this.terminals.values()) {
2725
+ process2.kill();
2726
+ }
2727
+ this.terminals.clear();
2728
+ this.terminalGenerations.clear();
2712
2729
  this.detachRegistry?.();
2713
2730
  this.detachRegistry = void 0;
2714
2731
  this.detachTranscripts?.();
@@ -2796,10 +2813,83 @@ var HostSessionController = class {
2796
2813
  case "host/command":
2797
2814
  await this.runHostCommand(message.payload.command, message.payload.args, message.payload.requestId);
2798
2815
  return;
2816
+ case "terminal/open":
2817
+ await this.openTerminal(message.payload);
2818
+ return;
2819
+ case "terminal/input":
2820
+ this.terminals.get(message.payload.terminalId)?.write(message.payload.data);
2821
+ return;
2822
+ case "terminal/resize":
2823
+ this.terminals.get(message.payload.terminalId)?.resize(message.payload.cols, message.payload.rows);
2824
+ return;
2825
+ case "terminal/close":
2826
+ this.closeTerminal(message.payload.terminalId);
2827
+ return;
2799
2828
  default:
2800
2829
  return;
2801
2830
  }
2802
2831
  }
2832
+ /**
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.
2836
+ */
2837
+ async openTerminal(payload) {
2838
+ if (this.options.terminal === void 0) {
2839
+ this.emitTerminalExit(payload.terminalId, void 0, "This host has no terminal.");
2840
+ return;
2841
+ }
2842
+ this.closeTerminal(payload.terminalId);
2843
+ const generation = this.terminalGenerations.get(payload.terminalId) ?? 0;
2844
+ const cwd = payload.cwd ?? this.currentState().workspace.cwd;
2845
+ const cols = clampGeometry(payload.cols, 80);
2846
+ const rows = clampGeometry(payload.rows, 24);
2847
+ let process2;
2848
+ try {
2849
+ process2 = await this.options.terminal.open({ cwd, cols, rows }, {
2850
+ output: (data) => {
2851
+ if (this.terminalGenerations.get(payload.terminalId) === generation) {
2852
+ this.emitTerminalOutput(payload.terminalId, data);
2853
+ }
2854
+ },
2855
+ exit: (code, error) => {
2856
+ if (this.terminalGenerations.get(payload.terminalId) !== generation) {
2857
+ return;
2858
+ }
2859
+ this.terminals.delete(payload.terminalId);
2860
+ this.emitTerminalExit(payload.terminalId, code, error);
2861
+ }
2862
+ });
2863
+ } catch (error) {
2864
+ this.options.logger.warn("Failed to open a terminal", error);
2865
+ this.emitTerminalExit(payload.terminalId, void 0, describeTerminalError(error));
2866
+ return;
2867
+ }
2868
+ if (!this.disposed && this.terminalGenerations.get(payload.terminalId) === generation) {
2869
+ this.terminals.set(payload.terminalId, process2);
2870
+ } else {
2871
+ process2.kill();
2872
+ }
2873
+ }
2874
+ closeTerminal(terminalId) {
2875
+ 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
+ }
2880
+ this.terminals.delete(terminalId);
2881
+ process2.kill();
2882
+ }
2883
+ emitTerminalOutput(terminalId, data) {
2884
+ if (data.length > 0 && !this.disposed) {
2885
+ this.options.emit({ type: "terminal/output", payload: { terminalId, data } });
2886
+ }
2887
+ }
2888
+ emitTerminalExit(terminalId, code, error) {
2889
+ if (!this.disposed) {
2890
+ this.options.emit({ type: "terminal/exit", payload: { terminalId, code, error } });
2891
+ }
2892
+ }
2803
2893
  async openDefaultSession() {
2804
2894
  const existingKey = this.options.services.registry.activeKeyOf();
2805
2895
  if (existingKey) {
@@ -3227,15 +3317,15 @@ var HostSessionController = class {
3227
3317
  this.answerHostCommand(requestId, false, void 0, `This host does not support "${command}".`);
3228
3318
  return;
3229
3319
  }
3230
- const result = await this.guard(() => Promise.resolve(handler(command, args, { cwd: this.currentState().workspace.cwd })));
3231
- if (!requestId) {
3232
- return;
3233
- }
3234
- if (result.ok) {
3235
- this.answerHostCommand(requestId, true, result.value);
3320
+ let value;
3321
+ try {
3322
+ value = await handler(command, args, { cwd: this.currentState().workspace.cwd });
3323
+ } catch (error) {
3324
+ this.options.logger.error(`Morse command failed: ${describeError(error)}`, error);
3325
+ this.answerHostCommand(requestId, false, void 0, `"${command}" failed`);
3236
3326
  return;
3237
3327
  }
3238
- this.answerHostCommand(requestId, false, void 0, `"${command}" failed`);
3328
+ this.answerHostCommand(requestId, true, value);
3239
3329
  }
3240
3330
  /** Replies to a host command the frontend asked a value for. */
3241
3331
  answerHostCommand(requestId, ok, data, error) {
@@ -3543,6 +3633,15 @@ function describeError(error) {
3543
3633
  }
3544
3634
  return typeof error === "string" ? error : JSON.stringify(error);
3545
3635
  }
3636
+ function clampGeometry(value, fallback) {
3637
+ if (value === void 0 || !Number.isFinite(value)) {
3638
+ return fallback;
3639
+ }
3640
+ return Math.max(1, Math.min(1e3, Math.floor(value)));
3641
+ }
3642
+ function describeTerminalError(error) {
3643
+ return error instanceof MorseError ? error.message : describeError(error);
3644
+ }
3546
3645
  function describeAgentFailure(error, hint) {
3547
3646
  const code = agentErrorCode(error);
3548
3647
  const install = error instanceof AgentUnavailableError ? error.remedy?.install : void 0;
@@ -3667,8 +3766,75 @@ var ServerProjectPolicy = class {
3667
3766
  }
3668
3767
  };
3669
3768
 
3670
- // ../server/src/internal/registry.ts
3769
+ // ../server/src/internal/terminal/terminal.service.ts
3770
+ import { spawn as spawnPty } from "node-pty";
3671
3771
  import { Inject as Inject2, Injectable } from "@nestjs/common";
3772
+ var SIGKILL_AFTER_MS = 1500;
3773
+ function defaultShell() {
3774
+ const configured = process.env.SHELL?.trim();
3775
+ if (configured !== void 0 && configured.length > 0) {
3776
+ return configured;
3777
+ }
3778
+ return process.platform === "win32" ? "powershell.exe" : "/bin/bash";
3779
+ }
3780
+ var ServerTerminalBackend = class {
3781
+ constructor(policy, logger) {
3782
+ this.policy = policy;
3783
+ this.logger = logger;
3784
+ }
3785
+ open(options, sink) {
3786
+ if (!this.policy.canOpen(options.cwd)) {
3787
+ throw new UnsupportedByHostError(
3788
+ `Morse is not allowed to run a terminal in ${options.cwd}.`
3789
+ );
3790
+ }
3791
+ const shell = defaultShell();
3792
+ const pty = spawnPty(shell, [], {
3793
+ name: "xterm-256color",
3794
+ cols: options.cols,
3795
+ rows: options.rows,
3796
+ cwd: options.cwd,
3797
+ env: { ...process.env, TERM: "xterm-256color", MORSE_TERMINAL: "1" }
3798
+ });
3799
+ 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));
3802
+ pty.onExit(({ exitCode }) => {
3803
+ exited = true;
3804
+ data.dispose();
3805
+ sink.exit(exitCode);
3806
+ });
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?.();
3826
+ }
3827
+ };
3828
+ }
3829
+ };
3830
+ ServerTerminalBackend = __decorateClass([
3831
+ Injectable(),
3832
+ __decorateParam(0, Inject2(MORSE_PROJECT_POLICY)),
3833
+ __decorateParam(1, Inject2(MORSE_LOGGER))
3834
+ ], ServerTerminalBackend);
3835
+
3836
+ // ../server/src/internal/registry.ts
3837
+ import { Inject as Inject3, Injectable as Injectable2 } from "@nestjs/common";
3672
3838
  function createSessionRegistry(adapter, config, logger) {
3673
3839
  return new SessionRegistry({
3674
3840
  factory: adapter.factory,
@@ -3689,19 +3855,19 @@ var MorseRegistryLifecycle = class {
3689
3855
  }
3690
3856
  };
3691
3857
  MorseRegistryLifecycle = __decorateClass([
3692
- Injectable(),
3693
- __decorateParam(0, Inject2(MORSE_SESSION_REGISTRY)),
3694
- __decorateParam(1, Inject2(MORSE_TRANSCRIPT_STORE))
3858
+ Injectable2(),
3859
+ __decorateParam(0, Inject3(MORSE_SESSION_REGISTRY)),
3860
+ __decorateParam(1, Inject3(MORSE_TRANSCRIPT_STORE))
3695
3861
  ], MorseRegistryLifecycle);
3696
3862
 
3697
3863
  // ../server/src/internal/ws/morse.gateway.ts
3698
3864
  import {
3699
3865
  WebSocketGateway
3700
3866
  } from "@nestjs/websockets";
3701
- import { Inject as Inject4 } from "@nestjs/common";
3867
+ import { Inject as Inject5 } from "@nestjs/common";
3702
3868
 
3703
3869
  // ../server/src/internal/ws/session-factory.service.ts
3704
- import { Inject as Inject3, Injectable as Injectable2 } from "@nestjs/common";
3870
+ import { Inject as Inject4, Injectable as Injectable3 } from "@nestjs/common";
3705
3871
 
3706
3872
  // ../server/src/internal/uploads/upload-store.ts
3707
3873
  import { existsSync } from "node:fs";
@@ -4087,7 +4253,10 @@ async function commitGit(cwd, message) {
4087
4253
  return { ok: false, message: "This project is not a git repository." };
4088
4254
  }
4089
4255
  const result = await gitExec(["commit", "-m", text], cwd, 6e4);
4090
- return { ok: result.ok, message: result.ok ? void 0 : firstLine(result.output) };
4256
+ if (result.ok) {
4257
+ return { ok: true };
4258
+ }
4259
+ return { ok: false, message: describeCommitFailure(result.output) };
4091
4260
  }
4092
4261
  async function checkoutGit(cwd, branch, create) {
4093
4262
  const name = branch.trim();
@@ -4103,13 +4272,21 @@ async function checkoutGit(cwd, branch, create) {
4103
4272
  cwd,
4104
4273
  6e4
4105
4274
  );
4106
- return { ok: result.ok, message: result.ok ? void 0 : firstLine(result.output) };
4275
+ return { ok: result.ok, message: result.ok ? void 0 : gitRefusal(result.output) };
4107
4276
  }
4108
4277
  function isBranchName(name) {
4109
4278
  return name.length > 0 && !name.startsWith("-") && !name.includes("..") && !name.includes("@{") && !name.endsWith("/") && !/\s/.test(name) && !/[~^:?*\[\\]/.test(name);
4110
4279
  }
4111
- function firstLine(output) {
4112
- const line = output.split("\n").find((candidate) => candidate.trim().length > 0)?.trim();
4280
+ function describeCommitFailure(output) {
4281
+ if (/nothing to commit|no changes added to commit|nothing added to commit/i.test(output)) {
4282
+ return "Nothing is staged to commit. Stage a change first.";
4283
+ }
4284
+ return gitRefusal(output);
4285
+ }
4286
+ function gitRefusal(output) {
4287
+ const line = output.split("\n").map((candidate) => candidate.trim()).find(
4288
+ (candidate) => candidate.length > 0 && !candidate.startsWith("Command failed:") && !/^On branch /.test(candidate) && !/^Your branch /.test(candidate)
4289
+ );
4113
4290
  if (line === void 0) {
4114
4291
  return void 0;
4115
4292
  }
@@ -4193,9 +4370,12 @@ function gitExec(args, cwd, timeout = 5e3) {
4193
4370
  }
4194
4371
  },
4195
4372
  (error, stdout, stderr) => {
4196
- resolve6(
4197
- error === null ? { ok: true, output: stdout.trim() } : { ok: false, output: (stderr || error.message || "").trim() }
4198
- );
4373
+ if (error === null) {
4374
+ resolve6({ ok: true, output: stdout.trim() });
4375
+ return;
4376
+ }
4377
+ const output = [stderr.trim(), stdout.trim()].filter((part) => part.length > 0).join("\n");
4378
+ resolve6({ ok: false, output: output.length > 0 ? output : error.message.trim() });
4199
4379
  }
4200
4380
  );
4201
4381
  });
@@ -4332,14 +4512,90 @@ function toPosix3(path) {
4332
4512
  return path.split("\\").join("/");
4333
4513
  }
4334
4514
 
4515
+ // ../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";
4518
+ var LAYOUT_FILE = "workbench.json";
4519
+ var DRAFTS_FILE = "drafts.json";
4520
+ var LAYOUT_MAX_BYTES = 256 * 1024;
4521
+ var DRAFTS_MAX_BYTES = 32 * 1024 * 1024;
4522
+ function readWorkbench(dataDir) {
4523
+ return readSlot(join8(dataDir, LAYOUT_FILE), LAYOUT_MAX_BYTES);
4524
+ }
4525
+ function saveWorkbench(dataDir, snapshot) {
4526
+ return saveSlot(dataDir, LAYOUT_FILE, snapshot, LAYOUT_MAX_BYTES);
4527
+ }
4528
+ function readDrafts(dataDir) {
4529
+ return readSlot(join8(dataDir, DRAFTS_FILE), DRAFTS_MAX_BYTES);
4530
+ }
4531
+ function saveDrafts(dataDir, snapshot) {
4532
+ return saveSlot(dataDir, DRAFTS_FILE, snapshot, DRAFTS_MAX_BYTES);
4533
+ }
4534
+ function readSlot(path, maxBytes) {
4535
+ let raw;
4536
+ try {
4537
+ raw = readFileSync(path, "utf8");
4538
+ } catch {
4539
+ return void 0;
4540
+ }
4541
+ if (raw.length > maxBytes) {
4542
+ return void 0;
4543
+ }
4544
+ let parsed;
4545
+ try {
4546
+ parsed = JSON.parse(raw);
4547
+ } catch {
4548
+ return void 0;
4549
+ }
4550
+ return asSnapshot(parsed);
4551
+ }
4552
+ function saveSlot(dataDir, file, snapshot, maxBytes) {
4553
+ const valid = asSnapshot(snapshot);
4554
+ if (valid === void 0) {
4555
+ throw new UnsupportedByHostError(`Saving ${file} needs a { version, data } snapshot.`);
4556
+ }
4557
+ const serialized = JSON.stringify(valid);
4558
+ if (Buffer.byteLength(serialized, "utf8") > maxBytes) {
4559
+ throw new UnsupportedByHostError(`That ${file} snapshot is too large to save.`);
4560
+ }
4561
+ const target = join8(dataDir, file);
4562
+ const temp = `${target}.${process.pid}.tmp`;
4563
+ try {
4564
+ mkdirSync(dataDir, { recursive: true });
4565
+ writeFileSync(temp, serialized);
4566
+ renameSync(temp, target);
4567
+ return true;
4568
+ } catch {
4569
+ try {
4570
+ writeFileSync(target, serialized);
4571
+ return true;
4572
+ } catch {
4573
+ rmSync(temp, { force: true });
4574
+ return false;
4575
+ }
4576
+ }
4577
+ }
4578
+ function asSnapshot(value) {
4579
+ if (typeof value !== "object" || value === null) {
4580
+ return void 0;
4581
+ }
4582
+ const candidate = value;
4583
+ const version = candidate["version"];
4584
+ if (typeof version !== "number" || !Number.isInteger(version) || version < 1) {
4585
+ return void 0;
4586
+ }
4587
+ return { version, data: candidate["data"] };
4588
+ }
4589
+
4335
4590
  // ../server/src/internal/ws/session-factory.service.ts
4336
4591
  var MorseSessionFactory = class {
4337
- constructor(registry, transcripts, policy, logger, config) {
4592
+ constructor(registry, transcripts, policy, logger, config, terminal) {
4338
4593
  this.registry = registry;
4339
4594
  this.transcripts = transcripts;
4340
4595
  this.policy = policy;
4341
4596
  this.logger = logger;
4342
4597
  this.config = config;
4598
+ this.terminal = terminal;
4343
4599
  }
4344
4600
  capabilities() {
4345
4601
  return {
@@ -4361,6 +4617,14 @@ var MorseSessionFactory = class {
4361
4617
  // The same reasoning for history: VS Code has Source Control, so the
4362
4618
  // browser host is the one that answers `gitLog` for its own git panel.
4363
4619
  gitPanel: true,
4620
+ // VS Code already has an integrated terminal; the browser host has none, so
4621
+ // the bottom panel's terminal is this host's. It runs in the viewing
4622
+ // session's directory and is gated by `ProjectPolicy`, like a session.
4623
+ terminal: true,
4624
+ // The browser host restores the tabs, panel, terminals and half-written
4625
+ // prompts the reader left open, kept under `<MORSE_HOME>`. VS Code has its
4626
+ // own tab restoration and leaves this off.
4627
+ workbench: true,
4364
4628
  // A browser cannot hand a dragged file's path to pi, so the host takes the
4365
4629
  // bytes and writes them next to the session; the frontend then `@mentions`
4366
4630
  // the path it gets back.
@@ -4399,6 +4663,8 @@ var MorseSessionFactory = class {
4399
4663
  // A page load must not create a session; the first prompt opens one.
4400
4664
  autoOpen: false,
4401
4665
  onHostCommand: (command, args, context) => this.runHostCommand(command, args, context),
4666
+ // The bottom panel's shell: this host can spawn one on its own machine.
4667
+ terminal: this.terminal,
4402
4668
  agentHint: "Install the pi CLI and make sure it is on PATH, or start the server with MORSE_PI_PATH set."
4403
4669
  });
4404
4670
  }
@@ -4422,13 +4688,25 @@ var MorseSessionFactory = class {
4422
4688
  });
4423
4689
  return { ...listing, canOpen: this.policy.canOpen(listing.path) };
4424
4690
  }
4691
+ case "readWorkbench": {
4692
+ return readWorkbench(this.config.dataDir) ?? null;
4693
+ }
4694
+ case "saveWorkbench": {
4695
+ return { ok: saveWorkbench(this.config.dataDir, args) };
4696
+ }
4697
+ case "readDrafts": {
4698
+ return readDrafts(this.config.dataDir) ?? null;
4699
+ }
4700
+ case "saveDrafts": {
4701
+ return { ok: saveDrafts(this.config.dataDir, args) };
4702
+ }
4425
4703
  case "listFiles": {
4426
4704
  const cwd = this.requireWritableCwd(context);
4427
4705
  const files = await workspaceFiles(cwd, this.logger, { fresh: args?.fresh === true });
4428
4706
  return { files };
4429
4707
  }
4430
4708
  case "readFile": {
4431
- const cwd = this.requireWritableCwd(context);
4709
+ const cwd = this.commandCwd(context, args);
4432
4710
  const path = typeof args?.path === "string" ? args.path : "";
4433
4711
  if (path.length === 0) {
4434
4712
  throw new UnsupportedByHostError('readFile needs a "path" argument.');
@@ -4452,7 +4730,7 @@ var MorseSessionFactory = class {
4452
4730
  return readGitStatus(cwd);
4453
4731
  }
4454
4732
  case "gitDiff": {
4455
- const cwd = this.requireWritableCwd(context);
4733
+ const cwd = this.commandCwd(context, args);
4456
4734
  const path = typeof args?.path === "string" ? args.path : "";
4457
4735
  if (path.length === 0) {
4458
4736
  throw new UnsupportedByHostError('gitDiff needs a "path" argument.');
@@ -4473,7 +4751,7 @@ var MorseSessionFactory = class {
4473
4751
  return readCommitFiles(cwd, hash);
4474
4752
  }
4475
4753
  case "gitCommitDiff": {
4476
- const cwd = this.requireWritableCwd(context);
4754
+ const cwd = this.commandCwd(context, args);
4477
4755
  const hash = typeof args?.hash === "string" ? args.hash : "";
4478
4756
  const path = typeof args?.path === "string" ? args.path : "";
4479
4757
  if (path.length === 0) {
@@ -4526,6 +4804,22 @@ var MorseSessionFactory = class {
4526
4804
  }
4527
4805
  return cwd;
4528
4806
  }
4807
+ /**
4808
+ * The directory a file command works in. A restored preview tab carries the
4809
+ * project it was read from (`args.cwd`), because the host may still be on
4810
+ * another session when that tab is loaded; the policy checks it exactly like a
4811
+ * terminal's directory. Without one, the viewing session's directory is used.
4812
+ */
4813
+ commandCwd(context, args) {
4814
+ const requested = args?.cwd;
4815
+ if (typeof requested === "string" && requested.length > 0) {
4816
+ if (!this.policy.canOpen(requested)) {
4817
+ throw new UnsupportedByHostError(`Morse is not allowed to work in ${requested}.`);
4818
+ }
4819
+ return requested;
4820
+ }
4821
+ return this.requireWritableCwd(context);
4822
+ }
4529
4823
  /** The cwd of the session this connection is showing. */
4530
4824
  activeCwd() {
4531
4825
  const key = this.registry.activeKeyOf();
@@ -4534,12 +4828,13 @@ var MorseSessionFactory = class {
4534
4828
  }
4535
4829
  };
4536
4830
  MorseSessionFactory = __decorateClass([
4537
- Injectable2(),
4538
- __decorateParam(0, Inject3(MORSE_SESSION_REGISTRY)),
4539
- __decorateParam(1, Inject3(MORSE_TRANSCRIPT_STORE)),
4540
- __decorateParam(2, Inject3(MORSE_PROJECT_POLICY)),
4541
- __decorateParam(3, Inject3(MORSE_LOGGER)),
4542
- __decorateParam(4, Inject3(MORSE_CONFIG))
4831
+ Injectable3(),
4832
+ __decorateParam(0, Inject4(MORSE_SESSION_REGISTRY)),
4833
+ __decorateParam(1, Inject4(MORSE_TRANSCRIPT_STORE)),
4834
+ __decorateParam(2, Inject4(MORSE_PROJECT_POLICY)),
4835
+ __decorateParam(3, Inject4(MORSE_LOGGER)),
4836
+ __decorateParam(4, Inject4(MORSE_CONFIG)),
4837
+ __decorateParam(5, Inject4(ServerTerminalBackend))
4543
4838
  ], MorseSessionFactory);
4544
4839
  function gitPaths(args) {
4545
4840
  const paths = args?.paths;
@@ -4592,23 +4887,24 @@ var MorseGateway = class {
4592
4887
  };
4593
4888
  MorseGateway = __decorateClass([
4594
4889
  WebSocketGateway({ path: DEFAULT_WS_PATH }),
4595
- __decorateParam(0, Inject4(MorseSessionFactory)),
4596
- __decorateParam(1, Inject4(MORSE_LOGGER))
4890
+ __decorateParam(0, Inject5(MorseSessionFactory)),
4891
+ __decorateParam(1, Inject5(MORSE_LOGGER))
4597
4892
  ], MorseGateway);
4598
4893
 
4599
4894
  // ../server/src/app/config.ts
4600
- import { basename as basename4, dirname as dirname2, isAbsolute as isAbsolute6, join as join8 } from "node:path";
4601
- import { existsSync as existsSync2, readFileSync } from "node:fs";
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";
4602
4898
  import { fileURLToPath } from "node:url";
4603
4899
  function loadConfig(env = process.env) {
4604
4900
  const workspacePath = resolveWorkspacePath(env);
4605
4901
  const configuredUiDir = env.MORSE_UI_DIR;
4606
4902
  const moduleDir = dirname2(fileURLToPath(import.meta.url));
4607
- const bundledUiDir = join8(moduleDir, "ui");
4608
- const monorepoUiDir = join8(moduleDir, "..", "..", "..", "ui-angular", "dist");
4609
- const defaultUiDir = existsSync2(join8(bundledUiDir, "index.html")) ? bundledUiDir : monorepoUiDir;
4903
+ const bundledUiDir = join9(moduleDir, "ui");
4904
+ const monorepoUiDir = join9(moduleDir, "..", "..", "..", "ui-angular", "dist");
4905
+ const defaultUiDir = existsSync2(join9(bundledUiDir, "index.html")) ? bundledUiDir : monorepoUiDir;
4610
4906
  const defaultHost = "127.0.0.1";
4611
- const uiDir = configuredUiDir ? isAbsolute6(configuredUiDir) ? configuredUiDir : join8(process.cwd(), configuredUiDir) : defaultUiDir;
4907
+ const uiDir = configuredUiDir ? isAbsolute6(configuredUiDir) ? configuredUiDir : join9(process.cwd(), configuredUiDir) : defaultUiDir;
4612
4908
  return {
4613
4909
  host: env.MORSE_HOST ?? defaultHost,
4614
4910
  port: parsePositiveInt(env.MORSE_PORT, 4399),
@@ -4623,6 +4919,7 @@ function loadConfig(env = process.env) {
4623
4919
  hotSessions: parsePositiveInt(env.MORSE_HOT_SESSIONS, 4),
4624
4920
  projects: parseList(env.MORSE_PROJECTS),
4625
4921
  uploadDir: env.MORSE_UPLOAD_DIR?.trim() || DEFAULT_UPLOAD_DIR,
4922
+ dataDir: resolveDataDir(env),
4626
4923
  updateCheck: !isOff(env.MORSE_UPDATE_CHECK),
4627
4924
  instance: env.MORSE_INSTANCE?.trim() || void 0
4628
4925
  };
@@ -4630,12 +4927,16 @@ function loadConfig(env = process.env) {
4630
4927
  function readFrontendIdentity(uiDir) {
4631
4928
  try {
4632
4929
  return frontendIdentity(
4633
- parseFrontendManifest(readFileSync(join8(uiDir, FRONTEND_MANIFEST_FILE), "utf8"))
4930
+ parseFrontendManifest(readFileSync2(join9(uiDir, FRONTEND_MANIFEST_FILE), "utf8"))
4634
4931
  );
4635
4932
  } catch {
4636
4933
  return void 0;
4637
4934
  }
4638
4935
  }
4936
+ function resolveDataDir(env) {
4937
+ const explicit = env.MORSE_HOME?.trim();
4938
+ return explicit && explicit.length > 0 ? explicit : join9(homedir4(), ".morse");
4939
+ }
4639
4940
  function parsePositiveInt(value, fallback) {
4640
4941
  if (!value) {
4641
4942
  return fallback;
@@ -4661,7 +4962,7 @@ function resolveWorkspacePath(env) {
4661
4962
  const start = process.cwd();
4662
4963
  let directory = start;
4663
4964
  for (; ; ) {
4664
- const manifest = join8(directory, "package.json");
4965
+ const manifest = join9(directory, "package.json");
4665
4966
  if (existsSync2(manifest) && declaresWorkspaces(manifest)) {
4666
4967
  return directory;
4667
4968
  }
@@ -4674,7 +4975,7 @@ function resolveWorkspacePath(env) {
4674
4975
  }
4675
4976
  function declaresWorkspaces(manifestPath) {
4676
4977
  try {
4677
- const manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
4978
+ const manifest = JSON.parse(readFileSync2(manifestPath, "utf8"));
4678
4979
  return manifest.workspaces !== void 0;
4679
4980
  } catch {
4680
4981
  return false;
@@ -4725,6 +5026,7 @@ AppModule = __decorateClass([
4725
5026
  inject: [MORSE_CONFIG]
4726
5027
  },
4727
5028
  MorseRegistryLifecycle,
5029
+ ServerTerminalBackend,
4728
5030
  MorseSessionFactory,
4729
5031
  MorseGateway
4730
5032
  ]