@evref-bl/pharo-agent 0.11.0 → 0.12.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.11.0",
4
+ "version": "0.12.1",
5
5
  "description": "Terminal-first Pharo image setup for coding agents.",
6
6
  "author": {
7
7
  "name": "Evref-BL",
package/.mcp.json CHANGED
@@ -1,8 +1,12 @@
1
1
  {
2
2
  "mcpServers": {
3
3
  "image": {
4
- "command": "node",
5
- "args": ["${CLAUDE_PLUGIN_ROOT}/bin/pharo-agent.js", "mcp"]
4
+ "command": "npx",
5
+ "args": [
6
+ "--yes",
7
+ "@evref-bl/pharo-agent@0.12.1",
8
+ "mcp"
9
+ ]
6
10
  }
7
11
  }
8
12
  }
package/README.md CHANGED
@@ -6,6 +6,20 @@ Claude Code, Codex and OpenCode.
6
6
 
7
7
  ## Setup
8
8
 
9
+ In Claude Code, install it as a plugin:
10
+
11
+ ```text
12
+ /plugin marketplace add Evref-BL/pharo-agent-plugin
13
+ /plugin install pharo-agent@evref-bl
14
+ ```
15
+
16
+ Then ask the agent to enable Pharo tooling in a project. The plugin carries a
17
+ setup skill that runs the command below for you, and declares the `image` MCP
18
+ server, so the running image's tools reach the chat without a server
19
+ configured by hand. [Hosts](docs/hosts.md) covers enabling it per project
20
+ rather than everywhere.
21
+
22
+ Any host can run the CLI instead, which is the whole of the setup either way.
9
23
  In the project directory:
10
24
 
11
25
  ```sh
@@ -35,8 +49,10 @@ the file ownership rules and [Images](docs/images.md) for image-side MCP updates
35
49
 
36
50
  Node 22.12 or later.
37
51
 
38
- The source projection currently supports macOS and needs
39
- [fuse-t](https://www.fuse-t.org/). Its daemon is downloaded for you.
52
+ Images and their Pharo tools run anywhere Node does. The source projection is
53
+ the exception: it needs macOS with [fuse-t](https://www.fuse-t.org/), whose
54
+ daemon is downloaded for you. Everywhere else an image starts with its tools
55
+ and says that the projection is not available, rather than failing.
40
56
 
41
57
  ## What the agent gets
42
58
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@evref-bl/pharo-agent",
3
- "version": "0.11.0",
3
+ "version": "0.12.1",
4
4
  "private": false,
5
5
  "description": "Terminal-first Pharo integration for coding agents.",
6
6
  "license": "Apache-2.0",
@@ -33,7 +33,7 @@
33
33
  },
34
34
  "dependencies": {
35
35
  "@evref-bl/pharo-mcp": "3.3.1",
36
- "@evref-bl/plexus": "0.11.0",
36
+ "@evref-bl/plexus": "0.12.1",
37
37
  "@modelcontextprotocol/sdk": "1.32.1"
38
38
  },
39
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.11.0",
4
+ "version": "0.12.1",
5
5
  "description": "Terminal-first Pharo image setup for coding agents.",
6
6
  "author": {
7
7
  "name": "Evref-BL",
@@ -8,7 +8,7 @@ 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.11.0 init --host claude
11
+ npx --yes @evref-bl/pharo-agent@0.12.1 init --host claude
12
12
  ```
13
13
 
14
14
  Name the host this chat runs in: `--host claude` in Claude Code,
package/src/cli.js CHANGED
@@ -29,11 +29,18 @@ function parseInit(args) {
29
29
  return { host, mountRoot };
30
30
  }
31
31
 
32
+ /**
33
+ * The template is required, and the loop below refuses the call without one,
34
+ * which the dynamic key assignment hides from the typechecker.
35
+ *
36
+ * @returns {{ id: string, template: string, category?: string, profile?: string }}
37
+ */
32
38
  function parseImageAdd(args) {
33
39
  const [id, ...rest] = args;
34
40
  if (!id || id.startsWith("--")) {
35
41
  throw new Error("image add requires an image id");
36
42
  }
43
+ /** @type {{ id: string, template?: string, category?: string, profile?: string }} */
37
44
  const parsed = { id };
38
45
  const options = {
39
46
  "--template": "template",
@@ -56,7 +63,7 @@ function parseImageAdd(args) {
56
63
  if (!parsed.template) {
57
64
  throw new Error("image add requires --template <name>");
58
65
  }
59
- return parsed;
66
+ return { ...parsed, template: parsed.template };
60
67
  }
61
68
 
62
69
  // `image start` and `image stop` name their subject, like `image use` and
package/src/gateway.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { createRequire } from "node:module";
2
2
  import { PlexusGateway } from "@evref-bl/plexus/gateway";
3
+ import { loadProjectState } from "@evref-bl/plexus";
3
4
  import {
4
- readJson,
5
5
  selectRunningImage,
6
6
  unappliedPharoMcpPin,
7
7
  } from "./runtime.js";
@@ -15,7 +15,10 @@ export const { version } = createRequire(import.meta.url)("../package.json");
15
15
  // is nothing to register here and nothing that can go stale between the call
16
16
  // that started the image and this one.
17
17
  export function imageGateway(projectRoot, running) {
18
- const state = readJson(running.statePath);
18
+ const state = loadProjectState(running.statePath);
19
+ if (!state) {
20
+ throw new Error(`PLexus has no state at ${running.statePath}.`);
21
+ }
19
22
  const { image, mcpServerId } = selectRunningImage(state, running.imageId);
20
23
  if (image.id !== running.imageId || mcpServerId !== running.mcpServerId) {
21
24
  throw new Error(`The PLexus image ${running.imageId} changed; run pharo-agent image start again.`);
package/src/help.js CHANGED
@@ -40,15 +40,16 @@ const commands = {
40
40
  },
41
41
  "image start": {
42
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.",
43
+ description: "Create if needed and start an image with its Pharo tools. Omit the id to use the\n"
44
+ + "project's default image. Images already running stay running. A source projection is\n"
45
+ + "mounted on macOS with fuse-t; elsewhere the image starts without one and says so.\n"
46
+ + "Pharo versions outside the MCP support range start without tools or a projection.",
46
47
  example: "image start dev",
47
48
  },
48
49
  "image stop": {
49
50
  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.",
51
+ description: "Stop the named image and any source projection it has, or the project's default\n"
52
+ + "image when no id is given. Use --all to stop every image this project started.",
52
53
  example: "image stop dev",
53
54
  },
54
55
  mcp: {
package/src/images.js CHANGED
@@ -1,9 +1,5 @@
1
- import {
2
- configuredImages,
3
- projectIdentity,
4
- readProjectConfig,
5
- writeProjectConfig,
6
- } from "./project.js";
1
+ import { declareProjectImage, removeProjectImage } from "@evref-bl/plexus";
2
+ import { configuredImages } from "./project.js";
7
3
  import { defaultImageId, readConfig, readRunningImages, writeConfig } from "./runtime.js";
8
4
 
9
5
  export const defaultLoadScript = "pharo/load-mcp.st";
@@ -16,11 +12,6 @@ function assertNotRunning(projectRoot, imageId, action) {
16
12
  }
17
13
  }
18
14
 
19
- function derivedImageName(projectRoot, imageId) {
20
- const { name } = projectIdentity(projectRoot);
21
- return `${name}-{workspaceId}-${imageId}`;
22
- }
23
-
24
15
  export function listImages(projectRoot) {
25
16
  const configured = configuredImages(projectRoot);
26
17
  const running = readRunningImages(projectRoot);
@@ -51,40 +42,37 @@ export function listImages(projectRoot) {
51
42
  };
52
43
  }
53
44
 
54
- /** @param {{ id: string, template?: string, category?: string, profile?: string }} request */
45
+ /**
46
+ * PLexus owns `plexus.json`, so PLexus writes it. This package chooses what
47
+ * to declare — the load script that puts the pinned MCP in the image — and
48
+ * leaves the schema, the name template, the defaults and the validation to
49
+ * the package that reads them back.
50
+ *
51
+ * @param {{ id: string, template: string, category?: string, profile?: string }} request
52
+ */
55
53
  export function addImage(projectRoot, request) {
56
54
  const { id, template, category, profile } = request;
57
- const config = readProjectConfig(projectRoot);
58
- const images = Array.isArray(config.images) ? config.images : [];
59
- if (images.some((image) => image.id === id)) {
60
- throw new Error(`Image ${id} is already configured.`);
61
- }
62
- const imageName = derivedImageName(projectRoot, id);
63
- if (images.some((image) => image.imageName === imageName)) {
64
- throw new Error(`Image name ${imageName} is already configured.`);
55
+ const declaredBefore = configuredImages(projectRoot).length;
56
+ const config = declareProjectImage(projectRoot, {
57
+ id,
58
+ templateName: template,
59
+ ...(category ? { templateCategory: category } : {}),
60
+ ...(profile ? { profileId: profile } : {}),
61
+ loadScript: defaultLoadScript,
62
+ });
63
+ const declared = config.images.find((image) => image.id === id);
64
+ if (!declared) {
65
+ throw new Error(`PLexus declared image ${id} but did not record it.`);
65
66
  }
66
67
 
67
- const image = {
68
- id,
69
- imageName,
70
- mcp: { loadScript: defaultLoadScript, loadPolicy: "ifMissing" },
71
- create: {
72
- kind: "template",
73
- ...(profile ? { profileId: profile } : {}),
74
- templateName: template,
75
- ...(category ? { templateCategory: category } : {}),
76
- cleanupPolicy: "workspace_cleanup_only",
77
- },
78
- };
79
- writeProjectConfig(projectRoot, { ...config, images: [...images, image] });
80
68
  const pluginConfig = readConfig(projectRoot);
81
- const isDefault = pluginConfig.defaultImage === undefined && images.length === 0;
69
+ const isDefault = pluginConfig.defaultImage === undefined && declaredBefore === 0;
82
70
  if (isDefault) {
83
71
  writeConfig(projectRoot, { ...pluginConfig, defaultImage: id });
84
72
  }
85
73
  return {
86
74
  id,
87
- imageName,
75
+ imageName: declared.imageName,
88
76
  isDefault,
89
77
  templateName: template,
90
78
  ...(category ? { templateCategory: category } : {}),
@@ -100,16 +88,11 @@ export function useImage(projectRoot, id) {
100
88
  }
101
89
 
102
90
  export function removeImage(projectRoot, id) {
103
- const config = readProjectConfig(projectRoot);
104
- const images = Array.isArray(config.images) ? config.images : [];
105
- if (!images.some((image) => image.id === id)) {
91
+ if (!configuredImages(projectRoot).some((image) => image.id === id)) {
106
92
  throw new Error(`Image ${id} is not configured.`);
107
93
  }
108
94
  assertNotRunning(projectRoot, id, "removing it");
109
- writeProjectConfig(projectRoot, {
110
- ...config,
111
- images: images.filter((image) => image.id !== id),
112
- });
95
+ removeProjectImage(projectRoot, id);
113
96
  const pluginConfig = readConfig(projectRoot);
114
97
  if (pluginConfig.defaultImage === id) {
115
98
  const { defaultImage: _removed, ...rest } = pluginConfig;
package/src/init.js CHANGED
@@ -137,8 +137,10 @@ starts without MCP tools or a source projection, saying so.
137
137
  use <id>\` changes the default, and \`pharo-agent image remove <id>\` drops a
138
138
  declaration. Stop an image before removing its declaration.
139
139
 
140
- Use \`pharo-agent image start\` before live-image work and edit projected source
141
- under \`.pharo-agent/mounts\`.
140
+ Use \`pharo-agent image start\` before live-image work. On macOS with fuse-t
141
+ installed the image's source is also projected under \`.pharo-agent/mounts\`
142
+ and can be edited as files; everywhere else the Pharo tools are the way in,
143
+ and \`image start\` says so.
142
144
 
143
145
  With the pharo-agent plugin enabled for this project, the image's tools are MCP
144
146
  tools: start an image with \`image_start\` and call the rest by name. Without it,
package/src/live.js CHANGED
@@ -9,7 +9,7 @@ import {
9
9
  stopProjectImage,
10
10
  } from "@evref-bl/plexus";
11
11
  import { callImageTool } from "./gateway.js";
12
- import { readProjectConfig, writeProjectConfig } from "./project.js";
12
+ import { readProjectConfig } from "./project.js";
13
13
  import {
14
14
  readConfig,
15
15
  readRunningImages,
@@ -24,6 +24,48 @@ import {
24
24
 
25
25
  export const pifsRelease = "v0.9.2";
26
26
 
27
+ // fuse-t ships as a macOS installer package, so it lands in /usr/local
28
+ // whatever Homebrew's prefix is. The unversioned symlink is what the daemon
29
+ // links against.
30
+ export const fuseLibraryPath = "/usr/local/lib/libfuse-t.dylib";
31
+
32
+ /**
33
+ * Whether a source projection can be mounted here.
34
+ *
35
+ * PIFS needs macOS and fuse-t, which is a kernel-adjacent install the user
36
+ * has to approve. PLexus and the Pharo MCP run anywhere, so until this was
37
+ * checked, `image start` mounted unconditionally and then stopped the image
38
+ * when the mount failed -- every image on Linux and Windows, and on any Mac
39
+ * without fuse-t. The image and its tools were never the part that could not
40
+ * work.
41
+ *
42
+ * @param {{ platform?: string, exists?: (path: string) => boolean }} [host]
43
+ */
44
+ export function projectionAvailability(host = {}) {
45
+ const platform = host.platform ?? process.platform;
46
+ const exists = host.exists ?? fs.existsSync;
47
+ if (platform !== "darwin") {
48
+ return {
49
+ available: false,
50
+ reason: "A source projection needs macOS and fuse-t, which this platform"
51
+ + ` (${platform}) cannot provide.`,
52
+ };
53
+ }
54
+ if (!exists(fuseLibraryPath)) {
55
+ return {
56
+ available: false,
57
+ reason: "A source projection needs fuse-t, which is not installed."
58
+ + " Install it with `brew install --cask fuse-t`.",
59
+ };
60
+ }
61
+ return { available: true };
62
+ }
63
+
64
+ export function projectionUnavailableWarning(imageId, reason) {
65
+ return `${reason} Image ${imageId} is running with its Pharo tools and no`
66
+ + " source projection; read and edit its code through the tools.";
67
+ }
68
+
27
69
  // The class-side PIFSServer messages this plugin sends into the image. The
28
70
  // release automation checks that a published PIFS still implements them: a pin
29
71
  // bump that leaves a removed selector in place passes every test here, because
@@ -106,48 +148,6 @@ export function imageCreationPlan(listed, imageId) {
106
148
  return "none";
107
149
  }
108
150
 
109
- // The launcher resolves a template name against its default group only, so an
110
- // image naming a template from any other group cannot be created until the
111
- // project config says which group that is. Find it once, on first creation.
112
- export async function resolveTemplateCategory(projectRoot, launcher, imageId) {
113
- const config = readProjectConfig(projectRoot);
114
- const images = Array.isArray(config.images) ? config.images : [];
115
- const configured = images.find((image) => image.id === imageId);
116
- const create = configured?.create;
117
- if (!create?.templateName || create.templateCategory) {
118
- return create?.templateCategory;
119
- }
120
-
121
- const listed = await launcher.listTemplates();
122
- const templates = Array.isArray(listed?.templates) ? listed.templates : [];
123
- const categories = [
124
- ...new Set(
125
- templates
126
- .filter((template) => template?.name === create.templateName)
127
- .map((template) => template?.templateCategory)
128
- .filter(Boolean),
129
- ),
130
- ];
131
- if (categories.length === 0) {
132
- throw new Error(`No template named ${create.templateName} in any category.`);
133
- }
134
- if (categories.length > 1) {
135
- throw new Error(
136
- `Template ${create.templateName} exists in several categories: ${categories.join(", ")}.`
137
- + ` Set create.templateCategory for image ${imageId} to choose one.`,
138
- );
139
- }
140
-
141
- const [templateCategory] = categories;
142
- writeProjectConfig(projectRoot, {
143
- ...config,
144
- images: images.map((image) => (image.id === imageId
145
- ? { ...image, create: { ...image.create, templateCategory } }
146
- : image)),
147
- });
148
- return templateCategory;
149
- }
150
-
151
151
  export function describeLifecycleFailure(failure) {
152
152
  const stderr = failure.launcherResult?.raw?.stderr?.trim();
153
153
  return [
@@ -207,12 +207,14 @@ export async function startAgentImage(projectRoot, imageId) {
207
207
  writeRunningImages(projectRoot, runningImages);
208
208
  restarted = true;
209
209
  }
210
- fetchPifsRelease(pifsCloneDirectory(projectRoot));
210
+ if (projectionAvailability().available) {
211
+ fetchPifsRelease(pifsCloneDirectory(projectRoot));
212
+ }
211
213
  const launcher = new ScopedPharoLauncher({ projectRoot });
212
214
  const plan = imageCreationPlan(await launcher.listImages(), resolved);
213
215
  const created = plan !== "none";
214
216
  if (created) {
215
- await resolveTemplateCategory(projectRoot, launcher, resolved);
217
+ // PLexus resolves which category the template lives in.
216
218
  await launcher.createImage(resolved);
217
219
  }
218
220
  const started = await runLifecycle(
@@ -237,6 +239,24 @@ export async function startAgentImage(projectRoot, imageId) {
237
239
  writeRunningImages(projectRoot, { ...runningImages, [resolved]: record });
238
240
  return { ...record, created, warning: pharoMcpWarning(withoutMcp) };
239
241
  }
242
+ const unappliedPin = unappliedPharoMcpPin(image);
243
+ const projection = projectionAvailability();
244
+ if (!projection.available) {
245
+ // The image and its tools are the point; the projection is the extra this
246
+ // package bundles where the platform allows it.
247
+ const record = { statePath: started.statePath, imageId: image.id, mcpServerId };
248
+ writeRunningImages(projectRoot, { ...runningImages, [resolved]: record });
249
+ return {
250
+ imageId: image.id,
251
+ created,
252
+ ...(restarted ? { restarted } : {}),
253
+ warning: [
254
+ projectionUnavailableWarning(image.id, projection.reason),
255
+ unappliedPin,
256
+ ].filter(Boolean).join("\n"),
257
+ };
258
+ }
259
+
240
260
  const mountPath = resolveMountPath(projectRoot, readConfig(projectRoot), image.id);
241
261
  fs.mkdirSync(path.dirname(mountPath), { recursive: true });
242
262
  const record = {
@@ -255,7 +275,6 @@ export async function startAgentImage(projectRoot, imageId) {
255
275
  throw error;
256
276
  }
257
277
  writeRunningImages(projectRoot, { ...runningImages, [resolved]: { ...record, projectionPort } });
258
- const unappliedPin = unappliedPharoMcpPin(image);
259
278
  return {
260
279
  imageId: image.id,
261
280
  created,
package/src/mcpServer.js CHANGED
@@ -39,8 +39,9 @@ const lifecycleTools = [
39
39
  name: "image_start",
40
40
  description:
41
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.",
42
+ + "when it does not exist yet. The image's own tools are listed once it "
43
+ + "answers. On macOS with fuse-t its source also mounts as files; "
44
+ + "elsewhere the image starts without that and says so.",
44
45
  inputSchema: {
45
46
  type: "object",
46
47
  properties: {
@@ -61,7 +62,7 @@ const lifecycleTools = [
61
62
  {
62
63
  name: "image_stop",
63
64
  description:
64
- "Stop a running image and the source projection this project started for it.",
65
+ "Stop a running image, and any source projection this project started for it.",
65
66
  inputSchema: {
66
67
  type: "object",
67
68
  properties: {
package/src/project.js CHANGED
@@ -1,7 +1,16 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
+ // The config file is PLexus's, so its name and its path come from PLexus.
4
+ // Spelling them here made this package a second definition of another
5
+ // package's format, free to drift from it without anything noticing.
6
+ import {
7
+ createProjectConfigFile,
8
+ loadProjectConfig,
9
+ plexusProjectConfigFileName,
10
+ projectConfigPath as plexusProjectConfigPath,
11
+ } from "@evref-bl/plexus";
3
12
 
4
- export const projectConfigFileName = "plexus.json";
13
+ export const projectConfigFileName = plexusProjectConfigFileName;
5
14
 
6
15
  function projectRootAbove(start) {
7
16
  let directory = path.resolve(start);
@@ -47,43 +56,26 @@ export function enclosingProjectRoot(start) {
47
56
  }
48
57
 
49
58
  export function projectConfigPath(projectRoot) {
50
- return path.join(projectRoot, projectConfigFileName);
59
+ return plexusProjectConfigPath(projectRoot);
51
60
  }
52
61
 
53
62
  export function pluginDirectory(projectRoot) {
54
63
  return path.join(projectRoot, ".pharo-agent");
55
64
  }
56
65
 
57
- export function projectIdentity(projectRoot) {
58
- const name = path.basename(path.resolve(projectRoot));
59
- const id = name
60
- .replaceAll(/[^A-Za-z0-9._-]+/g, "-")
61
- .replaceAll(/^-+|-+$/g, "")
62
- .toLowerCase();
63
- return { id: id || "pharo-project", name };
64
- }
65
-
66
+ // Validated, not merely parsed: a malformed plexus.json then fails naming
67
+ // what is wrong with it, rather than reaching PLexus as a shape it refuses
68
+ // further in.
66
69
  export function readProjectConfig(projectRoot) {
67
- const configPath = projectConfigPath(projectRoot);
68
- try {
69
- return JSON.parse(fs.readFileSync(configPath, "utf8"));
70
- } catch (error) {
71
- throw new Error(`Cannot read ${configPath}: ${error.message}`);
72
- }
73
- }
74
-
75
- export function writeProjectConfig(projectRoot, config) {
76
- fs.writeFileSync(projectConfigPath(projectRoot), `${JSON.stringify(config, null, 2)}\n`);
70
+ return loadProjectConfig(projectRoot);
77
71
  }
78
72
 
79
73
  export function createProjectConfig(projectRoot) {
80
- const { id, name } = projectIdentity(projectRoot);
81
- const config = { id, name, images: [] };
82
- writeProjectConfig(projectRoot, config);
83
- return config;
74
+ return createProjectConfigFile(projectRoot, {
75
+ name: path.basename(path.resolve(projectRoot)),
76
+ });
84
77
  }
85
78
 
86
79
  export function configuredImages(projectRoot) {
87
- const config = readProjectConfig(projectRoot);
88
- return Array.isArray(config.images) ? config.images : [];
80
+ return readProjectConfig(projectRoot).images;
89
81
  }
package/src/runtime.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
+ import { loadProjectState } from "@evref-bl/plexus";
3
4
  import { configuredImages, pluginDirectory } from "./project.js";
4
5
 
5
6
  export function runtimeDirectory(projectRoot) {
@@ -114,13 +115,10 @@ export function imageProcessIsAlive(pid) {
114
115
  }
115
116
 
116
117
  export function recordedImageIsRunning(record) {
117
- let state;
118
- try {
119
- state = readJson(record.statePath);
120
- } catch {
121
- return false;
122
- }
123
- const image = (state.images ?? []).find((candidate) => candidate.id === record.imageId);
118
+ // PLexus owns the state file, so PLexus reads it: a hand-rolled JSON.parse
119
+ // here was this package deciding what another package's format means.
120
+ const state = loadProjectState(record.statePath);
121
+ const image = state?.images.find((candidate) => candidate.id === record.imageId);
124
122
  return image?.status === "running" && imageProcessIsAlive(image.pid);
125
123
  }
126
124