paseo-smart-session 3.6.0 → 3.6.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/hooks/ask.mjs CHANGED
@@ -12,15 +12,20 @@
12
12
  * a queued compaction is still delivered once the session goes idle.
13
13
  */
14
14
 
15
- import { readFileSync } from "node:fs";
15
+ import { existsSync, readFileSync } from "node:fs";
16
16
  import { join } from "node:path";
17
17
 
18
18
  import { forTools, isEnrolled, occupancy, readSettings } from "./context.mjs";
19
19
  import { fileURLToPath } from "node:url";
20
20
  import { dirname as parentOf } from "node:path";
21
21
 
22
- /** The one-call bridge a detached session is pointed at (beside this plugin's hooks). */
23
- export const BRIDGE = join(parentOf(fileURLToPath(import.meta.url)), "..", "mcp.mjs");
22
+ /**
23
+ * The one-call bridge a detached session is pointed at. The stable shim in the data
24
+ * directory when it exists, since this install's own path is gone after the next update;
25
+ * otherwise the `mcp.mjs` beside these hooks.
26
+ */
27
+ const BRIDGE_SHIM = join(pluginDir(), "hooks", "mcp.mjs");
28
+ export const BRIDGE = existsSync(BRIDGE_SHIM) ? BRIDGE_SHIM : join(parentOf(fileURLToPath(import.meta.url)), "..", "mcp.mjs");
24
29
  import { pluginDir, stateAgeSeconds, statePath } from "./pointer.mjs";
25
30
  import { readLedger } from "./asks.mjs";
26
31
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "paseo-smart-session",
3
3
  "type": "module",
4
- "version": "3.6.0",
4
+ "version": "3.6.1",
5
5
  "license": "MIT",
6
6
  "description": "Records Claude plan-usage history and lets a Paseo agent manage its own context.",
