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 +8 -3
- package/package.json +1 -1
- package/server/hook-path.ts +133 -0
- package/server/install-on-load.ts +17 -1
- package/server/install.ts +128 -32
- package/server/session-mcp.ts +6 -2
- package/shared/version.ts +10 -0
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
|
-
/**
|
|
23
|
-
|
|
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
|
@@ -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 {
|
|
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
|
|
18
|
-
* `hooks/*.mjs`
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
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
|
|
134
|
+
* Where the running plugin's files live.
|
|
99
135
|
*
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
-
*
|
|
103
|
-
*
|
|
104
|
-
*
|
|
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
|
|
122
|
-
|
|
123
|
-
|
|
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
|
-
|
|
127
|
-
|
|
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
|
|
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
|
-
|
|
141
|
-
|
|
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,
|
|
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 (
|
|
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(
|
|
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
|
-
|
|
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
|
+
}
|
package/server/session-mcp.ts
CHANGED
|
@@ -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
|
-
|
|
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";
|