forge-openclaw-plugin 0.2.13 → 0.2.15

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.
package/README.md CHANGED
@@ -201,7 +201,7 @@ The skill is entity-format-driven. It teaches the agent how to:
201
201
  - ask only for missing fields
202
202
  - capture goals, projects, tasks, values, patterns, behaviors, beliefs, and trigger reports
203
203
 
204
- For local use, set the plugin origin to `http://127.0.0.1` or `http://localhost` and the plugin will bring Forge up on the configured port automatically.
204
+ For local use, set the plugin origin to `http://127.0.0.1` or `http://localhost` and the plugin will bring Forge up automatically. If you leave the default localhost setup alone and `4317` is already taken, Forge now moves to the next free local port and remembers that choice for future runs.
205
205
 
206
206
  If you want to manage that plugin-managed local runtime cleanly, use:
207
207
 
@@ -217,7 +217,7 @@ These commands only manage the runtime when it was auto-started by the OpenClaw
217
217
  If the local runtime fails to come up, check the plugin-managed runtime log at:
218
218
 
219
219
  ```bash
220
- ~/.openclaw/logs/forge-openclaw-plugin/127.0.0.1-4317.log
220
+ ~/.openclaw/logs/forge-openclaw-plugin/<host>-<port>.log
221
221
  ```
222
222
 
223
223
  On clean installs, the plugin now also repairs missing bundled runtime dependencies on first local start before it launches Forge.