7
7
  "repository": {
@@ -0,0 +1,133 @@
1
+ /**
2
+ * A path to this plugin's scripts that survives updates.
3
+ *
4
+ * Paseo installs every npm or Git update into a fresh directory and deletes the old one.
5
+ * Claude Code's hook commands and the stdio MCP server are absolute paths, so anything
6
+ * written against an install directory breaks the moment that directory goes: on
7
+ * 2026-11-09 the 3.6.0 update registered the hooks against the 3.5 directory seconds
8
+ * before Paseo removed it, and every hook failed with MODULE_NOT_FOUND for hours.
9
+ *
10
+ * So nothing outside the plugin names an install directory any more. Small shims live in
11
+ * the fixed data directory (`plugin-data/smart-session/hooks/`), and each one reads
12
+ * `current.json` beside it to find the install it should run. The plugin rewrites that
13
+ * pointer on every load and checks it on a slow timer. A shim whose target is missing
14
+ * exits 0 without a word: a hook that fails would fail every turn of every session.
15
+ */
16
+
17
+ import { randomUUID } from "node:crypto";
18
+ import { access, mkdir, readFile, rename, writeFile } from "node:fs/promises";
19
+ import { join } from "node:path";
20
+
21
+ import { dataDir } from "./store.ts";
22
+
23
+ /** The scripts that get a shim: the three hooks, and the agent-facing MCP server. */
24
+ export const SHIMMED = ["context-threshold.mjs", "ask-compact.mjs", "post-compact.mjs", "mcp.mjs"] as const;
25
+ export type Shimmed = (typeof SHIMMED)[number];
26
+
27
+ /** Where each script lives inside an install. */
28
+ export function scriptIn(pluginDir: string, script: Shimmed): string {
29
+ return script === "mcp.mjs" ? join(pluginDir, "mcp.mjs") : join(pluginDir, "hooks", script);
30
+ }
31
+
32
+ /** The stable directory the shims and their pointer live in. */
33
+ export function shimDir(): string {
34
+ return join(dataDir(), "hooks");
35
+ }
36
+
37
+ export function pointerPath(): string {
38
+ return join(shimDir(), "current.json");
39
+ }
40
+
41
+ /**
42
+ * The shim itself. Dependency-free, and it never fails: an unreadable pointer, a missing
43
+ * target, or a target that disappears between the check and the import all end in a
44
+ * silent exit 0. The real script runs in this same process through `import()`, so it
45
+ * keeps its own `import.meta.url` (and with it its siblings) and the hot `PostToolUse`
46
+ * path pays for no second Node start.
47
+ */
48
+ export function shimSource(script: Shimmed): string {
49
+ const relative = script === "mcp.mjs" ? ["mcp.mjs"] : ["hooks", script];
50
+ return `#!/usr/bin/env node
51
+ // Written by the Smart Session Paseo plugin, rewritten on every load. Do not edit.
52
+ // Runs ${script} from the install named in current.json, or exits 0 if there is none.
53
+ import { existsSync, readFileSync } from "node:fs";
54
+ import { dirname, join } from "node:path";
55
+ import { fileURLToPath, pathToFileURL } from "node:url";
56
+
57
+ let target = null;
58
+ try {
59
+ const { dir } = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), "current.json"), "utf8"));
60
+ const candidate = typeof dir === "string" && dir !== "" ? join(dir, ...${JSON.stringify(relative)}) : null;
61
+ if (candidate !== null && existsSync(candidate)) target = candidate;
62
+ } catch {}
63
+ if (target === null) process.exit(0);
64
+ try {
65
+ await import(pathToFileURL(target).href);
66
+ } catch {
67
+ process.exit(0);
68
+ }
69
+ `;
70
+ }
71
+
72
+ async function exists(path: string): Promise<boolean> {
73
+ try {
74
+ await access(path);
75
+ return true;
76
+ } catch {
77
+ return false;
78
+ }
79
+ }
80
+
81
+ async function writeIfChanged(path: string, text: string): Promise<boolean> {
82
+ const current = await readFile(path, "utf8").catch(() => null);
83
+ if (current === text) return false;
84
+ const temp = `${path}.${randomUUID()}.tmp`;
85
+ await writeFile(temp, text, "utf8");
86
+ await rename(temp, path);
87
+ return true;
88
+ }
89
+
90
+ /** The install directory the pointer names, or null when there is no readable pointer. */
91
+ export async function readPointer(): Promise<string | null> {
92
+ try {
93
+ const { dir } = JSON.parse(await readFile(pointerPath(), "utf8")) as { dir?: unknown };
94
+ return typeof dir === "string" && dir !== "" ? dir : null;
95
+ } catch {
96
+ return null;
97
+ }
98
+ }
99
+
100
+ /** Whether every shimmed script exists in that install. */
101
+ export async function complete(pluginDir: string): Promise<boolean> {
102
+ const found = await Promise.all(SHIMMED.map((script) => exists(scriptIn(pluginDir, script))));
103
+ return found.every(Boolean);
104
+ }
105
+
106
+ /**
107
+ * Writes the shims and points them at `pluginDir`. Touches nothing that is already
108
+ * right, so the common case (every load after the first) reads five small files and
109
+ * writes none. Returns the shim directory, and whether the pointer moved.
110
+ */
111
+ export async function pointShimsAt(pluginDir: string): Promise<{ dir: string; moved: boolean }> {
112
+ const dir = shimDir();
113
+ await mkdir(dir, { recursive: true });
114
+ for (const script of SHIMMED) await writeIfChanged(join(dir, script), shimSource(script));
115
+ const moved = await writeIfChanged(pointerPath(), `${JSON.stringify({ dir: pluginDir })}\n`);
116
+ return { dir, moved };
117
+ }
118
+
119
+ /** The stable path of one shim (whether or not it has been written yet). */
120
+ export function shimFor(script: Shimmed): string {
121
+ return join(shimDir(), script);
122
+ }
123
+
124
+ /**
125
+ * The cheap check the timer runs: true when the pointer names an install that still has
126
+ * its scripts and every shim is in place. Stats only; reads and writes nothing else.
127
+ */
128
+ export async function pointerHealthy(): Promise<boolean> {
129
+ const dir = await readPointer();
130
+ if (dir === null) return false;
131
+ const shims = await Promise.all(SHIMMED.map((script) => exists(shimFor(script))));
132
+ return shims.every(Boolean) && (await complete(dir));
133
+ }
@@ -13,7 +13,8 @@
13
13
  * machine's real Claude Code settings just because something imported a function.
14
14
  */
15
15
 
16
- import { install, resolvePluginDir } from "./install.ts";
16
+ import { lifecycle } from "../shared/lifecycle.ts";
17
+ import { install, refreshPointer, resolvePluginDir } from "./install.ts";
17
18
  import { removeGlobalRegistrations } from "./mcp-registrations.ts";
18
19
 
19
20
  void install()
@@ -47,3 +48,18 @@ void resolvePluginDir()
47
48
  }
