@clawling/clawchat-plugin-openclaw 2026.7.2 → 2026.7.6-2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clawling/clawchat-plugin-openclaw",
3
- "version": "2026.7.2",
3
+ "version": "2026.7.6-2",
4
4
  "description": "OpenClaw ClawChat channel plugin",
5
5
  "license": "MIT",
6
6
  "author": "CLAWLING PTE. LTD.",
@@ -0,0 +1,35 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+
5
+ // Worded to make the greeting the single required action. Earlier wording
6
+ // ("ClawChat activation bootstrap: ... do both ...") led some agents to treat
7
+ // it as a setup task and write a `BOOTSTRAP.md` file instead of replying, so
8
+ // no greeting reached the user. Lead with the chat reply, forbid file/tool
9
+ // detours, and keep the profile update strictly optional and secondary.
10
+ const ACTIVATION_BOOTSTRAP_FALLBACK = [
11
+ "You are now connected to a ClawChat direct conversation with your user.",
12
+ "Reply now with one short, friendly greeting message in this conversation: introduce yourself and say you are connected and ready.",
13
+ "Send it as a normal chat reply. Do not write or create any files or notes, and do not call tools just to greet.",
14
+ "Only if you already have your own profile details (display name, bio, or avatar) may you also call `clawchat_update_account_profile` (use `clawchat_upload_avatar_image` first for a local avatar image); otherwise skip that and just greet.",
15
+ "Do not ask the user for profile information.",
16
+ ].join("\n");
17
+
18
+ // Cross-plugin, user-editable override read lazily so edits apply on the next
19
+ // first-load without a restart. Any read failure falls back to the built-in text.
20
+ export function buildActivationBootstrapText(homeDir: string = os.homedir()): string {
21
+ const greetingPath = path.join(homeDir, "clawchat", "greeting.md");
22
+ try {
23
+ const raw = fs.readFileSync(greetingPath);
24
+ const override = new TextDecoder("utf-8", { fatal: true }).decode(raw).trim();
25
+ if (override.length > 0) {
26
+ return override;
27
+ }
28
+ } catch (error) {
29
+ const code = (error as NodeJS.ErrnoException).code;
30
+ if (code !== "ENOENT") {
31
+ console.warn(`clawchat.greeting failed to read override ${greetingPath}:`, error);
32
+ }
33
+ }
34
+ return ACTIVATION_BOOTSTRAP_FALLBACK;
35
+ }
package/src/api-client.ts CHANGED
@@ -24,6 +24,7 @@ export interface PluginReportInput {
24
24
  deviceId: string;
25
25
  platform: string;
26
26
  pluginVersion: string;
27
+ agentVersion: string;
27
28
  runtimeName: string;
28
29
  runtimeVersion: string;
29
30
  }
@@ -33,6 +34,7 @@ export function buildPluginReportBody(input: PluginReportInput): Record<string,
33
34
  device_id: input.deviceId,
34
35
  platform: input.platform,
35
36
  plugin_version: input.pluginVersion,
37
+ agent_version: input.agentVersion,
36
38
  runtime_name: input.runtimeName,
37
39
  runtime_version: input.runtimeVersion,
38
40
  };
package/src/config.ts CHANGED
@@ -341,7 +341,7 @@ function decodeJwtStringClaim(token: string, claim: string): string {
341
341
  * userId that disagrees with `sub` is always wrong for the session this token
342
342
  * creates, and it silently breaks self-echo detection: with the OWNER's id
343
343
  * here, every owner message is dropped as the bot's own echo (2026-07-02
344
- * clawnest incident — a provisioner wrote the owner id into the config). Token
344
+ * production incident — a provisioner wrote the owner id into the config). Token
345
345
  * wins; the mismatch is logged. Also derives userId from `sub` when nothing is
346
346
  * configured, mirroring the `oid` fallback above.
347
347
  */
@@ -46,6 +46,7 @@ export interface ReportParams {
46
46
  token: string;
47
47
  deviceId: string;
48
48
  pluginVersion: string;
49
+ agentVersion: string;
49
50
  authenticated: boolean;
50
51
  log?: { debug?: (msg: string) => void };
51
52
  }
