forge-openclaw-plugin 0.2.10 → 0.2.11

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
@@ -202,6 +202,14 @@ The skill is entity-format-driven. It teaches the agent how to:
202
202
 
203
203
  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
204
 
205
+ If you want to stop that plugin-managed local runtime cleanly, use:
206
+
207
+ ```bash
208
+ forge stop
209
+ ```
210
+
211
+ This only stops the runtime when it was auto-started by the OpenClaw plugin. If Forge was started manually some other way, `forge stop` tells you that instead of killing unrelated processes.
212
+
205
213
  ## Publishing and listing
206
214
 
207
215
  The reliable publication path for the Forge plugin is:
@@ -1,3 +1,11 @@
1
1
  import type { ForgePluginConfig } from "./api-client.js";
2
+ export type ForgeRuntimeStopResult = {
3
+ ok: boolean;
4
+ stopped: boolean;
5
+ managed: boolean;
6
+ message: string;
7
+ pid: number | null;
8
+ };
2
9
  export declare function ensureForgeRuntimeReady(config: ForgePluginConfig): Promise<void>;
3
10
  export declare function primeForgeRuntime(config: ForgePluginConfig): void;
11
+ export declare function stopForgeRuntime(config: ForgePluginConfig): Promise<ForgeRuntimeStopResult>;
@@ -1,6 +1,8 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { existsSync } from "node:fs";
3
+ import { homedir } from "node:os";
3
4
  import path from "node:path";
5
+ import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
4
6
  import { fileURLToPath } from "node:url";
5
7
  const LOCAL_HOSTNAMES = new Set(["127.0.0.1", "localhost", "::1"]);
6
8
  const STARTUP_TIMEOUT_MS = 15_000;
@@ -12,6 +14,63 @@ let startupPromise = null;
12
14
  function runtimeKey(config) {
13
15
  return `${config.origin}:${config.port}`;
14
16
  }
17
+ function getRuntimeStatePath(config) {
18
+ const origin = new URL(config.origin).hostname.toLowerCase().replace(/[^a-z0-9._-]+/g, "-");
19
+ return path.join(homedir(), ".openclaw", "run", "forge-openclaw-plugin", `${origin}-${config.port}.json`);
20
+ }
21
+ async function writeRuntimeState(config, pid) {
22
+ const statePath = getRuntimeStatePath(config);
23
+ await mkdir(path.dirname(statePath), { recursive: true });
24
+ const payload = {
25
+ pid,
26
+ origin: config.origin,
27
+ port: config.port,
28
+ baseUrl: config.baseUrl,
29
+ startedAt: new Date().toISOString()
30
+ };
31
+ await writeFile(statePath, `${JSON.stringify(payload, null, 2)}\n`, "utf8");
32
+ }
33
+ async function clearRuntimeState(config) {
34
+ await rm(getRuntimeStatePath(config), { force: true });
35
+ }
36
+ async function readRuntimeState(config) {
37
+ try {
38
+ const payload = await readFile(getRuntimeStatePath(config), "utf8");
39
+ const parsed = JSON.parse(payload);
40
+ if (typeof parsed.pid !== "number" || !Number.isFinite(parsed.pid)) {
41
+ return null;
42
+ }
43
+ return {
44
+ pid: Math.trunc(parsed.pid),
45
+ origin: typeof parsed.origin === "string" ? parsed.origin : config.origin,
46
+ port: typeof parsed.port === "number" ? parsed.port : config.port,
47
+ baseUrl: typeof parsed.baseUrl === "string" ? parsed.baseUrl : config.baseUrl,
48
+ startedAt: typeof parsed.startedAt === "string" ? parsed.startedAt : new Date(0).toISOString()
49
+ };
50
+ }
51
+ catch {
52
+ return null;
53
+ }
54
+ }
55
+ function processExists(pid) {
56
+ try {
57
+ process.kill(pid, 0);
58
+ return true;
59
+ }
60
+ catch (error) {
61
+ return !(error instanceof Error) || !("code" in error) || error.code !== "ESRCH";
62
+ }
63
+ }
64
+ async function waitForProcessExit(pid, timeoutMs) {
65
+ const deadline = Date.now() + timeoutMs;
66
+ while (Date.now() < deadline) {
67
+ if (!processExists(pid)) {
68
+ return true;
69
+ }
70
+ await new Promise((resolve) => setTimeout(resolve, HEALTHCHECK_INTERVAL_MS));
71
+ }
72
+ return !processExists(pid);
73
+ }
15
74
  function isLocalOrigin(origin) {
16
75
  try {
17
76
  return LOCAL_HOSTNAMES.has(new URL(origin).hostname.toLowerCase());
@@ -87,9 +146,13 @@ function spawnManagedRuntime(config, plan) {
87
146
  managedRuntimeChild = null;
88
147
  managedRuntimeKey = null;
89
148
  }
149
+ void clearRuntimeState(config);
90
150
  });
91
151
  managedRuntimeChild = child;
92
152
  managedRuntimeKey = runtimeKey(config);