48
49
  })
49
50
  .catch(() => undefined);
51
+
52
+ // Self-heal. The hooks run through shims whose pointer names an install directory, and
53
+ // Paseo deletes an install directory on update. install() above already points the shims
54
+ // at the running version; this repairs the pointer if its target disappears later. A
55
+ // healthy pointer costs a few stat calls and writes nothing, hence ten minutes.
56
+ const HEAL_MS = 10 * 60_000;
57
+ const heal = setInterval(() => {
58
+ void refreshPointer()
59
+ .then((moved) => {
60
+ if (moved) console.log("[smart-session] re-pointed the hook shims at the running install");
61
+ })
62
+ .catch(() => undefined);
63
+ }, HEAL_MS);
64
+ heal.unref?.();
65
+ lifecycle.teardowns.push(() => clearInterval(heal));
package/server/install.ts CHANGED
@@ -14,12 +14,15 @@
14
14
  *
15
15
  * The rules that make that acceptable:
16
16
  *
17
- * - **Own only our own.** An entry is ours when its command points at a
18
- * `hooks/*.mjs` in this repository. Nothing else in the file is read for
19
- * meaning, reordered or rewritten.
20
- * - **Never write a path that does not exist.** The plugin directory comes from
21
- * Paseo's own `config.json` and is then checked for the scripts themselves. A
22
- * hook command pointing at a missing file fails on every turn of every session.
17
+ * - **Own only our own.** An entry is ours when its command points at one of our
18
+ * `hooks/*.mjs` scripts: the stable shim in Smart Session's data directory, or
19
+ * (from older versions, migrated on the next load) an install's own `hooks/`.
20
+ * Nothing else in the file is read for meaning, reordered or rewritten.
21
+ * - **Never name an install directory.** Paseo deletes the old one on every update,
22
+ * so the commands run shims in the fixed data directory (`server/hook-path.ts`),
23
+ * each guarded so that a missing shim is a silent no-op. The shims are written, and
24
+ * their pointer checked against the scripts themselves, before any command naming
25
+ * them is.
23
26
  * - **Write only on a real change.** Reconciling to identical JSON writes nothing,
24
27
  * so the common case — every reload after the first — touches no files.
25
28
  * - **Be reversible.** One backup, kept from the first modification, and turning
@@ -28,11 +31,13 @@
28
31
 
29
32
  import { randomUUID } from "node:crypto";
30
33
  import { execFile } from "node:child_process";
31
- import { access, copyFile, mkdir, readFile, rename, writeFile } from "node:fs/promises";
34
+ import { access, copyFile, mkdir, readFile, readdir, rename, stat, writeFile } from "node:fs/promises";
32
35
  import { homedir } from "node:os";
33
36
  import { dirname, join } from "node:path";
34
37
  import { promisify } from "node:util";
35
38
 
39
+ import { PLUGIN_VERSION } from "../shared/version.ts";
40
+ import { complete, pointerHealthy, pointShimsAt } from "./hook-path.ts";
36
41
  import { readSettings } from "./settings.ts";
37
42
 
38
43
  const execFileAsync = promisify(execFile);
@@ -94,16 +99,55 @@ async function exists(path: string): Promise<boolean> {
94
99
  }
95
100
  }
96
101
 
