@clawling/clawchat-plugin-openclaw 2026.8.2-1 → 2026.8.4-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.
@@ -15,6 +15,35 @@ export const LIVEWARE_SAMPLE_APP_NAME = "Liveware Sample";
15
15
  export const MAX_SAMPLE_FILE_BYTES = 512 * 1024;
16
16
  /** Files the agent owns at runtime; preserved across sample upgrades. */
17
17
  const USER_DATA_FILES = ["state.json", "events.jsonl"];
18
+ /**
19
+ * Windows refuses to unlink or rename a directory while ANY file inside it is
20
+ * still open, and reports it as a sharing violation (EPERM/EBUSY/EACCES) or a
21
+ * non-empty directory. A still-draining sample server, an antivirus scan or the
22
+ * search indexer is enough to trigger it, and all of those clear within
23
+ * milliseconds — so a short bounded retry turns a hard install failure into a
24
+ * brief pause. On POSIX these codes essentially never appear here and the first
25
+ * attempt always wins, so the retry costs nothing.
26
+ */
27
+ const FS_SWAP_RETRY_DELAYS_MS = [50, 150, 400];
28
+ const TRANSIENT_FS_CODES = new Set(["EPERM", "EACCES", "EBUSY", "ENOTEMPTY"]);
29
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
30
+ async function withFsSwapRetry(op) {
31
+ let lastError;
32
+ for (let attempt = 0; attempt <= FS_SWAP_RETRY_DELAYS_MS.length; attempt += 1) {
33
+ try {
34
+ return await op();
35
+ }
36
+ catch (err) {
37
+ lastError = err;
38
+ const code = err.code ?? "";
39
+ const delay = FS_SWAP_RETRY_DELAYS_MS[attempt];
40
+ if (!TRANSIENT_FS_CODES.has(code) || delay === undefined)
41
+ break;
42
+ await sleep(delay);
43
+ }
44
+ }
45
+ throw lastError;
46
+ }
18
47
  function livewaresBase(base, ref) {
19
48
  return `${(base ?? OFFICIAL_SKILLS_BASE).replace(/\/+$/, "")}/${ref}/livewares`;
20
49
  }
@@ -116,6 +145,14 @@ export async function downloadLivewareSample(opts) {
116
145
  if (await localInstallMatches(appDir, entry)) {
117
146
  return { version: entry.version, appDir, reused: true };
118
147
  }
148
+ // Sweep app.old-* left behind when a previous swap could not delete the old
149
+ // tree (a handle was still open then, but is not now). Best effort: a dir we
150
+ // still cannot remove is simply retried next run.
151
+ for (const name of await fs.promises.readdir(opts.sampleRoot).catch(() => [])) {
152
+ if (name.startsWith("app.old-")) {
153
+ await fs.promises.rm(path.join(opts.sampleRoot, name), { recursive: true, force: true }).catch(() => { });
154
+ }
155
+ }
119
156
  const tmpDir = path.join(opts.sampleRoot, `app.tmp-${process.pid}`);
120
157
  await fs.promises.rm(tmpDir, { recursive: true, force: true });
121
158
  await fs.promises.mkdir(tmpDir, { recursive: true });
@@ -138,8 +175,18 @@ export async function downloadLivewareSample(opts) {
138
175
  await fs.promises.copyFile(prev, path.join(tmpDir, name));
139
176
  }
140
177
  }
141
- await fs.promises.rm(appDir, { recursive: true, force: true });
142
- await fs.promises.rename(tmpDir, appDir);
178
+ // Swap by moving the old tree ASIDE rather than deleting it first. On
179
+ // Windows the delete is the step most likely to lose a race with an open
180
+ // handle, and doing it first leaves no app/ at all when it fails. Renaming
181
+ // aside puts the new tree in place first and makes the delete best-effort:
182
+ // a leftover app.old-<pid> is swept on the next run, never a broken install.
183
+ const oldDir = path.join(opts.sampleRoot, `app.old-${process.pid}`);
184
+ await fs.promises.rm(oldDir, { recursive: true, force: true }).catch(() => { });
185
+ if (fs.existsSync(appDir)) {
186
+ await withFsSwapRetry(() => fs.promises.rename(appDir, oldDir));
187
+ }
188
+ await withFsSwapRetry(() => fs.promises.rename(tmpDir, appDir));
189
+ await fs.promises.rm(oldDir, { recursive: true, force: true }).catch(() => { });
143
190
  return { version: entry.version, appDir };
144
191
  }
145
192
  catch (err) {
@@ -147,6 +194,35 @@ export async function downloadLivewareSample(opts) {
147
194
  throw err;
148
195
  }
149
196
  }
