@clawling/clawchat-plugin-openclaw 2026.7.6-3 → 2026.7.8-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.
package/src/runtime.ts CHANGED
@@ -17,7 +17,9 @@ import { createOpenclawClawlingClient, resolveOpenclawClawlingDeviceId } from ".
17
17
  import { createOpenclawClawlingApiClient } from "./api-client.ts";
18
18
  import { buildActivationBootstrapText } from "./activation-greeting.ts";
19
19
  import { reportPluginVersionSafe, resolvePluginVersion } from "./plugin-report.ts";
20
- import { ensureLivewareCli } from "./liveware-cli.ts";
20
+ import path from "node:path";
21
+ import { ensureLivewareCli, resolveLivewarePath } from "./liveware-cli.ts";
22
+ import { LivewareSampleSupervisor, type LivewareSampleStoreLike } from "./liveware-sample.ts";
21
23
  import { ClawlingApiError } from "./api-types.ts";
22
24
  import { RefreshManager } from "./refresh-manager.ts";
23
25
  import {
@@ -38,6 +40,7 @@ import type { ClawlingChatClient } from "./ws-client.ts";
38
40
  import { dispatchOpenclawClawlingInbound, type IngestTurnParams } from "./inbound.ts";
39
41
  import {
40
42
  PendingConsentStore,
43
+ cleanupTombstonedManagedSkills,
41
44
  handleOwnerConsentReply,
42
45
  managedSkillExists,
43
46
  readLocalSkillState,
@@ -978,7 +981,9 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
978
981
  log,
979
982
  });
980
983
  // Ensure the liveware CLI is installed (async, never blocks startup).
