@sunnoy/wecom 1.9.0 → 2.0.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,330 @@
1
+ const DAY_MS = 24 * 60 * 60 * 1000;
2
+ const ACTIVE_SEND_LIMIT = 10;
3
+ const ACTIVE_SEND_WARN_THRESHOLD = 8;
4
+ const REPLY_LIMIT = 30;
5
+ const REPLY_WARN_THRESHOLD = 24;
6
+ const MAX_TRACKED_CHATS_PER_ACCOUNT = 500;
7
+
8
+ const accountStates = new Map();
9
+
10
+ function currentDayKey(at) {
11
+ return new Date(at).toISOString().slice(0, 10);
12
+ }
13
+
14
+ function ensureAccountState(accountId) {
15
+ if (!accountStates.has(accountId)) {
16
+ accountStates.set(accountId, {
17
+ lastInboundAt: null,
18
+ lastOutboundAt: null,
19
+ displaced: false,
20
+ lastDisplacedAt: null,
21
+ lastDisplacedReason: null,
22
+ chats: new Map(),
23
+ });
24
+ }
25
+ return accountStates.get(accountId);
26
+ }
27
+
28
+ function ensureChatState(accountState, chatId) {
29
+ const normalizedChatId = String(chatId ?? "").trim();
30
+ if (!normalizedChatId) {
31
+ return null;
32
+ }
33
+ if (!accountState.chats.has(normalizedChatId)) {
34
+ accountState.chats.set(normalizedChatId, {
35
+ chatId: normalizedChatId,
36
+ lastInboundAt: null,
37
+ lastOutboundAt: null,
38
+ lastTouchedAt: 0,
39
+ replyCount24h: 0,
40
+ activeSendDay: currentDayKey(Date.now()),
41
+ activeSendCountToday: 0,
42
+ });
43
+ }
44
+ return accountState.chats.get(normalizedChatId);
45
+ }
46
+
47
+ function touchChatState(chatState, at) {
48
+ chatState.lastTouchedAt = Math.max(chatState.lastTouchedAt ?? 0, at);
49
+ }
50
+
51
+ function pruneChatState(chatState, at) {
52
+ const dayKey = currentDayKey(at);
53
+ if (chatState.activeSendDay !== dayKey) {
54
+ chatState.activeSendDay = dayKey;
55
+ chatState.activeSendCountToday = 0;
56
+ }
57
+
58
+ if (!chatState.lastInboundAt || at - chatState.lastInboundAt >= DAY_MS) {
59
+ chatState.replyCount24h = 0;
60
+ }
61
+ }
62
+
63
+ function pruneAccountState(accountState, at) {
64
+ for (const [chatId, chatState] of accountState.chats.entries()) {
65
+ pruneChatState(chatState, at);
66
+
67
+ const lastRelevantAt = Math.max(
68
+ chatState.lastTouchedAt ?? 0,
69
+ chatState.lastInboundAt ?? 0,
70
+ chatState.lastOutboundAt ?? 0,
71
+ );
72
+ const hasReplyWindow = chatState.lastInboundAt && at - chatState.lastInboundAt < DAY_MS;
73
+ const hasDailyQuotaState = chatState.activeSendCountToday > 0;
74
+ if (!hasReplyWindow && !hasDailyQuotaState && lastRelevantAt > 0 && at - lastRelevantAt >= DAY_MS) {
75
+ accountState.chats.delete(chatId);
76
+ }
77
+ }
78
+
79
+ if (accountState.chats.size <= MAX_TRACKED_CHATS_PER_ACCOUNT) {
80
+ return;
81
+ }
82
+
83
+ const oldestFirst = [...accountState.chats.entries()].sort((left, right) => {
84
+ const leftTouched = left[1].lastTouchedAt ?? 0;
85
+ const rightTouched = right[1].lastTouchedAt ?? 0;
86
+ return leftTouched - rightTouched;
87
+ });
88
+ const excess = accountState.chats.size - MAX_TRACKED_CHATS_PER_ACCOUNT;
89
+ for (const [chatId] of oldestFirst.slice(0, excess)) {
90
+ accountState.chats.delete(chatId);
91
+ }
92
+ }
93
+
94
+ function describeReplyQuota(chatState, at) {
95
+ pruneChatState(chatState, at);
96
+ const windowActive = Boolean(chatState.lastInboundAt && at - chatState.lastInboundAt < DAY_MS);
97
+ const used = windowActive ? chatState.replyCount24h : 0;
98
+ const remaining = windowActive ? Math.max(0, REPLY_LIMIT - used) : REPLY_LIMIT;
99
+ return {
100
+ bucket: "reply24h",
101
+ limit: REPLY_LIMIT,
102
+ windowActive,
103
+ used,
104
+ remaining,
105
+ exhausted: windowActive && used >= REPLY_LIMIT,
106
+ nearLimit: windowActive && used >= REPLY_WARN_THRESHOLD,
107
+ };
108
+ }
109
+
110
+ function describeActiveQuota(chatState, at) {
111
+ pruneChatState(chatState, at);
112
+ const used = chatState.activeSendCountToday;
113
+ return {
114
+ bucket: "activeDaily",
115
+ limit: ACTIVE_SEND_LIMIT,
116
+ used,
117
+ remaining: Math.max(0, ACTIVE_SEND_LIMIT - used),
118
+ exhausted: used >= ACTIVE_SEND_LIMIT,
119
+ nearLimit: used >= ACTIVE_SEND_WARN_THRESHOLD,
120
+ };
121
+ }
122
+
123
+ export function forecastReplyQuota({ accountId, chatId, at = Date.now() }) {
124
+ const accountState = ensureAccountState(accountId);
125
+ const chatState = ensureChatState(accountState, chatId);
126
+ if (!chatState) {
127
+ return {
128
+ bucket: "reply24h",
129
+ limit: REPLY_LIMIT,
130
+ windowActive: false,
131
+ used: 0,
132
+ remaining: REPLY_LIMIT,
133
+ exhausted: false,
134
+ nearLimit: false,
135
+ };
136
+ }
137
+ return describeReplyQuota(chatState, at);
138
+ }
139
+
140
+ export function forecastActiveSendQuota({ accountId, chatId, at = Date.now() }) {
141
+ const accountState = ensureAccountState(accountId);
142
+ const chatState = ensureChatState(accountState, chatId);
143
+ if (!chatState) {
144
+ return {
145
+ bucket: "activeDaily",
146
+ limit: ACTIVE_SEND_LIMIT,
147
+ windowActive: false,
148
+ used: 0,
149
+ remaining: ACTIVE_SEND_LIMIT,
150
+ exhausted: false,
151
+ nearLimit: false,
152
+ };
153
+ }
154
+
155
+ const replyQuota = describeReplyQuota(chatState, at);
156
+ if (replyQuota.windowActive && !replyQuota.exhausted) {
157
+ return replyQuota;
158
+ }
159
+ return describeActiveQuota(chatState, at);
160
+ }
161
+
162
+ export function recordInboundMessage({ accountId, chatId, at = Date.now() }) {
163
+ const accountState = ensureAccountState(accountId);
164
+ accountState.lastInboundAt = at;
165
+
166
+ const chatState = ensureChatState(accountState, chatId);
167
+ if (!chatState) {
168
+ return;
169
+ }
170
+
171
+ chatState.lastInboundAt = at;
172
+ chatState.replyCount24h = 0;
173
+ touchChatState(chatState, at);
174
+ pruneAccountState(accountState, at);
175
+ }
176
+
177
+ export function recordPassiveReply({ accountId, chatId, at = Date.now(), countQuota = true } = {}) {
178
+ const accountState = ensureAccountState(accountId);
179
+ accountState.lastOutboundAt = at;
180
+
181
+ const chatState = ensureChatState(accountState, chatId);
182
+ if (!chatState) {
183
+ return {
184
+ bucket: "reply24h",
185
+ used: 0,
186
+ limit: REPLY_LIMIT,
187
+ remaining: REPLY_LIMIT,
188
+ windowActive: false,
189
+ exhausted: false,
190
+ nearLimit: false,
191
+ };
192
+ }
193
+
194
+ chatState.lastOutboundAt = at;
195
+ touchChatState(chatState, at);
196
+ const quota = describeReplyQuota(chatState, at);
197
+ if (countQuota && quota.windowActive) {
198
+ chatState.replyCount24h += 1;
199
+ }
200
+ pruneAccountState(accountState, at);
201
+ return describeReplyQuota(chatState, at);
202
+ }
203
+
204
+ export function recordActiveSend({ accountId, chatId, at = Date.now() }) {
205
+ const accountState = ensureAccountState(accountId);
206
+ accountState.lastOutboundAt = at;
207
+
208
+ const chatState = ensureChatState(accountState, chatId);
209
+ if (!chatState) {
210
+ return {
211
+ bucket: "activeDaily",
212
+ used: 0,
213
+ limit: ACTIVE_SEND_LIMIT,
214
+ remaining: ACTIVE_SEND_LIMIT,
215
+ windowActive: false,
216
+ exhausted: false,
217
+ nearLimit: false,
218
+ };
219
+ }
220
+
221
+ chatState.lastOutboundAt = at;
222
+ touchChatState(chatState, at);
223
+ const quota = forecastActiveSendQuota({ accountId, chatId, at });
224
+ if (quota.bucket === "reply24h" && quota.windowActive) {
225
+ chatState.replyCount24h += 1;
226
+ } else {
227
+ pruneChatState(chatState, at);
228
+ chatState.activeSendCountToday += 1;
229
+ }
230
+ pruneAccountState(accountState, at);
231
+ return forecastActiveSendQuota({ accountId, chatId, at });
232
+ }
233
+
234
+ export function recordOutboundActivity({ accountId, at = Date.now() }) {
235
+ const accountState = ensureAccountState(accountId);
236
+ accountState.lastOutboundAt = at;
237
+ }
238
+
239
+ export function markAccountDisplaced({ accountId, reason = null, at = Date.now() }) {
240
+ const accountState = ensureAccountState(accountId);
241
+ accountState.displaced = true;
242
+ accountState.lastDisplacedAt = at;
243
+ accountState.lastDisplacedReason = reason ? String(reason) : null;
244
+ }
245
+
246
+ export function clearAccountDisplaced(accountId) {
247
+ const accountState = ensureAccountState(accountId);
248
+ accountState.displaced = false;
249
+ }
250
+
251
+ export function getAccountTelemetry(accountId, { now = Date.now() } = {}) {
252
+ const accountState = accountStates.get(accountId);
253
+ if (!accountState) {
254
+ return {
255
+ lastInboundAt: null,
256
+ lastOutboundAt: null,
257
+ connection: {
258
+ displaced: false,
259
+ lastDisplacedAt: null,
260
+ lastDisplacedReason: null,
261
+ },
262
+ quotas: {
263
+ trackedChats: 0,
264
+ replyWindowChats: 0,
265
+ totalReplyCount24h: 0,
266
+ nearLimitReplyChats: 0,
267
+ exhaustedReplyChats: 0,
268
+ activeDailyChats: 0,
269
+ totalActiveSendCountToday: 0,
270
+ nearLimitActiveChats: 0,
271
+ exhaustedActiveChats: 0,
272
+ },
273
+ };
274
+ }
275
+
276
+ pruneAccountState(accountState, now);
277
+
278
+ const summary = {
279
+ trackedChats: 0,
280
+ replyWindowChats: 0,
281
+ totalReplyCount24h: 0,
282
+ nearLimitReplyChats: 0,
283
+ exhaustedReplyChats: 0,
284
+ activeDailyChats: 0,
285
+ totalActiveSendCountToday: 0,
286
+ nearLimitActiveChats: 0,
287
+ exhaustedActiveChats: 0,
288
+ };
289
+
290
+ for (const chatState of accountState.chats.values()) {
291
+ summary.trackedChats += 1;
292
+
293
+ const replyQuota = describeReplyQuota(chatState, now);
294
+ if (replyQuota.windowActive) {
295
+ summary.replyWindowChats += 1;
296
+ summary.totalReplyCount24h += replyQuota.used;
297
+ if (replyQuota.exhausted) {
298
+ summary.exhaustedReplyChats += 1;
299
+ } else if (replyQuota.nearLimit) {
300
+ summary.nearLimitReplyChats += 1;
301
+ }
302
+ }
303
+
304
+ const activeQuota = describeActiveQuota(chatState, now);
305
+ if (activeQuota.used > 0) {
306
+ summary.activeDailyChats += 1;
307
+ summary.totalActiveSendCountToday += activeQuota.used;
308
+ if (activeQuota.exhausted) {
309
+ summary.exhaustedActiveChats += 1;
310
+ } else if (activeQuota.nearLimit) {
311
+ summary.nearLimitActiveChats += 1;
312
+ }
313
+ }
314
+ }
315
+
316
+ return {
317
+ lastInboundAt: accountState.lastInboundAt,
318
+ lastOutboundAt: accountState.lastOutboundAt,
319
+ connection: {
320
+ displaced: accountState.displaced,
321
+ lastDisplacedAt: accountState.lastDisplacedAt,
322
+ lastDisplacedReason: accountState.lastDisplacedReason,
323
+ },
324
+ quotas: summary,
325
+ };
326
+ }
327
+
328
+ export function resetRuntimeTelemetryForTesting() {
329
+ accountStates.clear();
330
+ }
@@ -0,0 +1,60 @@
1
+ import { realpath } from "node:fs/promises";
2
+ import path from "node:path";
3
+
4
+ /**
5
+ * Resolve the allowed sandbox roots for outbound file access.
6
+ *
7
+ * @param {object} params
8
+ * @param {Function} params.resolveAgentWorkspaceDir - (config, agentId) => string
9
+ * @param {Function} params.resolveDefaultAgentId - (config) => string
10
+ * @param {Function} params.resolveStateDir - () => string
11
+ * @param {object} params.config - OpenClaw configuration
12
+ * @param {string} [params.agentId] - Agent identifier
13
+ * @returns {string[]} Deduplicated list of resolved absolute root paths.
14
+ */
15
+ export function resolveSandboxRoots({ resolveAgentWorkspaceDir, resolveDefaultAgentId, resolveStateDir, config, agentId }) {
16
+ const effectiveAgentId = agentId || resolveDefaultAgentId(config);
17
+ const workspaceDir = resolveAgentWorkspaceDir(config, effectiveAgentId);
18
+ const stateDir = resolveStateDir();
19
+ const mediaCacheDir = path.join(stateDir, "media");
20
+ const browserMediaDir = path.join(stateDir, "media", "browser");
21
+
22
+ return [...new Set([workspaceDir, mediaCacheDir, browserMediaDir].map((p) => path.resolve(p)))];
23
+ }
24
+
25
+ /**
26
+ * Validate that a file path falls within one of the allowed sandbox roots.
27
+ *
28
+ * Uses `fs.realpath` to resolve symlinks before checking, preventing
29
+ * symlink-based escapes. Falls back to `path.resolve` if `realpath` fails
30
+ * (e.g. file does not exist yet), which still catches `..` traversal.
31
+ *
32
+ * @param {string} filePath - The absolute file path to validate.
33
+ * @param {string[]} allowedRoots - List of allowed root directories.
34
+ * @throws {Error} If the path escapes all sandbox roots.
35
+ */
36
+ export async function assertPathInsideSandbox(filePath, allowedRoots) {
37
+ if (!filePath || typeof filePath !== "string") {
38
+ throw new Error("Sandbox violation: empty file path");
39
+ }
40
+
41
+ const resolved = path.resolve(filePath);
42
+
43
+ // Resolve symlinks when possible; fall back to lexical resolve.
44
+ let real;
45
+ try {
46
+ real = await realpath(resolved);
47
+ } catch {
48
+ real = resolved;
49
+ }
50
+
51
+ for (const root of allowedRoots) {
52
+ const resolvedRoot = path.resolve(root);
53
+ // Ensure trailing separator so "/workspace-x" doesn't match "/workspace-xyz".
54
+ if (real === resolvedRoot || real.startsWith(resolvedRoot + path.sep)) {
55
+ return;
56
+ }
57
+ }
58
+
59
+ throw new Error(`Sandbox violation: path "${filePath}" escapes allowed roots`);
60
+ }
package/wecom/state.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { AsyncLocalStorage } from "node:async_hooks";
2
2
  import { getWebhookBotSendUrl } from "./constants.js";