@@ -66,6 +67,7 @@ export async function reportPluginVersionSafe(p: ReportParams): Promise<void> {
66
67
  deviceId: p.deviceId,
67
68
  platform: "openclaw",
68
69
  pluginVersion: p.pluginVersion,
70
+ agentVersion: p.agentVersion,
69
71
  runtimeName: "node",
70
72
  runtimeVersion: process.version,
71
73
  },
@@ -32,6 +32,13 @@ export const MIN_REFRESH_INTERVAL_MS = 30_000;
32
32
  export const PROACTIVE_JITTER_MS = 5 * MINUTE_MS;
33
33
  /** §A.0 — fallback access-token TTL when `exp` is unparseable. */
34
34
  export const ACCESS_TOKEN_TTL_MS = 24 * HOUR_MS;
35
+ /**
36
+ * §A.1 — Node stores a `setTimeout` delay in a signed 32-bit int; a delay above
37
+ * this silently clamps to 1ms and fires *immediately*. Long-lived (~30-day)
38
+ * access tokens push `refresh_at - now` well past this, so the proactive wait is
39
+ * chunked into at-most-this-long sleeps that re-arm until the real time.
40
+ */
41
+ export const MAX_TIMER_DELAY_MS = 2_147_483_647; // 2^31 - 1
35
42
 
36
43
  /** Opaque timer handle — `setTimeout` returns a `Timeout` in node, a number in others. */
37
44
  export type TimerHandle = ReturnType<typeof setTimeout> | number;
@@ -296,6 +303,20 @@ export class RefreshManager {
296
303
  jitterMs,
297
304
  });
298
305
  const delayMs = Math.max(0, refreshAtMs - this.now());
306
+ if (delayMs > MAX_TIMER_DELAY_MS) {
307
+ // Too far out for a single setTimeout without 32-bit overflow (which would
308
+ // fire at 1ms and trigger an immediate refresh + WS reconnect, cancelling
309
+ // any in-flight send). Sleep one max chunk, then re-arm from the still-live
310
+ // token for the remaining wait.
311
+ this.ports.log?.debug?.(
312
+ `clawchat-plugin-openclaw proactive timer chunked delayMs=${delayMs} chunk=${MAX_TIMER_DELAY_MS}`,
313
+ );
314
+ this.proactiveTimer = this.setTimer(() => {
315
+ this.proactiveTimer = null;
316
+ this.armProactiveTimer(activatedAtMs);
317
+ }, MAX_TIMER_DELAY_MS);
318
+ return;
319
+ }
299
320
  this.ports.log?.debug?.(
300
321
  `clawchat-plugin-openclaw proactive timer armed delayMs=${delayMs}`,
301
322
  );
package/src/runtime.ts CHANGED
@@ -15,6 +15,7 @@ import type { OpenClawConfig, PluginRuntime } from "openclaw/plugin-sdk/core";
15
15
  import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store";
16
16
  import { createOpenclawClawlingClient, resolveOpenclawClawlingDeviceId } from "./client.ts";
17
17
  import { createOpenclawClawlingApiClient } from "./api-client.ts";
18
+ import { buildActivationBootstrapText } from "./activation-greeting.ts";
18
19
  import { reportPluginVersionSafe, resolvePluginVersion } from "./plugin-report.ts";
19
20
  import { ensureLivewareCli } from "./liveware-cli.ts";
20
21
  import { ClawlingApiError } from "./api-types.ts";