102
+ interface Candidate {
103
+ readonly dir: string;
104
+ /** Explicitly named (SMART_SESSION_PLUGIN_DIR): taken as is, whatever its version. */
105
+ readonly explicit?: boolean;
106
+ }
107
+
108
+ /** Every install directory under `$PASEO_HOME/plugins/<id>/<install>/`, newest first. */
109
+ async function scanInstalls(home: string): Promise<string[]> {
110
+ const root = join(home, "plugins");
111
+ const found: { dir: string; mtime: number }[] = [];
112
+ for (const id of await readdir(root).catch(() => [] as string[])) {
113
+ for (const install of await readdir(join(root, id)).catch(() => [] as string[])) {
114
+ // npm installs land in node_modules/<package>, Git installs in checkout/.
115
+ for (const dir of [join(root, id, install, "node_modules", "paseo-smart-session"), join(root, id, install, "checkout")]) {
116
+ const info = await stat(dir).catch(() => null);
117
+ if (info?.isDirectory()) found.push({ dir, mtime: info.mtimeMs });
118
+ }
119
+ }
120
+ }
121
+ return found.sort((a, b) => b.mtime - a.mtime).map(({ dir }) => dir);
122
+ }
123
+
124
+ async function packageVersion(dir: string): Promise<string | null> {
125
+ try {
126
+ const pkg = JSON.parse(await readFile(join(dir, "package.json"), "utf8")) as { name?: unknown; version?: unknown };
127
+ return pkg.name === "paseo-smart-session" && typeof pkg.version === "string" ? pkg.version : null;
128
+ } catch {
129
+ return null;
130
+ }
131
+ }
132
+
97
133
  /**
98
- * Where this plugin's files live, according to Paseo.
134
+ * Where the running plugin's files live.
99
135
  *
100
- * Read from the host's own `config.json` rather than derived from the module's
101
- * location: Paseo compiles `index.server.ts` into a bundle whose path is an
102
- * implementation detail, and `import.meta` is not safe to rely on across that
103
- * compilation. Every configured plugin is checked rather than just `smart-session`,
104
- * because a second install under `--id something-else` is a supported thing to do.
136
+ * Paseo evaluates the server bundle from a string and hands it no directory, so
137
+ * `import.meta.url` and `__dirname` say nothing. What the bundle does carry is its own
138
+ * version, and the install it came from carries the same one in `package.json`. That is
139
+ * the first test, because `config.json` is not trustworthy during an update: on
140
+ * 2026-11-09 it still named the previous install when this ran, the hooks were written
141
+ * against it, and Paseo deleted it seconds later.
142
+ *
143
+ * Candidates are `SMART_SESSION_PLUGIN_DIR` (taken as is), every plugin `config.json`
144
+ * names (a second install under `--id something-else` is supported), then every install
145
+ * directory under `$PASEO_HOME/plugins`, newest first. Only a directory holding the
146
+ * scripts counts. The first whose version is the running one wins; failing that, the
147
+ * first complete one, which is what this did before it knew its version.
105
148
  */
