@clawling/clawchat-plugin-openclaw 2026.7.6-3 → 2026.7.6-4

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.
@@ -82,6 +82,7 @@ export const openclawClawlingConfigSchema = {
82
82
  forwardToolCalls: { type: "boolean" },
83
83
  richInteractions: { type: "boolean" },
84
84
  awarenessNote: { type: "boolean" },
85
+ livewareSample: { type: "boolean" },
85
86
  reconnect: {
86
87
  type: "object",
87
88
  additionalProperties: false,
@@ -352,6 +353,7 @@ export function resolveOpenclawClawlingAccount(cfg, env = process.env) {
352
353
  const forwardToolCalls = typeof channel.forwardToolCalls === "boolean" ? channel.forwardToolCalls : false;
353
354
  const richInteractions = typeof channel.richInteractions === "boolean" ? channel.richInteractions : false;
354
355
  const awarenessNote = typeof channel.awarenessNote === "boolean" ? channel.awarenessNote : false;
356
+ const livewareSample = typeof channel.livewareSample === "boolean" ? channel.livewareSample : true;
355
357
  return {
356
358
  accountId: DEFAULT_ACCOUNT_ID,
357
359
  name: CHANNEL_ID,
@@ -378,6 +380,7 @@ export function resolveOpenclawClawlingAccount(cfg, env = process.env) {
378
380
  forwardToolCalls,
379
381
  richInteractions,
380
382
  awarenessNote,
383
+ livewareSample,
381
384
  allowFrom: [],
382
385
  reconnect: readReconnect(channel.reconnect),
383
386
  heartbeat: readHeartbeat(channel.heartbeat),
@@ -0,0 +1,532 @@
1
+ /**
2
+ * Liveware Sample auto-boot: download the zero-dependency demo web app from the
3
+ * official install-cli repo (GitHub raw + manifest sha256, same channel as
4
+ * skill hot-update), run it locally, bind a liveware tunnel and register it as
5
+ * a ClawChat app — deterministically, without going through the LLM.
6
+ *
7
+ * Spec: docs/superpowers/specs/2026-07-06-liveware-sample-autoboot-design.md
8
+ */
9
+ import crypto from "node:crypto";
10
+ import { execFile as nodeExecFile, spawn as nodeSpawn } from "node:child_process";
11
+ import fs from "node:fs";
12
+ import path from "node:path";
13
+ import { DEFAULT_SKILLS_REF, OFFICIAL_SKILLS_BASE } from "./skill-update.js";
14
+ export const LIVEWARES_TARGET = "openclaw";
15
+ export const LIVEWARE_SAMPLE_ID = "liveware-sample";
16
+ export const LIVEWARE_SAMPLE_APP_NAME = "Liveware Sample";
17
+ export const MAX_SAMPLE_FILE_BYTES = 512 * 1024;
18
+ /** Files the agent owns at runtime; preserved across sample upgrades. */
19
+ const USER_DATA_FILES = ["state.json", "events.jsonl"];
20
+ function livewaresBase(base, ref) {
21
+ return `${(base ?? OFFICIAL_SKILLS_BASE).replace(/\/+$/, "")}/${ref}/livewares`;
22
+ }
23
+ export function livewaresManifestUrl(ref = DEFAULT_SKILLS_REF, base) {
24
+ return `${livewaresBase(base, ref)}/manifest.json`;
25
+ }
26
+ export function livewareFileUrl(filePath, ref = DEFAULT_SKILLS_REF, base) {
27
+ return `${livewaresBase(base, ref)}/${filePath.replace(/^\/+/, "")}`;
28
+ }
29
+ export function parseLivewaresManifest(raw) {
30
+ const parsed = JSON.parse(raw);
31
+ const entry = parsed.livewares?.[LIVEWARES_TARGET]?.[LIVEWARE_SAMPLE_ID];
32
+ if (!entry) {
33
+ throw new Error(`livewares manifest missing ${LIVEWARES_TARGET}/${LIVEWARE_SAMPLE_ID}`);
34
+ }
35
+ const version = typeof entry.version === "string" ? entry.version.trim() : "";
36
+ if (!version)
37
+ throw new Error("livewares manifest entry missing version");
38
+ if (!Array.isArray(entry.files) || entry.files.length === 0) {
39
+ throw new Error("livewares manifest entry missing files");
40
+ }
41
+ const files = entry.files.map((f, i) => {
42
+ const v = f;
43
+ const p = typeof v.path === "string" ? v.path.trim() : "";
44
+ const sha256 = typeof v.sha256 === "string" ? v.sha256.trim().toLowerCase() : "";
45
+ const bytes = typeof v.bytes === "number" ? v.bytes : NaN;
46
+ if (!p || p.includes(".."))
47
+ throw new Error(`livewares manifest file[${i}] bad path`);
48
+ if (!/^[0-9a-f]{64}$/.test(sha256))
49
+ throw new Error(`livewares manifest file[${i}] bad sha256`);
50
+ if (!Number.isFinite(bytes) || bytes < 0 || bytes > MAX_SAMPLE_FILE_BYTES) {
51
+ throw new Error(`livewares manifest file[${i}] bad bytes`);
52
+ }
53
+ return { path: p, sha256, bytes };
54
+ });
55
+ return { version, files };
56
+ }
57
+ async function fetchText(fetchFn, url) {
58
+ const res = await fetchFn(url);
59
+ if (!res.ok)
60
+ throw new Error(`fetch ${url} failed: HTTP ${res.status}`);
61
+ const text = await res.text();
62
+ if (Buffer.byteLength(text, "utf8") > MAX_SAMPLE_FILE_BYTES) {
63
+ throw new Error(`fetch ${url} exceeds ${MAX_SAMPLE_FILE_BYTES} bytes`);
64
+ }
65
+ return text;
66
+ }
67
+ /**
68
+ * Download + verify + atomically install the sample under `<sampleRoot>/app`.
69
+ * Preserves USER_DATA_FILES from an existing install. Throws on any failure;
70
+ * never leaves a partially-written `app/` dir behind.
71
+ */
72
+ export async function downloadLivewareSample(opts) {
73
+ const ref = opts.ref ?? DEFAULT_SKILLS_REF;
74
+ const entry = parseLivewaresManifest(await fetchText(opts.fetchFn, livewaresManifestUrl(ref, opts.base)));
75
+ const appDir = path.join(opts.sampleRoot, "app");
76
+ const tmpDir = path.join(opts.sampleRoot, `app.tmp-${process.pid}`);
77
+ await fs.promises.rm(tmpDir, { recursive: true, force: true });
78
+ await fs.promises.mkdir(tmpDir, { recursive: true });
79
+ try {
80
+ for (const file of entry.files) {
81
+ const text = await fetchText(opts.fetchFn, livewareFileUrl(file.path, ref, opts.base));
82
+ const buf = Buffer.from(text, "utf8");
83
+ const actual = crypto.createHash("sha256").update(buf).digest("hex");
84
+ if (actual !== file.sha256) {
85
+ throw new Error(`sha256 mismatch for ${file.path}: expected ${file.sha256} got ${actual}`);
86
+ }
87
+ await fs.promises.writeFile(path.join(tmpDir, path.basename(file.path)), buf);
88
+ }
89
+ // Preserve agent/user-owned data files from a previous install.
90
+ for (const name of USER_DATA_FILES) {
91
+ const prev = path.join(appDir, name);
92
+ if (fs.existsSync(prev)) {
93
+ await fs.promises.copyFile(prev, path.join(tmpDir, name));
94
+ }
95
+ }
96
+ await fs.promises.rm(appDir, { recursive: true, force: true });
97
+ await fs.promises.rename(tmpDir, appDir);
98
+ return { version: entry.version, appDir };
99
+ }
100
+ catch (err) {
101
+ await fs.promises.rm(tmpDir, { recursive: true, force: true });
102
+ throw err;
103
+ }
104
+ }
105
+ const SERVER_START_TIMEOUT_MS = 10_000;
106
+ const TUNNEL_START_TIMEOUT_MS = 30_000;
107
+ const CLI_TIMEOUT_MS = 30_000;
108
+ /** Wait for a line matching `match` on the child's stdout/stderr, else kill+reject. */
109
+ function waitForOutput(child, match, timeoutMs, label) {
110
+ return new Promise((resolve, reject) => {
111
+ let acc = "";
112
+ let done = false;
113
+ const finish = (err, value) => {
114
+ if (done)
115
+ return;
116
+ done = true;
117
+ clearTimeout(timer);
118
+ child.stdout?.removeListener("data", onData);
119
+ child.stderr?.removeListener("data", onData);
120
+ child.removeListener("exit", onExit);
121
+ if (err) {
122
+ try {
123
+ child.kill("SIGTERM");
124
+ }
125
+ catch { /* already dead */ }
126
+ reject(err);
127
+ }
128
+ else {
129
+ resolve(value);
130
+ }
131
+ };
132
+ const timer = setTimeout(() => finish(new Error(`${label} timed out after ${timeoutMs}ms`)), timeoutMs);
133
+ const onData = (chunk) => {
134
+ acc += chunk.toString();
135
+ const hit = match(acc);
136
+ if (hit != null)
137
+ finish(null, hit);
138
+ };
139
+ const onExit = (code) => finish(new Error(`${label} exited early (code ${code})`));
140
+ child.stdout?.on("data", onData);
141
+ child.stderr?.on("data", onData);
142
+ child.on("exit", onExit);
143
+ });
144
+ }
145
+ export async function startSampleServer(opts) {
146
+ const spawnFn = opts.spawnFn ?? nodeSpawn;
147
+ const child = spawnFn(process.execPath, [path.join(opts.appDir, "server.mjs"), "--dir", opts.appDir, "--port", String(opts.port)], { stdio: ["ignore", "pipe", "pipe"] });
148
+ const line = await waitForOutput(child, (acc) => acc.split("\n").find((l) => l.trim().startsWith('{"port"')) ?? null, opts.timeoutMs ?? SERVER_START_TIMEOUT_MS, "liveware-sample server start");
149
+ return { child, port: JSON.parse(line).port };
150
+ }
151
+ const URL_RE = /https?:\/\/[^\s"']+/g;
152
+ /**
153
+ * Extract the public URL from `liveware tunnel bind` output. The real CLI prints
154
+ * an aligned table whose `domain` row carries the public host (no scheme), while
155
+ * the only literal URL in the output is the LOCAL `upstreamUrl` (http://127…).
156
+ * So prefer the `domain` field (→ `https://<domain>`); fall back to the first
157
+ * non-local http(s) URL for other output shapes. Returns null until a full
158
+ * `domain` line (trailing newline) has arrived, so streamed/partial chunks are
159
+ * never parsed into a truncated host.
160
+ */
161
+ export function parseTunnelPublicUrl(output) {
162
+ const text = output ?? "";
163
+ const dom = text.match(/(?:^|\n)[ \t]*domain\b[ \t:=]+([A-Za-z0-9][A-Za-z0-9._-]*[A-Za-z0-9])[ \t]*\r?\n/i);
164
+ if (dom?.[1])
165
+ return `https://${dom[1]}`;
166
+ for (const u of text.match(URL_RE) ?? []) {
167
+ if (!/^https?:\/\/(127\.0\.0\.1|localhost|0\.0\.0\.0|\[::1\])(?:[:/]|$)/i.test(u))
168
+ return u;
169
+ }
170
+ return null;
171
+ }
172
+ export async function startTunnel(opts) {
173
+ const spawnFn = opts.spawnFn ?? nodeSpawn;
174
+ const child = spawnFn(opts.livewarePath, ["tunnel", "bind", opts.appId, `http://127.0.0.1:${opts.port}`], { stdio: ["ignore", "pipe", "pipe"] });
175
+ const url = await waitForOutput(child, (acc) => parseTunnelPublicUrl(acc), opts.timeoutMs ?? TUNNEL_START_TIMEOUT_MS, "liveware tunnel bind");
176
+ return { child, publicUrl: url };
177
+ }
178
+ export async function livewareLogin(opts) {
179
+ const execFileFn = opts.execFileFn ?? nodeExecFile;
180
+ await new Promise((resolve, reject) => {
181
+ execFileFn(opts.livewarePath, ["login", "--access-token", opts.token], { timeout: CLI_TIMEOUT_MS }, (err, stdout, stderr) => {
182
+ if (!err) {
183
+ resolve();
184
+ return;
185
+ }
186
+ const scrub = (s) => (opts.token ? String(s).split(opts.token).join("***") : String(s));
187
+ reject(new Error(`liveware login failed: ${scrub(stderr || stdout || String(err))}`.trim()));
188
+ });
189
+ });
190
+ }
191
+ /**
192
+ * Parse the app id out of `liveware app create` output. Tolerant of JSON or
193
+ * key/value text. Verified against the real CLI in the final manual task —
194
+ * adjust here if the real output shape differs.
195
+ */
196
+ export function parseAppCreateOutput(stdout) {
197
+ const text = (stdout ?? "").trim();
198
+ if (!text)
199
+ return null;
200
+ // 1. JSON output: find the first string-valued id-like key, depth-first.
201
+ try {
202
+ const found = findIdDeep(JSON.parse(text));
203
+ if (found)
204
+ return found;
205
+ }
206
+ catch { /* not JSON */ }
207
+ // 2. Real `liveware app create` prints an aligned table row `appId <id>`
208
+ // (whitespace-separated, no colon); also tolerate `app id: xxx` / `AppID=xxx`.
209
+ const kv = text.match(/app[ _-]?id\b\s*[:=]?\s*"?([A-Za-z0-9][A-Za-z0-9_-]*)"?/i)
210
+ ?? text.match(/\bid\s*[:=]\s*"?([A-Za-z0-9][A-Za-z0-9_-]*)"?/i);
211
+ if (kv?.[1])
212
+ return kv[1];
213
+ // 3. A lone id-looking line (≥6 chars, single token).
214
+ for (const line of text.split("\n")) {
215
+ const t = line.trim();
216
+ if (/^[A-Za-z0-9][A-Za-z0-9_-]{5,}$/.test(t))
217
+ return t;
218
+ }
219
+ return null;
220
+ }
221
+ function findIdDeep(value) {
222
+ if (!value || typeof value !== "object")
223
+ return null;
224
+ const obj = value;
225
+ for (const key of ["app_id", "appId", "id"]) {
226
+ if (typeof obj[key] === "string" && obj[key].trim()) {
227
+ return obj[key].trim();
228
+ }
229
+ }
230
+ for (const v of Object.values(obj)) {
231
+ const found = findIdDeep(v);
232
+ if (found)
233
+ return found;
234
+ }
235
+ return null;
236
+ }
237
+ export async function livewareAppCreate(opts) {
238
+ const execFileFn = opts.execFileFn ?? nodeExecFile;
239
+ const stdout = await new Promise((resolve, reject) => {
240
+ execFileFn(opts.livewarePath, ["app", "create", opts.name], { timeout: CLI_TIMEOUT_MS }, (err, out, errOut) => {
241
+ if (err) {
242
+ reject(new Error(`liveware app create failed: ${errOut || out || String(err)}`.trim()));
243
+ return;
244
+ }
245
+ resolve(String(out ?? ""));
246
+ });
247
+ });
248
+ const appId = parseAppCreateOutput(stdout);
249
+ if (!appId)
250
+ throw new Error(`liveware app create: cannot parse app id from output: ${stdout.slice(0, 500)}`);
251
+ return appId;
252
+ }
253
+ export const LIVEWARE_SAMPLE_INTRO_TEXT = "我给你安装了一个 liveware 演示应用「Liveware Sample」,它已经出现在我们的聊天里。" +
254
+ "点开它看看,然后试试对我说:把标题改成 Hello Liveware。" +
255
+ "你在页面上点的按钮、提交的留言我也能看到,随时问我。";
256
+ const DEFAULT_SAMPLE_PORT = 43110;
257
+ const RESTART_WINDOW_MS = 30 * 60 * 1000;
258
+ const MAX_RESTARTS_PER_WINDOW = 5;
259
+ const INTRO_RETRY_DELAY_MS = 30_000;
260
+ const INTRO_MAX_TRIES = 20;
261
+ export class LivewareSampleSupervisor {
262
+ deps;
263
+ serverChild = null;
264
+ tunnelChild = null;
265
+ stopped = false;
266
+ restartTimes = [];
267
+ timers = new Set();
268
+ /** Bumped on every (re)launch and on killChildren: a child's exit handler
269
+ * only counts if its generation is still current — killing the sibling
270
+ * child during a restart must NOT double-count as a second crash. */
271
+ generation = 0;
272
+ constructor(deps) {
273
+ this.deps = deps;
274
+ }
275
+ /** Entry point. Never throws; every failure is logged + persisted only. */
276
+ async start() {
277
+ const { deps } = this;
278
+ try {
279
+ if (!deps.enabled)
280
+ return;
281
+ const key = { platform: deps.platform, accountId: deps.accountId };
282
+ const row = deps.store.getLivewareSample(key);
283
+ if (row?.status === "disabled")
284
+ return;
285
+ if (row) {
286
+ await this.relaunch(row);
287
+ }
288
+ else {
289
+ await this.bootstrap();
290
+ }
291
+ }
292
+ catch (err) {
293
+ // A mid-flow failure (e.g. registerApp rejected) may have left children
294
+ // running without a persisted row — never leak orphan processes.
295
+ this.killChildren();
296
+ deps.log?.warn?.(`clawchat-plugin-openclaw liveware-sample start failed: ${String(err)}`);
297
+ }
298
+ }
299
+ stop() {
300
+ this.stopped = true;
301
+ for (const t of this.timers)
302
+ clearTimeout(t);
303
+ this.timers.clear();
304
+ this.killChildren();
305
+ }
306
+ killChildren() {
307
+ this.generation += 1; // invalidate pending exit handlers before killing
308
+ for (const child of [this.serverChild, this.tunnelChild]) {
309
+ try {
310
+ child?.kill("SIGTERM");
311
+ }
312
+ catch { /* already dead */ }
313
+ }
314
+ this.serverChild = null;
315
+ this.tunnelChild = null;
316
+ }
317
+ schedule(fn, delayMs) {
318
+ if (this.stopped)
319
+ return;
320
+ const setTimeoutFn = this.deps.setTimeoutFn ?? setTimeout;
321
+ const t = setTimeoutFn(() => { this.timers.delete(t); fn(); }, delayMs);
322
+ this.timers.add(t);
323
+ }
324
+ /** True (after killing any spawned children) when stop() arrived mid-flight.
325
+ * bootstrap()/relaunch() must re-check after every await: a child spawned
326
+ * after stop() ran would otherwise never be killed. */
327
+ bailIfStopped() {
328
+ if (!this.stopped)
329
+ return false;
330
+ this.killChildren();
331
+ return true;
332
+ }
333
+ async bootstrap() {
334
+ const { deps } = this;
335
+ if (this.stopped)
336
+ return;
337
+ const livewarePath = deps.resolveLivewarePath();
338
+ if (!livewarePath) {
339
+ deps.log?.debug?.("liveware-sample: liveware CLI not ready; skip");
340
+ return;
341
+ }
342
+ const token = deps.resolveToken();
343
+ if (!token) {
344
+ deps.log?.debug?.("liveware-sample: no token; skip");
345
+ return;
346
+ }
347
+ let apps;
348
+ try {
349
+ apps = await deps.listApps();
350
+ }
351
+ catch (err) {
352
+ deps.log?.debug?.(`liveware-sample: listApps failed; skip bootstrap: ${String(err)}`);
353
+ return;
354
+ }
355
+ if (this.bailIfStopped())
356
+ return;
357
+ if (apps.apps.length > 0) {
358
+ deps.log?.debug?.("liveware-sample: user already has liveware apps; skip");
359
+ return;
360
+ }
361
+ const { version, appDir } = await downloadLivewareSample({
362
+ fetchFn: deps.fetchFn, sampleRoot: deps.sampleRoot, ref: deps.ref,
363
+ });
364
+ if (this.bailIfStopped())
365
+ return;
366
+ const server = await startSampleServer({
367
+ appDir, port: DEFAULT_SAMPLE_PORT, spawnFn: deps.spawnFn,
368
+ });
369
+ this.serverChild = server.child;
370
+ if (this.bailIfStopped())
371
+ return;
372
+ await livewareLogin({ livewarePath, token, execFileFn: deps.execFileFn });
373
+ const appId = await livewareAppCreate({
374
+ livewarePath, name: LIVEWARE_SAMPLE_APP_NAME, execFileFn: deps.execFileFn,
375
+ });
376
+ const tunnel = await startTunnel({
377
+ livewarePath, appId, port: server.port, spawnFn: deps.spawnFn,
378
+ });
379
+ this.tunnelChild = tunnel.child;
380
+ if (this.bailIfStopped())
381
+ return;
382
+ await deps.registerApp({ name: LIVEWARE_SAMPLE_APP_NAME, appId, url: tunnel.publicUrl });
383
+ deps.store.upsertLivewareSample({
384
+ platform: deps.platform, accountId: deps.accountId,
385
+ appId, appName: LIVEWARE_SAMPLE_APP_NAME, port: server.port,
386
+ publicUrl: tunnel.publicUrl, sampleVersion: version, status: "active",
387
+ });
388
+ this.watchChildren();
389
+ await this.deliverIntro();
390
+ deps.log?.debug?.(`liveware-sample: bootstrap complete at ${tunnel.publicUrl}`);
391
+ }
392
+ async relaunch(row) {
393
+ const { deps } = this;
394
+ if (this.stopped)
395
+ return;
396
+ const livewarePath = deps.resolveLivewarePath();
397
+ if (!livewarePath) {
398
+ deps.log?.debug?.("liveware-sample: liveware CLI not ready; skip relaunch");
399
+ return;
400
+ }
401
+ // The user deleting the app tile is an explicit opt-out — never reinstall.
402
+ try {
403
+ const apps = await deps.listApps();
404
+ if (!apps.apps.some((a) => a.app_id === row.app_id)) {
405
+ deps.store.updateLivewareSampleStatus({
406
+ platform: deps.platform, accountId: deps.accountId, status: "disabled",
407
+ lastError: "app removed by user",
408
+ });
409
+ return;
410
+ }
411
+ }
412
+ catch (err) {
413
+ deps.log?.debug?.(`liveware-sample: listApps failed; skip relaunch: ${String(err)}`);
414
+ return;
415
+ }
416
+ if (this.bailIfStopped())
417
+ return;
418
+ let version = row.sample_version;
419
+ let appDir = path.join(deps.sampleRoot, "app");
420
+ try {
421
+ const dl = await downloadLivewareSample({
422
+ fetchFn: deps.fetchFn, sampleRoot: deps.sampleRoot, ref: deps.ref,
423
+ });
424
+ version = dl.version;
425
+ appDir = dl.appDir;
426
+ }
427
+ catch (err) {
428
+ if (!fs.existsSync(path.join(appDir, "server.mjs")))
429
+ throw err; // no local copy either — genuine failure
430
+ deps.log?.debug?.(`liveware-sample: download failed, reusing local copy: ${String(err)}`);
431
+ }
432
+ if (this.bailIfStopped())
433
+ return;
434
+ const server = await startSampleServer({
435
+ appDir, port: row.port || DEFAULT_SAMPLE_PORT, spawnFn: deps.spawnFn,
436
+ });
437
+ this.serverChild = server.child;
438
+ if (this.bailIfStopped())
439
+ return;
440
+ const tunnel = await startTunnel({
441
+ livewarePath, appId: row.app_id, port: server.port, spawnFn: deps.spawnFn,
442
+ });
443
+ this.tunnelChild = tunnel.child;
444
+ if (this.bailIfStopped())
445
+ return;
446
+ if (tunnel.publicUrl !== row.public_url) {
447
+ await deps.registerApp({
448
+ name: row.app_name, appId: row.app_id, url: tunnel.publicUrl,
449
+ });
450
+ }
451
+ deps.store.upsertLivewareSample({
452
+ platform: deps.platform, accountId: deps.accountId,
453
+ appId: row.app_id, appName: row.app_name, port: server.port,
454
+ publicUrl: tunnel.publicUrl, sampleVersion: version, status: "active",
455
+ });
456
+ this.watchChildren();
457
+ if (row.intro_sent === 0)
458
+ await this.deliverIntro();
459
+ }
460
+ /** Restart (bounded backoff) when either child dies while we are not stopping. */
461
+ watchChildren() {
462
+ const gen = ++this.generation;
463
+ const onChildExit = () => {
464
+ if (this.stopped || gen !== this.generation)
465
+ return;
466
+ this.killChildren();
467
+ const now = Date.now();
468
+ this.restartTimes = this.restartTimes.filter((t) => now - t < RESTART_WINDOW_MS);
469
+ if (this.restartTimes.length >= MAX_RESTARTS_PER_WINDOW) {
470
+ this.deps.store.updateLivewareSampleStatus({
471
+ platform: this.deps.platform, accountId: this.deps.accountId,
472
+ status: "failed", lastError: "sample process crash-looping; restart cap reached",
473
+ });
474
+ this.deps.log?.warn?.("liveware-sample: restart cap reached; marked failed");
475
+ return;
476
+ }
477
+ const n = this.restartTimes.length;
478
+ this.restartTimes.push(now);
479
+ const delay = Math.min(5_000 * 2 ** n, 60_000);
480
+ this.schedule(() => {
481
+ const row = this.deps.store.getLivewareSample({
482
+ platform: this.deps.platform, accountId: this.deps.accountId,
483
+ });
484
+ if (!row || row.status === "disabled")
485
+ return;
486
+ void this.relaunch(row).catch((err) => {
487
+ // A partially-completed relaunch may have spawned children before
488
+ // failing — never leave them running unwatched.
489
+ this.killChildren();
490
+ this.deps.log?.warn?.(`liveware-sample: relaunch failed: ${String(err)}`);
491
+ this.deps.store.updateLivewareSampleStatus({
492
+ platform: this.deps.platform, accountId: this.deps.accountId,
493
+ status: "failed", lastError: String(err),
494
+ });
495
+ });
496
+ }, delay);
497
+ };
498
+ for (const child of [this.serverChild, this.tunnelChild]) {
499
+ if (!child)
500
+ continue;
501
+ child.once("exit", onChildExit);
502
+ // The child may have died during the awaits between spawn and here — its
503
+ // 'exit' event has already fired, so the once() above would never see it
504
+ // and the crash would be silently lost. Invoke the handler manually; the
505
+ // generation guard makes this idempotent with any late-arriving event.
506
+ if (child.exitCode != null || child.signalCode != null)
507
+ onChildExit();
508
+ }
509
+ }
510
+ /** Deliver the intro chat message; retries while the WS client is not ready. */
511
+ async deliverIntro(tryIndex = 0) {
512
+ if (this.stopped)
513
+ return;
514
+ const { deps } = this;
515
+ let delivered = false;
516
+ try {
517
+ delivered = await deps.notifyOwner(LIVEWARE_SAMPLE_INTRO_TEXT);
518
+ }
519
+ catch (err) {
520
+ deps.log?.debug?.(`liveware-sample: intro send error: ${String(err)}`);
521
+ }
522
+ if (delivered) {
523
+ deps.store.markLivewareSampleIntroSent({ platform: deps.platform, accountId: deps.accountId });
524
+ return;
525
+ }
526
+ if (tryIndex + 1 >= INTRO_MAX_TRIES) {
527
+ deps.log?.debug?.("liveware-sample: intro not delivered; will retry next boot");
528
+ return;
529
+ }
530
+ this.schedule(() => { void this.deliverIntro(tryIndex + 1); }, INTRO_RETRY_DELAY_MS);
531
+ }
532
+ }
@@ -6,13 +6,15 @@ import { createOpenclawClawlingClient, resolveOpenclawClawlingDeviceId } from ".
6
6
  import { createOpenclawClawlingApiClient } from "./api-client.js";
7
7
  import { buildActivationBootstrapText } from "./activation-greeting.js";
8
8
  import { reportPluginVersionSafe, resolvePluginVersion } from "./plugin-report.js";
9
- import { ensureLivewareCli } from "./liveware-cli.js";
9
+ import path from "node:path";
10
+ import { ensureLivewareCli, resolveLivewarePath } from "./liveware-cli.js";
11
+ import { LivewareSampleSupervisor } from "./liveware-sample.js";
10
12
  import { ClawlingApiError } from "./api-types.js";
11
13
  import { RefreshManager } from "./refresh-manager.js";
12
14
  import { runOpenclawClawlingLogin, } from "./login.runtime.js";
13
15
  import { CHANNEL_ID, effectiveOutputVisibility, effectiveGroupCommandMode, effectiveGroupMode, hasOpenclawClawlingConnectCredentials, resolveOpenclawClawlingAccount, } from "./config.js";
14
16
  import { dispatchOpenclawClawlingInbound } from "./inbound.js";
15
- import { PendingConsentStore, handleOwnerConsentReply, managedSkillExists, readLocalSkillState, resolveBundledSkillsDir, resolveManagedSkillsDir, runSkillUpdateCheck, } from "./skill-update.js";
17
+ import { PendingConsentStore, cleanupTombstonedManagedSkills, handleOwnerConsentReply, managedSkillExists, readLocalSkillState, resolveBundledSkillsDir, resolveManagedSkillsDir, runSkillUpdateCheck, } from "./skill-update.js";
16
18
  import { fetchInboundMedia } from "./media-runtime.js";
17
19
  import { createOpenclawClawlingReplyDispatcher } from "./reply-dispatcher.js";
18
20
  import { runWithTerminalClawChatSendScope } from "./terminal-send.js";
@@ -779,6 +781,44 @@ export async function startOpenclawClawlingGateway(params) {
779
781
  const invalidateConversationApiClient = () => {
780
782
  conversationApiClient = undefined;
781
783
  };
784
+ // Liveware Sample auto-boot (spec: docs/superpowers/specs/2026-07-06-liveware-sample-autoboot-design.md).
785
+ // Fire-and-forget like ensureLivewareCli above: never blocks or fails the channel.
786
+ // `livewareSampleNotify` is reassigned once `client`/`account` are ready (see
787
+ // `sendOwnerSkillMessage` below) — both live in this same function closure, so
788
+ // the forward reference here just resolves to the fresh value at call time.
789
+ let livewareSampleNotify = async () => false;
790
+ if (store) {
791
+ const stateDirForSample = runtime.state?.resolveStateDir?.();
792
+ const livewareSampleSupervisor = new LivewareSampleSupervisor({
793
+ platform: "openclaw",
794
+ accountId,
795
+ enabled: account.livewareSample,
796
+ // `store` here is the `ClawChatStore` returned by `resolveConnectionStore`;
797
+ // `RuntimeConnectionStore` only `Pick`s a subset of its methods, so widen
798
+ // through the structural `LivewareSampleStoreLike` contract (Task 8/4).
799
+ store: store,
800
+ sampleRoot: path.join(stateDirForSample ?? "", "clawchat", "liveware-sample"),
801
+ resolveToken: () => account.token ?? "",
802
+ resolveLivewarePath: () => resolveLivewarePath(stateDirForSample),
803
+ listApps: async () => {
804
+ const client = getConversationApiClient();
805
+ return await client.listApps();
806
+ },
807
+ registerApp: async (p) => {
808
+ const client = getConversationApiClient();
809
+ return await client.registerApp(p);
810
+ },
811
+ notifyOwner: (text) => livewareSampleNotify(text),
812
+ fetchFn: globalThis.fetch,
813
+ log: {
814
+ warn: (m) => log?.info?.(m),
815
+ debug: (m) => log?.debug?.(m),
816
+ error: (m) => log?.error?.(m),
817
+ },
818
+ });
819
+ abortSignal.addEventListener("abort", () => livewareSampleSupervisor.stop());
820
+ void livewareSampleSupervisor.start();
821
+ }
782
822
  const resolveMutateConfigFile = () => {
783
823
  if (params.mutateConfigFile)
784
824
  return params.mutateConfigFile;
@@ -1837,6 +1877,14 @@ export async function startOpenclawClawlingGateway(params) {
1837
1877
  catch (err) {
1838
1878
  log?.info?.(`[${accountId}] clawchat-plugin-openclaw skill-update: bundled skills dir not resolvable (using managed-only current): ${err instanceof Error ? err.message : String(err)}`);
1839
1879
  }
1880
+ // Load-time tombstone sweep: delete any managed skill copy the bundled
1881
+ // manifest snapshot marks as removed, so a stale id from an old install
1882
+ // stops shadowing/loading. Fire-and-forget — must never block or fail boot.
1883
+ void cleanupTombstonedManagedSkills({
1884
+ managedDir: managedSkillsDir,
1885
+ bundledDir: bundledSkillsDir,
1886
+ log,
1887
+ }).catch(() => { });
1840
1888
  // A usr_... id is not a valid chat_id — member-backend rejects it with
1841
1889
  // "invalid conversation id" and msghub never acks the send. Proactive owner
1842
1890
  // messages must be addressed to the direct conversation recorded at
@@ -1864,6 +1912,22 @@ export async function startOpenclawClawlingGateway(params) {
1864
1912
  log,
1865
1913
  });
1866
1914
  };
1915
+ // Wire the liveware-sample intro notifier now that `client`/`account` are in
1916
+ // scope (both declared earlier in this same function body — see the
1917
+ // `livewareSampleNotify` forward declaration above the supervisor construction).
1918
+ livewareSampleNotify = async (text) => {
1919
+ const conversationId = resolveOwnerDirectConversationId();
1920
+ if (!conversationId)
1921
+ return false;
1922
+ const result = await sendOpenclawClawlingText({
1923
+ client,
1924
+ account,
1925
+ to: { chatId: conversationId, chatType: "direct" },
1926
+ text,
1927
+ log,
1928
+ });
1929
+ return result != null;
1930
+ };
1867
1931
  const triggerSkillUpdateCheck = async () => {
1868
1932
  const ownerUserId = account.ownerUserId?.trim();
1869
1933
  if (!ownerUserId) {