@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.
- package/dist/src/channel.setup.js +3 -1
- package/dist/src/client.js +1 -0
- package/dist/src/commands.js +47 -0
- package/dist/src/install-conflict.js +76 -0
- package/dist/src/install-identity.js +86 -0
- package/dist/src/login.runtime.js +5 -0
- package/dist/src/owner-sibling-notify.js +59 -0
- package/dist/src/runtime.js +217 -46
- package/dist/src/storage.js +98 -2
- package/dist/src/ws-client.js +54 -9
- package/package.json +1 -1
- package/src/channel.setup.ts +3 -1
- package/src/client.ts +2 -0
- package/src/commands.ts +43 -0
- package/src/install-conflict.ts +81 -0
- package/src/install-identity.ts +91 -0
- package/src/login.runtime.ts +6 -1
- package/src/owner-sibling-notify.ts +75 -0
- package/src/protocol-types.ts +2 -0
- package/src/runtime.ts +188 -44
- package/src/storage.ts +110 -2
- package/src/ws-client.ts +53 -9
|
@@ -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,
|
package/dist/src/client.js
CHANGED
|
@@ -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,
|
package/dist/src/commands.js
CHANGED
|
@@ -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
|
+
}
|