@clawling/clawchat-plugin-openclaw 2026.7.26-1 → 2026.7.28-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.
@@ -1,7 +1,28 @@
1
1
  import { CHANNEL_ID } from "./config.js";
2
+ /** Activation intent flags — the two escape hatches out of the live-activation guard. */
3
+ const ACTIVATION_FLAGS = ["--new-account", "--repair"];
4
+ function stripActivationFlags(raw) {
5
+ return ACTIVATION_FLAGS.reduce((acc, flag) => acc.split(flag).join(" "), raw);
6
+ }
2
7
  function extractInviteCode(value) {
3
8
  const raw = typeof value === "string" ? value.trim() : "";
4
- return raw.match(/\b[A-Z0-9]{6}\b/u)?.[0] ?? "";
9
+ return stripActivationFlags(raw).match(/\b[A-Z0-9]{6}\b/u)?.[0] ?? "";
10
+ }
11
+ /**
12
+ * Read the activation intent from the command args.
13
+ *
14
+ * Redeeming a code while this instance holds a live activation is refused by
15
+ * `runOpenclawClawlingLogin` (it would re-bind the code to the incumbent agent
16
+ * rather than add a second one). `/clawchat-activate` is the only activation
17
+ * surface whose arguments this plugin parses, so it carries the two intents:
18
+ * `--new-account` replaces the identity, `--repair` re-pairs the current one.
19
+ */
20
+ function extractActivationIntent(value) {
21
+ const raw = typeof value === "string" ? value : "";
22
+ return {
23
+ newAccount: raw.includes("--new-account"),
24
+ repair: raw.includes("--repair"),
25
+ };
5
26
  }
