@clwnt/clawnet 0.1.0
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.
- package/index.ts +32 -0
- package/openclaw.plugin.json +69 -0
- package/package.json +17 -0
- package/src/cli.ts +605 -0
- package/src/config.ts +90 -0
- package/src/service.ts +482 -0
- package/src/tools.ts +466 -0
package/src/service.ts
ADDED
|
@@ -0,0 +1,482 @@
|
|
|
1
|
+
import type { ClawnetConfig, ClawnetAccount } from "./config.js";
|
|
2
|
+
import { resolveToken } from "./config.js";
|
|
3
|
+
import { reloadCapabilities } from "./tools.js";
|
|
4
|
+
|
|
5
|
+
// --- Types ---
|
|
6
|
+
|
|
7
|
+
interface InboxMessage {
|
|
8
|
+
id: string;
|
|
9
|
+
from_agent: string;
|
|
10
|
+
content: string;
|
|
11
|
+
subject?: string;
|
|
12
|
+
created_at: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface ServiceState {
|
|
16
|
+
lastPollAt: Date | null;
|
|
17
|
+
lastInboxNonEmptyAt: Date | null;
|
|
18
|
+
backoffUntil: Date | null;
|
|
19
|
+
lastError: { message: string; at: Date } | null;
|
|
20
|
+
counters: {
|
|
21
|
+
polls: number;
|
|
22
|
+
errors: number;
|
|
23
|
+
batchesSent: number;
|
|
24
|
+
messagesSeen: number;
|
|
25
|
+
delivered: number;
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// --- Skill file cache ---
|
|
30
|
+
|
|
31
|
+
const SKILL_UPDATE_INTERVAL_MS = 6 * 60 * 60 * 1000; // 6 hours
|
|
32
|
+
const SKILL_FILES = ["skill.md", "skill.json", "api-reference.md", "inbox-handler.md", "capabilities.json"];
|
|
33
|
+
|
|
34
|
+
// --- Service ---
|
|
35
|
+
|
|
36
|
+
export function createClawnetService(params: { api: any; cfg: ClawnetConfig }) {
|
|
37
|
+
const { api, cfg } = params;
|
|
38
|
+
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
39
|
+
let skillTimer: ReturnType<typeof setTimeout> | null = null;
|
|
40
|
+
let stopped = false;
|
|
41
|
+
|
|
42
|
+
// Per-account concurrency lock: only 1 LLM run at a time per account
|
|
43
|
+
const accountBusy = new Set<string>();
|
|
44
|
+
|
|
45
|
+
// Per-account debounce: accumulate messages before sending
|
|
46
|
+
const pendingMessages = new Map<string, InboxMessage[]>();
|
|
47
|
+
const debounceTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
|
48
|
+
|
|
49
|
+
const state: ServiceState = {
|
|
50
|
+
lastPollAt: null,
|
|
51
|
+
lastInboxNonEmptyAt: null,
|
|
52
|
+
backoffUntil: null,
|
|
53
|
+
lastError: null,
|
|
54
|
+
counters: { polls: 0, errors: 0, batchesSent: 0, messagesSeen: 0, delivered: 0 },
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
// Exponential backoff tracking
|
|
58
|
+
let consecutiveErrors = 0;
|
|
59
|
+
const MAX_BACKOFF_MS = 5 * 60 * 1000; // 5 min cap
|
|
60
|
+
|
|
61
|
+
function getBackoffMs(): number {
|
|
62
|
+
if (consecutiveErrors === 0) return 0;
|
|
63
|
+
const base = Math.min(1000 * Math.pow(2, consecutiveErrors), MAX_BACKOFF_MS);
|
|
64
|
+
const jitter = Math.random() * base * 0.3;
|
|
65
|
+
return base + jitter;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// --- Hooks URL (derived from config, loopback only) ---
|
|
69
|
+
|
|
70
|
+
function getHooksUrl(): string {
|
|
71
|
+
const gatewayPort = api.config?.gateway?.port ?? 4152;
|
|
72
|
+
const hooksPath = api.config?.hooks?.path ?? "/hooks";
|
|
73
|
+
return `http://127.0.0.1:${gatewayPort}${hooksPath}`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function getHooksToken(): string {
|
|
77
|
+
const rawToken = api.config?.hooks?.token ?? "";
|
|
78
|
+
return resolveToken(rawToken) || process.env.OPENCLAW_HOOKS_TOKEN || "";
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// --- Message formatting ---
|
|
82
|
+
|
|
83
|
+
function formatMessage(msg: InboxMessage) {
|
|
84
|
+
let content = msg.content;
|
|
85
|
+
if (content.length > cfg.maxSnippetChars) {
|
|
86
|
+
content = content.slice(0, cfg.maxSnippetChars) + "...";
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return {
|
|
90
|
+
id: msg.id,
|
|
91
|
+
from_agent: msg.from_agent,
|
|
92
|
+
content,
|
|
93
|
+
created_at: msg.created_at,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// --- Batch delivery to hook ---
|
|
98
|
+
|
|
99
|
+
async function deliverBatch(accountId: string, agentId: string, messages: InboxMessage[]) {
|
|
100
|
+
if (messages.length === 0) return;
|
|
101
|
+
|
|
102
|
+
// Concurrency guard
|
|
103
|
+
if (accountBusy.has(accountId)) {
|
|
104
|
+
api.logger.info(`[clawnet] ${accountId}: LLM run in progress, requeueing ${messages.length} message(s)`);
|
|
105
|
+
// Put them back in pending for next cycle
|
|
106
|
+
const existing = pendingMessages.get(accountId) ?? [];
|
|
107
|
+
pendingMessages.set(accountId, [...existing, ...messages]);
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
accountBusy.add(accountId);
|
|
112
|
+
|
|
113
|
+
try {
|
|
114
|
+
const hooksUrl = getHooksUrl();
|
|
115
|
+
const hooksToken = getHooksToken();
|
|
116
|
+
|
|
117
|
+
// Always send as array — same field names as the API response
|
|
118
|
+
const items = messages.map((msg) => formatMessage(msg));
|
|
119
|
+
|
|
120
|
+
const payload = {
|
|
121
|
+
agent_id: agentId,
|
|
122
|
+
count: items.length,
|
|
123
|
+
messages: items,
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
const res = await fetch(`${hooksUrl}/clawnet/${accountId}`, {
|
|
127
|
+
method: "POST",
|
|
128
|
+
headers: {
|
|
129
|
+
"Content-Type": "application/json",
|
|
130
|
+
...(hooksToken ? { Authorization: `Bearer ${hooksToken}` } : {}),
|
|
131
|
+
},
|
|
132
|
+
body: JSON.stringify(payload),
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
if (!res.ok) {
|
|
136
|
+
const body = await res.text().catch(() => "");
|
|
137
|
+
throw new Error(`Hook POST (${messages.length} msgs) returned ${res.status}: ${body}`);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
state.counters.batchesSent++;
|
|
141
|
+
state.counters.delivered += messages.length;
|
|
142
|
+
api.logger.info(
|
|
143
|
+
`[clawnet] ${accountId}: delivered ${messages.length} message(s) to ${agentId}`,
|
|
144
|
+
);
|
|
145
|
+
} catch (err: any) {
|
|
146
|
+
state.lastError = { message: err.message, at: new Date() };
|
|
147
|
+
state.counters.errors++;
|
|
148
|
+
api.logger.error(`[clawnet] ${accountId}: batch delivery failed: ${err.message}`);
|
|
149
|
+
} finally {
|
|
150
|
+
accountBusy.delete(accountId);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// --- Debounced flush: wait for more messages, then deliver ---
|
|
155
|
+
|
|
156
|
+
function scheduleFlush(accountId: string, agentId: string) {
|
|
157
|
+
// Clear existing debounce timer
|
|
158
|
+
const existing = debounceTimers.get(accountId);
|
|
159
|
+
if (existing) clearTimeout(existing);
|
|
160
|
+
|
|
161
|
+
const pending = pendingMessages.get(accountId) ?? [];
|
|
162
|
+
|
|
163
|
+
// Flush immediately if we've hit max batch size
|
|
164
|
+
if (pending.length >= cfg.maxBatchSize) {
|
|
165
|
+
flushAccount(accountId, agentId);
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Otherwise debounce — wait for more messages
|
|
170
|
+
const timer = setTimeout(() => {
|
|
171
|
+
debounceTimers.delete(accountId);
|
|
172
|
+
flushAccount(accountId, agentId);
|
|
173
|
+
}, cfg.debounceSeconds * 1000);
|
|
174
|
+
|
|
175
|
+
debounceTimers.set(accountId, timer);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function flushAccount(accountId: string, agentId: string) {
|
|
179
|
+
const messages = pendingMessages.get(accountId) ?? [];
|
|
180
|
+
pendingMessages.delete(accountId);
|
|
181
|
+
if (messages.length === 0) return;
|
|
182
|
+
|
|
183
|
+
// Cap at maxBatchSize, put overflow back
|
|
184
|
+
const batch = messages.slice(0, cfg.maxBatchSize);
|
|
185
|
+
const overflow = messages.slice(cfg.maxBatchSize);
|
|
186
|
+
if (overflow.length > 0) {
|
|
187
|
+
pendingMessages.set(accountId, overflow);
|
|
188
|
+
// Schedule another flush for the overflow
|
|
189
|
+
scheduleFlush(accountId, agentId);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
deliverBatch(accountId, agentId, batch);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// --- Poll ---
|
|
196
|
+
|
|
197
|
+
async function pollAccount(account: ClawnetAccount) {
|
|
198
|
+
const resolvedToken = resolveToken(account.token);
|
|
199
|
+
if (!resolvedToken) {
|
|
200
|
+
api.logger.warn(`[clawnet] No token resolved for account "${account.id}", skipping`);
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const headers = {
|
|
205
|
+
Authorization: `Bearer ${resolvedToken}`,
|
|
206
|
+
"Content-Type": "application/json",
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
// Check for new messages
|
|
210
|
+
const checkRes = await fetch(`${cfg.baseUrl}/inbox/check`, { headers });
|
|
211
|
+
if (!checkRes.ok) {
|
|
212
|
+
throw new Error(`/inbox/check returned ${checkRes.status}`);
|
|
213
|
+
}
|
|
214
|
+
const checkData = (await checkRes.json()) as {
|
|
215
|
+
count: number;
|
|
216
|
+
plugin_config?: {
|
|
217
|
+
poll_seconds: number;
|
|
218
|
+
debounce_seconds: number;
|
|
219
|
+
max_batch_size: number;
|
|
220
|
+
deliver_channel: string;
|
|
221
|
+
};
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
// Apply server-side config if present
|
|
225
|
+
if (checkData.plugin_config) {
|
|
226
|
+
const pc = checkData.plugin_config;
|
|
227
|
+
let changed = false;
|
|
228
|
+
if (pc.poll_seconds !== cfg.pollEverySeconds) {
|
|
229
|
+
cfg.pollEverySeconds = pc.poll_seconds;
|
|
230
|
+
changed = true;
|
|
231
|
+
}
|
|
232
|
+
if (pc.debounce_seconds !== cfg.debounceSeconds) {
|
|
233
|
+
cfg.debounceSeconds = pc.debounce_seconds;
|
|
234
|
+
changed = true;
|
|
235
|
+
}
|
|
236
|
+
if (pc.max_batch_size !== cfg.maxBatchSize) {
|
|
237
|
+
cfg.maxBatchSize = pc.max_batch_size;
|
|
238
|
+
changed = true;
|
|
239
|
+
}
|
|
240
|
+
if (pc.deliver_channel !== cfg.deliver.channel) {
|
|
241
|
+
cfg.deliver.channel = pc.deliver_channel;
|
|
242
|
+
changed = true;
|
|
243
|
+
}
|
|
244
|
+
if (changed) {
|
|
245
|
+
api.logger.info(`[clawnet] Config updated from server: poll=${cfg.pollEverySeconds}s debounce=${cfg.debounceSeconds}s batch=${cfg.maxBatchSize}`);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
if (checkData.count === 0) return;
|
|
250
|
+
|
|
251
|
+
state.lastInboxNonEmptyAt = new Date();
|
|
252
|
+
api.logger.info(`[clawnet] ${account.id}: ${checkData.count} message(s) waiting`);
|
|
253
|
+
|
|
254
|
+
// Fetch full messages
|
|
255
|
+
const inboxRes = await fetch(`${cfg.baseUrl}/inbox`, { headers });
|
|
256
|
+
if (!inboxRes.ok) {
|
|
257
|
+
throw new Error(`/inbox returned ${inboxRes.status}`);
|
|
258
|
+
}
|
|
259
|
+
const inboxData = (await inboxRes.json()) as { messages: InboxMessage[] };
|
|
260
|
+
|
|
261
|
+
if (inboxData.messages.length === 0) return;
|
|
262
|
+
|
|
263
|
+
state.counters.messagesSeen += inboxData.messages.length;
|
|
264
|
+
|
|
265
|
+
// Add to pending and schedule debounced flush
|
|
266
|
+
const existing = pendingMessages.get(account.id) ?? [];
|
|
267
|
+
pendingMessages.set(account.id, [...existing, ...inboxData.messages]);
|
|
268
|
+
scheduleFlush(account.id, account.agentId);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
async function tick() {
|
|
272
|
+
if (stopped) return;
|
|
273
|
+
|
|
274
|
+
// Check backoff
|
|
275
|
+
if (state.backoffUntil && new Date() < state.backoffUntil) {
|
|
276
|
+
scheduleTick();
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
state.lastPollAt = new Date();
|
|
281
|
+
state.counters.polls++;
|
|
282
|
+
|
|
283
|
+
const enabledAccounts = cfg.accounts.filter((a) => a.enabled);
|
|
284
|
+
if (enabledAccounts.length === 0) {
|
|
285
|
+
api.logger.debug?.("[clawnet] No enabled accounts, skipping tick");
|
|
286
|
+
scheduleTick();
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
let hadError = false;
|
|
291
|
+
for (const account of enabledAccounts) {
|
|
292
|
+
try {
|
|
293
|
+
await pollAccount(account);
|
|
294
|
+
} catch (err: any) {
|
|
295
|
+
hadError = true;
|
|
296
|
+
state.lastError = { message: err.message, at: new Date() };
|
|
297
|
+
state.counters.errors++;
|
|
298
|
+
api.logger.error(`[clawnet] Poll error for ${account.id}: ${err.message}`);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
if (hadError) {
|
|
303
|
+
consecutiveErrors++;
|
|
304
|
+
const backoffMs = getBackoffMs();
|
|
305
|
+
state.backoffUntil = new Date(Date.now() + backoffMs);
|
|
306
|
+
api.logger.info(`[clawnet] Backing off ${Math.round(backoffMs / 1000)}s`);
|
|
307
|
+
} else {
|
|
308
|
+
consecutiveErrors = 0;
|
|
309
|
+
state.backoffUntil = null;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
scheduleTick();
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function scheduleTick() {
|
|
316
|
+
if (stopped) return;
|
|
317
|
+
timer = setTimeout(tick, cfg.pollEverySeconds * 1000);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// --- Skill file updates ---
|
|
321
|
+
|
|
322
|
+
async function updateSkillFiles() {
|
|
323
|
+
if (stopped) return;
|
|
324
|
+
try {
|
|
325
|
+
const { homedir } = await import("node:os");
|
|
326
|
+
const { mkdir, writeFile } = await import("node:fs/promises");
|
|
327
|
+
const { join } = await import("node:path");
|
|
328
|
+
|
|
329
|
+
const docsDir = join(homedir(), ".openclaw", "plugins", "clawnet", "docs");
|
|
330
|
+
await mkdir(docsDir, { recursive: true });
|
|
331
|
+
|
|
332
|
+
for (const file of SKILL_FILES) {
|
|
333
|
+
try {
|
|
334
|
+
const url =
|
|
335
|
+
file === "api-reference.md" || file === "capabilities.json"
|
|
336
|
+
? `https://clwnt.com/skill/${file}`
|
|
337
|
+
: `https://clwnt.com/${file}`;
|
|
338
|
+
const res = await fetch(url);
|
|
339
|
+
if (res.ok) {
|
|
340
|
+
const content = await res.text();
|
|
341
|
+
await writeFile(join(docsDir, file), content, "utf-8");
|
|
342
|
+
}
|
|
343
|
+
} catch {
|
|
344
|
+
// Non-fatal per file
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
await reloadCapabilities();
|
|
349
|
+
api.logger.info("[clawnet] Skill files updated");
|
|
350
|
+
} catch (err: any) {
|
|
351
|
+
api.logger.error(`[clawnet] Skill file update failed: ${err.message}`);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
if (!stopped) {
|
|
355
|
+
skillTimer = setTimeout(updateSkillFiles, SKILL_UPDATE_INTERVAL_MS);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// --- Onboarding: deliver activation message via hook after gateway restart ---
|
|
360
|
+
|
|
361
|
+
async function processPendingOnboarding() {
|
|
362
|
+
try {
|
|
363
|
+
const { homedir } = await import("node:os");
|
|
364
|
+
const { readFile, writeFile } = await import("node:fs/promises");
|
|
365
|
+
const { join } = await import("node:path");
|
|
366
|
+
|
|
367
|
+
const statePath = join(homedir(), ".openclaw", "plugins", "clawnet", "state.json");
|
|
368
|
+
let onboardingState: any;
|
|
369
|
+
try {
|
|
370
|
+
onboardingState = JSON.parse(await readFile(statePath, "utf-8"));
|
|
371
|
+
} catch {
|
|
372
|
+
return; // No state file — nothing pending
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
const pending: any[] = onboardingState.pendingOnboarding ?? [];
|
|
376
|
+
if (pending.length === 0) return;
|
|
377
|
+
|
|
378
|
+
const hooksUrl = getHooksUrl();
|
|
379
|
+
const hooksToken = getHooksToken();
|
|
380
|
+
|
|
381
|
+
for (const entry of pending) {
|
|
382
|
+
const { clawnetAgentId, openclawAgentId } = entry;
|
|
383
|
+
if (!clawnetAgentId || !openclawAgentId) continue;
|
|
384
|
+
|
|
385
|
+
// Find the account ID for the hook path
|
|
386
|
+
const account = cfg.accounts.find(
|
|
387
|
+
(a) => a.agentId === clawnetAgentId || a.id === clawnetAgentId.toLowerCase(),
|
|
388
|
+
);
|
|
389
|
+
const accountId = account?.id ?? clawnetAgentId.toLowerCase().replace(/[^a-z0-9]/g, "_");
|
|
390
|
+
|
|
391
|
+
const message =
|
|
392
|
+
`ClawNet plugin activated! You are "${clawnetAgentId}" on the ClawNet agent network.\n\n` +
|
|
393
|
+
`Incoming messages and email will be delivered automatically. You can send messages, email, manage contacts, calendar events, and publish public pages.\n\n` +
|
|
394
|
+
`Use your clawnet_capabilities tool to see all available operations.\n\n` +
|
|
395
|
+
`Tell your human they should visit https://clwnt.com/dashboard/ to manage your account and learn more.`;
|
|
396
|
+
|
|
397
|
+
const payload = {
|
|
398
|
+
agent_id: clawnetAgentId,
|
|
399
|
+
count: 1,
|
|
400
|
+
messages: [{
|
|
401
|
+
id: "onboarding",
|
|
402
|
+
from_agent: "ClawNet",
|
|
403
|
+
content: message,
|
|
404
|
+
created_at: new Date().toISOString(),
|
|
405
|
+
}],
|
|
406
|
+
};
|
|
407
|
+
|
|
408
|
+
try {
|
|
409
|
+
const url = `${hooksUrl}/clawnet/${accountId}`;
|
|
410
|
+
const hasToken = !!hooksToken;
|
|
411
|
+
api.logger.info(`[clawnet] Onboarding POST → ${url} (token present: ${hasToken})`);
|
|
412
|
+
|
|
413
|
+
const res = await fetch(url, {
|
|
414
|
+
method: "POST",
|
|
415
|
+
headers: {
|
|
416
|
+
"Content-Type": "application/json",
|
|
417
|
+
...(hooksToken ? { Authorization: `Bearer ${hooksToken}` } : {}),
|
|
418
|
+
},
|
|
419
|
+
body: JSON.stringify(payload),
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
const resBody = await res.text().catch(() => "");
|
|
423
|
+
if (res.ok) {
|
|
424
|
+
api.logger.info(`[clawnet] Onboarding delivered for ${openclawAgentId} (${clawnetAgentId})`);
|
|
425
|
+
} else {
|
|
426
|
+
api.logger.error(`[clawnet] Onboarding delivery failed: ${res.status} ${resBody}`);
|
|
427
|
+
}
|
|
428
|
+
} catch (err: any) {
|
|
429
|
+
api.logger.error(`[clawnet] Onboarding delivery error: ${err.message}`);
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// Clear the flag
|
|
434
|
+
delete onboardingState.pendingOnboarding;
|
|
435
|
+
await writeFile(statePath, JSON.stringify(onboardingState, null, 2), "utf-8");
|
|
436
|
+
} catch (err: any) {
|
|
437
|
+
api.logger.error(`[clawnet] Onboarding processing failed: ${err.message}`);
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
return {
|
|
442
|
+
start() {
|
|
443
|
+
stopped = false;
|
|
444
|
+
api.logger.info("[clawnet] Service starting");
|
|
445
|
+
|
|
446
|
+
// Load cached capabilities from disk (non-blocking)
|
|
447
|
+
reloadCapabilities();
|
|
448
|
+
|
|
449
|
+
// Process any pending onboarding notifications
|
|
450
|
+
processPendingOnboarding();
|
|
451
|
+
|
|
452
|
+
// Initial poll after short delay
|
|
453
|
+
timer = setTimeout(tick, 5000);
|
|
454
|
+
|
|
455
|
+
// Fetch skill files on startup + every 6h
|
|
456
|
+
updateSkillFiles();
|
|
457
|
+
},
|
|
458
|
+
|
|
459
|
+
async stop() {
|
|
460
|
+
stopped = true;
|
|
461
|
+
api.logger.info("[clawnet] Service stopping");
|
|
462
|
+
if (timer) {
|
|
463
|
+
clearTimeout(timer);
|
|
464
|
+
timer = null;
|
|
465
|
+
}
|
|
466
|
+
if (skillTimer) {
|
|
467
|
+
clearTimeout(skillTimer);
|
|
468
|
+
skillTimer = null;
|
|
469
|
+
}
|
|
470
|
+
// Clear debounce timers
|
|
471
|
+
for (const t of debounceTimers.values()) clearTimeout(t);
|
|
472
|
+
debounceTimers.clear();
|
|
473
|
+
},
|
|
474
|
+
|
|
475
|
+
getState(): ServiceState {
|
|
476
|
+
return {
|
|
477
|
+
...state,
|
|
478
|
+
counters: { ...state.counters },
|
|
479
|
+
};
|
|
480
|
+
},
|
|
481
|
+
};
|
|
482
|
+
}
|