@supanadit/morse-web 0.10.0 → 0.11.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
 
@@ -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";
@@ -4334,12 +4500,13 @@ function toPosix3(path) {
4334
4500
 
4335
4501
  // ../server/src/internal/ws/session-factory.service.ts
4336
4502
  var MorseSessionFactory = class {
4337
- constructor(registry, transcripts, policy, logger, config) {
4503
+ constructor(registry, transcripts, policy, logger, config, terminal) {
4338
4504
  this.registry = registry;
4339
4505
  this.transcripts = transcripts;
4340
4506
  this.policy = policy;
4341
4507
  this.logger = logger;
4342
4508
  this.config = config;
4509
+ this.terminal = terminal;
4343
4510
  }
4344
4511
  capabilities() {
4345
4512
  return {
@@ -4361,6 +4528,10 @@ var MorseSessionFactory = class {
4361
4528
  // The same reasoning for history: VS Code has Source Control, so the
4362
4529
  // browser host is the one that answers `gitLog` for its own git panel.
4363
4530
  gitPanel: true,
4531
+ // VS Code already has an integrated terminal; the browser host has none, so
4532
+ // the bottom panel's terminal is this host's. It runs in the viewing
4533
+ // session's directory and is gated by `ProjectPolicy`, like a session.
4534
+ terminal: true,
4364
4535
  // A browser cannot hand a dragged file's path to pi, so the host takes the
4365
4536
  // bytes and writes them next to the session; the frontend then `@mentions`
4366
4537
  // the path it gets back.
@@ -4399,6 +4570,8 @@ var MorseSessionFactory = class {
4399
4570
  // A page load must not create a session; the first prompt opens one.
4400
4571
  autoOpen: false,
4401
4572
  onHostCommand: (command, args, context) => this.runHostCommand(command, args, context),
4573
+ // The bottom panel's shell: this host can spawn one on its own machine.
4574
+ terminal: this.terminal,
4402
4575
  agentHint: "Install the pi CLI and make sure it is on PATH, or start the server with MORSE_PI_PATH set."
4403
4576
  });
4404
4577
  }
@@ -4534,12 +4707,13 @@ var MorseSessionFactory = class {
4534
4707
  }
4535
4708
  };
4536
4709
  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))
4710
+ Injectable3(),
4711
+ __decorateParam(0, Inject4(MORSE_SESSION_REGISTRY)),
4712
+ __decorateParam(1, Inject4(MORSE_TRANSCRIPT_STORE)),
4713
+ __decorateParam(2, Inject4(MORSE_PROJECT_POLICY)),
4714
+ __decorateParam(3, Inject4(MORSE_LOGGER)),
4715
+ __decorateParam(4, Inject4(MORSE_CONFIG)),
4716
+ __decorateParam(5, Inject4(ServerTerminalBackend))
4543
4717
  ], MorseSessionFactory);
4544
4718
  function gitPaths(args) {
4545
4719
  const paths = args?.paths;
@@ -4592,8 +4766,8 @@ var MorseGateway = class {
4592
4766
  };
4593
4767
  MorseGateway = __decorateClass([
4594
4768
  WebSocketGateway({ path: DEFAULT_WS_PATH }),
4595
- __decorateParam(0, Inject4(MorseSessionFactory)),
4596
- __decorateParam(1, Inject4(MORSE_LOGGER))
4769
+ __decorateParam(0, Inject5(MorseSessionFactory)),
4770
+ __decorateParam(1, Inject5(MORSE_LOGGER))
4597
4771
  ], MorseGateway);
4598
4772
 
4599
4773
  // ../server/src/app/config.ts
@@ -4725,6 +4899,7 @@ AppModule = __decorateClass([
4725
4899
  inject: [MORSE_CONFIG]
4726
4900
  },
4727
4901
  MorseRegistryLifecycle,
4902
+ ServerTerminalBackend,
4728
4903
  MorseSessionFactory,
4729
4904
  MorseGateway
4730
4905
  ]