@@ -5,6 +5,7 @@ export type ForgePluginConfig = {
5
5
  port: number;
6
6
  baseUrl: string;
7
7
  webAppUrl: string;
8
+ portSource: "configured" | "default" | "preferred";
8
9
  dataRoot: string;
9
10
  apiToken: string;
10
11
  actorLabel: string;
@@ -1,5 +1,6 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { closeSync, existsSync, mkdirSync, openSync } from "node:fs";
3
+ import net from "node:net";
3
4
  import { homedir } from "node:os";
4
5
  import path from "node:path";
5
6
  import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
@@ -8,18 +9,79 @@ const LOCAL_HOSTNAMES = new Set(["127.0.0.1", "localhost", "::1"]);
8
9
  const STARTUP_TIMEOUT_MS = 15_000;
9
10
  const HEALTHCHECK_TIMEOUT_MS = 1_500;
10
11
  const HEALTHCHECK_INTERVAL_MS = 250;
12
+ const EXISTING_RUNTIME_GRACE_MS = 3_000;
13
+ const MAX_PORT_SCAN_ATTEMPTS = 20;
14
+ const FORGE_PLUGIN_ID = "forge-openclaw-plugin";
11
15
  let managedRuntimeChild = null;
12
16
  let managedRuntimeKey = null;
13
17
  let managedRuntimeLogPath = null;
14
18
  let lastRuntimeExitDetails = null;
15
19
  let startupPromise = null;
20
+ let startupRuntimeKey = null;
16
21
  const dependencyInstallPromises = new Map();
17
22
  function runtimeKey(config) {
18
23
  return `${config.origin}:${config.port}`;
19
24
  }
25
+ function buildForgeBaseUrl(origin, port) {
26
+ const url = new URL(origin.endsWith("/") ? origin : `${origin}/`);
27
+ url.port = String(port);
28
+ url.pathname = "/";
29
+ url.search = "";
30
+ url.hash = "";
31
+ return url.origin;
32
+ }
33
+ function buildForgeWebAppUrl(origin, port) {
34
+ return `${buildForgeBaseUrl(origin, port)}/forge/`;
35
+ }
20
36
  function getRuntimeStatePath(config) {
21
37
  const origin = new URL(config.origin).hostname.toLowerCase().replace(/[^a-z0-9._-]+/g, "-");
22
- return path.join(homedir(), ".openclaw", "run", "forge-openclaw-plugin", `${origin}-${config.port}.json`);
38
+ return path.join(homedir(), ".openclaw", "run", FORGE_PLUGIN_ID, `${origin}-${config.port}.json`);
39
+ }
40
+ function getPreferredPortStatePath(origin) {
41
+ const hostname = new URL(origin).hostname.toLowerCase().replace(/[^a-z0-9._-]+/g, "-");
42
+ return path.join(homedir(), ".openclaw", "run", FORGE_PLUGIN_ID, `${hostname}-preferred-port.json`);
43
+ }
44
+ function applyPortToConfig(config, port, portSource) {
45
+ config.port = port;
46
+ config.baseUrl = buildForgeBaseUrl(config.origin, port);
47
+ config.webAppUrl = buildForgeWebAppUrl(config.origin, port);
48
+ config.portSource = portSource;
49
+ }
50
+ async function writePreferredPortState(config, port) {
51
+ const statePath = getPreferredPortStatePath(config.origin);
52
+ await mkdir(path.dirname(statePath), { recursive: true });
53
+ await writeFile(statePath, `${JSON.stringify({ origin: config.origin, port, updatedAt: new Date().toISOString() }, null, 2)}\n`, "utf8");
54
+ }
55
+ async function isPortAvailable(host, port) {
56
+ return await new Promise((resolve) => {
57
+ const server = net.createServer();
58
+ server.unref();
59
+ server.once("error", (error) => {
60
+ resolve(error.code !== "EADDRINUSE");
61
+ });
62
+ server.listen({ host, port, exclusive: true }, () => {
63
+ server.close(() => resolve(true));
64
+ });
65
+ });
66
+ }
67
+ async function findAvailableLocalPort(host, startPort) {
68
+ for (let candidate = Math.max(1, startPort), attempts = 0; candidate <= 65_535 && attempts < MAX_PORT_SCAN_ATTEMPTS; candidate += 1, attempts += 1) {
69
+ if (await isPortAvailable(host, candidate)) {
70
+ return candidate;
71
+ }
72
+ }
73
+ return null;
74
+ }
75
+ async function relocateLocalRuntimePort(config) {
76
+ if (config.portSource === "configured") {
77
+ throw new Error(`Configured Forge port ${config.port} is already in use on ${new URL(config.origin).hostname}. Set a different plugin port or stop the process using it.`);
78
+ }
79
+ const nextPort = await findAvailableLocalPort("127.0.0.1", config.port + 1);
80
+ if (nextPort === null) {
81
+ throw new Error(`Forge could not find a free localhost port after ${config.port}.`);
82
+ }
83
+ applyPortToConfig(config, nextPort, "preferred");
84
+ await writePreferredPortState(config, nextPort);
23
85
  }
24
86
  async function writeRuntimeState(config, pid) {
25
87
  const statePath = getRuntimeStatePath(config);
@@ -89,7 +151,7 @@ function getCurrentModuleRoot() {
89
151
  }
90
152
  function getRuntimeLogPath(config) {
91
153
  const origin = new URL(config.origin).hostname.toLowerCase().replace(/[^a-z0-9._-]+/g, "-");
92
- return path.join(homedir(), ".openclaw", "logs", "forge-openclaw-plugin", `${origin}-${config.port}.log`);
154
+ return path.join(homedir(), ".openclaw", "logs", FORGE_PLUGIN_ID, `${origin}-${config.port}.log`);
93
155
  }
94
156
  function openRuntimeLogFile(logPath) {
95
157
  mkdirSync(path.dirname(logPath), { recursive: true });
@@ -210,7 +272,7 @@ async function isForgeHealthy(config, timeoutMs) {
210
272
  clearTimeout(timeout);
211
273
  }
212
274
  }
213
- function spawnManagedRuntime(config, plan) {
275
+ async function spawnManagedRuntime(config, plan) {
214
276
  const isPackagedServer = isPackagedServerPlan(plan);
215
277
  const args = isPackagedServer ? [plan.entryFile] : [plan.entryFile, path.join(plan.packageRoot, "server", "src", "index.ts")];
216
278
  const logPath = getRuntimeLogPath(config);
@@ -246,9 +308,20 @@ function spawnManagedRuntime(config, plan) {
246
308
  });
247
309
  managedRuntimeChild = child;
248
310
  managedRuntimeKey = runtimeKey(config);
249
- void writeRuntimeState(config, child.pid).catch(() => {
250
- // State tracking is best effort. Runtime health checks remain authoritative.
251
- });
311
+ try {
312
+ await writeRuntimeState(config, child.pid);
313
+ }
314
+ catch (error) {
315
+ managedRuntimeChild = null;
316
+ managedRuntimeKey = null;
317
+ try {
318
+ process.kill(child.pid, "SIGTERM");
319
+ }
320
+ catch {
321
+ // If the child already exited we still want to surface the state-write failure.
322
+ }
323
+ throw new Error(`Forge local runtime started on ${config.baseUrl}, but the plugin could not persist its state. ${error instanceof Error ? error.message : String(error)}`);
324
+ }
252
325
  }
253
326
  function formatRuntimeFailure(details, config) {
254
327
  if (!details) {
@@ -283,8 +356,21 @@ export async function ensureForgeRuntimeReady(config) {
283
356
  if (await isForgeHealthy(config, HEALTHCHECK_TIMEOUT_MS)) {
284
357
  return;
285
358
  }
359
+ const savedState = await readRuntimeState(config);
360
+ if (savedState && !processExists(savedState.pid)) {
361
+ await clearRuntimeState(config);
362
+ }
363
+ else if (savedState && processExists(savedState.pid)) {
364
+ try {
365
+ await waitForRuntime(config, EXISTING_RUNTIME_GRACE_MS, null);
366
+ return;
367
+ }
368
+ catch {
369
+ await stopForgeRuntime(config);
370
+ }
371
+ }
286
372
  const key = runtimeKey(config);
287
- if (startupPromise && managedRuntimeKey === key) {
373
+ if (startupPromise && (startupRuntimeKey === null || startupRuntimeKey === key)) {
288
374
  return startupPromise;
289
375
  }
290
376
  const plan = resolveLaunchPlan();
@@ -295,13 +381,22 @@ export async function ensureForgeRuntimeReady(config) {
295
381
  if (await isForgeHealthy(config, HEALTHCHECK_TIMEOUT_MS)) {
296
382
  return;
297
383
  }
384
+ startupRuntimeKey = runtimeKey(config);
385
+ if (!(await isPortAvailable("127.0.0.1", config.port))) {
386
+ await relocateLocalRuntimePort(config);
387
+ startupRuntimeKey = runtimeKey(config);
388
+ if (await isForgeHealthy(config, HEALTHCHECK_TIMEOUT_MS)) {
389
+ return;
390
+ }
391
+ }
298
392
  await ensurePackagedRuntimeDependencies(plan, config);
299
393
  if (!managedRuntimeChild || managedRuntimeKey !== key || managedRuntimeChild.killed) {
300
- spawnManagedRuntime(config, plan);
394
+ await spawnManagedRuntime(config, plan);
301
395
  }
302
396
  await waitForRuntime(config, STARTUP_TIMEOUT_MS, managedRuntimeChild?.pid ?? null);
303
397
  })().finally(() => {
304
398
  startupPromise = null;
399
+ startupRuntimeKey = null;
305
400
  });
306
401
  return startupPromise;
307
402
  }
@@ -1,3 +1,6 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import path from "node:path";
1
4
  import { buildForgeBaseUrl, buildForgeWebAppUrl } from "./api-client.js";
2
5
  import { primeForgeRuntime } from "./local-runtime.js";
3
6
  import { registerForgePluginCli, registerForgePluginRoutes } from "./routes.js";
@@ -7,6 +10,7 @@ export const FORGE_PLUGIN_NAME = "Forge";
7
10
  export const FORGE_PLUGIN_DESCRIPTION = "Curated OpenClaw adapter for the Forge collaboration API, UI entrypoint, and localhost auto-start runtime.";
8
11
  export const DEFAULT_FORGE_ORIGIN = "http://127.0.0.1";
9
12
  export const DEFAULT_FORGE_PORT = 4317;
13
+ const LOCAL_HOSTNAMES = new Set(["127.0.0.1", "localhost", "::1"]);
10
14
  function normalizeString(value, fallback) {
11
15
  return typeof value === "string" && value.trim().length > 0 ? value.trim() : fallback;
12
16
  }
@@ -36,15 +40,46 @@ function normalizeTimeout(value, fallback) {
36
40
  }
37
41
  return Math.min(120_000, Math.max(1000, Math.round(value)));
38
42
  }
43
+ function isLocalOrigin(origin) {
44
+ try {
45
+ return LOCAL_HOSTNAMES.has(new URL(origin).hostname.toLowerCase());
46
+ }
47
+ catch {
48
+ return false;
49
+ }
50
+ }
51
+ function getPreferredLocalPortPath(origin) {
52
+ const hostname = new URL(origin).hostname.toLowerCase().replace(/[^a-z0-9._-]+/g, "-");
53
+ return path.join(homedir(), ".openclaw", "run", FORGE_PLUGIN_ID, `${hostname}-preferred-port.json`);
54
+ }
55
+ function readPreferredLocalPort(origin) {
56
+ if (!isLocalOrigin(origin)) {
57
+ return null;
58
+ }
59
+ try {
60
+ const preferredPortPath = getPreferredLocalPortPath(origin);
61
+ if (!existsSync(preferredPortPath)) {
62
+ return null;
63
+ }
64
+ const payload = JSON.parse(readFileSync(preferredPortPath, "utf8"));
65
+ return typeof payload.port === "number" && Number.isFinite(payload.port) ? payload.port : null;
66
+ }
67
+ catch {
68
+ return null;
69
+ }
70
+ }
39
71
  export function resolveForgePluginConfig(pluginConfig) {
40
72
  const raw = (pluginConfig ?? {});
41
73
  const origin = normalizeOrigin(raw.origin, DEFAULT_FORGE_ORIGIN);
42
- const port = normalizePort(raw.port, DEFAULT_FORGE_PORT);
74
+ const hasConfiguredPort = typeof raw.port === "number" && Number.isFinite(raw.port);
75
+ const preferredPort = hasConfiguredPort ? null : readPreferredLocalPort(origin);
76
+ const port = normalizePort(hasConfiguredPort ? raw.port : preferredPort ?? DEFAULT_FORGE_PORT, DEFAULT_FORGE_PORT);
43
77
  return {
44
78
  origin,
45
79
  port,
46
80
  baseUrl: buildForgeBaseUrl(origin, port),
47
81
  webAppUrl: buildForgeWebAppUrl(origin, port),
82
+ portSource: hasConfiguredPort ? "configured" : preferredPort !== null ? "preferred" : "default",
48
83
  dataRoot: typeof raw.dataRoot === "string" ? raw.dataRoot.trim() : "",
49
84
  apiToken: typeof raw.apiToken === "string" ? raw.apiToken.trim() : "",
50
85
  actorLabel: normalizeString(raw.actorLabel, "aurel"),
@@ -69,7 +104,7 @@ export const forgePluginConfigSchema = {
69
104
  default: DEFAULT_FORGE_PORT,
70
105
  minimum: 1,
71
106
  maximum: 65535,
72
- description: "Forge server port. Override this when your local machine uses a different port."
107
+ description: "Forge server port. Override this only when you want to pin a specific port. Default localhost installs can move to the next free port automatically if 4317 is already taken."
73
108
  },
74
109
  dataRoot: {
75
110
  type: "string",
@@ -103,7 +138,7 @@ export const forgePluginConfigSchema = {
103
138
  },
104
139
  port: {
105
140
  label: "Forge Port",
106
- help: "Forge server port. Change this if your local machine uses another port.",
141
+ help: "Forge server port. Change this only when you want to pin a specific port. Default localhost installs can move to the next free port automatically if 4317 is busy.",
107
142
  placeholder: "4317"
108
143
  },
109
144
  dataRoot: {
@@ -2,7 +2,7 @@
2
2
  "id": "forge-openclaw-plugin",
3
3
  "name": "Forge",
4
4
  "description": "Curated OpenClaw adapter for the Forge collaboration API, UI entrypoint, and localhost auto-start runtime.",
5
- "version": "0.2.13",
5
+ "version": "0.2.15",
6
6
  "skills": [
7
7
  "./skills"
8
8
  ],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "forge-openclaw-plugin",
3
- "version": "0.2.13",
3
+ "version": "0.2.15",
4
4
  "description": "Curated OpenClaw adapter for the Forge collaboration API, UI entrypoint, and localhost auto-start runtime.",
5
5
  "type": "module",
6
6
  "license": "MIT",