@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,549 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { defaultDbPath, defaultServerUrl } from '../config.js';
3
+ import { MessageStore } from '../db.js';
4
+ import {
5
+ boundAgents,
6
+ defaultLarkConfigPath,
7
+ findCredential,
8
+ loadLarkCredentials,
9
+ } from './credentials.js';
10
+ import { countInboundEvents, listRecentInboundEvents } from './inbound-events.js';
11
+ import { extractMentionOpenIds, ingestLarkMessage, type LarkMessageEnvelope } from './event-router.js';
12
+ import { ChatDispatcher, chatKeyOf, parseReceivePolicy, PeriodicQueue, shouldAcceptForAgent, type DispatchInput, type ReceivePolicy } from './dispatcher.js';
13
+ import { parseRuntimeSpec } from './agent-runtime.js';
14
+ import {
15
+ formatLarkSetupNextSteps,
16
+ persistLarkCredential,
17
+ resolveLarkBotInfo,
18
+ runInteractiveLarkSetup,
19
+ } from './setup.js';
20
+ import { createLarkApiClient, sendTextMessage, startLarkDaemon } from './ws-daemon.js';
21
+
22
+ export interface LarkCliArgs {
23
+ command: string;
24
+ values: string[];
25
+ flags: Record<string, unknown>;
26
+ }
27
+
28
+ interface RunOptions {
29
+ argv: LarkCliArgs;
30
+ log?: Partial<Pick<Console, 'log' | 'warn' | 'error'>>;
31
+ setupAsk?: (question: string) => Promise<string>;
32
+ }
33
+
34
+ function printJson(value: unknown, log: NonNullable<RunOptions['log']>): void {
35
+ (log.log ?? console.log)(JSON.stringify(value, null, 2));
36
+ }
37
+
38
+ function flagString(flags: Record<string, unknown>, key: string): string | undefined {
39
+ const v = flags[key];
40
+ return typeof v === 'string' && v.length > 0 ? v : undefined;
41
+ }
42
+
43
+ function flagBool(flags: Record<string, unknown>, key: string): boolean {
44
+ return flags[key] === true;
45
+ }
46
+
47
+ async function postJson(url: string, body: unknown): Promise<{ ok: boolean; status: number; text: string }> {
48
+ try {
49
+ const response = await fetch(url, {
50
+ method: 'POST',
51
+ headers: { 'content-type': 'application/json' },
52
+ body: JSON.stringify(body),
53
+ });
54
+ return { ok: response.ok, status: response.status, text: await response.text() };
55
+ } catch (error) {
56
+ return { ok: false, status: 0, text: error instanceof Error ? error.message : String(error) };
57
+ }
58
+ }
59
+
60
+ async function listAgentsForSetup(serverUrl: string): Promise<Array<{ agent_key: string; display_name: string; runtime?: string | null }>> {
61
+ const response = await fetch(`${serverUrl.replace(/\/$/, '')}/api/agents`);
62
+ const payload = await response.json() as { ok?: boolean; data?: { agents?: Array<{ agent_key?: unknown; display_name?: unknown; runtime?: unknown }> }; message?: string };
63
+ if (!response.ok || payload.ok === false) {
64
+ throw new Error(payload.message ?? `agent list failed: ${response.status}`);
65
+ }
66
+ return (payload.data?.agents ?? [])
67
+ .filter((agent) => typeof agent.agent_key === 'string' && typeof agent.display_name === 'string')
68
+ .map((agent) => ({
69
+ agent_key: String(agent.agent_key),
70
+ display_name: String(agent.display_name),
71
+ runtime: agent.runtime === null || typeof agent.runtime === 'string' ? agent.runtime : undefined,
72
+ }));
73
+ }
74
+
75
+ async function onboardAgentForLark(flags: Record<string, unknown>, log: NonNullable<RunOptions['log']>, agent: string | undefined): Promise<boolean> {
76
+ if (!flagBool(flags, 'create-agent')) return true;
77
+ if (!agent) {
78
+ (log.error ?? console.error)('lark setup --create-agent requires --agent');
79
+ return false;
80
+ }
81
+ const serverUrl = flagString(flags, 'server') ?? defaultServerUrl();
82
+ const displayName = flagString(flags, 'agent-name') ?? agent;
83
+ const runtime = flagString(flags, 'runtime') ?? 'codex';
84
+ const computerId = flagString(flags, 'computer-id');
85
+ const result = await postJson(`${serverUrl.replace(/\/$/, '')}/api/agents/onboard`, {
86
+ agent_key: agent,
87
+ display_name: displayName,
88
+ runtime,
89
+ computer_id: computerId,
90
+ });
91
+ if (!result.ok) {
92
+ (log.error ?? console.error)(`agent onboard failed (${result.status || 'network'}): ${result.text}`);
93
+ return false;
94
+ }
95
+ (log.log ?? console.log)(`[lark setup] agent onboarded: ${agent}`);
96
+ return true;
97
+ }
98
+
99
+ async function reloadLarkIntegration(flags: Record<string, unknown>, log: NonNullable<RunOptions['log']>): Promise<void> {
100
+ if (flagBool(flags, 'no-reload')) return;
101
+ const serverUrl = flagString(flags, 'server') ?? defaultServerUrl();
102
+ const result = await postJson(`${serverUrl.replace(/\/$/, '')}/api/lark/reload`, {});
103
+ if (result.ok) {
104
+ (log.log ?? console.log)('[lark setup] server lark integration reloaded');
105
+ } else {
106
+ (log.warn ?? console.warn)(`[lark setup] saved config, but server reload failed (${result.status || 'network'}): ${result.text}`);
107
+ }
108
+ }
109
+
110
+ export async function runLarkCli(options: RunOptions): Promise<number> {
111
+ const { argv } = options;
112
+ const log = options.log ?? {};
113
+ const sub = argv.values[0];
114
+
115
+ if (!sub || sub === 'help' || flagBool(argv.flags, 'help')) {
116
+ printLarkUsage(log);
117
+ return 0;
118
+ }
119
+
120
+ if (sub === 'setup') {
121
+ const appId = flagString(argv.flags, 'app-id');
122
+ const appSecret = flagString(argv.flags, 'app-secret');
123
+ const label = flagString(argv.flags, 'label');
124
+ const agent = flagString(argv.flags, 'agent');
125
+ const path = flagString(argv.flags, 'config') ?? defaultLarkConfigPath();
126
+ if ('agents' in argv.flags) {
127
+ (log.error ?? console.error)('lark setup no longer supports --agents; bind one bot to one agent with --agent');
128
+ return 2;
129
+ }
130
+ if (!appId && !appSecret) {
131
+ const result = await runInteractiveLarkSetup({
132
+ configPath: path,
133
+ log: {
134
+ log: log.log ?? console.log,
135
+ warn: log.warn ?? console.warn,
136
+ error: log.error ?? console.error,
137
+ },
138
+ ask: options.setupAsk,
139
+ listAgents: () => listAgentsForSetup(flagString(argv.flags, 'server') ?? defaultServerUrl()),
140
+ });
141
+ if (result) await reloadLarkIntegration(argv.flags, log);
142
+ return result ? 0 : 2;
143
+ }
144
+ if (!appId || !appSecret) {
145
+ (log.error ?? console.error)('lark setup requires --app-id and --app-secret');
146
+ return 2;
147
+ }
148
+ if (!(await onboardAgentForLark(argv.flags, log, agent))) return 2;
149
+ const botInfo = await resolveLarkBotInfo(appId, appSecret);
150
+ if (!botInfo.ok) {
151
+ (log.error ?? console.error)(`lark setup could not resolve bot open_id (${botInfo.error}): ${botInfo.message}`);
152
+ return 2;
153
+ }
154
+ const result = persistLarkCredential({
155
+ appId,
156
+ appSecret,
157
+ label,
158
+ agent,
159
+ botOpenId: botInfo.openId,
160
+ configPath: path,
161
+ });
162
+ if (result.replaced) {
163
+ (log.warn ?? console.warn)(`[lark setup] overwrote existing credential for appId=${appId} in ${path}`);
164
+ }
165
+ if (flagBool(argv.flags, 'next-steps')) {
166
+ (log.log ?? console.log)(formatLarkSetupNextSteps(result));
167
+ await reloadLarkIntegration(argv.flags, log);
168
+ return 0;
169
+ }
170
+ printJson(result, { log: log.log });
171
+ await reloadLarkIntegration(argv.flags, log);
172
+ return 0;
173
+ }
174
+
175
+ if (sub === 'list') {
176
+ // Listing config file contents (acceptor-locked: no remote list, only read local file)
177
+ const path = flagString(argv.flags, 'config') ?? defaultLarkConfigPath();
178
+ const store = loadLarkCredentials(path);
179
+ const safe = store.bots.map((b) => ({ appId: b.appId, label: b.label, agent: b.agent ?? null, boundAgents: boundAgents(b), botOpenId: b.botOpenId ?? null, hasSecret: Boolean(b.appSecret) }));
180
+ printJson({ path, bots: safe }, { log: log.log });
181
+ return 0;
182
+ }
183
+
184
+ if (sub === 'events') {
185
+ const path = flagString(argv.flags, 'db') ?? defaultDbPath();
186
+ const limit = Number(flagString(argv.flags, 'limit') ?? '20');
187
+ const store = new MessageStore(path);
188
+ try {
189
+ const total = countInboundEvents(store.db);
190
+ const rows = listRecentInboundEvents(store.db, Number.isFinite(limit) ? limit : 20);
191
+ printJson({
192
+ path,
193
+ total,
194
+ rows: rows.map((r) => ({
195
+ id: r.id,
196
+ received_at: r.received_at,
197
+ app_id: r.app_id,
198
+ event_type: r.event_type,
199
+ event_id: r.event_id,
200
+ parse_ok: r.parse_ok,
201
+ bytes: r.raw_body_bytes?.byteLength ?? 0,
202
+ })),
203
+ }, { log: log.log });
204
+ } finally {
205
+ store.close();
206
+ }
207
+ return 0;
208
+ }
209
+
210
+ if (sub === 'daemon') {
211
+ return runDaemon(argv, log);
212
+ }
213
+
214
+ if (sub === 'send') {
215
+ return runSend(argv, log);
216
+ }
217
+
218
+ printLarkUsage(log);
219
+ return 2;
220
+ }
221
+
222
+ function printLarkUsage(log: NonNullable<RunOptions['log']>): void {
223
+ (log.log ?? console.log)(`pal lark <subcommand> [flags]
224
+
225
+ Subcommands:
226
+ setup [--app-id <id> --app-secret <secret>] [--label <name>] [--agent <agent-key>] [--config <path>] [--next-steps]
227
+ [--create-agent --agent-name <name> --runtime codex --computer-id <machine>] [--server <url>] [--no-reload]
228
+ Persist a (appId, appSecret) credential pair to ~/.pal/lark.json (0600).
229
+ With no app-id/app-secret flags, starts an interactive setup wizard that
230
+ validates credentials before writing the config.
231
+ Overwrites if appId already present and logs a warning.
232
+ Setup resolves the bot open_id through Feishu's bot info API before
233
+ writing config. --agent binds this bot to a logical agent key. --create-agent creates or
234
+ updates that agent through the Pal server before writing lark.json.
235
+ By default setup asks the running server to reload Lark integration.
236
+
237
+ list [--config <path>]
238
+ List configured bots (secrets redacted).
239
+
240
+ daemon [--app-id <id> | --all] [--config <path>] [--db <path>] [--agent <runtime-spec>] [--no-reply]
241
+ [--policy every|mention|off]
242
+ Start a WSClient and stream events into channel_inbound_raw_events with dedupe.
243
+ When --agent is set (e.g. echo, echo:bot, shell:'bun -e ...'), every fresh
244
+ lock message triggers the agent; the agent's reply is sent back via Lark
245
+ unless --no-reply is set.
246
+ If --agent is omitted and the bot config has an "agent" field, that value is used.
247
+ --policy controls when the agent runs (default: every).
248
+ - every: agent runs for every fresh message in any chat
249
+ - mention: only when the bot's own open_id appears in @mentions
250
+ - periodic: messages buffer per-chat; flush on --periodic-interval-ms ticks
251
+ - off: ingest + store, but never dispatch to agent
252
+ --periodic-interval-ms sets the pull interval for policy=periodic (default 30000 = 30s).
253
+ Send SIGUSR1 to restart-all (re-deliver in-flight + pending), SIGUSR2 to kill-all (drop),
254
+ SIGHUP to flush all periodic buffers immediately.
255
+
256
+ events [--db <path>] [--limit 20]
257
+ Show recently stored raw events.
258
+
259
+ send --app-id <id> --to <receive_id> [--to-type chat_id|open_id|union_id|email|user_id] <text>
260
+ Send a text message via the bot identified by app-id.
261
+
262
+ Environment:
263
+ PAL_HOME home dir (default ~/.pal)
264
+ PAL_LARK_CONFIG override lark.json path
265
+ PAL_DB override sqlite db path
266
+ `);
267
+ }
268
+
269
+ async function runDaemon(argv: LarkCliArgs, log: NonNullable<RunOptions['log']>): Promise<number> {
270
+ const configPath = flagString(argv.flags, 'config') ?? defaultLarkConfigPath();
271
+ const dbPath = flagString(argv.flags, 'db') ?? defaultDbPath();
272
+ const all = flagBool(argv.flags, 'all');
273
+ const onlyAppId = flagString(argv.flags, 'app-id');
274
+ let agentSpec = flagString(argv.flags, 'agent');
275
+ const replyToLark = !flagBool(argv.flags, 'no-reply');
276
+ const policy: ReceivePolicy = parseReceivePolicy(flagString(argv.flags, 'policy'), 'every');
277
+ const periodicIntervalMs = Number(flagString(argv.flags, 'periodic-interval-ms') ?? '30000');
278
+ if (policy === 'periodic' && (!Number.isFinite(periodicIntervalMs) || periodicIntervalMs <= 0)) {
279
+ (log.error ?? console.error)('--periodic-interval-ms must be a positive number');
280
+ return 2;
281
+ }
282
+ const store = loadLarkCredentials(configPath);
283
+ if (store.bots.length === 0) {
284
+ (log.error ?? console.error)(`No bots configured in ${configPath}. Run pal lark setup first.`);
285
+ return 2;
286
+ }
287
+ let targets = store.bots;
288
+ if (!all) {
289
+ if (!onlyAppId) {
290
+ if (store.bots.length === 1) {
291
+ targets = [store.bots[0]];
292
+ } else {
293
+ (log.error ?? console.error)('Multiple bots configured; pass --app-id <id> or --all.');
294
+ return 2;
295
+ }
296
+ } else {
297
+ const single = findCredential(store, onlyAppId);
298
+ if (!single) {
299
+ (log.error ?? console.error)(`appId ${onlyAppId} not found in ${configPath}.`);
300
+ return 2;
301
+ }
302
+ targets = [single];
303
+ }
304
+ }
305
+
306
+ // If no --agent flag, fall back to the agent bound in lark.json for the target bot(s).
307
+ if (!agentSpec && targets.length > 0) {
308
+ const boundAgents = targets.map((b) => b.agent).filter((a): a is string => Boolean(a));
309
+ const unique = Array.from(new Set(boundAgents));
310
+ if (unique.length === 1) {
311
+ agentSpec = unique[0];
312
+ (log.log ?? console.log)(`[lark] using bound agent from config: ${agentSpec}`);
313
+ } else if (unique.length > 1) {
314
+ (log.warn ?? console.warn)(`[lark] multiple bots have different bound agents (${unique.join(', ')}); pass --agent explicitly`);
315
+ } else {
316
+ (log.warn ?? console.warn)(`[lark] no agent bound in config; pass --agent explicitly`);
317
+ }
318
+ }
319
+
320
+ const msgStore = new MessageStore(dbPath);
321
+
322
+ // Resolve whether the agent runs in delivery mode (external daemon) or inline dispatcher mode.
323
+ // If agentSpec is a known daemon runtime (neeko/coco/codex), use it directly.
324
+ // Otherwise, treat it as a logical agent key and look up its runtime in the DB.
325
+ let resolvedRuntime: string | null = null;
326
+ if (agentSpec) {
327
+ const directRuntime = agentSpec.split(':')[0];
328
+ if (directRuntime === 'neeko' || directRuntime === 'coco' || directRuntime === 'coco-stream-json' || directRuntime === 'codex') {
329
+ resolvedRuntime = directRuntime;
330
+ } else {
331
+ resolvedRuntime = msgStore.getAgentRuntime(agentSpec);
332
+ if (!resolvedRuntime) {
333
+ (log.warn ?? console.warn)(`[lark] agent ${agentSpec} has no runtime configured in DB; defaulting to neeko. Run "bun run src/cli.ts agents create --key ${agentSpec} --name <name> --runtime neeko|coco|coco-stream-json|codex" to configure.`);
334
+ resolvedRuntime = 'neeko';
335
+ }
336
+ }
337
+ }
338
+ const isDeliveryAgent = resolvedRuntime === 'neeko' || resolvedRuntime === 'coco' || resolvedRuntime === 'coco-stream-json' || resolvedRuntime === 'codex';
339
+
340
+ const dispatchers = new Map<string, { dispatcher: ChatDispatcher; periodic: PeriodicQueue | null; chatIdToLarkChatId: Map<string, string> }>();
341
+ if (agentSpec && !isDeliveryAgent) {
342
+ for (const bot of targets) {
343
+ const runtime = parseRuntimeSpec(agentSpec);
344
+ const chatIdToLarkChatId = new Map<string, string>();
345
+ const apiClient = replyToLark ? createLarkApiClient(bot.appId, bot.appSecret) : null;
346
+ const dispatcher = new ChatDispatcher({
347
+ runtime,
348
+ logger: { log: log.log ?? console.log, warn: log.warn, error: log.error },
349
+ onReply: async ({ chatKey, reply, sourceMessages }) => {
350
+ const larkChatId = chatIdToLarkChatId.get(chatKey);
351
+ if (!larkChatId) {
352
+ (log.warn ?? console.warn)(`[lark/${bot.appId}] no larkChatId mapped for ${chatKey}; reply not sent`);
353
+ return;
354
+ }
355
+ (log.log ?? console.log)(
356
+ `[lark/${bot.appId}] agent ${runtime.name} replied to chat=${larkChatId} (${reply.text.length} chars) sources=${sourceMessages.length}`,
357
+ );
358
+ if (apiClient) {
359
+ try {
360
+ const sent = await sendTextMessage({ client: apiClient, receiveIdType: 'chat_id', receiveId: larkChatId, text: reply.text });
361
+ (log.log ?? console.log)(`[lark/${bot.appId}] sent reply message_id=${sent.messageId ?? '-'}`);
362
+ } catch (err) {
363
+ (log.error ?? console.error)(`[lark/${bot.appId}] send failed:`, err);
364
+ }
365
+ }
366
+ },
367
+ });
368
+ let periodic: PeriodicQueue | null = null;
369
+ if (policy === 'periodic') {
370
+ periodic = new PeriodicQueue({
371
+ intervalMs: periodicIntervalMs,
372
+ logger: { log: log.log ?? console.log, warn: log.warn, error: log.error },
373
+ onFlush: async ({ chatKey, batch }) => {
374
+ (log.log ?? console.log)(`[lark/${bot.appId}] periodic flush chat=${chatKey} count=${batch.length}`);
375
+ await dispatcher.enqueueBatch(batch);
376
+ },
377
+ });
378
+ periodic.start();
379
+ }
380
+ dispatchers.set(bot.appId, { dispatcher, periodic, chatIdToLarkChatId });
381
+ }
382
+ (log.log ?? console.log)(`[lark] dispatcher armed: runtime=${agentSpec} policy=${policy}${policy === 'periodic' ? ` intervalMs=${periodicIntervalMs}` : ''} replyToLark=${replyToLark}`);
383
+ } else if (isDeliveryAgent) {
384
+ (log.log ?? console.log)(`[lark] delivery mode: agent=${agentSpec} resolvedRuntime=${resolvedRuntime} policy=${policy}`);
385
+ }
386
+ const handles = targets.map((bot) =>
387
+ startLarkDaemon({
388
+ appId: bot.appId,
389
+ appSecret: bot.appSecret,
390
+ db: msgStore.db,
391
+ logger: { info: log.log ?? console.log, warn: log.warn, error: log.error },
392
+ onEvent: ({ envelope, data, storeResult }) => {
393
+ (log.log ?? console.log)(`[lark/${bot.appId}] ${envelope} stored id=${storeResult.id} event_id=${storeResult.event_id} parse_ok=${storeResult.parse_ok}`);
394
+ if (envelope === 'im.message.receive_v1') {
395
+ try {
396
+ const result = ingestLarkMessage({
397
+ appId: bot.appId,
398
+ envelope: data as LarkMessageEnvelope,
399
+ store: msgStore,
400
+ });
401
+ if (result.status === 'ok' && result.message) {
402
+ const tag = result.deduped ? 'dup' : result.threadOrphan ? 'orphan' : 'new';
403
+ (log.log ?? console.log)(
404
+ `[lark/${bot.appId}] ingest ${tag} lock_msg=${result.message.id} chat=${result.message.chat_name} parent=${result.message.parent_id ?? '-'}`,
405
+ );
406
+ if (tag === 'new') {
407
+ if (isDeliveryAgent) {
408
+ const mentionOpenIds = extractMentionOpenIds(data as LarkMessageEnvelope);
409
+ const accept = shouldAcceptForAgent({
410
+ policy,
411
+ botOpenId: bot.botOpenId ?? null,
412
+ mentionOpenIds,
413
+ });
414
+ if (!accept) {
415
+ (log.log ?? console.log)(`[lark/${bot.appId}] policy=${policy} drop (no mention or off)`);
416
+ } else {
417
+ try {
418
+ const delivery = msgStore.createDelivery({ messageId: result.message.id, agent: agentSpec! });
419
+ (log.log ?? console.log)(`[lark/${bot.appId}] created delivery id=${delivery.id} for agent=${agentSpec} message=${result.message.id}`);
420
+ } catch (err) {
421
+ (log.warn ?? console.warn)(`[lark/${bot.appId}] createDelivery failed: ${err instanceof Error ? err.message : String(err)}`);
422
+ }
423
+ }
424
+ } else {
425
+ const wired = dispatchers.get(bot.appId);
426
+ if (wired) {
427
+ const mentionOpenIds = extractMentionOpenIds(data as LarkMessageEnvelope);
428
+ const larkChatId = (data as LarkMessageEnvelope).message?.chat_id;
429
+ if (larkChatId) wired.chatIdToLarkChatId.set(result.message.chat_id, larkChatId);
430
+ const input: DispatchInput = {
431
+ chatKey: chatKeyOf(result.message),
432
+ senderOpenId: result.message.sender,
433
+ text: result.message.content,
434
+ larkMessageId: result.message.idempotency_key ?? `lock-${result.message.id}`,
435
+ lockMessageId: result.message.id,
436
+ parentLockMessageId: result.message.parent_id,
437
+ };
438
+ if (policy === 'periodic' && wired.periodic) {
439
+ wired.periodic.add(input);
440
+ (log.log ?? console.log)(`[lark/${bot.appId}] policy=periodic buffered chat=${input.chatKey} buf=${wired.periodic.inspect().find((e) => e.chatKey === input.chatKey)?.pending ?? '?'}`);
441
+ } else {
442
+ const accept = shouldAcceptForAgent({
443
+ policy,
444
+ botOpenId: bot.botOpenId ?? null,
445
+ mentionOpenIds,
446
+ });
447
+ if (!accept) {
448
+ (log.log ?? console.log)(`[lark/${bot.appId}] policy=${policy} drop (no mention or off)`);
449
+ } else {
450
+ void wired.dispatcher.enqueue(input);
451
+ }
452
+ }
453
+ }
454
+ }
455
+ }
456
+ } else if (result.status === 'skipped') {
457
+ (log.log ?? console.log)(`[lark/${bot.appId}] ingest skipped reason=${result.reason}`);
458
+ }
459
+ } catch (err) {
460
+ (log.error ?? console.error)(`[lark/${bot.appId}] ingest error:`, err);
461
+ }
462
+ }
463
+ },
464
+ }),
465
+ );
466
+ (log.log ?? console.log)(`[lark] daemon up; bots=${handles.map((h) => h.appId).join(',')}; db=${dbPath}`);
467
+
468
+ // Keep the event loop alive so the WSClient connection persists.
469
+ // The WSClient's internal websocket may not hold the loop in some runtimes.
470
+ const heartbeat = setInterval(() => {
471
+ /* no-op: just keeps the process alive */
472
+ }, 30000);
473
+
474
+ return new Promise<number>((resolve) => {
475
+ const shutdown = (signal: string) => {
476
+ clearInterval(heartbeat);
477
+ (log.log ?? console.log)(`[lark] received ${signal}, stopping ${handles.length} bot(s)`);
478
+ for (const wired of dispatchers.values()) {
479
+ if (wired.periodic) wired.periodic.stop();
480
+ }
481
+ for (const h of handles) h.stop();
482
+ msgStore.close();
483
+ resolve(0);
484
+ };
485
+ process.once('SIGINT', () => shutdown('SIGINT'));
486
+ process.once('SIGTERM', () => shutdown('SIGTERM'));
487
+ process.on('SIGUSR1', () => {
488
+ for (const [appId, wired] of dispatchers.entries()) {
489
+ const { redelivered } = wired.dispatcher.restartAll();
490
+ (log.log ?? console.log)(`[lark/${appId}] SIGUSR1 restart-all redelivered=${redelivered}`);
491
+ }
492
+ });
493
+ process.on('SIGUSR2', () => {
494
+ for (const [appId, wired] of dispatchers.entries()) {
495
+ const { aborted, dropped } = wired.dispatcher.killAll();
496
+ (log.log ?? console.log)(`[lark/${appId}] SIGUSR2 kill-all aborted=${aborted} dropped=${dropped}`);
497
+ }
498
+ });
499
+ // SIGHUP → flush periodic buffers now (operator can use this when they
500
+ // want to wake the agent without waiting for the next tick).
501
+ process.on('SIGHUP', () => {
502
+ for (const [appId, wired] of dispatchers.entries()) {
503
+ if (wired.periodic) {
504
+ void wired.periodic.flushAll().then((n) => {
505
+ (log.log ?? console.log)(`[lark/${appId}] SIGHUP periodic flushAll released=${n}`);
506
+ });
507
+ }
508
+ }
509
+ });
510
+ });
511
+ }
512
+
513
+ async function runSend(argv: LarkCliArgs, log: NonNullable<RunOptions['log']>): Promise<number> {
514
+ const appId = flagString(argv.flags, 'app-id');
515
+ const to = flagString(argv.flags, 'to');
516
+ const toType = (flagString(argv.flags, 'to-type') ?? 'chat_id') as 'chat_id' | 'open_id' | 'union_id' | 'email' | 'user_id';
517
+ const text = argv.values.slice(1).join(' ').trim() || readStdinSync();
518
+ const configPath = flagString(argv.flags, 'config') ?? defaultLarkConfigPath();
519
+ if (!appId) {
520
+ (log.error ?? console.error)('lark send requires --app-id');
521
+ return 2;
522
+ }
523
+ if (!to) {
524
+ (log.error ?? console.error)('lark send requires --to');
525
+ return 2;
526
+ }
527
+ if (!text) {
528
+ (log.error ?? console.error)('lark send requires text (positional arg or stdin)');
529
+ return 2;
530
+ }
531
+ const store = loadLarkCredentials(configPath);
532
+ const bot = findCredential(store, appId);
533
+ if (!bot) {
534
+ (log.error ?? console.error)(`appId ${appId} not found in ${configPath}`);
535
+ return 2;
536
+ }
537
+ const client = createLarkApiClient(bot.appId, bot.appSecret);
538
+ const result = await sendTextMessage({ client, receiveIdType: toType, receiveId: to, text });
539
+ printJson({ ok: true, messageId: result.messageId }, { log: log.log });
540
+ return 0;
541
+ }
542
+
543
+ function readStdinSync(): string {
544
+ try {
545
+ return readFileSync(0, 'utf8').trim();
546
+ } catch {
547
+ return '';
548
+ }
549
+ }
@@ -0,0 +1,105 @@
1
+ import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
2
+ import { dirname, join } from 'node:path';
3
+ import { homeDir } from '../config.js';
4
+
5
+ export interface LarkCredential {
6
+ appId: string;
7
+ appSecret: string;
8
+ label?: string;
9
+ agent?: string;
10
+ botOpenId?: string;
11
+ }
12
+
13
+ export interface LarkCredentialStore {
14
+ bots: LarkCredential[];
15
+ }
16
+
17
+ export function defaultLarkConfigPath(): string {
18
+ return process.env.PAL_LARK_CONFIG ?? join(homeDir(), 'lark.json');
19
+ }
20
+
21
+ export function loadLarkCredentials(path: string = defaultLarkConfigPath()): LarkCredentialStore {
22
+ if (!existsSync(path)) {
23
+ return { bots: [] };
24
+ }
25
+ const raw = readFileSync(path, 'utf8');
26
+ const parsed = JSON.parse(raw) as unknown;
27
+ return normalizeStore(parsed);
28
+ }
29
+
30
+ export function saveLarkCredentials(store: LarkCredentialStore, path: string = defaultLarkConfigPath()): void {
31
+ mkdirSync(dirname(path), { recursive: true });
32
+ const serialized = `${JSON.stringify(store, null, 2)}\n`;
33
+ const tmpPath = `${path}.tmp`;
34
+ writeFileSync(tmpPath, serialized, { encoding: 'utf8', mode: 0o600 });
35
+ chmodSync(tmpPath, 0o600);
36
+ renameSync(tmpPath, path);
37
+ chmodSync(path, 0o600);
38
+ }
39
+
40
+ export interface AddCredentialResult {
41
+ store: LarkCredentialStore;
42
+ replaced: boolean;
43
+ }
44
+
45
+ export function upsertCredential(store: LarkCredentialStore, credential: LarkCredential): AddCredentialResult {
46
+ validateCredential(credential);
47
+ const existingIndex = store.bots.findIndex((bot) => bot.appId === credential.appId);
48
+ const next: LarkCredentialStore = { bots: [...store.bots] };
49
+ if (existingIndex === -1) {
50
+ next.bots.push(credential);
51
+ return { store: next, replaced: false };
52
+ }
53
+ next.bots[existingIndex] = credential;
54
+ return { store: next, replaced: true };
55
+ }
56
+
57
+ export function findCredential(store: LarkCredentialStore, appId: string): LarkCredential | undefined {
58
+ return store.bots.find((bot) => bot.appId === appId);
59
+ }
60
+
61
+ export function boundAgents(credential: Pick<LarkCredential, 'agent'>): string[] {
62
+ const agent = credential.agent?.trim();
63
+ return agent ? [agent] : [];
64
+ }
65
+
66
+ function validateCredential(credential: LarkCredential): void {
67
+ if (!credential.appId || typeof credential.appId !== 'string') {
68
+ throw new Error('appId is required');
69
+ }
70
+ if (!credential.appSecret || typeof credential.appSecret !== 'string') {
71
+ throw new Error('appSecret is required');
72
+ }
73
+ }
74
+
75
+ function normalizeStore(parsed: unknown): LarkCredentialStore {
76
+ if (parsed && typeof parsed === 'object' && 'bots' in parsed && Array.isArray((parsed as { bots: unknown }).bots)) {
77
+ const bots = (parsed as { bots: unknown[] }).bots.map((entry, index) => {
78
+ if (!entry || typeof entry !== 'object') {
79
+ throw new Error(`bots[${index}] is not an object`);
80
+ }
81
+ const obj = entry as Record<string, unknown>;
82
+ const appId = obj.appId ?? obj.larkAppId;
83
+ const appSecret = obj.appSecret ?? obj.larkAppSecret;
84
+ const label = obj.label;
85
+ const agent = obj.agent;
86
+ const botOpenId = obj.botOpenId;
87
+ if (typeof appId !== 'string' || typeof appSecret !== 'string') {
88
+ throw new Error(`bots[${index}] is missing appId/appSecret`);
89
+ }
90
+ if ('agents' in obj) {
91
+ throw new Error(`bots[${index}].agents is no longer supported; use agent`);
92
+ }
93
+ const credential: LarkCredential = { appId, appSecret };
94
+ if (typeof label === 'string') credential.label = label;
95
+ if (typeof agent === 'string') credential.agent = agent;
96
+ if (typeof botOpenId === 'string') credential.botOpenId = botOpenId;
97
+ return credential;
98
+ });
99
+ return { bots };
100
+ }
101
+ if (Array.isArray(parsed)) {
102
+ return normalizeStore({ bots: parsed });
103
+ }
104
+ throw new Error('lark.json must be { bots: [...] } or a bots array');
105
+ }