@evref-bl/pharo-agent 0.10.2 → 0.11.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.10.2",
4
+ "version": "0.11.0",
5
5
  "description": "Terminal-first Pharo image setup for coding agents.",
6
6
  "author": {
7
7
  "name": "Evref-BL",
package/.mcp.json ADDED
@@ -0,0 +1,8 @@
1
+ {
2
+ "mcpServers": {
3
+ "image": {
4
+ "command": "node",
5
+ "args": ["${CLAUDE_PLUGIN_ROOT}/bin/pharo-agent.js", "mcp"]
6
+ }
7
+ }
8
+ }
package/README.md CHANGED
@@ -14,7 +14,7 @@ npx --yes @evref-bl/pharo-agent init --host <claude|codex|opencode>
14
14
 
15
15
  That is all of it. `init` writes the project's Pharo guidance and copies the
16
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
17
+ image the project needs, starts it and uses its tools by following what was
18
18
  written; ask it for Pharo work as you would for anything else.
19
19
 
20
20
  ## Refreshing an existing project
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@evref-bl/pharo-agent",
3
- "version": "0.10.2",
3
+ "version": "0.11.0",
4
4
  "private": false,
5
5
  "description": "Terminal-first Pharo integration for coding agents.",
6
6
  "license": "Apache-2.0",
@@ -16,6 +16,7 @@
16
16
  "bin",
17
17
  "src",
18
18
  "skills",
19
+ ".mcp.json",
19
20
  "plugin.json",
20
21
  ".agents",
21
22
  ".claude-plugin",
@@ -32,7 +33,7 @@
32
33
  },
33
34
  "dependencies": {
34
35
  "@evref-bl/pharo-mcp": "3.3.1",
35
- "@evref-bl/plexus": "0.10.1",
36
+ "@evref-bl/plexus": "0.11.0",
36
37
  "@modelcontextprotocol/sdk": "1.32.1"
37
38
  },