106
- export async function resolvePluginDir(): Promise<string | null> {
149
+ export async function resolvePluginDir(options: { version?: string } = {}): Promise<string | null> {
150
+ const version = options.version ?? PLUGIN_VERSION;
107
151
  const home = process.env.PASEO_HOME ?? join(homedir(), ".paseo");
108
152
  let configured: string[] = [];
109
153
  try {
@@ -114,21 +158,28 @@ export async function resolvePluginDir(): Promise<string | null> {
114
158
  .map((entry) => entry.path)
115
159
  .filter((path): path is string => typeof path === "string" && path !== "");
116
160
  } catch {
117
- // No config, or one we cannot read. Fall through to the environment.
161
+ // No config, or one we cannot read. Fall through to the environment and the scan.
118
162
  }
119
163
 
120
164
  const fromEnv = process.env.SMART_SESSION_PLUGIN_DIR;
121
- const candidates = fromEnv !== undefined && fromEnv !== "" ? [fromEnv, ...configured] : configured;
122
-
123
- for (const candidate of candidates) {
165
+ const candidates: Candidate[] = [
166
+ ...(fromEnv !== undefined && fromEnv !== "" ? [{ dir: fromEnv, explicit: true }] : []),
167
+ ...configured.map((dir) => ({ dir })),
168
+ ...(await scanInstalls(home)).map((dir) => ({ dir })),
169
+ ];
170
+
171
+ let fallback: string | null = null;
172
+ const seen = new Set<string>();
173
+ for (const { dir, explicit } of candidates) {
174
+ if (seen.has(dir)) continue;
175
+ seen.add(dir);
124
176
  // The scripts themselves are the proof. A directory that merely has the right
125
177
  // name would still produce hook commands that fail on every turn.
126
- const complete = await Promise.all(
127
- OUR_SCRIPTS.map((script) => exists(join(candidate, "hooks", script))),
128
- );
129
- if (complete.every(Boolean)) return candidate;
178
+ if (!(await complete(dir))) continue;
179
+ if (explicit === true || (await packageVersion(dir)) === version) return dir;
180
+ fallback ??= dir;
130
181
  }
131
- return null;
182
+ return fallback;
132
183
  }
133
184
 
134
185
  /** Whether a hook command is one of ours, wherever it was installed from. */
@@ -137,8 +188,18 @@ function isOurs(command: unknown): boolean {
137
188
  return OUR_SCRIPTS.some((script) => command.includes(`hooks/${script}`));
138
189
  }
139
190
 
140
- function commandFor(pluginDir: string, script: string): string {
141
- return `node ${join(pluginDir, "hooks", script)}`;
191
+ /** A shell word, single-quoted. */
192
+ function quote(value: string): string {
193
+ return `'${value.replaceAll("'", `'\\''`)}'`;
194
+ }
195
+
196
+ /**
197
+ * The command for one hook: the stable shim, guarded, so a missing shim is a silent
198
+ * no-op rather than a failing hook on every turn of every session.
199
+ */
200
+ export function commandFor(shims: string, script: string): string {
201
+ const path = quote(join(shims, script));
202
+ return `if [ -f ${path} ]; then node ${path}; fi`;
142
203
  }
143
204
 
144
205
  /**
@@ -148,7 +209,7 @@ function commandFor(pluginDir: string, script: string): string {
148
209
  * directory or renamed a script leaves nothing stale behind — and so the manual
149
210
  * entries an early adopter added by hand are adopted rather than duplicated.
150
211
  */
151
- export function reconcile(existing: HookMap, pluginDir: string | null): HookMap {
212
+ export function reconcile(existing: HookMap, shims: string | null): HookMap {
152
213
  const next: HookMap = {};
153
214
 
154
215
  for (const [event, groups] of Object.entries(existing)) {
@@ -162,13 +223,13 @@ export function reconcile(existing: HookMap, pluginDir: string | null): HookMap
162
223
  if (kept.length > 0) next[event] = kept;
163
224
  }
164
225
 
165
- if (pluginDir === null) return next;
226
+ if (shims === null) return next;
166
227
 
167
228
  for (const { event, matcher, script } of MANAGED) {
168
229
  const groups = next[event] ?? [];
169
230
  const entry: HookEntry = {
170
231
  type: "command",
171
- command: commandFor(pluginDir, script),
232
+ command: commandFor(shims, script),
172
233
  timeout: HOOK_TIMEOUT_SECONDS,
173
234
  };
174
235
  const group = groups.find((candidate) => (candidate.matcher ?? "") === matcher);
@@ -226,8 +287,8 @@ const currentRuntime = (): McpRuntime => ({
226
287
  * the app launches its bundled `paseo` CLI. An AppImage mounts at a fresh path on
227
288
  * every launch, so its stable `$APPIMAGE` stands in for the mounted executable.
228
289
  */
229
- export function mcpDefinition(pluginDir: string, runtime: McpRuntime = currentRuntime()): McpDefinition {
230
- const args = [join(pluginDir, "mcp.mjs")];
290
+ export function mcpDefinition(pluginDir: string, runtime: McpRuntime = currentRuntime(), script?: string): McpDefinition {
291
+ const args = [script ?? join(pluginDir, "mcp.mjs")];
231
292
  if (runtime.electron === undefined) return { type: "stdio", command: "node", args };
232
293
  return {
233
294
  type: "stdio",
@@ -303,7 +364,25 @@ export function install(options: { registerMcp?: boolean } = {}): Promise<Instal
303
364
  return serialize(async () => {
304
365
  const path = settingsPath();
305
366
  const settings = await readSettings();
306
- const pluginDir = settings.installHooks ? await resolvePluginDir() : null;
367
+ // Resolved and pointed at whether or not hooks are installed: the stdio MCP server
368
+ // and the bridge the asks name run through the same shims.
369
+ const resolved = await resolvePluginDir();
370
+ let shims: string | null = null;
371
+ if (resolved !== null) {
372
+ try {
373
+ shims = (await pointShimsAt(resolved)).dir;
374
+ } catch (error) {
375
+ return {
376
+ pluginDir: resolved,
377
+ settingsPath: path,
378
+ changed: false,
379
+ hooks: [],
380
+ mcp: "skipped",
381
+ error: `Could not write the hook shims into Smart Session's data directory (${(error as NodeJS.ErrnoException).code ?? "error"}), so no hooks were installed.`,
382
+ };
383
+ }
384
+ }
385
+ const pluginDir = settings.installHooks ? resolved : null;
307
386
 
308
387
  if (settings.installHooks && pluginDir === null) {
309
388
  return {
@@ -339,7 +418,7 @@ export function install(options: { registerMcp?: boolean } = {}): Promise<Instal
339
418
  }
340
419
 
341
420
  const before = (parsed.hooks ?? {}) as HookMap;
342
- const after = reconcile(before, pluginDir);
421
+ const after = reconcile(before, pluginDir === null ? null : shims);
343
422
  const merged = { ...parsed, hooks: after };
344
423
  if (Object.keys(after).length === 0) delete (merged as { hooks?: unknown }).hooks;
345
424
 
@@ -364,3 +443,20 @@ export function install(options: { registerMcp?: boolean } = {}): Promise<Instal
364
443
  };
365
444
  });
