@clawling/clawchat-plugin-openclaw 2026.9.10-1 → 2026.9.11-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,5 +1,6 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
+ import { resolveStateDirFromEnv } from "./state-paths.js";
3
4
  function envEnabled(value) {
4
5
  return ["1", "true", "yes", "on"].includes(String(value ?? "").trim().toLowerCase());
5
6
  }
@@ -10,7 +11,12 @@ function safeId(value) {
10
11
  export function createOpenclawLlmContextDebug(params = {}) {
11
12
  const env = params.env ?? process.env;
12
13
  const enabled = envEnabled(env.CLAWCHAT_LLM_CONTEXT_DEBUG);
13
- const snapshotRoot = path.resolve(env.CLAWCHAT_LLM_CONTEXT_SNAPSHOT_DIR || params.defaultSnapshotRoot || ".clawchat-llm-context-debug");
14
+ // Default under the per-instance OpenClaw state dir, never the process cwd:
15
+ // two profiles launched from one directory must not interleave runs and
16
+ // overwrite each other's `latest.json`.
17
+ const snapshotRoot = path.resolve(env.CLAWCHAT_LLM_CONTEXT_SNAPSHOT_DIR ||
18
+ params.defaultSnapshotRoot ||
19
+ path.join(resolveStateDirFromEnv(env), "clawchat", "llm-context-debug"));
14
20
  return {
15
21
  enabled,
16
22
  captureFullInput: envEnabled(env.CLAWCHAT_LLM_CONTEXT_CAPTURE_FULL_INPUT),
@@ -20,16 +20,42 @@
20
20
  * The discriminator is `payload.metadata.kind === "permission_result"`.
21
21
  */
22
22
  import { EVENT } from "./protocol-types.js";
23
- // Module-level dedup set — `request_id` values are stable backend identifiers.
24
- // A duplicate receipt (e.g. from WS replay on reconnect) must not re-trigger
25
- // an agent turn.
26
- const _seenRequestIds = new Set();
27
23
  /**
28
- * @internal Test helper — reset the dedup set between isolated test runs.
24
+ * Upper bound on retained `request_id`s per account (FIFO eviction). Mirrors
25
+ * `createNotifySignalObserver` in `src/ws-alignment.ts`: permission receipts
26
+ * are rare (one per owner decision), so 512 covers any realistic replay window
27
+ * while keeping a long-running gateway's memory flat.
28
+ */
29
+ export const PERMISSION_RESULT_DEDUP_MAX_SEEN = 512;
30
+ // Dedup state partitioned by accountId — `request_id` values are stable backend
31
+ // identifiers scoped to the agent identity that raised the request, so one
32
+ // account's history must never suppress another account's receipt with the
33
+ // same id. A duplicate receipt within one account (e.g. from WS replay on
34
+ // reconnect) must not re-trigger an agent turn.
35
+ const _seenRequestIdsByAccount = new Map();
36
+ function seenRequestIdsFor(accountId) {
37
+ let entry = _seenRequestIdsByAccount.get(accountId);
38
+ if (!entry) {
39
+ entry = { seen: new Set(), order: [] };
40
+ _seenRequestIdsByAccount.set(accountId, entry);
41
+ }
42
+ return entry;
43
+ }
44
+ function markRequestIdSeen(entry, requestId) {
45
+ entry.seen.add(requestId);
46
+ entry.order.push(requestId);
47
+ while (entry.order.length > PERMISSION_RESULT_DEDUP_MAX_SEEN) {
48
+ const evicted = entry.order.shift();
49
+ if (evicted !== undefined)
50
+ entry.seen.delete(evicted);
51
+ }
52
+ }
53
+ /**
54
+ * @internal Test helper — reset the dedup state between isolated test runs.
29
55
  * Never call this in production code.
30
56
  */
31
57
  export function _resetPermissionResultDedup() {
32
- _seenRequestIds.clear();
58
+ _seenRequestIdsByAccount.clear();
33
59
  }
34
60
  function buildOutcomeNote(metadata) {
35
61
  const { operation, outcome, reason } = metadata;
@@ -107,8 +133,10 @@ export function buildPermissionResultEnvelope(params) {
107
133
  };
108
134
  }
109
135
  /**
110
- * Handles a `permission_result` receipt. Deduplicates by `request_id`
111
- * (idempotent — WS replay on reconnect may deliver the same receipt again).
136
+ * Handles a `permission_result` receipt. Deduplicates by `request_id` within
137
+ * the receiving account (idempotent — WS replay on reconnect may deliver the
138
+ * same receipt again), retaining at most {@link PERMISSION_RESULT_DEDUP_MAX_SEEN}
139
+ * ids per account (oldest evicted first).
112
140
  *
113
141
  * An empty/falsy `request_id` is never added to the dedup set — two receipts
114
142
  * can legitimately share an empty id, and both must still be rendered — but
@@ -127,9 +155,11 @@ export function buildPermissionResultEnvelope(params) {
127
155
  export function handlePermissionResult(metadata, account, ownerConversationId) {
128
156
  if (!ownerConversationId)
129
157
  return null;
130
- if (metadata.request_id && _seenRequestIds.has(metadata.request_id))
131
- return null;
132
- if (metadata.request_id)
133
- _seenRequestIds.add(metadata.request_id);
158
+ if (metadata.request_id) {
159
+ const entry = seenRequestIdsFor(account.accountId);
160
+ if (entry.seen.has(metadata.request_id))
161
+ return null;
162
+ markRequestIdSeen(entry, metadata.request_id);
163
+ }
134
164
  return buildPermissionResultEnvelope({ account, metadata, ownerConversationId });
135
165
  }
@@ -12,7 +12,7 @@ import { LivewareSampleSupervisor, } from "./liveware-sample.js";
12
12
  import { ClawlingApiError } from "./api-types.js";
13
13
  import { RefreshManager } from "./refresh-manager.js";
14
14
  import { runOpenclawClawlingLogin, } from "./login.runtime.js";
15
- import { CHANNEL_ID, effectiveOutputVisibility, effectiveGroupCommandMode, effectiveGroupMode, hasOpenclawClawlingConnectCredentials, resolveOpenclawClawlingAccount, } from "./config.js";
15
+ import { CHANNEL_ID, effectiveOutputVisibility, effectiveGroupCommandMode, effectiveGroupMode, hasOpenclawClawlingConnectCredentials, resolveOpenclawClawlingAccount, CLAWCHAT_REFRESH_TOKEN_ENV, } from "./config.js";
16
16
  import { isValidChatId } from "./ws-client.js";
17
17
  import { dispatchOpenclawClawlingInbound } from "./inbound.js";
18
18
  import { PendingConsentStore, cleanupTombstonedManagedSkills, handleOwnerConsentReply, managedSkillExists, readLocalSkillState, resolveBundledSkillsDir, resolveManagedSkillsDir, runSkillUpdateCheck, } from "./skill-update.js";
@@ -131,11 +131,25 @@ export function classifyHelloFailReason(reason) {
131
131
  return "generic";
132
132
  }
133
133
  /** Read `channels.<CHANNEL_ID>.refreshToken` from a live config, or null. */
134
- function readConfigRefreshToken(cfg) {
134
+ /**
135
+ * The refresh token paired with the configured access token. Channel config
136
+ * `refreshToken` wins. `CLAWCHAT_REFRESH_TOKEN` (declared in the manifest's
137
+ * `channelEnvVars`) is honoured ONLY when the channel config carries no `token`
138
+ * of its own — i.e. when the access token itself came from `CLAWCHAT_TOKEN` —
139
+ * so an env-provisioned pair can rotate, while a login-written config token is
140
+ * never paired with a stray refresh token left in the environment.
141
+ */
142
+ function readConfigRefreshToken(cfg, env = process.env) {
135
143
  const channels = cfg.channels;
136
144
  const channel = isRecord(channels) ? channels[CHANNEL_ID] : undefined;
137
145
  const refreshToken = isRecord(channel) ? channel.refreshToken : undefined;
138
- return typeof refreshToken === "string" && refreshToken.trim() ? refreshToken.trim() : null;
146
+ if (typeof refreshToken === "string" && refreshToken.trim())
147
+ return refreshToken.trim();
148
+ const configToken = isRecord(channel) ? channel.token : undefined;
149
+ if (typeof configToken === "string" && configToken.trim())
150
+ return null;
151
+ const envRefreshToken = env[CLAWCHAT_REFRESH_TOKEN_ENV];
152
+ return typeof envRefreshToken === "string" && envRefreshToken.trim() ? envRefreshToken.trim() : null;
139
153
  }
140
154
  function withFullVerboseDispatchConfig(cfg, agentId) {
141
155
  const cfgRecord = cfg;
@@ -445,12 +459,17 @@ export async function maybeAutoActivateFromEnv(params) {
445
459
  return params.account;
446
460
  if (accountHasConnectCredentials(params.account))
447
461
  return params.account;
462
+ const accountId = params.account.accountId;
463
+ const stored = params.store?.getActivationCredentials?.({ platform: "openclaw", accountId });
464
+ if (stored) {
465
+ params.log?.info?.(`[${accountId}] clawchat-plugin-openclaw ${CONNECT_CODE_ENV} present but a sqlite activation row already holds credentials; not redeeming`);
466
+ return params.account;
467
+ }
448
468
  if (consumedAutoActivationCodes.has(code))
449
469
  return params.account;
450
470
  consumedAutoActivationCodes.add(code);
451
471
  const runLogin = params.runLogin ?? runOpenclawClawlingLogin;
452
472
  const resolveAccount = params.resolveAccount ?? resolveOpenclawClawlingAccount;
453
- const accountId = params.account.accountId;
454
473
  params.log?.info?.(`[${accountId}] clawchat-plugin-openclaw ${CONNECT_CODE_ENV} present; attempting non-interactive activation`);
455
474
  try {
456
475
  await runLogin({
@@ -722,6 +741,7 @@ export async function startOpenclawClawlingGateway(params) {
722
741
  account,
723
742
  accountId,
724
743
  connectCode: envConnectCode,
744
+ store,
725
745
  log,
726
746
  });
727
747
  }
@@ -8,13 +8,28 @@ function key(accountId, chatId, scopeId) {
8
8
  export function runWithTerminalClawChatSendScope(scopeId, fn) {
9
9
  return terminalSendScope.run(scopeId, fn);
10
10
  }
11
+ /**
12
+ * Drop every marker whose TTL has lapsed. A marker is only otherwise deleted
13
+ * when the same dispatch scope consumes it, and a scope that never produces a
14
+ * follow-up reply for that chat (mention sent to another chat, `<clawchat:no-reply/>`,
15
+ * dispatch failure) leaves it behind; without this sweep the map would grow by
16
+ * one entry per such turn for the lifetime of the gateway.
17
+ */
18
+ function sweepExpiredTerminalSends(nowMs) {
19
+ for (const [recordKey, record] of terminalSends) {
20
+ if (record.expiresAt <= nowMs)
21
+ terminalSends.delete(recordKey);
22
+ }
23
+ }
11
24
  export function markTerminalClawChatSend(params) {
12
25
  const scopeId = params.scopeId ?? terminalSendScope.getStore();
13
26
  if (!scopeId)
14
27
  return;
28
+ const nowMs = params.nowMs ?? Date.now();
29
+ sweepExpiredTerminalSends(nowMs);
15
30
  terminalSends.set(key(params.accountId, params.chatId, scopeId), {
16
31
  messageId: params.messageId,
17
- expiresAt: (params.nowMs ?? Date.now()) + (params.ttlMs ?? DEFAULT_TTL_MS),
32
+ expiresAt: nowMs + (params.ttlMs ?? DEFAULT_TTL_MS),
18
33
  scopeId,
19
34
  });
20
35
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clawling/clawchat-plugin-openclaw",
3
- "version": "2026.9.10-1",
3
+ "version": "2026.9.11-1",
4
4
  "description": "OpenClaw ClawChat channel plugin",
5
5
  "license": "MIT",
6
6
  "author": "CLAWLING PTE. LTD.",
@@ -1,5 +1,6 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
+ import { resolveStateDirFromEnv } from "./state-paths.ts";
3
4
 
4
5
  export type OpenclawLlmContextVisibility =
5
6
  | "full_llm_input"
@@ -60,8 +61,13 @@ export function createOpenclawLlmContextDebug(params: {
60
61
  } = {}): OpenclawLlmContextDebug {
61
62
  const env = params.env ?? process.env;
62
63
  const enabled = envEnabled(env.CLAWCHAT_LLM_CONTEXT_DEBUG);
64
+ // Default under the per-instance OpenClaw state dir, never the process cwd:
65
+ // two profiles launched from one directory must not interleave runs and
66
+ // overwrite each other's `latest.json`.
63
67
  const snapshotRoot = path.resolve(
64
- env.CLAWCHAT_LLM_CONTEXT_SNAPSHOT_DIR || params.defaultSnapshotRoot || ".clawchat-llm-context-debug",
68
+ env.CLAWCHAT_LLM_CONTEXT_SNAPSHOT_DIR ||
69
+ params.defaultSnapshotRoot ||
70
+ path.join(resolveStateDirFromEnv(env as NodeJS.ProcessEnv), "clawchat", "llm-context-debug"),
65
71
  );
66
72
 
67
73
  return {
@@ -30,17 +30,47 @@ export interface PermissionResultMetadata {
30
30
  request_id: string;
31
31
  }
32
32
 
33
- // Module-level dedup set — `request_id` values are stable backend identifiers.
34
- // A duplicate receipt (e.g. from WS replay on reconnect) must not re-trigger
35
- // an agent turn.
36
- const _seenRequestIds = new Set<string>();
33
+ /**
34
+ * Upper bound on retained `request_id`s per account (FIFO eviction). Mirrors
35
+ * `createNotifySignalObserver` in `src/ws-alignment.ts`: permission receipts
36
+ * are rare (one per owner decision), so 512 covers any realistic replay window
37
+ * while keeping a long-running gateway's memory flat.
38
+ */
39
+ export const PERMISSION_RESULT_DEDUP_MAX_SEEN = 512;
40
+
41
+ type SeenRequestIds = { seen: Set<string>; order: string[] };
42
+
43
+ // Dedup state partitioned by accountId — `request_id` values are stable backend
44
+ // identifiers scoped to the agent identity that raised the request, so one
45
+ // account's history must never suppress another account's receipt with the
46
+ // same id. A duplicate receipt within one account (e.g. from WS replay on
47
+ // reconnect) must not re-trigger an agent turn.
48
+ const _seenRequestIdsByAccount = new Map<string, SeenRequestIds>();
49
+
50
+ function seenRequestIdsFor(accountId: string): SeenRequestIds {
51
+ let entry = _seenRequestIdsByAccount.get(accountId);
52
+ if (!entry) {
53
+ entry = { seen: new Set<string>(), order: [] };
54
+ _seenRequestIdsByAccount.set(accountId, entry);
55
+ }
56
+ return entry;
57
+ }
58
+
59
+ function markRequestIdSeen(entry: SeenRequestIds, requestId: string): void {
60
+ entry.seen.add(requestId);
61
+ entry.order.push(requestId);
62
+ while (entry.order.length > PERMISSION_RESULT_DEDUP_MAX_SEEN) {
63
+ const evicted = entry.order.shift();
64
+ if (evicted !== undefined) entry.seen.delete(evicted);
65
+ }
66
+ }
37
67
 
38
68
  /**
39
- * @internal Test helper — reset the dedup set between isolated test runs.
69
+ * @internal Test helper — reset the dedup state between isolated test runs.
40
70
  * Never call this in production code.
41
71
  */
42
72
  export function _resetPermissionResultDedup(): void {
43
- _seenRequestIds.clear();
73
+ _seenRequestIdsByAccount.clear();
44
74
  }
45
75
 
46
76
  function buildOutcomeNote(metadata: PermissionResultMetadata): string {
@@ -127,8 +157,10 @@ export function buildPermissionResultEnvelope(params: {
127
157
  }
128
158
 
129
159
  /**
130
- * Handles a `permission_result` receipt. Deduplicates by `request_id`
131
- * (idempotent — WS replay on reconnect may deliver the same receipt again).
160
+ * Handles a `permission_result` receipt. Deduplicates by `request_id` within
161
+ * the receiving account (idempotent — WS replay on reconnect may deliver the
162
+ * same receipt again), retaining at most {@link PERMISSION_RESULT_DEDUP_MAX_SEEN}
163
+ * ids per account (oldest evicted first).
132
164
  *
133
165
  * An empty/falsy `request_id` is never added to the dedup set — two receipts
134
166
  * can legitimately share an empty id, and both must still be rendered — but
@@ -146,11 +178,14 @@ export function buildPermissionResultEnvelope(params: {
146
178
  */
147
179
  export function handlePermissionResult(
148
180
  metadata: PermissionResultMetadata,
149
- account: Pick<ResolvedOpenclawClawlingAccount, "userId" | "ownerUserId">,
181
+ account: Pick<ResolvedOpenclawClawlingAccount, "accountId" | "userId" | "ownerUserId">,
150
182
  ownerConversationId: string | null,
151
183
  ): Envelope | null {
152
184
  if (!ownerConversationId) return null;
153
- if (metadata.request_id && _seenRequestIds.has(metadata.request_id)) return null;
154
- if (metadata.request_id) _seenRequestIds.add(metadata.request_id);
185
+ if (metadata.request_id) {
186
+ const entry = seenRequestIdsFor(account.accountId);
187
+ if (entry.seen.has(metadata.request_id)) return null;
188
+ markRequestIdSeen(entry, metadata.request_id);
189
+ }
155
190
  return buildPermissionResultEnvelope({ account, metadata, ownerConversationId });
156
191
  }
package/src/runtime.ts CHANGED
@@ -40,6 +40,7 @@ import {
40
40
  hasOpenclawClawlingConnectCredentials,
41
41
  resolveOpenclawClawlingAccount,
42
42
  type ResolvedOpenclawClawlingAccount,
43
+ CLAWCHAT_REFRESH_TOKEN_ENV,
43
44
  } from "./config.ts";
44
45
  import { isValidChatId, type ClawlingChatClient } from "./ws-client.ts";
45
46
  import { dispatchOpenclawClawlingInbound, type IngestTurnParams } from "./inbound.ts";
@@ -234,11 +235,23 @@ export function classifyHelloFailReason(
234
235
  }
235
236
 
236
237
  /** Read `channels.<CHANNEL_ID>.refreshToken` from a live config, or null. */
237
- function readConfigRefreshToken(cfg: OpenClawConfig): string | null {
238
+ /**
239
+ * The refresh token paired with the configured access token. Channel config
240
+ * `refreshToken` wins. `CLAWCHAT_REFRESH_TOKEN` (declared in the manifest's
241
+ * `channelEnvVars`) is honoured ONLY when the channel config carries no `token`
242
+ * of its own — i.e. when the access token itself came from `CLAWCHAT_TOKEN` —
243
+ * so an env-provisioned pair can rotate, while a login-written config token is
244
+ * never paired with a stray refresh token left in the environment.
245
+ */
246
+ function readConfigRefreshToken(cfg: OpenClawConfig, env: NodeJS.ProcessEnv = process.env): string | null {
238
247
  const channels = (cfg as { channels?: Record<string, unknown> }).channels;
239
248
  const channel = isRecord(channels) ? channels[CHANNEL_ID] : undefined;
240
249
  const refreshToken = isRecord(channel) ? channel.refreshToken : undefined;
241
- return typeof refreshToken === "string" && refreshToken.trim() ? refreshToken.trim() : null;
250
+ if (typeof refreshToken === "string" && refreshToken.trim()) return refreshToken.trim();
251
+ const configToken = isRecord(channel) ? channel.token : undefined;
252
+ if (typeof configToken === "string" && configToken.trim()) return null;
253
+ const envRefreshToken = env[CLAWCHAT_REFRESH_TOKEN_ENV];
254
+ return typeof envRefreshToken === "string" && envRefreshToken.trim() ? envRefreshToken.trim() : null;
242
255
  }
243
256
 
244
257
  function withFullVerboseDispatchConfig(cfg: OpenClawConfig, agentId: string): OpenClawConfig {
@@ -673,6 +686,13 @@ export interface AutoActivateFromEnvParams {
673
686
  accountId: string | null;
674
687
  /** The connect code (production: `process.env[CONNECT_CODE_ENV]`). */
675
688
  connectCode: string | undefined;
689
+ /**
690
+ * Plugin SQLite store. A complete activation row is the durable credential
691
+ * source of truth (`waitForActivationCredentials` resolves SQLite-first), so
692
+ * when one exists the env code is skipped — NOT consumed — rather than
693
+ * re-redeemed on every restart of a container that still carries the code.
694
+ */
695
+ store?: Pick<RuntimeConnectionStore, "getActivationCredentials"> | null;
676
696
  log?: Log;
677
697
  /** Test seam — login implementation. */
678
698
  runLogin?: (params: LoginParams) => Promise<void>;
@@ -695,12 +715,19 @@ export async function maybeAutoActivateFromEnv(
695
715
  const code = (params.connectCode ?? "").trim();
696
716
  if (!code) return params.account;
697
717
  if (accountHasConnectCredentials(params.account)) return params.account;
718
+ const accountId = params.account.accountId;
719
+ const stored = params.store?.getActivationCredentials?.({ platform: "openclaw", accountId });
720
+ if (stored) {
721
+ params.log?.info?.(
722
+ `[${accountId}] clawchat-plugin-openclaw ${CONNECT_CODE_ENV} present but a sqlite activation row already holds credentials; not redeeming`,
723
+ );
724
+ return params.account;
725
+ }
698
726
  if (consumedAutoActivationCodes.has(code)) return params.account;
699
727
  consumedAutoActivationCodes.add(code);
700
728
 
701
729
  const runLogin = params.runLogin ?? runOpenclawClawlingLogin;
702
730
  const resolveAccount = params.resolveAccount ?? resolveOpenclawClawlingAccount;
703
- const accountId = params.account.accountId;
704
731
  params.log?.info?.(
705
732
  `[${accountId}] clawchat-plugin-openclaw ${CONNECT_CODE_ENV} present; attempting non-interactive activation`,
706
733
  );
@@ -1044,6 +1071,7 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
1044
1071
  account,
1045
1072
  accountId,
1046
1073
  connectCode: envConnectCode,
1074
+ store,
1047
1075
  log,
1048
1076
  });
1049
1077
  }
@@ -18,6 +18,19 @@ export function runWithTerminalClawChatSendScope<T>(scopeId: string, fn: () => T
18
18
  return terminalSendScope.run(scopeId, fn);
19
19
  }
20
20
 
21
+ /**
22
+ * Drop every marker whose TTL has lapsed. A marker is only otherwise deleted
23
+ * when the same dispatch scope consumes it, and a scope that never produces a
24
+ * follow-up reply for that chat (mention sent to another chat, `<clawchat:no-reply/>`,
25
+ * dispatch failure) leaves it behind; without this sweep the map would grow by
26
+ * one entry per such turn for the lifetime of the gateway.
27
+ */
28
+ function sweepExpiredTerminalSends(nowMs: number): void {
29
+ for (const [recordKey, record] of terminalSends) {
30
+ if (record.expiresAt <= nowMs) terminalSends.delete(recordKey);
31
+ }
32
+ }
33
+
21
34
  export function markTerminalClawChatSend(params: {
22
35
  accountId: string;
23
36
  chatId: string;
@@ -28,9 +41,11 @@ export function markTerminalClawChatSend(params: {
28
41
  }): void {
29
42
  const scopeId = params.scopeId ?? terminalSendScope.getStore();
30
43
  if (!scopeId) return;
44
+ const nowMs = params.nowMs ?? Date.now();
45
+ sweepExpiredTerminalSends(nowMs);
31
46
  terminalSends.set(key(params.accountId, params.chatId, scopeId), {
32
47
  messageId: params.messageId,
33
- expiresAt: (params.nowMs ?? Date.now()) + (params.ttlMs ?? DEFAULT_TTL_MS),
48
+ expiresAt: nowMs + (params.ttlMs ?? DEFAULT_TTL_MS),
34
49
  scopeId,
35
50
  });
36
51
  }