@evref-bl/pharo-agent 0.4.0 → 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.4.0",
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,73 +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. Pass `--profile <id>` to put
30
- it under a particular launcher profile. `image list`, `image use <id>` (change
31
- the default) and `image remove <id>` cover the rest; an open image must be
32
- closed before its declaration is removed.
33
-
34
- `open` starts an image through PLexus, loads PIFS, and mounts its projection
35
- below `.pharo-agent/mounts`. `close` unmounts the projection and closes the
36
- PLexus image. `pharo <tool> [arguments-json]` calls any exposed MCP tool
37
- through the same project route and `pharo list` names the tools an image
38
- offers, while `mcp` exposes that tool surface as a command-scoped stdio MCP
39
- server.
40
-
41
- Several images can be open at once. `open`, `close`, `pharo` and `mcp` accept
42
- `--image <id>`; without it the default image answers, so a project with one
43
- image never mentions an image at all. `open --image <id>` adds an image
44
- without closing the others, and `close` without `--image` closes every image
45
- this project opened. `status` takes no `--image`, because it reports every
46
- open image and which one is the default. Over `mcp`, each Pharo tool takes the
47
- same optional `image` argument.
48
-
49
- 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.
50
19
 
51
20
  ## Requirements
52
21
 
53
22
  Node 22.12 or later.
54
23
 
55
- PIFS currently supports the macOS workflow and needs
56
- [fuse-t](https://www.fuse-t.org/). Its matching daemon is downloaded by PIFS;
57
- Go is not required at runtime.
58
-
59
- The CLI pins released MCP and PLexus dependencies. PLexus, in turn, pins the
60
- MCP release used to prepare the image; PIFS is loaded at its own exact release
61
- 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.
62
26
 
63
- ## Host setup
27
+ ## What the agent gets
64
28
 
65
- Codex and Claude Code can install the repository as a plugin. Its only global
66
- skill initializes the current project; it does not add an MCP server or the
67
- Pharo workflow catalogue to every chat. OpenCode uses the same CLI command
68
- with `--host opencode`.
29
+ Reference, for reading rather than typing: these are the commands the guidance
30
+ hands to the agent.
69
31
 
70
- After initialization, the selected host guide says to read
71
- `pharo.AGENTS.md` only for Pharo work. That guide points to local workflow
72
- skills and references on demand. In the chat that runs setup, read that file
73
- 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.4.0",
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": "2.0.0",
35
- "@evref-bl/plexus": "0.5.1",
36
- "@evref-bl/plexus-gateway": "0.5.1",
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.4.0",
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.4.0 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;
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
@@ -43,32 +43,58 @@ export function pifsStopCode(projectionPort) {
43
43
  return `(Smalltalk globals at: #PIFSServer) stopOn: ${projectionPort}`;
44
44
  }
45
45
 
46
- export function resultPrintString(result) {
47
- // MCP tool results carry the image's answer as JSON inside a text content part.
48
- if (typeof result === "string") {
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") {
49
50
  try {
50
- return resultPrintString(JSON.parse(result));
51
+ return namedPrintString(JSON.parse(value));
51
52
  } catch {
52
53
  return undefined;
53
54
  }
54
55
  }
55
- if (!result || typeof result !== "object") return undefined;
56
- if (typeof result.resultPrintString === "string") return result.resultPrintString;
57
- for (const value of Object.values(result)) {
58
- 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);
59
60
  if (found) return found;
60
61
  }
61
62
  return undefined;
62
63
  }
63
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
+ }
64
85
 
65
- export function needsImageCreation(listed, imageId) {
86
+ export function imageCreationPlan(listed, imageId) {
66
87
  const summary = listed.images.find((image) => image.imageId === imageId);
67
88
  if (!summary) {
68
89
  throw new Error(`PLexus does not know image ${imageId}.`);
69
90
  }
70
91
  // PLexus reports an image it has no runtime state for as declared.
71
- 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";
72
98
  }
73
99
 
74
100
  // The launcher resolves a template name against its default group only, so an
@@ -165,8 +191,10 @@ export async function openAgentImage(projectRoot, imageId) {
165
191
  }
166
192
  fetchPifsRelease(pifsCloneDirectory(projectRoot));
167
193
  const launcher = new ScopedPharoLauncher({ projectRoot });
168
- const created = needsImageCreation(launcher.listImages(), resolved);
194
+ const plan = imageCreationPlan(launcher.listImages(), resolved);
195
+ const created = plan !== "none";
169
196
  if (created) {
197
+ if (plan === "reset") await launcher.resetImage(resolved);
170
198
  await resolveTemplateCategory(projectRoot, launcher, resolved);
171
199
  await launcher.createImage(resolved);
172
200
  }
@@ -179,9 +207,18 @@ export async function openAgentImage(projectRoot, imageId) {
179
207
  try {
180
208
  ({ image, mcpServerId } = selectRunningImage(opened.state, resolved));
181
209
  } catch (error) {
182
- // The image is running but unusable to us, so do not leave it behind.
183
- await closeProject({ projectRoot, imageIds: [resolved] }).catch(() => {});
184
- 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) };
185
222
  }
186
223
  const mountPath = resolveMountPath(projectRoot, readConfig(projectRoot), image.id);
187
224
  fs.mkdirSync(path.dirname(mountPath), { recursive: true });
@@ -191,8 +228,36 @@ export async function openAgentImage(projectRoot, imageId) {
191
228
  mcpServerId,
192
229
  mountPath,
193
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) {
194
259
  const result = await callImageTool(projectRoot, open, "image_evaluate", {
195
- code: pifsStartCode(mountPath, image.imageName),
260
+ code: pifsStartCode(open.mountPath, imageName),
196
261
  });
197
262
  const projectionPort = Number(resultPrintString(result));
198
263
  if (!Number.isInteger(projectionPort) || projectionPort < 1) {
@@ -200,19 +265,22 @@ export async function openAgentImage(projectRoot, imageId) {
200
265
  `PIFS did not report a projection port: ${JSON.stringify(result).slice(0, 500)}`,
201
266
  );
202
267
  }
203
- writeOpenImages(projectRoot, { ...openImages, [resolved]: { ...open, projectionPort } });
204
- return { imageId: image.id, created, mountPath, projectionPort };
268
+ return projectionPort;
205
269
  }
206
270
 
207
271
  async function closeOneImage(projectRoot, openImages, imageId) {
208
272
  const open = openImages[imageId];
209
273
  let projectionError;
210
- try {
211
- await callImageTool(projectRoot, open, "image_evaluate", {
212
- code: pifsStopCode(open.projectionPort),
213
- });
214
- } catch (error) {
215
- 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
+ }
216
284
  }
217
285
  const closed = await runLifecycle(
218
286
  () => closeProject({ projectRoot, imageIds: [imageId] }),
@@ -269,3 +337,21 @@ export function agentImageStatus(projectRoot) {
269
337
  })),
270
338
  };
271
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