@clawling/clawchat-plugin-openclaw 2026.9.10-1 → 2026.9.13-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.
@@ -11,6 +11,8 @@ import net from "node:net";
11
11
  import path from "node:path";
12
12
  import { DEFAULT_SKILLS_REF, OFFICIAL_SKILLS_BASE } from "./skill-update.js";
13
13
  import { livewareCliEnv } from "./liveware-cli.js";
14
+ import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/setup";
15
+ import { normalizeOpenclawClawlingAccountId } from "./config.js";
14
16
  export const LIVEWARES_TARGET = "openclaw";
15
17
  export const LIVEWARE_SAMPLE_ID = "liveware-sample";
16
18
  export const LIVEWARE_SAMPLE_APP_NAME = "Liveware Sample";
@@ -127,6 +129,15 @@ async function localInstallMatches(appDir, entry) {
127
129
  return false; // missing / unreadable — treat as "not installed"
128
130
  }
129
131
  }
132
+ /**
133
+ * Suffix for the `app.tmp-…` / `app.old-…` staging dirs. The pid keeps two
134
+ * PROCESSES apart; the account id keeps two supervisors inside ONE process
135
+ * apart, which share a pid and could otherwise share a staging tree.
136
+ */
137
+ function stagingSuffix(accountId) {
138
+ const id = normalizeOpenclawClawlingAccountId(accountId);
139
+ return id === DEFAULT_ACCOUNT_ID ? `${process.pid}` : `${process.pid}-${id}`;
140
+ }
130
141
  /**
131
142
  * Download + verify + atomically install the sample under `<sampleRoot>/app`.
132
143
  * Preserves USER_DATA_FILES from an existing install. Throws on any failure;
@@ -155,7 +166,8 @@ export async function downloadLivewareSample(opts) {
155
166
  await fs.promises.rm(path.join(opts.sampleRoot, name), { recursive: true, force: true }).catch(() => { });
156
167
  }
157
168
  }
158
- const tmpDir = path.join(opts.sampleRoot, `app.tmp-${process.pid}`);
169
+ const swapSuffix = stagingSuffix(opts.accountId);
170
+ const tmpDir = path.join(opts.sampleRoot, `app.tmp-${swapSuffix}`);
159
171
  await fs.promises.rm(tmpDir, { recursive: true, force: true });
160
172
  await fs.promises.mkdir(tmpDir, { recursive: true });
161
173
  try {
@@ -182,7 +194,7 @@ export async function downloadLivewareSample(opts) {
182
194
  // handle, and doing it first leaves no app/ at all when it fails. Renaming
183
195
  // aside puts the new tree in place first and makes the delete best-effort:
184
196
  // a leftover app.old-<pid> is swept on the next run, never a broken install.
185
- const oldDir = path.join(opts.sampleRoot, `app.old-${process.pid}`);
197
+ const oldDir = path.join(opts.sampleRoot, `app.old-${swapSuffix}`);
186
198
  await fs.promises.rm(oldDir, { recursive: true, force: true }).catch(() => { });
187
199
  if (fs.existsSync(appDir)) {
188
200
  await withFsSwapRetry(() => fs.promises.rename(appDir, oldDir));
@@ -737,6 +749,7 @@ export class LivewareSampleSupervisor {
737
749
  }
738
750
  const { version, appDir } = await downloadLivewareSample({
739
751
  fetchFn: deps.fetchFn, sampleRoot: deps.sampleRoot, ref: deps.ref,
752
+ accountId: deps.accountId,
740
753
  });
741
754
  if (this.bailIfStopped())
742
755
  return;
@@ -826,6 +839,7 @@ export class LivewareSampleSupervisor {
826
839
  try {
827
840
  const dl = await downloadLivewareSample({
828
841
  fetchFn: deps.fetchFn, sampleRoot: deps.sampleRoot, ref: deps.ref,
842
+ accountId: deps.accountId,
829
843
  });
830
844
  version = dl.version;
831
845
  appDir = dl.appDir;
@@ -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),
@@ -2,7 +2,9 @@ import { createInterface } from "node:readline/promises";
2
2
  import { createOpenclawClawlingApiClient } from "./api-client.js";
3
3
  import { ClawlingApiError } from "./api-types.js";
4
4
  import { resolvePluginVersion } from "./plugin-report.js";
5
- import { CHANNEL_ID, mergeOpenclawClawchatRuntimePluginActivation, mergeOpenclawClawchatToolAllow, resolveOpenclawClawlingAccount, } from "./config.js";
5
+ import { patchOpenclawClawlingAccountSection } from "./account-config-writes.js";
6
+ import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/setup";
7
+ import { CHANNEL_ID, mergeOpenclawClawchatRuntimePluginActivation, mergeOpenclawClawchatToolAllow, normalizeOpenclawClawlingAccountId, readOpenclawClawlingAccountSection, resolveOpenclawClawlingAccount, } from "./config.js";
6
8
  import { getClawChatStore } from "./storage.js";
7
9
  /**
8
10
  * Platform tag sent to `/v1/agents/connect`. Identifies the host of this
@@ -24,8 +26,7 @@ export const AGENT_NOT_FOUND_CODE = 16001;
24
26
  /**
25
27
  * Thrown when redeeming an invite code would silently replace a live activation.
26
28
  *
27
- * This channel holds exactly one ClawChat identity — `channels.<id>` is a single
28
- * slot and `listOpenclawClawlingAccountIds()` returns a single account. Because
29
+ * Each account slot holds exactly one ClawChat identity. Because
29
30
  * the stored `user_id` is replayed as a re-pair hint, redeeming a *new* invite
30
31
  * code here cannot produce a second agent: the server binds the code to the
31
32
  * existing agent, consumes it, creates no second agent and no second contact
@@ -45,9 +46,9 @@ export class ExistingActivationError extends Error {
45
46
  " - RESTORE the identity above (re-pairs that same agent; if it was " +
46
47
  "deleted this brings it back with its history): " +
47
48
  "/clawchat-activate <CODE> --repair\n" +
48
- " - To run a SECOND agent alongside this one instead, give it its own " +
49
- "OpenClaw home:\n" +
50
- " OPENCLAW_HOME=<path> openclaw channels login --channel clawchat-plugin-openclaw");
49
+ " - To run a SECOND agent alongside this one instead, activate it as a " +
50
+ "named account: /clawchat-activate <CODE> --account <name> (or: openclaw " +
51
+ "channels add --channel clawchat-plugin-openclaw --account <name> --token <CODE>)");
51
52
  this.name = "ExistingActivationError";
52
53
  this.userId = userId;
53
54
  this.agentId = agentId;
@@ -113,37 +114,30 @@ async function promptActivationIntentFromStdin(runtime, who) {
113
114
  rl?.close();
114
115
  }
115
116
  }
116
- function buildLoginConfig(cfg, result) {
117
- const channels = (cfg.channels ?? {});
118
- const existing = (channels[CHANNEL_ID] ?? {});
117
+ function buildLoginConfig(cfg, accountId, result) {
118
+ const existing = readOpenclawClawlingAccountSection(cfg, accountId);
119
119
  const groupMode = existing.groupMode === "mention" || existing.groupMode === "all"
120
120
  ? existing.groupMode
121
121
  : "all";
122
122
  const groupCommandMode = existing.groupCommandMode === "all" || existing.groupCommandMode === "off"
123
123
  ? existing.groupCommandMode
124
124
  : "owner";
125
- const nextSection = {
126
- ...existing,
125
+ const patch = {
127
126
  enabled: true,
128
- groupMode,
129
- groupCommandMode,
127
+ // Group policy defaults are root-level shared keys; named accounts inherit
128
+ // them and only carry credentials.
129
+ ...(accountId === DEFAULT_ACCOUNT_ID ? { groupMode, groupCommandMode } : {}),
130
130
  token: result.access_token,
131
+ // `undefined` deletes the key so a login without a refresh token never
132
+ // leaves a stale one behind.
133
+ refreshToken: result.refresh_token ? result.refresh_token : undefined,
131
134
  ...(result.agent.id ? { agentId: result.agent.id } : {}),
132
135
  userId: result.agent.user_id,
133
136
  ownerUserId: result.agent.owner_id,
134
137
  };
135
- if (result.refresh_token) {
136
- nextSection.refreshToken = result.refresh_token;
137
- }
138
- else {
139
- delete nextSection.refreshToken;
140
- }
141
- return mergeOpenclawClawchatRuntimePluginActivation(mergeOpenclawClawchatToolAllow({
142
- ...cfg,
143
- channels: { ...channels, [CHANNEL_ID]: nextSection },
144
- }));
138
+ return mergeOpenclawClawchatRuntimePluginActivation(mergeOpenclawClawchatToolAllow(patchOpenclawClawlingAccountSection(cfg, accountId, patch)));
145
139
  }
146
- async function persistLoginConfig(params, result) {
140
+ async function persistLoginConfig(accountId, params, result) {
147
141
  if (params.mutateConfigFile) {
148
142
  params.runtime.log(`Persisting ClawChat credentials and plugin activation for userId=${result.agent.user_id} ownerUserId=${result.agent.owner_id} with Gateway restart intent.`);
149
143
  await params.mutateConfigFile({
@@ -152,7 +146,7 @@ async function persistLoginConfig(params, result) {
152
146
  reason: "clawchat-plugin-openclaw credentials changed",
153
147
  },
154
148
  mutate(draft) {
155
- Object.assign(draft, buildLoginConfig(draft, result));
149
+ Object.assign(draft, buildLoginConfig(draft, accountId, result));
156
150
  },
157
151
  });
158
152
  params.runtime.log(`ClawChat credentials and plugin activation persisted for userId=${result.agent.user_id} ownerUserId=${result.agent.owner_id}.`);
@@ -160,7 +154,7 @@ async function persistLoginConfig(params, result) {
160
154
  }
161
155
  if (params.persistConfig) {
162
156
  params.runtime.log(`Persisting ClawChat credentials and plugin activation for userId=${result.agent.user_id} ownerUserId=${result.agent.owner_id}.`);
163
- await params.persistConfig(buildLoginConfig(params.cfg, result));
157
+ await params.persistConfig(buildLoginConfig(params.cfg, accountId, result));
164
158
  params.runtime.log(`ClawChat credentials and plugin activation persisted for userId=${result.agent.user_id} ownerUserId=${result.agent.owner_id}.`);
165
159
  return;
166
160
  }
@@ -198,10 +192,11 @@ function readOptionalConnectString(value, fieldName) {
198
192
  */
