@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.
@@ -0,0 +1,66 @@
1
+ import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/setup";
2
+ import { CHANNEL_ID, normalizeOpenclawClawlingAccountId } from "./config.js";
3
+ function readRecord(value) {
4
+ return value && typeof value === "object" && !Array.isArray(value)
5
+ ? value
6
+ : {};
7
+ }
8
+ function findAccountKey(accounts, accountId) {
9
+ return Object.keys(accounts).find((key) => normalizeOpenclawClawlingAccountId(key) === accountId);
10
+ }
11
+ function applyPatch(target, patch) {
12
+ const next = { ...target };
13
+ for (const [key, value] of Object.entries(patch)) {
14
+ if (value === undefined)
15
+ delete next[key];
16
+ else
17
+ next[key] = value;
18
+ }
19
+ return next;
20
+ }
21
+ /**
22
+ * The RAW object a patch for `accountId` lands in (not the merged view — use
23
+ * `readOpenclawClawlingAccountSection` for that). Default: the `accounts.default`
24
+ * entry when the host's single-account promotion created one, else the root.
25
+ * Named: the matching `accounts.<key>` entry (case-insensitive) or `{}`.
26
+ */
27
+ export function readOpenclawClawlingAccountEntry(cfg, accountId) {
28
+ const id = normalizeOpenclawClawlingAccountId(accountId);
29
+ const section = readRecord(readRecord(cfg.channels)[CHANNEL_ID]);
30
+ const accounts = readRecord(section.accounts);
31
+ const key = findAccountKey(accounts, id);
32
+ if (key !== undefined)
33
+ return readRecord(accounts[key]);
34
+ return id === DEFAULT_ACCOUNT_ID ? section : {};
35
+ }
36
+ /**
37
+ * The single config write point. Pure. Default → root section (or
38
+ * `accounts.default` when that entry exists, so a host-promoted token is never
39
+ * shadowed by a stale copy); named → `accounts.<id>` keeping the existing key
40
+ * spelling. A patch value of `undefined` deletes the key. `enabled` is only
41
+ * touched when the patch carries it.
42
+ */
43
+ export function patchOpenclawClawlingAccountSection(cfg, accountId, patch) {
44
+ const id = normalizeOpenclawClawlingAccountId(accountId);
45
+ const channels = readRecord(cfg.channels);
46
+ const section = readRecord(channels[CHANNEL_ID]);
47
+ const accounts = readRecord(section.accounts);
48
+ const existingKey = findAccountKey(accounts, id);
49
+ if (id === DEFAULT_ACCOUNT_ID && existingKey === undefined) {
50
+ return {
51
+ ...cfg,
52
+ channels: { ...channels, [CHANNEL_ID]: applyPatch(section, patch) },
53
+ };
54
+ }
55
+ const key = existingKey ?? id;
56
+ return {
57
+ ...cfg,
58
+ channels: {
59
+ ...channels,
60
+ [CHANNEL_ID]: {
61
+ ...section,
62
+ accounts: { ...accounts, [key]: applyPatch(readRecord(accounts[key]), patch) },
63
+ },
64
+ },
65
+ };
66
+ }
@@ -0,0 +1,21 @@
1
+ import { normalizeOpenclawClawlingAccountId } from "./config.js";
2
+ /**
3
+ * `openclaw channels remove --account <id> [--delete]` — the host has already
4
+ * rewritten the config (hybrid adapter) and stopped the account. Blank that
5
+ * account's SQLite credentials so a later start of the same id cannot resume
6
+ * the old identity from the row. Identity columns are kept (re-pair mode).
7
+ */
8
+ export function clearOpenclawClawlingAccountOnRemoval(params) {
9
+ const accountId = normalizeOpenclawClawlingAccountId(params.accountId);
10
+ if (!params.store) {
11
+ params.log?.(`[${accountId}] clawchat-plugin-openclaw account removed; sqlite unavailable, nothing to clear`);
12
+ return;
13
+ }
14
+ try {
15
+ params.store.clearActivationCredentials({ platform: "openclaw", accountId });
16
+ params.log?.(`[${accountId}] clawchat-plugin-openclaw account removed; sqlite credentials cleared`);
17
+ }
18
+ catch (err) {
19
+ params.log?.(`[${accountId}] clawchat-plugin-openclaw account removed; sqlite clear failed: ${err instanceof Error ? err.message : String(err)}`);
20
+ }
21
+ }
@@ -1,8 +1,10 @@
1
1
  import { createChatChannelPlugin } from "openclaw/plugin-sdk/core";