366
445
  }
446
+
447
+ /**
448
+ * Points the shims at the running install, if they do not already point at one that
449
+ * still exists. Cheap enough for a timer: when the pointer is healthy it stats a handful
450
+ * of files and writes nothing. When it moves, the hooks are reconciled too (which writes
451
+ * `settings.json` only if that changes anything). Returns whether the pointer moved.
452
+ */
453
+ export async function refreshPointer(): Promise<boolean> {
454
+ // The load-time install() already pointed the shims at the running version; this only
455
+ // repairs a pointer whose target has since gone (or a shim someone deleted).
456
+ if (await pointerHealthy()) return false;
457
+ const dir = await resolvePluginDir();
458
+ if (dir === null) return false;
459
+ const { moved } = await pointShimsAt(dir);
460
+ if (moved) await install();
461
+ return moved;
462
+ }
@@ -1,9 +1,10 @@
1
1
  import type { PluginServerContext, PluginSessionOpenRequest } from "@getpaseo/plugin/server";
2
2
  import { randomUUID } from "node:crypto";
3
- import { mkdirSync, renameSync, writeFileSync } from "node:fs";
3
+ import { existsSync, mkdirSync, renameSync, writeFileSync } from "node:fs";
4
4
  import { readFile, writeFile } from "node:fs/promises";
5
5
  import { homedir } from "node:os";
6
6
  import { join } from "node:path";
7
+ import { readPointer, shimFor } from "./hook-path.ts";
7
8
  import { mcpDefinition, resolvePluginDir } from "./install.ts";
8
9
  import { type Invoke, nativeTools, nativeToolsAvailable } from "./native-tools.ts";
9
10
  import { readSettings } from "./settings.ts";
@@ -48,7 +49,10 @@ export function contributeSessionMcp(server: PluginServerContext, invoke: Invoke
48
49
  async function withStdio(request: PluginSessionOpenRequest): Promise<PluginSessionOpenRequest | void> {
49
50
  const directory = await resolvePluginDir();
50
51
  if (!directory) return;
51
- const definition = mcpDefinition(directory);
52
+ // Through the stable shim when it is in place, so a session opened just before an
53
+ // update does not hold a path Paseo is about to delete.
54
+ const shim = shimFor("mcp.mjs");
55
+ const definition = mcpDefinition(directory, undefined, (await readPointer()) === directory && existsSync(shim) ? shim : undefined);
52
56
  return {
53
57
  ...request,
54
58
  env: { ...request.env, SMART_SESSION_TOOLS: "stdio" },
@@ -0,0 +1,10 @@
1
+ /**
2
+ * This plugin's version, compiled into the bundle.
3
+ *
4
+ * Paseo evaluates the server bundle from a string, so the running code has no
5
+ * `import.meta.url` or `__dirname` to say which install directory it came from. Its
6
+ * version is the one thing it carries that the directory on disk also carries
7
+ * (`package.json`), which is how `resolvePluginDir()` tells the install that is running
8
+ * from a stale one during an update. `version.test.ts` keeps it equal to `package.json`.
9
+ */
10
+ export const PLUGIN_VERSION = "3.6.1";