197
+ /**
198
+ * Terminate a child and everything it spawned.
199
+ *
200
+ * POSIX: `SIGTERM` to the child is what we want and what the child expects.
201
+ *
202
+ * Windows: there are no signals. `child.kill()` maps to TerminateProcess on
203
+ * that one process, so any grandchild the liveware agent spawned survives —
204
+ * orphaned, still holding the sample port, and invisible to the next boot,
205
+ * which then fails to bind. `taskkill /T /F` takes the whole tree down.
206
+ * Fire-and-forget: we never wait on it, and a failure (already dead, no such
207
+ * pid) falls through to the ordinary kill().
208
+ */
209
+ export function killProcessTree(child, execFileFn = nodeExecFile) {
210
+ if (!child)
211
+ return;
212
+ if (process.platform === "win32" && typeof child.pid === "number") {
213
+ try {
214
+ execFileFn("taskkill", ["/pid", String(child.pid), "/T", "/F"], () => { });
215
+ return;
216
+ }
217
+ catch {
218
+ // taskkill missing or spawn refused — fall back to the plain kill below.
219
+ }
220
+ }
221
+ try {
222
+ child.kill("SIGTERM");
223
+ }
224
+ catch { /* already dead */ }
225
+ }
150
226
  const SERVER_START_TIMEOUT_MS = 10_000;
151
227
  const TUNNEL_START_TIMEOUT_MS = 30_000;
152
228
  const CLI_TIMEOUT_MS = 30_000;
