@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/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 { guideForHost, hostList } from "./hosts.js";
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 runs.
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
- After upgrading the plugin/CLI, re-run \`pharo-agent init --host <host>\` from
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 project's images. A freshly initialized
123
- project declares none; register one before the first image start. With the
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 list
130
- pharo-agent image add dev --template '<launcher template name>'
166
+ pharo-agent image add <id> --template '<template name>'
167
+ pharo-agent image start <id>
131
168
  \`\`\`
132
169
 
133
- \`image templates\` names the templates the launcher offers, with the category
134
- of each; a name two categories offer also needs \`--category <name>\`. Any
135
- version the launcher offers can be registered, and one outside the MCP range
136
- starts without MCP tools or a source projection, saying so.
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
- Several images can run at once. \`pharo-agent image start <id>\` and
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
- Image, Tonel, Iceberg, disk Git, and host processes are separate states.
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 guideName = guideForHost(host);
196
- if (!guideName) {
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.10.2";
25
+ export const pifsRelease = "v0.11.0";
26
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";
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 a source projection can be mounted here.
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
- * 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.
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 {{ platform?: string, exists?: (path: string) => boolean }} [host]
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
- const exists = host.exists ?? fs.existsSync;
47
- if (platform !== "darwin") {
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: "A source projection needs macOS and fuse-t, which this platform"
51
- + ` (${platform}) cannot provide.`,
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 (!exists(fuseLibraryPath)) {
68
+ if (!projectedPlatforms.includes(platform)) {
55
69
  return {
56
70
  available: false,
57
- reason: "A source projection needs fuse-t, which is not installed."
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
- return `Metacello new
77
- baseline: 'PharoImageFS';
78
- repository: 'github://Evref-BL/pharo-image-fs:${pifsRelease}/src';
79
- load: 'Core'.
80
- ((Smalltalk globals at: #PIFSServer)
81
- startMountedAt: ${smalltalkString(mountPath)} asFileReference
82
- named: ${smalltalkString(imageName)}) port`;
83
- }
84
-
85
- export function pifsStopCode(projectionPort) {
86
- return `(Smalltalk globals at: #PIFSServer) stopOn: ${projectionPort}`;
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
- // `mount` prints one "<source> on <path> (<options>)" line per mount, with the
90
- // path resolved, so a project under a symlink is looked for by its real path.
91
- export function isMountedAt(mountPath, mountTable) {
92
- let realPath = mountPath;
93
- try {
94
- realPath = path.join(fs.realpathSync(path.dirname(mountPath)), path.basename(mountPath));
95
- } catch {
96
- // No parent directory, so nothing can be mounted there either.
97
- }
98
- return mountTable
99
- .split("\n")
100
- .some((line) => line.includes(` on ${mountPath} (`) || line.includes(` on ${realPath} (`));
101
- }
102
-
103
- function currentMountTable() {
104
- return spawnSync("mount", [], { encoding: "utf8" }).stdout ?? "";
105
- }
106
-
107
- function unmount(mountPath) {
108
- // What PIFSServer>>stopDaemon runs when it stops a projection itself.
109
- if (spawnSync("umount", [mountPath], { stdio: "ignore" }).status !== 0) {
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 a projection whose image is gone.
132
+ * Unmount the projection of an image that is gone, when nothing else will.
116
133
  *
117
- * The mount is served by a PIFS daemon, a process of its own that reads the
118
- * source over HTTP from the image. PIFS unmounts it from inside the image when
119
- * it stops, which an image that crashed or was closed never does: the mount
120
- * stays, every read in it fails with an I/O error, and it holds the path
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} mountPath
125
- * @param {{ mountTable?: () => string, unmount?: (path: string) => void }} [host]
126
- * @returns {boolean} whether something was unmounted
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 releaseProjectionMount(mountPath, host = {}) {
129
- const mountTable = host.mountTable ?? currentMountTable;
130
- if (mountPath === undefined || !isMountedAt(mountPath, mountTable())) return false;
131
- (host.unmount ?? unmount)(mountPath);
132
- if (isMountedAt(mountPath, mountTable())) {
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 projection = projectionAvailability();
301
- if (!projection.available) {
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 platform allows it.
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
- const mountPath = resolveMountPath(projectRoot, readConfig(projectRoot), image.id);
318
- fs.mkdirSync(path.dirname(mountPath), { recursive: true });
319
- const record = {
320
- statePath: started.statePath,
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(record.mountPath, imageName),
370
+ code: pifsStartCode(mountPath, imageName),
362
371
  });
363
- const projectionPort = Number(resultPrintString(result));
364
- if (!Number.isInteger(projectionPort) || projectionPort < 1) {
365
- throw new Error(
366
- `PIFS did not report a projection port: ${JSON.stringify(result).slice(0, 500)}`,
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 projectionPort;
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
- if (recordedImageIsRunning(record)) {
380
- await callImageTool(projectRoot, record, "image_evaluate", {
381
- code: pifsStopCode(record.projectionPort),
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 writing Smalltalk code in the
23
- image, running tests, finding senders and implementors, applying critiques,
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 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. A project that declares no image yet needs
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
- The image advertises the common tools rather than all of it. Use tool_search
33
- and tool_get to find the rest, then call one by name like any other.`;
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 in this project, from a template image_templates "
53
- + "names. Declaring does not create the image: the first image_start "
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: "Short id this project calls the image by, such as dev.",
56
+ description: "What the image is for, named like a branch.",
62
57
  },
63
58
  template: {
64
59
  type: "string",
65
- description: "Template name, exactly as image_templates gives it.",
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 one of this project's Pharo images, creating it from its template "
84
- + "when it does not exist yet. The image's own tools are listed once it "
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 to start. Defaults to the project's default image.",
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 to stop. Defaults to the project's default image.",
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, instead of one.",
106
+ description: "Stop every image this project started.",
119
107
  },
120
108
  },
121
109
  },