@clawling/clawchat-plugin-openclaw 2026.7.6-2 → 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/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,
@@ -1096,6 +1099,45 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
1096
1099
  conversationApiClient = undefined;
1097
1100
  };
1098
1101
 
1102
+ // Liveware Sample auto-boot (spec: docs/superpowers/specs/2026-07-06-liveware-sample-autoboot-design.md).
1103
+ // Fire-and-forget like ensureLivewareCli above: never blocks or fails the channel.
1104
+ // `livewareSampleNotify` is reassigned once `client`/`account` are ready (see
1105
+ // `sendOwnerSkillMessage` below) — both live in this same function closure, so
1106
+ // the forward reference here just resolves to the fresh value at call time.
1107
+ let livewareSampleNotify: (text: string) => Promise<boolean> = async () => false;
1108
+ if (store) {
1109
+ const stateDirForSample = runtime.state?.resolveStateDir?.();
1110
+ const livewareSampleSupervisor = new LivewareSampleSupervisor({
1111
+ platform: "openclaw",
1112
+ accountId,
1113
+ enabled: account.livewareSample,
1114
+ // `store` here is the `ClawChatStore` returned by `resolveConnectionStore`;
1115
+ // `RuntimeConnectionStore` only `Pick`s a subset of its methods, so widen
1116
+ // through the structural `LivewareSampleStoreLike` contract (Task 8/4).
1117
+ store: store as unknown as LivewareSampleStoreLike,
1118
+ sampleRoot: path.join(stateDirForSample ?? "", "clawchat", "liveware-sample"),
1119
+ resolveToken: () => account.token ?? "",
1120
+ resolveLivewarePath: () => resolveLivewarePath(stateDirForSample),
1121
+ listApps: async () => {
1122
+ const client = getConversationApiClient();
1123
+ return await client.listApps();
1124
+ },
1125
+ registerApp: async (p) => {
1126
+ const client = getConversationApiClient();
1127
+ return await client.registerApp(p);
1128
+ },
1129
+ notifyOwner: (text) => livewareSampleNotify(text),
1130
+ fetchFn: globalThis.fetch,
1131
+ log: {
1132
+ warn: (m) => log?.info?.(m),
1133
+ debug: (m) => log?.debug?.(m),
1134
+ error: (m) => log?.error?.(m),
1135
+ },
1136
+ });
1137
+ abortSignal.addEventListener("abort", () => livewareSampleSupervisor.stop());
1138
+ void livewareSampleSupervisor.start();
1139
+ }
1140
+
1099
1141
  const resolveMutateConfigFile = (): OpenclawClawchatMutateConfigFile | undefined => {
1100
1142
  if (params.mutateConfigFile) return params.mutateConfigFile;
1101
1143
  const runtimeConfig = runtime.config as unknown as {
@@ -2235,6 +2277,14 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
2235
2277
  }`,
2236
2278
  );
2237
2279
  }
2280
+ // Load-time tombstone sweep: delete any managed skill copy the bundled
2281
+ // manifest snapshot marks as removed, so a stale id from an old install
2282
+ // stops shadowing/loading. Fire-and-forget — must never block or fail boot.
2283
+ void cleanupTombstonedManagedSkills({
2284
+ managedDir: managedSkillsDir,
2285
+ bundledDir: bundledSkillsDir,
2286
+ log,
2287
+ }).catch(() => {});
2238
2288
  // A usr_... id is not a valid chat_id — member-backend rejects it with
2239
2289
  // "invalid conversation id" and msghub never acks the send. Proactive owner
2240
2290
  // messages must be addressed to the direct conversation recorded at
@@ -2263,6 +2313,21 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
2263
2313
  log,
2264
2314
  });
2265
2315
  };
2316
+ // Wire the liveware-sample intro notifier now that `client`/`account` are in
2317
+ // scope (both declared earlier in this same function body — see the
2318
+ // `livewareSampleNotify` forward declaration above the supervisor construction).
2319
+ livewareSampleNotify = async (text: string): Promise<boolean> => {
2320
+ const conversationId = resolveOwnerDirectConversationId();
2321
+ if (!conversationId) return false;
2322
+ const result = await sendOpenclawClawlingText({
2323
+ client,
2324
+ account,
2325
+ to: { chatId: conversationId, chatType: "direct" },
2326
+ text,
2327
+ log,
2328
+ });
2329
+ return result != null;
2330
+ };
2266
2331
  const triggerSkillUpdateCheck = async (): Promise<void> => {
2267
2332
  const ownerUserId = account.ownerUserId?.trim();
2268
2333
  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", "liveware-app"] 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();