@evref-bl/pharo-agent 0.3.10 → 0.5.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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pharo-agent",
3
3
  "displayName": "Pharo Agent",
4
- "version": "0.3.10",
4
+ "version": "0.5.0",
5
5
  "description": "Terminal-first Pharo image setup for coding agents.",
6
6
  "author": {
7
7
  "name": "Evref-BL",
package/README.md CHANGED
@@ -1,68 +1,36 @@
1
1
  # pharo-agent
2
2
 
3
- `pharo-agent` is a terminal-first Pharo integration for Codex, Claude Code, and
4
- OpenCode. It has one small setup skill; a project receives the full Pharo
5
- guidance only after setup.
3
+ `pharo-agent` gives a coding agent a live Pharo image: semantic tools over MCP,
4
+ and the image's source projected as files it can read and edit. It works with
5
+ Claude Code, Codex and OpenCode.
6
6
 
7
- Initialize the current directory for one selected agent host:
7
+ ## Setup
8
8
 
9
- ```sh
10
- npx --yes @evref-bl/pharo-agent init --host codex
11
- ```
12
-
13
- It writes a small conditional instruction block, `pharo.AGENTS.md`, and copies
14
- on-demand Pharo workflow skills and references from the pinned MCP template
15
- package. It does not install an agent-host MCP server.
16
-
17
- `init` acts on the current directory and never searches above it. If that
18
- directory has no `plexus.json`, it creates one that declares the
19
- project and no image; an existing one is left untouched. It refuses to run
20
- inside an enclosing PLexus project rather than initializing that project root.
21
-
22
- Register the image the project needs before the first `open`:
9
+ In the project directory:
23
10
 
24
11
  ```sh
25
- pharo-agent image add dev --template 'Pharo 13.0 - 64bit (stable)'
12
+ npx --yes @evref-bl/pharo-agent init --host <claude|codex|opencode>
26
13
  ```
27
14
 
28
- `image add` registers a template-created image with the pinned MCP load policy,
29
- and the first one registered becomes the default. `image list`,
30
- `image use <id>` (change the default) and `image remove <id>` cover the rest;
31
- an open image must be closed before its declaration is removed.
32
-
33
- `open` starts an image through PLexus, loads PIFS, and mounts its projection
34
- below `.pharo-agent/mounts`. `close` unmounts the projection and closes the
35
- PLexus image. `pharo` calls any exposed MCP tool through the same project
36
- route, while `mcp` exposes that tool surface as a command-scoped stdio MCP
37
- server.
38
-
39
- Several images can be open at once. `open`, `close`, `status`, `pharo` and
40
- `mcp` all accept `--image <id>`; without it the default image answers, so a
41
- project with one image never mentions an image at all. `open --image <id>`
42
- adds an image without closing the others, and `close` without `--image` closes
43
- every image this project opened. Over `mcp`, each Pharo tool takes the same
44
- optional `image` argument.
45
-
46
- Pass `--mount-root <path>` to `init` to use a different projection root.
15
+ That is all of it. `init` writes the project's Pharo guidance and copies the
16
+ workflow skills that guidance refers to. From there the agent registers the
17
+ image the project needs, opens it and uses its tools by following what was
18
+ written; ask it for Pharo work as you would for anything else.
47
19
 
48
20
  ## Requirements
49
21
 
50
- PIFS currently supports the macOS workflow and needs
51
- [fuse-t](https://www.fuse-t.org/). Its matching daemon is downloaded by PIFS;
52
- Go is not required at runtime.
22
+ Node 22.12 or later.
53
23
 
54
- The CLI pins released MCP and PLexus dependencies. PLexus, in turn, pins the
55
- MCP release used to prepare the image; PIFS is loaded at its own exact release
56
- from the image.
24
+ The source projection currently supports macOS and needs
25
+ [fuse-t](https://www.fuse-t.org/). Its daemon is downloaded for you.
57
26
 
58
- ## Host setup
27
+ ## What the agent gets
59
28
 
60
- Codex and Claude Code can install the repository as a plugin. Its only global
61
- skill initializes the current project; it does not add an MCP server or the
62
- Pharo workflow catalogue to every chat. OpenCode uses the same CLI command
63
- with `--host opencode`.
29
+ Reference, for reading rather than typing: these are the commands the guidance
30
+ hands to the agent.
64
31
 
65
- After initialization, the selected host guide says to read
66
- `pharo.AGENTS.md` only for Pharo work. That guide points to local workflow
67
- skills and references on demand. In the chat that runs setup, read that file
68
- before beginning Pharo work; later chats load the conditional guidance normally.
32
+ - [Hosts](docs/hosts.md) — what `init` writes, and installing as a plugin.
33
+ - [Images](docs/images.md) — templates and categories, several images at once,
34
+ Pharo versions outside the MCP range.
35
+ - [Tools](docs/tools.md) — the advertised surface, finding the rest, and the
36
+ stdio bundle.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@evref-bl/pharo-agent",
3
- "version": "0.3.10",
3
+ "version": "0.5.0",
4
4
  "private": false,
5
5
  "description": "Terminal-first Pharo integration for coding agents.",
6
6
  "license": "Apache-2.0",
@@ -31,9 +31,9 @@
31
31
  "test": "node --test"
32
32
  },
33
33
  "dependencies": {
34
- "@evref-bl/pharo-mcp": "1.5.1",
35
- "@evref-bl/plexus": "0.4.8",
36
- "@evref-bl/plexus-gateway": "0.4.8",
34
+ "@evref-bl/pharo-mcp": "2.1.0",
35
+ "@evref-bl/plexus": "0.5.2",
36
+ "@evref-bl/plexus-gateway": "0.5.2",
37
37
  "@modelcontextprotocol/sdk": "1.32.1"
38
38
  }
39
39
  }
package/plugin.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
3
3
  "name": "pharo-agent",
4
- "version": "0.3.10",
4
+ "version": "0.5.0",
5
5
  "description": "Terminal-first Pharo image setup for coding agents.",
6
6
  "author": {
7
7
  "name": "Evref-BL",
@@ -8,10 +8,11 @@ Run this only when the user asks to enable Pharo tooling in this project.
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.3.10 init --host codex
11
+ npx --yes @evref-bl/pharo-agent@0.5.0 init --host claude
12
12
  ```
13
13
 
14
- Use `--host claude` in Claude Code and `--host opencode` in OpenCode.
14
+ Name the host this chat runs in: `--host claude` in Claude Code,
15
+ `--host codex` in Codex, `--host opencode` in OpenCode.
15
16
  Do not configure an MCP server in the agent host. The command adds only a
16
17
  conditional instruction block, so Pharo guidance stays out of unrelated work.
17
18
 
@@ -19,9 +20,11 @@ Setup initializes the current directory, creating `plexus.json` with
19
20
  no image when it is absent. Register one before the first `pharo-agent open`:
20
21
 
21
22
  ```sh
22
- pharo-agent image list
23
+ pharo-agent image templates
23
24
  pharo-agent image add dev --template '<launcher template name>'
24
25
  ```
25
26
 
27
+ `image templates` names what the launcher offers, with the category of each.
28
+
26
29
  After setup, read `pharo.AGENTS.md` in this chat before doing Pharo work. A
27
30
  new chat picks up the conditional project guidance automatically.
package/src/cli.js CHANGED
@@ -1,7 +1,13 @@
1
+ import { hostChoices, hostList } from "./hosts.js";
1
2
  import { initialize } from "./init.js";
2
3
  import { addImage, listImages, removeImage, useImage } from "./images.js";
3
4
  import { callImageTool, listImageTools, serveImagesMcp } from "./gateway.js";
4
- import { agentImageStatus, closeAgentImage, openAgentImage } from "./live.js";
5
+ import {
6
+ agentImageStatus,
7
+ closeAgentImage,
8
+ listImageTemplates,
9
+ openAgentImage,
10
+ } from "./live.js";
5
11
  import { findProjectRoot } from "./project.js";
6
12
  import { readOpenImage } from "./runtime.js";
7
13
 
@@ -22,7 +28,7 @@ function parseInit(args) {
22
28
  }
23
29
  throw new Error(`Unknown init option: ${option}`);
24
30
  }
25
- if (!host) throw new Error("init requires --host <codex|claude|opencode>");
31
+ if (!host) throw new Error(`init requires --host <${hostChoices()}>`);
26
32
  return { host, mountRoot };
27
33
  }
28
34
 
@@ -32,7 +38,11 @@ function parseImageAdd(args) {
32
38
  throw new Error("image add requires an image id");
33
39
  }
34
40
  const parsed = { id };
35
- const options = { "--template": "template", "--profile": "profile" };
41
+ const options = {
42
+ "--template": "template",
43
+ "--category": "category",
44
+ "--profile": "profile",
45
+ };
36
46
  for (let index = 0; index < rest.length; index += 1) {
37
47
  const option = rest[index];
38
48
  const key = options[option];
@@ -65,9 +75,10 @@ function takeImageOption(args) {
65
75
  function usage() {
66
76
  return [
67
77
  "Usage:",
68
- " pharo-agent init --host <codex|claude|opencode> [--mount-root <path>]",
78
+ ` pharo-agent init --host <${hostChoices()}> [--mount-root <path>]`,
69
79
  " pharo-agent image list",
70
- " pharo-agent image add <id> --template <name> [--profile <id>]",
80
+ " pharo-agent image templates",
81
+ " pharo-agent image add <id> --template <name> [--category <name>] [--profile <id>]",
71
82
  " pharo-agent image use <id>",
72
83
  " pharo-agent image remove <id>",
73
84
  " pharo-agent open [--image <id>]",
@@ -106,7 +117,10 @@ export async function main(args) {
106
117
  console.log(JSON.stringify({ ok: true, ...result }, null, 2));
107
118
  return;
108
119
  }
109
- if (!command || command === "--help" || command === "help") {
120
+ // A caller reaching for help is asking the same question wherever the word
121
+ // lands, and `image add --help` answering that it needs an image id is no
122
+ // answer at all.
123
+ if (!command || args.some((value) => value === "--help" || value === "help")) {
110
124
  console.log(usage());
111
125
  return;
112
126
  }
@@ -118,6 +132,15 @@ export async function main(args) {
118
132
  console.log(JSON.stringify({ ok: true, ...listImages(projectRoot) }, null, 2));
119
133
  return;
120
134
  }
135
+ if (subcommand === "templates") {
136
+ requireNoArguments("image templates", imageArgs);
137
+ console.log(JSON.stringify(
138
+ { ok: true, ...(await listImageTemplates(projectRoot)) },
139
+ null,
140
+ 2,
141
+ ));
142
+ return;
143
+ }
121
144
  if (subcommand === "add") {
122
145
  console.log(JSON.stringify({ ok: true, ...addImage(projectRoot, parseImageAdd(imageArgs)) }, null, 2));
123
146
  return;
@@ -146,6 +169,9 @@ export async function main(args) {
146
169
  }
147
170
  if (command === "status") {
148
171
  requireNoArguments(command, commandArgs);
172
+ if (image) {
173
+ throw new Error("status reports every open image and does not accept --image");
174
+ }
149
175
  console.log(JSON.stringify({ ok: true, ...agentImageStatus(projectRoot) }, null, 2));
150
176
  return;
151
177
  }
package/src/gateway.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { createRequire } from "node:module";
1
2
  import { PlexusGateway } from "@evref-bl/plexus-gateway";
2
3
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
3
4
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
@@ -7,6 +8,10 @@ import {
7
8
  } from "@modelcontextprotocol/sdk/types.js";
8
9
  import { readJson, readOpenImage, selectRunningImage } from "./runtime.js";
9
10
 
11
+ // Read rather than restate: a hard-coded version is one more place for a
12
+ // release to forget, and the host shows this one to the user.
13
+ export const { version } = createRequire(import.meta.url)("../package.json");
14
+
10
15
  export async function imageGateway(projectRoot, open) {
11
16
  const state = readJson(open.statePath);
12
17
  const { image, mcpServerId } = selectRunningImage(state, open.imageId);
@@ -75,17 +80,17 @@ export async function serveImagesMcp(projectRoot, defaultImage) {
75
80
  const defaultOpen = readOpenImage(projectRoot, defaultImage);
76
81
  const tools = (await listImageTools(projectRoot, defaultOpen))
77
82
  .map((tool) => withImageSelector(tool, defaultImage));
78
- const toolNames = new Set(tools.map((tool) => tool.name));
79
83
 
80
84
  const server = new Server(
81
- { name: "pharo-agent", version: "0.0.0" },
85
+ { name: "pharo-agent", version },
82
86
  { capabilities: { tools: {} } },
83
87
  );
84
88
  server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools }));
85
89
  server.setRequestHandler(CallToolRequestSchema, async (request) => {
86
- if (!toolNames.has(request.params.name)) {
87
- throw new Error(`Unknown Pharo tool: ${request.params.name}`);
88
- }
90
+ // Listed tools are the common ones, not the whole catalogue, and the image
91
+ // answers for any tool it holds. Refusing a name this list does not carry
92
+ // would hide tools that tool_search just found, and the image's own error
93
+ // is the better answer for a name nothing holds.
89
94
  const { image, ...argumentsValue } = request.params.arguments ?? {};
90
95
  const open = readOpenImage(projectRoot, image);
91
96
  return toolResult(
package/src/hosts.js ADDED
@@ -0,0 +1,29 @@
1
+ // The agent hosts this CLI initializes, in the order they are offered. One
2
+ // list, so the usage line, the error message and the documentation cannot
3
+ // drift apart or disagree about which hosts exist.
4
+ export const supportedHosts = [
5
+ { id: "claude", name: "Claude Code", guide: "CLAUDE.md", plugin: true },
6
+ { id: "codex", name: "Codex", guide: "AGENTS.md", plugin: true },
7
+ { id: "opencode", name: "OpenCode", guide: "AGENTS.md", plugin: false },
8
+ ];
9
+
10
+ export const hostIds = supportedHosts.map((host) => host.id);
11
+
12
+ export function hostNamed(id) {
13
+ return supportedHosts.find((host) => host.id === id);
14
+ }
15
+
16
+ export function guideForHost(id) {
17
+ return hostNamed(id)?.guide;
18
+ }
19
+
20
+ // "claude|codex|opencode", for the usage line.
21
+ export function hostChoices() {
22
+ return hostIds.join("|");
23
+ }
24
+
25
+ // "claude, codex, or opencode", for a sentence.
26
+ export function hostList() {
27
+ if (hostIds.length < 2) return hostIds.join("");
28
+ return `${hostIds.slice(0, -1).join(", ")}, or ${hostIds.at(-1)}`;
29
+ }
package/src/images.js CHANGED
@@ -37,7 +37,7 @@ export function listImages(projectRoot) {
37
37
  };
38
38
  }
39
39
 
40
- export function addImage(projectRoot, { id, template, profile }) {
40
+ export function addImage(projectRoot, { id, template, category, profile }) {
41
41
  const config = readProjectConfig(projectRoot);
42
42
  const images = Array.isArray(config.images) ? config.images : [];
43
43
  if (images.some((image) => image.id === id)) {
@@ -57,6 +57,7 @@ export function addImage(projectRoot, { id, template, profile }) {
57
57
  kind: "template",
58
58
  ...(profile ? { profileId: profile } : {}),
59
59
  templateName: template,
60
+ ...(category ? { templateCategory: category } : {}),
60
61
  cleanupPolicy: "workspace_cleanup_only",
61
62
  },
62
63
  };
@@ -66,7 +67,13 @@ export function addImage(projectRoot, { id, template, profile }) {
66
67
  if (isDefault) {
67
68
  writeConfig(projectRoot, { ...pluginConfig, defaultImage: id });
68
69
  }
69
- return { id, imageName, isDefault, templateName: template };
70
+ return {
71
+ id,
72
+ imageName,
73
+ isDefault,
74
+ templateName: template,
75
+ ...(category ? { templateCategory: category } : {}),
76
+ };
70
77
  }
71
78
 
72
79
  export function useImage(projectRoot, id) {
package/src/init.js CHANGED
@@ -1,6 +1,7 @@
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
5
  import {
5
6
  createProjectConfig,
6
7
  enclosingProjectRoot,
@@ -9,14 +10,12 @@ import {
9
10
  projectConfigPath,
10
11
  } from "./project.js";
11
12
 
13
+ // The host guide is the user's file, so our few lines are fenced off inside
14
+ // it. `pharo.AGENTS.md` is ours whole, and needs no fence: one sentence says
15
+ // where it came from, which a reader can act on and init can recognize.
12
16
  const managedStart = "<!-- pharo-agent:begin -->";
13
17
  const managedEnd = "<!-- pharo-agent:end -->";
14
-
15
- const hostGuide = {
16
- claude: "CLAUDE.md",
17
- codex: "AGENTS.md",
18
- opencode: "AGENTS.md",
19
- };
18
+ const generatedBy = "Generated by `pharo-agent init`";
20
19
 
21
20
  function copyDirectory(source, target) {
22
21
  for (const entry of fs.readdirSync(source, { withFileTypes: true })) {
@@ -63,10 +62,7 @@ function writeIfMissing(filePath, content) {
63
62
  function assertGeneratedGuideCanBeUpdated(filePath) {
64
63
  if (!fs.existsSync(filePath)) return;
65
64
  const content = fs.readFileSync(filePath, "utf8");
66
- if (
67
- content.trimEnd() !== pharoGuide().trimEnd()
68
- && (!content.includes(managedStart) || !content.includes(managedEnd))
69
- ) {
65
+ if (!content.includes(generatedBy) && !content.includes(managedStart)) {
70
66
  throw new Error(`Refusing to replace user-owned ${filePath}`);
71
67
  }
72
68
  }
@@ -78,23 +74,40 @@ function guidance() {
78
74
  function pharoGuide() {
79
75
  return `# Pharo project guide
80
76
 
77
+ ${generatedBy}. Edits here are replaced the next time it runs.
78
+
81
79
  \`plexus.json\` declares this project's images. A freshly initialized
82
80
  project declares none; register one before the first \`pharo-agent open\`:
83
81
 
84
82
  \`\`\`sh
83
+ pharo-agent image templates
85
84
  pharo-agent image list
86
85
  pharo-agent image add dev --template '<launcher template name>'
87
86
  \`\`\`
88
87
 
88
+ \`image templates\` names the templates the launcher offers, with the category
89
+ of each; a name two categories offer also needs \`--category <name>\`. Any
90
+ version the launcher offers can be registered, and one outside the MCP range
91
+ opens without MCP tools or a source projection, saying so.
92
+
89
93
  \`image add\` makes the first registered image the default. \`pharo-agent image
90
94
  use <id>\` changes the default, and \`pharo-agent image remove <id>\` drops a
91
95
  declaration. Close an image before removing its declaration.
92
96
 
93
97
  Use \`pharo-agent open\` before live-image work and edit projected source under
94
- \`.pharo-agent/mounts\`. Use \`pharo-agent pharo list\` to inspect the complete
95
- live MCP tool surface and \`pharo-agent pharo <tool> [arguments-json]\` to call
96
- a tool. \`pharo-agent mcp\` exposes the image as a command-scoped stdio MCP
97
- server.
98
+ \`.pharo-agent/mounts\`. Call a tool with
99
+ \`pharo-agent pharo <tool> [arguments-json]\`, and \`pharo-agent mcp\` exposes the
100
+ image as a command-scoped stdio MCP server.
101
+
102
+ \`pharo-agent pharo list\` names the tools the image advertises, which are the
103
+ common ones rather than all of it. Find the rest through the catalogue rather
104
+ than guessing at a name, then call one the same way as any other:
105
+
106
+ \`\`\`sh
107
+ pharo-agent pharo tool_search '{"query":"repository commit"}'
108
+ pharo-agent pharo tool_get '{"name":"repository_commit"}'
109
+ pharo-agent pharo repository_commit '{"name":"MCP","message":"..."}'
110
+ \`\`\`
98
111
 
99
112
  Several images can be open at once. Every image-facing command takes
100
113
  \`--image <id>\`; without it the default image answers. \`pharo-agent open
@@ -151,9 +164,9 @@ function resolveInitProjectRoot(cwd) {
151
164
  }
152
165
 
153
166
  export function initialize({ host, mountRoot, cwd = process.cwd() }) {
154
- const guideName = hostGuide[host];
167
+ const guideName = guideForHost(host);
155
168
  if (!guideName) {
156
- throw new Error("--host must be codex, claude, or opencode");
169
+ throw new Error(`--host must be ${hostList()}`);
157
170
  }
158
171
 
159
172
  const { projectRoot, createdProjectConfig } = resolveInitProjectRoot(cwd);
@@ -173,7 +186,7 @@ export function initialize({ host, mountRoot, cwd = process.cwd() }) {
173
186
  );
174
187
  }
175
188
  writeIfMissing(path.join(directory, ".gitignore"), "mounts/\nruntime/\n");
176
- updateManagedBlock(pharoGuidePath, pharoGuide());
189
+ fs.writeFileSync(pharoGuidePath, pharoGuide());
177
190
  const templates = mcpTemplateDirectory();
178
191
  copyDirectory(path.join(templates, "pharo-references"), path.join(directory, "references"));
179
192
  copyDirectory(path.join(templates, "pharo-skills"), path.join(directory, "skills"));
package/src/live.js CHANGED
@@ -21,7 +21,13 @@ import {
21
21
  writeOpenImages,
22
22
  } from "./runtime.js";
23
23
 
24
- export const pifsRelease = "v0.4.1";
24
+ export const pifsRelease = "v0.6.0";
25
+
26
+ // The class-side PIFSServer messages this plugin sends into the image. The
27
+ // release automation checks that a published PIFS still implements them: a pin
28
+ // bump that leaves a removed selector in place passes every test here, because
29
+ // the tests read generated source, and fails only in a live image.
30
+ export const pifsSelectors = ["startMountedAt:named:", "stopOn:"];
25
31
 
26
32
  export function pifsStartCode(mountPath, imageName) {
27
33
  return `Metacello new
@@ -29,37 +35,66 @@ export function pifsStartCode(mountPath, imageName) {
29
35
  repository: 'github://Evref-BL/pharo-image-fs:${pifsRelease}/src';
30
36
  load: 'Core'.
31
37
  ((Smalltalk globals at: #PIFSServer)
32
- startOn: 0
33
- mountAt: ${smalltalkString(mountPath)} asFileReference
38
+ startMountedAt: ${smalltalkString(mountPath)} asFileReference
34
39
  named: ${smalltalkString(imageName)}) port`;
35
40
  }
36
41
 
37
- export function resultPrintString(result) {
38
- // MCP tool results carry the image's answer as JSON inside a text content part.
39
- if (typeof result === "string") {
42
+ export function pifsStopCode(projectionPort) {
43
+ return `(Smalltalk globals at: #PIFSServer) stopOn: ${projectionPort}`;
44
+ }
45
+
46
+ function namedPrintString(value) {
47
+ // Structured results name the print string, somewhere under the envelope,
48
+ // and reach us either as an object or as JSON inside a text part.
49
+ if (typeof value === "string") {
40
50
  try {
41
- return resultPrintString(JSON.parse(result));
51
+ return namedPrintString(JSON.parse(value));
42
52
  } catch {
43
53
  return undefined;
44
54
  }
45
55
  }
46
- if (!result || typeof result !== "object") return undefined;
47
- if (typeof result.resultPrintString === "string") return result.resultPrintString;
48
- for (const value of Object.values(result)) {
49
- const found = resultPrintString(value);
56
+ if (!value || typeof value !== "object") return undefined;
57
+ if (typeof value.resultPrintString === "string") return value.resultPrintString;
58
+ for (const each of Object.values(value)) {
59
+ const found = namedPrintString(each);
50
60
  if (found) return found;
51
61
  }
52
62
  return undefined;
53
63
  }
54
64
 
65
+ function textPartText(result) {
66
+ if (!result || typeof result !== "object" || !Array.isArray(result.content)) {
67
+ return undefined;
68
+ }
69
+ const part = result.content.find(
70
+ (candidate) =>
71
+ candidate
72
+ && candidate.type === "text"
73
+ && typeof candidate.text === "string",
74
+ );
75
+ const text = part?.text.trim();
76
+ return text === "" ? undefined : text;
77
+ }
78
+
79
+ export function resultPrintString(result) {
80
+ // A server answering with structured content names the print string. In the
81
+ // content-only mode every MCP server defaults to, the text part is the print
82
+ // string, so a bare `60341` arrives as the whole answer.
83
+ return namedPrintString(result) ?? textPartText(result);
84
+ }
55
85
 
56
- export function needsImageCreation(listed, imageId) {
86
+ export function imageCreationPlan(listed, imageId) {
57
87
  const summary = listed.images.find((image) => image.imageId === imageId);
58
88
  if (!summary) {
59
89
  throw new Error(`PLexus does not know image ${imageId}.`);
60
90
  }
61
91
  // PLexus reports an image it has no runtime state for as declared.
62
- return summary.status === "declared";
92
+ if (summary.status === "declared") return "create";
93
+ // A creation that failed leaves runtime state that blocks both a retry and a
94
+ // fresh declaration of the same id, so the id would be spent for good. Clear
95
+ // it and create again.
96
+ if (summary.status === "failed") return "reset";
97
+ return "none";
63
98
  }
64
99
 
65
100
  // The launcher resolves a template name against its default group only, so an
@@ -156,8 +191,10 @@ export async function openAgentImage(projectRoot, imageId) {
156
191
  }
157
192
  fetchPifsRelease(pifsCloneDirectory(projectRoot));
158
193
  const launcher = new ScopedPharoLauncher({ projectRoot });
159
- const created = needsImageCreation(launcher.listImages(), resolved);
194
+ const plan = imageCreationPlan(launcher.listImages(), resolved);
195
+ const created = plan !== "none";
160
196
  if (created) {
197
+ if (plan === "reset") await launcher.resetImage(resolved);
161
198
  await resolveTemplateCategory(projectRoot, launcher, resolved);
162
199
  await launcher.createImage(resolved);
163
200
  }
@@ -170,9 +207,18 @@ export async function openAgentImage(projectRoot, imageId) {
170
207
  try {
171
208
  ({ image, mcpServerId } = selectRunningImage(opened.state, resolved));
172
209
  } catch (error) {
173
- // The image is running but unusable to us, so do not leave it behind.
174
- await closeProject({ projectRoot, imageIds: [resolved] }).catch(() => {});
175
- throw error;
210
+ // An image whose Pharo version is outside the MCP contract still runs, and
211
+ // is still worth having: the caller asked for that version, and the tools
212
+ // it cannot offer are the only thing missing. Keep it, and say what is gone.
213
+ const withoutMcp = unsupportedRunningImage(opened.state, resolved);
214
+ if (!withoutMcp) {
215
+ // The image is running but unusable to us, so do not leave it behind.
216
+ await closeProject({ projectRoot, imageIds: [resolved] }).catch(() => {});
217
+ throw error;
218
+ }
219
+ const open = { statePath: opened.statePath, imageId: resolved, mcp: false };
220
+ writeOpenImages(projectRoot, { ...openImages, [resolved]: open });
221
+ return { ...open, created, warning: pharoMcpWarning(withoutMcp) };
176
222
  }
177
223
  const mountPath = resolveMountPath(projectRoot, readConfig(projectRoot), image.id);
178
224
  fs.mkdirSync(path.dirname(mountPath), { recursive: true });
@@ -182,8 +228,36 @@ export async function openAgentImage(projectRoot, imageId) {
182
228
  mcpServerId,
183
229
  mountPath,
184
230
  };
231
+ let projectionPort;
232
+ try {
233
+ projectionPort = await startProjection(projectRoot, open, image.imageName);
234
+ } catch (error) {
235
+ // The projection is half-started at best and nothing records it yet, so
236
+ // close the image rather than leave an image and a mount nothing owns.
237
+ await closeProject({ projectRoot, imageIds: [resolved] }).catch(() => {});
238
+ throw error;
239
+ }
240
+ writeOpenImages(projectRoot, { ...openImages, [resolved]: { ...open, projectionPort } });
241
+ return { imageId: image.id, created, mountPath, projectionPort };
242
+ }
243
+
244
+ function unsupportedRunningImage(state, imageId) {
245
+ const image = (state.images ?? []).find(
246
+ (candidate) => candidate.id === imageId && candidate.status === "running",
247
+ );
248
+ return image?.pharoMcpContract?.status === "unsupported" ? image : undefined;
249
+ }
250
+
251
+ export function pharoMcpWarning(image) {
252
+ const reason = image.pharoMcpContract?.reason
253
+ ?? "This image Pharo version is outside the supported Pharo MCP range.";
254
+ return `${reason} Image ${image.id} is open without MCP tools or a source projection;`
255
+ + " drive it with the Pharo command line instead.";
256
+ }
257
+
258
+ async function startProjection(projectRoot, open, imageName) {
185
259
  const result = await callImageTool(projectRoot, open, "image_evaluate", {
186
- code: pifsStartCode(mountPath, image.imageName),
260
+ code: pifsStartCode(open.mountPath, imageName),
187
261
  });
188
262
  const projectionPort = Number(resultPrintString(result));
189
263
  if (!Number.isInteger(projectionPort) || projectionPort < 1) {
@@ -191,19 +265,22 @@ export async function openAgentImage(projectRoot, imageId) {
191
265
  `PIFS did not report a projection port: ${JSON.stringify(result).slice(0, 500)}`,
192
266
  );
193
267
  }
194
- writeOpenImages(projectRoot, { ...openImages, [resolved]: { ...open, projectionPort } });
195
- return { imageId: image.id, created, mountPath, projectionPort };
268
+ return projectionPort;
196
269
  }
197
270
 
198
271
  async function closeOneImage(projectRoot, openImages, imageId) {
199
272
  const open = openImages[imageId];
200
273
  let projectionError;
201
- try {
202
- await callImageTool(projectRoot, open, "image_evaluate", {
203
- code: `(Smalltalk globals at: #PIFSServer) stopOn: ${open.projectionPort}`,
204
- });
205
- } catch (error) {
206
- projectionError = error;
274
+ // An image opened without MCP has no projection to stop, and no route to
275
+ // ask over either.
276
+ if (open.projectionPort !== undefined) {
277
+ try {
278
+ await callImageTool(projectRoot, open, "image_evaluate", {
279
+ code: pifsStopCode(open.projectionPort),
280
+ });
281
+ } catch (error) {
282
+ projectionError = error;
283
+ }
207
284
  }
208
285
  const closed = await runLifecycle(
209
286
  () => closeProject({ projectRoot, imageIds: [imageId] }),
@@ -260,3 +337,21 @@ export function agentImageStatus(projectRoot) {
260
337
  })),
261
338
  };
262
339
  }
340
+
341
+ export async function listImageTemplates(projectRoot) {
342
+ // `image add --template` takes a name the launcher must already know, and a
343
+ // name that lands in two categories needs the category as well, so answer
344
+ // both rather than leave the caller guessing a magic string.
345
+ const listed = await new ScopedPharoLauncher({ projectRoot }).listTemplates();
346
+ const counts = new Map();
347
+ for (const template of listed.templates) {
348
+ counts.set(template.name, (counts.get(template.name) ?? 0) + 1);
349
+ }
350
+ return {
351
+ templates: listed.templates.map((template) => ({
352
+ name: template.name,
353
+ category: template.templateCategory,
354
+ ...(counts.get(template.name) > 1 ? { categoryRequired: true } : {}),
355
+ })),
356
+ };
357
+ }
package/src/runtime.js CHANGED
@@ -90,6 +90,12 @@ export function readOpenImage(projectRoot, imageId) {
90
90
  `Image ${resolved} is not open. Run pharo-agent open${imageId ? ` --image ${resolved}` : ""} first.`,
91
91
  );
92
92
  }
93
+ if (open.mcp === false) {
94
+ throw new Error(
95
+ `Image ${resolved} is open without MCP, so it offers no Pharo tools. `
96
+ + "Register an image on a Pharo version the MCP contract supports to use them.",
97
+ );
98
+ }
93
99
  return open;
94
100
  }
95
101