@controlflow-ai/daemon 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.
Files changed (67) hide show
  1. package/README.md +360 -0
  2. package/bin/console.js +2 -0
  3. package/bin/daemon.js +2 -0
  4. package/bin/pal.js +2 -0
  5. package/bin/server.js +2 -0
  6. package/package.json +31 -0
  7. package/src/agent-runtime.ts +285 -0
  8. package/src/app.ts +745 -0
  9. package/src/args.ts +54 -0
  10. package/src/artifacts.ts +85 -0
  11. package/src/cli.ts +284 -0
  12. package/src/client.ts +310 -0
  13. package/src/coco.ts +52 -0
  14. package/src/codex.ts +41 -0
  15. package/src/coding-agent-runtime.ts +20 -0
  16. package/src/config.ts +106 -0
  17. package/src/console.ts +349 -0
  18. package/src/daemon-client.ts +91 -0
  19. package/src/daemon.ts +580 -0
  20. package/src/db.ts +2830 -0
  21. package/src/failure-message.ts +17 -0
  22. package/src/format.ts +13 -0
  23. package/src/http.ts +55 -0
  24. package/src/lark/agent-runtime.ts +142 -0
  25. package/src/lark/cli.ts +549 -0
  26. package/src/lark/credentials.ts +105 -0
  27. package/src/lark/daemon-integration.ts +108 -0
  28. package/src/lark/dispatcher.ts +374 -0
  29. package/src/lark/event-router.ts +329 -0
  30. package/src/lark/inbound-events.ts +131 -0
  31. package/src/lark/server-integration.ts +445 -0
  32. package/src/lark/setup.ts +326 -0
  33. package/src/lark/ws-daemon.ts +224 -0
  34. package/src/lark-fixture-diagnostics.ts +56 -0
  35. package/src/lark-fixture.ts +277 -0
  36. package/src/local-api.ts +155 -0
  37. package/src/local-auth.ts +45 -0
  38. package/src/migrations/001_initial.ts +61 -0
  39. package/src/migrations/002_daemon_deliveries.ts +52 -0
  40. package/src/migrations/003_sessions_runs.ts +49 -0
  41. package/src/migrations/004_message_idempotency.ts +21 -0
  42. package/src/migrations/005_artifacts.ts +24 -0
  43. package/src/migrations/006_lark_channel_foundation.ts +119 -0
  44. package/src/migrations/007_agents_a0.ts +17 -0
  45. package/src/migrations/008_b0_chat_history.ts +31 -0
  46. package/src/migrations/009_b0_transcript_ingest_seq.ts +35 -0
  47. package/src/migrations/010_b0_transcript_shadow_external_ids.ts +32 -0
  48. package/src/migrations/011_b0_channel_conversation_audit_only.ts +27 -0
  49. package/src/migrations/012_b0_cross_conversation_invariant.ts +45 -0
  50. package/src/migrations/013_b1_0_eng_inbound_raw_events.ts +56 -0
  51. package/src/migrations/014_agents_runtime.ts +10 -0
  52. package/src/migrations/015_agent_runtime_sessions.ts +15 -0
  53. package/src/migrations/016_room_participants.ts +27 -0
  54. package/src/migrations/017_unified_room_delivery.ts +203 -0
  55. package/src/migrations/018_room_display_names.ts +36 -0
  56. package/src/migrations/019_computer_connections.ts +63 -0
  57. package/src/migrations/020_computer_agent_assignments.ts +20 -0
  58. package/src/migrations/021_provider_identity_bindings.ts +32 -0
  59. package/src/migrations.ts +85 -0
  60. package/src/neeko.ts +23 -0
  61. package/src/provider-identity.ts +40 -0
  62. package/src/runtime-registry.ts +41 -0
  63. package/src/server-auth.ts +13 -0
  64. package/src/server.ts +63 -0
  65. package/src/token-file.ts +57 -0
  66. package/src/types.ts +408 -0
  67. package/src/web.ts +565 -0