@@ -164,10 +240,7 @@ function waitForOutput(child, match, timeoutMs, label) {
164
240
  child.stderr?.removeListener("data", onData);
165
241
  child.removeListener("exit", onExit);
166
242
  if (err) {
167
- try {
168
- child.kill("SIGTERM");
169
- }
170
- catch { /* already dead */ }
243
+ killProcessTree(child);
171
244
  reject(err);
172
245
  }
173
246
  else {
@@ -535,10 +608,7 @@ export class LivewareSampleSupervisor {
535
608
  killChildren() {
536
609
  this.generation += 1; // invalidate pending exit handlers before killing
537
610
  for (const child of [this.serverChild, this.tunnelChild]) {
538
- try {
539
- child?.kill("SIGTERM");
540
- }
541
- catch { /* already dead */ }
611
+ killProcessTree(child, this.deps.execFileFn);
542
612
  }
543
613
  this.serverChild = null;
544
614
  this.tunnelChild = null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clawling/clawchat-plugin-openclaw",
3
- "version": "2026.8.2-1",
3
+ "version": "2026.8.4-1",
4
4
  "description": "OpenClaw ClawChat channel plugin",
5
5
  "license": "MIT",
6
6
  "author": "CLAWLING PTE. LTD.",
@@ -20,6 +20,36 @@ export const MAX_SAMPLE_FILE_BYTES = 512 * 1024;
20
20
  /** Files the agent owns at runtime; preserved across sample upgrades. */
21
21
  const USER_DATA_FILES = ["state.json", "events.jsonl"];
22
22
 
23
+ /**
24
+ * Windows refuses to unlink or rename a directory while ANY file inside it is
25
+ * still open, and reports it as a sharing violation (EPERM/EBUSY/EACCES) or a
26
+ * non-empty directory. A still-draining sample server, an antivirus scan or the
27
+ * search indexer is enough to trigger it, and all of those clear within
28
+ * milliseconds — so a short bounded retry turns a hard install failure into a
29
+ * brief pause. On POSIX these codes essentially never appear here and the first
30
+ * attempt always wins, so the retry costs nothing.
31
+ */
32
+ const FS_SWAP_RETRY_DELAYS_MS = [50, 150, 400];
33
+ const TRANSIENT_FS_CODES = new Set(["EPERM", "EACCES", "EBUSY", "ENOTEMPTY"]);
34
+
35
+ const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
36
+
37
+ async function withFsSwapRetry<T>(op: () => Promise<T>): Promise<T> {
38
+ let lastError: unknown;
39
+ for (let attempt = 0; attempt <= FS_SWAP_RETRY_DELAYS_MS.length; attempt += 1) {
40
+ try {
41
+ return await op();
42
+ } catch (err) {
43
+ lastError = err;
44
+ const code = (err as NodeJS.ErrnoException).code ?? "";
45
+ const delay = FS_SWAP_RETRY_DELAYS_MS[attempt];
46
+ if (!TRANSIENT_FS_CODES.has(code) || delay === undefined) break;
47
+ await sleep(delay);
48
+ }
49
+ }
50
+ throw lastError;
51
+ }
52
+
23
53
  export interface LivewareSampleManifestFile {
24
54
  path: string;
25
55
  sha256: string;
@@ -151,6 +181,15 @@ export async function downloadLivewareSample(opts: {
151
181
  if (await localInstallMatches(appDir, entry)) {
152
182
  return { version: entry.version, appDir, reused: true };
153
183
  }
184
+ // Sweep app.old-* left behind when a previous swap could not delete the old
185
+ // tree (a handle was still open then, but is not now). Best effort: a dir we
186
+ // still cannot remove is simply retried next run.
187
+ for (const name of await fs.promises.readdir(opts.sampleRoot).catch(() => [])) {
188
+ if (name.startsWith("app.old-")) {
189
+ await fs.promises.rm(path.join(opts.sampleRoot, name), { recursive: true, force: true }).catch(() => {});
190
+ }
191
+ }
192
+
154
193
  const tmpDir = path.join(opts.sampleRoot, `app.tmp-${process.pid}`);
155
194
  await fs.promises.rm(tmpDir, { recursive: true, force: true });
156
195
  await fs.promises.mkdir(tmpDir, { recursive: true });
@@ -173,8 +212,18 @@ export async function downloadLivewareSample(opts: {
173
212
  await fs.promises.copyFile(prev, path.join(tmpDir, name));
174
213
  }
175
214
  }
176
- await fs.promises.rm(appDir, { recursive: true, force: true });
177
- await fs.promises.rename(tmpDir, appDir);
215
+ // Swap by moving the old tree ASIDE rather than deleting it first. On
216
+ // Windows the delete is the step most likely to lose a race with an open
217
+ // handle, and doing it first leaves no app/ at all when it fails. Renaming
218
+ // aside puts the new tree in place first and makes the delete best-effort:
219
+ // a leftover app.old-<pid> is swept on the next run, never a broken install.
220
+ const oldDir = path.join(opts.sampleRoot, `app.old-${process.pid}`);
221
+ await fs.promises.rm(oldDir, { recursive: true, force: true }).catch(() => {});
222
+ if (fs.existsSync(appDir)) {
223
+ await withFsSwapRetry(() => fs.promises.rename(appDir, oldDir));
224
+ }
225
+ await withFsSwapRetry(() => fs.promises.rename(tmpDir, appDir));
226
+ await fs.promises.rm(oldDir, { recursive: true, force: true }).catch(() => {});
178
227
  return { version: entry.version, appDir };
179
228
  } catch (err) {
180
229
  await fs.promises.rm(tmpDir, { recursive: true, force: true });
@@ -185,6 +234,34 @@ export async function downloadLivewareSample(opts: {
185
234
  export type SpawnLike = typeof nodeSpawn;
186
235
  export type ExecFileLike = typeof nodeExecFile;
187
236
 
237
+ /**
238
+ * Terminate a child and everything it spawned.
239
+ *
240
+ * POSIX: `SIGTERM` to the child is what we want and what the child expects.
241
+ *
242
+ * Windows: there are no signals. `child.kill()` maps to TerminateProcess on
243
+ * that one process, so any grandchild the liveware agent spawned survives —
244
+ * orphaned, still holding the sample port, and invisible to the next boot,
245
+ * which then fails to bind. `taskkill /T /F` takes the whole tree down.
246
+ * Fire-and-forget: we never wait on it, and a failure (already dead, no such
247
+ * pid) falls through to the ordinary kill().
248
+ */
249
+ export function killProcessTree(
250
+ child: ChildProcess | null | undefined,
251
+ execFileFn: ExecFileLike = nodeExecFile,
252
+ ): void {
253
+ if (!child) return;
254
+ if (process.platform === "win32" && typeof child.pid === "number") {
255
+ try {
256
+ execFileFn("taskkill", ["/pid", String(child.pid), "/T", "/F"], () => {});
257
+ return;
258
+ } catch {
259
+ // taskkill missing or spawn refused — fall back to the plain kill below.
260
+ }
261
+ }
262
+ try { child.kill("SIGTERM"); } catch { /* already dead */ }
263
+ }
264
+
188
265
  const SERVER_START_TIMEOUT_MS = 10_000;
189
266
  const TUNNEL_START_TIMEOUT_MS = 30_000;
190
267
  const CLI_TIMEOUT_MS = 30_000;
@@ -207,7 +284,7 @@ function waitForOutput(
207
284
  child.stderr?.removeListener("data", onData);
208
285
  child.removeListener("exit", onExit);
209
286
  if (err) {
210
- try { child.kill("SIGTERM"); } catch { /* already dead */ }
287
+ killProcessTree(child);
211
288
  reject(err);
212
289
  } else {
213
290
  resolve(value as string);
@@ -670,7 +747,7 @@ export class LivewareSampleSupervisor {
670
747
  private killChildren(): void {
671
748
  this.generation += 1; // invalidate pending exit handlers before killing
672
749
  for (const child of [this.serverChild, this.tunnelChild]) {
673
- try { child?.kill("SIGTERM"); } catch { /* already dead */ }
750
+ killProcessTree(child, this.deps.execFileFn);
674
751
  }
675
752
  this.serverChild = null;
676
753
  this.tunnelChild = null;