paseo-smart-session 3.5.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/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
+ }
@@ -0,0 +1,126 @@
1
+ import { execFile } from "node:child_process";
2
+ import { chmod, copyFile, readFile, readdir, realpath, rename, stat, writeFile } from "node:fs/promises";
3
+ import { homedir } from "node:os";
4
+ import { dirname, join, resolve } from "node:path";
5
+ import { randomBytes } from "node:crypto";
6
+ import { promisify } from "node:util";
7
+
8
+ /**
9
+ * Smart Session's agent tools are attached per launch (natively on Paseo 0.15+, or as a per-launch
10
+ * stdio server), so a user-scope "smart-session" MCP entry left by an older version only makes every
11
+ * Claude or Codex session on the machine, Paseo's or not, start its own `node mcp.mjs` — or fail to,
12
+ * when it points at an install directory a later update removed. This removes such entries from
13
+ * every Claude and Codex home Paseo launches agents with, backing each file up first.
14
+ *
15
+ * An entry is ours only when it runs this plugin's `mcp.mjs`. `.claude.json` holds credentials, so
16
+ * it is read but never written here: `claude mcp remove` does that, under Claude Code's own lock,
17
+ * with `CLAUDE_CONFIG_DIR` pointing at the home in question.
18
+ */
19
+ export type Unregistered = { path: string; status: "removed" | "absent" | "foreign" | "unavailable" | "error"; error?: string };
20
+ const NAME = "smart-session";
21
+ const BACKUP = ".smart-session-unregister.bak";
22
+ const OURS = /(?:^|[\\/])(?:paseo-smart-session|smart-session)[\\/](?:.*[\\/])?mcp\.mjs$/;
23
+ const execFileAsync = promisify(execFile);
24
+
25
+ /** Every Claude global config (.claude.json) and Codex config.toml Paseo may launch an agent with. */
26
+ export async function agentHomes(env: NodeJS.ProcessEnv = process.env, paseoHome = env.PASEO_HOME ?? join(homedir(), ".paseo"), home = homedir()): Promise<{ claude: string[]; codex: string[] }> {
27
+ const claudeDirs = new Set<string>([env.CLAUDE_CONFIG_DIR?.trim() || home, home]);
28
+ const codexDirs = new Set<string>([env.CODEX_HOME?.trim() || join(home, ".codex"), join(home, ".codex")]);
29
+ // Homes set in Paseo's provider settings (agents.providers.<id>.env).
30
+ try {
31
+ const config = JSON.parse(await readFile(join(paseoHome, "config.json"), "utf8"));
32
+ for (const provider of Object.values(config?.agents?.providers ?? {}) as { env?: Record<string, string> }[]) {
33
+ if (provider?.env?.CLAUDE_CONFIG_DIR) claudeDirs.add(provider.env.CLAUDE_CONFIG_DIR);
34
+ if (provider?.env?.CODEX_HOME) codexDirs.add(provider.env.CODEX_HOME);
35
+ }
36
+ } catch { /* No config: the defaults. */ }
37
+ // Homes ZeroSub creates per account (~/.paseo/zerosub/homes/<id>/), Claude and Codex alike. ZeroSub
38
+ // seeds them from the main .claude.json, which is in the list above, so the entry does not come back.
39
+ const zerosub = join(paseoHome, "zerosub", "homes");
40
+ for (const entry of await readdir(zerosub, { withFileTypes: true }).catch(() => [])) {
41
+ if (!entry.isDirectory()) continue;
42
+ claudeDirs.add(join(zerosub, entry.name)); codexDirs.add(join(zerosub, entry.name));
43
+ }
44
+ const existing = async (paths: string[]) => [...new Set((await Promise.all(paths.map(async (p) => (await stat(p).catch(() => null))?.isFile() ? await realpath(p) : null))).filter((p): p is string => !!p))];
45
+ return {
46
+ claude: await existing([...claudeDirs].map((dir) => join(resolve(dir), ".claude.json"))),
47
+ codex: await existing([...codexDirs].map((dir) => join(resolve(dir), "config.toml"))),
48
+ };
49
+ }
50
+
51
+ const ours = (server: unknown) => {
52
+ const entry = server as { command?: unknown; args?: unknown } | null;
53
+ return [entry?.command, ...(Array.isArray(entry?.args) ? entry.args : [])].some((part) => typeof part === "string" && OURS.test(part));
54
+ };
55
+
56
+ async function backup(path: string): Promise<void> {
57
+ await copyFile(path, path + BACKUP, 1).catch((error) => { if (error.code !== "EEXIST") throw error; });
58
+ await chmod(path + BACKUP, (await stat(path)).mode & 0o777);
59
+ }
60
+
61
+ export type RunClaude = (args: string[], env: NodeJS.ProcessEnv) => Promise<unknown>;
62
+ const runClaude: RunClaude = (args, env) => execFileAsync("claude", args, { env, timeout: 30_000, maxBuffer: 1024 * 1024 });
63
+
64
+ export async function unregisterClaude(path: string, run: RunClaude = runClaude, home = homedir()): Promise<Unregistered> {
65
+ try {
66
+ const entry = JSON.parse(await readFile(path, "utf8"))?.mcpServers?.[NAME];
67
+ if (!entry) return { path, status: "absent" };
68
+ if (!ours(entry)) return { path, status: "foreign" };
69
+ await backup(path);
70
+ const env: NodeJS.ProcessEnv = { ...process.env };
71
+ // ~/.claude.json is the default home's; any other directory is a CLAUDE_CONFIG_DIR.
72
+ if (dirname(path) === await realpath(home).catch(() => resolve(home))) delete env.CLAUDE_CONFIG_DIR; else env.CLAUDE_CONFIG_DIR = dirname(path);
73
+ try { await run(["mcp", "remove", NAME, "--scope", "user"], env); } catch (error) {
74
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return { path, status: "unavailable" };
75
+ throw error;
76
+ }
77
+ const after = JSON.parse(await readFile(path, "utf8"))?.mcpServers?.[NAME];
78
+ return after ? { path, status: "error", error: "still registered after claude mcp remove" } : { path, status: "removed" };
79
+ } catch (error) { return { path, status: "error", error: (error as Error).message }; }
80
+ }
81
+
82
+ /** Removes [mcp_servers.smart-session] and its sub-tables when they run our mcp.mjs, keeping everything else verbatim. */
83
+ export function withoutCodexEntry(text: string): string | null {
84
+ const lines = text.split("\n");
85
+ const header = (line: string) => /^\s*\[\[?\s*([^\]]+?)\s*\]\]?\s*(?:#.*)?$/.exec(line)?.[1].replace(/["']/g, "") ?? null;
86
+ const mine = (name: string | null) => name === `mcp_servers.${NAME}` || name?.startsWith(`mcp_servers.${NAME}.`);
87
+ const tables: { start: number; end: number }[] = [];
88
+ for (let i = 0; i < lines.length; i++) {
89
+ if (!mine(header(lines[i]!))) continue;
90
+ let end = i + 1;
91
+ while (end < lines.length && header(lines[end]!) === null) end++;
92
+ // Comments and blank lines just above the next table belong to it.
93
+ while (end > i + 1 && /^\s*(#.*)?$/.test(lines[end - 1]!)) end--;
94
+ tables.push({ start: i, end }); i = end - 1;
95
+ }
96
+ if (!tables.length) return null;
97
+ const body = tables.map((t) => lines.slice(t.start, t.end).join("\n")).join("\n");
98
+ const strings = [...body.matchAll(/["']([^"']+)["']/g)].map((m) => m[1]!);
99
+ if (!strings.some((value) => OURS.test(value))) return text;
100
+ const keep = lines.filter((_, i) => !tables.some((t) => i >= t.start && i < t.end));
101
+ return keep.join("\n").replace(/\n{3,}/g, "\n\n");
102
+ }
103
+
104
+ export async function unregisterCodex(path: string): Promise<Unregistered> {
105
+ try {
106
+ const text = await readFile(path, "utf8");
107
+ const next = withoutCodexEntry(text);
108
+ if (next === null) return { path, status: "absent" };
109
+ if (next === text) return { path, status: "foreign" };
110
+ await backup(path);
111
+ const temporary = `${path}.smart-session.${randomBytes(4).toString("hex")}.tmp`;
112
+ await writeFile(temporary, next, { mode: (await stat(path)).mode & 0o777 });
113
+ await rename(temporary, path);
114
+ return { path, status: "removed" };
115
+ } catch (error) { return { path, status: "error", error: (error as Error).message }; }
116
+ }
117
+
118
+ /** Removes Smart Session's own global registrations from every known home. Never throws. */
119
+ export async function removeGlobalRegistrations(homes?: { claude: string[]; codex: string[] }, run: RunClaude = runClaude, home = homedir()): Promise<Unregistered[]> {
120
+ const found = homes ?? await agentHomes().catch(() => ({ claude: [], codex: [] }));
121
+ // One at a time: `claude mcp remove` is a whole CLI process, and they would share the main file's lock.
122
+ const results: Unregistered[] = [];
123
+ for (const path of found.claude) results.push(await unregisterClaude(path, run, home));
124
+ for (const path of found.codex) results.push(await unregisterCodex(path));
125
+ return results;
126
+ }
@@ -0,0 +1,60 @@
1
+ import { z } from "zod";
2
+ import type { AgentToolContext, AgentToolDefinition, PluginSessionOpenRequest } from "@getpaseo/plugin/server";
3
+ import { TOOL_SHAPES, agentTools, named } from "./agent-tools.mjs";
4
+
5
+ /**
6
+ * Smart Session's agent tools served natively (Paseo 0.15+). At `agent.session_open` they go on
7
+ * `request.tools`, and the daemon lists them on the agent's own Paseo MCP server and routes each
8
+ * call here over IPC. No process per agent, and the caller's id comes from the daemon's per-agent
9
+ * endpoint rather than an environment variable. `mcp.mjs` stays as the per-launch fallback where
10
+ * the host doesn't serve plugin tools (`nativeToolsAvailable`).
11
+ *
12
+ * Names carry a `smart_` prefix: plugin tools share one namespace with Paseo's own, and a
13
+ * collision fails the agent's whole Paseo tool list.
14
+ */
15
+ export const PREFIX = "smart_";
16
+
17
+ type PaseoConfig = {
18
+ daemon?: { mcp?: { enabled?: boolean; injectIntoAgents?: boolean } };
19
+ agents?: { providers?: Record<string, { paseoTools?: { enabled?: boolean; disabledTools?: string[] } }> };
20
+ };
21
+
22
+ /** Whether this host serves plugin tools to this agent: `request.tools` exists, the daemon injects its MCP, and the provider's Paseo tools aren't off. */
23
+ export function nativeToolsAvailable(request: Pick<PluginSessionOpenRequest, "provider"> & { tools?: unknown }, config: PaseoConfig | null): boolean {
24
+ if (!Array.isArray(request.tools)) return false;
25
+ if (config?.daemon?.mcp?.enabled === false || config?.daemon?.mcp?.injectIntoAgents === false) return false;
26
+ const policy = config?.agents?.providers?.[request.provider]?.paseoTools;
27
+ if (policy?.enabled === false) return false;
28
+ return !policy?.disabledTools?.some((name) => name.startsWith(PREFIX));
29
+ }
30
+
31
+ /** Calls one of this plugin's RPCs in-process, as the stdio server would over the daemon. */
32
+ export type Invoke = (method: string, input: unknown, context: AgentToolContext) => Promise<unknown>;
33
+
34
+ const fromJSONSchema = (schema: unknown): z.ZodType =>
35
+ (z as unknown as { fromJSONSchema: (schema: unknown) => z.ZodType }).fromJSONSchema(schema);
36
+
37
+ /** The tool definitions for one session. The same text and rules as `mcp.mjs`, run in this process. */
38
+ export function nativeTools(invoke: Invoke): AgentToolDefinition[] {
39
+ // Built once per worker: the definitions hold no per-session state (the caller comes with each call).
40
+ const cached = built.get(invoke);
41
+ if (cached) return cached;
42
+ const tools = TOOL_SHAPES.map((shape) => ({
43
+ name: PREFIX + shape.name,
44
+ description: named(shape.description, true),
45
+ input: fromJSONSchema(shape.inputSchema),
46
+ async execute(input: unknown, context: AgentToolContext) {
47
+ try {
48
+ const tools = agentTools({ agentId: context.agent.id, call: (method, payload) => invoke(method, payload, context) });
49
+ const text = await tools[shape.name as keyof typeof tools].run((input ?? {}) as Record<string, unknown>);
50
+ return { text: named(text, true) };
51
+ } catch (error) {
52
+ // A tool result, not a protocol error, so the model reads it, as in mcp.mjs.
53
+ return { isError: true, text: `Failed: ${error instanceof Error ? error.message : String(error)}` };
54
+ }
55
+ },
56
+ }));
57
+ built.set(invoke, tools);
58
+ return tools;
59
+ }
60
+ const built = new WeakMap<Invoke, AgentToolDefinition[]>();
@@ -1,26 +1,67 @@
1
- import type { PluginServerContext } from "@getpaseo/plugin/server";
1
+ import type { PluginServerContext, PluginSessionOpenRequest } from "@getpaseo/plugin/server";
2
+ import { randomUUID } from "node:crypto";
3
+ import { existsSync, mkdirSync, renameSync, writeFileSync } from "node:fs";
4
+ import { readFile, writeFile } from "node:fs/promises";
2
5
  import { homedir } from "node:os";
3
6
  import { join } from "node:path";
7
+ import { readPointer, shimFor } from "./hook-path.ts";
4
8
  import { mcpDefinition, resolvePluginDir } from "./install.ts";
9
+ import { type Invoke, nativeTools, nativeToolsAvailable } from "./native-tools.ts";
5
10
  import { readSettings } from "./settings.ts";
6
11
 
7
- /** MCP is a launch contribution, so it never writes global provider settings. */
8
- export function contributeSessionMcp(server: PluginServerContext) {
12
+ const paseoHome = () => process.env.PASEO_HOME ?? join(homedir(), ".paseo");
13
+
14
+ async function paseoConfig(): Promise<Record<string, any> | null> {
15
+ try { return JSON.parse(await readFile(join(paseoHome(), "config.json"), "utf8")); } catch { return null; }
16
+ }
17
+
18
+ /** Providers whose sessions get the tools. A test harness may widen it to its fixture provider. */
19
+ const providers = () => (process.env.SMART_SESSION_TOOL_PROVIDERS ?? "claude,codex").split(",").map((name) => name.trim());
20
+
21
+ /**
22
+ * The agent tools are a launch contribution, so nothing global is written. On Paseo 0.15+ they go
23
+ * on `request.tools` and the daemon serves them through the agent's own Paseo MCP; otherwise this
24
+ * launch alone gets `mcp.mjs` as a stdio server. `SMART_SESSION_TOOLS` tells the hooks which names
25
+ * the agent has (`smart_checkpoint` or `checkpoint`).
26
+ */
27
+ export function contributeSessionMcp(server: PluginServerContext, invoke: Invoke) {
28
+ // A reload or update detaches native tools from open sessions until each reopens. This worker's instance, and the
29
+ // instance that attached each session, let the hooks tell a detached session how to reach the tools meanwhile
30
+ // (hooks/context.mjs toolsMode). Written synchronously, before any session can open.
31
+ const instance = randomUUID();
32
+ const data = join(paseoHome(), "plugin-data", "smart-session");
33
+ try {
34
+ mkdirSync(join(data, "tools"), { recursive: true });
35
+ writeFileSync(join(data, `tools-instance.json.${instance}`), JSON.stringify({ instance }));
36
+ renameSync(join(data, `tools-instance.json.${instance}`), join(data, "tools-instance.json"));
37
+ } catch { /* Without it the hooks assume the tools are attached. */ }
9
38
  return server.before("agent.session_open", async ({ request }) => {
10
- if (request.purpose !== "interactive" || !["claude", "codex"].includes(request.provider)
39
+ if (request.purpose !== "interactive" || !providers().includes(request.provider)
11
40
  || request.labels["plugin-tools/enabled"] === "false" || !(await readSettings()).enabled) return;
12
- const directory = await resolvePluginDir();
13
- if (!directory) return;
14
- const definition = mcpDefinition(directory);
15
- return {
16
- ...request,
17
- mcpServers: { ...request.mcpServers, "smart-session": { ...definition, env: {
18
- ...definition.env, PASEO_AGENT_ID: request.agentId,
19
- PASEO_HOME: process.env.PASEO_HOME ?? join(homedir(), ".paseo"), SMART_SESSION_MCP_SCOPE: "paseo",
20
- ...(process.env.PASEO_DAEMON_URL ? { PASEO_DAEMON_URL: process.env.PASEO_DAEMON_URL } : {}),
21
- ...(process.env.PASEO_PASSWORD ? { PASEO_PASSWORD: process.env.PASEO_PASSWORD } : {}),
22
- ...(process.env.PASEO_PASSWORD_FILE ? { PASEO_PASSWORD_FILE: process.env.PASEO_PASSWORD_FILE } : {}),
23
- } } },
24
- };
41
+ if (process.env.SMART_SESSION_FORCE_STDIO !== "1" && nativeToolsAvailable(request, await paseoConfig())) {
42
+ await writeFile(join(data, "tools", `native-${request.agentId.replace(/[^A-Za-z0-9_-]/g, "_")}.json`), JSON.stringify({ instance })).catch(() => undefined);
43
+ return { ...request, env: { ...request.env, SMART_SESSION_TOOLS: "native" }, tools: [...request.tools, ...nativeTools(invoke)] };
44
+ }
45
+ return withStdio(request);
25
46
  });
26
47
  }
48
+
49
+ async function withStdio(request: PluginSessionOpenRequest): Promise<PluginSessionOpenRequest | void> {
50
+ const directory = await resolvePluginDir();
51
+ if (!directory) return;
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);
56
+ return {
57
+ ...request,
58
+ env: { ...request.env, SMART_SESSION_TOOLS: "stdio" },
59
+ mcpServers: { ...request.mcpServers, "smart-session": { ...definition, env: {
60
+ ...definition.env, PASEO_AGENT_ID: request.agentId,
61
+ PASEO_HOME: paseoHome(), SMART_SESSION_MCP_SCOPE: "paseo",
62
+ ...(process.env.PASEO_DAEMON_URL ? { PASEO_DAEMON_URL: process.env.PASEO_DAEMON_URL } : {}),
63
+ ...(process.env.PASEO_PASSWORD ? { PASEO_PASSWORD: process.env.PASEO_PASSWORD } : {}),
64
+ ...(process.env.PASEO_PASSWORD_FILE ? { PASEO_PASSWORD_FILE: process.env.PASEO_PASSWORD_FILE } : {}),
65
+ } } },
66
+ };
67
+ }
package/shared/chat.ts CHANGED
@@ -31,9 +31,13 @@ export const PromptCardSchema = z.object({
31
31
  });
32
32
  export type PromptCard = z.infer<typeof PromptCardSchema>;
33
33
 
34
- /** Claude records mcp__smart-session__context_status; Codex records smart-session.context_status. */
34
+ /**
35
+ * Claude records mcp__smart-session__context_status and Codex smart-session.context_status from the
36
+ * stdio server; served natively through Paseo's MCP they are mcp__paseo__smart_context_status and
37
+ * paseo.smart_context_status.
38
+ */
35
39
  export function smartSessionTool(name: string): SmartSessionTool | null {
36
- const match = /^(?:mcp__)?smart-session(?:__|\.|\/|:)([a-z_]+)$/.exec(name);
40
+ const match = /^(?:mcp__)?(?:smart-session(?:__|\.|\/|:)|paseo(?:__|\.|\/|:)smart_)([a-z_]+)$/.exec(name);
37
41
  return match && (SMART_SESSION_TOOLS as readonly string[]).includes(match[1]!) ? (match[1] as SmartSessionTool) : null;
38
42
  }
39
43
 
@@ -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";