@evref-bl/pharo-agent 0.10.1 → 0.11.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/.claude-plugin/plugin.json +1 -1
- package/.mcp.json +8 -0
- package/README.md +1 -1
- package/package.json +3 -2
- package/plugin.json +1 -1
- package/skills/pharo-setup/SKILL.md +27 -4
- package/src/cli.js +49 -33
- package/src/gateway.js +11 -43
- package/src/help.js +22 -24
- package/src/images.js +30 -17
- package/src/init.js +16 -12
- package/src/live.js +82 -79
- package/src/mcpServer.js +153 -0
- package/src/project.js +18 -2
- package/src/runtime.js +24 -25
package/.mcp.json
ADDED
package/README.md
CHANGED
|
@@ -14,7 +14,7 @@ npx --yes @evref-bl/pharo-agent init --host <claude|codex|opencode>
|
|
|
14
14
|
|
|
15
15
|
That is all of it. `init` writes the project's Pharo guidance and copies the
|
|
16
16
|
workflow skills that guidance refers to. From there the agent registers the
|
|
17
|
-
image the project needs,
|
|
17
|
+
image the project needs, starts it and uses its tools by following what was
|
|
18
18
|
written; ask it for Pharo work as you would for anything else.
|
|
19
19
|
|
|
20
20
|
## Refreshing an existing project
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@evref-bl/pharo-agent",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Terminal-first Pharo integration for coding agents.",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
"bin",
|
|
17
17
|
"src",
|
|
18
18
|
"skills",
|
|
19
|
+
".mcp.json",
|
|
19
20
|
"plugin.json",
|
|
20
21
|
".agents",
|
|
21
22
|
".claude-plugin",
|
|
@@ -32,7 +33,7 @@
|
|
|
32
33
|
},
|
|
33
34
|
"dependencies": {
|
|
34
35
|
"@evref-bl/pharo-mcp": "3.3.1",
|
|
35
|
-
"@evref-bl/plexus": "0.
|
|
36
|
+
"@evref-bl/plexus": "0.11.0",
|
|
36
37
|
"@modelcontextprotocol/sdk": "1.32.1"
|
|
37
38
|
},
|
|
38
39
|
"devDependencies": {
|
package/plugin.json
CHANGED
|
@@ -8,13 +8,35 @@ Run this only when the user asks to enable or refresh Pharo tooling in this proj
|
|
|
8
8
|
From the project root, run the released CLI that matches this plugin:
|
|
9
9
|
|
|
10
10
|
```sh
|
|
11
|
-
npx --yes @evref-bl/pharo-agent@0.
|
|
11
|
+
npx --yes @evref-bl/pharo-agent@0.11.0 init --host claude
|
|
12
12
|
```
|
|
13
13
|
|
|
14
14
|
Name the host this chat runs in: `--host claude` in Claude Code,
|
|
15
15
|
`--host codex` in Codex, `--host opencode` in OpenCode.
|
|
16
|
-
|
|
17
|
-
|
|
16
|
+
The command writes no host configuration. It adds a conditional instruction
|
|
17
|
+
block and this project's own files, so Pharo guidance stays out of unrelated
|
|
18
|
+
work.
|
|
19
|
+
|
|
20
|
+
To get the image's tools as MCP tools in Claude Code, enable this plugin for
|
|
21
|
+
this project rather than configuring a server by hand. Commit
|
|
22
|
+
`.claude/settings.json`:
|
|
23
|
+
|
|
24
|
+
```json
|
|
25
|
+
{
|
|
26
|
+
"extraKnownMarketplaces": {
|
|
27
|
+
"evref-bl": {
|
|
28
|
+
"source": {
|
|
29
|
+
"source": "git",
|
|
30
|
+
"url": "https://github.com/Evref-BL/pharo-agent-plugin.git"
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
"enabledPlugins": { "pharo-agent@evref-bl": true }
|
|
35
|
+
}
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
A host reads that file when a session starts, so the tools appear in the next
|
|
39
|
+
one. Enabling it per project keeps every other project untouched.
|
|
18
40
|
|
|
19
41
|
After upgrading the plugin/CLI, re-run the setup command from the project root
|
|
20
42
|
with the same host to refresh the generated guide and copied skills/references.
|
|
@@ -24,7 +46,8 @@ existing settings. Image declarations, runtime state, and live image code are
|
|
|
24
46
|
preserved. This refresh does not install a newer package or upgrade image tools.
|
|
25
47
|
|
|
26
48
|
Setup initializes the current directory, creating `plexus.json` with
|
|
27
|
-
no image when it is absent. Register one before the first
|
|
49
|
+
no image when it is absent. Register one before the first
|
|
50
|
+
`pharo-agent image start`:
|
|
28
51
|
|
|
29
52
|
```sh
|
|
30
53
|
pharo-agent image templates
|
package/src/cli.js
CHANGED
|
@@ -2,15 +2,11 @@ import { hostChoices, hostList } from "./hosts.js";
|
|
|
2
2
|
import { usage } from "./help.js";
|
|
3
3
|
import { initialize } from "./init.js";
|
|
4
4
|
import { addImage, listImages, removeImage, useImage } from "./images.js";
|
|
5
|
-
import { callImageTool, listImageTools
|
|
6
|
-
import {
|
|
7
|
-
|
|
8
|
-
closeAgentImage,
|
|
9
|
-
listImageTemplates,
|
|
10
|
-
openAgentImage,
|
|
11
|
-
} from "./live.js";
|
|
5
|
+
import { callImageTool, listImageTools } from "./gateway.js";
|
|
6
|
+
import { serveImagesMcp } from "./mcpServer.js";
|
|
7
|
+
import { listImageTemplates, startAgentImage, stopAgentImage } from "./live.js";
|
|
12
8
|
import { findProjectRoot } from "./project.js";
|
|
13
|
-
import {
|
|
9
|
+
import { readRunningImage } from "./runtime.js";
|
|
14
10
|
|
|
15
11
|
function parseInit(args) {
|
|
16
12
|
let host;
|
|
@@ -63,6 +59,21 @@ function parseImageAdd(args) {
|
|
|
63
59
|
return parsed;
|
|
64
60
|
}
|
|
65
61
|
|
|
62
|
+
// `image start` and `image stop` name their subject, like `image use` and
|
|
63
|
+
// `image remove`; `--image` stays on the commands that scope a call.
|
|
64
|
+
function parseImageTarget(subcommand, args) {
|
|
65
|
+
const all = args.includes("--all");
|
|
66
|
+
const rest = args.filter((value) => value !== "--all");
|
|
67
|
+
const [id, ...extra] = rest;
|
|
68
|
+
if (extra.length > 0 || (id && id.startsWith("--"))) {
|
|
69
|
+
throw new Error(`image ${subcommand} takes one image id`);
|
|
70
|
+
}
|
|
71
|
+
if (all && subcommand !== "stop") {
|
|
72
|
+
throw new Error(`image ${subcommand} does not accept --all`);
|
|
73
|
+
}
|
|
74
|
+
return { id, all };
|
|
75
|
+
}
|
|
76
|
+
|
|
66
77
|
function takeImageOption(args) {
|
|
67
78
|
const index = args.indexOf("--image");
|
|
68
79
|
if (index === -1) return { image: undefined, rest: args };
|
|
@@ -115,6 +126,16 @@ export async function main(args) {
|
|
|
115
126
|
console.log(JSON.stringify({ ok: true, ...result }, null, 2));
|
|
116
127
|
return;
|
|
117
128
|
}
|
|
129
|
+
// Above the project lookup on purpose: a host starts this in whatever
|
|
130
|
+
// directory it likes, which may hold no project at all, and a server that
|
|
131
|
+
// exits is a broken server in the host's list. It resolves a project per
|
|
132
|
+
// request instead, so the reason reaches whoever calls a tool.
|
|
133
|
+
if (command === "mcp") {
|
|
134
|
+
const { image: mcpImage, rest: mcpArgs } = takeImageOption(rest);
|
|
135
|
+
requireNoArguments(command, mcpArgs);
|
|
136
|
+
await serveImagesMcp(findProjectRoot, mcpImage);
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
118
139
|
const projectRoot = findProjectRoot();
|
|
119
140
|
if (command === "image") {
|
|
120
141
|
const [subcommand, ...imageArgs] = rest;
|
|
@@ -136,6 +157,24 @@ export async function main(args) {
|
|
|
136
157
|
console.log(JSON.stringify({ ok: true, ...addImage(projectRoot, parseImageAdd(imageArgs)) }, null, 2));
|
|
137
158
|
return;
|
|
138
159
|
}
|
|
160
|
+
if (subcommand === "start") {
|
|
161
|
+
const { id } = parseImageTarget(subcommand, imageArgs);
|
|
162
|
+
console.log(JSON.stringify(
|
|
163
|
+
{ ok: true, ...(await startAgentImage(projectRoot, id)) },
|
|
164
|
+
null,
|
|
165
|
+
2,
|
|
166
|
+
));
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
if (subcommand === "stop") {
|
|
170
|
+
const { id, all } = parseImageTarget(subcommand, imageArgs);
|
|
171
|
+
console.log(JSON.stringify(
|
|
172
|
+
{ ok: true, ...(await stopAgentImage(projectRoot, id, { all })) },
|
|
173
|
+
null,
|
|
174
|
+
2,
|
|
175
|
+
));
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
139
178
|
if (subcommand === "use" || subcommand === "remove") {
|
|
140
179
|
const [id, ...extra] = imageArgs;
|
|
141
180
|
if (!id || extra.length > 0) {
|
|
@@ -148,35 +187,12 @@ export async function main(args) {
|
|
|
148
187
|
throw new Error(`Unsupported image command: ${subcommand ?? ""}\n${usage()}`);
|
|
149
188
|
}
|
|
150
189
|
const { image, rest: commandArgs } = takeImageOption(rest);
|
|
151
|
-
if (command === "open") {
|
|
152
|
-
requireNoArguments(command, commandArgs);
|
|
153
|
-
console.log(JSON.stringify({ ok: true, ...(await openAgentImage(projectRoot, image)) }, null, 2));
|
|
154
|
-
return;
|
|
155
|
-
}
|
|
156
|
-
if (command === "close") {
|
|
157
|
-
requireNoArguments(command, commandArgs);
|
|
158
|
-
console.log(JSON.stringify({ ok: true, ...(await closeAgentImage(projectRoot, image)) }, null, 2));
|
|
159
|
-
return;
|
|
160
|
-
}
|
|
161
|
-
if (command === "status") {
|
|
162
|
-
requireNoArguments(command, commandArgs);
|
|
163
|
-
if (image) {
|
|
164
|
-
throw new Error("status reports every open image and does not accept --image");
|
|
165
|
-
}
|
|
166
|
-
console.log(JSON.stringify({ ok: true, ...agentImageStatus(projectRoot) }, null, 2));
|
|
167
|
-
return;
|
|
168
|
-
}
|
|
169
|
-
if (command === "mcp") {
|
|
170
|
-
requireNoArguments(command, commandArgs);
|
|
171
|
-
await serveImagesMcp(projectRoot, image);
|
|
172
|
-
return;
|
|
173
|
-
}
|
|
174
190
|
if (command === "pharo") {
|
|
175
191
|
const [toolName, argumentsJson, ...extra] = commandArgs;
|
|
176
192
|
if (toolName === "list") {
|
|
177
193
|
if (argumentsJson || extra.length > 0) throw new Error("pharo list does not accept arguments");
|
|
178
194
|
console.log(JSON.stringify(
|
|
179
|
-
await listImageTools(projectRoot,
|
|
195
|
+
await listImageTools(projectRoot, readRunningImage(projectRoot, image)),
|
|
180
196
|
null,
|
|
181
197
|
2,
|
|
182
198
|
));
|
|
@@ -187,7 +203,7 @@ export async function main(args) {
|
|
|
187
203
|
}
|
|
188
204
|
const result = await callImageTool(
|
|
189
205
|
projectRoot,
|
|
190
|
-
|
|
206
|
+
readRunningImage(projectRoot, image),
|
|
191
207
|
toolName,
|
|
192
208
|
parseToolArguments(argumentsJson),
|
|
193
209
|
);
|
package/src/gateway.js
CHANGED
|
@@ -1,14 +1,7 @@
|
|
|
1
1
|
import { createRequire } from "node:module";
|
|
2
2
|
import { PlexusGateway } from "@evref-bl/plexus/gateway";
|
|
3
|
-
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
4
|
-
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
5
|
-
import {
|
|
6
|
-
CallToolRequestSchema,
|
|
7
|
-
ListToolsRequestSchema,
|
|
8
|
-
} from "@modelcontextprotocol/sdk/types.js";
|
|
9
3
|
import {
|
|
10
4
|
readJson,
|
|
11
|
-
readOpenImage,
|
|
12
5
|
selectRunningImage,
|
|
13
6
|
unappliedPharoMcpPin,
|
|
14
7
|
} from "./runtime.js";
|
|
@@ -20,15 +13,15 @@ export const { version } = createRequire(import.meta.url)("../package.json");
|
|
|
20
13
|
// A route source is the directory, not the route. PLexus re-reads the state
|
|
21
14
|
// file and the image's endpoint handoff every time it resolves one, so there
|
|
22
15
|
// is nothing to register here and nothing that can go stale between the call
|
|
23
|
-
// that
|
|
24
|
-
export function imageGateway(projectRoot,
|
|
25
|
-
const state = readJson(
|
|
26
|
-
const { image, mcpServerId } = selectRunningImage(state,
|
|
27
|
-
if (image.id !==
|
|
28
|
-
throw new Error(`The PLexus image ${
|
|
16
|
+
// that started the image and this one.
|
|
17
|
+
export function imageGateway(projectRoot, running) {
|
|
18
|
+
const state = readJson(running.statePath);
|
|
19
|
+
const { image, mcpServerId } = selectRunningImage(state, running.imageId);
|
|
20
|
+
if (image.id !== running.imageId || mcpServerId !== running.mcpServerId) {
|
|
21
|
+
throw new Error(`The PLexus image ${running.imageId} changed; run pharo-agent image start again.`);
|
|
29
22
|
}
|
|
30
23
|
const gateway = new PlexusGateway({
|
|
31
|
-
routeSources: [{ projectRoot, statePath:
|
|
24
|
+
routeSources: [{ projectRoot, statePath: running.statePath }],
|
|
32
25
|
pharoScope: {
|
|
33
26
|
projectId: state.projectId,
|
|
34
27
|
workspaceId: state.workspaceId,
|
|
@@ -49,8 +42,8 @@ export function toolFailureMessage(error, image) {
|
|
|
49
42
|
return unappliedPin ? `${error}\n${unappliedPin}` : error;
|
|
50
43
|
}
|
|
51
44
|
|
|
52
|
-
export async function callImageTool(projectRoot,
|
|
53
|
-
const { gateway, mcpServerId, image } = imageGateway(projectRoot,
|
|
45
|
+
export async function callImageTool(projectRoot, running, toolName, argumentsValue) {
|
|
46
|
+
const { gateway, mcpServerId, image } = imageGateway(projectRoot, running);
|
|
54
47
|
const result = await gateway.routeToImage({
|
|
55
48
|
mcpServerId,
|
|
56
49
|
toolName,
|
|
@@ -64,8 +57,8 @@ export async function callImageTool(projectRoot, open, toolName, argumentsValue)
|
|
|
64
57
|
);
|
|
65
58
|
}
|
|
66
59
|
|
|
67
|
-
export async function listImageTools(projectRoot,
|
|
68
|
-
const { gateway } = imageGateway(projectRoot,
|
|
60
|
+
export async function listImageTools(projectRoot, running) {
|
|
61
|
+
const { gateway } = imageGateway(projectRoot, running);
|
|
69
62
|
return gateway.refreshPharoTools();
|
|
70
63
|
}
|
|
71
64
|
|
|
@@ -89,28 +82,3 @@ export function toolResult(data) {
|
|
|
89
82
|
}
|
|
90
83
|
return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
|
|
91
84
|
}
|
|
92
|
-
|
|
93
|
-
export async function serveImagesMcp(projectRoot, defaultImage) {
|
|
94
|
-
const defaultOpen = readOpenImage(projectRoot, defaultImage);
|
|
95
|
-
const tools = (await listImageTools(projectRoot, defaultOpen))
|
|
96
|
-
.map((tool) => withImageSelector(tool, defaultImage));
|
|
97
|
-
|
|
98
|
-
const server = new Server(
|
|
99
|
-
{ name: "pharo-agent", version },
|
|
100
|
-
{ capabilities: { tools: {} } },
|
|
101
|
-
);
|
|
102
|
-
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools }));
|
|
103
|
-
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
104
|
-
// Listed tools are the common ones, not the whole catalogue, and the image
|
|
105
|
-
// answers for any tool it holds. Refusing a name this list does not carry
|
|
106
|
-
// would hide tools that tool_search just found, and the image's own error
|
|
107
|
-
// is the better answer for a name nothing holds.
|
|
108
|
-
const { image, ...argumentsValue } = request.params.arguments ?? {};
|
|
109
|
-
const open = readOpenImage(projectRoot, image);
|
|
110
|
-
return toolResult(
|
|
111
|
-
await callImageTool(projectRoot, open, request.params.name, argumentsValue),
|
|
112
|
-
);
|
|
113
|
-
});
|
|
114
|
-
|
|
115
|
-
await server.connect(new StdioServerTransport());
|
|
116
|
-
}
|
package/src/help.js
CHANGED
|
@@ -10,7 +10,8 @@ const commands = {
|
|
|
10
10
|
},
|
|
11
11
|
"image list": {
|
|
12
12
|
usage: "image list",
|
|
13
|
-
description: "List the project's declared images
|
|
13
|
+
description: "List the project's declared images and the default one, with the mount and\n"
|
|
14
|
+
+ "projection port of each image this project has running.",
|
|
14
15
|
example: "image list",
|
|
15
16
|
},
|
|
16
17
|
"image templates": {
|
|
@@ -21,55 +22,52 @@ const commands = {
|
|
|
21
22
|
},
|
|
22
23
|
"image add": {
|
|
23
24
|
usage: "image add <id> --template <name> [--category <name>] [--profile <id>]",
|
|
24
|
-
description: "Declare an image with a required id and template name;
|
|
25
|
+
description: "Declare an image with a required id and template name; image start creates it later.\n"
|
|
25
26
|
+ "Use --category for an ambiguous template name and --profile to choose a launcher profile.\n"
|
|
26
27
|
+ "The first declared image becomes the default.",
|
|
27
28
|
example: "image add dev --template '<template name>'",
|
|
28
29
|
},
|
|
29
30
|
"image use": {
|
|
30
31
|
usage: "image use <id>",
|
|
31
|
-
description: "Make the required declared image the project's default. It may already be
|
|
32
|
+
description: "Make the required declared image the project's default. It may already be running.",
|
|
32
33
|
example: "image use dev",
|
|
33
34
|
},
|
|
34
35
|
"image remove": {
|
|
35
36
|
usage: "image remove <id>",
|
|
36
|
-
description: "Remove the required image declaration.
|
|
37
|
+
description: "Remove the required image declaration. Stop the image first. Its image files are kept.\n"
|
|
37
38
|
+ "Removing the default image clears the default selection.",
|
|
38
39
|
example: "image remove dev",
|
|
39
40
|
},
|
|
40
|
-
|
|
41
|
-
usage: "
|
|
42
|
-
description: "Create if needed and
|
|
43
|
-
+ "to use the project's default image.
|
|
44
|
-
+ "the MCP support range
|
|
45
|
-
example: "
|
|
41
|
+
"image start": {
|
|
42
|
+
usage: "image start [<id>]",
|
|
43
|
+
description: "Create if needed and start an image with its tools and source projection. Omit the id\n"
|
|
44
|
+
+ "to use the project's default image. Images already running stay running. Pharo versions\n"
|
|
45
|
+
+ "outside the MCP support range start without tools or a source projection.",
|
|
46
|
+
example: "image start dev",
|
|
46
47
|
},
|
|
47
|
-
|
|
48
|
-
usage: "
|
|
49
|
-
description: "
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
close: {
|
|
53
|
-
usage: "close [--image <id>]",
|
|
54
|
-
description: "Close the selected image and its source projection. Omitting --image closes all images\n"
|
|
55
|
-
+ "this project opened, rather than only the default image.",
|
|
56
|
-
example: "close --image dev",
|
|
48
|
+
"image stop": {
|
|
49
|
+
usage: "image stop [<id>] [--all]",
|
|
50
|
+
description: "Stop the named image and its source projection, or the project's default image when\n"
|
|
51
|
+
+ "no id is given. Use --all to stop every image this project started.",
|
|
52
|
+
example: "image stop dev",
|
|
57
53
|
},
|
|
58
54
|
mcp: {
|
|
59
55
|
usage: "mcp [--image <id>]",
|
|
60
|
-
description: "
|
|
61
|
-
+ "to use the project's default image.
|
|
56
|
+
description: "Serve this project's images as a command-scoped stdio MCP server. Omit\n"
|
|
57
|
+
+ "--image to use the project's default image. It starts whether or not an image\n"
|
|
58
|
+
+ "is running: image_start starts one and the image's own tools join the list. The\n"
|
|
59
|
+
+ "server exits with this command, and the plugin's .mcp.json starts it for a host.",
|
|
62
60
|
example: "mcp --image dev",
|
|
63
61
|
},
|
|
64
62
|
"pharo list": {
|
|
65
63
|
usage: "pharo list [--image <id>]",
|
|
66
|
-
description: "List
|
|
64
|
+
description: "List a running image's advertised tools. Omit --image to use the project's default image.\n"
|
|
67
65
|
+ "Use tool_search and tool_get to discover and inspect the rest of its catalogue.",
|
|
68
66
|
example: "pharo list",
|
|
69
67
|
},
|
|
70
68
|
pharo: {
|
|
71
69
|
usage: "pharo <tool> [arguments-json] [--image <id>]",
|
|
72
|
-
description: "Call a tool in
|
|
70
|
+
description: "Call a tool in a running image. Arguments must be a JSON object and default to {}.\n"
|
|
73
71
|
+ "Omit --image to use the project's default image. Inspect the tool's schema with tool_get\n"
|
|
74
72
|
+ "before calling it; a failed tool result makes the command exit unsuccessfully.",
|
|
75
73
|
example: 'pharo tool_get \'{"name":"class_search"}\'',
|
package/src/images.js
CHANGED
|
@@ -4,13 +4,15 @@ import {
|
|
|
4
4
|
readProjectConfig,
|
|
5
5
|
writeProjectConfig,
|
|
6
6
|
} from "./project.js";
|
|
7
|
-
import { readConfig,
|
|
7
|
+
import { defaultImageId, readConfig, readRunningImages, writeConfig } from "./runtime.js";
|
|
8
8
|
|
|
9
9
|
export const defaultLoadScript = "pharo/load-mcp.st";
|
|
10
10
|
|
|
11
|
-
function
|
|
12
|
-
if (
|
|
13
|
-
throw new Error(
|
|
11
|
+
function assertNotRunning(projectRoot, imageId, action) {
|
|
12
|
+
if (readRunningImages(projectRoot)[imageId]) {
|
|
13
|
+
throw new Error(
|
|
14
|
+
`Image ${imageId} is running. Run pharo-agent image stop ${imageId} before ${action}.`,
|
|
15
|
+
);
|
|
14
16
|
}
|
|
15
17
|
}
|
|
16
18
|
|
|
@@ -21,19 +23,31 @@ function derivedImageName(projectRoot, imageId) {
|
|
|
21
23
|
|
|
22
24
|
export function listImages(projectRoot) {
|
|
23
25
|
const configured = configuredImages(projectRoot);
|
|
24
|
-
const
|
|
25
|
-
|
|
26
|
-
|
|
26
|
+
const running = readRunningImages(projectRoot);
|
|
27
|
+
let defaultImage;
|
|
28
|
+
try {
|
|
29
|
+
defaultImage = defaultImageId(projectRoot);
|
|
30
|
+
} catch {
|
|
31
|
+
// Several declared and none chosen. The list is how a caller finds that
|
|
32
|
+
// out, so it answers rather than raising the question back at them.
|
|
33
|
+
defaultImage = undefined;
|
|
34
|
+
}
|
|
27
35
|
return {
|
|
28
36
|
...(defaultImage ? { defaultImage } : {}),
|
|
29
|
-
images: configured.map((image) =>
|
|
30
|
-
id
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
+
images: configured.map((image) => {
|
|
38
|
+
// The record repeats the id it is keyed by; everything else in it is
|
|
39
|
+
// runtime detail this list carries when the image is running.
|
|
40
|
+
const { imageId: _keyed, ...runtime } = running[image.id] ?? {};
|
|
41
|
+
return {
|
|
42
|
+
id: image.id,
|
|
43
|
+
imageName: image.imageName,
|
|
44
|
+
isDefault: image.id === defaultImage,
|
|
45
|
+
running: Boolean(running[image.id]),
|
|
46
|
+
...runtime,
|
|
47
|
+
templateName: image.create?.templateName,
|
|
48
|
+
templateCategory: image.create?.templateCategory,
|
|
49
|
+
};
|
|
50
|
+
}),
|
|
37
51
|
};
|
|
38
52
|
}
|
|
39
53
|
|
|
@@ -53,7 +67,6 @@ export function addImage(projectRoot, request) {
|
|
|
53
67
|
const image = {
|
|
54
68
|
id,
|
|
55
69
|
imageName,
|
|
56
|
-
active: true,
|
|
57
70
|
mcp: { loadScript: defaultLoadScript, loadPolicy: "ifMissing" },
|
|
58
71
|
create: {
|
|
59
72
|
kind: "template",
|
|
@@ -92,7 +105,7 @@ export function removeImage(projectRoot, id) {
|
|
|
92
105
|
if (!images.some((image) => image.id === id)) {
|
|
93
106
|
throw new Error(`Image ${id} is not configured.`);
|
|
94
107
|
}
|
|
95
|
-
|
|
108
|
+
assertNotRunning(projectRoot, id, "removing it");
|
|
96
109
|
writeProjectConfig(projectRoot, {
|
|
97
110
|
...config,
|
|
98
111
|
images: images.filter((image) => image.id !== id),
|
package/src/init.js
CHANGED
|
@@ -120,7 +120,7 @@ outside generated/copied files and outside the host guide's managed block.
|
|
|
120
120
|
Refreshing project files does not install a newer package or upgrade image tools.
|
|
121
121
|
|
|
122
122
|
\`plexus.json\` declares this project's images. A freshly initialized
|
|
123
|
-
project declares none; register one before the first \`pharo-agent
|
|
123
|
+
project declares none; register one before the first \`pharo-agent image start\`:
|
|
124
124
|
|
|
125
125
|
\`\`\`sh
|
|
126
126
|
pharo-agent image templates
|
|
@@ -131,16 +131,20 @@ pharo-agent image add dev --template '<launcher template name>'
|
|
|
131
131
|
\`image templates\` names the templates the launcher offers, with the category
|
|
132
132
|
of each; a name two categories offer also needs \`--category <name>\`. Any
|
|
133
133
|
version the launcher offers can be registered, and one outside the MCP range
|
|
134
|
-
|
|
134
|
+
starts without MCP tools or a source projection, saying so.
|
|
135
135
|
|
|
136
136
|
\`image add\` makes the first registered image the default. \`pharo-agent image
|
|
137
137
|
use <id>\` changes the default, and \`pharo-agent image remove <id>\` drops a
|
|
138
|
-
declaration.
|
|
138
|
+
declaration. Stop an image before removing its declaration.
|
|
139
139
|
|
|
140
|
-
Use \`pharo-agent
|
|
141
|
-
\`.pharo-agent/mounts\`.
|
|
142
|
-
|
|
143
|
-
|
|
140
|
+
Use \`pharo-agent image start\` before live-image work and edit projected source
|
|
141
|
+
under \`.pharo-agent/mounts\`.
|
|
142
|
+
|
|
143
|
+
With the pharo-agent plugin enabled for this project, the image's tools are MCP
|
|
144
|
+
tools: start an image with \`image_start\` and call the rest by name. Without it,
|
|
145
|
+
call a tool with \`pharo-agent pharo <tool> [arguments-json]\`, and
|
|
146
|
+
\`pharo-agent mcp\` serves the same surface as a command-scoped stdio MCP
|
|
147
|
+
server.
|
|
144
148
|
|
|
145
149
|
\`pharo-agent pharo list\` names the tools the image advertises, which are the
|
|
146
150
|
common ones rather than all of it. Find the rest through the catalogue rather
|
|
@@ -152,11 +156,11 @@ pharo-agent pharo tool_get '{"name":"repository_commit"}'
|
|
|
152
156
|
pharo-agent pharo repository_commit '{"name":"MCP","message":"..."}'
|
|
153
157
|
\`\`\`
|
|
154
158
|
|
|
155
|
-
Several images can
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
\`pharo-agent
|
|
159
|
-
|
|
159
|
+
Several images can run at once. \`pharo-agent image start <id>\` and
|
|
160
|
+
\`pharo-agent image stop <id>\` name their image, \`pharo\` and \`mcp\` take
|
|
161
|
+
\`--image <id>\`, and without either the default image answers.
|
|
162
|
+
\`pharo-agent image list\` says which are running, and
|
|
163
|
+
\`pharo-agent image stop --all\` stops every one this project started.
|
|
160
164
|
|
|
161
165
|
Image, Tonel, Iceberg, disk Git, and host processes are separate states.
|
|
162
166
|
|
package/src/live.js
CHANGED
|
@@ -11,19 +11,18 @@ import {
|
|
|
11
11
|
import { callImageTool } from "./gateway.js";
|
|
12
12
|
import { readProjectConfig, writeProjectConfig } from "./project.js";
|
|
13
13
|
import {
|
|
14
|
-
defaultImageId,
|
|
15
|
-
openedImageIsAlive,
|
|
16
14
|
readConfig,
|
|
17
|
-
|
|
15
|
+
readRunningImages,
|
|
16
|
+
recordedImageIsRunning,
|
|
18
17
|
resolveImageId,
|
|
19
18
|
resolveMountPath,
|
|
20
19
|
selectRunningImage,
|
|
21
20
|
smalltalkString,
|
|
22
21
|
unappliedPharoMcpPin,
|
|
23
|
-
|
|
22
|
+
writeRunningImages,
|
|
24
23
|
} from "./runtime.js";
|
|
25
24
|
|
|
26
|
-
export const pifsRelease = "v0.9.
|
|
25
|
+
export const pifsRelease = "v0.9.2";
|
|
27
26
|
|
|
28
27
|
// The class-side PIFSServer messages this plugin sends into the image. The
|
|
29
28
|
// release automation checks that a published PIFS still implements them: a pin
|
|
@@ -85,17 +84,25 @@ export function resultPrintString(result) {
|
|
|
85
84
|
return namedPrintString(result) ?? textPartText(result);
|
|
86
85
|
}
|
|
87
86
|
|
|
87
|
+
/**
|
|
88
|
+
* The status is spelled out rather than taken as a string: this read branched
|
|
89
|
+
* for a long time on "declared" and "failed", which PLexus does not report,
|
|
90
|
+
* so a declared image was never created and the launch that followed failed
|
|
91
|
+
* on an image that was not there. Naming the four it does report makes the
|
|
92
|
+
* typecheck reject a fifth.
|
|
93
|
+
*
|
|
94
|
+
* @param {{ images: { imageId: string,
|
|
95
|
+
* status: "absent" | "stopped" | "starting" | "running" }[] }} listed
|
|
96
|
+
* @param {string} imageId
|
|
97
|
+
*/
|
|
88
98
|
export function imageCreationPlan(listed, imageId) {
|
|
89
99
|
const summary = listed.images.find((image) => image.imageId === imageId);
|
|
90
100
|
if (!summary) {
|
|
91
101
|
throw new Error(`PLexus does not know image ${imageId}.`);
|
|
92
102
|
}
|
|
93
|
-
// PLexus
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
// fresh declaration of the same id, so the id would be spent for good. Clear
|
|
97
|
-
// it and create again.
|
|
98
|
-
if (summary.status === "failed") return "reset";
|
|
103
|
+
// "absent" is PLexus's word for no image file on disk, which is what a
|
|
104
|
+
// declared image looks like until something creates it.
|
|
105
|
+
if (summary.status === "absent") return "create";
|
|
99
106
|
return "none";
|
|
100
107
|
}
|
|
101
108
|
|
|
@@ -185,70 +192,69 @@ export function fetchPifsRelease(cloneDirectory, release = pifsRelease) {
|
|
|
185
192
|
return git("fetch", "--tags", "--quiet", "origin");
|
|
186
193
|
}
|
|
187
194
|
|
|
188
|
-
export async function
|
|
195
|
+
export async function startAgentImage(projectRoot, imageId) {
|
|
189
196
|
const resolved = resolveImageId(projectRoot, imageId);
|
|
190
|
-
let
|
|
197
|
+
let runningImages = readRunningImages(projectRoot);
|
|
191
198
|
let restarted = false;
|
|
192
|
-
if (
|
|
193
|
-
if (
|
|
194
|
-
return {
|
|
199
|
+
if (runningImages[resolved]) {
|
|
200
|
+
if (recordedImageIsRunning(runningImages[resolved])) {
|
|
201
|
+
return { alreadyRunning: true, ...runningImages[resolved] };
|
|
195
202
|
}
|
|
196
|
-
// The image this record names has exited. Forget it and
|
|
197
|
-
// rather than hand back an endpoint nothing answers and
|
|
198
|
-
const { [resolved]: exited, ...remaining } =
|
|
199
|
-
|
|
200
|
-
|
|
203
|
+
// The image this record names has exited. Forget it and start again,
|
|
204
|
+
// rather than hand back an endpoint nothing answers and call it running.
|
|
205
|
+
const { [resolved]: exited, ...remaining } = runningImages;
|
|
206
|
+
runningImages = remaining;
|
|
207
|
+
writeRunningImages(projectRoot, runningImages);
|
|
201
208
|
restarted = true;
|
|
202
209
|
}
|
|
203
210
|
fetchPifsRelease(pifsCloneDirectory(projectRoot));
|
|
204
211
|
const launcher = new ScopedPharoLauncher({ projectRoot });
|
|
205
|
-
const plan = imageCreationPlan(launcher.listImages(), resolved);
|
|
212
|
+
const plan = imageCreationPlan(await launcher.listImages(), resolved);
|
|
206
213
|
const created = plan !== "none";
|
|
207
214
|
if (created) {
|
|
208
|
-
if (plan === "reset") await launcher.resetImage(resolved);
|
|
209
215
|
await resolveTemplateCategory(projectRoot, launcher, resolved);
|
|
210
216
|
await launcher.createImage(resolved);
|
|
211
217
|
}
|
|
212
|
-
const
|
|
218
|
+
const started = await runLifecycle(
|
|
213
219
|
() => startProjectImage({ projectRoot, imageId: resolved }),
|
|
214
|
-
`PLexus could not
|
|
220
|
+
`PLexus could not start image ${resolved}.`,
|
|
215
221
|
);
|
|
216
222
|
let image;
|
|
217
223
|
let mcpServerId;
|
|
218
224
|
try {
|
|
219
|
-
({ image, mcpServerId } = selectRunningImage(
|
|
225
|
+
({ image, mcpServerId } = selectRunningImage(started.state, resolved));
|
|
220
226
|
} catch (error) {
|
|
221
227
|
// An image whose Pharo version is outside the MCP contract still runs, and
|
|
222
228
|
// is still worth having: the caller asked for that version, and the tools
|
|
223
229
|
// it cannot offer are the only thing missing. Keep it, and say what is gone.
|
|
224
|
-
const withoutMcp = unsupportedRunningImage(
|
|
230
|
+
const withoutMcp = unsupportedRunningImage(started.state, resolved);
|
|
225
231
|
if (!withoutMcp) {
|
|
226
232
|
// The image is running but unusable to us, so do not leave it behind.
|
|
227
233
|
await stopProjectImage({ projectRoot, imageId: resolved }).catch(() => {});
|
|
228
234
|
throw error;
|
|
229
235
|
}
|
|
230
|
-
const
|
|
231
|
-
|
|
232
|
-
return { ...
|
|
236
|
+
const record = { statePath: started.statePath, imageId: resolved, mcp: false };
|
|
237
|
+
writeRunningImages(projectRoot, { ...runningImages, [resolved]: record });
|
|
238
|
+
return { ...record, created, warning: pharoMcpWarning(withoutMcp) };
|
|
233
239
|
}
|
|
234
240
|
const mountPath = resolveMountPath(projectRoot, readConfig(projectRoot), image.id);
|
|
235
241
|
fs.mkdirSync(path.dirname(mountPath), { recursive: true });
|
|
236
|
-
const
|
|
237
|
-
statePath:
|
|
242
|
+
const record = {
|
|
243
|
+
statePath: started.statePath,
|
|
238
244
|
imageId: image.id,
|
|
239
245
|
mcpServerId,
|
|
240
246
|
mountPath,
|
|
241
247
|
};
|
|
242
248
|
let projectionPort;
|
|
243
249
|
try {
|
|
244
|
-
projectionPort = await startProjection(projectRoot,
|
|
250
|
+
projectionPort = await startProjection(projectRoot, record, image.imageName);
|
|
245
251
|
} catch (error) {
|
|
246
252
|
// The projection is half-started at best and nothing records it yet, so
|
|
247
|
-
//
|
|
253
|
+
// stop the image rather than leave an image and a mount nothing owns.
|
|
248
254
|
await stopProjectImage({ projectRoot, imageId: resolved }).catch(() => {});
|
|
249
255
|
throw error;
|
|
250
256
|
}
|
|
251
|
-
|
|
257
|
+
writeRunningImages(projectRoot, { ...runningImages, [resolved]: { ...record, projectionPort } });
|
|
252
258
|
const unappliedPin = unappliedPharoMcpPin(image);
|
|
253
259
|
return {
|
|
254
260
|
imageId: image.id,
|
|
@@ -270,13 +276,13 @@ function unsupportedRunningImage(state, imageId) {
|
|
|
270
276
|
export function pharoMcpWarning(image) {
|
|
271
277
|
const reason = image.pharoMcpContract?.reason
|
|
272
278
|
?? "This image Pharo version is outside the supported Pharo MCP range.";
|
|
273
|
-
return `${reason} Image ${image.id} is
|
|
279
|
+
return `${reason} Image ${image.id} is running without MCP tools or a source projection;`
|
|
274
280
|
+ " drive it with the Pharo command line instead.";
|
|
275
281
|
}
|
|
276
282
|
|
|
277
|
-
async function startProjection(projectRoot,
|
|
278
|
-
const result = await callImageTool(projectRoot,
|
|
279
|
-
code: pifsStartCode(
|
|
283
|
+
async function startProjection(projectRoot, record, imageName) {
|
|
284
|
+
const result = await callImageTool(projectRoot, record, "image_evaluate", {
|
|
285
|
+
code: pifsStartCode(record.mountPath, imageName),
|
|
280
286
|
});
|
|
281
287
|
const projectionPort = Number(resultPrintString(result));
|
|
282
288
|
if (!Number.isInteger(projectionPort) || projectionPort < 1) {
|
|
@@ -287,76 +293,73 @@ async function startProjection(projectRoot, open, imageName) {
|
|
|
287
293
|
return projectionPort;
|
|
288
294
|
}
|
|
289
295
|
|
|
290
|
-
async function
|
|
291
|
-
const
|
|
296
|
+
async function stopOneImage(projectRoot, runningImages, imageId) {
|
|
297
|
+
const record = runningImages[imageId];
|
|
292
298
|
let projectionError;
|
|
293
|
-
// An image
|
|
299
|
+
// An image started without MCP has no projection to stop, and no route to
|
|
294
300
|
// ask over either.
|
|
295
|
-
if (
|
|
301
|
+
if (record.projectionPort !== undefined) {
|
|
296
302
|
try {
|
|
297
|
-
await callImageTool(projectRoot,
|
|
298
|
-
code: pifsStopCode(
|
|
303
|
+
await callImageTool(projectRoot, record, "image_evaluate", {
|
|
304
|
+
code: pifsStopCode(record.projectionPort),
|
|
299
305
|
});
|
|
300
306
|
} catch (error) {
|
|
301
307
|
projectionError = error;
|
|
302
308
|
}
|
|
303
309
|
}
|
|
304
|
-
const
|
|
310
|
+
const stopped = await runLifecycle(
|
|
305
311
|
() => stopProjectImage({ projectRoot, imageId }),
|
|
306
|
-
`PLexus could not
|
|
312
|
+
`PLexus could not stop image ${imageId}.`,
|
|
307
313
|
);
|
|
308
|
-
return {
|
|
314
|
+
return { stoppedImage: stopped.stoppedImage?.id, projectionError };
|
|
309
315
|
}
|
|
310
316
|
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
317
|
+
/**
|
|
318
|
+
* Stop one image, or with `all`, every image this project started. Naming no
|
|
319
|
+
* image stops the default one, as every other command addresses it; `all` is
|
|
320
|
+
* the cleanup, and says so where the caller can see it.
|
|
321
|
+
*
|
|
322
|
+
* @param {string} projectRoot
|
|
323
|
+
* @param {string} [imageId]
|
|
324
|
+
* @param {{ all?: boolean }} [options]
|
|
325
|
+
*/
|
|
326
|
+
export async function stopAgentImage(projectRoot, imageId, options = {}) {
|
|
327
|
+
const runningImages = readRunningImages(projectRoot);
|
|
328
|
+
if (options.all && imageId !== undefined) {
|
|
329
|
+
throw new Error("Stopping every image takes no image id.");
|
|
330
|
+
}
|
|
331
|
+
const identifiers = options.all
|
|
332
|
+
? Object.keys(runningImages)
|
|
315
333
|
: [resolveImageId(projectRoot, imageId)];
|
|
316
334
|
if (identifiers.length === 0) {
|
|
317
|
-
throw new Error("No
|
|
335
|
+
throw new Error("No pharo-agent image is running.");
|
|
318
336
|
}
|
|
319
337
|
for (const identifier of identifiers) {
|
|
320
|
-
if (!
|
|
321
|
-
throw new Error(`Image ${identifier} is not
|
|
338
|
+
if (!runningImages[identifier]) {
|
|
339
|
+
throw new Error(`Image ${identifier} is not running.`);
|
|
322
340
|
}
|
|
323
341
|
}
|
|
324
342
|
|
|
325
343
|
const stoppedImages = [];
|
|
326
344
|
const projectionErrors = [];
|
|
327
|
-
const remaining = { ...
|
|
345
|
+
const remaining = { ...runningImages };
|
|
328
346
|
for (const identifier of identifiers) {
|
|
329
|
-
const
|
|
347
|
+
const stopped = await stopOneImage(projectRoot, runningImages, identifier);
|
|
330
348
|
delete remaining[identifier];
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
349
|
+
writeRunningImages(projectRoot, remaining);
|
|
350
|
+
if (stopped.stoppedImage) {
|
|
351
|
+
stoppedImages.push(stopped.stoppedImage);
|
|
352
|
+
}
|
|
353
|
+
if (stopped.projectionError) {
|
|
354
|
+
projectionErrors.push(`${identifier}: ${stopped.projectionError.message}`);
|
|
335
355
|
}
|
|
336
356
|
}
|
|
337
357
|
if (projectionErrors.length > 0) {
|
|
338
|
-
throw new Error(`Images
|
|
358
|
+
throw new Error(`Images stopped, but PIFS cleanup failed: ${projectionErrors.join("; ")}`);
|
|
339
359
|
}
|
|
340
360
|
return { stoppedImages };
|
|
341
361
|
}
|
|
342
362
|
|
|
343
|
-
export function agentImageStatus(projectRoot) {
|
|
344
|
-
const openImages = readOpenImages(projectRoot);
|
|
345
|
-
let defaultImage;
|
|
346
|
-
try {
|
|
347
|
-
defaultImage = defaultImageId(projectRoot);
|
|
348
|
-
} catch {
|
|
349
|
-
defaultImage = undefined;
|
|
350
|
-
}
|
|
351
|
-
return {
|
|
352
|
-
...(defaultImage ? { defaultImage } : {}),
|
|
353
|
-
images: Object.values(openImages).map((open) => ({
|
|
354
|
-
...open,
|
|
355
|
-
isDefault: open.imageId === defaultImage,
|
|
356
|
-
})),
|
|
357
|
-
};
|
|
358
|
-
}
|
|
359
|
-
|
|
360
363
|
export async function listImageTemplates(projectRoot) {
|
|
361
364
|
// `image add --template` takes a name the launcher must already know, and a
|
|
362
365
|
// name that lands in two categories needs the category as well, so answer
|
package/src/mcpServer.js
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
2
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
3
|
+
import {
|
|
4
|
+
CallToolRequestSchema,
|
|
5
|
+
ListToolsRequestSchema,
|
|
6
|
+
} from "@modelcontextprotocol/sdk/types.js";
|
|
7
|
+
import {
|
|
8
|
+
callImageTool,
|
|
9
|
+
listImageTools,
|
|
10
|
+
toolResult,
|
|
11
|
+
version,
|
|
12
|
+
withImageSelector,
|
|
13
|
+
} from "./gateway.js";
|
|
14
|
+
import { listImages } from "./images.js";
|
|
15
|
+
import { startAgentImage, stopAgentImage } from "./live.js";
|
|
16
|
+
import { readRunningImage } from "./runtime.js";
|
|
17
|
+
|
|
18
|
+
// The host loads only tool names and these instructions at session start and
|
|
19
|
+
// defers the rest, so this is what decides whether Pharo tools are searched
|
|
20
|
+
// for at all. It is the conditional guidance the generated guide gives,
|
|
21
|
+
// written where the host can act on it.
|
|
22
|
+
export const serverInstructions = `Live Pharo images for this project: reading and writing Smalltalk code in the
|
|
23
|
+
image, running tests, finding senders and implementors, applying critiques,
|
|
24
|
+
and projecting image source as files on disk.
|
|
25
|
+
|
|
26
|
+
Search these tools whenever the task involves Pharo, Smalltalk, a Tonel
|
|
27
|
+
package, or a .st file. Start an image with image_start before calling the
|
|
28
|
+
image's own tools, which are listed once an image answers; image_list says
|
|
29
|
+
what is already running.
|
|
30
|
+
|
|
31
|
+
The image advertises the common tools rather than all of it. Use tool_search
|
|
32
|
+
and tool_get to find the rest, then call one by name like any other.`;
|
|
33
|
+
|
|
34
|
+
// Listed whether or not an image is running, so the list is never empty. A
|
|
35
|
+
// host that caches tools and ignores tools/list_changed would otherwise hold
|
|
36
|
+
// an empty list for the whole session and never learn that one had started.
|
|
37
|
+
const lifecycleTools = [
|
|
38
|
+
{
|
|
39
|
+
name: "image_start",
|
|
40
|
+
description:
|
|
41
|
+
"Start one of this project's Pharo images, creating it from its template "
|
|
42
|
+
+ "when it does not exist yet, and mount its source projection. The "
|
|
43
|
+
+ "image's own tools are listed once it answers.",
|
|
44
|
+
inputSchema: {
|
|
45
|
+
type: "object",
|
|
46
|
+
properties: {
|
|
47
|
+
image: {
|
|
48
|
+
type: "string",
|
|
49
|
+
description: "Image id to start. Defaults to the project's default image.",
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
name: "image_list",
|
|
56
|
+
description:
|
|
57
|
+
"List this project's declared images, which are running, and the route "
|
|
58
|
+
+ "and mount of each running one.",
|
|
59
|
+
inputSchema: { type: "object", properties: {} },
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
name: "image_stop",
|
|
63
|
+
description:
|
|
64
|
+
"Stop a running image and the source projection this project started for it.",
|
|
65
|
+
inputSchema: {
|
|
66
|
+
type: "object",
|
|
67
|
+
properties: {
|
|
68
|
+
image: {
|
|
69
|
+
type: "string",
|
|
70
|
+
description: "Image id to stop. Defaults to the project's default image.",
|
|
71
|
+
},
|
|
72
|
+
all: {
|
|
73
|
+
type: "boolean",
|
|
74
|
+
description: "Stop every image this project started, instead of one.",
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
];
|
|
80
|
+
|
|
81
|
+
// Resolved per request rather than once at startup. A host starts this server
|
|
82
|
+
// when its session starts, which is before anyone has started an image, so
|
|
83
|
+
// resolving then meant the server died on its first line.
|
|
84
|
+
async function runningImageTools(projectRoot, defaultImage) {
|
|
85
|
+
let running;
|
|
86
|
+
try {
|
|
87
|
+
running = readRunningImage(projectRoot, defaultImage);
|
|
88
|
+
} catch {
|
|
89
|
+
// No image running, none configured, or one running without MCP. The image
|
|
90
|
+
// contributes no tools yet and the lifecycle tools say how to change that.
|
|
91
|
+
return [];
|
|
92
|
+
}
|
|
93
|
+
return (await listImageTools(projectRoot, running))
|
|
94
|
+
.map((tool) => withImageSelector(tool, running.imageId));
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export async function serveImagesMcp(resolveProjectRoot, defaultImage) {
|
|
98
|
+
const server = new Server(
|
|
99
|
+
{ name: "pharo-agent", version },
|
|
100
|
+
{ capabilities: { tools: { listChanged: true } }, instructions: serverInstructions },
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
104
|
+
let projectRoot;
|
|
105
|
+
try {
|
|
106
|
+
projectRoot = resolveProjectRoot();
|
|
107
|
+
} catch {
|
|
108
|
+
// No project here. The lifecycle tools are still listed rather than
|
|
109
|
+
// nothing, so the host has a server that works and a call can say why.
|
|
110
|
+
return { tools: lifecycleTools };
|
|
111
|
+
}
|
|
112
|
+
return {
|
|
113
|
+
tools: [...lifecycleTools, ...(await runningImageTools(projectRoot, defaultImage))],
|
|
114
|
+
};
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
118
|
+
const { image, all, ...argumentsValue } = request.params.arguments ?? {};
|
|
119
|
+
const selected = image ?? defaultImage;
|
|
120
|
+
// Throws when there is no project, which is the answer the caller needs.
|
|
121
|
+
const projectRoot = resolveProjectRoot();
|
|
122
|
+
|
|
123
|
+
// Matched before the image's catalogue: these three names belong to this
|
|
124
|
+
// server, and an image that happened to hold one must not decide what
|
|
125
|
+
// starting an image means.
|
|
126
|
+
if (request.params.name === "image_start") {
|
|
127
|
+
const started = await startAgentImage(projectRoot, selected);
|
|
128
|
+
await server.sendToolListChanged();
|
|
129
|
+
return toolResult({ ok: true, ...started });
|
|
130
|
+
}
|
|
131
|
+
if (request.params.name === "image_list") {
|
|
132
|
+
return toolResult({ ok: true, ...listImages(projectRoot) });
|
|
133
|
+
}
|
|
134
|
+
if (request.params.name === "image_stop") {
|
|
135
|
+
const stopped = all
|
|
136
|
+
? await stopAgentImage(projectRoot, undefined, { all: true })
|
|
137
|
+
: await stopAgentImage(projectRoot, selected);
|
|
138
|
+
await server.sendToolListChanged();
|
|
139
|
+
return toolResult({ ok: true, ...stopped });
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Listed tools are the common ones, not the whole catalogue, and the image
|
|
143
|
+
// answers for any tool it holds. Refusing a name this list does not carry
|
|
144
|
+
// would hide tools that tool_search just found, and the image's own error
|
|
145
|
+
// is the better answer for a name nothing holds.
|
|
146
|
+
const running = readRunningImage(projectRoot, selected);
|
|
147
|
+
return toolResult(
|
|
148
|
+
await callImageTool(projectRoot, running, request.params.name, argumentsValue),
|
|
149
|
+
);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
await server.connect(new StdioServerTransport());
|
|
153
|
+
}
|
package/src/project.js
CHANGED
|
@@ -3,7 +3,7 @@ import path from "node:path";
|
|
|
3
3
|
|
|
4
4
|
export const projectConfigFileName = "plexus.json";
|
|
5
5
|
|
|
6
|
-
|
|
6
|
+
function projectRootAbove(start) {
|
|
7
7
|
let directory = path.resolve(start);
|
|
8
8
|
|
|
9
9
|
while (true) {
|
|
@@ -13,12 +13,28 @@ export function findProjectRoot(start = process.cwd()) {
|
|
|
13
13
|
|
|
14
14
|
const parent = path.dirname(directory);
|
|
15
15
|
if (parent === directory) {
|
|
16
|
-
|
|
16
|
+
return undefined;
|
|
17
17
|
}
|
|
18
18
|
directory = parent;
|
|
19
19
|
}
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
export function findProjectRoot(start = process.cwd()) {
|
|
23
|
+
const found = projectRootAbove(start);
|
|
24
|
+
if (found) return found;
|
|
25
|
+
|
|
26
|
+
// A plugin's MCP server is started by the host, in the plugin's own
|
|
27
|
+
// directory rather than the project's, so the working directory leads
|
|
28
|
+
// nowhere. The host says where the project is, and that is only consulted
|
|
29
|
+
// here, after the ordinary search has failed, so running the CLI inside a
|
|
30
|
+
// project still answers with that project.
|
|
31
|
+
const hosted = process.env.CLAUDE_PROJECT_DIR;
|
|
32
|
+
const hostedRoot = hosted ? projectRootAbove(hosted) : undefined;
|
|
33
|
+
if (hostedRoot) return hostedRoot;
|
|
34
|
+
|
|
35
|
+
throw new Error("No plexus.json found above the current directory");
|
|
36
|
+
}
|
|
37
|
+
|
|
22
38
|
export function enclosingProjectRoot(start) {
|
|
23
39
|
const directory = path.resolve(start);
|
|
24
40
|
const parent = path.dirname(directory);
|
package/src/runtime.js
CHANGED
|
@@ -6,8 +6,8 @@ export function runtimeDirectory(projectRoot) {
|
|
|
6
6
|
return path.join(pluginDirectory(projectRoot), "runtime");
|
|
7
7
|
}
|
|
8
8
|
|
|
9
|
-
export function
|
|
10
|
-
return path.join(runtimeDirectory(projectRoot), "
|
|
9
|
+
export function runningImagesPath(projectRoot) {
|
|
10
|
+
return path.join(runtimeDirectory(projectRoot), "running.json");
|
|
11
11
|
}
|
|
12
12
|
|
|
13
13
|
export function readJson(filePath) {
|
|
@@ -30,23 +30,22 @@ export function writeConfig(projectRoot, config) {
|
|
|
30
30
|
fs.writeFileSync(configPath(projectRoot), `${JSON.stringify(config, null, 2)}\n`);
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
-
export function
|
|
34
|
-
const filePath =
|
|
33
|
+
export function readRunningImages(projectRoot) {
|
|
34
|
+
const filePath = runningImagesPath(projectRoot);
|
|
35
35
|
if (!fs.existsSync(filePath)) return {};
|
|
36
36
|
const recorded = readJson(filePath);
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
return typeof recorded?.imageId === "string" ? { [recorded.imageId]: recorded } : {};
|
|
37
|
+
return recorded && typeof recorded.images === "object" && recorded.images !== null
|
|
38
|
+
? recorded.images
|
|
39
|
+
: {};
|
|
41
40
|
}
|
|
42
41
|
|
|
43
|
-
export function
|
|
42
|
+
export function writeRunningImages(projectRoot, images) {
|
|
44
43
|
if (Object.keys(images).length === 0) {
|
|
45
|
-
fs.rmSync(
|
|
44
|
+
fs.rmSync(runningImagesPath(projectRoot), { force: true });
|
|
46
45
|
return;
|
|
47
46
|
}
|
|
48
47
|
fs.mkdirSync(runtimeDirectory(projectRoot), { recursive: true });
|
|
49
|
-
fs.writeFileSync(
|
|
48
|
+
fs.writeFileSync(runningImagesPath(projectRoot), `${JSON.stringify({ images }, null, 2)}\n`);
|
|
50
49
|
}
|
|
51
50
|
|
|
52
51
|
export function defaultImageId(projectRoot) {
|
|
@@ -70,7 +69,7 @@ export function defaultImageId(projectRoot) {
|
|
|
70
69
|
}
|
|
71
70
|
throw new Error(
|
|
72
71
|
`Several images are configured (${images.map((image) => image.id).join(", ")}); `
|
|
73
|
-
+ "choose one with pharo-agent image use <id>, or
|
|
72
|
+
+ "choose one with pharo-agent image use <id>, or name one.",
|
|
74
73
|
);
|
|
75
74
|
}
|
|
76
75
|
|
|
@@ -82,26 +81,26 @@ export function resolveImageId(projectRoot, imageId) {
|
|
|
82
81
|
return imageId;
|
|
83
82
|
}
|
|
84
83
|
|
|
85
|
-
export function
|
|
84
|
+
export function readRunningImage(projectRoot, imageId) {
|
|
86
85
|
const resolved = resolveImageId(projectRoot, imageId);
|
|
87
|
-
const
|
|
88
|
-
if (!
|
|
86
|
+
const running = readRunningImages(projectRoot)[resolved];
|
|
87
|
+
if (!running) {
|
|
89
88
|
throw new Error(
|
|
90
|
-
`Image ${resolved} is not
|
|
89
|
+
`Image ${resolved} is not running. Run pharo-agent image start ${resolved} first.`,
|
|
91
90
|
);
|
|
92
91
|
}
|
|
93
|
-
if (
|
|
92
|
+
if (running.mcp === false) {
|
|
94
93
|
throw new Error(
|
|
95
|
-
`Image ${resolved} is
|
|
94
|
+
`Image ${resolved} is running without MCP, so it offers no Pharo tools. `
|
|
96
95
|
+ "Register an image on a Pharo version the MCP contract supports to use them.",
|
|
97
96
|
);
|
|
98
97
|
}
|
|
99
|
-
return
|
|
98
|
+
return running;
|
|
100
99
|
}
|
|
101
100
|
|
|
102
|
-
// A record in the project's runtime state says this project
|
|
101
|
+
// A record in the project's runtime state says this project started the image,
|
|
103
102
|
// not that the image is still there. PLexus reclaims a dead image's port and
|
|
104
|
-
// relaunches on the next
|
|
103
|
+
// relaunches on the next start, so the only thing standing between a crashed
|
|
105
104
|
// image and a working one is believing the record.
|
|
106
105
|
export function imageProcessIsAlive(pid) {
|
|
107
106
|
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
@@ -114,14 +113,14 @@ export function imageProcessIsAlive(pid) {
|
|
|
114
113
|
}
|
|
115
114
|
}
|
|
116
115
|
|
|
117
|
-
export function
|
|
116
|
+
export function recordedImageIsRunning(record) {
|
|
118
117
|
let state;
|
|
119
118
|
try {
|
|
120
|
-
state = readJson(
|
|
119
|
+
state = readJson(record.statePath);
|
|
121
120
|
} catch {
|
|
122
121
|
return false;
|
|
123
122
|
}
|
|
124
|
-
const image = (state.images ?? []).find((candidate) => candidate.id ===
|
|
123
|
+
const image = (state.images ?? []).find((candidate) => candidate.id === record.imageId);
|
|
125
124
|
return image?.status === "running" && imageProcessIsAlive(image.pid);
|
|
126
125
|
}
|
|
127
126
|
|
|
@@ -164,7 +163,7 @@ export function selectRunningImage(state, imageId) {
|
|
|
164
163
|
// written against. PLexus records it; this says it.
|
|
165
164
|
export function unappliedPharoMcpPin(image) {
|
|
166
165
|
if (image?.pharoMcpLoad?.state !== "provided") return undefined;
|
|
167
|
-
return `Image ${image.id}
|
|
166
|
+
return `Image ${image.id} started with the Pharo MCP it already had, so the version this project pins`
|
|
168
167
|
+ " was not loaded and its tools may differ from the ones this project expects."
|
|
169
168
|
+ " Recreate the image, or set mcp.loadPolicy to always.";
|
|
170
169
|
}
|