981
- void ensureLivewareCli({
984
+ // Keep the promise: the liveware-sample supervisor must await it, or a
985
+ // first boot races the download, sees no CLI, and silently skips forever.
986
+ const livewareCliReady = ensureLivewareCli({
982
987
  stateDir: runtime.state?.resolveStateDir?.(),
983
988
  log: {
984
989
  warn: (m) => log?.info?.(m),
@@ -1096,6 +1101,46 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
1096
1101
  conversationApiClient = undefined;
1097
1102
  };
1098
1103
 
1104
+ // Liveware Sample auto-boot (spec: docs/superpowers/specs/2026-07-06-liveware-sample-autoboot-design.md).
1105
+ // Fire-and-forget like ensureLivewareCli above: never blocks or fails the channel.
1106
+ // `livewareSampleNotify` is reassigned once `client`/`account` are ready (see
1107
+ // `sendOwnerSkillMessage` below) — both live in this same function closure, so
1108
+ // the forward reference here just resolves to the fresh value at call time.
1109
+ let livewareSampleNotify: (text: string) => Promise<boolean> = async () => false;
1110
+ if (store) {
1111
+ const stateDirForSample = runtime.state?.resolveStateDir?.();
1112
+ const livewareSampleSupervisor = new LivewareSampleSupervisor({
1113
+ platform: "openclaw",
1114
+ accountId,
1115
+ enabled: account.livewareSample,
1116
+ // `store` here is the `ClawChatStore` returned by `resolveConnectionStore`;
1117
+ // `RuntimeConnectionStore` only `Pick`s a subset of its methods, so widen
1118
+ // through the structural `LivewareSampleStoreLike` contract (Task 8/4).
1119
+ store: store as unknown as LivewareSampleStoreLike,
1120
+ sampleRoot: path.join(stateDirForSample ?? "", "clawchat", "liveware-sample"),
1121
+ resolveToken: () => account.token ?? "",
1122
+ resolveLivewarePath: () => resolveLivewarePath(stateDirForSample),
1123
+ cliReady: livewareCliReady,
1124
+ listApps: async () => {
1125
+ const client = getConversationApiClient();
1126
+ return await client.listApps();
1127
+ },
1128
+ registerApp: async (p) => {
1129
+ const client = getConversationApiClient();
1130
+ return await client.registerApp(p);
1131
+ },
1132
+ notifyOwner: (text) => livewareSampleNotify(text),
1133
+ fetchFn: globalThis.fetch,
1134
+ log: {
1135
+ warn: (m) => log?.info?.(m),
1136
+ debug: (m) => log?.debug?.(m),
1137
+ error: (m) => log?.error?.(m),
1138
+ },
1139
+ });
1140
+ abortSignal.addEventListener("abort", () => livewareSampleSupervisor.stop());
1141
+ void livewareSampleSupervisor.start();
1142
+ }
1143
+
1099
1144
  const resolveMutateConfigFile = (): OpenclawClawchatMutateConfigFile | undefined => {
1100
1145
  if (params.mutateConfigFile) return params.mutateConfigFile;
1101
1146
  const runtimeConfig = runtime.config as unknown as {
@@ -2235,6 +2280,14 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
2235
2280
  }`,
2236
2281
  );
2237
2282
  }
2283
+ // Load-time tombstone sweep: delete any managed skill copy the bundled
2284
+ // manifest snapshot marks as removed, so a stale id from an old install
2285
+ // stops shadowing/loading. Fire-and-forget — must never block or fail boot.
2286
+ void cleanupTombstonedManagedSkills({
2287
+ managedDir: managedSkillsDir,
2288
+ bundledDir: bundledSkillsDir,
2289
+ log,
2290
+ }).catch(() => {});
2238
2291
  // A usr_... id is not a valid chat_id — member-backend rejects it with
2239
2292
  // "invalid conversation id" and msghub never acks the send. Proactive owner
2240
2293
  // messages must be addressed to the direct conversation recorded at
@@ -2263,6 +2316,21 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
2263
2316
  log,
2264
2317
  });
2265
2318
  };
2319
+ // Wire the liveware-sample intro notifier now that `client`/`account` are in
2320
+ // scope (both declared earlier in this same function body — see the
2321
+ // `livewareSampleNotify` forward declaration above the supervisor construction).
2322
+ livewareSampleNotify = async (text: string): Promise<boolean> => {
2323
+ const conversationId = resolveOwnerDirectConversationId();
2324
+ if (!conversationId) return false;
2325
+ const result = await sendOpenclawClawlingText({
2326
+ client,
2327
+ account,
2328
+ to: { chatId: conversationId, chatType: "direct" },
2329
+ text,
2330
+ log,
2331
+ });
2332
+ return result != null;
2333
+ };
2266
2334
  const triggerSkillUpdateCheck = async (): Promise<void> => {
2267
2335
  const ownerUserId = account.ownerUserId?.trim();
2268
2336
  if (!ownerUserId) {
@@ -1,5 +1,5 @@
1
1
  import crypto from "node:crypto";
2
- import { existsSync } from "node:fs";
2
+ import { existsSync, readdirSync } from "node:fs";
3
3
  import fs from "node:fs/promises";
4
4
  import os from "node:os";
5
5
  import path from "node:path";
@@ -69,9 +69,27 @@ export const MAX_SKILL_BYTES = 256 * 1024;
69
69
  /** This adapter's host target inside `skills/manifest.json`. */
70
70
  export const SKILL_TARGET = "openclaw";
71
71
 
72
- /** Skill ids this OpenClaw plugin bundles and can hot-update. */
73
- export const OPENCLAW_SKILL_IDS = ["clawchat-core", "clawchat-liveware"] as const;
74
- export type OpenclawSkillId = (typeof OPENCLAW_SKILL_IDS)[number];
72
+ /**
73
+ * Skill ids bundled with this plugin, discovered by scanning the bundled
74
+ * skills dir. Returns [] when the dir is unresolvable (managed-only mode —
75
+ * nothing bundled to protect from tombstones). Never hard-code this list.
76
+ */
77
+ export function getBundledSkillIds(fromUrl?: string): readonly string[] {
78
+ let dir: string;
79
+ try {
80
+ dir = fromUrl === undefined ? resolveBundledSkillsDir() : resolveBundledSkillsDir(fromUrl);
81
+ } catch {
82
+ return [];
83
+ }
84
+ try {
85
+ return readdirSync(dir, { withFileTypes: true })
86
+ .filter((e) => e.isDirectory() && existsSync(path.join(dir, e.name, "SKILL.md")))
87
+ .map((e) => e.name)
88
+ .sort();
89
+ } catch {
90
+ return [];
91
+ }
92
+ }
75
93
 
76
94
  // ---------------------------------------------------------------------------
77
95
  // Minimal fetch abstraction (compatible with the global `fetch`).
@@ -490,6 +508,50 @@ export async function atomicWriteSkill(
490
508
  }
491
509
  }
492
510
 
511
+ /**
512
+ * Load-time counterpart of the dynamic removal path: delete managed copies of
513
+ * ids tombstoned by the bundled manifest snapshot, so stale ids from an old
514
+ * install stop shadowing/loading. Never throws — a bad snapshot must not
515
+ * break plugin startup. Returns the removed ids.
516
+ */
517
+ export async function cleanupTombstonedManagedSkills(options: {
518
+ managedDir: string;
519
+ bundledDir: string | null;
520
+ log?: { info?: (msg: string) => void };
521
+ }): Promise<string[]> {
522
+ if (!options.bundledDir) return [];
523
+ let removedIds: string[];
524
+ try {
525
+ const raw = await fs.readFile(path.join(options.bundledDir, "manifest.json"), "utf8");
526
+ const manifest = parseSkillsManifest(raw);
527
+ removedIds = manifest.removed[SKILL_TARGET] ?? [];
528
+ } catch {
529
+ return [];
530
+ }
531
+ const bundledIds = getBundledSkillIds();
532
+ const removed: string[] = [];
533
+ for (const id of removedIds) {
534
+ if (bundledIds.includes(id)) continue;
535
+ if (!existsSync(path.join(options.managedDir, id, "SKILL.md"))) continue;
536
+ // Per-id guard: one id's fs failure (EBUSY, EPERM, a directory squatting
537
+ // on the SKILL.md path, ...) must not reject the whole sweep nor discard
538
+ // the ids already removed — log it and move on to the next id.
539
+ try {
540
+ await removeManagedSkill(options.managedDir, id);
541
+ } catch (err) {
542
+ options.log?.info?.(
543
+ `clawchat skill-update: failed to remove tombstoned managed skill ${id} at load: ${
544
+ err instanceof Error ? err.message : String(err)
545
+ }`,
546
+ );
547
+ continue;
548
+ }
549
+ removed.push(id);
550
+ options.log?.info?.(`clawchat skill-update: removed tombstoned managed skill ${id} at load`);
551
+ }
552
+ return removed;
553
+ }
554
+
493
555
  /**
494
556
  * Delete a tombstoned managed skill: remove `<skillsDir>/<skillId>/SKILL.md`
495
557
  * (the host chokidar-watches the dir; the unlink triggers a snapshot rebuild
@@ -684,7 +746,7 @@ export async function runSkillUpdateCheck(
684
746
 
685
747
  const removals: PendingSkillRemoval[] = [];
686
748
  for (const skillId of outcome.removedIds) {
687
- if ((OPENCLAW_SKILL_IDS as readonly string[]).includes(skillId)) {
749
+ if (getBundledSkillIds().includes(skillId)) {
688
750
  options.log?.info?.(`clawchat skill-update: ignoring tombstone for bundled skill ${skillId}`);
689
751
  continue;
690
752
  }
@@ -810,7 +872,7 @@ export async function handleOwnerConsentReply(
810
872
  // Defensive re-guard: bundled skills must never be deleted, even though
811
873
  // runSkillUpdateCheck already filters them out of removal candidates.
812
874
  // Unreachable in the normal flow; kept as a cheap safety net.
813
- if ((OPENCLAW_SKILL_IDS as readonly string[]).includes(removal.skillId)) {
875
+ if (getBundledSkillIds().includes(removal.skillId)) {
814
876
  options.log?.info?.(
815
877
  `clawchat skill-update: skipped removal of bundled skill ${removal.skillId} (defensive re-guard)`,
816
878
  );
package/src/storage.ts CHANGED
@@ -157,6 +157,40 @@ export type ActivationConversation = {
157
157
  lastRefreshedAt: number | null;
158
158
  };
159
159
 
160
+ export type LivewareSampleRow = {
161
+ platform: string;
162
+ account_id: string;
163
+ app_id: string;
164
+ app_name: string;
165
+ port: number;
166
+ public_url: string | null;
167
+ sample_version: string;
168
+ status: string;
169
+ last_error: string | null;
170
+ intro_sent: number;
171
+ created_at: number;
172
+ updated_at: number;
173
+ };
174
+
175
+ export type LivewareSampleUpsert = {
176
+ platform: string;
177
+ accountId: string;
178
+ appId: string;
179
+ appName: string;
180
+ port: number;
181
+ publicUrl?: string | null;
182
+ sampleVersion: string;
183
+ status: "active" | "failed" | "disabled";
184
+ lastError?: string | null;
185
+ };
186
+
187
+ export type UpdateLivewareSampleStatusInput = {
188
+ platform: string;
189
+ accountId: string;
190
+ status: "active" | "failed" | "disabled";
191
+ lastError?: string | null;
192
+ };
193
+
160
194
  type Migration = { version: number; name: string; sql: string };
161
195
 
162
196
  const DB_FILENAME = "clawchat.sqlite";
@@ -290,6 +324,27 @@ ALTER TABLE clawchat_messages ADD COLUMN send_error TEXT;
290
324
  name: "activation_device_id",
291
325
  sql: `
292
326
  ALTER TABLE activations ADD COLUMN device_id TEXT;
327
+ `,
328
+ },
329
+ {
330
+ version: 8,
331
+ name: "liveware_sample",
332
+ sql: `
333
+ CREATE TABLE IF NOT EXISTS liveware_sample (
334
+ platform TEXT NOT NULL,
335
+ account_id TEXT NOT NULL,
336
+ app_id TEXT NOT NULL,
337
+ app_name TEXT NOT NULL,
338
+ port INTEGER NOT NULL,
339
+ public_url TEXT,
340
+ sample_version TEXT NOT NULL,
341
+ status TEXT NOT NULL,
342
+ last_error TEXT,
343
+ intro_sent INTEGER NOT NULL DEFAULT 0,
344
+ created_at INTEGER NOT NULL,
345
+ updated_at INTEGER NOT NULL,
346
+ PRIMARY KEY (platform, account_id)
347
+ );
293
348
  `,
294
349
  },
295
350
  ];
@@ -810,6 +865,72 @@ export class ClawChatStore {
810
865
  }) ?? null;
811
866
  }
812
867
 
868
+ getLivewareSample(input: ConversationAccountInput): LivewareSampleRow | null {
869
+ return this.read(() => {
870
+ const row = this.requireDb()
871
+ .prepare(`SELECT * FROM liveware_sample WHERE platform = ? AND account_id = ?`)
872
+ .get(input.platform, input.accountId);
873
+ return (row as LivewareSampleRow | undefined) ?? null;
874
+ }) ?? null;
875
+ }
876
+
877
+ upsertLivewareSample(input: LivewareSampleUpsert): void {
878
+ this.write(() => {
879
+ const now = Date.now();
880
+ this.requireDb()
881
+ .prepare(
882
+ `INSERT INTO liveware_sample
883
+ (platform, account_id, app_id, app_name, port, public_url,
884
+ sample_version, status, last_error, intro_sent, created_at, updated_at)
885
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?)
886
+ ON CONFLICT(platform, account_id) DO UPDATE SET
887
+ app_id = excluded.app_id,
888
+ app_name = excluded.app_name,
889
+ port = excluded.port,
890
+ public_url = excluded.public_url,
891
+ sample_version = excluded.sample_version,
892
+ status = excluded.status,
893
+ last_error = excluded.last_error,
894
+ updated_at = excluded.updated_at`,
895
+ )
896
+ .run(
897
+ input.platform,
898
+ input.accountId,
899
+ input.appId,
900
+ input.appName,
901
+ input.port,
902
+ input.publicUrl ?? null,
903
+ input.sampleVersion,
904
+ input.status,
905
+ input.lastError ?? null,
906
+ now,
907
+ now,
908
+ );
909
+ });
910
+ }
911
+
912
+ updateLivewareSampleStatus(input: UpdateLivewareSampleStatusInput): void {
913
+ this.write(() => {
914
+ this.requireDb()
915
+ .prepare(
916
+ `UPDATE liveware_sample SET status = ?, last_error = ?, updated_at = ?
917
+ WHERE platform = ? AND account_id = ?`,
918
+ )
919
+ .run(input.status, input.lastError ?? null, Date.now(), input.platform, input.accountId);
920
+ });
921
+ }
922
+
923
+ markLivewareSampleIntroSent(input: ConversationAccountInput): void {
924
+ this.write(() => {
925
+ this.requireDb()
926
+ .prepare(
927
+ `UPDATE liveware_sample SET intro_sent = 1, updated_at = ?
928
+ WHERE platform = ? AND account_id = ?`,
929
+ )
930
+ .run(Date.now(), input.platform, input.accountId);
931
+ });
932
+ }
933
+
813
934
  recordToolCall(input: ToolCallInput): void {
814
935
  this.write(() => {
815
936
  const startedAt = input.startedAt ?? Date.now();