2
2
  import { createEmptyChannelDirectoryAdapter } from "openclaw/plugin-sdk/directory-runtime";
3
+ import { clearOpenclawClawlingAccountOnRemoval } from "./account-removal.js";
3
4
  import { CHANNEL_ID, canStartOpenclawClawlingAccount, resolveOpenclawClawlingAccount, } from "./config.js";
4
5
  import { openclawClawlingOutbound } from "./outbound.js";
5
6
  import { getOpenclawClawlingRuntime, startOpenclawClawlingGateway } from "./runtime.js";
7
+ import { clawChatDbPathForStateDir, getClawChatStore } from "./storage.js";
6
8
  import { openclawClawlingSetupPlugin } from "./channel.setup.js";
7
9
  import { getClawChatPlatformPrompt } from "./plugin-prompts.js";
8
10
  export const openclawClawlingPlugin = createChatChannelPlugin({
@@ -23,9 +25,29 @@ export const openclawClawlingPlugin = createChatChannelPlugin({
23
25
  });
24
26
  },
25
27
  },
28
+ lifecycle: {
29
+ onAccountRemoved: ({ accountId, runtime }) => {
30
+ let store = null;
31
+ try {
32
+ const stateDir = getOpenclawClawlingRuntime().state?.resolveStateDir?.();
33
+ store = getClawChatStore({
34
+ ...(stateDir ? { dbPath: clawChatDbPathForStateDir(stateDir) } : {}),
35
+ log: { error: (message) => runtime.error(message) },
36
+ });
37
+ }
38
+ catch {
39
+ store = null;
40
+ }
41
+ clearOpenclawClawlingAccountOnRemoval({
42
+ accountId,
43
+ store,
44
+ log: (message) => runtime.log(message),
45
+ });
46
+ },
47
+ },
26
48
  gateway: {
27
49
  startAccount: async (ctx) => {
28
- const account = ctx.account ?? resolveOpenclawClawlingAccount(ctx.cfg);
50
+ const account = ctx.account ?? resolveOpenclawClawlingAccount(ctx.cfg, ctx.accountId);
29
51
  ctx.log?.info?.(`[${account.accountId}] clawchat-plugin-openclaw lifecycle START_ACCOUNT_CALLED configured=${account.configured} enabled=${account.enabled} hasToken=${Boolean(account.token)} hasUserId=${Boolean(account.userId)} websocketUrl=${account.websocketUrl || "(empty)"}`);
30
52
  if (!canStartOpenclawClawlingAccount(account)) {
31
53
  ctx.log?.error?.(`[${account.accountId}] clawchat-plugin-openclaw lifecycle startAccount refused: websocketUrl is required`);
@@ -1,68 +1,96 @@
1
- import { createTopLevelChannelConfigAdapter } from "openclaw/plugin-sdk/channel-config-helpers";
1
+ import { createHybridChannelConfigAdapter } from "openclaw/plugin-sdk/channel-config-helpers";
2
2
  import { mutateConfigFile } from "openclaw/plugin-sdk/config-mutation";
3
+ import { patchOpenclawClawlingAccountSection } from "./account-config-writes.js";
3
4
  import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/setup";
4
5
  import { createComputedAccountStatusAdapter, createDefaultChannelRuntimeState, } from "openclaw/plugin-sdk/status-helpers";
5
- import { CHANNEL_ID, canStartOpenclawClawlingAccount, listOpenclawClawlingAccountIds, openclawClawlingConfigSchema, resolveOpenclawClawlingAccount, } from "./config.js";
6
- const configAdapter = createTopLevelChannelConfigAdapter({
6
+ import { CHANNEL_ID, canStartOpenclawClawlingAccount, listOpenclawClawlingAccountIds, normalizeOpenclawClawlingAccountId, openclawClawlingConfigSchema, resolveOpenclawClawlingAccount, resolveOpenclawClawlingDefaultAccountId, } from "./config.js";
7
+ /** Keys a `channels remove --account default --delete` clears from the section. */
8
+ const CLEAR_BASE_FIELDS = [
9
+ "websocketUrl",
10
+ "baseUrl",
11
+ "token",
12
+ "refreshToken",
13
+ "agentId",
14
+ "userId",
15
+ "ownerUserId",
16
+ "forwardThinking",
17
+ "forwardToolCalls",
18
+ "richInteractions",
19
+ "enabled",
20
+ ];
21
+ /**
22
+ * Hybrid layout: the channel root IS the `default` account (and the
23
+ * inheritance base); named accounts live under `accounts.<id>`. The SDK
24
+ * adapter branches `setAccountEnabled` / `deleteAccount` on the id for us.
25
+ */
26
+ const hybridConfigAdapter = createHybridChannelConfigAdapter({
7
27
  sectionKey: CHANNEL_ID,
8
- resolveAccount: (cfg) => resolveOpenclawClawlingAccount(cfg),
9
- listAccountIds: () => listOpenclawClawlingAccountIds(),
10
- defaultAccountId: () => DEFAULT_ACCOUNT_ID,
11
- deleteMode: "clear-fields",
12
- clearBaseFields: [
13
- "websocketUrl",
14
- "baseUrl",
15
- "token",
16
- "userId",
17
- "forwardThinking",
18
- "forwardToolCalls",
19
- "richInteractions",
20
- "enabled",
21
- ],
28
+ resolveAccount: (cfg, accountId) => resolveOpenclawClawlingAccount(cfg, accountId),
29
+ listAccountIds: (cfg) => listOpenclawClawlingAccountIds(cfg),
30
+ defaultAccountId: (cfg) => resolveOpenclawClawlingDefaultAccountId(cfg),
31
+ preserveSectionOnDefaultDelete: true,
32
+ clearBaseFields: [...CLEAR_BASE_FIELDS],
22
33
  resolveAllowFrom: (account) => account.allowFrom,
23
34
  formatAllowFrom: () => [],
24
35
  });
36
+ /** `{ token: undefined, refreshToken: undefined, … }` — `undefined` deletes. */
37
+ const CLEAR_BASE_FIELDS_PATCH = Object.fromEntries(CLEAR_BASE_FIELDS.map((key) => [key, undefined]));
38
+ const configAdapter = {
39
+ ...hybridConfigAdapter,
40
+ /**
41
+ * The SDK's default branch clears `clearBaseFields` from the ROOT only. Once
42
+ * the host's single-account promotion has moved the identity into
43
+ * `accounts.default`, that leaves the deleted default fully credentialed:
44
+ * the default reader merges the promoted entry back over the blanked root,
45
+ * `ownerUserId` returns via the token's `oid` claim, and the account resolves
46
+ * `configured: true` again on the next gateway start. Clear the same keys
47
+ * from the promoted entry so `--delete` actually deletes. Named ids are the
48
+ * SDK's alone — it removes the whole `accounts.<id>` entry.
49
+ */
50
+ deleteAccount: (args) => {
51
+ const next = hybridConfigAdapter.deleteAccount?.(args) ?? args.cfg;
52
+ if (normalizeOpenclawClawlingAccountId(args.accountId) !== DEFAULT_ACCOUNT_ID)
53
+ return next;
54
+ return patchOpenclawClawlingAccountSection(next, DEFAULT_ACCOUNT_ID, CLEAR_BASE_FIELDS_PATCH);
55
+ },
56
+ };
57
+ function readInviteCode(input) {
58
+ return typeof input.code === "string" && input.code.trim()
59
+ ? input.code.trim()
60
+ : typeof input.token === "string"
61
+ ? input.token.trim()
62
+ : "";
63
+ }
25
64
  /**
26
65
  * Invite-code setup adapter used by OpenClaw setup surfaces.
27
66
  *
28
- * `channels add --token` passes the invite code as setup input. The setup
29
- * write leaves channel config unchanged; `afterAccountConfigWritten` exchanges
30
- * the invite code and persists token/userId through the host runtime mutator.
67
+ * `channels add [--account <id>] --token <code>` passes the invite code as
68
+ * setup input and the (optional) account id from the host. The setup write
69
+ * leaves channel config unchanged; `afterAccountConfigWritten` exchanges the
70
+ * invite code and persists credentials for THAT account through the host
71
+ * runtime mutator.
31
72
  */
32
73
  const setupAdapter = {
33
- resolveAccountId: () => DEFAULT_ACCOUNT_ID,
34
- validateInput: ({ input }) => {
35
- const inviteCode = typeof input.code === "string" && input.code.trim()
36
- ? input.code.trim()
37
- : typeof input.token === "string"
38
- ? input.token.trim()
39
- : "";
40
- if (!inviteCode) {
41
- return "ClawChat invite code is required.";
42
- }
43
- return null;
44
- },
74
+ resolveAccountId: ({ accountId }) => normalizeOpenclawClawlingAccountId(accountId),
75
+ validateInput: ({ input }) => readInviteCode(input) ? null : "ClawChat invite code is required.",
45
76
  applyAccountConfig: ({ cfg }) => cfg,
46
- afterAccountConfigWritten: async ({ cfg, input, runtime }) => {
47
- runtime.log("[default] clawchat-plugin-openclaw setup afterAccountConfigWritten invoked");
48
- const code = typeof input.code === "string" && input.code.trim()
49
- ? input.code.trim()
50
- : typeof input.token === "string"
51
- ? input.token.trim()
52
- : "";
77
+ afterAccountConfigWritten: async ({ cfg, accountId, input, runtime }) => {
78
+ const id = normalizeOpenclawClawlingAccountId(accountId);
79
+ runtime.log(`[${id}] clawchat-plugin-openclaw setup afterAccountConfigWritten invoked`);
80
+ const code = readInviteCode(input);
53
81
  if (!code) {
54
- runtime.log("[default] clawchat-plugin-openclaw setup afterAccountConfigWritten skipped: empty invite code");
82
+ runtime.log(`[${id}] clawchat-plugin-openclaw setup afterAccountConfigWritten skipped: empty invite code`);
55
83
  return;
56
84
  }
57
85
  const { runOpenclawClawlingLogin } = await import("./login.runtime.js");
58
86
  await runOpenclawClawlingLogin({
59
87
  cfg,
60
- accountId: null,
88
+ accountId: id,
61
89
  runtime: { log: (message) => runtime.log(message) },
62
90
  readInviteCode: async () => code,
63
91
  mutateConfigFile: mutateConfigFile,
64
92
  });
65
- runtime.log("[default] clawchat-plugin-openclaw setup afterAccountConfigWritten completed");
93
+ runtime.log(`[${id}] clawchat-plugin-openclaw setup afterAccountConfigWritten completed`);
66
94
  },
67
95
  };
68
96
  export const openclawClawlingSetupPlugin = {
@@ -1,9 +1,34 @@
1
- import { CHANNEL_ID } from "./config.js";
1
+ import { patchOpenclawClawlingAccountSection } from "./account-config-writes.js";
2
+ import { CHANNEL_ID, normalizeOpenclawClawlingAccountId, readOpenclawClawlingAccountSection, } from "./config.js";
2
3
  /** Activation intent flags — the two escape hatches out of the live-activation guard. */
3
4
  const ACTIVATION_FLAGS = ["--new-account", "--repair"];
4
5
  function stripActivationFlags(raw) {
5
6
  return ACTIVATION_FLAGS.reduce((acc, flag) => acc.split(flag).join(" "), raw);
6
7
  }
8
+ /** `--account <id>` / `--account=<id>`; the value is removed before the code scan so it can never be mistaken for a code. */
9
+ const ACCOUNT_FLAG_PATTERN = /(?:^|\s)--account(?:=|\s+)(\S+)/u;
10
+ /** `--account` with nothing after it — including a dangling `--account=`, which
11
+ * is a typo rather than "use the default account". */
12
+ const BARE_ACCOUNT_FLAG_PATTERN = /(?:^|\s)--account=?(?=\s|$)/u;
13
+ function extractAccountFlag(value) {
14
+ const raw = typeof value === "string" ? value : "";
15
+ const match = ACCOUNT_FLAG_PATTERN.exec(raw);
16
+ if (match) {
17
+ const id = match[1] ?? "";
18
+ if (!id || id.startsWith("--")) {
19
+ return { accountId: null, rest: raw, error: "--account requires an account id" };
20
+ }
21
+ return {
22
+ accountId: normalizeOpenclawClawlingAccountId(id),
23
+ rest: raw.replace(match[0], " "),
24
+ error: null,
25
+ };
26
+ }
27
+ if (BARE_ACCOUNT_FLAG_PATTERN.test(raw)) {
28
+ return { accountId: null, rest: raw, error: "--account requires an account id" };
29
+ }
30
+ return { accountId: null, rest: raw, error: null };
31
+ }
7
32
  /**
8
33
  * A whole argument token that looks like a ClawChat connect code.
9
34
  *
@@ -80,27 +105,19 @@ function resolveCommandChatId(ctx) {
80
105
  ? String(ctx.messageThreadId).trim()
81
106
  : "");
82
107
  }
83
- function persistOutputVisibility(draft, chatId, outputVisibility) {
84
- const channels = readRecord(draft.channels);
85
- const channel = readRecord(channels[CHANNEL_ID]);
86
- const chats = readRecord(channel.chats);
108
+ function persistOutputVisibility(draft, accountId, chatId, outputVisibility) {
109
+ // Seeded from the MERGED view, never the raw target entry: `mergeAccountConfig`
110
+ // REPLACES `chats` rather than deep-merging it, so a first write seeded from an
111
+ // entry that has no `chats` of its own (a named account, or a `default` the
112
+ // host has promoted into `accounts.default`) would shadow every inherited
113
+ // conversation. Copying the inherited map forward and overriding one key keeps
114
+ // the resolved view additive.
115
+ const merged = readOpenclawClawlingAccountSection(draft, accountId);
116
+ const chats = readRecord(merged.chats);
87
117
  const chat = readRecord(chats[chatId]);
88
- Object.assign(draft, {
89
- ...draft,
90
- channels: {
91
- ...channels,
92
- [CHANNEL_ID]: {
93
- ...channel,
94
- chats: {
95
- ...chats,
96
- [chatId]: {
97
- ...chat,
98
- outputVisibility,
99
- },
100
- },
101
- },
102
- },
103
- });
118
+ Object.assign(draft, patchOpenclawClawlingAccountSection(draft, accountId, {
119
+ chats: { ...chats, [chatId]: { ...chat, outputVisibility } },
120
+ }));
104
121
  }
105
122
  function formatOutputVisibilityResult(outputVisibility) {
106
123
  const detailLevel = {
@@ -120,21 +137,26 @@ function formatOutputVisibilityResult(outputVisibility) {
120
137
  export function registerOpenclawClawlingCommands(api) {
121
138
  api.registerCommand({
122
139
  name: "clawchat-activate",
123
- description: "Activate ClawChat with an invite code, e.g. /clawchat-activate K7RM4TQP.",
140
+ description: "Activate ClawChat with an invite code, e.g. /clawchat-activate K7RM4TQP [--account <id>].",
124
141
  acceptsArgs: true,
125
142
  requireAuth: true,
126
143
  async handler(ctx) {
127
144
  const rawArgs = ctx.args ?? ctx.commandBody;
128
- const code = extractInviteCode(rawArgs);
145
+ const accountFlag = extractAccountFlag(rawArgs);
146
+ if (accountFlag.error) {
147
+ return { text: `${accountFlag.error}. Usage: /clawchat-activate K7RM4TQP [--account <id>]` };
148
+ }
149
+ const code = extractInviteCode(accountFlag.rest);
129
150
  if (!code) {
130
- return { text: "ClawChat invite code is required. Usage: /clawchat-activate K7RM4TQP" };
151
+ return { text: "ClawChat invite code is required. Usage: /clawchat-activate K7RM4TQP [--account <id>]" };
131
152
  }
132
- const intent = extractActivationIntent(rawArgs);
153
+ const intent = extractActivationIntent(accountFlag.rest);
154
+ const accountId = accountFlag.accountId ?? normalizeOpenclawClawlingAccountId(ctx.accountId);
133
155
  try {
134
156
  const { runOpenclawClawlingLogin } = await import("./login.runtime.js");
135
157
  await runOpenclawClawlingLogin({
136
158
  cfg: ctx.config,
137
- accountId: ctx.accountId ?? null,
159
+ accountId,
138
160
  runtime: { log: (message) => api.logger?.info?.(message) },
139
161
  readInviteCode: async () => code,
140
162
  mutateConfigFile: api.runtime.config.mutateConfigFile,
@@ -166,10 +188,11 @@ export function registerOpenclawClawlingCommands(api) {
166
188
  if (!mutateConfigFile) {
167
189
  return { text: "OpenClaw runtime config mutation is unavailable for /clawchat-output." };
168
190
  }
191
+ const accountId = normalizeOpenclawClawlingAccountId(ctx.accountId);
169
192
  await mutateConfigFile({
170
193
  afterWrite: { mode: "auto" },
171
194
  mutate(draft) {
172
- persistOutputVisibility(draft, chatId, outputVisibility);
195
+ persistOutputVisibility(draft, accountId, chatId, outputVisibility);
173
196
  },
174
197
  });
175
198
  return { text: formatOutputVisibilityResult(outputVisibility) };
@@ -1,3 +1,4 @@
1
+ import { createAccountListHelpers, mergeAccountConfig, normalizeAccountId, resolveNormalizedAccountEntry, } from "openclaw/plugin-sdk/account-core";
1
2
  import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/setup";
2
3
  export const CHANNEL_ID = "clawchat-plugin-openclaw";
3
4
  export const CLAWCHAT_TOKEN_ENV = "CLAWCHAT_TOKEN";
@@ -40,7 +41,7 @@ export const DEFAULT_ACK = {
40
41
  // reconnect path re-queues via `queueWhileReconnecting` instead.
41
42
  autoResendOnTimeout: false,
42
43
  };
43
- export const openclawClawlingConfigSchema = {
44
+ export const openclawClawlingAccountConfigSchema = {
44
45
  type: "object",
45
46
  additionalProperties: false,
46
47
  properties: {
@@ -111,6 +112,18 @@ export const openclawClawlingConfigSchema = {
111
112
  },
112
113
  },
113
114
  };
115
+ export const openclawClawlingConfigSchema = {
116
+ type: "object",
117
+ additionalProperties: false,
118
+ properties: {
119
+ ...openclawClawlingAccountConfigSchema.properties,
120
+ defaultAccount: { type: "string" },
121
+ accounts: {
122
+ type: "object",
123
+ additionalProperties: openclawClawlingAccountConfigSchema,
124
+ },
125
+ },
126
+ };
114
127
  function isOpenclawClawchatToolAllowEntry(entry) {
115
128
  return entry === CHANNEL_ID || entry === "group:plugins";
116
129
  }
@@ -181,6 +194,56 @@ function readChannelSection(cfg) {
181
194
  const channel = channels[CHANNEL_ID];
182
195
  return channel && typeof channel === "object" ? channel : {};
183
196
  }
197
+ /** Keys that identify a ClawChat account. Never inherited by a named account from the root. */
198
+ export const ACCOUNT_CREDENTIAL_KEYS = ["token", "refreshToken", "agentId", "userId", "ownerUserId"];
199
+ const ACCOUNT_MAP_KEYS = ["accounts", "defaultAccount"];
200
+ const NESTED_ACCOUNT_OBJECT_KEYS = ["reconnect", "heartbeat", "ack"];
201
+ /** Host-identical account id normalization (lowercase, `[^a-z0-9_-]` → `-`, blank → `default`). */
202
+ export function normalizeOpenclawClawlingAccountId(value) {
203
+ return normalizeAccountId(value);
204
+ }
205
+ function readAccountsMap(section) {
206
+ const accounts = section.accounts;
207
+ return accounts && typeof accounts === "object" && !Array.isArray(accounts)
208
+ ? accounts
209
+ : undefined;
210
+ }
211
+ /**
212
+ * The single config read point. Root = the `default` account and the
213
+ * inheritance base; `accounts.<id>` overrides it. Credential keys are omitted
214
+ * from the base for named ids so an empty `accounts.work: {}` is unconfigured
215
+ * rather than a clone of the default identity. `accounts.default`, when the
216
+ * host's single-account promotion created one, merges over the root.
217
+ */
218
+ export function readOpenclawClawlingAccountSection(cfg, accountId) {
219
+ const root = readChannelSection(cfg);
220
+ const id = normalizeOpenclawClawlingAccountId(accountId);
221
+ const resolved = resolveNormalizedAccountEntry(readAccountsMap(root), id, normalizeAccountId);
222
+ // The schema says every entry is an object; the file on disk may disagree
223
+ // (a hand-edited `accounts.work: "work"`). Spreading a non-record merges it
224
+ // in character by character, so treat it as absent.
225
+ const entry = resolved && typeof resolved === "object" && !Array.isArray(resolved) ? resolved : undefined;
226
+ const omitKeys = [...ACCOUNT_MAP_KEYS];
227
+ if (id !== DEFAULT_ACCOUNT_ID)
228
+ omitKeys.push(...ACCOUNT_CREDENTIAL_KEYS);
229
+ return mergeAccountConfig({
230
+ channelConfig: root,
231
+ accountConfig: entry ?? {},
232
+ omitKeys,
233
+ nestedObjectKeys: NESTED_ACCOUNT_OBJECT_KEYS,
234
+ });
235
+ }
236
+ const accountListHelpers = createAccountListHelpers(CHANNEL_ID, {
237
+ normalizeAccountId,
238
+ // The root section is always an account, even when unconfigured.
239
+ hasImplicitDefaultAccount: () => true,
240
+ });
241
+ export function listOpenclawClawlingAccountIds(cfg) {
242
+ return accountListHelpers.listAccountIds(cfg);
243
+ }
244
+ export function resolveOpenclawClawlingDefaultAccountId(cfg) {
245
+ return accountListHelpers.resolveDefaultAccountId(cfg);
246
+ }
184
247
  function readOptionalString(value) {
185
248
  return typeof value === "string" ? value.trim() : "";
186
249
  }
@@ -345,8 +408,16 @@ function readAck(raw) {
345
408
  : DEFAULT_ACK.autoResendOnTimeout,
346
409
  };
347
410
  }
348
- export function resolveOpenclawClawlingAccount(cfg, env = process.env) {
349
- const channel = readChannelSection(cfg);
411
+ export function resolveOpenclawClawlingAccount(cfg, accountId, env = process.env) {
412
+ const resolvedAccountId = accountId === undefined || accountId === null || !accountId.trim()
413
+ ? resolveOpenclawClawlingDefaultAccountId(cfg)
414
+ : normalizeOpenclawClawlingAccountId(accountId);
415
+ const isDefault = resolvedAccountId === DEFAULT_ACCOUNT_ID;
416
+ const root = readChannelSection(cfg);
417
+ const channel = readOpenclawClawlingAccountSection(cfg, resolvedAccountId);
418
+ // Credential env vars are process-wide and can only ever describe ONE
419
+ // identity — they belong to the default account. Endpoint env vars are shared.
420
+ const credentialEnv = isDefault ? env : {};
350
421
  // Apply built-in defaults so login/gateway work without prior setup.
351
422
  const websocketUrl = readOptionalString(channel.websocketUrl) ||
352
423
  readEnvString(env, CLAWCHAT_WEBSOCKET_URL_ENV) ||
@@ -355,17 +426,19 @@ export function resolveOpenclawClawlingAccount(cfg, env = process.env) {
355
426
  readEnvString(env, CLAWCHAT_BASE_URL_ENV) ||
356
427
  DEFAULT_BASE_URL;
357
428
  const mediaBaseUrl = readOptionalString(channel.mediaBaseUrl) || readEnvString(env, CLAWCHAT_MEDIA_BASE_URL_ENV);
358
- const token = readOptionalString(channel.token) || readEnvString(env, CLAWCHAT_TOKEN_ENV);
359
- const agentId = readOptionalString(channel.agentId) || readEnvString(env, CLAWCHAT_AGENT_ID_ENV);
360
- const userId = resolveUserId(token, readOptionalString(channel.userId) || readEnvString(env, CLAWCHAT_USER_ID_ENV));
429
+ const token = readOptionalString(channel.token) || readEnvString(credentialEnv, CLAWCHAT_TOKEN_ENV);
430
+ const agentId = readOptionalString(channel.agentId) || readEnvString(credentialEnv, CLAWCHAT_AGENT_ID_ENV);
431
+ const userId = resolveUserId(token, readOptionalString(channel.userId) || readEnvString(credentialEnv, CLAWCHAT_USER_ID_ENV));
361
432
  const ownerUserId = rejectPlaceholderOwnerUserId(readOptionalString(channel.ownerUserId), "channel ownerUserId") ||
362
- rejectPlaceholderOwnerUserId(readEnvString(env, CLAWCHAT_OWNER_USER_ID_ENV), CLAWCHAT_OWNER_USER_ID_ENV) ||
433
+ rejectPlaceholderOwnerUserId(readEnvString(credentialEnv, CLAWCHAT_OWNER_USER_ID_ENV), CLAWCHAT_OWNER_USER_ID_ENV) ||
363
434
  // Fall back to the access token's `oid` (owner) claim. A provisioner may
364
435
  // inject token + userId but omit ownerUserId; without it the connect gate
365
436
  // (hasOpenclawClawlingConnectCredentials) never passes and the plugin sits
366
437
  // in the wait-for-activation loop. The owner is always carried in the token.
367
438
  decodeJwtStringClaim(token, "oid");
368
- const enabled = typeof channel.enabled === "boolean" ? channel.enabled : true;
439
+ const rootEnabled = typeof root.enabled === "boolean" ? root.enabled : true;
440
+ const sectionEnabled = typeof channel.enabled === "boolean" ? channel.enabled : true;
441
+ const enabled = rootEnabled && sectionEnabled;
369
442
  const outputVisibility = readOutputVisibility(channel.outputVisibility);
370
443
  const chats = readChats(channel.chats);
371
444
  const groupMode = readGroupMode(channel.groupMode);
@@ -377,8 +450,8 @@ export function resolveOpenclawClawlingAccount(cfg, env = process.env) {
377
450
  const awarenessNote = typeof channel.awarenessNote === "boolean" ? channel.awarenessNote : false;
378
451
  const livewareSample = typeof channel.livewareSample === "boolean" ? channel.livewareSample : true;
379
452
  return {
380
- accountId: DEFAULT_ACCOUNT_ID,
381
- name: CHANNEL_ID,
453
+ accountId: resolvedAccountId,
454
+ name: isDefault ? CHANNEL_ID : resolvedAccountId,
382
455
  enabled,
383
456
  configured: hasOpenclawClawlingConnectCredentials({
384
457
  websocketUrl,
@@ -409,6 +482,3 @@ export function resolveOpenclawClawlingAccount(cfg, env = process.env) {
409
482
  ack: readAck(channel.ack),
410
483
  };
411
484
  }
412
- export function listOpenclawClawlingAccountIds() {
413
- return [DEFAULT_ACCOUNT_ID];
414
- }
@@ -1,6 +1,8 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { resolveStateDir } from "openclaw/plugin-sdk/state-paths";
4
+ import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/setup";
5
+ import { normalizeOpenclawClawlingAccountId } from "./config.js";
4
6
  export const LIVEWARE_BASE_URL = "https://media.clawling.chat/liveware/";
5
7
  // A non-default User-Agent is required: the CDN (Cloudflare) returns 403 for
6
8
  // known scripting/default UAs, so download requests must identify themselves.
@@ -11,7 +13,23 @@ export function livewareDir(stateDir) {
11
13
  return path.join(root, "clawchat", "liveware");
12
14
  }
13
15
  /**
14
- * Private HOME for the liveware CLI: `<stateDir>/clawchat/liveware-home`.
16
+ * Root for one account's liveware state: `<stateDir>/clawchat` for the
17
+ * `default` account, `<stateDir>/clawchat/accounts/<id>` for a named one.
18
+ *
19
+ * The `default` account keeps the pre-multi-account layout exactly, so an
20
+ * existing single-account install never has to migrate anything.
21
+ */
22
+ function livewareAccountRoot(stateDir, accountId) {
23
+ const root = stateDir ?? resolveStateDir();
24
+ const id = normalizeOpenclawClawlingAccountId(accountId);
25
+ return id === DEFAULT_ACCOUNT_ID
26
+ ? path.join(root, "clawchat")
27
+ : path.join(root, "clawchat", "accounts", id);
28
+ }
29
+ /**
30
+ * Private HOME for the liveware CLI: `<stateDir>/clawchat/liveware-home` for
31
+ * the default account, `<stateDir>/clawchat/accounts/<id>/liveware-home` for a
32
+ * named one.
15
33
  *
16
34
  * The CLI keeps its login credentials in `$HOME/.clawling`, which is
17
35
  * host-global. Two OpenClaw instances on one box (`openclaw --profile <name>`,
@@ -22,13 +40,29 @@ export function livewareDir(stateDir) {
22
40
  * credential store per-instance, which is the same isolation the rest of the
23
41
  * plugin already gets for free.
24
42
  *
43
+ * Two ACCOUNTS in ONE instance have the same problem and no state dir to tell
44
+ * them apart, so a named account gets its own sub-root: otherwise `work`'s
45
+ * `liveware login` would overwrite `default`'s session in the shared home and
46
+ * `default`'s next `tunnel bind` / `app create` would run as `work`'s user.
47
+ *
25
48
  * Migration is automatic: `livewareLogin` runs on every bootstrap AND (best
26
49
  * effort) on every relaunch, so an existing install simply re-logs into its new
27
50
  * private home on the next start.
28
51
  */
29
- export function livewareCliHomeDir(stateDir) {
30
- const root = stateDir ?? resolveStateDir();
31
- return path.join(root, "clawchat", "liveware-home");
52
+ export function livewareCliHomeDir(stateDir, accountId) {
53
+ return path.join(livewareAccountRoot(stateDir, accountId), "liveware-home");
54
+ }
55
+ /**
56
+ * Where the Liveware Sample app tree is installed for one account:
57
+ * `<stateDir>/clawchat/liveware-sample` (default) or
58
+ * `<stateDir>/clawchat/accounts/<id>/liveware-sample` (named).
59
+ *
60
+ * Per account for the same reason as {@link livewareCliHomeDir}: two
61
+ * supervisors in one process otherwise install, swap and delete inside the
62
+ * same `app/` tree concurrently.
63
+ */
64
+ export function livewareSampleRootDir(stateDir, accountId) {
65
+ return path.join(livewareAccountRoot(stateDir, accountId), "liveware-sample");
32
66
  }
33
67
  /**
34
68
  * Environment for every liveware CLI invocation: the ambient env with HOME