@clawling/clawchat-plugin-openclaw 2026.10.19-1 → 2026.10.21-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.
@@ -142,12 +142,14 @@ export const openclawClawlingSetupPlugin = {
142
142
  lastInboundAt: null,
143
143
  lastOutboundAt: null,
144
144
  }),
145
- resolveAccountSnapshot: ({ account }) => ({
145
+ resolveAccountSnapshot: ({ account, runtime }) => ({
146
146
  accountId: account.accountId,
147
147
  name: account.name,
148
148
  enabled: account.enabled,
149
149
  configured: account.configured,
150
150
  extra: {
151
+ ...Object.fromEntries(["installId", "superseded", "supersededAt", "byClientVersion", "ownerNotifiedAt"]
152
+ .map((key) => [key, runtime?.[key] ?? null])),
151
153
  websocketUrl: account.websocketUrl || null,
152
154
  baseUrl: account.baseUrl || null,
153
155
  userId: account.userId || null,
@@ -37,6 +37,7 @@ export function createOpenclawClawlingClient(account, overrides = {}) {
37
37
  token: account.token,
38
38
  deviceId,
39
39
  clientVersion: resolvePluginVersion(),
40
+ ...(overrides.installId ? { installId: overrides.installId } : {}),
40
41
  ...(overrides.transport ? { transport: overrides.transport } : {}),
41
42
  reconnect: {
42
43
  enabled: true,
@@ -1,3 +1,6 @@
1
+ import { hasReclaimWaiter, installationStatus, recalledSuperseded, rememberSuperseded, signalReclaim } from "./install-conflict.js";
2
+ import { resolveInstallId } from "./install-identity.js";
3
+ import { clawChatDbPathForStateDir, getClawChatStore } from "./storage.js";
1
4
  import { patchOpenclawClawlingAccountSection } from "./account-config-writes.js";
2
5
  import { CHANNEL_ID, normalizeOpenclawClawlingAccountId, readOpenclawClawlingAccountSection, } from "./config.js";
3
6
  /** Activation intent flags — the two escape hatches out of the live-activation guard. */
@@ -198,4 +201,48 @@ export function registerOpenclawClawlingCommands(api) {
198
201
  return { text: formatOutputVisibilityResult(outputVisibility) };
199
202
  },
200
203
  });
204
+ for (const action of ["reclaim", "status"]) {
205
+ api.registerCommand({
206
+ name: `clawchat-${action}`,
207
+ description: action === "reclaim" ? "Reclaim an agent superseded by another installation." : "Show ClawChat installation and connection status.",
208
+ requireAuth: true,
209
+ acceptsArgs: true,
210
+ async handler(ctx) {
211
+ const flag = extractAccountFlag(ctx.args ?? ctx.commandBody);
212
+ if (flag.error)
213
+ return { text: `${flag.error}. Usage: /clawchat-${action} [--account <id>]` };
214
+ const accountId = flag.accountId ?? normalizeOpenclawClawlingAccountId(ctx.accountId);
215
+ try {
216
+ const stateDir = api.runtime.state?.resolveStateDir?.();
217
+ const store = getClawChatStore(stateDir ? { dbPath: clawChatDbPathForStateDir(stateDir) } : {});
218
+ const lookup = store.lookupSuperseded(accountId);
219
+ if (lookup.status === "unavailable")
220
+ return { text: "ClawChat installation conflict storage unavailable; try again after storage recovers (a gateway restart also drops a stop that could not be saved)." };
221
+ if (action === "reclaim") {
222
+ if (lookup.status === "absent" && !hasReclaimWaiter(accountId) && !recalledSuperseded(accountId))
223
+ return { text: "This ClawChat installation is not superseded; nothing to reclaim." };
224
+ if (!store.clearSuperseded(accountId))
225
+ return { text: "Unable to clear ClawChat installation conflict; reclaim failed." };
226
+ rememberSuperseded(accountId, null);
227
+ return { text: signalReclaim(accountId)
228
+ ? "ClawChat reclaim requested; reconnecting this installation."
229
+ : "ClawChat conflict cleared; restart the OpenClaw gateway to reconnect this installation." };
230
+ }
231
+ const fields = installationStatus(lookup.status === "row" ? lookup.row : recalledSuperseded(accountId), resolveInstallId(store));
232
+ const { getOpenclawClawlingClient } = await import("./runtime.js");
233
+ const connection = fields.superseded ? "superseded" : getOpenclawClawlingClient(accountId)?.state ?? "disconnected";
234
+ return { text: [
235
+ `ClawChat account: ${accountId}`, `install_id: ${fields.installId ?? "unavailable"}`,
236
+ `connection: ${connection}`, `superseded: ${fields.superseded}`,
237
+ `superseded_at: ${fields.supersededAt ? new Date(fields.supersededAt).toISOString() : "-"}`,
238
+ `by_client_version: ${fields.byClientVersion ?? "unknown"}`,
239
+ `owner_notified: ${Boolean(fields.ownerNotifiedAt)}`,
240
+ ].join("\n") };
241
+ }
242
+ catch {
243
+ return { text: "ClawChat installation conflict storage unavailable; command failed." };
244
+ }
245
+ },
246
+ });
247
+ }
201
248
  }
@@ -0,0 +1,76 @@
1
+ const key = Symbol.for("clawchat-plugin-openclaw.reclaimWaiters");
2
+ const markersKey = Symbol.for("clawchat-plugin-openclaw.supersededMarkersByIdentity");
3
+ const state = globalThis;
4
+ const waiters = state[key] ??= new Map();
5
+ const markers = state[markersKey] ??= new Map();
6
+ export function rememberSuperseded(accountId, marker) {
7
+ if (marker) {
8
+ const identities = markers.get(accountId) ?? new Map();
9
+ identities.set(marker.userId, marker);
10
+ markers.set(accountId, identities);
11
+ }
12
+ else
13
+ markers.delete(accountId);
14
+ }
15
+ /** Fence per account and identity, retained across runtime calls and module generations.
16
+ * Only explicit reclaim or successful activation clears all identities of an account.
17
+ */
18
+ export function isInstallationSuperseded(accountId, userId) {
19
+ const identities = markers.get(accountId);
20
+ return userId === undefined ? Boolean(identities?.size) : Boolean(identities?.has(userId));
21
+ }
22
+ export function recalledSuperseded(accountId, userId) {
23
+ const identities = markers.get(accountId);
24
+ if (userId !== undefined)
25
+ return identities?.get(userId) ?? null;
26
+ let newest = null;
27
+ for (const marker of identities?.values() ?? []) {
28
+ if (!newest || marker.supersededAt > newest.supersededAt)
29
+ newest = marker;
30
+ }
31
+ return newest;
32
+ }
33
+ export function resetInstallationConflictsForTests() {
34
+ markers.clear();
35
+ }
36
+ /** Register synchronously, before any notification or other asynchronous work. */
37
+ export function waitForReclaim(accountId, signal) {
38
+ return new Promise((resolve) => {
39
+ const entries = waiters.get(accountId) ?? new Set();
40
+ waiters.set(accountId, entries);
41
+ const finish = (reclaim) => {
42
+ entries.delete(wake);
43
+ if (!entries.size)
44
+ waiters.delete(accountId);
45
+ signal.removeEventListener("abort", abort);
46
+ resolve(reclaim);
47
+ };
48
+ const wake = () => finish(true);
49
+ const abort = () => finish(false);
50
+ entries.add(wake);
51
+ signal.addEventListener("abort", abort, { once: true });
52
+ if (signal.aborted)
53
+ abort();
54
+ });
55
+ }
56
+ export function hasReclaimWaiter(accountId) {
57
+ return Boolean(waiters.get(accountId)?.size);
58
+ }
59
+ export function signalReclaim(accountId) {
60
+ const entries = waiters.get(accountId);
61
+ if (!entries?.size)
62
+ return false;
63
+ for (const wake of [...entries])
64
+ wake();
65
+ return true;
66
+ }
67
+ /** The same fields are used by slash commands and the host status adapter. */
68
+ export function installationStatus(marker, installId) {
69
+ return {
70
+ installId: installId?.slice(0, 8) ?? marker?.installId.slice(0, 8) ?? null,
71
+ superseded: Boolean(marker),
72
+ supersededAt: marker?.supersededAt ?? null,
73
+ byClientVersion: marker?.byClientVersion ?? null,
74
+ ownerNotifiedAt: marker?.ownerNotifiedAt ?? null,
75
+ };
76
+ }
@@ -0,0 +1,86 @@
1
+ import childProcess from "node:child_process";
2
+ import { createHash } from "node:crypto";
3
+ import fs, { realpathSync } from "node:fs";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+ let cachedMachineFingerprint;
7
+ export function resetMachineFingerprintForTests() {
8
+ cachedMachineFingerprint = undefined;
9
+ }
10
+ export function readMachineFingerprint(deps) {
11
+ if (deps === undefined && cachedMachineFingerprint !== undefined)
12
+ return cachedMachineFingerprint;
13
+ const fingerprint = detectMachineFingerprint(deps ?? {});
14
+ if (deps === undefined)
15
+ cachedMachineFingerprint = fingerprint;
16
+ return fingerprint;
17
+ }
18
+ function detectMachineFingerprint(deps) {
19
+ const platform = deps.platform ?? process.platform;
20
+ const execFile = deps.execFile ?? ((command, args) => childProcess.execFileSync(command, args, {
21
+ timeout: 2000, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"],
22
+ }));
23
+ try {
24
+ if (platform === "darwin") {
25
+ const output = execFile("ioreg", ["-rd1", "-c", "IOPlatformExpertDevice"]);
26
+ const value = /"IOPlatformUUID"\s*=\s*"([^"\r\n]+)"/.exec(output)?.[1]?.trim().toLowerCase();
27
+ if (value)
28
+ return `mac:${value}`;
29
+ }
30
+ else if (platform === "win32") {
31
+ const output = execFile("reg.exe", ["query", "HKLM\\SOFTWARE\\Microsoft\\Cryptography", "/v", "MachineGuid"]);
32
+ const value = /^[\t ]*MachineGuid[\t ]+REG_SZ[\t ]+([^\r\n]+)/im.exec(output)?.[1]?.trim().toLowerCase();
33
+ if (value)
34
+ return `win:${value}`;
35
+ }
36
+ }
37
+ catch { /* Fall through without logging machine identifiers. */ }
38
+ const readFile = deps.readFile ?? ((file) => fs.readFileSync(file, "utf8"));
39
+ for (const file of ["/etc/machine-id", "/var/lib/dbus/machine-id"]) {
40
+ try {
41
+ const value = readFile(file).trim();
42
+ if (value)
43
+ return value;
44
+ }
45
+ catch { /* Try the fallback without logging machine identifiers. */ }
46
+ }
47
+ try {
48
+ const mac = Object.values((deps.networkInterfaces ?? os.networkInterfaces)())
49
+ .flatMap((interfaces) => interfaces ?? [])
50
+ .filter((entry) => !entry.internal)
51
+ .map((entry) => entry.mac.toLowerCase())
52
+ .filter((value) => value && value !== "00:00:00:00:00:00")
53
+ .sort()[0];
54
+ if (mac)
55
+ return `host:${createHash("sha256").update(`${(deps.hostname ?? os.hostname)()}:${mac}`).digest("hex").slice(0, 24)}`;
56
+ }
57
+ catch { /* No usable host fingerprint; preserve legacy empty material. */ }
58
+ return "";
59
+ }
60
+ /** Resolve symlinks so one state directory reached through an alias keeps one ID. */
61
+ function canonicalDir(dir) {
62
+ try {
63
+ return realpathSync(dir);
64
+ }
65
+ catch {
66
+ return dir;
67
+ }
68
+ }
69
+ export function resolveInstallId(store, options = {}) {
70
+ try {
71
+ const uuid = store?.getOrCreateInstallUuid?.();
72
+ if (!uuid)
73
+ throw new Error("Install identity unavailable");
74
+ // The state directory's absolute path separates a copy of the same state
75
+ // into another directory (or OS user) on the same machine: that copy keeps
76
+ // the persisted device id, so it must not look like the same installation.
77
+ const stateDir = options.stateDir ?? (store?.dbPath ? canonicalDir(path.dirname(path.resolve(store.dbPath))) : "");
78
+ return createHash("sha256")
79
+ .update(`${uuid}:${options.fingerprint ?? readMachineFingerprint()}:${options.hostname ?? os.hostname()}:${stateDir}`)
80
+ .digest("hex").slice(0, 24);
81
+ }
82
+ catch {
83
+ options.warn?.("clawchat.install_identity unavailable; using legacy connection behavior");
84
+ return undefined;
85
+ }
86
+ }
@@ -5,6 +5,7 @@ import { resolvePluginVersion } from "./plugin-report.js";
5
5
  import { openclawClawlingAccountSlotPath, patchOpenclawClawlingAccountSection } from "./account-config-writes.js";
6
6
  import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/setup";
7
7
  import { CHANNEL_ID, mergeOpenclawClawchatRuntimePluginActivation, mergeOpenclawClawchatToolAllow, normalizeOpenclawClawlingAccountId, readOpenclawClawlingAccountSection, resolveOpenclawClawlingAccount, } from "./config.js";
8
+ import { rememberSuperseded, signalReclaim } from "./install-conflict.js";
8
9
  import { getClawChatStore } from "./storage.js";
9
10
  import { buildOnboardingContext, RECONNECT_GUIDE_URL } from "./onboarding-context.js";
10
11
  import { boundCodeNewIdentityMessage, preCheckConnectCode, unpairableMessage } from "./connect-check.js";
@@ -405,6 +406,10 @@ export async function runOpenclawClawlingLogin(params) {
405
406
  // sends the same device id the backend baked into the session.
406
407
  deviceId: CHANNEL_ID,
407
408
  });
409
+ if (store.clearSuperseded?.(accountId) === true) {
410
+ rememberSuperseded(accountId, null);
411
+ signalReclaim(accountId);
412
+ }
408
413
  }
