@evref-bl/pharo-agent 0.10.2 → 0.12.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 +5 -3
- package/package.json +3 -2
- package/plugin.json +1 -1
- package/skills/pharo-setup/SKILL.md +27 -4
- package/src/cli.js +57 -34
- package/src/gateway.js +15 -44
- package/src/help.js +23 -24
- package/src/images.js +55 -59
- package/src/init.js +18 -12
- package/src/live.js +146 -124
- package/src/mcpServer.js +154 -0
- package/src/project.js +37 -29
- package/src/runtime.js +27 -30
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,22 @@ 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
|
-
image
|
|
140
|
+
Use \`pharo-agent image start\` before live-image work. On macOS with fuse-t
|
|
141
|
+
installed the image's source is also projected under \`.pharo-agent/mounts\`
|
|
142
|
+
and can be edited as files; everywhere else the Pharo tools are the way in,
|
|
143
|
+
and \`image start\` says so.
|
|
144
|
+
|
|
145
|
+
With the pharo-agent plugin enabled for this project, the image's tools are MCP
|
|
146
|
+
tools: start an image with \`image_start\` and call the rest by name. Without it,
|
|
147
|
+
call a tool with \`pharo-agent pharo <tool> [arguments-json]\`, and
|
|
148
|
+
\`pharo-agent mcp\` serves the same surface as a command-scoped stdio MCP
|
|
149
|
+
server.
|
|
144
150
|
|
|
145
151
|
\`pharo-agent pharo list\` names the tools the image advertises, which are the
|
|
146
152
|
common ones rather than all of it. Find the rest through the catalogue rather
|
|
@@ -152,11 +158,11 @@ pharo-agent pharo tool_get '{"name":"repository_commit"}'
|
|
|
152
158
|
pharo-agent pharo repository_commit '{"name":"MCP","message":"..."}'
|
|
153
159
|
\`\`\`
|
|
154
160
|
|
|
155
|
-
Several images can
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
\`pharo-agent
|
|
159
|
-
|
|
161
|
+
Several images can run at once. \`pharo-agent image start <id>\` and
|
|
162
|
+
\`pharo-agent image stop <id>\` name their image, \`pharo\` and \`mcp\` take
|
|
163
|
+
\`--image <id>\`, and without either the default image answers.
|
|
164
|
+
\`pharo-agent image list\` says which are running, and
|
|
165
|
+
\`pharo-agent image stop --all\` stops every one this project started.
|
|
160
166
|
|
|
161
167
|
Image, Tonel, Iceberg, disk Git, and host processes are separate states.
|
|
162
168
|
|
package/src/live.js
CHANGED
|
@@ -9,22 +9,63 @@ import {
|
|
|
9
9
|
stopProjectImage,
|
|
10
10
|
} from "@evref-bl/plexus";
|
|
11
11
|
import { callImageTool } from "./gateway.js";
|
|
12
|
-
import { readProjectConfig
|
|
12
|
+
import { readProjectConfig } 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
25
|
export const pifsRelease = "v0.9.2";
|
|
27
26
|
|
|
27
|
+
// fuse-t ships as a macOS installer package, so it lands in /usr/local
|
|
28
|
+
// whatever Homebrew's prefix is. The unversioned symlink is what the daemon
|
|
29
|
+
// links against.
|
|
30
|
+
export const fuseLibraryPath = "/usr/local/lib/libfuse-t.dylib";
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Whether a source projection can be mounted here.
|
|
34
|
+
*
|
|
35
|
+
* PIFS needs macOS and fuse-t, which is a kernel-adjacent install the user
|
|
36
|
+
* has to approve. PLexus and the Pharo MCP run anywhere, so until this was
|
|
37
|
+
* checked, `image start` mounted unconditionally and then stopped the image
|
|
38
|
+
* when the mount failed -- every image on Linux and Windows, and on any Mac
|
|
39
|
+
* without fuse-t. The image and its tools were never the part that could not
|
|
40
|
+
* work.
|
|
41
|
+
*
|
|
42
|
+
* @param {{ platform?: string, exists?: (path: string) => boolean }} [host]
|
|
43
|
+
*/
|
|
44
|
+
export function projectionAvailability(host = {}) {
|
|
45
|
+
const platform = host.platform ?? process.platform;
|
|
46
|
+
const exists = host.exists ?? fs.existsSync;
|
|
47
|
+
if (platform !== "darwin") {
|
|
48
|
+
return {
|
|
49
|
+
available: false,
|
|
50
|
+
reason: "A source projection needs macOS and fuse-t, which this platform"
|
|
51
|
+
+ ` (${platform}) cannot provide.`,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
if (!exists(fuseLibraryPath)) {
|
|
55
|
+
return {
|
|
56
|
+
available: false,
|
|
57
|
+
reason: "A source projection needs fuse-t, which is not installed."
|
|
58
|
+
+ " Install it with `brew install --cask fuse-t`.",
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
return { available: true };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function projectionUnavailableWarning(imageId, reason) {
|
|
65
|
+
return `${reason} Image ${imageId} is running with its Pharo tools and no`
|
|
66
|
+
+ " source projection; read and edit its code through the tools.";
|
|
67
|
+
}
|
|
68
|
+
|
|
28
69
|
// The class-side PIFSServer messages this plugin sends into the image. The
|
|
29
70
|
// release automation checks that a published PIFS still implements them: a pin
|
|
30
71
|
// bump that leaves a removed selector in place passes every test here, because
|
|
@@ -85,62 +126,28 @@ export function resultPrintString(result) {
|
|
|
85
126
|
return namedPrintString(result) ?? textPartText(result);
|
|
86
127
|
}
|
|
87
128
|
|
|
129
|
+
/**
|
|
130
|
+
* The status is spelled out rather than taken as a string: this read branched
|
|
131
|
+
* for a long time on "declared" and "failed", which PLexus does not report,
|
|
132
|
+
* so a declared image was never created and the launch that followed failed
|
|
133
|
+
* on an image that was not there. Naming the four it does report makes the
|
|
134
|
+
* typecheck reject a fifth.
|
|
135
|
+
*
|
|
136
|
+
* @param {{ images: { imageId: string,
|
|
137
|
+
* status: "absent" | "stopped" | "starting" | "running" }[] }} listed
|
|
138
|
+
* @param {string} imageId
|
|
139
|
+
*/
|
|
88
140
|
export function imageCreationPlan(listed, imageId) {
|
|
89
141
|
const summary = listed.images.find((image) => image.imageId === imageId);
|
|
90
142
|
if (!summary) {
|
|
91
143
|
throw new Error(`PLexus does not know image ${imageId}.`);
|
|
92
144
|
}
|
|
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";
|
|
145
|
+
// "absent" is PLexus's word for no image file on disk, which is what a
|
|
146
|
+
// declared image looks like until something creates it.
|
|
147
|
+
if (summary.status === "absent") return "create";
|
|
99
148
|
return "none";
|
|
100
149
|
}
|
|
101
150
|
|
|
102
|
-
// The launcher resolves a template name against its default group only, so an
|
|
103
|
-
// image naming a template from any other group cannot be created until the
|
|
104
|
-
// project config says which group that is. Find it once, on first creation.
|
|
105
|
-
export async function resolveTemplateCategory(projectRoot, launcher, imageId) {
|
|
106
|
-
const config = readProjectConfig(projectRoot);
|
|
107
|
-
const images = Array.isArray(config.images) ? config.images : [];
|
|
108
|
-
const configured = images.find((image) => image.id === imageId);
|
|
109
|
-
const create = configured?.create;
|
|
110
|
-
if (!create?.templateName || create.templateCategory) {
|
|
111
|
-
return create?.templateCategory;
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
const listed = await launcher.listTemplates();
|
|
115
|
-
const templates = Array.isArray(listed?.templates) ? listed.templates : [];
|
|
116
|
-
const categories = [
|
|
117
|
-
...new Set(
|
|
118
|
-
templates
|
|
119
|
-
.filter((template) => template?.name === create.templateName)
|
|
120
|
-
.map((template) => template?.templateCategory)
|
|
121
|
-
.filter(Boolean),
|
|
122
|
-
),
|
|
123
|
-
];
|
|
124
|
-
if (categories.length === 0) {
|
|
125
|
-
throw new Error(`No template named ${create.templateName} in any category.`);
|
|
126
|
-
}
|
|
127
|
-
if (categories.length > 1) {
|
|
128
|
-
throw new Error(
|
|
129
|
-
`Template ${create.templateName} exists in several categories: ${categories.join(", ")}.`
|
|
130
|
-
+ ` Set create.templateCategory for image ${imageId} to choose one.`,
|
|
131
|
-
);
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
const [templateCategory] = categories;
|
|
135
|
-
writeProjectConfig(projectRoot, {
|
|
136
|
-
...config,
|
|
137
|
-
images: images.map((image) => (image.id === imageId
|
|
138
|
-
? { ...image, create: { ...image.create, templateCategory } }
|
|
139
|
-
: image)),
|
|
140
|
-
});
|
|
141
|
-
return templateCategory;
|
|
142
|
-
}
|
|
143
|
-
|
|
144
151
|
export function describeLifecycleFailure(failure) {
|
|
145
152
|
const stderr = failure.launcherResult?.raw?.stderr?.trim();
|
|
146
153
|
return [
|
|
@@ -185,71 +192,89 @@ 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
|
+
if (projectionAvailability().available) {
|
|
211
|
+
fetchPifsRelease(pifsCloneDirectory(projectRoot));
|
|
212
|
+
}
|
|
204
213
|
const launcher = new ScopedPharoLauncher({ projectRoot });
|
|
205
|
-
const plan = imageCreationPlan(launcher.listImages(), resolved);
|
|
214
|
+
const plan = imageCreationPlan(await launcher.listImages(), resolved);
|
|
206
215
|
const created = plan !== "none";
|
|
207
216
|
if (created) {
|
|
208
|
-
|
|
209
|
-
await resolveTemplateCategory(projectRoot, launcher, resolved);
|
|
217
|
+
// PLexus resolves which category the template lives in.
|
|
210
218
|
await launcher.createImage(resolved);
|
|
211
219
|
}
|
|
212
|
-
const
|
|
220
|
+
const started = await runLifecycle(
|
|
213
221
|
() => startProjectImage({ projectRoot, imageId: resolved }),
|
|
214
|
-
`PLexus could not
|
|
222
|
+
`PLexus could not start image ${resolved}.`,
|
|
215
223
|
);
|
|
216
224
|
let image;
|
|
217
225
|
let mcpServerId;
|
|
218
226
|
try {
|
|
219
|
-
({ image, mcpServerId } = selectRunningImage(
|
|
227
|
+
({ image, mcpServerId } = selectRunningImage(started.state, resolved));
|
|
220
228
|
} catch (error) {
|
|
221
229
|
// An image whose Pharo version is outside the MCP contract still runs, and
|
|
222
230
|
// is still worth having: the caller asked for that version, and the tools
|
|
223
231
|
// it cannot offer are the only thing missing. Keep it, and say what is gone.
|
|
224
|
-
const withoutMcp = unsupportedRunningImage(
|
|
232
|
+
const withoutMcp = unsupportedRunningImage(started.state, resolved);
|
|
225
233
|
if (!withoutMcp) {
|
|
226
234
|
// The image is running but unusable to us, so do not leave it behind.
|
|
227
235
|
await stopProjectImage({ projectRoot, imageId: resolved }).catch(() => {});
|
|
228
236
|
throw error;
|
|
229
237
|
}
|
|
230
|
-
const
|
|
231
|
-
|
|
232
|
-
return { ...
|
|
238
|
+
const record = { statePath: started.statePath, imageId: resolved, mcp: false };
|
|
239
|
+
writeRunningImages(projectRoot, { ...runningImages, [resolved]: record });
|
|
240
|
+
return { ...record, created, warning: pharoMcpWarning(withoutMcp) };
|
|
241
|
+
}
|
|
242
|
+
const unappliedPin = unappliedPharoMcpPin(image);
|
|
243
|
+
const projection = projectionAvailability();
|
|
244
|
+
if (!projection.available) {
|
|
245
|
+
// The image and its tools are the point; the projection is the extra this
|
|
246
|
+
// package bundles where the platform allows it.
|
|
247
|
+
const record = { statePath: started.statePath, imageId: image.id, mcpServerId };
|
|
248
|
+
writeRunningImages(projectRoot, { ...runningImages, [resolved]: record });
|
|
249
|
+
return {
|
|
250
|
+
imageId: image.id,
|
|
251
|
+
created,
|
|
252
|
+
...(restarted ? { restarted } : {}),
|
|
253
|
+
warning: [
|
|
254
|
+
projectionUnavailableWarning(image.id, projection.reason),
|
|
255
|
+
unappliedPin,
|
|
256
|
+
].filter(Boolean).join("\n"),
|
|
257
|
+
};
|
|
233
258
|
}
|
|
259
|
+
|
|
234
260
|
const mountPath = resolveMountPath(projectRoot, readConfig(projectRoot), image.id);
|
|
235
261
|
fs.mkdirSync(path.dirname(mountPath), { recursive: true });
|
|
236
|
-
const
|
|
237
|
-
statePath:
|
|
262
|
+
const record = {
|
|
263
|
+
statePath: started.statePath,
|
|
238
264
|
imageId: image.id,
|
|
239
265
|
mcpServerId,
|
|
240
266
|
mountPath,
|
|
241
267
|
};
|
|
242
268
|
let projectionPort;
|
|
243
269
|
try {
|
|
244
|
-
projectionPort = await startProjection(projectRoot,
|
|
270
|
+
projectionPort = await startProjection(projectRoot, record, image.imageName);
|
|
245
271
|
} catch (error) {
|
|
246
272
|
// The projection is half-started at best and nothing records it yet, so
|
|
247
|
-
//
|
|
273
|
+
// stop the image rather than leave an image and a mount nothing owns.
|
|
248
274
|
await stopProjectImage({ projectRoot, imageId: resolved }).catch(() => {});
|
|
249
275
|
throw error;
|
|
250
276
|
}
|
|
251
|
-
|
|
252
|
-
const unappliedPin = unappliedPharoMcpPin(image);
|
|
277
|
+
writeRunningImages(projectRoot, { ...runningImages, [resolved]: { ...record, projectionPort } });
|
|
253
278
|
return {
|
|
254
279
|
imageId: image.id,
|
|
255
280
|
created,
|
|
@@ -270,13 +295,13 @@ function unsupportedRunningImage(state, imageId) {
|
|
|
270
295
|
export function pharoMcpWarning(image) {
|
|
271
296
|
const reason = image.pharoMcpContract?.reason
|
|
272
297
|
?? "This image Pharo version is outside the supported Pharo MCP range.";
|
|
273
|
-
return `${reason} Image ${image.id} is
|
|
298
|
+
return `${reason} Image ${image.id} is running without MCP tools or a source projection;`
|
|
274
299
|
+ " drive it with the Pharo command line instead.";
|
|
275
300
|
}
|
|
276
301
|
|
|
277
|
-
async function startProjection(projectRoot,
|
|
278
|
-
const result = await callImageTool(projectRoot,
|
|
279
|
-
code: pifsStartCode(
|
|
302
|
+
async function startProjection(projectRoot, record, imageName) {
|
|
303
|
+
const result = await callImageTool(projectRoot, record, "image_evaluate", {
|
|
304
|
+
code: pifsStartCode(record.mountPath, imageName),
|
|
280
305
|
});
|
|
281
306
|
const projectionPort = Number(resultPrintString(result));
|
|
282
307
|
if (!Number.isInteger(projectionPort) || projectionPort < 1) {
|
|
@@ -287,76 +312,73 @@ async function startProjection(projectRoot, open, imageName) {
|
|
|
287
312
|
return projectionPort;
|
|
288
313
|
}
|
|
289
314
|
|
|
290
|
-
async function
|
|
291
|
-
const
|
|
315
|
+
async function stopOneImage(projectRoot, runningImages, imageId) {
|
|
316
|
+
const record = runningImages[imageId];
|
|
292
317
|
let projectionError;
|
|
293
|
-
// An image
|
|
318
|
+
// An image started without MCP has no projection to stop, and no route to
|
|
294
319
|
// ask over either.
|
|
295
|
-
if (
|
|
320
|
+
if (record.projectionPort !== undefined) {
|
|
296
321
|
try {
|
|
297
|
-
await callImageTool(projectRoot,
|
|
298
|
-
code: pifsStopCode(
|
|
322
|
+
await callImageTool(projectRoot, record, "image_evaluate", {
|
|
323
|
+
code: pifsStopCode(record.projectionPort),
|
|
299
324
|
});
|
|
300
325
|
} catch (error) {
|
|
301
326
|
projectionError = error;
|
|
302
327
|
}
|
|
303
328
|
}
|
|
304
|
-
const
|
|
329
|
+
const stopped = await runLifecycle(
|
|
305
330
|
() => stopProjectImage({ projectRoot, imageId }),
|
|
306
|
-
`PLexus could not
|
|
331
|
+
`PLexus could not stop image ${imageId}.`,
|
|
307
332
|
);
|
|
308
|
-
return {
|
|
333
|
+
return { stoppedImage: stopped.stoppedImage?.id, projectionError };
|
|
309
334
|
}
|
|
310
335
|
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
336
|
+
/**
|
|
337
|
+
* Stop one image, or with `all`, every image this project started. Naming no
|
|
338
|
+
* image stops the default one, as every other command addresses it; `all` is
|
|
339
|
+
* the cleanup, and says so where the caller can see it.
|
|
340
|
+
*
|
|
341
|
+
* @param {string} projectRoot
|
|
342
|
+
* @param {string} [imageId]
|
|
343
|
+
* @param {{ all?: boolean }} [options]
|
|
344
|
+
*/
|
|
345
|
+
export async function stopAgentImage(projectRoot, imageId, options = {}) {
|
|
346
|
+
const runningImages = readRunningImages(projectRoot);
|
|
347
|
+
if (options.all && imageId !== undefined) {
|
|
348
|
+
throw new Error("Stopping every image takes no image id.");
|
|
349
|
+
}
|
|
350
|
+
const identifiers = options.all
|
|
351
|
+
? Object.keys(runningImages)
|
|
315
352
|
: [resolveImageId(projectRoot, imageId)];
|
|
316
353
|
if (identifiers.length === 0) {
|
|
317
|
-
throw new Error("No
|
|
354
|
+
throw new Error("No pharo-agent image is running.");
|
|
318
355
|
}
|
|
319
356
|
for (const identifier of identifiers) {
|
|
320
|
-
if (!
|
|
321
|
-
throw new Error(`Image ${identifier} is not
|
|
357
|
+
if (!runningImages[identifier]) {
|
|
358
|
+
throw new Error(`Image ${identifier} is not running.`);
|
|
322
359
|
}
|
|
323
360
|
}
|
|
324
361
|
|
|
325
362
|
const stoppedImages = [];
|
|
326
363
|
const projectionErrors = [];
|
|
327
|
-
const remaining = { ...
|
|
364
|
+
const remaining = { ...runningImages };
|
|
328
365
|
for (const identifier of identifiers) {
|
|
329
|
-
const
|
|
366
|
+
const stopped = await stopOneImage(projectRoot, runningImages, identifier);
|
|
330
367
|
delete remaining[identifier];
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
368
|
+
writeRunningImages(projectRoot, remaining);
|
|
369
|
+
if (stopped.stoppedImage) {
|
|
370
|
+
stoppedImages.push(stopped.stoppedImage);
|
|
371
|
+
}
|
|
372
|
+
if (stopped.projectionError) {
|
|
373
|
+
projectionErrors.push(`${identifier}: ${stopped.projectionError.message}`);
|
|
335
374
|
}
|
|
336
375
|
}
|
|
337
376
|
if (projectionErrors.length > 0) {
|
|
338
|
-
throw new Error(`Images
|
|
377
|
+
throw new Error(`Images stopped, but PIFS cleanup failed: ${projectionErrors.join("; ")}`);
|
|
339
378
|
}
|
|
340
379
|
return { stoppedImages };
|
|
341
380
|
}
|
|
342
381
|
|
|
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
382
|
export async function listImageTemplates(projectRoot) {
|
|
361
383
|
// `image add --template` takes a name the launcher must already know, and a
|
|
362
384
|
// name that lands in two categories needs the category as well, so answer
|
package/src/mcpServer.js
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
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. The image's own tools are listed once it "
|
|
43
|
+
+ "answers. On macOS with fuse-t its source also mounts as files; "
|
|
44
|
+
+ "elsewhere the image starts without that and says so.",
|
|
45
|
+
inputSchema: {
|
|
46
|
+
type: "object",
|
|
47
|
+
properties: {
|
|
48
|
+
image: {
|
|
49
|
+
type: "string",
|
|
50
|
+
description: "Image id to start. Defaults to the project's default image.",
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
name: "image_list",
|
|
57
|
+
description:
|
|
58
|
+
"List this project's declared images, which are running, and the route "
|
|
59
|
+
+ "and mount of each running one.",
|
|
60
|
+
inputSchema: { type: "object", properties: {} },
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
name: "image_stop",
|
|
64
|
+
description:
|
|
65
|
+
"Stop a running image, and any source projection this project started for it.",
|
|
66
|
+
inputSchema: {
|
|
67
|
+
type: "object",
|
|
68
|
+
properties: {
|
|
69
|
+
image: {
|
|
70
|
+
type: "string",
|
|
71
|
+
description: "Image id to stop. Defaults to the project's default image.",
|
|
72
|
+
},
|
|
73
|
+
all: {
|
|
74
|
+
type: "boolean",
|
|
75
|
+
description: "Stop every image this project started, instead of one.",
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
},
|
|
80
|
+
];
|
|
81
|
+
|
|
82
|
+
// Resolved per request rather than once at startup. A host starts this server
|
|
83
|
+
// when its session starts, which is before anyone has started an image, so
|
|
84
|
+
// resolving then meant the server died on its first line.
|
|
85
|
+
async function runningImageTools(projectRoot, defaultImage) {
|
|
86
|
+
let running;
|
|
87
|
+
try {
|
|
88
|
+
running = readRunningImage(projectRoot, defaultImage);
|
|
89
|
+
} catch {
|
|
90
|
+
// No image running, none configured, or one running without MCP. The image
|
|
91
|
+
// contributes no tools yet and the lifecycle tools say how to change that.
|
|
92
|
+
return [];
|
|
93
|
+
}
|
|
94
|
+
return (await listImageTools(projectRoot, running))
|
|
95
|
+
.map((tool) => withImageSelector(tool, running.imageId));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export async function serveImagesMcp(resolveProjectRoot, defaultImage) {
|
|
99
|
+
const server = new Server(
|
|
100
|
+
{ name: "pharo-agent", version },
|
|
101
|
+
{ capabilities: { tools: { listChanged: true } }, instructions: serverInstructions },
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
105
|
+
let projectRoot;
|
|
106
|
+
try {
|
|
107
|
+
projectRoot = resolveProjectRoot();
|
|
108
|
+
} catch {
|
|
109
|
+
// No project here. The lifecycle tools are still listed rather than
|
|
110
|
+
// nothing, so the host has a server that works and a call can say why.
|
|
111
|
+
return { tools: lifecycleTools };
|
|
112
|
+
}
|
|
113
|
+
return {
|
|
114
|
+
tools: [...lifecycleTools, ...(await runningImageTools(projectRoot, defaultImage))],
|
|
115
|
+
};
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
119
|
+
const { image, all, ...argumentsValue } = request.params.arguments ?? {};
|
|
120
|
+
const selected = image ?? defaultImage;
|
|
121
|
+
// Throws when there is no project, which is the answer the caller needs.
|
|
122
|
+
const projectRoot = resolveProjectRoot();
|
|
123
|
+
|
|
124
|
+
// Matched before the image's catalogue: these three names belong to this
|
|
125
|
+
// server, and an image that happened to hold one must not decide what
|
|
126
|
+
// starting an image means.
|
|
127
|
+
if (request.params.name === "image_start") {
|
|
128
|
+
const started = await startAgentImage(projectRoot, selected);
|
|
129
|
+
await server.sendToolListChanged();
|
|
130
|
+
return toolResult({ ok: true, ...started });
|
|
131
|
+
}
|
|
132
|
+
if (request.params.name === "image_list") {
|
|
133
|
+
return toolResult({ ok: true, ...listImages(projectRoot) });
|
|
134
|
+
}
|
|
135
|
+
if (request.params.name === "image_stop") {
|
|
136
|
+
const stopped = all
|
|
137
|
+
? await stopAgentImage(projectRoot, undefined, { all: true })
|
|
138
|
+
: await stopAgentImage(projectRoot, selected);
|
|
139
|
+
await server.sendToolListChanged();
|
|
140
|
+
return toolResult({ ok: true, ...stopped });
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Listed tools are the common ones, not the whole catalogue, and the image
|
|
144
|
+
// answers for any tool it holds. Refusing a name this list does not carry
|
|
145
|
+
// would hide tools that tool_search just found, and the image's own error
|
|
146
|
+
// is the better answer for a name nothing holds.
|
|
147
|
+
const running = readRunningImage(projectRoot, selected);
|
|
148
|
+
return toolResult(
|
|
149
|
+
await callImageTool(projectRoot, running, request.params.name, argumentsValue),
|
|
150
|
+
);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
await server.connect(new StdioServerTransport());
|
|
154
|
+
}
|