@evref-bl/pharo-agent 0.2.1 → 0.3.1

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.2.1",
4
+ "version": "0.3.1",
5
5
  "description": "Terminal-first Pharo image setup for coding agents.",
6
6
  "author": {
7
7
  "name": "Evref-BL",
package/README.md CHANGED
@@ -15,7 +15,7 @@ on-demand Pharo workflow skills and references from the pinned MCP template
15
15
  package. It does not install an agent-host MCP server.
16
16
 
17
17
  `init` acts on the current directory and never searches above it. If that
18
- directory has no `plexus.project.json`, it creates one that declares the
18
+ directory has no `plexus.json`, it creates one that declares the
19
19
  project and no image; an existing one is left untouched. It refuses to run
20
20
  inside an enclosing PLexus project rather than initializing that project root.
21
21
 
@@ -25,16 +25,23 @@ Register the image the project needs before the first `open`:
25
25
  pharo-agent image add dev --template 'Pharo 13.0 - 64bit (stable)'
26
26
  ```
27
27
 
28
- `image add` registers a template-created image with the pinned MCP load policy
29
- and makes it active when no other image is. `image list`, `image use <id>`,
30
- and `image remove <id>` cover the rest; an open image must be closed before
31
- its declaration changes.
32
-
33
- `open` starts the project image through PLexus, loads PIFS, and mounts its
34
- projection below `.pharo-agent/mounts`. `close` unmounts the projection and
35
- closes the PLexus image. `pharo` calls any exposed MCP tool through the same
36
- project route, while `mcp` exposes that tool surface as a command-scoped stdio
37
- MCP server.
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.
38
45
 
39
46
  Pass `--mount-root <path>` to `init` to use a different projection root.
40
47
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@evref-bl/pharo-agent",
3
- "version": "0.2.1",
3
+ "version": "0.3.1",
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.4.1",
35
- "@evref-bl/plexus": "0.3.0",
36
- "@evref-bl/plexus-gateway": "0.3.0",
34
+ "@evref-bl/pharo-mcp": "1.5.0",
35
+ "@evref-bl/plexus": "0.4.1",
36
+ "@evref-bl/plexus-gateway": "0.4.1",
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.2.1",
4
+ "version": "0.3.1",
5
5
  "description": "Terminal-first Pharo image setup for coding agents.",
6
6
  "author": {
7
7
  "name": "Evref-BL",
@@ -8,14 +8,14 @@ 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.2.1 init --host codex
11
+ npx --yes @evref-bl/pharo-agent@0.3.1 init --host codex
12
12
  ```
13
13
 
14
14
  Use `--host claude` in Claude Code and `--host opencode` in OpenCode.
15
15
  Do not configure an MCP server in the agent host. The command adds only a
16
16
  conditional instruction block, so Pharo guidance stays out of unrelated work.
17
17
 
18
- Setup initializes the current directory, creating `plexus.project.json` with
18
+ Setup initializes the current directory, creating `plexus.json` with
19
19
  no image when it is absent. Register one before the first `pharo-agent open`:
20
20
 
21
21
  ```sh
package/src/cli.js CHANGED
@@ -1,9 +1,9 @@
1
1
  import { initialize } from "./init.js";
2
2
  import { addImage, listImages, removeImage, useImage } from "./images.js";
3
- import { callActiveTool, listActiveTools, serveActiveMcp } from "./gateway.js";
4
- import { closeAgentImage, openAgentImage } from "./live.js";
3
+ import { callImageTool, listImageTools, serveImagesMcp } from "./gateway.js";
4
+ import { agentImageStatus, closeAgentImage, openAgentImage } from "./live.js";
5
5
  import { findProjectRoot } from "./project.js";
6
- import { readActive } from "./runtime.js";
6
+ import { readOpenImage } from "./runtime.js";
7
7
 
8
8
  function parseInit(args) {
9
9
  let host;
@@ -52,6 +52,16 @@ function parseImageAdd(args) {
52
52
  return parsed;
53
53
  }
54
54
 
55
+ function takeImageOption(args) {
56
+ const index = args.indexOf("--image");
57
+ if (index === -1) return { image: undefined, rest: args };
58
+ const image = args[index + 1];
59
+ if (!image) {
60
+ throw new Error("--image requires a value");
61
+ }
62
+ return { image, rest: [...args.slice(0, index), ...args.slice(index + 2)] };
63
+ }
64
+
55
65
  function usage() {
56
66
  return [
57
67
  "Usage:",
@@ -60,12 +70,12 @@ function usage() {
60
70
  " pharo-agent image add <id> --template <name> [--category <name>] [--profile <id>]",
61
71
  " pharo-agent image use <id>",
62
72
  " pharo-agent image remove <id>",
63
- " pharo-agent open",
73
+ " pharo-agent open [--image <id>]",
64
74
  " pharo-agent status",
65
- " pharo-agent close",
66
- " pharo-agent mcp",
67
- " pharo-agent pharo list",
68
- " pharo-agent pharo <tool> [arguments-json]",
75
+ " pharo-agent close [--image <id>]",
76
+ " pharo-agent mcp [--image <id>]",
77
+ " pharo-agent pharo list [--image <id>]",
78
+ " pharo-agent pharo <tool> [arguments-json] [--image <id>]",
69
79
  ].join("\n");
70
80
  }
71
81
 
@@ -123,39 +133,44 @@ export async function main(args) {
123
133
  }
124
134
  throw new Error(`Unsupported image command: ${subcommand ?? ""}\n${usage()}`);
125
135
  }
136
+ const { image, rest: commandArgs } = takeImageOption(rest);
126
137
  if (command === "open") {
127
- requireNoArguments(command, rest);
128
- console.log(JSON.stringify({ ok: true, ...(await openAgentImage(projectRoot)) }, null, 2));
138
+ requireNoArguments(command, commandArgs);
139
+ console.log(JSON.stringify({ ok: true, ...(await openAgentImage(projectRoot, image)) }, null, 2));
129
140
  return;
130
141
  }
131
142
  if (command === "close") {
132
- requireNoArguments(command, rest);
133
- console.log(JSON.stringify({ ok: true, ...(await closeAgentImage(projectRoot)) }, null, 2));
143
+ requireNoArguments(command, commandArgs);
144
+ console.log(JSON.stringify({ ok: true, ...(await closeAgentImage(projectRoot, image)) }, null, 2));
134
145
  return;
135
146
  }
136
147
  if (command === "status") {
137
- requireNoArguments(command, rest);
138
- console.log(JSON.stringify({ ok: true, ...readActive(projectRoot) }, null, 2));
148
+ requireNoArguments(command, commandArgs);
149
+ console.log(JSON.stringify({ ok: true, ...agentImageStatus(projectRoot) }, null, 2));
139
150
  return;
140
151
  }
141
152
  if (command === "mcp") {
142
- requireNoArguments(command, rest);
143
- await serveActiveMcp(projectRoot, readActive(projectRoot));
153
+ requireNoArguments(command, commandArgs);
154
+ await serveImagesMcp(projectRoot, image);
144
155
  return;
145
156
  }
146
157
  if (command === "pharo") {
147
- const [toolName, argumentsJson, ...extra] = rest;
158
+ const [toolName, argumentsJson, ...extra] = commandArgs;
148
159
  if (toolName === "list") {
149
160
  if (argumentsJson || extra.length > 0) throw new Error("pharo list does not accept arguments");
150
- console.log(JSON.stringify(await listActiveTools(projectRoot, readActive(projectRoot)), null, 2));
161
+ console.log(JSON.stringify(
162
+ await listImageTools(projectRoot, readOpenImage(projectRoot, image)),
163
+ null,
164
+ 2,
165
+ ));
151
166
  return;
152
167
  }
153
168
  if (!toolName || extra.length > 0) {
154
169
  throw new Error("pharo requires one tool name and an optional JSON object");
155
170
  }
156
- console.log(JSON.stringify(await callActiveTool(
171
+ console.log(JSON.stringify(await callImageTool(
157
172
  projectRoot,
158
- readActive(projectRoot),
173
+ readOpenImage(projectRoot, image),
159
174
  toolName,
160
175
  parseToolArguments(argumentsJson),
161
176
  ), null, 2));
package/src/gateway.js CHANGED
@@ -1,13 +1,17 @@
1
1
  import { PlexusGateway } from "@evref-bl/plexus-gateway";
2
- import { createGatewayServerWithOptions } from "@evref-bl/plexus-gateway";
2
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
3
3
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
- import { readJson, selectRunningImage } from "./runtime.js";
4
+ import {
5
+ CallToolRequestSchema,
6
+ ListToolsRequestSchema,
7
+ } from "@modelcontextprotocol/sdk/types.js";
8
+ import { readJson, readOpenImage, selectRunningImage } from "./runtime.js";
5
9
 
6
- export async function activeGateway(projectRoot, active) {
7
- const state = readJson(active.statePath);
8
- const { image, mcpServerId } = selectRunningImage(state, active.imageId);
9
- if (image.id !== active.imageId || mcpServerId !== active.mcpServerId) {
10
- throw new Error("The active PLexus image changed; run pharo-agent open again.");
10
+ export async function imageGateway(projectRoot, open) {
11
+ const state = readJson(open.statePath);
12
+ const { image, mcpServerId } = selectRunningImage(state, open.imageId);
13
+ if (image.id !== open.imageId || mcpServerId !== open.mcpServerId) {
14
+ throw new Error(`The PLexus image ${open.imageId} changed; run pharo-agent open again.`);
11
15
  }
12
16
  const gateway = new PlexusGateway({
13
17
  pharoScope: {
@@ -19,7 +23,7 @@ export async function activeGateway(projectRoot, active) {
19
23
  });
20
24
  const registered = await gateway.registerProjectRoute({
21
25
  projectRoot,
22
- statePath: active.statePath,
26
+ statePath: open.statePath,
23
27
  state,
24
28
  });
25
29
  if (!registered.ok) {
@@ -28,8 +32,8 @@ export async function activeGateway(projectRoot, active) {
28
32
  return { gateway, mcpServerId };
29
33
  }
30
34
 
31
- export async function callActiveTool(projectRoot, active, toolName, argumentsValue) {
32
- const { gateway, mcpServerId } = await activeGateway(projectRoot, active);
35
+ export async function callImageTool(projectRoot, open, toolName, argumentsValue) {
36
+ const { gateway, mcpServerId } = await imageGateway(projectRoot, open);
33
37
  const result = await gateway.routeToImage({
34
38
  mcpServerId,
35
39
  toolName,
@@ -41,13 +45,53 @@ export async function callActiveTool(projectRoot, active, toolName, argumentsVal
41
45
  return result.data;
42
46
  }
43
47
 
44
- export async function listActiveTools(projectRoot, active) {
45
- const { gateway } = await activeGateway(projectRoot, active);
48
+ export async function listImageTools(projectRoot, open) {
49
+ const { gateway } = await imageGateway(projectRoot, open);
46
50
  return gateway.refreshPharoTools();
47
51
  }
48
52
 
49
- export async function serveActiveMcp(projectRoot, active) {
50
- const { gateway, mcpServerId } = await activeGateway(projectRoot, active);
51
- const server = createGatewayServerWithOptions(gateway, { fixedMcpServerId: mcpServerId });
53
+ export function withImageSelector(tool, defaultImage) {
54
+ const inputSchema = tool.inputSchema ?? {};
55
+ const properties = { ...(inputSchema.properties ?? {}) };
56
+ delete properties.mcpServerId;
57
+ properties.image = {
58
+ type: "string",
59
+ description: `Project image to call. Defaults to ${defaultImage}.`,
60
+ };
61
+ const required = Array.isArray(inputSchema.required)
62
+ ? inputSchema.required.filter((value) => value !== "mcpServerId")
63
+ : [];
64
+ return { ...tool, inputSchema: { ...inputSchema, properties, required } };
65
+ }
66
+
67
+ export function toolResult(data) {
68
+ if (data && typeof data === "object" && Array.isArray(data.content)) {
69
+ return data;
70
+ }
71
+ return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
72
+ }
73
+
74
+ export async function serveImagesMcp(projectRoot, defaultImage) {
75
+ const defaultOpen = readOpenImage(projectRoot, defaultImage);
76
+ const tools = (await listImageTools(projectRoot, defaultOpen))
77
+ .map((tool) => withImageSelector(tool, defaultImage));
78
+ const toolNames = new Set(tools.map((tool) => tool.name));
79
+
80
+ const server = new Server(
81
+ { name: "pharo-agent", version: "0.0.0" },
82
+ { capabilities: { tools: {} } },
83
+ );
84
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools }));
85
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
86
+ if (!toolNames.has(request.params.name)) {
87
+ throw new Error(`Unknown Pharo tool: ${request.params.name}`);
88
+ }
89
+ const { image, ...argumentsValue } = request.params.arguments ?? {};
90
+ const open = readOpenImage(projectRoot, image);
91
+ return toolResult(
92
+ await callImageTool(projectRoot, open, request.params.name, argumentsValue),
93
+ );
94
+ });
95
+
52
96
  await server.connect(new StdioServerTransport());
53
97
  }
package/src/images.js CHANGED
@@ -1,18 +1,15 @@
1
- import fs from "node:fs";
2
1
  import {
3
2
  configuredImages,
4
3
  projectIdentity,
5
4
  readProjectConfig,
6
5
  writeProjectConfig,
7
6
  } from "./project.js";
8
- import { activePath, readActive } from "./runtime.js";
7
+ import { readConfig, readOpenImages, writeConfig } from "./runtime.js";
9
8
 
10
9
  export const defaultLoadScript = "pharo/load-mcp.st";
11
10
 
12
11
  function assertNoOpenImage(projectRoot, imageId, action) {
13
- if (!fs.existsSync(activePath(projectRoot))) return;
14
- const active = readActive(projectRoot);
15
- if (active.imageId === imageId) {
12
+ if (readOpenImages(projectRoot)[imageId]) {
16
13
  throw new Error(`Image ${imageId} is open. Run pharo-agent close before ${action}.`);
17
14
  }
18
15
  }
@@ -23,11 +20,17 @@ function derivedImageName(projectRoot, imageId) {
23
20
  }
24
21
 
25
22
  export function listImages(projectRoot) {
23
+ const configured = configuredImages(projectRoot);
24
+ const open = readOpenImages(projectRoot);
25
+ const defaultImage = readConfig(projectRoot).defaultImage
26
+ ?? (configured.length === 1 ? configured[0].id : undefined);
26
27
  return {
27
- images: configuredImages(projectRoot).map((image) => ({
28
+ ...(defaultImage ? { defaultImage } : {}),
29
+ images: configured.map((image) => ({
28
30
  id: image.id,
29
31
  imageName: image.imageName,
30
- active: image.active === true,
32
+ isDefault: image.id === defaultImage,
33
+ open: Boolean(open[image.id]),
31
34
  templateName: image.create?.templateName,
32
35
  templateCategory: image.create?.templateCategory,
33
36
  })),
@@ -48,7 +51,7 @@ export function addImage(projectRoot, { id, template, category, profile }) {
48
51
  const image = {
49
52
  id,
50
53
  imageName,
51
- active: !images.some((other) => other.active === true),
54
+ active: true,
52
55
  mcp: { loadScript: defaultLoadScript, loadPolicy: "ifMissing" },
53
56
  create: {
54
57
  kind: "template",
@@ -59,25 +62,20 @@ export function addImage(projectRoot, { id, template, category, profile }) {
59
62
  },
60
63
  };
61
64
  writeProjectConfig(projectRoot, { ...config, images: [...images, image] });
62
- return { id, imageName, active: image.active, templateName: template };
65
+ const pluginConfig = readConfig(projectRoot);
66
+ const isDefault = pluginConfig.defaultImage === undefined && images.length === 0;
67
+ if (isDefault) {
68
+ writeConfig(projectRoot, { ...pluginConfig, defaultImage: id });
69
+ }
70
+ return { id, imageName, isDefault, templateName: template };
63
71
  }
64
72
 
65
73
  export function useImage(projectRoot, id) {
66
- const config = readProjectConfig(projectRoot);
67
- const images = Array.isArray(config.images) ? config.images : [];
68
- if (!images.some((image) => image.id === id)) {
74
+ if (!configuredImages(projectRoot).some((image) => image.id === id)) {
69
75
  throw new Error(`Image ${id} is not configured.`);
70
76
  }
71
- for (const image of images) {
72
- if (image.active === true && image.id !== id) {
73
- assertNoOpenImage(projectRoot, image.id, "switching the active image");
74
- }
75
- }
76
- writeProjectConfig(projectRoot, {
77
- ...config,
78
- images: images.map((image) => ({ ...image, active: image.id === id })),
79
- });
80
- return { activeImageId: id };
77
+ writeConfig(projectRoot, { ...readConfig(projectRoot), defaultImage: id });
78
+ return { defaultImage: id };
81
79
  }
82
80
 
83
81
  export function removeImage(projectRoot, id) {
@@ -91,5 +89,10 @@ export function removeImage(projectRoot, id) {
91
89
  ...config,
92
90
  images: images.filter((image) => image.id !== id),
93
91
  });
92
+ const pluginConfig = readConfig(projectRoot);
93
+ if (pluginConfig.defaultImage === id) {
94
+ const { defaultImage: _removed, ...rest } = pluginConfig;
95
+ writeConfig(projectRoot, rest);
96
+ }
94
97
  return { removedImageId: id };
95
98
  }
package/src/init.js CHANGED
@@ -77,7 +77,7 @@ function guidance() {
77
77
  function pharoGuide() {
78
78
  return `# Pharo project guide
79
79
 
80
- \`plexus.project.json\` declares this project's images. A freshly initialized
80
+ \`plexus.json\` declares this project's images. A freshly initialized
81
81
  project declares none; register one before the first \`pharo-agent open\`:
82
82
 
83
83
  \`\`\`sh
@@ -85,9 +85,9 @@ pharo-agent image list
85
85
  pharo-agent image add dev --template '<launcher template name>'
86
86
  \`\`\`
87
87
 
88
- \`image add\` makes the first registered image active. \`pharo-agent image use
89
- <id>\` switches the active image, and \`pharo-agent image remove <id>\` drops a
90
- declaration. Close an image before changing its declaration.
88
+ \`image add\` makes the first registered image the default. \`pharo-agent image
89
+ use <id>\` changes the default, and \`pharo-agent image remove <id>\` drops a
90
+ declaration. Close an image before removing its declaration.
91
91
 
92
92
  Use \`pharo-agent open\` before live-image work and edit projected source under
93
93
  \`.pharo-agent/mounts\`. Use \`pharo-agent pharo list\` to inspect the complete
@@ -95,6 +95,12 @@ live MCP tool surface and \`pharo-agent pharo <tool> [arguments-json]\` to call
95
95
  a tool. \`pharo-agent mcp\` exposes the image as a command-scoped stdio MCP
96
96
  server.
97
97
 
98
+ Several images can be open at once. Every image-facing command takes
99
+ \`--image <id>\`; without it the default image answers. \`pharo-agent open
100
+ --image <id>\` opens another image without closing the first,
101
+ \`pharo-agent status\` lists what is open, and \`pharo-agent close\` closes
102
+ everything this project opened.
103
+
98
104
  Image, Tonel, Iceberg, disk Git, and host processes are separate states.
99
105
 
100
106
  ## Read first
package/src/live.js CHANGED
@@ -1,17 +1,16 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { closeProject, openProject } from "@evref-bl/plexus";
4
- import { callActiveTool } from "./gateway.js";
5
- import { configuredActiveImageId } from "./project.js";
4
+ import { callImageTool } from "./gateway.js";
6
5
  import {
7
- activePath,
8
- clearActive,
9
- readActive,
6
+ defaultImageId,
10
7
  readConfig,
8
+ readOpenImages,
9
+ resolveImageId,
11
10
  resolveMountPath,
12
11
  selectRunningImage,
13
12
  smalltalkString,
14
- writeActive,
13
+ writeOpenImages,
15
14
  } from "./runtime.js";
16
15
 
17
16
  export const pifsRelease = "v0.4.0";
@@ -37,52 +36,98 @@ export function resultPrintString(result) {
37
36
  return undefined;
38
37
  }
39
38
 
40
- export async function openAgentImage(projectRoot) {
41
- if (fs.existsSync(activePath(projectRoot))) {
42
- return { alreadyOpen: true, ...readActive(projectRoot) };
39
+ export async function openAgentImage(projectRoot, imageId) {
40
+ const resolved = resolveImageId(projectRoot, imageId);
41
+ const openImages = readOpenImages(projectRoot);
42
+ if (openImages[resolved]) {
43
+ return { alreadyOpen: true, ...openImages[resolved] };
43
44
  }
44
- const configuredImageId = configuredActiveImageId(projectRoot);
45
- const opened = await openProject({ projectRoot, imageIds: [configuredImageId] });
45
+ const opened = await openProject({ projectRoot, imageIds: [resolved] });
46
46
  if (!opened.ok || opened.failures.length > 0) {
47
47
  throw new Error(opened.failures.map((failure) => failure.message).join("\n") || "PLexus could not open the project image.");
48
48
  }
49
- const { image, mcpServerId } = selectRunningImage(opened.state, configuredImageId);
49
+ const { image, mcpServerId } = selectRunningImage(opened.state, resolved);
50
50
  const mountPath = resolveMountPath(projectRoot, readConfig(projectRoot), image.id);
51
51
  fs.mkdirSync(path.dirname(mountPath), { recursive: true });
52
- const active = {
52
+ const open = {
53
53
  statePath: opened.statePath,
54
54
  imageId: image.id,
55
55
  mcpServerId,
56
56
  mountPath,
57
57
  };
58
- const result = await callActiveTool(projectRoot, active, "image_evaluate", {
58
+ const result = await callImageTool(projectRoot, open, "image_evaluate", {
59
59
  code: pifsStartCode(mountPath, image.imageName),
60
60
  });
61
- const port = Number(resultPrintString(result));
62
- if (!Number.isInteger(port) || port < 1) {
61
+ const projectionPort = Number(resultPrintString(result));
62
+ if (!Number.isInteger(projectionPort) || projectionPort < 1) {
63
63
  throw new Error("PIFS started but did not report its projection port.");
64
64
  }
65
- writeActive(projectRoot, { ...active, projectionPort: port });
66
- return { imageId: image.id, mountPath, projectionPort: port };
65
+ writeOpenImages(projectRoot, { ...openImages, [resolved]: { ...open, projectionPort } });
66
+ return { imageId: image.id, mountPath, projectionPort };
67
67
  }
68
68
 
69
- export async function closeAgentImage(projectRoot) {
70
- const active = readActive(projectRoot);
69
+ async function closeOneImage(projectRoot, openImages, imageId) {
70
+ const open = openImages[imageId];
71
71
  let projectionError;
72
72
  try {
73
- await callActiveTool(projectRoot, active, "image_evaluate", {
74
- code: `PIFSServer stopOn: ${active.projectionPort}`,
73
+ await callImageTool(projectRoot, open, "image_evaluate", {
74
+ code: `PIFSServer stopOn: ${open.projectionPort}`,
75
75
  });
76
76
  } catch (error) {
77
77
  projectionError = error;
78
78
  }
79
- const closed = await closeProject({ projectRoot, imageIds: [active.imageId] });
79
+ const closed = await closeProject({ projectRoot, imageIds: [imageId] });
80
80
  if (!closed.ok || closed.failures.length > 0) {
81
81
  throw new Error(closed.failures.map((failure) => failure.message).join("\n") || "PLexus could not close the project image.");
82
82
  }
83
- clearActive(projectRoot);
84
- if (projectionError) {
85
- throw new Error(`Image closed, but PIFS cleanup failed: ${projectionError.message}`);
83
+ return { stoppedImages: closed.stoppedImages.map((image) => image.id), projectionError };
84
+ }
85
+
86
+ export async function closeAgentImage(projectRoot, imageId) {
87
+ const openImages = readOpenImages(projectRoot);
88
+ const identifiers = imageId === undefined
89
+ ? Object.keys(openImages)
90
+ : [resolveImageId(projectRoot, imageId)];
91
+ if (identifiers.length === 0) {
92
+ throw new Error("No open pharo-agent image.");
93
+ }
94
+ for (const identifier of identifiers) {
95
+ if (!openImages[identifier]) {
96
+ throw new Error(`Image ${identifier} is not open.`);
97
+ }
98
+ }
99
+
100
+ const stoppedImages = [];
101
+ const projectionErrors = [];
102
+ const remaining = { ...openImages };
103
+ for (const identifier of identifiers) {
104
+ const closed = await closeOneImage(projectRoot, openImages, identifier);
105
+ delete remaining[identifier];
106
+ writeOpenImages(projectRoot, remaining);
107
+ stoppedImages.push(...closed.stoppedImages);
108
+ if (closed.projectionError) {
109
+ projectionErrors.push(`${identifier}: ${closed.projectionError.message}`);
110
+ }
111
+ }
112
+ if (projectionErrors.length > 0) {
113
+ throw new Error(`Images closed, but PIFS cleanup failed: ${projectionErrors.join("; ")}`);
86
114
  }
87
- return { stoppedImages: closed.stoppedImages.map((image) => image.id) };
115
+ return { stoppedImages };
116
+ }
117
+
118
+ export function agentImageStatus(projectRoot) {
119
+ const openImages = readOpenImages(projectRoot);
120
+ let defaultImage;
121
+ try {
122
+ defaultImage = defaultImageId(projectRoot);
123
+ } catch {
124
+ defaultImage = undefined;
125
+ }
126
+ return {
127
+ ...(defaultImage ? { defaultImage } : {}),
128
+ images: Object.values(openImages).map((open) => ({
129
+ ...open,
130
+ isDefault: open.imageId === defaultImage,
131
+ })),
132
+ };
88
133
  }
package/src/project.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
 
4
- export const projectConfigFileName = "plexus.project.json";
4
+ export const projectConfigFileName = "plexus.json";
5
5
 
6
6
  export function findProjectRoot(start = process.cwd()) {
7
7
  let directory = path.resolve(start);
@@ -13,7 +13,7 @@ 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.project.json found above the current directory");
16
+ throw new Error("No plexus.json found above the current directory");
17
17
  }
18
18
  directory = parent;
19
19
  }
@@ -71,18 +71,3 @@ export function configuredImages(projectRoot) {
71
71
  const config = readProjectConfig(projectRoot);
72
72
  return Array.isArray(config.images) ? config.images : [];
73
73
  }
74
-
75
- export function configuredActiveImageId(projectRoot) {
76
- const images = configuredImages(projectRoot).filter((image) => image?.active);
77
- if (images.length === 0) {
78
- throw new Error(
79
- "No active image in plexus.project.json. Register one with pharo-agent image add <id> --template <name>.",
80
- );
81
- }
82
- if (images.length !== 1 || typeof images[0].id !== "string") {
83
- throw new Error(
84
- `pharo-agent needs exactly one active image in plexus.project.json; found ${images.length}.`,
85
- );
86
- }
87
- return images[0].id;
88
- }
package/src/runtime.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
- import { pluginDirectory } from "./project.js";
3
+ import { configuredImages, pluginDirectory } from "./project.js";
4
4
 
5
5
  export function runtimeDirectory(projectRoot) {
6
6
  return path.join(pluginDirectory(projectRoot), "runtime");
@@ -18,25 +18,79 @@ export function readJson(filePath) {
18
18
  }
19
19
  }
20
20
 
21
+ export function configPath(projectRoot) {
22
+ return path.join(pluginDirectory(projectRoot), "config.json");
23
+ }
24
+
21
25
  export function readConfig(projectRoot) {
22
- return readJson(path.join(pluginDirectory(projectRoot), "config.json"));
26
+ return readJson(configPath(projectRoot));
23
27
  }
24
28
 
25
- export function readActive(projectRoot) {
29
+ export function writeConfig(projectRoot, config) {
30
+ fs.writeFileSync(configPath(projectRoot), `${JSON.stringify(config, null, 2)}\n`);
31
+ }
32
+
33
+ export function readOpenImages(projectRoot) {
26
34
  const filePath = activePath(projectRoot);
27
- if (!fs.existsSync(filePath)) {
28
- throw new Error("No active pharo-agent image. Run pharo-agent open first.");
35
+ if (!fs.existsSync(filePath)) return {};
36
+ const recorded = readJson(filePath);
37
+ if (recorded && typeof recorded.images === "object" && recorded.images !== null) {
38
+ return recorded.images;
29
39
  }
30
- return readJson(filePath);
40
+ return typeof recorded?.imageId === "string" ? { [recorded.imageId]: recorded } : {};
31
41
  }
32
42
 
33
- export function writeActive(projectRoot, value) {
43
+ export function writeOpenImages(projectRoot, images) {
44
+ if (Object.keys(images).length === 0) {
45
+ fs.rmSync(activePath(projectRoot), { force: true });
46
+ return;
47
+ }
34
48
  fs.mkdirSync(runtimeDirectory(projectRoot), { recursive: true });
35
- fs.writeFileSync(activePath(projectRoot), `${JSON.stringify(value, null, 2)}\n`);
49
+ fs.writeFileSync(activePath(projectRoot), `${JSON.stringify({ images }, null, 2)}\n`);
50
+ }
51
+
52
+ export function defaultImageId(projectRoot) {
53
+ const configured = readConfig(projectRoot).defaultImage;
54
+ const images = configuredImages(projectRoot);
55
+ if (typeof configured === "string") {
56
+ if (!images.some((image) => image.id === configured)) {
57
+ throw new Error(
58
+ `The default image ${configured} is not configured. Run pharo-agent image use <id>.`,
59
+ );
60
+ }
61
+ return configured;
62
+ }
63
+ if (images.length === 1 && typeof images[0].id === "string") {
64
+ return images[0].id;
65
+ }
66
+ if (images.length === 0) {
67
+ throw new Error(
68
+ "No image is configured. Register one with pharo-agent image add <id> --template <name>.",
69
+ );
70
+ }
71
+ throw new Error(
72
+ `Several images are configured (${images.map((image) => image.id).join(", ")}); `
73
+ + "choose one with pharo-agent image use <id>, or pass --image <id>.",
74
+ );
75
+ }
76
+
77
+ export function resolveImageId(projectRoot, imageId) {
78
+ if (imageId === undefined) return defaultImageId(projectRoot);
79
+ if (!configuredImages(projectRoot).some((image) => image.id === imageId)) {
80
+ throw new Error(`Image ${imageId} is not configured.`);
81
+ }
82
+ return imageId;
36
83
  }
37
84
 
38
- export function clearActive(projectRoot) {
39
- fs.rmSync(activePath(projectRoot), { force: true });
85
+ export function readOpenImage(projectRoot, imageId) {
86
+ const resolved = resolveImageId(projectRoot, imageId);
87
+ const open = readOpenImages(projectRoot)[resolved];
88
+ if (!open) {
89
+ throw new Error(
90
+ `Image ${resolved} is not open. Run pharo-agent open${imageId ? ` --image ${resolved}` : ""} first.`,
91
+ );
92
+ }
93
+ return open;
40
94
  }
41
95
 
42
96
  export function resolveMountPath(projectRoot, config, imageId) {
@@ -45,12 +99,15 @@ export function resolveMountPath(projectRoot, config, imageId) {
45
99
  }
46
100
 
47
101
  export function selectRunningImage(state, imageId) {
48
- const images = (state.images ?? []).filter(
49
- (image) => image.status === "running" && (imageId === undefined || image.id === imageId),
50
- );
102
+ const running = (state.images ?? []).filter((image) => image.status === "running");
103
+ const images = imageId === undefined
104
+ ? running
105
+ : running.filter((image) => image.id === imageId);
51
106
  if (images.length !== 1) {
52
107
  throw new Error(
53
- `pharo-agent needs exactly one running image; PLexus reported ${images.length}.`,
108
+ imageId === undefined
109
+ ? `pharo-agent needs exactly one running image; PLexus reported ${images.length}.`
110
+ : `PLexus reports ${images.length} running images with id ${imageId}.`,
54
111
  );
55
112
  }
56
113
  const image = images[0];