409
414
  catch {
410
415
  runtime.log("clawchat-plugin-openclaw sqlite activation persistence failed; login continues.");
@@ -0,0 +1,59 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import os from "node:os";
3
+ import { resolveOwnerIdentity } from "./owner-identity.js";
4
+ import { resolveOwnerLanguage } from "./owner-language.js";
5
+ import { createClawChatClient } from "./ws-client.js";
6
+ export const STANDALONE_DEVICE_SUFFIX = "-standalone";
7
+ export const OWNER_NOTICE_TIMEOUT_MS = 20_000;
8
+ export function formatSupersededOwnerNotice(locale, version, hostname) {
9
+ const language = resolveOwnerLanguage(locale);
10
+ const host = hostname?.trim();
11
+ return language === "zh" || language === "zh_Hant"
12
+ ? `⚠️ 这个 agent 已在另一处安装上线${version ? `(对方版本 ${version})` : ""},${host ? `本机(${host})上的` : "这里的"}这份安装已停止连接,避免两边互相顶号。如需切回本机,请在本机的 OpenClaw 里发送 /clawchat-reclaim。`
13
+ : `⚠️ This agent was started by another installation${version ? ` (version ${version})` : ""}, so the installation on ${host ? `this machine (${host})` : "this machine"} has stopped connecting to avoid the two kicking each other offline. To switch back to this machine, send /clawchat-reclaim in OpenClaw on this machine.`;
14
+ }
15
+ export async function notifySupersededOwner(options) {
16
+ const { store, marker, warn } = options;
17
+ let client;
18
+ let timer;
19
+ const close = () => client?.close();
20
+ try {
21
+ const owner = resolveOwnerIdentity(store, marker.accountId);
22
+ const token = store?.getActivationCredentials?.({ platform: "openclaw", accountId: marker.accountId })?.accessToken;
23
+ if (!owner.conversationId || !token) {
24
+ warn("clawchat.superseded owner notice skipped; owner chat or stored credentials unavailable");
25
+ return;
26
+ }
27
+ if (options.signal?.aborted || !store?.claimSupersededNotification?.(marker.accountId, Date.now(), marker.supersededAt))
28
+ return;
29
+ client = (options.createClient ?? createClawChatClient)({
30
+ url: options.url, token, deviceId: `${marker.deviceId}${STANDALONE_DEVICE_SUFFIX}`,
31
+ reconnect: { enabled: false }, heartbeat: { enabled: false },
32
+ ack: { timeout: OWNER_NOTICE_TIMEOUT_MS, autoResendOnTimeout: false },
33
+ });
34
+ options.signal?.addEventListener("abort", close, { once: true });
35
+ const send = async () => {
36
+ await client.connect();
37
+ const locale = store?.getOwnerProfile?.({ platform: "openclaw", accountId: marker.accountId })?.locale;
38
+ await client.sendAckableEnvelope({
39
+ eventName: "message.send", chatId: owner.conversationId,
40
+ payload: {
41
+ message_id: randomUUID(), message_mode: "normal",
42
+ message: { body: { fragments: [{ kind: "text", text: formatSupersededOwnerNotice(locale, marker.byClientVersion, os.hostname()) }] },
43
+ context: { mentions: [], reply: null } },
44
+ },
45
+ });
46
+ };
47
+ await Promise.race([send(), new Promise((_, reject) => {
48
+ timer = setTimeout(() => reject(new Error("Owner notice timeout")), OWNER_NOTICE_TIMEOUT_MS);
49
+ })]);
50
+ }
51
+ catch {
52
+ warn("clawchat.superseded owner notice failed; will not retry");
53
+ }
54
+ finally {
55
+ clearTimeout(timer);
56
+ options.signal?.removeEventListener("abort", close);
57
+ close();
58
+ }
59
+ }