6
27
  function errorMessage(err) {
7
28
  return err instanceof Error ? err.message : String(err);
@@ -85,10 +106,12 @@ export function registerOpenclawClawlingCommands(api) {
85
106
  acceptsArgs: true,
86
107
  requireAuth: true,
87
108
  async handler(ctx) {
88
- const code = extractInviteCode(ctx.args ?? ctx.commandBody);
109
+ const rawArgs = ctx.args ?? ctx.commandBody;
110
+ const code = extractInviteCode(rawArgs);
89
111
  if (!code) {
90
112
  return { text: "ClawChat invite code is required. Usage: /clawchat-activate A1B2C3" };
91
113
  }
114
+ const intent = extractActivationIntent(rawArgs);
92
115
  try {
93
116
  const { runOpenclawClawlingLogin } = await import("./login.runtime.js");
94
117
  await runOpenclawClawlingLogin({
@@ -97,6 +120,8 @@ export function registerOpenclawClawlingCommands(api) {
97
120
  runtime: { log: (message) => api.logger?.info?.(message) },
98
121
  readInviteCode: async () => code,
99
122
  mutateConfigFile: api.runtime.config.mutateConfigFile,
123
+ newAccount: intent.newAccount,
124
+ repair: intent.repair,
100
125
  });
101
126
  return { text: "✅ ClawChat activated successfully." };
102
127
  }
@@ -86,7 +86,15 @@ export function createGroupMessageCoalescer(params) {
86
86
  return;
87
87
  }
88
88
  const idleTimer = setTimeout(() => flush(chatId), idleMs);
89
- const maxWaitTimer = setTimeout(() => flush(chatId), params.maxWaitMs);
89
+ // maxWait bounds how long a chat that keeps talking can defer its batch;
90
+ // it is NOT a second, shorter deadline competing with the owner's chosen
91
+ // delay. Arming it from params.maxWaitMs alone silently truncated any
92
+ // longer idleMsOverride — a group set to batch_delay_seconds=300 still
93
+ // got a reply at 30s. Take whichever is larger so the ceiling can never
94
+ // undercut the delay, while the default shape (idle 10s / maxWait 30s)
95
+ // keeps its existing 30s cutoff.
96
+ const maxWaitMs = Math.max(params.maxWaitMs, idleMs);
97
+ const maxWaitTimer = setTimeout(() => flush(chatId), maxWaitMs);
90
98
  pending.set(chatId, { turns: [turn], idleTimer, maxWaitTimer });
91
99
  },
92
100
  flushNow(chatId) {
@@ -21,6 +21,38 @@ export const AGENTS_CONNECT_TYPE = "clawbot";
21
21
  * retries once.
22
22
  */
23
23
  export const AGENT_NOT_FOUND_CODE = 16001;
24
+ /**
25
+ * Thrown when redeeming an invite code would silently replace a live activation.
26
+ *
27
+ * This channel holds exactly one ClawChat identity — `channels.<id>` is a single
28
+ * slot and `listOpenclawClawlingAccountIds()` returns a single account. Because
29
+ * the stored `user_id` is replayed as a re-pair hint, redeeming a *new* invite
30
+ * code here cannot produce a second agent: the server binds the code to the
31
+ * existing agent, consumes it, creates no second agent and no second contact
32
+ * entry, and the local credentials are overwritten in place — losing the first
33
+ * agent with no error and no warning. Refuse instead, and name the real intents.
34
+ */
35
+ export class ExistingActivationError extends Error {
36
+ userId;
37
+ agentId;
38
+ constructor(userId, agentId = "") {
39
+ const who = agentId ? `agent ${agentId} (shadow user ${userId})` : `agent ${userId}`;
40
+ super(`this OpenClaw instance is already paired to ClawChat ${who}. ` +
41
+ "Redeeming another invite code here would re-bind that code to the SAME " +
42
+ "agent — no second agent is created, and this instance's credentials are " +
43
+ "overwritten.\n" +
44
+ " - To run a SECOND agent alongside this one, give it its own OpenClaw " +
45
+ "home:\n" +
46
+ " OPENCLAW_HOME=<path> openclaw channels login --channel clawchat-plugin-openclaw\n" +
47
+ " - To REPLACE this instance's agent with a brand-new identity: " +
48
+ "/clawchat-activate <CODE> --new-account\n" +
49
+ " - To re-pair THIS agent (e.g. after losing its token): " +
50
+ "/clawchat-activate <CODE> --repair");
51
+ this.name = "ExistingActivationError";
52
+ this.userId = userId;
53
+ this.agentId = agentId;
54
+ }
55
+ }
24
56
  /**
25
57
  * Prompt the operator for an invite code.
26
58
  *
@@ -133,6 +165,14 @@ export async function runOpenclawClawlingLogin(params) {
133
165
  // `DEFAULT_BASE_URL` / `DEFAULT_WEBSOCKET_URL` when the operator has not
134
166
  // overridden them, so login works without a prior `openclaw channels setup --channel clawchat-plugin-openclaw`.
135
167
  const account = resolveOpenclawClawlingAccount(cfg);
168
+ // Guard before the invite code is even read, so a refused activation never
169
+ // spends the code — the operator can still redeem it the right way. "Live" is
170
+ // decided by whether a usable token remains: auto-logout (§C) blanks the
171
+ // tokens but preserves the identity, so a logged-out instance still re-pairs
172
+ // with no flag at all, which is the flow the replay exists for.
173
+ if (account.userId.trim() && account.token.trim() && !params.newAccount && !params.repair) {
174
+ throw new ExistingActivationError(account.userId.trim(), account.agentId.trim());
175
+ }
136
176
  const inviteCode = (await (params.readInviteCode ?? (() => promptInviteCodeFromStdin(runtime)))()).trim();
137
177
  if (!inviteCode) {
138
178
  throw new Error("Login aborted: invite code is required.");
@@ -147,7 +187,9 @@ export async function runOpenclawClawlingLogin(params) {
147
187
  });
148
188
  runtime.log("Verifying invite code …");
149
189
  let result;
150
- const existingUserId = account.userId.trim();
190
+ // A brand-new identity is precisely "do not replay" — the replay is the only
191
+ // thing that would bind this code to the incumbent agent.
192
+ const existingUserId = params.newAccount ? "" : account.userId.trim();
151
193
  try {
152
194
  result = await apiClient.agentsConnect({
153
195
  code: inviteCode,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clawling/clawchat-plugin-openclaw",
3
- "version": "2026.7.26-1",
3
+ "version": "2026.7.28-1",
4
4
  "description": "OpenClaw ClawChat channel plugin",
5
5
  "license": "MIT",
6
6
  "author": "CLAWLING PTE. LTD.",
package/src/commands.ts CHANGED
@@ -3,9 +3,33 @@ import type { OpenClawConfig, PluginCommandContext } from "openclaw/plugin-sdk/c
3
3
  import { CHANNEL_ID, type OutputVisibility } from "./config.ts";
4
4
  import type { OpenclawClawchatMutateConfigFile } from "./login.runtime.ts";
5
5
 
6
+ /** Activation intent flags — the two escape hatches out of the live-activation guard. */
7
+ const ACTIVATION_FLAGS = ["--new-account", "--repair"] as const;
8
+
9
+ function stripActivationFlags(raw: string): string {
10
+ return ACTIVATION_FLAGS.reduce((acc, flag) => acc.split(flag).join(" "), raw);
11
+ }
12
+
6
13
  function extractInviteCode(value: unknown): string {
7
14
  const raw = typeof value === "string" ? value.trim() : "";
8
- return raw.match(/\b[A-Z0-9]{6}\b/u)?.[0] ?? "";
15
+ return stripActivationFlags(raw).match(/\b[A-Z0-9]{6}\b/u)?.[0] ?? "";
16
+ }
17
+
18
+ /**
19
+ * Read the activation intent from the command args.
20
+ *
21
+ * Redeeming a code while this instance holds a live activation is refused by
22
+ * `runOpenclawClawlingLogin` (it would re-bind the code to the incumbent agent
23
+ * rather than add a second one). `/clawchat-activate` is the only activation
24
+ * surface whose arguments this plugin parses, so it carries the two intents:
25
+ * `--new-account` replaces the identity, `--repair` re-pairs the current one.
26
+ */
27
+ function extractActivationIntent(value: unknown): { newAccount: boolean; repair: boolean } {
28
+ const raw = typeof value === "string" ? value : "";
29
+ return {
30
+ newAccount: raw.includes("--new-account"),
31
+ repair: raw.includes("--repair"),
32
+ };
9
33
  }
10
34
 
11
35
  function errorMessage(err: unknown): string {
@@ -98,10 +122,12 @@ export function registerOpenclawClawlingCommands(api: Pick<OpenClawPluginApi, "r
98
122
  acceptsArgs: true,
99
123
  requireAuth: true,
100
124
  async handler(ctx) {
101
- const code = extractInviteCode(ctx.args ?? ctx.commandBody);
125
+ const rawArgs = ctx.args ?? ctx.commandBody;
126
+ const code = extractInviteCode(rawArgs);
102
127
  if (!code) {
103
128
  return { text: "ClawChat invite code is required. Usage: /clawchat-activate A1B2C3" };
104
129
  }
130
+ const intent = extractActivationIntent(rawArgs);
105
131
  try {
106
132
  const { runOpenclawClawlingLogin } = await import("./login.runtime.ts");
107
133
  await runOpenclawClawlingLogin({
@@ -112,6 +138,8 @@ export function registerOpenclawClawlingCommands(api: Pick<OpenClawPluginApi, "r
112
138
  mutateConfigFile: (api.runtime.config as unknown as {
113
139
  mutateConfigFile: OpenclawClawchatMutateConfigFile;
114
140
  }).mutateConfigFile,
141
+ newAccount: intent.newAccount,
142
+ repair: intent.repair,
115
143
  });
116
144
  return { text: "✅ ClawChat activated successfully." };
117
145
  } catch (err) {
@@ -161,7 +161,15 @@ export function createGroupMessageCoalescer<T extends CoalescableGroupTurn>(para
161
161
  return;
162
162
  }
163
163
  const idleTimer = setTimeout(() => flush(chatId), idleMs);
164
- const maxWaitTimer = setTimeout(() => flush(chatId), params.maxWaitMs);
164
+ // maxWait bounds how long a chat that keeps talking can defer its batch;
165
+ // it is NOT a second, shorter deadline competing with the owner's chosen
166
+ // delay. Arming it from params.maxWaitMs alone silently truncated any
167
+ // longer idleMsOverride — a group set to batch_delay_seconds=300 still
168
+ // got a reply at 30s. Take whichever is larger so the ceiling can never
169
+ // undercut the delay, while the default shape (idle 10s / maxWait 30s)
170
+ // keeps its existing 30s cutoff.
171
+ const maxWaitMs = Math.max(params.maxWaitMs, idleMs);
172
+ const maxWaitTimer = setTimeout(() => flush(chatId), maxWaitMs);
165
173
  pending.set(chatId, { turns: [turn], idleTimer, maxWaitTimer });
166
174
  },
167
175
  flushNow(chatId: string): void {
@@ -56,6 +56,49 @@ export interface LoginParams {
56
56
  store?: Pick<ClawChatStore, "upsertActivation">;
57
57
  /** Optional database path resolved by the host runtime. */
58
58
  dbPath?: string;
59
+ /**
60
+ * Replace this instance's ClawChat identity with a brand-new agent: drop the
61
+ * stored `user_id` so the server mints one for this invite code.
62
+ */
63
+ newAccount?: boolean;
64
+ /** Re-pair the agent this instance already holds (keeps its identity). */
65
+ repair?: boolean;
66
+ }
67
+
68
+ /**
69
+ * Thrown when redeeming an invite code would silently replace a live activation.
70
+ *
71
+ * This channel holds exactly one ClawChat identity — `channels.<id>` is a single
72
+ * slot and `listOpenclawClawlingAccountIds()` returns a single account. Because
73
+ * the stored `user_id` is replayed as a re-pair hint, redeeming a *new* invite
74
+ * code here cannot produce a second agent: the server binds the code to the
75
+ * existing agent, consumes it, creates no second agent and no second contact
76
+ * entry, and the local credentials are overwritten in place — losing the first
77
+ * agent with no error and no warning. Refuse instead, and name the real intents.
78
+ */
79
+ export class ExistingActivationError extends Error {
80
+ readonly userId: string;
81
+ readonly agentId: string;
82
+
83
+ constructor(userId: string, agentId = "") {
84
+ const who = agentId ? `agent ${agentId} (shadow user ${userId})` : `agent ${userId}`;
85
+ super(
86
+ `this OpenClaw instance is already paired to ClawChat ${who}. ` +
87
+ "Redeeming another invite code here would re-bind that code to the SAME " +
88
+ "agent — no second agent is created, and this instance's credentials are " +
89
+ "overwritten.\n" +
90
+ " - To run a SECOND agent alongside this one, give it its own OpenClaw " +
91
+ "home:\n" +
92
+ " OPENCLAW_HOME=<path> openclaw channels login --channel clawchat-plugin-openclaw\n" +
93
+ " - To REPLACE this instance's agent with a brand-new identity: " +
94
+ "/clawchat-activate <CODE> --new-account\n" +
95
+ " - To re-pair THIS agent (e.g. after losing its token): " +
96
+ "/clawchat-activate <CODE> --repair",
97
+ );
98
+ this.name = "ExistingActivationError";
99
+ this.userId = userId;
100
+ this.agentId = agentId;
101
+ }
59
102
  }
60
103
 
61
104
  /**
@@ -192,6 +235,15 @@ export async function runOpenclawClawlingLogin(params: LoginParams): Promise<voi
192
235
  // overridden them, so login works without a prior `openclaw channels setup --channel clawchat-plugin-openclaw`.
193
236
  const account = resolveOpenclawClawlingAccount(cfg);
194
237
 
238
+ // Guard before the invite code is even read, so a refused activation never
239
+ // spends the code — the operator can still redeem it the right way. "Live" is
240
+ // decided by whether a usable token remains: auto-logout (§C) blanks the
241
+ // tokens but preserves the identity, so a logged-out instance still re-pairs
242
+ // with no flag at all, which is the flow the replay exists for.
243
+ if (account.userId.trim() && account.token.trim() && !params.newAccount && !params.repair) {
244
+ throw new ExistingActivationError(account.userId.trim(), account.agentId.trim());
245
+ }
246
+
195
247
  const inviteCode = (
196
248
  await (params.readInviteCode ?? (() => promptInviteCodeFromStdin(runtime)))()
197
249
  ).trim();
@@ -210,7 +262,9 @@ export async function runOpenclawClawlingLogin(params: LoginParams): Promise<voi
210
262
 
211
263
  runtime.log("Verifying invite code …");
212
264
  let result;
213
- const existingUserId = account.userId.trim();
265
+ // A brand-new identity is precisely "do not replay" — the replay is the only
266
+ // thing that would bind this code to the incumbent agent.
267
+ const existingUserId = params.newAccount ? "" : account.userId.trim();
214
268
  try {
215
269
  result = await apiClient.agentsConnect({
216
270
  code: inviteCode,