@evref-bl/pharo-agent 0.15.0 → 0.17.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/README.md +9 -8
- package/package.json +3 -3
- package/plugin.json +1 -1
- package/skills/pharo-feedback/SKILL.md +1 -1
- package/skills/pharo-setup/SKILL.md +11 -27
- package/src/cli.js +65 -39
- package/src/gateway.js +33 -0
- package/src/help.js +25 -39
- package/src/hosts.js +3 -1
- package/src/images.js +3 -2
- package/src/init.js +68 -53
- package/src/live.js +127 -120
- package/src/mcpServer.js +23 -35
- package/src/mcpYaml.js +102 -0
- package/src/runtime.js +17 -1
- package/src/toolCommand.js +216 -0
package/src/init.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { templateDirectory as mcpTemplateDirectory } from "@evref-bl/pharo-mcp";
|
|
4
|
-
import {
|
|
4
|
+
import { version } from "./gateway.js";
|
|
5
|
+
import { hostList, hostNamed } from "./hosts.js";
|
|
5
6
|
import {
|
|
6
7
|
createProjectConfig,
|
|
7
8
|
enclosingProjectRoot,
|
|
@@ -67,6 +68,47 @@ function assertGeneratedGuideCanBeUpdated(filePath) {
|
|
|
67
68
|
}
|
|
68
69
|
}
|
|
69
70
|
|
|
71
|
+
// Pinned to the release that wrote the guide, as the setup skill pins its
|
|
72
|
+
// npx command: a host that has no plugin has no pharo-agent on its PATH
|
|
73
|
+
// either. Code mode off, because OpenCode would otherwise hide the tools
|
|
74
|
+
// inside its own execute tool.
|
|
75
|
+
function serverEntry() {
|
|
76
|
+
return {
|
|
77
|
+
type: "local",
|
|
78
|
+
command: ["npx", "--yes", `@evref-bl/pharo-agent@${version}`, "mcp"],
|
|
79
|
+
enabled: true,
|
|
80
|
+
codemode: false,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Only mcp.pharo is ours. A configuration that may hold comments is not
|
|
85
|
+
// rewritten, since parsing and writing it back would drop them.
|
|
86
|
+
function readServerConfig(projectRoot, fileName) {
|
|
87
|
+
const filePath = path.join(projectRoot, fileName);
|
|
88
|
+
const byHand = `add this under "mcp" by hand:\n"pharo": ${JSON.stringify(serverEntry(), null, 2)}`;
|
|
89
|
+
if (!fs.existsSync(filePath)) {
|
|
90
|
+
if (fs.existsSync(`${filePath}c`)) {
|
|
91
|
+
throw new Error(`${filePath}c may hold comments that rewriting it would drop; ${byHand}`);
|
|
92
|
+
}
|
|
93
|
+
return { filePath, config: { $schema: "https://opencode.ai/config.json" } };
|
|
94
|
+
}
|
|
95
|
+
let config;
|
|
96
|
+
try {
|
|
97
|
+
config = JSON.parse(fs.readFileSync(filePath, "utf8"));
|
|
98
|
+
} catch {
|
|
99
|
+
throw new Error(`${filePath} is not plain JSON, so rewriting it would lose what it holds; ${byHand}`);
|
|
100
|
+
}
|
|
101
|
+
if (config === null || typeof config !== "object" || Array.isArray(config)) {
|
|
102
|
+
throw new Error(`${filePath} is not a JSON object; ${byHand}`);
|
|
103
|
+
}
|
|
104
|
+
return { filePath, config };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function writeServerConfig({ filePath, config }) {
|
|
108
|
+
const mcp = config.mcp !== null && typeof config.mcp === "object" ? config.mcp : {};
|
|
109
|
+
fs.writeFileSync(filePath, `${JSON.stringify({ ...config, mcp: { ...mcp, pharo: serverEntry() } }, null, 2)}\n`);
|
|
110
|
+
}
|
|
111
|
+
|
|
70
112
|
function guidance() {
|
|
71
113
|
return `# Pharo work\n\nWhen the task uses Pharo, read \`pharo.AGENTS.md\` before acting.\n`;
|
|
72
114
|
}
|
|
@@ -110,64 +152,34 @@ function assertRoutingResolves(routing, templates) {
|
|
|
110
152
|
function pharoGuide() {
|
|
111
153
|
return `# Pharo project guide
|
|
112
154
|
|
|
113
|
-
${generatedBy}. Edits here are replaced the next time it
|
|
155
|
+
${generatedBy}. Edits here are replaced the next time it
|
|
156
|
+
runs: re-run it after upgrading the plugin, and keep your own instructions
|
|
157
|
+
elsewhere.
|
|
114
158
|
|
|
115
|
-
|
|
116
|
-
the project root with the same host to refresh this guide and the copied skills
|
|
117
|
-
and references. Omit \`--mount-root\` to keep existing settings. Image declarations,
|
|
118
|
-
runtime state, and live code are preserved. Keep project-specific instructions
|
|
119
|
-
outside generated/copied files and outside the host guide's managed block.
|
|
120
|
-
Refreshing project files does not install a newer package or upgrade image tools.
|
|
159
|
+
## Images
|
|
121
160
|
|
|
122
|
-
\`plexus.json\` declares this
|
|
123
|
-
|
|
124
|
-
pharo-agent plugin enabled, that is \`image_templates\` and then \`image_add\`,
|
|
125
|
-
with no shell. Otherwise:
|
|
161
|
+
\`plexus.json\` declares this workspace's images. With the \`pharo\` MCP server,
|
|
162
|
+
use the \`image_*\` tools; otherwise:
|
|
126
163
|
|
|
127
164
|
\`\`\`sh
|
|
128
165
|
pharo-agent image templates
|
|
129
|
-
pharo-agent image
|
|
130
|
-
pharo-agent image
|
|
166
|
+
pharo-agent image add <id> --template '<template name>'
|
|
167
|
+
pharo-agent image start <id>
|
|
131
168
|
\`\`\`
|
|
132
169
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
\`image add\` makes the first registered image the default. \`pharo-agent image
|
|
139
|
-
use <id>\` changes the default, and \`pharo-agent image remove <id>\` drops a
|
|
140
|
-
declaration. Stop an image before removing its declaration.
|
|
141
|
-
|
|
142
|
-
Use \`pharo-agent image start\` before live-image work. On macOS with fuse-t
|
|
143
|
-
installed the image's source is also projected under \`.pharo-agent/mounts\`
|
|
144
|
-
and can be edited as files; everywhere else the Pharo tools are the way in,
|
|
145
|
-
and \`image start\` says so.
|
|
146
|
-
|
|
147
|
-
With the pharo-agent plugin enabled for this project, this is all MCP tools:
|
|
148
|
-
declare an image with \`image_add\`, start it with \`image_start\`, and call the
|
|
149
|
-
image's own tools by name. Without it,
|
|
150
|
-
call a tool with \`pharo-agent pharo <tool> [arguments-json]\`, and
|
|
151
|
-
\`pharo-agent mcp\` serves the same surface as a command-scoped stdio MCP
|
|
152
|
-
server.
|
|
153
|
-
|
|
154
|
-
\`pharo-agent pharo list\` names the tools the image advertises, which are the
|
|
155
|
-
common ones rather than all of it. Find the rest through the catalogue rather
|
|
156
|
-
than guessing at a name, then call one the same way as any other:
|
|
157
|
-
|
|
158
|
-
\`\`\`sh
|
|
159
|
-
pharo-agent pharo tool_search '{"query":"repository commit"}'
|
|
160
|
-
pharo-agent pharo tool_get '{"name":"repository_commit"}'
|
|
161
|
-
pharo-agent pharo repository_commit '{"name":"MCP","message":"..."}'
|
|
162
|
-
\`\`\`
|
|
170
|
+
The first image declared is the default, the main line. An image takes one
|
|
171
|
+
writer, and making one takes seconds: add a new one for each new subject or
|
|
172
|
+
parallel agent rather than share one, named like a branch
|
|
173
|
+
(\`pretty-print-formatter\`, not \`p15\`). A running image may be another
|
|
174
|
+
session's. Stop an image and remove its declaration when its subject is done.
|
|
163
175
|
|
|
164
|
-
|
|
165
|
-
\`pharo-agent image stop <id>\` name their image, \`pharo\` and \`mcp\` take
|
|
166
|
-
\`--image <id>\`, and without either the default image answers.
|
|
167
|
-
\`pharo-agent image list\` says which are running, and
|
|
168
|
-
\`pharo-agent image stop --all\` stops every one this project started.
|
|
176
|
+
## Tools
|
|
169
177
|
|
|
170
|
-
|
|
178
|
+
With the \`pharo\` MCP server, an image's tools are MCP tools. Otherwise run
|
|
179
|
+
one as \`pharo-agent <tool> --<argument> <value>\`: \`<tool> --help\` lists its
|
|
180
|
+
arguments, and a value of \`-\` is read from stdin. \`pharo-agent list\` names
|
|
181
|
+
the common tools; \`tool_search\` finds the rest. On macOS and Linux,
|
|
182
|
+
\`image start\` also mounts the source under \`.pharo-agent/mounts\`.
|
|
171
183
|
|
|
172
184
|
${pharoRouting()}
|
|
173
185
|
`;
|
|
@@ -192,10 +204,11 @@ function resolveInitProjectRoot(cwd) {
|
|
|
192
204
|
|
|
193
205
|
/** @param {{ host: string, mountRoot?: string, cwd?: string }} options */
|
|
194
206
|
export function initialize({ host, mountRoot, cwd = process.cwd() }) {
|
|
195
|
-
const
|
|
196
|
-
if (!
|
|
207
|
+
const hostEntry = hostNamed(host);
|
|
208
|
+
if (!hostEntry) {
|
|
197
209
|
throw new Error(`--host must be ${hostList()}`);
|
|
198
210
|
}
|
|
211
|
+
const guideName = hostEntry.guide;
|
|
199
212
|
|
|
200
213
|
const { projectRoot, createdProjectConfig } = resolveInitProjectRoot(cwd);
|
|
201
214
|
const directory = pluginDirectory(projectRoot);
|
|
@@ -203,6 +216,7 @@ export function initialize({ host, mountRoot, cwd = process.cwd() }) {
|
|
|
203
216
|
const pharoGuidePath = path.join(projectRoot, "pharo.AGENTS.md");
|
|
204
217
|
assertGeneratedGuideCanBeUpdated(pharoGuidePath);
|
|
205
218
|
assertManagedBlockCanBeUpdated(path.join(projectRoot, guideName));
|
|
219
|
+
const serverConfig = hostEntry.serverConfig && readServerConfig(projectRoot, hostEntry.serverConfig);
|
|
206
220
|
if (createdProjectConfig) {
|
|
207
221
|
createProjectConfig(projectRoot);
|
|
208
222
|
}
|
|
@@ -219,6 +233,7 @@ export function initialize({ host, mountRoot, cwd = process.cwd() }) {
|
|
|
219
233
|
copyDirectory(path.join(templates, "pharo-references"), path.join(directory, "references"));
|
|
220
234
|
copyDirectory(path.join(templates, "pharo-skills"), path.join(directory, "skills"));
|
|
221
235
|
updateManagedBlock(path.join(projectRoot, guideName), guidance());
|
|
236
|
+
if (serverConfig) writeServerConfig(serverConfig);
|
|
222
237
|
|
|
223
|
-
return { projectRoot, guideName, createdProjectConfig };
|
|
238
|
+
return { projectRoot, guideName, createdProjectConfig, ...(serverConfig && { serverConfig: hostEntry.serverConfig }) };
|
|
224
239
|
}
|
package/src/live.js
CHANGED
|
@@ -22,47 +22,65 @@ import {
|
|
|
22
22
|
writeRunningImages,
|
|
23
23
|
} from "./runtime.js";
|
|
24
24
|
|
|
25
|
-
export const pifsRelease = "v0.
|
|
25
|
+
export const pifsRelease = "v0.11.0";
|
|
26
26
|
|
|
27
|
-
//
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
27
|
+
// Set to `off`, no image mounts a source projection, wherever it runs.
|
|
28
|
+
const projectionVariable = "PHARO_AGENT_PROJECTION";
|
|
29
|
+
|
|
30
|
+
// The systems PIFS releases a daemon for.
|
|
31
|
+
const projectedPlatforms = ["darwin", "linux"];
|
|
31
32
|
|
|
32
33
|
/**
|
|
33
|
-
* Whether
|
|
34
|
+
* Whether to ask the image for a source projection.
|
|
35
|
+
*
|
|
36
|
+
* Whether this machine can mount is the PIFS daemon's to say: macOS needs
|
|
37
|
+
* nothing, Linux needs fuse3, and the daemon names what is missing. A
|
|
38
|
+
* projection that fails leaves the image running with that reason as its
|
|
39
|
+
* warning, so this answers no only where asking is wasted, on a system PIFS
|
|
40
|
+
* has no daemon for, or forbidden.
|
|
34
41
|
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
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.
|
|
42
|
+
* A machine that could mount may still be one that must not, so the
|
|
43
|
+
* environment can turn projections off before anything is asked.
|
|
41
44
|
*
|
|
42
|
-
* @param {{
|
|
45
|
+
* @param {{
|
|
46
|
+
* env?: Record<string, string | undefined>,
|
|
47
|
+
* platform?: string,
|
|
48
|
+
* }} [host]
|
|
43
49
|
*/
|
|
44
50
|
export function projectionAvailability(host = {}) {
|
|
51
|
+
const setting = (host.env ?? process.env)[projectionVariable];
|
|
45
52
|
const platform = host.platform ?? process.platform;
|
|
46
|
-
|
|
47
|
-
|
|
53
|
+
if (setting === "off") {
|
|
54
|
+
return {
|
|
55
|
+
available: false,
|
|
56
|
+
reason: `Source projections are turned off by ${projectionVariable}=off.`,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
if (setting) {
|
|
60
|
+
// Any other value fails safe: an opt-out that ignored a typo, or a 0,
|
|
61
|
+
// would mount on the very machine that asked not to be mounted on.
|
|
48
62
|
return {
|
|
49
63
|
available: false,
|
|
50
|
-
reason:
|
|
51
|
-
+ `
|
|
64
|
+
reason: `Source projections are off: ${projectionVariable} takes only off,`
|
|
65
|
+
+ ` and is ${JSON.stringify(setting)}. Unset it to mount them where the platform allows.`,
|
|
52
66
|
};
|
|
53
67
|
}
|
|
54
|
-
if (!
|
|
68
|
+
if (!projectedPlatforms.includes(platform)) {
|
|
55
69
|
return {
|
|
56
70
|
available: false,
|
|
57
|
-
reason:
|
|
58
|
-
+ " Install it with `brew install --cask fuse-t`.",
|
|
71
|
+
reason: `PIFS mounts source projections on macOS and Linux, not on this platform (${platform}).`,
|
|
59
72
|
};
|
|
60
73
|
}
|
|
61
74
|
return { available: true };
|
|
62
75
|
}
|
|
63
76
|
|
|
77
|
+
// The daemon's own reasons end without a period.
|
|
78
|
+
function sentence(text) {
|
|
79
|
+
return /[.!?]$/.test(text) ? text : `${text}.`;
|
|
80
|
+
}
|
|
81
|
+
|
|
64
82
|
export function projectionUnavailableWarning(imageId, reason) {
|
|
65
|
-
return `${reason} Image ${imageId} is running with its Pharo tools and no`
|
|
83
|
+
return `${sentence(reason)} Image ${imageId} is running with its Pharo tools and no`
|
|
66
84
|
+ " source projection; read and edit its code through the tools.";
|
|
67
85
|
}
|
|
68
86
|
|
|
@@ -73,71 +91,67 @@ export function projectionUnavailableWarning(imageId, reason) {
|
|
|
73
91
|
export const pifsSelectors = ["startMountedAt:named:", "stopOn:"];
|
|
74
92
|
|
|
75
93
|
export function pifsStartCode(mountPath, imageName) {
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
94
|
+
// A projection that cannot mount answers why, as a string, rather than
|
|
95
|
+
// failing the evaluation: the reason is the daemon's, and becomes the
|
|
96
|
+
// warning of an image that keeps running.
|
|
97
|
+
return `[ Metacello new
|
|
98
|
+
baseline: 'PharoImageFS';
|
|
99
|
+
repository: 'github://Evref-BL/pharo-image-fs:${pifsRelease}/src';
|
|
100
|
+
load: 'Core'.
|
|
101
|
+
((Smalltalk globals at: #PIFSServer)
|
|
102
|
+
startMountedAt: ${smalltalkString(mountPath)} asFileReference
|
|
103
|
+
named: ${smalltalkString(imageName)}) port ]
|
|
104
|
+
on: Error
|
|
105
|
+
do: [ :error | error messageText asString ]`;
|
|
87
106
|
}
|
|
88
107
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
spawnSync("diskutil", ["unmount", mountPath], { stdio: "ignore" });
|
|
111
|
-
}
|
|
108
|
+
/**
|
|
109
|
+
* What a projection start answered: the port serving it, why PIFS could not
|
|
110
|
+
* mount, or nothing readable.
|
|
111
|
+
*
|
|
112
|
+
* @param {string | undefined} printString
|
|
113
|
+
* @returns {{ port: number } | { reason: string } | undefined}
|
|
114
|
+
*/
|
|
115
|
+
export function projectionStartOutcome(printString) {
|
|
116
|
+
// The value comes first; MCP appends warnings, the Transcript and a
|
|
117
|
+
// truncation mark on the lines after it.
|
|
118
|
+
const text = printString ?? "";
|
|
119
|
+
const digits = /^(\d+)(?:\n|$)/.exec(text);
|
|
120
|
+
if (digits && Number(digits[1]) > 0) return { port: Number(digits[1]) };
|
|
121
|
+
// A string prints quoted, its quotes doubled, and a long one is cut short
|
|
122
|
+
// of its closing quote, before the truncation mark.
|
|
123
|
+
const quoted = /^'((?:[^']|'')*)(?:'|$)/.exec(text);
|
|
124
|
+
if (!quoted) return undefined;
|
|
125
|
+
const literal = quoted[1].split("\n[truncated")[0].replaceAll("''", "'").trim();
|
|
126
|
+
return {
|
|
127
|
+
reason: `${sentence(literal)} Set ${projectionVariable}=off to start images without trying to mount.`,
|
|
128
|
+
};
|
|
112
129
|
}
|
|
113
130
|
|
|
114
131
|
/**
|
|
115
|
-
* Unmount
|
|
132
|
+
* Unmount the projection of an image that is gone, when nothing else will.
|
|
116
133
|
*
|
|
117
|
-
*
|
|
118
|
-
*
|
|
119
|
-
*
|
|
120
|
-
*
|
|
121
|
-
* against the next projection. Unmounting is enough; the daemon exits with its
|
|
122
|
-
* mount.
|
|
134
|
+
* Records written before this one name no PIFS release: fuse-t's daemon
|
|
135
|
+
* served their projection, and it outlives its image. A later daemon
|
|
136
|
+
* unmounts on its own once its image is gone. A path with nothing mounted
|
|
137
|
+
* just fails to unmount.
|
|
123
138
|
*
|
|
124
|
-
* @param {string | undefined}
|
|
125
|
-
* @param {
|
|
126
|
-
* @returns {boolean} whether
|
|
139
|
+
* @param {{ mountPath?: string, pifsRelease?: string, [key: string]: unknown } | undefined} record
|
|
140
|
+
* @param {(mountPath: string) => void} [unmount]
|
|
141
|
+
* @returns {boolean} whether it tried
|
|
127
142
|
*/
|
|
128
|
-
export function
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
(
|
|
132
|
-
|
|
133
|
-
throw new Error(
|
|
134
|
-
`The source projection of an image that is gone is still mounted at ${mountPath},`
|
|
135
|
-
+ ` and could not be unmounted. Run umount ${JSON.stringify(mountPath)}, then try again.`,
|
|
136
|
-
);
|
|
137
|
-
}
|
|
143
|
+
export function releaseFuseTMount(record, unmount = (mountPath) => {
|
|
144
|
+
spawnSync("umount", [mountPath], { stdio: "ignore" });
|
|
145
|
+
}) {
|
|
146
|
+
if (record?.mountPath === undefined || record.pifsRelease !== undefined) return false;
|
|
147
|
+
unmount(record.mountPath);
|
|
138
148
|
return true;
|
|
139
149
|
}
|
|
140
150
|
|
|
151
|
+
export function pifsStopCode(projectionPort) {
|
|
152
|
+
return `(Smalltalk globals at: #PIFSServer) stopOn: ${projectionPort}`;
|
|
153
|
+
}
|
|
154
|
+
|
|
141
155
|
function namedPrintString(value) {
|
|
142
156
|
// Structured results name the print string, somewhere under the envelope,
|
|
143
157
|
// and reach us either as an object or as JSON inside a text part.
|
|
@@ -255,16 +269,12 @@ export async function startAgentImage(projectRoot, imageId) {
|
|
|
255
269
|
// The image this record names has exited. Forget it and start again,
|
|
256
270
|
// rather than hand back an endpoint nothing answers and call it running.
|
|
257
271
|
const { [resolved]: exited, ...remaining } = runningImages;
|
|
272
|
+
releaseFuseTMount(exited);
|
|
258
273
|
runningImages = remaining;
|
|
259
274
|
writeRunningImages(projectRoot, runningImages);
|
|
260
275
|
restarted = true;
|
|
261
276
|
}
|
|
262
277
|
if (projectionAvailability().available) {
|
|
263
|
-
// This project has no live image at this mount path now: none was
|
|
264
|
-
// recorded, or the one recorded has exited. Whatever is still mounted
|
|
265
|
-
// there was left by an image that is gone, and would hold the path
|
|
266
|
-
// against the projection about to be mounted.
|
|
267
|
-
releaseProjectionMount(resolveMountPath(projectRoot, readConfig(projectRoot), resolved));
|
|
268
278
|
fetchPifsRelease(pifsCloneDirectory(projectRoot));
|
|
269
279
|
}
|
|
270
280
|
const launcher = new ScopedPharoLauncher({ projectRoot });
|
|
@@ -297,11 +307,24 @@ export async function startAgentImage(projectRoot, imageId) {
|
|
|
297
307
|
return { ...record, created, warning: pharoMcpWarning(withoutMcp) };
|
|
298
308
|
}
|
|
299
309
|
const unappliedPin = unappliedPharoMcpPin(image);
|
|
300
|
-
const
|
|
301
|
-
|
|
310
|
+
const record = { statePath: started.statePath, imageId: image.id, mcpServerId };
|
|
311
|
+
const mountPath = resolveMountPath(projectRoot, readConfig(projectRoot), image.id);
|
|
312
|
+
const availability = projectionAvailability();
|
|
313
|
+
/** @type {{ port?: number, reason?: string }} */
|
|
314
|
+
let projection = availability;
|
|
315
|
+
if (availability.available) {
|
|
316
|
+
try {
|
|
317
|
+
projection = await startProjection(projectRoot, record, mountPath, image.imageName);
|
|
318
|
+
} catch (error) {
|
|
319
|
+
// Nothing says whether PIFS mounted, so stop the image rather than keep
|
|
320
|
+
// a mount nothing records: its daemon unmounts once the image is gone.
|
|
321
|
+
await stopProjectImage({ projectRoot, imageId: resolved }).catch(() => {});
|
|
322
|
+
throw error;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
if (projection.reason !== undefined) {
|
|
302
326
|
// The image and its tools are the point; the projection is the extra this
|
|
303
|
-
// package bundles where the
|
|
304
|
-
const record = { statePath: started.statePath, imageId: image.id, mcpServerId };
|
|
327
|
+
// package bundles where the machine allows it.
|
|
305
328
|
writeRunningImages(projectRoot, { ...runningImages, [resolved]: record });
|
|
306
329
|
return {
|
|
307
330
|
imageId: image.id,
|
|
@@ -313,25 +336,11 @@ export async function startAgentImage(projectRoot, imageId) {
|
|
|
313
336
|
].filter(Boolean).join("\n"),
|
|
314
337
|
};
|
|
315
338
|
}
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
imageId: image.id,
|
|
322
|
-
mcpServerId,
|
|
323
|
-
mountPath,
|
|
324
|
-
};
|
|
325
|
-
let projectionPort;
|
|
326
|
-
try {
|
|
327
|
-
projectionPort = await startProjection(projectRoot, record, image.imageName);
|
|
328
|
-
} catch (error) {
|
|
329
|
-
// The projection is half-started at best and nothing records it yet, so
|
|
330
|
-
// stop the image rather than leave an image and a mount nothing owns.
|
|
331
|
-
await stopProjectImage({ projectRoot, imageId: resolved }).catch(() => {});
|
|
332
|
-
throw error;
|
|
333
|
-
}
|
|
334
|
-
writeRunningImages(projectRoot, { ...runningImages, [resolved]: { ...record, projectionPort } });
|
|
339
|
+
const projectionPort = projection.port;
|
|
340
|
+
writeRunningImages(projectRoot, {
|
|
341
|
+
...runningImages,
|
|
342
|
+
[resolved]: { ...record, mountPath, projectionPort, pifsRelease },
|
|
343
|
+
});
|
|
335
344
|
return {
|
|
336
345
|
imageId: image.id,
|
|
337
346
|
created,
|
|
@@ -356,17 +365,16 @@ export function pharoMcpWarning(image) {
|
|
|
356
365
|
+ " drive it with the Pharo command line instead.";
|
|
357
366
|
}
|
|
358
367
|
|
|
359
|
-
async function startProjection(projectRoot, record, imageName) {
|
|
368
|
+
async function startProjection(projectRoot, record, mountPath, imageName) {
|
|
360
369
|
const result = await callImageTool(projectRoot, record, "image_evaluate", {
|
|
361
|
-
code: pifsStartCode(
|
|
370
|
+
code: pifsStartCode(mountPath, imageName),
|
|
362
371
|
});
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
);
|
|
372
|
+
// A refusal is PIFS's own: it stopped what it had started.
|
|
373
|
+
const outcome = projectionStartOutcome(resultPrintString(result));
|
|
374
|
+
if (!outcome) {
|
|
375
|
+
throw new Error(`PIFS did not report a projection port: ${JSON.stringify(result).slice(0, 500)}`);
|
|
368
376
|
}
|
|
369
|
-
return
|
|
377
|
+
return outcome;
|
|
370
378
|
}
|
|
371
379
|
|
|
372
380
|
async function stopOneImage(projectRoot, runningImages, imageId) {
|
|
@@ -374,19 +382,18 @@ async function stopOneImage(projectRoot, runningImages, imageId) {
|
|
|
374
382
|
let projectionError;
|
|
375
383
|
// An image started without MCP has no projection to stop, and no route to
|
|
376
384
|
// ask over either.
|
|
377
|
-
if (record.projectionPort !== undefined) {
|
|
385
|
+
if (record.projectionPort !== undefined && recordedImageIsRunning(record)) {
|
|
378
386
|
try {
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
});
|
|
383
|
-
} else {
|
|
384
|
-
// PIFS stops a projection from inside its image, and this one is gone.
|
|
385
|
-
releaseProjectionMount(record.mountPath);
|
|
386
|
-
}
|
|
387
|
+
await callImageTool(projectRoot, record, "image_evaluate", {
|
|
388
|
+
code: pifsStopCode(record.projectionPort),
|
|
389
|
+
});
|
|
387
390
|
} catch (error) {
|
|
388
391
|
projectionError = error;
|
|
389
392
|
}
|
|
393
|
+
} else {
|
|
394
|
+
// A gone image has no one to ask, and its projection's daemon unmounts
|
|
395
|
+
// on its own, unless fuse-t's served it.
|
|
396
|
+
releaseFuseTMount(record);
|
|
390
397
|
}
|
|
391
398
|
const stopped = await runLifecycle(
|
|
392
399
|
() => stopProjectImage({ projectRoot, imageId }),
|
package/src/mcpServer.js
CHANGED
|
@@ -19,18 +19,18 @@ import { readRunningImage } from "./runtime.js";
|
|
|
19
19
|
// defers the rest, so this is what decides whether Pharo tools are searched
|
|
20
20
|
// for at all. It is the conditional guidance the generated guide gives,
|
|
21
21
|
// written where the host can act on it.
|
|
22
|
-
export const serverInstructions = `Live Pharo images for this project: reading and
|
|
23
|
-
|
|
24
|
-
and projecting image source as files on disk.
|
|
22
|
+
export const serverInstructions = `Live Pharo images for this project: reading and changing code, running tests,
|
|
23
|
+
searching and critiquing it.
|
|
25
24
|
|
|
26
|
-
Search these tools
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
image_templates and image_add first.
|
|
25
|
+
Search these tools for any Pharo, Smalltalk, Tonel or .st work. Start an
|
|
26
|
+
image with image_start before calling its tools; a project with no image needs
|
|
27
|
+
image_templates and image_add first. An image lists its common tools;
|
|
28
|
+
tool_search and tool_get find the rest.
|
|
31
29
|
|
|
32
|
-
|
|
33
|
-
|
|
30
|
+
An image takes one writer, and making one takes seconds: image_add a new one
|
|
31
|
+
for each new subject or parallel agent rather than share one, named like a
|
|
32
|
+
branch (pretty-print-formatter, not p15). The default image is the main line.
|
|
33
|
+
A running image may be another session's.`;
|
|
34
34
|
|
|
35
35
|
// This server's own tools, in the order a project meets them: declare an
|
|
36
36
|
// image, start it, and the image's tools join the list. Listed whether or not
|
|
@@ -40,34 +40,28 @@ and tool_get to find the rest, then call one by name like any other.`;
|
|
|
40
40
|
const projectTools = [
|
|
41
41
|
{
|
|
42
42
|
name: "image_templates",
|
|
43
|
-
description:
|
|
44
|
-
"List the images Pharo Launcher can create, with the category of each. "
|
|
45
|
-
+ "A template name that two categories offer is flagged, and needs its "
|
|
46
|
-
+ "category when it is declared.",
|
|
43
|
+
description: "List the templates images can be made from, with their categories.",
|
|
47
44
|
inputSchema: { type: "object", properties: {} },
|
|
48
45
|
},
|
|
49
46
|
{
|
|
50
47
|
name: "image_add",
|
|
51
48
|
description:
|
|
52
|
-
"Declare an image
|
|
53
|
-
+ "
|
|
54
|
-
+ "does, from this template. The first image declared becomes the "
|
|
55
|
-
+ "project's default.",
|
|
49
|
+
"Declare an image made from a template; image_start creates it. The "
|
|
50
|
+
+ "first image declared is the default.",
|
|
56
51
|
inputSchema: {
|
|
57
52
|
type: "object",
|
|
58
53
|
properties: {
|
|
59
54
|
id: {
|
|
60
55
|
type: "string",
|
|
61
|
-
description: "
|
|
56
|
+
description: "What the image is for, named like a branch.",
|
|
62
57
|
},
|
|
63
58
|
template: {
|
|
64
59
|
type: "string",
|
|
65
|
-
description: "Template name,
|
|
60
|
+
description: "Template name, as image_templates gives it.",
|
|
66
61
|
},
|
|
67
62
|
category: {
|
|
68
63
|
type: "string",
|
|
69
|
-
description:
|
|
70
|
-
"Template category. Needed only for a name two categories offer.",
|
|
64
|
+
description: "Template category, for a name two categories offer.",
|
|
71
65
|
},
|
|
72
66
|
profile: {
|
|
73
67
|
type: "string",
|
|
@@ -80,42 +74,36 @@ const projectTools = [
|
|
|
80
74
|
{
|
|
81
75
|
name: "image_start",
|
|
82
76
|
description:
|
|
83
|
-
"Start
|
|
84
|
-
+ "
|
|
85
|
-
+ "answers. On macOS with fuse-t its source also mounts as files; "
|
|
86
|
-
+ "elsewhere the image starts without that and says so.",
|
|
77
|
+
"Start an image, creating it the first time. Its tools are listed once "
|
|
78
|
+
+ "it answers. On macOS and Linux its source also mounts as files.",
|
|
87
79
|
inputSchema: {
|
|
88
80
|
type: "object",
|
|
89
81
|
properties: {
|
|
90
82
|
image: {
|
|
91
83
|
type: "string",
|
|
92
|
-
description: "Image id
|
|
84
|
+
description: "Image id. Defaults to the default image.",
|
|
93
85
|
},
|
|
94
86
|
},
|
|
95
87
|
},
|
|
96
88
|
},
|
|
97
89
|
{
|
|
98
90
|
name: "image_list",
|
|
99
|
-
description:
|
|
100
|
-
"List this project's declared images, which are running, and the route "
|
|
101
|
-
+ "and mount of each running one. An image that was started and has "
|
|
102
|
-
+ "exited since is listed as exited; image_start starts it again.",
|
|
91
|
+
description: "List the declared images, which are running, and the route and mount of each.",
|
|
103
92
|
inputSchema: { type: "object", properties: {} },
|
|
104
93
|
},
|
|
105
94
|
{
|
|
106
95
|
name: "image_stop",
|
|
107
|
-
description:
|
|
108
|
-
"Stop a running image, and any source projection this project started for it.",
|
|
96
|
+
description: "Stop an image and its source mount.",
|
|
109
97
|
inputSchema: {
|
|
110
98
|
type: "object",
|
|
111
99
|
properties: {
|
|
112
100
|
image: {
|
|
113
101
|
type: "string",
|
|
114
|
-
description: "Image id
|
|
102
|
+
description: "Image id. Defaults to the default image.",
|
|
115
103
|
},
|
|
116
104
|
all: {
|
|
117
105
|
type: "boolean",
|
|
118
|
-
description: "Stop every image this project started
|
|
106
|
+
description: "Stop every image this project started.",
|
|
119
107
|
},
|
|
120
108
|
},
|
|
121
109
|
},
|