@@ -38,7 +39,8 @@ import { dispatchOpenclawClawlingInbound, type IngestTurnParams } from "./inboun
38
39
  import {
39
40
  PendingConsentStore,
40
41
  handleOwnerConsentReply,
41
- readEffectiveSkillVersions,
42
+ managedSkillExists,
43
+ readLocalSkillState,
42
44
  resolveBundledSkillsDir,
43
45
  resolveManagedSkillsDir,
44
46
  runSkillUpdateCheck,
@@ -100,6 +102,7 @@ type RuntimeConnectionStore = Pick<
100
102
  Pick<
101
103
  ClawChatStore,
102
104
  | "getActivationCredentials"
105
+ | "upsertActivation"
103
106
  | "rotateActivationTokens"
104
107
  | "clearActivationCredentials"
105
108
  | "insertMessage"
@@ -455,21 +458,6 @@ function withClawChatSessionScope(cfg: OpenClawConfig): OpenClawConfig {
455
458
  };
456
459
  }
457
460
 
458
- function buildActivationBootstrapText(): string {
459
- // Worded to make the greeting the single required action. Earlier wording
460
- // ("ClawChat activation bootstrap: ... do both ...") led some agents to treat
461
- // it as a setup task and write a `BOOTSTRAP.md` file instead of replying, so
462
- // no greeting reached the user. Lead with the chat reply, forbid file/tool
463
- // detours, and keep the profile update strictly optional and secondary.
464
- return [
465
- "You are now connected to a ClawChat direct conversation with your user.",
466
- "Reply now with one short, friendly greeting message in this conversation: introduce yourself and say you are connected and ready.",
467
- "Send it as a normal chat reply. Do not write or create any files or notes, and do not call tools just to greet.",
468
- "Only if you already have your own profile details (display name, bio, or avatar) may you also call `clawchat_update_account_profile` (use `clawchat_upload_avatar_image` first for a local avatar image); otherwise skip that and just greet.",
469
- "Do not ask the user for profile information.",
470
- ].join("\n");
471
- }
472
-
473
461
  function buildActivationBootstrapEnvelope(params: {
474
462
  account: ResolvedOpenclawClawlingAccount;
475
463
  conversationId: string;
@@ -871,6 +859,28 @@ async function waitForActivationCredentials(params: {
871
859
  }): Promise<{ account: ResolvedOpenclawClawlingAccount; source: "configured" | "sqlite" } | null> {
872
860
  const { abortSignal, getStatus, setStatus, store, log } = params;
873
861
  const accountId = params.account.accountId;
862
+ // SQLite-first: activation and token rotation persist to the activations
863
+ // row, so a complete row is the durable credential source of truth even
864
+ // when channel config/env also carries credentials. Config/env credentials
865
+ // only bootstrap the no-row case (the caller seeds a row from them).
866
+ if (store?.getActivationCredentials) {
867
+ const stored = store.getActivationCredentials({ platform: "openclaw", accountId });
868
+ if (stored && stored.accessToken !== params.rejectedActivationToken) {
869
+ log?.info?.(
870
+ `[${accountId}] clawchat-plugin-openclaw loaded activation credentials from sqlite`,
871
+ );
872
+ return {
873
+ source: "sqlite",
874
+ account: {
875
+ ...params.account,
876
+ configured: true,
877
+ token: stored.accessToken,
878
+ userId: stored.userId,
879
+ ownerUserId: stored.ownerUserId,
880
+ },
881
+ };
882
+ }
883
+ }
874
884
  if (accountHasConnectCredentials(params.account)) {
875
885
  return { account: params.account, source: "configured" };
876
886
  }
@@ -956,12 +966,14 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
956
966
  // so the backend links both to the same row. Imported from ./client.ts.
957
967
  const reportDeviceId = resolveOpenclawClawlingDeviceId(account);
958
968
  const pluginVersion = resolvePluginVersion();
969
+ const agentVersion = runtime.version ?? "";
959
970
  void reportPluginVersionSafe({
960
971
  baseUrl: account.baseUrl,
961
972
  mediaBaseUrl: account.mediaBaseUrl,
962
973
  token: "",
963
974
  deviceId: reportDeviceId,
964
975
  pluginVersion,
976
+ agentVersion,
965
977
  authenticated: false,
966
978
  log,
967
979
  });
@@ -1001,6 +1013,27 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
1001
1013
  });
1002
1014
  if (!activationAccount) return;
1003
1015
  account = activationAccount.account;
1016
+ // Config/env bootstrap seeding: converge on SQLite as the single durable
1017
+ // credential store, so token rotation always has a row to update and the
1018
+ // next startup resolves SQLite-first to the same credentials.
1019
+ if (activationAccount.source === "configured" && store?.upsertActivation) {
1020
+ try {
1021
+ store.upsertActivation({
1022
+ platform: "openclaw",
1023
+ accountId,
1024
+ userId: account.userId,
1025
+ ownerUserId: account.ownerUserId,
1026
+ accessToken: account.token,
1027
+ refreshToken: readConfigRefreshToken(cfg),
1028
+ loginMethod: "config-seed",
1029
+ deviceId: CHANNEL_ID,
1030
+ });
1031
+ } catch (error) {
1032
+ log?.error?.(
1033
+ `[${accountId}] clawchat-plugin-openclaw sqlite credential seed failed; continuing with config credentials: ${error instanceof Error ? error.message : String(error)}`,
1034
+ );
1035
+ }
1036
+ }
1004
1037
  // Paired: link the report row via the authenticated endpoint, reusing the
1005
1038
  // SAME frozen device_id so the backend upserts the existing unpaired row.
1006
1039
  void reportPluginVersionSafe({
@@ -1009,12 +1042,15 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
1009
1042
  token: account.token,
1010
1043
  deviceId: reportDeviceId,
1011
1044
  pluginVersion,
1045
+ agentVersion,
1012
1046
  authenticated: true,
1013
1047
  log,
1014
1048
  });
1015
- // §A.0 — fallback expiry source. Prefer the SQLite `activated_at`; null when
1016
- // the credentials came from config (no activation row yet) — in that case the
1017
- // refresh manager relies on the JWT `exp` alone.
1049
+ // §A.0 — fallback expiry source. Prefer the SQLite `activated_at`; null for a
1050
+ // configured start even though the seed block above may have just created a
1051
+ // row — this cell only ever queries `store.getActivationCredentials` when
1052
+ // `source === "sqlite"`, so a config-sourced account relies on the JWT `exp`
1053
+ // alone until its next SQLite-first resolution.
1018
1054
  let activatedAtMs: number | null =
1019
1055
  activationAccount.source === "sqlite" && store?.getActivationCredentials
1020
1056
  ? store.getActivationCredentials({ platform: "openclaw", accountId })?.activatedAt ?? null
@@ -1207,16 +1243,16 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
1207
1243
  getAccessToken: () => account.token,
1208
1244
  getRefreshToken: () => latestRefreshToken,
1209
1245
  persistRotatedTokens: async (tokens) => {
1210
- // §0 — persist to BOTH stores BEFORE the in-memory swap. A failure in
1211
- // EITHER store must REJECT so the manager skips the in-memory swap and
1212
- // treats the refresh as transient (keep the current tokens, back off). Do
1213
- // NOT swallow the SQLite write error: `rotateActivationTokens` returns
1214
- // `null` when its internal `write()` caught an exception (a real write
1215
- // failure), `false` only when no activation row exists yet (config-sourced
1216
- // agent — legitimately nothing to update). A swallowed write failure must
1217
- // not leave the SQLite row holding the now-dead refresh token while the
1218
- // in-memory token is rotated, which would brick a sqlite-sourced agent on
1219
- // restart.
1246
+ // §0 — persist durably BEFORE the in-memory swap. SQLite is the
1247
+ // startup credential source of truth: when a store is available its
1248
+ // write must succeed or the refresh stays transient (no swap, keep
1249
+ // current tokens, back off). A 0-row UPDATE (`false`) means the
1250
+ // activations row is missing (e.g. cleared out-of-band) rather than a
1251
+ // write failure — self-heal by re-seeding it from the in-memory
1252
+ // identity with the ROTATED pair (mirrors Hermes's seed-on-rotate
1253
+ // §C.2 semantics), then continue exactly as on success. `null` is a
1254
+ // caught write exception and stays transient — that's a real failure,
1255
+ // not a missing row.
1220
1256
  if (store?.rotateActivationTokens) {
1221
1257
  const rotateResult = store.rotateActivationTokens({
1222
1258
  platform: "openclaw",
@@ -1224,12 +1260,41 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
1224
1260
  accessToken: tokens.accessToken,
1225
1261
  refreshToken: tokens.refreshToken,
1226
1262
  });
1227
- if (rotateResult === null) {
1263
+ if (rotateResult === false) {
1264
+ if (!store.upsertActivation) {
1265
+ throw new Error("clawchat-plugin-openclaw sqlite rotate activation tokens failed");
1266
+ }
1267
+ try {
1268
+ store.upsertActivation({
1269
+ platform: "openclaw",
1270
+ accountId,
1271
+ userId: account.userId,
1272
+ ownerUserId: account.ownerUserId,
1273
+ accessToken: tokens.accessToken,
1274
+ refreshToken: tokens.refreshToken,
1275
+ loginMethod: "config-seed",
1276
+ deviceId: refreshDeviceId,
1277
+ });
1278
+ } catch {
1279
+ throw new Error("clawchat-plugin-openclaw sqlite rotate activation tokens failed");
1280
+ }
1281
+ } else if (rotateResult === null) {
1228
1282
  throw new Error("clawchat-plugin-openclaw sqlite rotate activation tokens failed");
1229
1283
  }
1284
+ // Channel config is a human-readable mirror once SQLite holds the
1285
+ // pair — sync best-effort, never fail the refresh over it.
1286
+ try {
1287
+ await persistConfigTokens(tokens);
1288
+ } catch (error) {
1289
+ log?.error?.(
1290
+ `[${accountId}] clawchat-plugin-openclaw config token mirror failed (sqlite already rotated): ${error instanceof Error ? error.message : String(error)}`,
1291
+ );
1292
+ }
1293
+ return;
1230
1294
  }
1231
- // A config write failure rejects out of `mutateConfigFile` and propagates
1232
- // here, which is what we want — persistence incomplete ⇒ no swap.
1295
+ // No SQLite store available — channel config is the only durable
1296
+ // store, so its write failure must reject and keep the refresh
1297
+ // transient (pre-existing behavior for store-less test transports).
1233
1298
  await persistConfigTokens(tokens);
1234
1299
  },
1235
1300
  swapInMemoryTokens: (tokens) => {
@@ -2154,6 +2219,10 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
2154
2219
  // The bundled `./skills` dir is READ-ONLY here: a first-boot fallback for the
2155
2220
  // "current installed version" only — never written (it lives in node_modules
2156
2221
  // → may be read-only and is clobbered on `openclaw plugins update`).
2222
+ // Update detection converges on raw sha256 (readLocalSkillState), not the
2223
+ // frontmatter version, so a same-version content fix still applies; a
2224
+ // manifest `removed` id tombstones the LOCAL managed copy (never bundled)
2225
+ // via removeManagedSkill, deleting only ids the official manifest names.
2157
2226
  const skillUpdateConsent = new PendingConsentStore();
2158
2227
  const managedSkillsDir = resolveManagedSkillsDir();
2159
2228
  let bundledSkillsDir: string | null = null;
@@ -2203,10 +2272,10 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
2203
2272
  return;
2204
2273
  }
2205
2274
  // Effective current = managed copy if present, else bundled (host precedence).
2206
- const localVersions = await readEffectiveSkillVersions(managedSkillsDir, bundledSkillsDir);
2207
2275
  await runSkillUpdateCheck({
2208
2276
  ownerUserId,
2209
- localVersions,
2277
+ readLocalState: (id) => readLocalSkillState(managedSkillsDir, bundledSkillsDir, id),
2278
+ hasManagedSkill: (id) => managedSkillExists(managedSkillsDir, id),
2210
2279
  fetchFn: globalThis.fetch,
2211
2280
  store: skillUpdateConsent,
2212
2281
  sendOwnerMessage: sendOwnerSkillMessage,