@@ -0,0 +1,445 @@
1
+ import { MessageStore } from '../db.js';
2
+ import { boundAgents, loadLarkCredentials, type LarkCredential } from './credentials.js';
3
+ import { extractMentionOpenIds, ingestLarkMessage, type LarkMessageEnvelope } from './event-router.js';
4
+ import type { ReceivePolicy } from './dispatcher.js';
5
+ import { addMessageReaction, createLarkApiClient, getChatInfo, getChatMembers, startLarkDaemon, type LarkDaemonHandle } from './ws-daemon.js';
6
+
7
+ export interface LarkServerIntegrationOptions {
8
+ /** Shared MessageStore for DB operations (caller owns lifecycle). */
9
+ store: MessageStore;
10
+ policy?: ReceivePolicy;
11
+ botOpenIdByApp?: Map<string, string>;
12
+ logger?: Partial<Pick<Console, 'log' | 'warn' | 'error'>>;
13
+ configPath?: string;
14
+ startDaemon?: typeof startLarkDaemon;
15
+ }
16
+
17
+ export type LarkReloadResult =
18
+ | { ok: true; bots: string[]; started: string[]; stopped: string[]; restarted: string[] }
19
+ | { ok: false; bots: string[]; error: string };
20
+
21
+ export interface LarkServerIntegrationResult {
22
+ handles: LarkDaemonHandle[];
23
+ reload(): LarkReloadResult;
24
+ stop(): void;
25
+ }
26
+
27
+ /**
28
+ * Load all Lark bots from credentials file.
29
+ */
30
+ export function loadAllLarkBots(configPath?: string): LarkCredential[] {
31
+ const store = loadLarkCredentials(configPath);
32
+ return store.bots;
33
+ }
34
+
35
+ function textContent(envelope: LarkMessageEnvelope): string {
36
+ const raw = envelope.message?.content;
37
+ if (!raw) return '';
38
+ try {
39
+ const parsed = JSON.parse(raw) as { text?: unknown };
40
+ return typeof parsed.text === 'string' ? parsed.text : raw;
41
+ } catch {
42
+ return raw;
43
+ }
44
+ }
45
+
46
+ function mentionsBotLabel(envelope: LarkMessageEnvelope, bot: LarkCredential): boolean {
47
+ if (!bot.label) return false;
48
+ const expected = bot.label.trim().toLowerCase();
49
+ if (!expected) return false;
50
+ if (textContent(envelope).toLowerCase().includes(`@${expected}`)) return true;
51
+ return (envelope.message?.mentions ?? []).some((mention) => mention.name?.trim().toLowerCase() === expected);
52
+ }
53
+
54
+
55
+ function configuredOwnerUnionId(): string | null {
56
+ return process.env.PAL_OWNER_LARK_UNION_ID?.trim() || process.env.PAL_LARK_OWNER_UNION_ID?.trim() || null;
57
+ }
58
+
59
+ function senderUnionId(envelope: LarkMessageEnvelope): string | null {
60
+ return envelope.sender?.sender_id?.union_id?.trim() || null;
61
+ }
62
+
63
+ function senderOpenId(envelope: LarkMessageEnvelope): string | null {
64
+ return envelope.sender?.sender_id?.open_id?.trim() || null;
65
+ }
66
+
67
+ function deliveryReactionEmojiType(): string {
68
+ return process.env.PAL_LARK_ACTION_REACTION_EMOJI?.trim() || 'Typing';
69
+ }
70
+
71
+ function apiErrorMessage(err: unknown): string {
72
+ if (err instanceof Error) {
73
+ const responseData = (err as Error & { response?: { data?: unknown } }).response?.data;
74
+ if (responseData && typeof responseData === 'object') {
75
+ const data = responseData as { code?: unknown; msg?: unknown; message?: unknown };
76
+ const code = typeof data.code === 'number' || typeof data.code === 'string' ? `code=${data.code} ` : '';
77
+ const msg = typeof data.msg === 'string' ? data.msg : typeof data.message === 'string' ? data.message : err.message;
78
+ return `${code}${msg}`;
79
+ }
80
+ return err.message;
81
+ }
82
+ return String(err);
83
+ }
84
+
85
+ async function syncLarkRoomMetadata(input: {
86
+ bot: LarkCredential;
87
+ chatId: string;
88
+ roomId: string;
89
+ store: MessageStore;
90
+ botOpenId?: string | null;
91
+ agent?: string | null;
92
+ log: Pick<Console, 'log' | 'warn'>;
93
+ }): Promise<void> {
94
+ const client = createLarkApiClient(input.bot.appId, input.bot.appSecret);
95
+ try {
96
+ const info = await getChatInfo({ client, chatId: input.chatId });
97
+ const name = info.name?.trim();
98
+ if (name) input.store.updateRoomDisplayName(input.roomId, name);
99
+ } catch (err) {
100
+ input.log.warn(`[lark/${input.bot.appId}] chat name sync failed chat=${input.chatId}: ${apiErrorMessage(err)}`);
101
+ }
102
+ if (input.botOpenId) {
103
+ const account = input.store.getChannelAccountByAppId(input.bot.appId);
104
+ const participantId = account?.provider_account_id
105
+ ? input.store.getOrCreateProviderIdentity({
106
+ provider: 'lark',
107
+ providerAccountId: account.provider_account_id,
108
+ externalType: 'bot',
109
+ externalId: input.botOpenId,
110
+ displayName: input.bot.label ?? input.agent ?? input.bot.appId,
111
+ }).identity.stable_handle
112
+ : input.botOpenId;
113
+ input.store.upsertRoomParticipant({
114
+ roomId: input.roomId,
115
+ participantId,
116
+ kind: 'bot',
117
+ displayName: input.bot.label ?? input.agent ?? input.bot.appId,
118
+ source: 'known_bot',
119
+ });
120
+ }
121
+ try {
122
+ const result = await getChatMembers({ client, chatId: input.chatId });
123
+ const account = input.store.getChannelAccountByAppId(input.bot.appId);
124
+ for (const member of result.members) {
125
+ const participantId = account?.provider_account_id
126
+ ? input.store.getOrCreateProviderIdentity({
127
+ provider: 'lark',
128
+ providerAccountId: account.provider_account_id,
129
+ externalType: 'user',
130
+ externalId: member.memberId,
131
+ displayName: member.name,
132
+ }).identity.stable_handle
133
+ : member.memberId;
134
+ input.store.upsertRoomParticipant({
135
+ roomId: input.roomId,
136
+ participantId,
137
+ kind: 'user',
138
+ displayName: member.name,
139
+ source: 'lark_member_api',
140
+ });
141
+ }
142
+ input.log.log(`[lark/${input.bot.appId}] synced chat metadata chat=${input.chatId} members=${result.members.length}`);
143
+ } catch (err) {
144
+ input.log.warn(`[lark/${input.bot.appId}] chat member sync failed chat=${input.chatId}: ${apiErrorMessage(err)}`);
145
+ }
146
+ }
147
+
148
+ async function syncExistingLarkGroupRooms(input: {
149
+ bots: LarkCredential[];
150
+ store: MessageStore;
151
+ botOpenIdByApp: Map<string, string>;
152
+ log: Pick<Console, 'log' | 'warn'>;
153
+ }): Promise<void> {
154
+ const botsByApp = new Map(input.bots.map((bot) => [bot.appId, bot]));
155
+ const seen = new Set<string>();
156
+ for (const mapping of input.store.listLarkGroupRoomMappings()) {
157
+ const key = `${mapping.app_id}:${mapping.external_chat_id}:${mapping.room_id}`;
158
+ if (seen.has(key)) continue;
159
+ seen.add(key);
160
+ const bot = botsByApp.get(mapping.app_id);
161
+ if (!bot) continue;
162
+ await syncLarkRoomMetadata({
163
+ bot,
164
+ chatId: mapping.external_chat_id,
165
+ roomId: mapping.room_id,
166
+ store: input.store,
167
+ botOpenId: input.botOpenIdByApp.get(bot.appId) ?? bot.botOpenId ?? mapping.bot_open_id,
168
+ agent: mapping.agent,
169
+ log: input.log,
170
+ });
171
+ }
172
+ }
173
+
174
+ async function resolveSenderUnionId(input: {
175
+ bot: LarkCredential;
176
+ envelope: LarkMessageEnvelope;
177
+ store: MessageStore;
178
+ }): Promise<{ status: 'resolved'; unionId: string } | { status: 'missing_open_id' } | { status: 'pending'; reason: string }> {
179
+ const eventUnionId = senderUnionId(input.envelope);
180
+ if (eventUnionId) return { status: 'resolved', unionId: eventUnionId };
181
+
182
+ const openId = senderOpenId(input.envelope);
183
+ if (!openId) return { status: 'missing_open_id' };
184
+
185
+ const cached = input.store.getCachedProviderUnionId({ provider: 'lark', appId: input.bot.appId, openId });
186
+ if (cached) return { status: 'resolved', unionId: cached };
187
+
188
+ try {
189
+ const client = createLarkApiClient(input.bot.appId, input.bot.appSecret);
190
+ const response = await client.contact.user.batch({
191
+ params: { user_ids: [openId], user_id_type: 'open_id' },
192
+ });
193
+ const user = response.data?.items?.[0];
194
+ const unionId = user?.union_id?.trim();
195
+ if (!unionId) return { status: 'pending', reason: 'union_id_not_returned' };
196
+ input.store.cacheProviderUnionId({ provider: 'lark', appId: input.bot.appId, openId, unionId });
197
+ return { status: 'resolved', unionId };
198
+ } catch (err) {
199
+ return { status: 'pending', reason: err instanceof Error ? err.message : String(err) };
200
+ }
201
+ }
202
+
203
+ /**
204
+ * Start Lark WS connections on the Server.
205
+ * When a message arrives, it is ingested into the DB and a delivery is created
206
+ * for the bound agent.
207
+ *
208
+ * This replaces the Daemon-side lark integration. The Server is now the
209
+ * single entry point for all Lark messages.
210
+ */
211
+ export function startLarkOnServer(options: LarkServerIntegrationOptions): LarkServerIntegrationResult {
212
+ const log = {
213
+ log: options.logger?.log ?? console.log,
214
+ warn: options.logger?.warn ?? console.warn,
215
+ error: options.logger?.error ?? console.error,
216
+ };
217
+ const policy = options.policy ?? 'every';
218
+ const msgStore = options.store;
219
+ const botOpenIdByApp = options.botOpenIdByApp ?? new Map<string, string>();
220
+ const startDaemon = options.startDaemon ?? startLarkDaemon;
221
+ const active = new Map<string, { bot: LarkCredential; handle: LarkDaemonHandle; fingerprint: string }>();
222
+
223
+ const activeBots = (): LarkCredential[] => Array.from(active.values()).map((entry) => entry.bot);
224
+ const activeHandles = (): LarkDaemonHandle[] => Array.from(active.values()).map((entry) => entry.handle);
225
+ const activeAppIds = (): string[] => activeHandles().map((handle) => handle.appId);
226
+ const credentialFingerprint = (bot: LarkCredential): string => JSON.stringify({
227
+ appId: bot.appId,
228
+ appSecret: bot.appSecret,
229
+ label: bot.label ?? null,
230
+ agent: bot.agent ?? null,
231
+ botOpenId: bot.botOpenId ?? null,
232
+ });
233
+
234
+ const loadBotsForReload = (): LarkCredential[] => {
235
+ const bots = loadAllLarkBots(options.configPath);
236
+ const seen = new Set<string>();
237
+ for (const bot of bots) {
238
+ if (seen.has(bot.appId)) throw new Error(`duplicate Lark appId in config: ${bot.appId}`);
239
+ seen.add(bot.appId);
240
+ }
241
+ return bots;
242
+ };
243
+
244
+ const afterSuccessfulScan = (bots: LarkCredential[]): void => {
245
+ const backfilledDmNames = msgStore.backfillLarkDmDisplayNames();
246
+ if (backfilledDmNames > 0) {
247
+ log.log(`[lark] backfilled ${backfilledDmNames} DM display name(s)`);
248
+ }
249
+ void syncExistingLarkGroupRooms({ bots, store: msgStore, botOpenIdByApp, log }).catch((err) => {
250
+ log.warn(`[lark] startup chat metadata sync failed: ${err instanceof Error ? err.message : String(err)}`);
251
+ });
252
+ };
253
+
254
+ const startBot = (bot: LarkCredential): LarkDaemonHandle => startDaemon({
255
+ appId: bot.appId,
256
+ appSecret: bot.appSecret,
257
+ db: msgStore.db,
258
+ logger: { info: log.log, warn: log.warn, error: log.error },
259
+ onEvent: async ({ envelope, data, storeResult }) => {
260
+ log.log(`[lark/${bot.appId}] ${envelope} stored id=${storeResult.id} event_id=${storeResult.event_id} parse_ok=${storeResult.parse_ok}`);
261
+ if (envelope === 'im.message.receive_v1') {
262
+ try {
263
+ const larkEnvelope = data as LarkMessageEnvelope;
264
+ const ownerUnionId = configuredOwnerUnionId();
265
+ if (ownerUnionId) {
266
+ const identity = await resolveSenderUnionId({ bot, envelope: larkEnvelope, store: msgStore });
267
+ if (identity.status === 'pending') {
268
+ msgStore.recordPendingInboundEvent({ rawEventId: storeResult.event_id, provider: 'lark', reason: 'missing_union_id', error: identity.reason });
269
+ log.warn(`[lark/${bot.appId}] sender union_id lookup pending: ${identity.reason}`);
270
+ return;
271
+ }
272
+ if (identity.status !== 'resolved' || identity.unionId !== ownerUnionId) {
273
+ log.log(`[lark/${bot.appId}] business ingest skipped sender_union=${identity.status === 'resolved' ? identity.unionId : '-'} owner_configured=true`);
274
+ return;
275
+ }
276
+ }
277
+ const agents = boundAgents(bot);
278
+ const mentionOpenIds = extractMentionOpenIds(larkEnvelope);
279
+ const inferredBotOpenId = agents.length === 1 && mentionOpenIds.length === 1 ? mentionOpenIds[0] : null;
280
+ const botOpenId = botOpenIdByApp.get(bot.appId) ?? bot.botOpenId ?? inferredBotOpenId;
281
+ const recipientByMentionOpenId = new Map<string, string>();
282
+ if (botOpenId && agents.length === 1) recipientByMentionOpenId.set(botOpenId, agents[0]!);
283
+ const labelMentionedThisBot = mentionsBotLabel(larkEnvelope, bot);
284
+ const labelMentionedAnyKnownBot = activeBots().some((candidate) => mentionsBotLabel(larkEnvelope, candidate));
285
+ if (labelMentionedAnyKnownBot && !labelMentionedThisBot) {
286
+ log.log(`[lark/${bot.appId}] label mention targets another bot, skipping ingest`);
287
+ return;
288
+ }
289
+ const recipientOverride = larkEnvelope.message?.chat_type === 'p2p' && agents.length === 1
290
+ ? agents[0]!
291
+ : labelMentionedThisBot && agents.length === 1 ? agents[0]! : undefined;
292
+
293
+ const result = ingestLarkMessage({
294
+ appId: bot.appId,
295
+ envelope: larkEnvelope,
296
+ store: msgStore,
297
+ recipientByMentionOpenId,
298
+ recipientOverride,
299
+ });
300
+ if (result.status === 'ok' && result.message) {
301
+ const tag = result.deduped ? 'dup' : result.threadOrphan ? 'orphan' : 'new';
302
+ log.log(
303
+ `[lark/${bot.appId}] ingest ${tag} lock_msg=${result.message.id} chat=${result.message.chat_name} parent=${result.message.parent_id ?? '-'}`,
304
+ );
305
+ const larkMessage = (data as LarkMessageEnvelope).message;
306
+ if (larkMessage?.chat_id) {
307
+ try {
308
+ const account = msgStore.registerChannelAccount({
309
+ name: bot.label ?? bot.appId,
310
+ appId: bot.appId,
311
+ botOpenId,
312
+ agent: agents.length === 1 ? agents[0]! : null,
313
+ });
314
+ msgStore.resolveChannelConversation({
315
+ accountId: account.id,
316
+ chatName: result.message.chat_name,
317
+ conversationKey: `${result.message.chat_name}:app:${bot.appId}`,
318
+ externalChatId: larkMessage.chat_id,
319
+ externalRootId: larkMessage.root_id ?? null,
320
+ externalThreadId: larkMessage.thread_id ?? null,
321
+ scope: larkMessage.root_id || larkMessage.thread_id ? 'thread' : larkMessage.chat_type === 'p2p' ? 'p2p' : 'chat',
322
+ chatType: larkMessage.chat_type === 'p2p' ? 'p2p' : 'group',
323
+ auditOnly: false,
324
+ });
325
+ await syncLarkRoomMetadata({
326
+ bot,
327
+ chatId: larkMessage.chat_id,
328
+ roomId: result.message.chat_id,
329
+ store: msgStore,
330
+ botOpenId,
331
+ agent: agents.length === 1 ? agents[0]! : null,
332
+ log,
333
+ });
334
+ } catch (err) {
335
+ log.warn(`[lark/${bot.appId}] channel mapping failed: ${err instanceof Error ? err.message : String(err)}`);
336
+ }
337
+ }
338
+ if (tag === 'new' || result.deduped) {
339
+ const deliveries = msgStore.resolveDeliveriesForMessage(result.message.id);
340
+ if (deliveries.length === 0) {
341
+ log.log(`[lark/${bot.appId}] resolver created no deliveries message=${result.message.id}`);
342
+ } else {
343
+ for (const delivery of deliveries) {
344
+ log.log(`[lark/${bot.appId}] resolver delivery id=${delivery.id} agent=${delivery.agent} message=${result.message.id}`);
345
+ }
346
+ if (tag === 'new' && larkMessage?.message_id) {
347
+ const emojiType = deliveryReactionEmojiType();
348
+ try {
349
+ const client = createLarkApiClient(bot.appId, bot.appSecret);
350
+ const reaction = await addMessageReaction({
351
+ client,
352
+ messageId: larkMessage.message_id,
353
+ emojiType,
354
+ });
355
+ log.log(`[lark/${bot.appId}] added reaction emoji=${emojiType} message=${larkMessage.message_id} reaction=${reaction.reactionId ?? '-'}`);
356
+ } catch (err) {
357
+ log.warn(`[lark/${bot.appId}] add reaction failed message=${larkMessage.message_id}: ${apiErrorMessage(err)}`);
358
+ }
359
+ }
360
+ }
361
+ }
362
+ } else if (result.status === 'skipped') {
363
+ log.log(`[lark/${bot.appId}] ingest skipped reason=${result.reason}`);
364
+ }
365
+ } catch (err) {
366
+ log.error(`[lark/${bot.appId}] ingest error:`, err);
367
+ }
368
+ }
369
+ },
370
+ });
371
+
372
+ const applyBots = (bots: LarkCredential[]): LarkReloadResult => {
373
+ const nextByApp = new Map(bots.map((bot) => [bot.appId, bot]));
374
+ const started: string[] = [];
375
+ const stopped: string[] = [];
376
+ const restarted: string[] = [];
377
+ const pending = new Map<string, { bot: LarkCredential; handle: LarkDaemonHandle; fingerprint: string; replacing: boolean }>();
378
+
379
+ try {
380
+ for (const bot of bots) {
381
+ const fingerprint = credentialFingerprint(bot);
382
+ const current = active.get(bot.appId);
383
+ if (current?.fingerprint === fingerprint) continue;
384
+ const handle = startBot(bot);
385
+ pending.set(bot.appId, { bot, handle, fingerprint, replacing: Boolean(current) });
386
+ }
387
+ } catch (err) {
388
+ for (const entry of pending.values()) entry.handle.stop();
389
+ return { ok: false, bots: activeAppIds(), error: err instanceof Error ? err.message : String(err) };
390
+ }
391
+
392
+ for (const [appId, current] of active.entries()) {
393
+ const pendingReplacement = pending.get(appId);
394
+ if (!nextByApp.has(appId) || pendingReplacement) {
395
+ current.handle.stop();
396
+ stopped.push(appId);
397
+ active.delete(appId);
398
+ }
399
+ }
400
+
401
+ for (const [appId, entry] of pending.entries()) {
402
+ active.set(appId, entry);
403
+ if (entry.replacing) restarted.push(appId);
404
+ else started.push(appId);
405
+ }
406
+
407
+ afterSuccessfulScan(bots);
408
+ return { ok: true, bots: activeAppIds(), started, stopped, restarted };
409
+ };
410
+
411
+ const reload = (): LarkReloadResult => {
412
+ let bots: LarkCredential[];
413
+ try {
414
+ bots = loadBotsForReload();
415
+ } catch (err) {
416
+ const error = err instanceof Error ? err.message : String(err);
417
+ log.warn(`[lark] reload skipped: ${error}`);
418
+ return { ok: false, bots: activeAppIds(), error };
419
+ }
420
+ if (bots.length === 0) {
421
+ log.log('[lark] no bots configured, skipping lark integration');
422
+ } else {
423
+ log.log(`[lark] reloading ${bots.length} bot(s) on server policy=${policy}`);
424
+ }
425
+ return applyBots(bots);
426
+ };
427
+
428
+ const initial = reload();
429
+ if (!initial.ok) {
430
+ log.warn(`[lark] initial load failed: ${initial.error}`);
431
+ }
432
+
433
+ return {
434
+ get handles() {
435
+ return activeHandles();
436
+ },
437
+ reload,
438
+ stop() {
439
+ const handles = activeHandles();
440
+ for (const h of handles) h.stop();
441
+ active.clear();
442
+ log.log(`[lark] stopped ${handles.length} bot(s)`);
443
+ },
444
+ };
445
+ }