199
193
  export async function runOpenclawClawlingLogin(params) {
200
194
  const { cfg, runtime } = params;
195
+ const accountId = normalizeOpenclawClawlingAccountId(params.accountId);
201
196
  // `resolveOpenclawClawlingAccount` falls back to the built-in
202
197
  // `DEFAULT_BASE_URL` / `DEFAULT_WEBSOCKET_URL` when the operator has not
203
198
  // overridden them, so login works without a prior `openclaw channels setup --channel clawchat-plugin-openclaw`.
204
- const account = resolveOpenclawClawlingAccount(cfg);
199
+ const account = resolveOpenclawClawlingAccount(cfg, accountId);
205
200
  // Fork before the invite code is even read, so neither branch spends the code
206
201
  // before the outcome is settled. "Live" is decided by whether a usable token
207
202
  // remains: auto-logout (§C) blanks the tokens but preserves the identity, so a
@@ -315,8 +310,11 @@ export async function runOpenclawClawlingLogin(params) {
315
310
  }
316
311
  : {}),
317
312
  };
318
- runtime.log(`Updating config: channels.${CHANNEL_ID}.token=[REDACTED] agentId=${normalizedResult.agent.id || "-"} userId=${normalizedResult.agent.user_id} ownerUserId=${normalizedResult.agent.owner_id}${normalizedResult.refresh_token ? " refreshToken=[REDACTED]" : ""} plugins.entries.${CHANNEL_ID}.enabled=true plugins.allow+=${CHANNEL_ID} …`);
319
- await persistLoginConfig(params, normalizedResult);
313
+ const configPath = accountId === DEFAULT_ACCOUNT_ID
314
+ ? `channels.${CHANNEL_ID}`
315
+ : `channels.${CHANNEL_ID}.accounts.${accountId}`;
316
+ runtime.log(`Updating config: ${configPath}.token=[REDACTED] agentId=${normalizedResult.agent.id || "-"} userId=${normalizedResult.agent.user_id} ownerUserId=${normalizedResult.agent.owner_id}${normalizedResult.refresh_token ? " refreshToken=[REDACTED]" : ""} plugins.entries.${CHANNEL_ID}.enabled=true plugins.allow+=${CHANNEL_ID} …`);
317
+ await persistLoginConfig(accountId, params, normalizedResult);
320
318
  try {
321
319
  const store = params.store ??
322
320
  getClawChatStore({
@@ -673,8 +673,8 @@ export const openclawClawlingOutbound = {
673
673
  textChunkLimit: 4000,
674
674
  ...createAttachedChannelResultAdapter({
675
675
  channel: CHANNEL_ID,
676
- sendText: async ({ cfg, to, text }) => {
677
- const account = resolveOpenclawClawlingAccount(cfg);
676
+ sendText: async ({ cfg, to, text, accountId }) => {
677
+ const account = resolveOpenclawClawlingAccount(cfg, accountId);
678
678
  const client = getOpenclawClawlingClient(account.accountId) ??
679
679
  (await waitForOpenclawClawlingClient(account.accountId));
680
680
  const target = parseOpenclawRecipient(to);
@@ -709,8 +709,8 @@ export const openclawClawlingOutbound = {
709
709
  messageId: result?.messageId ?? messageId,
710
710
  };
711
711
  },
712
- sendMedia: async ({ cfg, to, text, mediaUrl, mediaAccess, mediaLocalRoots, mediaReadFile }) => {
713
- const account = resolveOpenclawClawlingAccount(cfg);
712
+ sendMedia: async ({ cfg, to, text, mediaUrl, mediaAccess, mediaLocalRoots, mediaReadFile, accountId }) => {
713
+ const account = resolveOpenclawClawlingAccount(cfg, accountId);
714
714
  const client = getOpenclawClawlingClient(account.accountId) ??
715
715
  (await waitForOpenclawClawlingClient(account.accountId));
716
716
  if (!mediaUrl?.trim()) {
@@ -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
  }
@@ -6,13 +6,14 @@ import { createOpenclawClawlingClient, resolveOpenclawClawlingDeviceId } from ".
6
6
  import { createOpenclawClawlingApiClient } from "./api-client.js";
7
7
  import { buildActivationBootstrapText } from "./activation-greeting.js";
8
8
  import { reportPluginVersionSafe, resolvePluginVersion } from "./plugin-report.js";
9
- import path from "node:path";
10
- import { ensureLivewareCli, livewareCliHomeDir, resolveLivewarePath } from "./liveware-cli.js";
9
+ import { ensureLivewareCli, livewareCliHomeDir, livewareSampleRootDir, resolveLivewarePath, } from "./liveware-cli.js";
11
10
  import { LivewareSampleSupervisor, } from "./liveware-sample.js";
12
11
  import { ClawlingApiError } from "./api-types.js";
13
12
  import { RefreshManager } from "./refresh-manager.js";
14
13
  import { runOpenclawClawlingLogin, } from "./login.runtime.js";
15
- import { CHANNEL_ID, effectiveOutputVisibility, effectiveGroupCommandMode, effectiveGroupMode, hasOpenclawClawlingConnectCredentials, resolveOpenclawClawlingAccount, } from "./config.js";
14
+ import { CHANNEL_ID, effectiveOutputVisibility, effectiveGroupCommandMode, effectiveGroupMode, hasOpenclawClawlingConnectCredentials, normalizeOpenclawClawlingAccountId, readOpenclawClawlingAccountSection, resolveOpenclawClawlingAccount, CLAWCHAT_REFRESH_TOKEN_ENV, } from "./config.js";
15
+ import { patchOpenclawClawlingAccountSection } from "./account-config-writes.js";
16
+ import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/setup";
16
17
  import { isValidChatId } from "./ws-client.js";
17
18
  import { dispatchOpenclawClawlingInbound } from "./inbound.js";
18
19
  import { PendingConsentStore, cleanupTombstonedManagedSkills, handleOwnerConsentReply, managedSkillExists, readLocalSkillState, resolveBundledSkillsDir, resolveManagedSkillsDir, runSkillUpdateCheck, } from "./skill-update.js";
@@ -130,12 +131,26 @@ export function classifyHelloFailReason(reason) {
130
131
  return "token-rejected";
131
132
  return "generic";
132
133
  }
133
- /** Read `channels.<CHANNEL_ID>.refreshToken` from a live config, or null. */
134
- function readConfigRefreshToken(cfg) {
135
- const channels = cfg.channels;
136
- const channel = isRecord(channels) ? channels[CHANNEL_ID] : undefined;
137
- const refreshToken = isRecord(channel) ? channel.refreshToken : undefined;
138
- return typeof refreshToken === "string" && refreshToken.trim() ? refreshToken.trim() : null;
134
+ /**
135
+ * The refresh token paired with the account's access token. The account
136
+ * section's `refreshToken` wins. `CLAWCHAT_REFRESH_TOKEN` is process-wide, so
137
+ * it is honoured ONLY for the default account and ONLY when that section
138
+ * carries no `token` of its own (i.e. the access token itself came from
139
+ * `CLAWCHAT_TOKEN`) — an env-provisioned pair can rotate, while a login-written
140
+ * token is never paired with a stray refresh token left in the environment.
141
+ */
142
+ function readConfigRefreshToken(cfg, accountId, env = process.env) {
143
+ const section = readOpenclawClawlingAccountSection(cfg, accountId);
144
+ const refreshToken = section.refreshToken;
145
+ if (typeof refreshToken === "string" && refreshToken.trim())
146
+ return refreshToken.trim();
147
+ if (normalizeOpenclawClawlingAccountId(accountId) !== DEFAULT_ACCOUNT_ID)
148
+ return null;
149
+ const configToken = section.token;
150
+ if (typeof configToken === "string" && configToken.trim())
151
+ return null;
152
+ const envRefreshToken = env[CLAWCHAT_REFRESH_TOKEN_ENV];
153
+ return typeof envRefreshToken === "string" && envRefreshToken.trim() ? envRefreshToken.trim() : null;
139
154
  }
140
155
  function withFullVerboseDispatchConfig(cfg, agentId) {
141
156
  const cfgRecord = cfg;
@@ -443,14 +458,25 @@ export async function maybeAutoActivateFromEnv(params) {
443
458
  const code = (params.connectCode ?? "").trim();
444
459
  if (!code)
445
460
  return params.account;
461
+ // The env var is process-wide: it can only ever describe ONE identity, and
462
+ // that is the default account. A named account never redeems it.
463
+ if (normalizeOpenclawClawlingAccountId(params.account.accountId) !== DEFAULT_ACCOUNT_ID) {
464
+ params.log?.info?.(`[${params.account.accountId}] clawchat-plugin-openclaw ${CONNECT_CODE_ENV} present but ignored: env activation is for the default account only`);
465
+ return params.account;
466
+ }
446
467
  if (accountHasConnectCredentials(params.account))
447
468
  return params.account;
469
+ const accountId = params.account.accountId;
470
+ const stored = params.store?.getActivationCredentials?.({ platform: "openclaw", accountId });
471
+ if (stored) {
472
+ params.log?.info?.(`[${accountId}] clawchat-plugin-openclaw ${CONNECT_CODE_ENV} present but a sqlite activation row already holds credentials; not redeeming`);
473
+ return params.account;
474
+ }
448
475
  if (consumedAutoActivationCodes.has(code))
449
476
  return params.account;
450
477
  consumedAutoActivationCodes.add(code);
451
478
  const runLogin = params.runLogin ?? runOpenclawClawlingLogin;
452
479
  const resolveAccount = params.resolveAccount ?? resolveOpenclawClawlingAccount;
453
- const accountId = params.account.accountId;
454
480
  params.log?.info?.(`[${accountId}] clawchat-plugin-openclaw ${CONNECT_CODE_ENV} present; attempting non-interactive activation`);
455
481
  try {
456
482
  await runLogin({
@@ -470,7 +496,7 @@ export async function maybeAutoActivateFromEnv(params) {
470
496
  params.log?.error?.(`[${accountId}] clawchat-plugin-openclaw ${CONNECT_CODE_ENV} activation failed: ${err instanceof Error ? err.message : String(err)}`);
471
497
  return params.account;
472
498
  }
473
- const next = resolveAccount(params.cfg);
499
+ const next = resolveAccount(params.cfg, accountId);
474
500
  params.log?.info?.(`[${accountId}] clawchat-plugin-openclaw ${CONNECT_CODE_ENV} activation succeeded hasToken=${Boolean(next.token)}`);
475
501
  return next;
476
502
  }
@@ -722,6 +748,7 @@ export async function startOpenclawClawlingGateway(params) {
722
748
  account,
723
749
  accountId,
724
750
  connectCode: envConnectCode,
751
+ store,
725
752
  log,
726
753
  });
727
754
  }
@@ -749,7 +776,7 @@ export async function startOpenclawClawlingGateway(params) {
749
776
  userId: account.userId,
750
777
  ownerUserId: account.ownerUserId,
751
778
  accessToken: account.token,
752
- refreshToken: readConfigRefreshToken(cfg),
779
+ refreshToken: readConfigRefreshToken(cfg, accountId),
753
780
  loginMethod: "config-seed",
754
781
  deviceId: CHANNEL_ID,
755
782
  });
@@ -831,13 +858,16 @@ export async function startOpenclawClawlingGateway(params) {
831
858
  // `RuntimeConnectionStore` only `Pick`s a subset of its methods, so widen
832
859
  // through the structural `LivewareSampleStoreLike` contract (Task 8/4).
833
860
  store: store,
834
- sampleRoot: path.join(stateDirForSample ?? "", "clawchat", "liveware-sample"),
861
+ // Per account: two supervisors in one process otherwise install, swap and
862
+ // delete inside the SAME `app/` tree. `default` keeps the legacy path.
863
+ sampleRoot: livewareSampleRootDir(stateDirForSample ?? "", accountId),
835
864
  resolveToken: () => account.token ?? "",
836
865
  resolveAgentUserId: () => account.userId ?? null,
837
866
  resolveLivewarePath: () => resolveLivewarePath(stateDirForSample),
838
- // Per-instance HOME for the liveware CLI: its credentials otherwise live
839
- // in the host-global `$HOME/.clawling`, which a second profile clobbers.
840
- cliHome: livewareCliHomeDir(stateDirForSample),
867
+ // Per-instance AND per-account HOME for the liveware CLI: its credentials
868
+ // otherwise live in the host-global `$HOME/.clawling`, which a second
869
+ // profile — or a second account in this very process — clobbers.
870
+ cliHome: livewareCliHomeDir(stateDirForSample, accountId),
841
871
  cliReady: livewareCliReady,
842
872
  listApps: async () => {
843
873
  const client = getConversationApiClient();
@@ -886,7 +916,7 @@ export async function startOpenclawClawlingGateway(params) {
886
916
  ? runtimeConfig.mutateConfigFile
887
917
  : undefined;
888
918
  };
889
- // §0/§C.1 — write the channel-config `token`/`refreshToken` keys. `tokens=null`
919
+ // §0/§C.1 — write the account slot's `token`/`refreshToken` keys. `tokens=null`
890
920
  // blanks them (auto-logout); otherwise persists the rotated pair.
891
921
  const persistConfigTokens = async (tokens) => {
892
922
  const mutateConfigFile = resolveMutateConfigFile();
@@ -897,17 +927,10 @@ export async function startOpenclawClawlingGateway(params) {
897
927
  await mutateConfigFile({
898
928
  afterWrite: { mode: "none", reason: "clawchat-plugin-openclaw token refresh" },
899
929
  mutate(draft) {
900
- const channels = (draft.channels ?? {});
901
- const existing = (channels[CHANNEL_ID] ?? {});
902
- const nextSection = {
903
- ...existing,
930
+ Object.assign(draft, patchOpenclawClawlingAccountSection(draft, accountId, {
904
931
  token: tokens ? tokens.accessToken : "",
905
932
  refreshToken: tokens ? tokens.refreshToken : "",
906
- };
907
- Object.assign(draft, {
908
- ...draft,
909
- channels: { ...channels, [CHANNEL_ID]: nextSection },
910
- });
933
+ }));
911
934
  },
912
935
  });
913
936
  };
@@ -996,7 +1019,7 @@ export async function startOpenclawClawlingGateway(params) {
996
1019
  (activationAccount.source === "sqlite" && store?.getActivationCredentials
997
1020
  ? store.getActivationCredentials({ platform: "openclaw", accountId })?.refreshToken
998
1021
  : null) ??
999
- readConfigRefreshToken(cfg);
1022
+ readConfigRefreshToken(cfg, accountId);
1000
1023
  const refreshManager = new RefreshManager({
1001
1024
  baseUrl: account.baseUrl,
1002
1025
  deviceId: refreshDeviceId,
@@ -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
  }