38
39
  "devDependencies": {
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.10.2",
4
+ "version": "0.11.0",
5
5
  "description": "Terminal-first Pharo image setup for coding agents.",
6
6
  "author": {
7
7
  "name": "Evref-BL",
@@ -8,13 +8,35 @@ Run this only when the user asks to enable or refresh Pharo tooling in this proj
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.10.2 init --host claude
11
+ npx --yes @evref-bl/pharo-agent@0.11.0 init --host claude
12
12
  ```
13
13
 
14
14
  Name the host this chat runs in: `--host claude` in Claude Code,
15
15
  `--host codex` in Codex, `--host opencode` in OpenCode.
16
- Do not configure an MCP server in the agent host. The command adds only a
17
- conditional instruction block, so Pharo guidance stays out of unrelated work.
16
+ The command writes no host configuration. It adds a conditional instruction
17
+ block and this project's own files, so Pharo guidance stays out of unrelated
18
+ work.
19
+
20
+ To get the image's tools as MCP tools in Claude Code, enable this plugin for
21
+ this project rather than configuring a server by hand. Commit
22
+ `.claude/settings.json`:
23
+
24
+ ```json
25
+ {
26
+ "extraKnownMarketplaces": {
27
+ "evref-bl": {
28
+ "source": {
29
+ "source": "git",
30
+ "url": "https://github.com/Evref-BL/pharo-agent-plugin.git"
31
+ }
32
+ }
33
+ },
34
+ "enabledPlugins": { "pharo-agent@evref-bl": true }
35
+ }
36
+ ```
37
+
38
+ A host reads that file when a session starts, so the tools appear in the next
39
+ one. Enabling it per project keeps every other project untouched.
18
40
 
19
41
  After upgrading the plugin/CLI, re-run the setup command from the project root
20
42
  with the same host to refresh the generated guide and copied skills/references.
@@ -24,7 +46,8 @@ existing settings. Image declarations, runtime state, and live image code are
24
46
  preserved. This refresh does not install a newer package or upgrade image tools.
25
47
 
26
48
  Setup initializes the current directory, creating `plexus.json` with
27
- no image when it is absent. Register one before the first `pharo-agent open`:
49
+ no image when it is absent. Register one before the first
50
+ `pharo-agent image start`:
28
51
 
29
52
  ```sh
30
53
  pharo-agent image templates
package/src/cli.js CHANGED
@@ -2,15 +2,11 @@ import { hostChoices, hostList } from "./hosts.js";
2
2
  import { usage } from "./help.js";
3
3
  import { initialize } from "./init.js";
4
4
  import { addImage, listImages, removeImage, useImage } from "./images.js";
5
- import { callImageTool, listImageTools, serveImagesMcp } from "./gateway.js";
6
- import {
7
- agentImageStatus,
8
- closeAgentImage,
9
- listImageTemplates,
10
- openAgentImage,
11
- } from "./live.js";
5
+ import { callImageTool, listImageTools } from "./gateway.js";
6
+ import { serveImagesMcp } from "./mcpServer.js";
7
+ import { listImageTemplates, startAgentImage, stopAgentImage } from "./live.js";
12
8
  import { findProjectRoot } from "./project.js";
13
- import { readOpenImage } from "./runtime.js";
9
+ import { readRunningImage } from "./runtime.js";
14
10
 
15
11
  function parseInit(args) {
16
12
  let host;
@@ -63,6 +59,21 @@ function parseImageAdd(args) {
63
59
  return parsed;
64
60
  }
65
61
 
62
+ // `image start` and `image stop` name their subject, like `image use` and
63
+ // `image remove`; `--image` stays on the commands that scope a call.
64
+ function parseImageTarget(subcommand, args) {
65
+ const all = args.includes("--all");
66
+ const rest = args.filter((value) => value !== "--all");
67
+ const [id, ...extra] = rest;
68
+ if (extra.length > 0 || (id && id.startsWith("--"))) {
69
+ throw new Error(`image ${subcommand} takes one image id`);
70
+ }
71
+ if (all && subcommand !== "stop") {
72
+ throw new Error(`image ${subcommand} does not accept --all`);
73
+ }
74
+ return { id, all };
75
+ }
76
+
66
77
  function takeImageOption(args) {
67
78
  const index = args.indexOf("--image");
68
79
  if (index === -1) return { image: undefined, rest: args };
@@ -115,6 +126,16 @@ export async function main(args) {
115
126
  console.log(JSON.stringify({ ok: true, ...result }, null, 2));
116
127
  return;
117
128
  }
129
+ // Above the project lookup on purpose: a host starts this in whatever
130
+ // directory it likes, which may hold no project at all, and a server that
131
+ // exits is a broken server in the host's list. It resolves a project per
132
+ // request instead, so the reason reaches whoever calls a tool.
133
+ if (command === "mcp") {
134
+ const { image: mcpImage, rest: mcpArgs } = takeImageOption(rest);
135
+ requireNoArguments(command, mcpArgs);
136
+ await serveImagesMcp(findProjectRoot, mcpImage);
137
+ return;
138
+ }
118
139
  const projectRoot = findProjectRoot();
119
140
  if (command === "image") {
120
141
  const [subcommand, ...imageArgs] = rest;
@@ -136,6 +157,24 @@ export async function main(args) {
136
157
  console.log(JSON.stringify({ ok: true, ...addImage(projectRoot, parseImageAdd(imageArgs)) }, null, 2));
137
158
  return;
138
159
  }
160
+ if (subcommand === "start") {
161
+ const { id } = parseImageTarget(subcommand, imageArgs);
162
+ console.log(JSON.stringify(
163
+ { ok: true, ...(await startAgentImage(projectRoot, id)) },
164
+ null,
165
+ 2,
166
+ ));
167
+ return;
168
+ }
169
+ if (subcommand === "stop") {
170
+ const { id, all } = parseImageTarget(subcommand, imageArgs);
171
+ console.log(JSON.stringify(
172
+ { ok: true, ...(await stopAgentImage(projectRoot, id, { all })) },
173
+ null,
174
+ 2,
175
+ ));
176
+ return;
177
+ }
139
178
  if (subcommand === "use" || subcommand === "remove") {
140
179
  const [id, ...extra] = imageArgs;
141
180
  if (!id || extra.length > 0) {
@@ -148,35 +187,12 @@ export async function main(args) {
148
187
  throw new Error(`Unsupported image command: ${subcommand ?? ""}\n${usage()}`);
149
188
  }
150
189
  const { image, rest: commandArgs } = takeImageOption(rest);
151
- if (command === "open") {
152
- requireNoArguments(command, commandArgs);
153
- console.log(JSON.stringify({ ok: true, ...(await openAgentImage(projectRoot, image)) }, null, 2));
154
- return;
155
- }
156
- if (command === "close") {
157
- requireNoArguments(command, commandArgs);
158
- console.log(JSON.stringify({ ok: true, ...(await closeAgentImage(projectRoot, image)) }, null, 2));
159
- return;
160
- }
161
- if (command === "status") {
162
- requireNoArguments(command, commandArgs);
163
- if (image) {
164
- throw new Error("status reports every open image and does not accept --image");
165
- }
166
- console.log(JSON.stringify({ ok: true, ...agentImageStatus(projectRoot) }, null, 2));
167
- return;
168
- }
169
- if (command === "mcp") {
170
- requireNoArguments(command, commandArgs);
171
- await serveImagesMcp(projectRoot, image);
172
- return;
173
- }
174
190
  if (command === "pharo") {
175
191
  const [toolName, argumentsJson, ...extra] = commandArgs;
176
192
  if (toolName === "list") {
177
193
  if (argumentsJson || extra.length > 0) throw new Error("pharo list does not accept arguments");
178
194
  console.log(JSON.stringify(
179
- await listImageTools(projectRoot, readOpenImage(projectRoot, image)),
195
+ await listImageTools(projectRoot, readRunningImage(projectRoot, image)),
180
196
  null,
181
197
  2,
182
198
  ));
@@ -187,7 +203,7 @@ export async function main(args) {
187
203
  }
188
204
  const result = await callImageTool(
189
205
  projectRoot,
190
- readOpenImage(projectRoot, image),
206
+ readRunningImage(projectRoot, image),
191
207
  toolName,
192
208
  parseToolArguments(argumentsJson),
193
209
  );
package/src/gateway.js CHANGED
@@ -1,14 +1,7 @@
1
1
  import { createRequire } from "node:module";
2
2
  import { PlexusGateway } from "@evref-bl/plexus/gateway";
3
- import { Server } from "@modelcontextprotocol/sdk/server/index.js";
4
- import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
5
- import {
6
- CallToolRequestSchema,
7
- ListToolsRequestSchema,
8
- } from "@modelcontextprotocol/sdk/types.js";
9
3
  import {
10
4
  readJson,
11
- readOpenImage,
12
5
  selectRunningImage,
13
6
  unappliedPharoMcpPin,
14
7
  } from "./runtime.js";
@@ -20,15 +13,15 @@ export const { version } = createRequire(import.meta.url)("../package.json");
20
13
  // A route source is the directory, not the route. PLexus re-reads the state
21
14
  // file and the image's endpoint handoff every time it resolves one, so there
22
15
  // is nothing to register here and nothing that can go stale between the call
23
- // that opened the image and this one.
24
- export function imageGateway(projectRoot, open) {
25
- const state = readJson(open.statePath);
26
- const { image, mcpServerId } = selectRunningImage(state, open.imageId);
27
- if (image.id !== open.imageId || mcpServerId !== open.mcpServerId) {
28
- throw new Error(`The PLexus image ${open.imageId} changed; run pharo-agent open again.`);
16
+ // that started the image and this one.
17
+ export function imageGateway(projectRoot, running) {
18
+ const state = readJson(running.statePath);
19
+ const { image, mcpServerId } = selectRunningImage(state, running.imageId);
20
+ if (image.id !== running.imageId || mcpServerId !== running.mcpServerId) {
21
+ throw new Error(`The PLexus image ${running.imageId} changed; run pharo-agent image start again.`);
29
22
  }
30
23
  const gateway = new PlexusGateway({
31
- routeSources: [{ projectRoot, statePath: open.statePath }],
24
+ routeSources: [{ projectRoot, statePath: running.statePath }],
32
25
  pharoScope: {
33
26
  projectId: state.projectId,
34
27
  workspaceId: state.workspaceId,
@@ -49,8 +42,8 @@ export function toolFailureMessage(error, image) {
49
42
  return unappliedPin ? `${error}\n${unappliedPin}` : error;
50
43
  }
51
44
 
52
- export async function callImageTool(projectRoot, open, toolName, argumentsValue) {
53
- const { gateway, mcpServerId, image } = imageGateway(projectRoot, open);
45
+ export async function callImageTool(projectRoot, running, toolName, argumentsValue) {
46
+ const { gateway, mcpServerId, image } = imageGateway(projectRoot, running);
54
47
  const result = await gateway.routeToImage({
55
48
  mcpServerId,
56
49
  toolName,
@@ -64,8 +57,8 @@ export async function callImageTool(projectRoot, open, toolName, argumentsValue)
64
57
  );
65
58
  }
66
59
 
67
- export async function listImageTools(projectRoot, open) {
68
- const { gateway } = imageGateway(projectRoot, open);
60
+ export async function listImageTools(projectRoot, running) {
61
+ const { gateway } = imageGateway(projectRoot, running);
69
62
  return gateway.refreshPharoTools();
70
63
  }
71
64
 
@@ -89,28 +82,3 @@ export function toolResult(data) {
89
82
  }
90
83
  return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
91
84
  }
92
-
93
- export async function serveImagesMcp(projectRoot, defaultImage) {
94
- const defaultOpen = readOpenImage(projectRoot, defaultImage);
95
- const tools = (await listImageTools(projectRoot, defaultOpen))
96
- .map((tool) => withImageSelector(tool, defaultImage));
97
-
98
- const server = new Server(
99
- { name: "pharo-agent", version },
100
- { capabilities: { tools: {} } },
101
- );
102
- server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools }));
103
- server.setRequestHandler(CallToolRequestSchema, async (request) => {
104
- // Listed tools are the common ones, not the whole catalogue, and the image
105
- // answers for any tool it holds. Refusing a name this list does not carry
106
- // would hide tools that tool_search just found, and the image's own error
107
- // is the better answer for a name nothing holds.
108
- const { image, ...argumentsValue } = request.params.arguments ?? {};
109
- const open = readOpenImage(projectRoot, image);
110
- return toolResult(
111
- await callImageTool(projectRoot, open, request.params.name, argumentsValue),
112
- );
113
- });
114
-
115
- await server.connect(new StdioServerTransport());
116
- }
package/src/help.js CHANGED
@@ -10,7 +10,8 @@ const commands = {
10
10
  },
11
11
  "image list": {
12
12
  usage: "image list",
13
- description: "List the project's declared images, default image, and recorded open state.",
13
+ description: "List the project's declared images and the default one, with the mount and\n"
14
+ + "projection port of each image this project has running.",
14
15
  example: "image list",
15
16
  },
16
17
  "image templates": {
@@ -21,55 +22,52 @@ const commands = {
21
22
  },
22
23
  "image add": {
23
24
  usage: "image add <id> --template <name> [--category <name>] [--profile <id>]",
24
- description: "Declare an image with a required id and template name; open creates it later.\n"
25
+ description: "Declare an image with a required id and template name; image start creates it later.\n"
25
26
  + "Use --category for an ambiguous template name and --profile to choose a launcher profile.\n"
26
27
  + "The first declared image becomes the default.",
27
28
  example: "image add dev --template '<template name>'",
28
29
  },
29
30
  "image use": {
30
31
  usage: "image use <id>",
31
- description: "Make the required declared image the project's default. It may already be open.",
32
+ description: "Make the required declared image the project's default. It may already be running.",
32
33
  example: "image use dev",
33
34
  },
34
35
  "image remove": {
35
36
  usage: "image remove <id>",
36
- description: "Remove the required image declaration. Close the image first. Its image files are kept.\n"
37
+ description: "Remove the required image declaration. Stop the image first. Its image files are kept.\n"
37
38
  + "Removing the default image clears the default selection.",
38
39
  example: "image remove dev",
39
40
  },
40
- open: {
41
- usage: "open [--image <id>]",
42
- description: "Create if needed and open an image with its tools and source projection. Omit --image\n"
43
- + "to use the project's default image. Other open images stay open. Pharo versions outside\n"
44
- + "the MCP support range open without tools or a source projection.",
45
- example: "open --image dev",
41
+ "image start": {
42
+ usage: "image start [<id>]",
43
+ description: "Create if needed and start an image with its tools and source projection. Omit the id\n"
44
+ + "to use the project's default image. Images already running stay running. Pharo versions\n"
45
+ + "outside the MCP support range start without tools or a source projection.",
46
+ example: "image start dev",
46
47
  },
47
- status: {
48
- usage: "status",
49
- description: "Report all images this project has open. This command does not accept --image.",
50
- example: "status",
51
- },
52
- close: {
53
- usage: "close [--image <id>]",
54
- description: "Close the selected image and its source projection. Omitting --image closes all images\n"
55
- + "this project opened, rather than only the default image.",
56
- example: "close --image dev",
48
+ "image stop": {
49
+ usage: "image stop [<id>] [--all]",
50
+ description: "Stop the named image and its source projection, or the project's default image when\n"
51
+ + "no id is given. Use --all to stop every image this project started.",
52
+ example: "image stop dev",
57
53
  },
58
54
  mcp: {
59
55
  usage: "mcp [--image <id>]",
60
- description: "Expose an open image's tools as a command-scoped stdio MCP server. Omit --image\n"
61
- + "to use the project's default image. The server exits with this command.",
56
+ description: "Serve this project's images as a command-scoped stdio MCP server. Omit\n"
57
+ + "--image to use the project's default image. It starts whether or not an image\n"
58
+ + "is running: image_start starts one and the image's own tools join the list. The\n"
59
+ + "server exits with this command, and the plugin's .mcp.json starts it for a host.",
62
60
  example: "mcp --image dev",
63
61
  },
64
62
  "pharo list": {
65
63
  usage: "pharo list [--image <id>]",
66
- description: "List an open image's advertised tools. Omit --image to use the project's default image.\n"
64
+ description: "List a running image's advertised tools. Omit --image to use the project's default image.\n"
67
65
  + "Use tool_search and tool_get to discover and inspect the rest of its catalogue.",
68
66
  example: "pharo list",
69
67
  },
70
68
  pharo: {
71
69
  usage: "pharo <tool> [arguments-json] [--image <id>]",
72
- description: "Call a tool in an open image. Arguments must be a JSON object and default to {}.\n"
70
+ description: "Call a tool in a running image. Arguments must be a JSON object and default to {}.\n"
73
71
  + "Omit --image to use the project's default image. Inspect the tool's schema with tool_get\n"
74
72
  + "before calling it; a failed tool result makes the command exit unsuccessfully.",
75
73
  example: 'pharo tool_get \'{"name":"class_search"}\'',
package/src/images.js CHANGED
@@ -4,13 +4,15 @@ import {
4
4
  readProjectConfig,
5
5
  writeProjectConfig,
6
6
  } from "./project.js";
7
- import { readConfig, readOpenImages, writeConfig } from "./runtime.js";
7
+ import { defaultImageId, readConfig, readRunningImages, writeConfig } from "./runtime.js";
8
8
 
9
9
  export const defaultLoadScript = "pharo/load-mcp.st";
10
10
 
11
- function assertNoOpenImage(projectRoot, imageId, action) {
12
- if (readOpenImages(projectRoot)[imageId]) {
13
- throw new Error(`Image ${imageId} is open. Run pharo-agent close before ${action}.`);
11
+ function assertNotRunning(projectRoot, imageId, action) {
12
+ if (readRunningImages(projectRoot)[imageId]) {
13
+ throw new Error(
14
+ `Image ${imageId} is running. Run pharo-agent image stop ${imageId} before ${action}.`,
15
+ );
14
16
  }
15
17
  }
16
18
 
@@ -21,19 +23,31 @@ function derivedImageName(projectRoot, imageId) {
21
23
 
22
24
  export function listImages(projectRoot) {
23
25
  const configured = configuredImages(projectRoot);
24
- const open = readOpenImages(projectRoot);
25
- const defaultImage = readConfig(projectRoot).defaultImage
26
- ?? (configured.length === 1 ? configured[0].id : undefined);
26
+ const running = readRunningImages(projectRoot);
27
+ let defaultImage;
28
+ try {
29
+ defaultImage = defaultImageId(projectRoot);
30
+ } catch {
31
+ // Several declared and none chosen. The list is how a caller finds that
32
+ // out, so it answers rather than raising the question back at them.
33
+ defaultImage = undefined;
34
+ }
27
35
  return {
28
36
  ...(defaultImage ? { defaultImage } : {}),
29
- images: configured.map((image) => ({
30
- id: image.id,
31
- imageName: image.imageName,
32
- isDefault: image.id === defaultImage,
33
- open: Boolean(open[image.id]),
34
- templateName: image.create?.templateName,
35
- templateCategory: image.create?.templateCategory,
36
- })),
37
+ images: configured.map((image) => {
38
+ // The record repeats the id it is keyed by; everything else in it is
39
+ // runtime detail this list carries when the image is running.
40
+ const { imageId: _keyed, ...runtime } = running[image.id] ?? {};
41
+ return {
42
+ id: image.id,
43
+ imageName: image.imageName,
44
+ isDefault: image.id === defaultImage,
45
+ running: Boolean(running[image.id]),
46
+ ...runtime,
47
+ templateName: image.create?.templateName,
48
+ templateCategory: image.create?.templateCategory,
49
+ };
50
+ }),
37
51
  };
38
52
  }
39
53
 
@@ -53,7 +67,6 @@ export function addImage(projectRoot, request) {
53
67
  const image = {
54
68
  id,
55
69
  imageName,
56
- active: true,
57
70
  mcp: { loadScript: defaultLoadScript, loadPolicy: "ifMissing" },
58
71
  create: {
59
72
  kind: "template",
@@ -92,7 +105,7 @@ export function removeImage(projectRoot, id) {
92
105
  if (!images.some((image) => image.id === id)) {
93
106
  throw new Error(`Image ${id} is not configured.`);
94
107
  }
95
- assertNoOpenImage(projectRoot, id, "removing it");
108
+ assertNotRunning(projectRoot, id, "removing it");
96
109
  writeProjectConfig(projectRoot, {
97
110
  ...config,
98
111
  images: images.filter((image) => image.id !== id),
package/src/init.js CHANGED
@@ -120,7 +120,7 @@ outside generated/copied files and outside the host guide's managed block.
120
120
  Refreshing project files does not install a newer package or upgrade image tools.
121
121
 
122
122
  \`plexus.json\` declares this project's images. A freshly initialized
123
- project declares none; register one before the first \`pharo-agent open\`:
123
+ project declares none; register one before the first \`pharo-agent image start\`:
124
124
 
125
125
  \`\`\`sh
126
126
  pharo-agent image templates
@@ -131,16 +131,20 @@ pharo-agent image add dev --template '<launcher template name>'
131
131
  \`image templates\` names the templates the launcher offers, with the category
132
132
  of each; a name two categories offer also needs \`--category <name>\`. Any
133
133
  version the launcher offers can be registered, and one outside the MCP range
134
- opens without MCP tools or a source projection, saying so.
134
+ starts without MCP tools or a source projection, saying so.
135
135
 
136
136
  \`image add\` makes the first registered image the default. \`pharo-agent image
137
137
  use <id>\` changes the default, and \`pharo-agent image remove <id>\` drops a
138
- declaration. Close an image before removing its declaration.
138
+ declaration. Stop an image before removing its declaration.
139
139
 
140
- Use \`pharo-agent open\` before live-image work and edit projected source under
141
- \`.pharo-agent/mounts\`. Call a tool with
142
- \`pharo-agent pharo <tool> [arguments-json]\`, and \`pharo-agent mcp\` exposes the
143
- image as a command-scoped stdio MCP server.
140
+ Use \`pharo-agent image start\` before live-image work and edit projected source
141
+ under \`.pharo-agent/mounts\`.
142
+
143
+ With the pharo-agent plugin enabled for this project, the image's tools are MCP
144
+ tools: start an image with \`image_start\` and call the rest by name. Without it,
145
+ call a tool with \`pharo-agent pharo <tool> [arguments-json]\`, and
146
+ \`pharo-agent mcp\` serves the same surface as a command-scoped stdio MCP
147
+ server.
144
148
 
145
149
  \`pharo-agent pharo list\` names the tools the image advertises, which are the
146
150
  common ones rather than all of it. Find the rest through the catalogue rather
@@ -152,11 +156,11 @@ pharo-agent pharo tool_get '{"name":"repository_commit"}'
152
156
  pharo-agent pharo repository_commit '{"name":"MCP","message":"..."}'
153
157
  \`\`\`
154
158
 
155
- Several images can be open at once. Every image-facing command takes
156
- \`--image <id>\`; without it the default image answers. \`pharo-agent open
157
- --image <id>\` opens another image without closing the first,
158
- \`pharo-agent status\` lists what is open, and \`pharo-agent close\` closes
159
- everything this project opened.
159
+ Several images can run at once. \`pharo-agent image start <id>\` and
160
+ \`pharo-agent image stop <id>\` name their image, \`pharo\` and \`mcp\` take
161
+ \`--image <id>\`, and without either the default image answers.
162
+ \`pharo-agent image list\` says which are running, and
163
+ \`pharo-agent image stop --all\` stops every one this project started.
160
164
 
161
165
  Image, Tonel, Iceberg, disk Git, and host processes are separate states.
162
166
 
package/src/live.js CHANGED
@@ -11,16 +11,15 @@ import {
11
11
  import { callImageTool } from "./gateway.js";
12
12
  import { readProjectConfig, writeProjectConfig } from "./project.js";
13
13
  import {
14
- defaultImageId,
15
- openedImageIsAlive,
16
14
  readConfig,
17
- readOpenImages,
15
+ readRunningImages,
16
+ recordedImageIsRunning,
18
17
  resolveImageId,
19
18
  resolveMountPath,
20
19
  selectRunningImage,
21
20
  smalltalkString,
22
21
  unappliedPharoMcpPin,
23
- writeOpenImages,
22
+ writeRunningImages,
24
23
  } from "./runtime.js";
25
24
 
26
25
  export const pifsRelease = "v0.9.2";
@@ -85,17 +84,25 @@ export function resultPrintString(result) {
85
84
  return namedPrintString(result) ?? textPartText(result);
86
85
  }
87
86
 
87
+ /**
88
+ * The status is spelled out rather than taken as a string: this read branched
89
+ * for a long time on "declared" and "failed", which PLexus does not report,
90
+ * so a declared image was never created and the launch that followed failed
91
+ * on an image that was not there. Naming the four it does report makes the
92
+ * typecheck reject a fifth.
93
+ *
94
+ * @param {{ images: { imageId: string,
95
+ * status: "absent" | "stopped" | "starting" | "running" }[] }} listed
96
+ * @param {string} imageId
97
+ */
88
98
  export function imageCreationPlan(listed, imageId) {
89
99
  const summary = listed.images.find((image) => image.imageId === imageId);
90
100
  if (!summary) {
91
101
  throw new Error(`PLexus does not know image ${imageId}.`);
92
102
  }
93
- // PLexus reports an image it has no runtime state for as declared.
94
- if (summary.status === "declared") return "create";
95
- // A creation that failed leaves runtime state that blocks both a retry and a
96
- // fresh declaration of the same id, so the id would be spent for good. Clear
97
- // it and create again.
98
- if (summary.status === "failed") return "reset";
103
+ // "absent" is PLexus's word for no image file on disk, which is what a
104
+ // declared image looks like until something creates it.
105
+ if (summary.status === "absent") return "create";
99
106
  return "none";
100
107
  }
101
108
 
@@ -185,70 +192,69 @@ export function fetchPifsRelease(cloneDirectory, release = pifsRelease) {
185
192
  return git("fetch", "--tags", "--quiet", "origin");
186
193
  }
187
194
 
188
- export async function openAgentImage(projectRoot, imageId) {
195
+ export async function startAgentImage(projectRoot, imageId) {
189
196
  const resolved = resolveImageId(projectRoot, imageId);
190
- let openImages = readOpenImages(projectRoot);
197
+ let runningImages = readRunningImages(projectRoot);
191
198
  let restarted = false;
192
- if (openImages[resolved]) {
193
- if (openedImageIsAlive(openImages[resolved])) {
194
- return { alreadyOpen: true, ...openImages[resolved] };
199
+ if (runningImages[resolved]) {
200
+ if (recordedImageIsRunning(runningImages[resolved])) {
201
+ return { alreadyRunning: true, ...runningImages[resolved] };
195
202
  }
196
- // The image this record names has exited. Forget it and open again,
197
- // rather than hand back an endpoint nothing answers and say it is open.
198
- const { [resolved]: exited, ...remaining } = openImages;
199
- openImages = remaining;
200
- writeOpenImages(projectRoot, openImages);
203
+ // The image this record names has exited. Forget it and start again,
204
+ // rather than hand back an endpoint nothing answers and call it running.
205
+ const { [resolved]: exited, ...remaining } = runningImages;
206
+ runningImages = remaining;
207
+ writeRunningImages(projectRoot, runningImages);
201
208
  restarted = true;
202
209
  }
203
210
  fetchPifsRelease(pifsCloneDirectory(projectRoot));
204
211
  const launcher = new ScopedPharoLauncher({ projectRoot });
205
- const plan = imageCreationPlan(launcher.listImages(), resolved);
212
+ const plan = imageCreationPlan(await launcher.listImages(), resolved);
206
213
  const created = plan !== "none";
207
214
  if (created) {
208
- if (plan === "reset") await launcher.resetImage(resolved);
209
215
  await resolveTemplateCategory(projectRoot, launcher, resolved);
210
216
  await launcher.createImage(resolved);
211
217
  }
212
- const opened = await runLifecycle(
218
+ const started = await runLifecycle(
213
219
  () => startProjectImage({ projectRoot, imageId: resolved }),
214
- `PLexus could not open image ${resolved}.`,
220
+ `PLexus could not start image ${resolved}.`,
215
221
  );
216
222
  let image;
217
223
  let mcpServerId;
218
224
  try {
219
- ({ image, mcpServerId } = selectRunningImage(opened.state, resolved));
225
+ ({ image, mcpServerId } = selectRunningImage(started.state, resolved));
220
226
  } catch (error) {
221
227
  // An image whose Pharo version is outside the MCP contract still runs, and
222
228
  // is still worth having: the caller asked for that version, and the tools
223
229
  // it cannot offer are the only thing missing. Keep it, and say what is gone.
224
- const withoutMcp = unsupportedRunningImage(opened.state, resolved);
230
+ const withoutMcp = unsupportedRunningImage(started.state, resolved);
225
231
  if (!withoutMcp) {
226
232
  // The image is running but unusable to us, so do not leave it behind.
227
233
  await stopProjectImage({ projectRoot, imageId: resolved }).catch(() => {});
228
234
  throw error;
229
235
  }
230
- const open = { statePath: opened.statePath, imageId: resolved, mcp: false };
231
- writeOpenImages(projectRoot, { ...openImages, [resolved]: open });
232
- return { ...open, created, warning: pharoMcpWarning(withoutMcp) };
236
+ const record = { statePath: started.statePath, imageId: resolved, mcp: false };
237
+ writeRunningImages(projectRoot, { ...runningImages, [resolved]: record });
238
+ return { ...record, created, warning: pharoMcpWarning(withoutMcp) };
233
239
  }
234
240
  const mountPath = resolveMountPath(projectRoot, readConfig(projectRoot), image.id);
235
241
  fs.mkdirSync(path.dirname(mountPath), { recursive: true });
236
- const open = {
237
- statePath: opened.statePath,
242
+ const record = {
243
+ statePath: started.statePath,
238
244
  imageId: image.id,
239
245
  mcpServerId,
240
246
  mountPath,
241
247
  };
242
248
  let projectionPort;
243
249
  try {
244
- projectionPort = await startProjection(projectRoot, open, image.imageName);
250
+ projectionPort = await startProjection(projectRoot, record, image.imageName);
245
251
  } catch (error) {
246
252
  // The projection is half-started at best and nothing records it yet, so
247
- // close the image rather than leave an image and a mount nothing owns.
253
+ // stop the image rather than leave an image and a mount nothing owns.
248
254
  await stopProjectImage({ projectRoot, imageId: resolved }).catch(() => {});
249
255
  throw error;
250
256
  }
251
- writeOpenImages(projectRoot, { ...openImages, [resolved]: { ...open, projectionPort } });
257
+ writeRunningImages(projectRoot, { ...runningImages, [resolved]: { ...record, projectionPort } });
252
258
  const unappliedPin = unappliedPharoMcpPin(image);
253
259
  return {
254
260
  imageId: image.id,
@@ -270,13 +276,13 @@ function unsupportedRunningImage(state, imageId) {
270
276
  export function pharoMcpWarning(image) {
271
277
  const reason = image.pharoMcpContract?.reason
272
278
  ?? "This image Pharo version is outside the supported Pharo MCP range.";
273
- return `${reason} Image ${image.id} is open without MCP tools or a source projection;`
279
+ return `${reason} Image ${image.id} is running without MCP tools or a source projection;`
274
280
  + " drive it with the Pharo command line instead.";
275
281
  }
276
282
 
277
- async function startProjection(projectRoot, open, imageName) {
278
- const result = await callImageTool(projectRoot, open, "image_evaluate", {
279
- code: pifsStartCode(open.mountPath, imageName),
283
+ async function startProjection(projectRoot, record, imageName) {
284
+ const result = await callImageTool(projectRoot, record, "image_evaluate", {
285
+ code: pifsStartCode(record.mountPath, imageName),
280
286
  });
281
287
  const projectionPort = Number(resultPrintString(result));
282
288
  if (!Number.isInteger(projectionPort) || projectionPort < 1) {
@@ -287,76 +293,73 @@ async function startProjection(projectRoot, open, imageName) {
287
293
  return projectionPort;
288
294
  }
289
295
 
290
- async function closeOneImage(projectRoot, openImages, imageId) {
291
- const open = openImages[imageId];
296
+ async function stopOneImage(projectRoot, runningImages, imageId) {
297
+ const record = runningImages[imageId];
292
298
  let projectionError;
293
- // An image opened without MCP has no projection to stop, and no route to
299
+ // An image started without MCP has no projection to stop, and no route to
294
300
  // ask over either.
295
- if (open.projectionPort !== undefined) {
301
+ if (record.projectionPort !== undefined) {
296
302
  try {
297
- await callImageTool(projectRoot, open, "image_evaluate", {
298
- code: pifsStopCode(open.projectionPort),
303
+ await callImageTool(projectRoot, record, "image_evaluate", {
304
+ code: pifsStopCode(record.projectionPort),
299
305
  });
300
306
  } catch (error) {
301
307
  projectionError = error;
302
308
  }
303
309
  }
304
- const closed = await runLifecycle(
310
+ const stopped = await runLifecycle(
305
311
  () => stopProjectImage({ projectRoot, imageId }),
306
- `PLexus could not close image ${imageId}.`,
312
+ `PLexus could not stop image ${imageId}.`,
307
313
  );
308
- return { stoppedImages: closed.stoppedImages.map((image) => image.id), projectionError };
314
+ return { stoppedImage: stopped.stoppedImage?.id, projectionError };
309
315
  }
310
316
 
311
- export async function closeAgentImage(projectRoot, imageId) {
312
- const openImages = readOpenImages(projectRoot);
313
- const identifiers = imageId === undefined
314
- ? Object.keys(openImages)
317
+ /**
318
+ * Stop one image, or with `all`, every image this project started. Naming no
319
+ * image stops the default one, as every other command addresses it; `all` is
320
+ * the cleanup, and says so where the caller can see it.
321
+ *
322
+ * @param {string} projectRoot
323
+ * @param {string} [imageId]
324
+ * @param {{ all?: boolean }} [options]
325
+ */
326
+ export async function stopAgentImage(projectRoot, imageId, options = {}) {
327
+ const runningImages = readRunningImages(projectRoot);
328
+ if (options.all && imageId !== undefined) {
329
+ throw new Error("Stopping every image takes no image id.");
330
+ }
331
+ const identifiers = options.all
332
+ ? Object.keys(runningImages)
315
333
  : [resolveImageId(projectRoot, imageId)];
316
334
  if (identifiers.length === 0) {
317
- throw new Error("No open pharo-agent image.");
335
+ throw new Error("No pharo-agent image is running.");
318
336
  }
319
337
  for (const identifier of identifiers) {
320
- if (!openImages[identifier]) {
321
- throw new Error(`Image ${identifier} is not open.`);
338
+ if (!runningImages[identifier]) {
339
+ throw new Error(`Image ${identifier} is not running.`);
322
340
  }
323
341
  }
324
342
 
325
343
  const stoppedImages = [];
326
344
  const projectionErrors = [];
327
- const remaining = { ...openImages };
345
+ const remaining = { ...runningImages };
328
346
  for (const identifier of identifiers) {
329
- const closed = await closeOneImage(projectRoot, openImages, identifier);
347
+ const stopped = await stopOneImage(projectRoot, runningImages, identifier);
330
348
  delete remaining[identifier];
331
- writeOpenImages(projectRoot, remaining);
332
- stoppedImages.push(...closed.stoppedImages);
333
- if (closed.projectionError) {
334
- projectionErrors.push(`${identifier}: ${closed.projectionError.message}`);
349
+ writeRunningImages(projectRoot, remaining);
350
+ if (stopped.stoppedImage) {
351
+ stoppedImages.push(stopped.stoppedImage);
352
+ }
353
+ if (stopped.projectionError) {
354
+ projectionErrors.push(`${identifier}: ${stopped.projectionError.message}`);
335
355
  }
336
356
  }
337
357
  if (projectionErrors.length > 0) {
338
- throw new Error(`Images closed, but PIFS cleanup failed: ${projectionErrors.join("; ")}`);
358
+ throw new Error(`Images stopped, but PIFS cleanup failed: ${projectionErrors.join("; ")}`);
339
359
  }
340
360
  return { stoppedImages };
341
361
  }
342
362
 
343
- export function agentImageStatus(projectRoot) {
344
- const openImages = readOpenImages(projectRoot);
345
- let defaultImage;
346
- try {
347
- defaultImage = defaultImageId(projectRoot);
348
- } catch {
349
- defaultImage = undefined;
350
- }
351
- return {
352
- ...(defaultImage ? { defaultImage } : {}),
353
- images: Object.values(openImages).map((open) => ({
354
- ...open,
355
- isDefault: open.imageId === defaultImage,
356
- })),
357
- };
358
- }
359
-
360
363
  export async function listImageTemplates(projectRoot) {
361
364
  // `image add --template` takes a name the launcher must already know, and a
362
365
  // name that lands in two categories needs the category as well, so answer
@@ -0,0 +1,153 @@
1
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
2
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3
+ import {
4
+ CallToolRequestSchema,
5
+ ListToolsRequestSchema,
6
+ } from "@modelcontextprotocol/sdk/types.js";
7
+ import {
8
+ callImageTool,
9
+ listImageTools,
10
+ toolResult,
11
+ version,
12
+ withImageSelector,
13
+ } from "./gateway.js";
14
+ import { listImages } from "./images.js";
15
+ import { startAgentImage, stopAgentImage } from "./live.js";
16
+ import { readRunningImage } from "./runtime.js";
17
+
18
+ // The host loads only tool names and these instructions at session start and
19
+ // defers the rest, so this is what decides whether Pharo tools are searched
20
+ // for at all. It is the conditional guidance the generated guide gives,
21
+ // written where the host can act on it.
22
+ export const serverInstructions = `Live Pharo images for this project: reading and writing Smalltalk code in the
23
+ image, running tests, finding senders and implementors, applying critiques,
24
+ and projecting image source as files on disk.
25
+
26
+ Search these tools whenever the task involves Pharo, Smalltalk, a Tonel
27
+ package, or a .st file. Start an image with image_start before calling the
28
+ image's own tools, which are listed once an image answers; image_list says
29
+ what is already running.
30
+
31
+ The image advertises the common tools rather than all of it. Use tool_search
32
+ and tool_get to find the rest, then call one by name like any other.`;
33
+
34
+ // Listed whether or not an image is running, so the list is never empty. A
35
+ // host that caches tools and ignores tools/list_changed would otherwise hold
36
+ // an empty list for the whole session and never learn that one had started.
37
+ const lifecycleTools = [
38
+ {
39
+ name: "image_start",
40
+ description:
41
+ "Start one of this project's Pharo images, creating it from its template "
42
+ + "when it does not exist yet, and mount its source projection. The "
43
+ + "image's own tools are listed once it answers.",
44
+ inputSchema: {
45
+ type: "object",
46
+ properties: {
47
+ image: {
48
+ type: "string",
49
+ description: "Image id to start. Defaults to the project's default image.",
50
+ },
51
+ },
52
+ },
53
+ },
54
+ {
55
+ name: "image_list",
56
+ description:
57
+ "List this project's declared images, which are running, and the route "
58
+ + "and mount of each running one.",
59
+ inputSchema: { type: "object", properties: {} },
60
+ },
61
+ {
62
+ name: "image_stop",
63
+ description:
64
+ "Stop a running image and the source projection this project started for it.",
65
+ inputSchema: {
66
+ type: "object",
67
+ properties: {
68
+ image: {
69
+ type: "string",
70
+ description: "Image id to stop. Defaults to the project's default image.",
71
+ },
72
+ all: {
73
+ type: "boolean",
74
+ description: "Stop every image this project started, instead of one.",
75
+ },
76
+ },
77
+ },
78
+ },
79
+ ];
80
+
81
+ // Resolved per request rather than once at startup. A host starts this server
82
+ // when its session starts, which is before anyone has started an image, so
83
+ // resolving then meant the server died on its first line.
84
+ async function runningImageTools(projectRoot, defaultImage) {
85
+ let running;
86
+ try {
87
+ running = readRunningImage(projectRoot, defaultImage);
88
+ } catch {
89
+ // No image running, none configured, or one running without MCP. The image
90
+ // contributes no tools yet and the lifecycle tools say how to change that.
91
+ return [];
92
+ }
93
+ return (await listImageTools(projectRoot, running))
94
+ .map((tool) => withImageSelector(tool, running.imageId));
95
+ }
96
+
97
+ export async function serveImagesMcp(resolveProjectRoot, defaultImage) {
98
+ const server = new Server(
99
+ { name: "pharo-agent", version },
100
+ { capabilities: { tools: { listChanged: true } }, instructions: serverInstructions },
101
+ );
102
+
103
+ server.setRequestHandler(ListToolsRequestSchema, async () => {
104
+ let projectRoot;
105
+ try {
106
+ projectRoot = resolveProjectRoot();
107
+ } catch {
108
+ // No project here. The lifecycle tools are still listed rather than
109
+ // nothing, so the host has a server that works and a call can say why.
110
+ return { tools: lifecycleTools };
111
+ }
112
+ return {
113
+ tools: [...lifecycleTools, ...(await runningImageTools(projectRoot, defaultImage))],
114
+ };
115
+ });
116
+
117
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
118
+ const { image, all, ...argumentsValue } = request.params.arguments ?? {};
119
+ const selected = image ?? defaultImage;
120
+ // Throws when there is no project, which is the answer the caller needs.
121
+ const projectRoot = resolveProjectRoot();
122
+
123
+ // Matched before the image's catalogue: these three names belong to this
124
+ // server, and an image that happened to hold one must not decide what
125
+ // starting an image means.
126
+ if (request.params.name === "image_start") {
127
+ const started = await startAgentImage(projectRoot, selected);
128
+ await server.sendToolListChanged();
129
+ return toolResult({ ok: true, ...started });
130
+ }
131
+ if (request.params.name === "image_list") {
132
+ return toolResult({ ok: true, ...listImages(projectRoot) });
133
+ }
134
+ if (request.params.name === "image_stop") {
135
+ const stopped = all
136
+ ? await stopAgentImage(projectRoot, undefined, { all: true })
137
+ : await stopAgentImage(projectRoot, selected);
138
+ await server.sendToolListChanged();
139
+ return toolResult({ ok: true, ...stopped });
140
+ }
141
+
142
+ // Listed tools are the common ones, not the whole catalogue, and the image
143
+ // answers for any tool it holds. Refusing a name this list does not carry
144
+ // would hide tools that tool_search just found, and the image's own error
145
+ // is the better answer for a name nothing holds.
146
+ const running = readRunningImage(projectRoot, selected);
147
+ return toolResult(
148
+ await callImageTool(projectRoot, running, request.params.name, argumentsValue),
149
+ );
150
+ });
151
+
152
+ await server.connect(new StdioServerTransport());
153
+ }
package/src/project.js CHANGED
@@ -3,7 +3,7 @@ import path from "node:path";
3
3
 
4
4
  export const projectConfigFileName = "plexus.json";
5
5
 
6
- export function findProjectRoot(start = process.cwd()) {
6
+ function projectRootAbove(start) {
7
7
  let directory = path.resolve(start);
8
8
 
9
9
  while (true) {
@@ -13,12 +13,28 @@ export function findProjectRoot(start = process.cwd()) {
13
13
 
14
14
  const parent = path.dirname(directory);
15
15
  if (parent === directory) {
16
- throw new Error("No plexus.json found above the current directory");
16
+ return undefined;
17
17
  }
18
18
  directory = parent;
19
19
  }
20
20
  }
21
21
 
22
+ export function findProjectRoot(start = process.cwd()) {
23
+ const found = projectRootAbove(start);
24
+ if (found) return found;
25
+
26
+ // A plugin's MCP server is started by the host, in the plugin's own
27
+ // directory rather than the project's, so the working directory leads
28
+ // nowhere. The host says where the project is, and that is only consulted
29
+ // here, after the ordinary search has failed, so running the CLI inside a
30
+ // project still answers with that project.
31
+ const hosted = process.env.CLAUDE_PROJECT_DIR;
32
+ const hostedRoot = hosted ? projectRootAbove(hosted) : undefined;
33
+ if (hostedRoot) return hostedRoot;
34
+
35
+ throw new Error("No plexus.json found above the current directory");
36
+ }
37
+
22
38
  export function enclosingProjectRoot(start) {
23
39
  const directory = path.resolve(start);
24
40
  const parent = path.dirname(directory);
package/src/runtime.js CHANGED
@@ -6,8 +6,8 @@ export function runtimeDirectory(projectRoot) {
6
6
  return path.join(pluginDirectory(projectRoot), "runtime");
7
7
  }
8
8
 
9
- export function activePath(projectRoot) {
10
- return path.join(runtimeDirectory(projectRoot), "active.json");
9
+ export function runningImagesPath(projectRoot) {
10
+ return path.join(runtimeDirectory(projectRoot), "running.json");
11
11
  }
12
12
 
13
13
  export function readJson(filePath) {
@@ -30,23 +30,22 @@ export function writeConfig(projectRoot, config) {
30
30
  fs.writeFileSync(configPath(projectRoot), `${JSON.stringify(config, null, 2)}\n`);
31
31
  }
32
32
 
33
- export function readOpenImages(projectRoot) {
34
- const filePath = activePath(projectRoot);
33
+ export function readRunningImages(projectRoot) {
34
+ const filePath = runningImagesPath(projectRoot);
35
35
  if (!fs.existsSync(filePath)) return {};
36
36
  const recorded = readJson(filePath);
37
- if (recorded && typeof recorded.images === "object" && recorded.images !== null) {
38
- return recorded.images;
39
- }
40
- return typeof recorded?.imageId === "string" ? { [recorded.imageId]: recorded } : {};
37
+ return recorded && typeof recorded.images === "object" && recorded.images !== null
38
+ ? recorded.images
39
+ : {};
41
40
  }
42
41
 
43
- export function writeOpenImages(projectRoot, images) {
42
+ export function writeRunningImages(projectRoot, images) {
44
43
  if (Object.keys(images).length === 0) {
45
- fs.rmSync(activePath(projectRoot), { force: true });
44
+ fs.rmSync(runningImagesPath(projectRoot), { force: true });
46
45
  return;
47
46
  }
48
47
  fs.mkdirSync(runtimeDirectory(projectRoot), { recursive: true });
49
- fs.writeFileSync(activePath(projectRoot), `${JSON.stringify({ images }, null, 2)}\n`);
48
+ fs.writeFileSync(runningImagesPath(projectRoot), `${JSON.stringify({ images }, null, 2)}\n`);
50
49
  }
51
50
 
52
51
  export function defaultImageId(projectRoot) {
@@ -70,7 +69,7 @@ export function defaultImageId(projectRoot) {
70
69
  }
71
70
  throw new Error(
72
71
  `Several images are configured (${images.map((image) => image.id).join(", ")}); `
73
- + "choose one with pharo-agent image use <id>, or pass --image <id>.",
72
+ + "choose one with pharo-agent image use <id>, or name one.",
74
73
  );
75
74
  }
76
75
 
@@ -82,26 +81,26 @@ export function resolveImageId(projectRoot, imageId) {
82
81
  return imageId;
83
82
  }
84
83
 
85
- export function readOpenImage(projectRoot, imageId) {
84
+ export function readRunningImage(projectRoot, imageId) {
86
85
  const resolved = resolveImageId(projectRoot, imageId);
87
- const open = readOpenImages(projectRoot)[resolved];
88
- if (!open) {
86
+ const running = readRunningImages(projectRoot)[resolved];
87
+ if (!running) {
89
88
  throw new Error(
90
- `Image ${resolved} is not open. Run pharo-agent open${imageId ? ` --image ${resolved}` : ""} first.`,
89
+ `Image ${resolved} is not running. Run pharo-agent image start ${resolved} first.`,
91
90
  );
92
91
  }
93
- if (open.mcp === false) {
92
+ if (running.mcp === false) {
94
93
  throw new Error(
95
- `Image ${resolved} is open without MCP, so it offers no Pharo tools. `
94
+ `Image ${resolved} is running without MCP, so it offers no Pharo tools. `
96
95
  + "Register an image on a Pharo version the MCP contract supports to use them.",
97
96
  );
98
97
  }
99
- return open;
98
+ return running;
100
99
  }
101
100
 
102
- // A record in the project's runtime state says this project opened the image,
101
+ // A record in the project's runtime state says this project started the image,
103
102
  // not that the image is still there. PLexus reclaims a dead image's port and
104
- // relaunches on the next open, so the only thing standing between a crashed
103
+ // relaunches on the next start, so the only thing standing between a crashed
105
104
  // image and a working one is believing the record.
106
105
  export function imageProcessIsAlive(pid) {
107
106
  if (!Number.isInteger(pid) || pid <= 0) return false;
@@ -114,14 +113,14 @@ export function imageProcessIsAlive(pid) {
114
113
  }
115
114
  }
116
115
 
117
- export function openedImageIsAlive(open) {
116
+ export function recordedImageIsRunning(record) {
118
117
  let state;
119
118
  try {
120
- state = readJson(open.statePath);
119
+ state = readJson(record.statePath);
121
120
  } catch {
122
121
  return false;
123
122
  }
124
- const image = (state.images ?? []).find((candidate) => candidate.id === open.imageId);
123
+ const image = (state.images ?? []).find((candidate) => candidate.id === record.imageId);
125
124
  return image?.status === "running" && imageProcessIsAlive(image.pid);
126
125
  }
127
126
 
@@ -164,7 +163,7 @@ export function selectRunningImage(state, imageId) {
164
163
  // written against. PLexus records it; this says it.
165
164
  export function unappliedPharoMcpPin(image) {
166
165
  if (image?.pharoMcpLoad?.state !== "provided") return undefined;
167
- return `Image ${image.id} opened with the Pharo MCP it already had, so the version this project pins`
166
+ return `Image ${image.id} started with the Pharo MCP it already had, so the version this project pins`
168
167
  + " was not loaded and its tools may differ from the ones this project expects."
169
168
  + " Recreate the image, or set mcp.loadPolicy to always.";
170
169
  }