3
- import { resolveAgentConfigForAccount, resolveAccount } from "./accounts.js";
3
+ import { resolveAgentConfigForAccount, resolveDefaultAccountId, resolveAccount } from "./accounts.js";
4
4
 
5
5
  const runtimeState = {
6
6
  runtime: null,
@@ -10,13 +10,6 @@ const runtimeState = {
10
10
  };
11
11
 
12
12
  export const dispatchLocks = new Map();
13
- export const messageBuffers = new Map();
14
- export const webhookTargets = new Map();
15
- export const activeStreams = new Map();
16
- export const activeStreamHistory = new Map();
17
- export const lastStreamByKey = new Map();
18
- export const streamMeta = new Map();
19
- export const responseUrls = new Map();
20
13
  export const streamContext = new AsyncLocalStorage();
21
14
 
22
15
  export function setRuntime(runtime) {
@@ -50,36 +43,41 @@ export function setEnsureDynamicAgentWriteQueue(queuePromise) {
50
43
  runtimeState.ensureDynamicAgentWriteQueue = queuePromise;
51
44
  }
52
45
 
53
- /**
54
- * Extract Agent API config from the runtime openclaw config.
55
- * Returns null when Agent mode is not configured.
56
- *
57
- * @param {string} [accountId] - Optional account ID. When omitted, first tries
58
- * the streamContext async store, then falls back to the default account.
59
- */
46
+ function resolveEffectiveAccountId(accountId) {
47
+ if (accountId) {
48
+ return accountId;
49
+ }
50
+ const contextual = streamContext.getStore()?.accountId;
51
+ if (contextual) {
52
+ return contextual;
53
+ }
54
+ return resolveDefaultAccountId(getOpenclawConfig());
55
+ }
56
+
60
57
  export function resolveAgentConfig(accountId) {
61
- const config = getOpenclawConfig();
62
- // Determine effective accountId: explicit param > async context > default.
63
- const effectiveId = accountId || streamContext.getStore()?.accountId || undefined;
64
- return resolveAgentConfigForAccount(config, effectiveId);
58
+ return resolveAgentConfigForAccount(getOpenclawConfig(), resolveEffectiveAccountId(accountId));
59
+ }
60
+
61
+ export function resolveAccountConfig(accountId) {
62
+ return resolveAccount(getOpenclawConfig(), resolveEffectiveAccountId(accountId));
65
63
  }
66
64
 
67
- /**
68
- * Resolve a webhook name to a full webhook URL.
69
- * Supports both full URLs and bare keys in config.
70
- * Returns null when the webhook name is not configured.
71
- *
72
- * @param {string} name - Webhook name from the `to` field (e.g. "ops-group")
73
- * @param {string} [accountId] - Optional account ID for multi-account lookup.
74
- * @returns {string|null}
75
- */
76
65
  export function resolveWebhookUrl(name, accountId) {
77
- const config = getOpenclawConfig();
78
- const effectiveId = accountId || streamContext.getStore()?.accountId || undefined;
79
- const account = resolveAccount(config, effectiveId);
80
- const webhooks = account?.config?.webhooks;
81
- if (!webhooks || !webhooks[name]) return null;
82
- const value = webhooks[name];
83
- if (value.startsWith("http")) return value;
66
+ const account = resolveAccountConfig(accountId);
67
+ const value = account?.config?.webhooks?.[name];
68
+ if (!value) {
69
+ return null;
70
+ }
71
+ if (String(value).startsWith("http")) {
72
+ return String(value);
73
+ }
84
74
  return `${getWebhookBotSendUrl()}?key=${value}`;
85
75
  }
76
+
77
+ export function resetStateForTesting() {
78
+ runtimeState.runtime = null;
79
+ runtimeState.openclawConfig = null;
80
+ runtimeState.ensuredDynamicAgentIds = new Set();
81
+ runtimeState.ensureDynamicAgentWriteQueue = Promise.resolve();
82
+ dispatchLocks.clear();
83
+ }
@@ -1,4 +1,5 @@
1
- import { copyFileSync, existsSync, mkdirSync, readdirSync } from "node:fs";
1
+ import { copyFileSync, existsSync, mkdirSync, readdirSync, statSync } from "node:fs";
2
+ import { homedir } from "node:os";
2
3
  import { join } from "node:path";
3
4
  import { logger } from "../logger.js";
4
5
  import { BOOTSTRAP_FILENAMES } from "./constants.js";
@@ -11,6 +12,40 @@ import {
11
12
  setOpenclawConfig,
12
13
  } from "./state.js";
13
14
 
15
+ function expandTilde(p) {
16
+ if (!p || !p.startsWith("~")) return p;
17
+ return join(homedir(), p.slice(1));
18
+ }
19
+
20
+ // --- mtime caches for force-reseed ---
21
+ const _templateMtimeCache = new Map(); // templateDir → { maxMtimeMs, checkedAt }
22
+ const _agentSeedMtimeCache = new Map(); // `${templateDir}::${agentId}` → maxMtimeMs
23
+ const TEMPLATE_MTIME_CACHE_TTL_MS = 60_000;
24
+
25
+ function getTemplateMaxMtimeMs(templateDir) {
26
+ const now = Date.now();
27
+ const cached = _templateMtimeCache.get(templateDir);
28
+ if (cached && now - cached.checkedAt < TEMPLATE_MTIME_CACHE_TTL_MS) {
29
+ return cached.maxMtimeMs;
30
+ }
31
+
32
+ let maxMtimeMs = 0;
33
+ const files = readdirSync(templateDir);
34
+ for (const file of files) {
35
+ if (!BOOTSTRAP_FILENAMES.has(file)) continue;
36
+ const st = statSync(join(templateDir, file));
37
+ if (st.mtimeMs > maxMtimeMs) maxMtimeMs = st.mtimeMs;
38
+ }
39
+
40
+ _templateMtimeCache.set(templateDir, { maxMtimeMs, checkedAt: now });
41
+ return maxMtimeMs;
42
+ }
43
+
44
+ export function clearTemplateMtimeCache({ agentSeedCache = true } = {}) {
45
+ _templateMtimeCache.clear();
46
+ if (agentSeedCache) _agentSeedMtimeCache.clear();
47
+ }
48
+
14
49
  /**
15
50
  * Resolve the agent workspace directory for a given agentId.
16
51
  * Mirrors openclaw core's resolveAgentWorkspaceDir logic for non-default agents:
@@ -42,7 +77,8 @@ export function getWorkspaceTemplateDir(config) {
42
77
  * @param {string} [overrideTemplateDir] - Optional per-account template directory
43
78
  */
44
79
  export function seedAgentWorkspace(agentId, config, overrideTemplateDir) {
45
- const templateDir = overrideTemplateDir || getWorkspaceTemplateDir(config);
80
+ const rawTemplateDir = overrideTemplateDir || getWorkspaceTemplateDir(config);
81
+ const templateDir = expandTilde(rawTemplateDir);
46
82
  if (!templateDir) {
47
83
  return;
48
84
  }
@@ -55,6 +91,15 @@ export function seedAgentWorkspace(agentId, config, overrideTemplateDir) {
55
91
  const workspaceDir = resolveAgentWorkspaceDirLocal(agentId);
56
92
 
57
93
  try {
94
+ const templateMaxMtimeMs = getTemplateMaxMtimeMs(templateDir);
95
+ const cacheKey = `${templateDir}::${agentId}`;
96
+ const lastSyncedMtimeMs = _agentSeedMtimeCache.get(cacheKey) ?? 0;
97
+ const isFirstSeed = lastSyncedMtimeMs === 0;
98
+
99
+ if (templateMaxMtimeMs <= lastSyncedMtimeMs && existsSync(workspaceDir)) {
100
+ return;
101
+ }
102
+
58
103
  mkdirSync(workspaceDir, { recursive: true });
59
104
 
60
105
  const files = readdirSync(templateDir);
@@ -62,13 +107,25 @@ export function seedAgentWorkspace(agentId, config, overrideTemplateDir) {
62
107
  if (!BOOTSTRAP_FILENAMES.has(file)) {
63
108
  continue;
64
109
  }
110
+ const src = join(templateDir, file);
65
111
  const dest = join(workspaceDir, file);
66
112
  if (existsSync(dest)) {
67
- continue;
113
+ if (!isFirstSeed) {
114
+ const srcMtimeMs = statSync(src).mtimeMs;
115
+ const destMtimeMs = statSync(dest).mtimeMs;
116
+ if (srcMtimeMs <= destMtimeMs) {
117
+ continue;
118
+ }
119
+ }
120
+ copyFileSync(src, dest);
121
+ logger.info("WeCom: re-seeded workspace file", { agentId, file, isFirstSeed });
122
+ } else {
123
+ copyFileSync(src, dest);
124
+ logger.info("WeCom: seeded workspace file", { agentId, file });
68
125
  }
69
- copyFileSync(join(templateDir, file), dest);
70
- logger.info("WeCom: seeded workspace file", { agentId, file });
71
126
  }
127
+
128
+ _agentSeedMtimeCache.set(cacheKey, templateMaxMtimeMs);
72
129
  } catch (err) {
73
130
  logger.warn("WeCom: failed to seed agent workspace", {
74
131
  agentId,
@@ -100,8 +157,10 @@ export function upsertAgentIdOnlyEntry(cfg, agentId) {
100
157
  const nextList = [...currentList];
101
158
 
102
159
  // Keep "main" as the explicit default when creating agents.list for the first time.
160
+ // Include heartbeat: {} so main inherits agents.defaults.heartbeat even when
161
+ // dynamic agents also have explicit heartbeat entries (hasExplicitHeartbeatAgents).
103
162
  if (nextList.length === 0) {
104
- nextList.push({ id: "main" });
163
+ nextList.push({ id: "main", heartbeat: {} });
105
164
  existingIds.add("main");
106
165
  changed = true;
107
166
  }
@@ -128,7 +187,7 @@ export async function ensureDynamicAgentListed(agentId, templateDir) {
128
187
 
129
188
  const runtime = getRuntime();
130
189
  const configRuntime = runtime?.config;
131
- if (!configRuntime?.loadConfig || !configRuntime?.writeConfigFile) {
190
+ if (!configRuntime?.writeConfigFile) {
132
191
  return;
133
192
  }
134
193
 
@@ -139,10 +198,26 @@ export async function ensureDynamicAgentListed(agentId, templateDir) {
139
198
  return;
140
199
  }
141
200
 
142
- // Upsert into memory only. Writing to config file is dangerous and can wipe user settings.
201
+ // Upsert into in-memory config so the running gateway sees it immediately.
143
202
  const changed = upsertAgentIdOnlyEntry(openclawConfig, normalizedId);
144
203
  if (changed) {
145
204
  logger.info("WeCom: dynamic agent added to in-memory agents.list", { agentId: normalizedId });
205
+
206
+ // Persist to disk so `openclaw agents list` (separate process) can see
207
+ // the dynamic agent and it survives gateway restarts.
208
+ // Write the mutated in-memory config directly (same pattern as logoutAccount).
209
+ // NOTE: loadConfig() returns runtimeConfigSnapshot in gateway mode — the same
210
+ // object we already mutated above — so a read-modify-write pattern silently
211
+ // skips the write (diskChanged=false). Writing directly avoids this.
212
+ try {
213
+ await configRuntime.writeConfigFile(openclawConfig);
214
+ logger.info("WeCom: dynamic agent persisted to config file", { agentId: normalizedId });
215
+ } catch (writeErr) {
216
+ logger.warn("WeCom: failed to persist dynamic agent to config file", {
217
+ agentId: normalizedId,
218
+ error: writeErr?.message || String(writeErr),
219
+ });
220
+ }
146
221
  }
147
222
 
148
223
  // Always attempt seeding so recreated/cleaned dynamic agents can recover