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/README.md +1 -1
- package/hooks/ask.mjs +16 -6
- package/hooks/asks.mjs +2 -164
- package/hooks/context-threshold.mjs +4 -1
- package/hooks/context.mjs +2 -247
- package/hooks/pointer.mjs +2 -149
- package/index.server.ts +30 -3
- package/mcp.mjs +49 -468
- package/package.json +3 -2
- package/server/agent-tools.d.mts +15 -0
- package/server/agent-tools.mjs +472 -0
- package/server/hook-path.ts +133 -0
- package/server/hooklib/asks.mjs +163 -0
- package/server/hooklib/context.mjs +284 -0
- package/server/hooklib/pointer.mjs +149 -0
- package/server/install-on-load.ts +31 -1
- package/server/install.ts +128 -32
- package/server/mcp-registrations.ts +126 -0
- package/server/native-tools.ts +60 -0
- package/server/session-mcp.ts +58 -17
- package/shared/chat.ts +6 -2
- package/shared/version.ts +10 -0
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
|
+
}
|
|
@@ -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[]>();
|
package/server/session-mcp.ts
CHANGED
|
@@ -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
|
-
|
|
8
|
-
|
|
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" || !
|
|
39
|
+
if (request.purpose !== "interactive" || !providers().includes(request.provider)
|
|
11
40
|
|| request.labels["plugin-tools/enabled"] === "false" || !(await readSettings()).enabled) return;
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
-
/**
|
|
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";
|