@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.
- package/dist/src/config.js +3 -0
- package/dist/src/liveware-sample.js +532 -0
- package/dist/src/runtime.js +66 -2
- package/dist/src/skill-update.js +66 -5
- package/dist/src/storage.js +65 -0
- package/openclaw.plugin.json +2 -0
- package/package.json +2 -2
- package/skills/clawchat-liveware-sample/SKILL.md +54 -0
- package/skills/clawchat-set-greeting/SKILL.md +44 -0
- package/skills/manifest.json +69 -0
- package/src/config.ts +7 -0
- package/src/liveware-sample.ts +617 -0
- package/src/runtime.ts +66 -1
- package/src/skill-update.ts +68 -6
- package/src/storage.ts +121 -0
package/dist/src/config.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/src/runtime.js
CHANGED
|
@@ -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
|
|
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) {
|