153
+ void writeRuntimeState(config, child.pid).catch(() => {
154
+ // State tracking is best effort. Runtime health checks remain authoritative.
155
+ });
93
156
  }
94
157
  async function waitForRuntime(config, timeoutMs) {
95
158
  const deadline = Date.now() + timeoutMs;
@@ -134,3 +197,61 @@ export function primeForgeRuntime(config) {
134
197
  // Keep plugin registration non-blocking. Failures surface on first real call.
135
198
  });
136
199
  }
200
+ export async function stopForgeRuntime(config) {
201
+ if (!isLocalOrigin(config.origin)) {
202
+ return {
203
+ ok: false,
204
+ stopped: false,
205
+ managed: false,
206
+ message: "Forge stop only supports local plugin-managed runtimes. Remote Forge targets must be stopped where they are hosted.",
207
+ pid: null
208
+ };
209
+ }
210
+ const state = await readRuntimeState(config);
211
+ if (!state) {
212
+ return {
213
+ ok: true,
214
+ stopped: false,
215
+ managed: false,
216
+ message: (await isForgeHealthy(config, HEALTHCHECK_TIMEOUT_MS))
217
+ ? "Forge is running, but it does not look like a plugin-managed runtime. Stop it where it was started."
218
+ : "Forge is not running through the plugin-managed local runtime.",
219
+ pid: null
220
+ };
221
+ }
222
+ if (!processExists(state.pid)) {
223
+ await clearRuntimeState(config);
224
+ return {
225
+ ok: true,
226
+ stopped: false,
227
+ managed: true,
228
+ message: "The saved Forge runtime PID was stale. The plugin-managed runtime is already stopped.",
229
+ pid: state.pid
230
+ };
231
+ }
232
+ process.kill(state.pid, "SIGTERM");
233
+ if (!(await waitForProcessExit(state.pid, 5_000))) {
234
+ process.kill(state.pid, "SIGKILL");
235
+ if (!(await waitForProcessExit(state.pid, 2_000))) {
236
+ return {
237
+ ok: false,
238
+ stopped: false,
239
+ managed: true,
240
+ message: `Forge runtime pid ${state.pid} did not stop cleanly.`,
241
+ pid: state.pid
242
+ };
243
+ }
244
+ }
245
+ if (managedRuntimeChild?.pid === state.pid) {
246
+ managedRuntimeChild = null;
247
+ managedRuntimeKey = null;
248
+ }
249
+ await clearRuntimeState(config);
250
+ return {
251
+ ok: true,
252
+ stopped: true,
253
+ managed: true,
254
+ message: `Stopped the plugin-managed Forge runtime on ${config.baseUrl}.`,
255
+ pid: state.pid
256
+ };
257
+ }
@@ -1,4 +1,5 @@
1
1
  import { canBootstrapOperatorSession, callConfiguredForgeApi, expectForgeSuccess, readJsonRequestBody, readSingleHeaderValue, requireApiToken, writeForgeProxyResponse, writePluginError, writeRedirectResponse } from "./api-client.js";
2
+ import { stopForgeRuntime } from "./local-runtime.js";
2
3
  import { collectSupportedPluginApiRouteKeys, makeApiRouteKey } from "./parity.js";
3
4
  function passthroughSearch(path, url) {
4
5
  return `${path}${url.search}`;
@@ -374,6 +375,9 @@ export function registerForgePluginCli(api, config) {
374
375
  command.command("ui").description("Print the Forge UI entrypoint").action(async () => {
375
376
  console.log(JSON.stringify({ webAppUrl: await resolveForgeUiUrl(config), pluginUiRoute: "/forge/v1/ui" }, null, 2));
376
377
  });
378
+ command.command("stop").description("Stop the local Forge runtime when it was auto-started by the OpenClaw plugin").action(async () => {
379
+ console.log(JSON.stringify(await stopForgeRuntime(config), null, 2));
380
+ });
377
381
  command.command("doctor").description("Run plugin connectivity and curated route diagnostics").action(async () => {
378
382
  console.log(JSON.stringify(await runDoctor(config), null, 2));
379
383
  });
@@ -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.10",
5
+ "version": "0.2.11",
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.10",
3
+ "version": "0.2.11",
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",
@@ -16,6 +16,8 @@ Forge data location rule:
16
16
  - on a linked repo-local install, this usually means `<repo>/openclaw-plugin/data/forge.sqlite`
17
17
  - if the user wants the data somewhere else for persistence, backup, or manual control, tell them to set `plugins.entries["forge-openclaw-plugin"].config.dataRoot` and restart the OpenClaw gateway
18
18
  - if the user asks where the data is stored or how to move it, explain the current default plainly and show the exact config field
19
+ - if the user wants to stop a plugin-managed local Forge runtime cleanly, tell them to run `forge stop`
20
+ - `forge stop` only shuts down a runtime that the OpenClaw plugin auto-started itself; if Forge was started manually elsewhere, it will say so instead of killing random local processes
19
21
 
20
22
  Use these exact entity meanings when deciding what the user is describing.
21
23