paseo-smart-session 1.2.5 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +47 -32
- package/client/button-visibility.ts +32 -0
- package/client/display.tsx +102 -0
- package/client/navigation.tsx +14 -0
- package/client/sidebar-visibility.ts +21 -0
- package/client/surface.tsx +1 -1
- package/docs/navigation-verification.md +11 -0
- package/docs/screenshots/display-settings-desktop.png +0 -0
- package/docs/screenshots/display-settings-mobile.png +0 -0
- package/index.client.tsx +10 -4
- package/index.server.ts +5 -0
- package/mcp.mjs +45 -13
- package/package.json +5 -5
- package/paseo-plugin.json +1 -1
- package/server/install.ts +53 -17
- package/server/session-mcp.ts +26 -0
- package/shared/display.ts +11 -0
- package/shared/smart-session.ts +1 -1
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
[](https://www.npmjs.com/package/paseo-smart-session)
|
|
4
4
|
[](https://www.npmjs.com/package/paseo-smart-session)
|
|
5
|
-
[](https://paseo.sh)
|
|
6
6
|
[](LICENSE)
|
|
7
7
|
|
|
8
8
|
Give Paseo agents a safe way to manage long-running work.
|
|
@@ -11,6 +11,22 @@ Smart Session lets an agent see how full its context is, save durable task state
|
|
|
11
11
|
|
|
12
12
|

|
|
13
13
|
|
|
14
|
+
## Install
|
|
15
|
+
|
|
16
|
+
Install from npm using the latest Paseo release:
|
|
17
|
+
|
|
18
|
+
```sh
|
|
19
|
+
paseo plugin install paseo-smart-session
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
```sh
|
|
23
|
+
paseo plugin ls # confirm it is running
|
|
24
|
+
paseo plugin update smart-session
|
|
25
|
+
paseo plugin remove smart-session
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
On first load, Smart Session registers its Claude Code hooks. Its MCP server is attached by Paseo when an interactive session opens. It only manages entries that point at its own scripts; other Claude Code configuration is left alone.
|
|
29
|
+
|
|
14
30
|
## What it does
|
|
15
31
|
|
|
16
32
|
### Lets agents compact themselves
|
|
@@ -32,28 +48,6 @@ Smart Session keeps a local history of:
|
|
|
32
48
|
|
|
33
49
|
That history remains useful after an individual session ends or a provider cache changes.
|
|
34
50
|
|
|
35
|
-
## Install
|
|
36
|
-
|
|
37
|
-
Install from npm on Paseo 0.9.0 or newer:
|
|
38
|
-
|
|
39
|
-
```sh
|
|
40
|
-
paseo plugin install npm:paseo-smart-session@1.2.5
|
|
41
|
-
```
|
|
42
|
-
|
|
43
|
-
Paseo 0.8 can install the same plugin from Git:
|
|
44
|
-
|
|
45
|
-
```sh
|
|
46
|
-
paseo plugin add tomgrin10/paseo-smart-session --ref v1.2.5
|
|
47
|
-
```
|
|
48
|
-
|
|
49
|
-
```sh
|
|
50
|
-
paseo plugin ls # confirm it is running
|
|
51
|
-
paseo plugin update smart-session
|
|
52
|
-
paseo plugin remove smart-session
|
|
53
|
-
```
|
|
54
|
-
|
|
55
|
-
On first load, Smart Session registers its Claude Code hooks and MCP server. It only manages entries that point at its own scripts; other Claude Code configuration is left alone.
|
|
56
|
-
|
|
57
51
|
## How an agent uses it
|
|
58
52
|
|
|
59
53
|
Smart Session exposes these MCP tools to Paseo agents:
|
|
@@ -141,7 +135,7 @@ When the Paseo daemon requires a password, Smart Session resolves it in this ord
|
|
|
141
135
|
|
|
142
136
|
The secret is used only to connect to the daemon. It is never written or logged.
|
|
143
137
|
|
|
144
|
-
##
|
|
138
|
+
## Development
|
|
145
139
|
|
|
146
140
|
```sh
|
|
147
141
|
git clone https://github.com/tomgrin10/paseo-smart-session.git
|
|
@@ -162,16 +156,37 @@ Reloading the plugin is enough. Do not restart the Paseo daemon: that interrupts
|
|
|
162
156
|
|
|
163
157
|
## Data and privacy
|
|
164
158
|
|
|
165
|
-
Smart Session reads local Claude Code usage information and communicates with the local Paseo daemon. Its usage history, settings, and task state stay under `$PASEO_HOME`. Apart from narrowly maintaining its own Claude Code hook
|
|
159
|
+
Smart Session reads local Claude Code usage information and communicates with the local Paseo daemon. Its usage history, settings, and task state stay under `$PASEO_HOME`. Apart from narrowly maintaining its own Claude Code hook entries, it does not modify user configuration. MCP attachment is local to each Paseo session.
|
|
166
160
|
|
|
167
|
-
##
|
|
161
|
+
## License
|
|
168
162
|
|
|
169
|
-
|
|
163
|
+
[MIT](LICENSE) © 2026 Tom Gringauz.
|
|
170
164
|
|
|
171
|
-
|
|
172
|
-
- [Graphite](https://www.npmjs.com/package/paseo-graphite) — Monitor Graphite stacks and PR action state.
|
|
173
|
-
- [Vitals](https://www.npmjs.com/package/paseo-vitals) — Host, Paseo, agent, and Docker health in one dashboard.
|
|
165
|
+
## Navigation and display
|
|
174
166
|
|
|
175
|
-
|
|
167
|
+
Open this plugin from Command-K or its sidebar entry. **Overview** and **Display settings** link the main view and settings directly, without opening the Plugins settings page. Hiding a sidebar entry keeps Command-K available.
|
|
176
168
|
|
|
177
|
-
|
|
169
|
+
Display settings can also hide this plugin's workspace header or composer buttons independently. Hidden buttons stay hidden for newly opened workspaces; restoring them retains the latest button status.
|
|
170
|
+
|
|
171
|
+
## Paseo 0.14 agent integration
|
|
172
|
+
|
|
173
|
+
The Smart Session MCP attaches automatically when an interactive Claude or Codex session opens, including resume, refresh and import. Normal plugin startup no longer registers a global Claude MCP. Existing global entries are preserved; usage history and checkpoints remain in place. The bridge also respects a custom daemon listen address.
|
|
174
|
+
|
|
175
|
+
Requires Paseo **0.14.0 or newer**. Agent tools are scoped to interactive Claude
|
|
176
|
+
and Codex sessions; history-only opens receive none. They become available on
|
|
177
|
+
the next idle session open after install or reload. The agent label
|
|
178
|
+
`plugin-tools/enabled: "false"` opts a session out. Existing plugin data and
|
|
179
|
+
settings are retained.
|
|
180
|
+
|
|
181
|
+
## Other plugins
|
|
182
|
+
|
|
183
|
+
- [Defer](https://github.com/tomgrin10/paseo-defer) — Schedule messages for later delivery.
|
|
184
|
+
- [Graphite](https://github.com/tomgrin10/paseo-graphite) — Graphite stacks and PR status.
|
|
185
|
+
- [Vitals](https://github.com/tomgrin10/paseo-vitals) — Host, agent, and Docker health.
|
|
186
|
+
- [Session Guard](https://github.com/tomgrin10/paseo-session-guard) — Pause at session limits and continue after reset.
|
|
187
|
+
- [Transfer](https://github.com/tomgrin10/paseo-transfer) — Copy or move workspaces between hosts.
|
|
188
|
+
- [Transcript Search](https://github.com/tomgrin10/paseo-transcript-search) — Search conversations across connected hosts.
|
|
189
|
+
- [Send to Paseo](https://github.com/tomgrin10/send-to-paseo) — Send GitHub and Graphite PRs to Paseo from Chrome.
|
|
190
|
+
- [Dispatch](https://github.com/tomgrin10/paseo-dispatch) — Route tasks to existing workspaces (development preview).
|
|
191
|
+
|
|
192
|
+
- [Follow-up](https://github.com/tomgrin10/paseo-follow-up) — Track workspace PRs, remaining tasks and completion.
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { PluginClientContext, PluginButtonRegistration } from "@getpaseo/plugin/client";
|
|
2
|
+
import { subscribeDisplay } from "./display";
|
|
3
|
+
|
|
4
|
+
export function withButtonVisibility(client: PluginClientContext) {
|
|
5
|
+
const entries = new Set<{ reconcile(): void; remove(): void }>();
|
|
6
|
+
let enableHeader = true;
|
|
7
|
+
let enableComposer = true;
|
|
8
|
+
function gated<K extends "addHeaderButton" | "addComposerPill">(method: K, input: Parameters<PluginClientContext[K]>[0]): PluginButtonRegistration {
|
|
9
|
+
let descriptor = input.button;
|
|
10
|
+
let registration: PluginButtonRegistration | undefined;
|
|
11
|
+
const entry = {
|
|
12
|
+
reconcile() {
|
|
13
|
+
const enabled = method === "addHeaderButton" ? enableHeader : enableComposer;
|
|
14
|
+
if (enabled && !registration) registration = (client[method] as (value: typeof input) => PluginButtonRegistration)({ ...input, button: descriptor });
|
|
15
|
+
if (!enabled && registration) { registration.remove(); registration = undefined; }
|
|
16
|
+
},
|
|
17
|
+
remove() { registration?.remove(); registration = undefined; entries.delete(entry); },
|
|
18
|
+
};
|
|
19
|
+
entries.add(entry);
|
|
20
|
+
entry.reconcile();
|
|
21
|
+
return { remove: entry.remove, update(patch) { descriptor = { ...descriptor, ...patch }; registration?.update(patch); } };
|
|
22
|
+
}
|
|
23
|
+
const buttons = Object.create(client) as PluginClientContext;
|
|
24
|
+
buttons.addHeaderButton = input => gated("addHeaderButton", input);
|
|
25
|
+
buttons.addComposerPill = input => gated("addComposerPill", input);
|
|
26
|
+
const unsubscribe = subscribeDisplay(values => {
|
|
27
|
+
|
|
28
|
+
enableComposer = values.enable_composer_button;
|
|
29
|
+
for (const entry of entries) entry.reconcile();
|
|
30
|
+
});
|
|
31
|
+
return { client: buttons, dispose() { unsubscribe(); for (const entry of [...entries]) entry.remove(); } };
|
|
32
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import type { PluginClientContext, PluginSidebarContribution, PluginSurfaceProps } from "@getpaseo/plugin/client";
|
|
2
|
+
import { useSettings } from "@getpaseo/plugin/client";
|
|
3
|
+
import { SettingsAction, SettingsCard, SettingsSection, SettingsSwitch } from "@getpaseo/plugin/client/ui";
|
|
4
|
+
import { useEffect } from "react";
|
|
5
|
+
import { ActivityIndicator, ScrollView, Text, View } from "react-native";
|
|
6
|
+
import { displayPreferences, displayRpc } from "../shared/display";
|
|
7
|
+
import { withNavigation } from "./navigation";
|
|
8
|
+
import { sidebarVisibility } from "./sidebar-visibility";
|
|
9
|
+
|
|
10
|
+
type Display = import("zod").infer<typeof displayPreferences.schema>;
|
|
11
|
+
const listeners = new Set<(values: Display) => void>();
|
|
12
|
+
export function subscribeDisplay(listener: (values: Display) => void) { listeners.add(listener); return () => { listeners.delete(listener); }; }
|
|
13
|
+
|
|
14
|
+
function DisplaySettings({ theme }: PluginSurfaceProps) {
|
|
15
|
+
const settings = useSettings(displayPreferences);
|
|
16
|
+
const values = settings.status === "ready" ? settings.values : undefined;
|
|
17
|
+
useEffect(() => {
|
|
18
|
+
if (values !== undefined) for (const listener of listeners) listener(values);
|
|
19
|
+
}, [values]);
|
|
20
|
+
|
|
21
|
+
if (settings.status === "loading") return <ActivityIndicator accessibilityLabel="Loading display settings" color={theme.colors.foregroundMuted} />;
|
|
22
|
+
if (settings.status !== "ready") return (
|
|
23
|
+
<View style={{ gap: 12 }}>
|
|
24
|
+
<Text style={{ color: theme.colors.statusDanger }}>{settings.error}</Text>
|
|
25
|
+
<SettingsCard>
|
|
26
|
+
<SettingsAction label="Try loading the saved settings again" actionLabel="Retry" onPress={() => void settings.reload()} />
|
|
27
|
+
{settings.status === "invalid" && <SettingsAction label="Replace invalid display settings with defaults" actionLabel="Reset" disabled={settings.saving} onPress={() => void settings.reset()} />}
|
|
28
|
+
|
|
29
|
+
</SettingsCard>
|
|
30
|
+
{settings.saveError && <Text style={{ color: theme.colors.statusDanger }}>{settings.saveError}</Text>}
|
|
31
|
+
</View>
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
return (
|
|
35
|
+
<ScrollView style={{ flex: 1 }} contentContainerStyle={{ padding: 16, gap: 16 }}>
|
|
36
|
+
<SettingsSection title="Navigation">
|
|
37
|
+
<SettingsCard>
|
|
38
|
+
<SettingsSwitch
|
|
39
|
+
label="Show in sidebar"
|
|
40
|
+
hint="Open this plugin from Command-K (Ctrl-K on Windows and Linux) while viewing a workspace or plugin on this host."
|
|
41
|
+
value={settings.values.showSidebar}
|
|
42
|
+
disabled={settings.saving}
|
|
43
|
+
onValueChange={(showSidebar) => void settings.save({ ...settings.values, showSidebar }, settings.revision)}
|
|
44
|
+
/>
|
|
45
|
+
|
|
46
|
+
<SettingsSwitch label="Show composer button" hint="Command-K and the sidebar remain available when this button is hidden." value={settings.values.enable_composer_button} disabled={settings.saving} onValueChange={(enable_composer_button) => void settings.save({ ...settings.values, enable_composer_button }, settings.revision)} />
|
|
47
|
+
</SettingsCard>
|
|
48
|
+
{settings.saveError && <Text accessibilityRole="alert" style={{ color: theme.colors.statusDanger }}>{settings.saveError}</Text>}
|
|
49
|
+
</SettingsSection>
|
|
50
|
+
</ScrollView>
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Settings notifications reach mounted screens; poll for changes made on other clients. */
|
|
55
|
+
export function contributeDisplay(client: PluginClientContext, item: PluginSidebarContribution) {
|
|
56
|
+
const sidebar = sidebarVisibility(() => client.addSidebarItem(item));
|
|
57
|
+
// Paseo 0.10.1 memoizes sidebar route resolution when an installation mounts.
|
|
58
|
+
// Register synchronously, then reconcile the saved preference, so that an
|
|
59
|
+
// already-open sidebar route can resolve its surface during that first render.
|
|
60
|
+
sidebar.set(true);
|
|
61
|
+
const Settings = withNavigation(DisplaySettings, client, item.surface, "display");
|
|
62
|
+
const removeSurface = client.addSurface("display-settings", Settings);
|
|
63
|
+
const removeSettings = client.addSettingsScreen({ id: "display", title: "Display", icon: "PanelLeft", Component: Settings });
|
|
64
|
+
const removeCommand = client.addCommandCenterItem({
|
|
65
|
+
id: "display-settings",
|
|
66
|
+
title: `${item.title}: display settings`,
|
|
67
|
+
icon: "PanelLeft",
|
|
68
|
+
keywords: [item.title, "sidebar", "show", "hide", "display", "settings"],
|
|
69
|
+
context: "global",
|
|
70
|
+
onSelect({ openSurface }) { openSurface("display-settings"); },
|
|
71
|
+
});
|
|
72
|
+
let disposed = false;
|
|
73
|
+
let pending = false;
|
|
74
|
+
let generation = 0;
|
|
75
|
+
const update = (values: Display) => { generation += 1; sidebar.set(values.showSidebar); };
|
|
76
|
+
listeners.add(update);
|
|
77
|
+
const refresh = async () => {
|
|
78
|
+
if (disposed || pending) return;
|
|
79
|
+
pending = true;
|
|
80
|
+
const currentGeneration = generation;
|
|
81
|
+
try {
|
|
82
|
+
const result = await client.rpc(displayRpc.read, {});
|
|
83
|
+
if (!disposed && generation === currentGeneration && result.status === "ready") {
|
|
84
|
+
const parsed = displayPreferences.schema.safeParse(result.values);
|
|
85
|
+
if (parsed.success) { sidebar.set(parsed.data.showSidebar); for (const listener of listeners) listener(parsed.data); }
|
|
86
|
+
}
|
|
87
|
+
} catch {
|
|
88
|
+
// Retain the last valid preference through temporary disconnects.
|
|
89
|
+
} finally { pending = false; }
|
|
90
|
+
};
|
|
91
|
+
void refresh();
|
|
92
|
+
const timer = setInterval(() => void refresh(), 5000);
|
|
93
|
+
return () => {
|
|
94
|
+
disposed = true;
|
|
95
|
+
clearInterval(timer);
|
|
96
|
+
listeners.delete(update);
|
|
97
|
+
sidebar.dispose();
|
|
98
|
+
removeSurface();
|
|
99
|
+
removeSettings();
|
|
100
|
+
removeCommand();
|
|
101
|
+
};
|
|
102
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { ComponentType } from "react";
|
|
2
|
+
import type { PluginClientContext, PluginSurfaceProps } from "@getpaseo/plugin/client";
|
|
3
|
+
import { Pressable, Text, View } from "react-native";
|
|
4
|
+
|
|
5
|
+
export function withNavigation<P extends PluginSurfaceProps>(Component: ComponentType<P>, client: PluginClientContext, home: string, active = "overview") {
|
|
6
|
+
return function PluginView(props: P) {
|
|
7
|
+
return <View style={{ flex: 1, backgroundColor: props.theme.colors.surface0 }}>
|
|
8
|
+
<View accessibilityRole="toolbar" style={{ flexDirection: "row", flexWrap: "wrap", gap: 4, padding: 8, borderBottomWidth: 1, borderColor: props.theme.colors.border }}>
|
|
9
|
+
{[{ id: "overview", title: "Overview", target: home }, { id: "display", title: "Display settings", target: "display-settings" }].map(item => <Pressable key={item.id} accessibilityRole="button" accessibilityState={{ selected: active === item.id }} onPress={() => client.openSurface(item.target)} style={({ pressed }) => ({ minHeight: 44, paddingHorizontal: 12, justifyContent: "center", borderRadius: 6, backgroundColor: pressed || active === item.id ? props.theme.colors.surface2 : "transparent" })}><Text style={{ color: active === item.id ? props.theme.colors.foreground : props.theme.colors.foregroundMuted, fontSize: 13, fontWeight: active === item.id ? "600" : "400" }}>{item.title}</Text></Pressable>)}
|
|
10
|
+
</View>
|
|
11
|
+
<Component {...props} />
|
|
12
|
+
</View>;
|
|
13
|
+
};
|
|
14
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/** Own one sidebar registration; late reads cannot register after teardown. */
|
|
2
|
+
export function sidebarVisibility(register: () => (() => void) | void) {
|
|
3
|
+
let disposed = false;
|
|
4
|
+
let remove: (() => void) | undefined;
|
|
5
|
+
return {
|
|
6
|
+
set(visible: boolean) {
|
|
7
|
+
if (disposed) return;
|
|
8
|
+
if (visible && !remove) remove = register() ?? (() => {});
|
|
9
|
+
if (!visible && remove) {
|
|
10
|
+
remove();
|
|
11
|
+
remove = undefined;
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
dispose() {
|
|
15
|
+
if (disposed) return;
|
|
16
|
+
disposed = true;
|
|
17
|
+
remove?.();
|
|
18
|
+
remove = undefined;
|
|
19
|
+
},
|
|
20
|
+
};
|
|
21
|
+
}
|
package/client/surface.tsx
CHANGED
|
@@ -536,7 +536,7 @@ export function SmartSessionSurface({ theme, layout }: PluginSurfaceProps) {
|
|
|
536
536
|
: install.pluginDir === null
|
|
537
537
|
? "Not registered. Nothing will ask a session to compact itself."
|
|
538
538
|
: `Registered: ${install.hooks.join(", ")}. Only entries pointing at this plugin are touched; the MCP server is ${
|
|
539
|
-
install.mcp === "unavailable" ? "not registered — the claude CLI was not reachable" : "registered"
|
|
539
|
+
install.mcp === "session" ? "attached when a Paseo session opens" : install.mcp === "unavailable" ? "not registered — the claude CLI was not reachable" : "registered"
|
|
540
540
|
}.`
|
|
541
541
|
}
|
|
542
542
|
onPress={toggleInstallHooks}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# Navigation verification
|
|
2
|
+
|
|
3
|
+
Verified the actual plugin in an isolated Paseo 0.12.0 daemon with synthetic workspace and provider data, using the app web runtime at 1440×1000 and 390×844.
|
|
4
|
+
|
|
5
|
+
- Sidebar main view → Display settings → Overview opens the plugin surfaces directly.
|
|
6
|
+
- Compact settings have no horizontal overflow and preserve accessible native switches.
|
|
7
|
+
- Existing saved sidebar preferences retain their values. Button-bearing plugins expose independent positive enable_ preferences; hiding a button does not stop its feature.
|
|
8
|
+
- Graphite and Transfer header hiding/restoration were exercised through the UI; Transfer settings remained reachable from Command-K while its sidebar was hidden. Follow-up's choice survived a full page reload.
|
|
9
|
+
- Button lifecycle regressions cover registrations discovered while hidden, retaining the latest descriptor, duplicate prevention, restoration and teardown.
|
|
10
|
+
|
|
11
|
+
Screenshots: [desktop](screenshots/display-settings-desktop.png), [compact](screenshots/display-settings-mobile.png). Screenshots contain synthetic data. No main daemon restart or real-agent messages were used.
|
|
Binary file
|
|
Binary file
|
package/index.client.tsx
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { withButtonVisibility } from "./client/button-visibility";
|
|
2
|
+
import { contributeDisplay } from "./client/display";
|
|
3
|
+
import { withNavigation } from "./client/navigation";
|
|
1
4
|
import type { PluginClientContext } from "@getpaseo/plugin/client";
|
|
2
5
|
|
|
3
6
|
import { contributeClient, refreshPills } from "./client/pill";
|
|
@@ -5,9 +8,11 @@ import { SmartSessionSurface } from "./client/surface";
|
|
|
5
8
|
import { enrolmentState } from "./shared/governor";
|
|
6
9
|
import { getSettings, setSettings } from "./shared/smart-session";
|
|
7
10
|
|
|
8
|
-
export default function contribute(
|
|
9
|
-
|
|
10
|
-
client.
|
|
11
|
+
export default function contribute(host: PluginClientContext) {
|
|
12
|
+
const visibility = withButtonVisibility(host);
|
|
13
|
+
const client = visibility.client;
|
|
14
|
+
client.addSurface("overview", withNavigation(SmartSessionSurface, client, "overview"));
|
|
15
|
+
const removeDisplay = contributeDisplay(client, {
|
|
11
16
|
id: "smart-session",
|
|
12
17
|
title: "Smart Session",
|
|
13
18
|
icon: "Gauge",
|
|
@@ -38,5 +43,6 @@ export default function contribute(client: PluginClientContext) {
|
|
|
38
43
|
},
|
|
39
44
|
});
|
|
40
45
|
|
|
41
|
-
|
|
46
|
+
const removeClient = contributeClient(client);
|
|
47
|
+
return () => { visibility.dispose(); removeDisplay(); return removeClient(); };
|
|
42
48
|
}
|
package/index.server.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { contributeSessionMcp } from "./server/session-mcp.ts";
|
|
2
|
+
import { displayPreferences } from "./shared/display";
|
|
1
3
|
import type { PluginServerContext } from "@getpaseo/plugin/server";
|
|
2
4
|
|
|
3
5
|
import { readAgents, withDaemon } from "./server/daemon.ts";
|
|
@@ -38,6 +40,8 @@ import { queue } from "./server/governor.ts";
|
|
|
38
40
|
import "./server/install-on-load.ts";
|
|
39
41
|
|
|
40
42
|
export default function contribute(server: PluginServerContext) {
|
|
43
|
+
const removeSessionMcp = contributeSessionMcp(server);
|
|
44
|
+
server.registerSettings(displayPreferences);
|
|
41
45
|
server.handle(budgetStatus, async () => {
|
|
42
46
|
try {
|
|
43
47
|
const since = Date.now() - 14 * 24 * 60 * 60 * 1000;
|
|
@@ -189,6 +193,7 @@ export default function contribute(server: PluginServerContext) {
|
|
|
189
193
|
// The recorder and governor register their timers in this shared lifecycle.
|
|
190
194
|
// Paseo waits for this cleanup before stopping the plugin subprocess.
|
|
191
195
|
return async () => {
|
|
196
|
+
removeSessionMcp();
|
|
192
197
|
const teardowns = lifecycle.teardowns.splice(0);
|
|
193
198
|
for (const teardown of teardowns) await teardown();
|
|
194
199
|
};
|
package/mcp.mjs
CHANGED
|
@@ -71,6 +71,12 @@ const log = (...args) => console.error("[smart-session mcp]", ...args);
|
|
|
71
71
|
* installed. On a managed Git install the fallback instead finds the `paseo` CLI
|
|
72
72
|
* on `PATH` and resolves the client from its own installed dependencies — the
|
|
73
73
|
* same package the running daemon was built with.
|
|
74
|
+
*
|
|
75
|
+
* The desktop app is the exception. Its client lives inside `app.asar`, which
|
|
76
|
+
* only Electron can read, and its `paseo` is a shell wrapper with no
|
|
77
|
+
* `node_modules` beside it. `server/install.ts` therefore registers this server
|
|
78
|
+
* to run on the app's own Electron runtime in node mode, and from there the
|
|
79
|
+
* client resolves through `process.resourcesPath`.
|
|
74
80
|
*/
|
|
75
81
|
let daemonClientModule = null;
|
|
76
82
|
|
|
@@ -78,22 +84,38 @@ const CLIENT_SPECIFIER = ["@getpaseo", "client", "internal", "daemon-client"].jo
|
|
|
78
84
|
|
|
79
85
|
async function loadDaemonClient() {
|
|
80
86
|
if (daemonClientModule !== null) return daemonClientModule;
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
return daemonClientModule;
|
|
84
|
-
} catch (directError) {
|
|
85
|
-
const cliPath = findPaseoCli();
|
|
86
|
-
if (cliPath === null) throw directError;
|
|
87
|
+
const failures = [];
|
|
88
|
+
for (const { label, resolve } of clientCandidates()) {
|
|
87
89
|
try {
|
|
88
|
-
|
|
89
|
-
daemonClientModule = await import(pathToFileURL(resolved).href);
|
|
90
|
+
daemonClientModule = await import(resolve());
|
|
90
91
|
return daemonClientModule;
|
|
91
|
-
} catch (
|
|
92
|
-
|
|
93
|
-
`Cannot resolve ${CLIENT_SPECIFIER} directly (${directError.message}) or via the paseo CLI at ${cliPath} (${viaCliError.message})`,
|
|
94
|
-
);
|
|
92
|
+
} catch (error) {
|
|
93
|
+
failures.push(`${label} (${error.message})`);
|
|
95
94
|
}
|
|
96
95
|
}
|
|
96
|
+
throw new Error(`Cannot resolve ${CLIENT_SPECIFIER} ${failures.join(" or ")}${desktopHint()}`);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Every place the client can come from, cheapest first. */
|
|
100
|
+
function clientCandidates() {
|
|
101
|
+
const fromEntry = (entry) => () => pathToFileURL(createRequire(entry).resolve(CLIENT_SPECIFIER)).href;
|
|
102
|
+
const candidates = [{ label: "directly", resolve: () => CLIENT_SPECIFIER }];
|
|
103
|
+
// Set only under Electron, where the bundled CLI inside app.asar is readable.
|
|
104
|
+
if (typeof process.resourcesPath === "string") {
|
|
105
|
+
const entry = join(process.resourcesPath, "app.asar", "node_modules", "@getpaseo", "cli", "dist", "index.js");
|
|
106
|
+
candidates.push({ label: `from the Paseo app bundle at ${process.resourcesPath}`, resolve: fromEntry(entry) });
|
|
107
|
+
}
|
|
108
|
+
const cliPath = findPaseoCli();
|
|
109
|
+
if (cliPath !== null) candidates.push({ label: `via the paseo CLI at ${cliPath}`, resolve: fromEntry(cliPath) });
|
|
110
|
+
return candidates;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Explains the one failure a person can fix without reading the code. */
|
|
114
|
+
function desktopHint() {
|
|
115
|
+
if (process.versions.electron !== undefined) return "";
|
|
116
|
+
const cliPath = findPaseoCli();
|
|
117
|
+
if (cliPath === null || !existsSync(join(dirname(cliPath), "..", "app.asar"))) return "";
|
|
118
|
+
return ". The paseo CLI is the desktop app's, whose client only its own runtime can load: reload the smart-session plugin so it re-registers this MCP server on that runtime, then restart the Claude Code session";
|
|
97
119
|
}
|
|
98
120
|
|
|
99
121
|
/** Finds `paseo` on PATH and resolves symlinks, the way a shell's `which` would. */
|
|
@@ -112,11 +134,21 @@ function findPaseoCli() {
|
|
|
112
134
|
return null;
|
|
113
135
|
}
|
|
114
136
|
|
|
137
|
+
function daemonUrl() {
|
|
138
|
+
if (process.env.PASEO_DAEMON_URL) return process.env.PASEO_DAEMON_URL;
|
|
139
|
+
const home = process.env.PASEO_HOME ?? join(homedir(), ".paseo");
|
|
140
|
+
try {
|
|
141
|
+
const config = JSON.parse(readFileSync(join(home, "config.json"), "utf8"));
|
|
142
|
+
if (typeof config.daemon?.listen === "string") return `ws://${config.daemon.listen}/ws`;
|
|
143
|
+
} catch { /* Use the documented default when configuration is absent. */ }
|
|
144
|
+
return "ws://127.0.0.1:6767/ws";
|
|
145
|
+
}
|
|
146
|
+
|
|
115
147
|
async function callPlugin(method, input) {
|
|
116
148
|
const { DaemonClient } = await loadDaemonClient();
|
|
117
149
|
const password = await resolveDaemonPassword();
|
|
118
150
|
const client = new DaemonClient({
|
|
119
|
-
url:
|
|
151
|
+
url: daemonUrl(),
|
|
120
152
|
clientId: "smart-session-mcp",
|
|
121
153
|
clientType: "cli",
|
|
122
154
|
reconnect: { enabled: false },
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "paseo-smart-session",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "
|
|
4
|
+
"version": "2.0.0",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"description": "Records Claude plan-usage history and lets a Paseo agent manage its own context.",
|
|
7
7
|
"repository": {
|
|
@@ -33,13 +33,13 @@
|
|
|
33
33
|
"typecheck": "tsc --noEmit",
|
|
34
34
|
"test": "node --test --experimental-strip-types *.test.ts",
|
|
35
35
|
"check": "node check-bundles.mjs && node check-gitinstall.mjs && node check-teardown.mjs && node check-mcp-resolve.mjs",
|
|
36
|
-
"verify": "npm run typecheck && npm run test && npm run check"
|
|
36
|
+
"verify": "npm run typecheck && npm run test && npm run check && node --test button-visibility.test.mjs"
|
|
37
37
|
},
|
|
38
38
|
"devDependencies": {
|
|
39
39
|
"@babel/parser": "^8.0.4",
|
|
40
|
-
"@getpaseo/client": "0.
|
|
41
|
-
"@getpaseo/plugin": "0.
|
|
42
|
-
"@getpaseo/protocol": "0.
|
|
40
|
+
"@getpaseo/client": "0.14.0",
|
|
41
|
+
"@getpaseo/plugin": "0.14.0",
|
|
42
|
+
"@getpaseo/protocol": "0.14.0",
|
|
43
43
|
"@tanstack/react-query": "^5.90.11",
|
|
44
44
|
"@types/node": "^22.10.2",
|
|
45
45
|
"@types/react": "~19.2.0",
|
package/paseo-plugin.json
CHANGED
package/server/install.ts
CHANGED
|
@@ -7,10 +7,10 @@
|
|
|
7
7
|
* feature was off for anyone who skimmed, and off in a way that looked like it was
|
|
8
8
|
* working — the surface said "on", and nothing ever happened.
|
|
9
9
|
*
|
|
10
|
-
* Paseo
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
10
|
+
* Paseo 0.14 attaches MCP servers at session open. Claude Code's provider hooks
|
|
11
|
+
* still need reconciliation in `~/.claude/settings.json`; this module owns only
|
|
12
|
+
* those hooks during normal load. Global MCP registration is an explicit legacy
|
|
13
|
+
* compatibility operation, never the default.
|
|
14
14
|
*
|
|
15
15
|
* The rules that make that acceptable:
|
|
16
16
|
*
|
|
@@ -78,7 +78,7 @@ export interface InstallReport {
|
|
|
78
78
|
readonly changed: boolean;
|
|
79
79
|
/** One line per managed event, for the surface to show. */
|
|
80
80
|
readonly hooks: string[];
|
|
81
|
-
readonly mcp: "present" | "added" | "updated" | "unavailable" | "skipped";
|
|
81
|
+
readonly mcp: "present" | "added" | "updated" | "unavailable" | "skipped" | "session";
|
|
82
82
|
readonly error: string | null;
|
|
83
83
|
}
|
|
84
84
|
|
|
@@ -198,6 +198,45 @@ async function writeAtomic(path: string, text: string): Promise<void> {
|
|
|
198
198
|
await rename(temp, path);
|
|
199
199
|
}
|
|
200
200
|
|
|
201
|
+
export interface McpDefinition {
|
|
202
|
+
type: "stdio";
|
|
203
|
+
command: string;
|
|
204
|
+
args: string[];
|
|
205
|
+
env?: Record<string, string>;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export interface McpRuntime {
|
|
209
|
+
electron: string | undefined;
|
|
210
|
+
execPath: string;
|
|
211
|
+
appImage: string | undefined;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const currentRuntime = (): McpRuntime => ({
|
|
215
|
+
electron: process.versions.electron,
|
|
216
|
+
execPath: process.execPath,
|
|
217
|
+
appImage: process.env.APPIMAGE,
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* How Claude Code should launch `mcp.mjs`.
|
|
222
|
+
*
|
|
223
|
+
* `node` from `PATH`, except under the desktop app. There the daemon client lives
|
|
224
|
+
* inside `app.asar`, which plain Node cannot read, so the server runs on the
|
|
225
|
+
* runtime this plugin is itself running on — Electron in node mode, the same way
|
|
226
|
+
* the app launches its bundled `paseo` CLI. An AppImage mounts at a fresh path on
|
|
227
|
+
* every launch, so its stable `$APPIMAGE` stands in for the mounted executable.
|
|
228
|
+
*/
|
|
229
|
+
export function mcpDefinition(pluginDir: string, runtime: McpRuntime = currentRuntime()): McpDefinition {
|
|
230
|
+
const args = [join(pluginDir, "mcp.mjs")];
|
|
231
|
+
if (runtime.electron === undefined) return { type: "stdio", command: "node", args };
|
|
232
|
+
return {
|
|
233
|
+
type: "stdio",
|
|
234
|
+
command: runtime.appImage ?? runtime.execPath,
|
|
235
|
+
args,
|
|
236
|
+
env: { ELECTRON_RUN_AS_NODE: "1" },
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
|
|
201
240
|
/**
|
|
202
241
|
* Registers the agent-facing MCP server, through Claude Code's own CLI.
|
|
203
242
|
*
|
|
@@ -208,34 +247,31 @@ async function writeAtomic(path: string, text: string): Promise<void> {
|
|
|
208
247
|
* install into a new directory, so an entry can exist while its script no longer
|
|
209
248
|
* does.
|
|
210
249
|
*/
|
|
211
|
-
export function mcpPointsAtPlugin(output: string,
|
|
250
|
+
export function mcpPointsAtPlugin(output: string, definition: McpDefinition): boolean {
|
|
212
251
|
const lines = output.split(/\r?\n/).map((line) => line.trim());
|
|
213
252
|
return lines.includes("Scope: User config (available in all your projects)")
|
|
214
|
-
&& lines.includes(
|
|
215
|
-
&& lines.includes(`Args: ${join(
|
|
253
|
+
&& lines.includes(`Command: ${definition.command}`)
|
|
254
|
+
&& lines.includes(`Args: ${definition.args.join(" ")}`)
|
|
255
|
+
&& Object.entries(definition.env ?? {}).every(([key, value]) => lines.includes(`${key}=${value}`));
|
|
216
256
|
}
|
|
217
257
|
|
|
218
258
|
async function ensureMcp(pluginDir: string): Promise<InstallReport["mcp"]> {
|
|
219
259
|
const run = (args: string[]): Promise<{ stdout: string }> =>
|
|
220
260
|
execFileAsync("claude", args, { timeout: 20_000, maxBuffer: 1024 * 1024 });
|
|
261
|
+
const definition = mcpDefinition(pluginDir);
|
|
221
262
|
|
|
222
263
|
let existing = false;
|
|
223
264
|
try {
|
|
224
265
|
const { stdout } = await run(["mcp", "get", "smart-session"]);
|
|
225
|
-
if (mcpPointsAtPlugin(stdout,
|
|
266
|
+
if (mcpPointsAtPlugin(stdout, definition)) return "present";
|
|
226
267
|
existing = true;
|
|
227
268
|
} catch {
|
|
228
269
|
// Absent, or no `claude` on PATH. The add below tells the two apart.
|
|
229
270
|
}
|
|
230
271
|
|
|
231
|
-
const definition = JSON.stringify({
|
|
232
|
-
type: "stdio",
|
|
233
|
-
command: "node",
|
|
234
|
-
args: [join(pluginDir, "mcp.mjs")],
|
|
235
|
-
});
|
|
236
272
|
try {
|
|
237
273
|
if (existing) await run(["mcp", "remove", "smart-session", "--scope", "user"]);
|
|
238
|
-
await run(["mcp", "add-json", "--scope", "user", "smart-session", definition]);
|
|
274
|
+
await run(["mcp", "add-json", "--scope", "user", "smart-session", JSON.stringify(definition)]);
|
|
239
275
|
return existing ? "updated" : "added";
|
|
240
276
|
} catch {
|
|
241
277
|
// No `claude` binary on the daemon's PATH is the usual reason, and it is not
|
|
@@ -263,7 +299,7 @@ function serialize<T>(work: () => Promise<T>): Promise<T> {
|
|
|
263
299
|
* reads two files and writes none.
|
|
264
300
|
*/
|
|
265
301
|
export function install(options: { registerMcp?: boolean } = {}): Promise<InstallReport> {
|
|
266
|
-
const registerMcp = options.registerMcp
|
|
302
|
+
const registerMcp = options.registerMcp === true;
|
|
267
303
|
return serialize(async () => {
|
|
268
304
|
const path = settingsPath();
|
|
269
305
|
const settings = await readSettings();
|
|
@@ -323,7 +359,7 @@ export function install(options: { registerMcp?: boolean } = {}): Promise<Instal
|
|
|
323
359
|
settingsPath: path,
|
|
324
360
|
changed,
|
|
325
361
|
hooks: lines,
|
|
326
|
-
mcp: pluginDir === null
|
|
362
|
+
mcp: pluginDir === null ? "skipped" : !registerMcp ? "session" : await ensureMcp(pluginDir),
|
|
327
363
|
error: null,
|
|
328
364
|
};
|
|
329
365
|
});
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { PluginServerContext } from "@getpaseo/plugin/server";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { mcpDefinition, resolvePluginDir } from "./install.ts";
|
|
5
|
+
import { readSettings } from "./settings.ts";
|
|
6
|
+
|
|
7
|
+
/** MCP is a launch contribution, so it never writes global provider settings. */
|
|
8
|
+
export function contributeSessionMcp(server: PluginServerContext) {
|
|
9
|
+
return server.before("agent.session_open", async ({ request }) => {
|
|
10
|
+
if (request.purpose !== "interactive" || !["claude", "codex"].includes(request.provider)
|
|
11
|
+
|| 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
|
+
};
|
|
25
|
+
});
|
|
26
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { defineSettings, settingsRpc } from "@getpaseo/plugin";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
|
|
4
|
+
export const displayPreferences = defineSettings({
|
|
5
|
+
id: "display",
|
|
6
|
+
scope: "host",
|
|
7
|
+
version: 1,
|
|
8
|
+
schema: z.object({ enable_composer_button: z.boolean().default(true), showSidebar: z.boolean().default(true) }),
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
export const displayRpc = settingsRpc(displayPreferences.id);
|
package/shared/smart-session.ts
CHANGED
|
@@ -109,7 +109,7 @@ export const InstallReportSchema = z.object({
|
|
|
109
109
|
settingsPath: z.string(),
|
|
110
110
|
changed: z.boolean(),
|
|
111
111
|
hooks: z.array(z.string()),
|
|
112
|
-
mcp: z.enum(["present", "added", "updated", "unavailable", "skipped"]),
|
|
112
|
+
mcp: z.enum(["present", "added", "updated", "unavailable", "skipped", "session"]),
|
|
113
113
|
error: z.string().nullable(),
|
|
114
114
|
});
|
|
115
115
|
|