@controlflow-ai/daemon 0.1.0 → 0.1.2

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/src/db.ts CHANGED
@@ -1,10 +1,13 @@
1
1
  import { Database, type SQLQueryBindings } from 'bun:sqlite';
2
2
  import { createHash, randomBytes } from 'node:crypto';
3
+ import { isAbsolute } from 'node:path';
3
4
  import { ensureParentDir } from './config.js';
4
5
  import { runMigrations } from './migrations.js';
5
6
  import { artifactExpiry, generateArtifactToken, hashArtifactToken, validateArtifactContent } from './artifacts.js';
6
7
  import { palIdentityHandle } from './provider-identity.js';
7
- import type { AgentDefinition, AgentRun, AgentSession, AgentValidationResult, Artifact, ArtifactMetadata, ChannelAccount, ChannelConversation, ChannelMessageMapping, ChannelOutboxRecord, Chat, ChatKind, Computer, ComputerAgentAssignment, ComputerConnection, DaemonInstance, LarkGroupRoomMapping, LockProviderEvidence, LockTranscriptMessage, Message, MessageDelivery, MessageType, AgentRoomSubscription, AgentRoomSubscriptionMode, PendingInboundEvent, ProvisionedComputer, ProviderExternalType, ProviderIdentityBinding, PalIdentity, RoomChannel, RoomParticipant, RoomParticipantKind, RoomParticipantSource, RoomProvider, RunAction, RunStatus, TranscriptReadModel, WorkbenchArtifact } from './types.js';
8
+ import type { AgentDefinition, AgentRun, AgentSession, AgentValidationResult, Artifact, ArtifactMetadata, ChannelAccount, ChannelConversation, ChannelMessageMapping, ChannelOutboxRecord, Chat, ChatKind, Computer, ComputerAgentAssignment, ComputerConnection, DaemonInstance, LarkAuthorizedUser, LarkGroupRoomMapping, LockProviderEvidence, LockTranscriptMessage, Message, MessageDelivery, MessageType, AgentRoomSubscription, AgentRoomSubscriptionMode, PendingInboundEvent, Project, ProvisionedComputer, ProviderExternalType, ProviderIdentityBinding, PalIdentity, RoomChannel, RoomParticipant, RoomParticipantKind, RoomParticipantSource, RoomProvider, RunAction, RunStatus, TranscriptReadModel, WorkbenchArtifact } from './types.js';
9
+
10
+ export const ALL_AGENTS_MENTION = '__pal_all_agents__';
8
11
 
9
12
  export interface CreateMessageInput {
10
13
  chatId?: string;
@@ -83,6 +86,7 @@ export interface ConnectComputerResult {
83
86
  computer: Computer;
84
87
  connection: ComputerConnection;
85
88
  token: string;
89
+ localControlToken: string;
86
90
  daemon: DaemonInstance;
87
91
  agents: ComputerAgentAssignment[];
88
92
  }
@@ -354,6 +358,19 @@ function rowToComputerAgentAssignment(row: Record<string, unknown>): ComputerAge
354
358
  };
355
359
  }
356
360
 
361
+ function rowToProject(row: Record<string, unknown>): Project {
362
+ return {
363
+ id: String(row.id),
364
+ name: String(row.name),
365
+ computer_id: String(row.computer_id),
366
+ computer_name: row.computer_name === null || row.computer_name === undefined ? null : String(row.computer_name),
367
+ root_path: String(row.root_path),
368
+ room_count: Number(row.room_count ?? 0),
369
+ created_at: String(row.created_at),
370
+ updated_at: String(row.updated_at),
371
+ };
372
+ }
373
+
357
374
  function rowToMessage(row: Record<string, unknown>): Message {
358
375
  return {
359
376
  id: Number(row.id),
@@ -529,6 +546,16 @@ function rowToChannelAccount(row: Record<string, unknown>): ChannelAccount {
529
546
  };
530
547
  }
531
548
 
549
+ function rowToLarkAuthorizedUser(row: Record<string, unknown>): LarkAuthorizedUser {
550
+ return {
551
+ id: String(row.id),
552
+ user_id: String(row.user_id),
553
+ display_name: row.display_name === null || row.display_name === undefined ? null : String(row.display_name),
554
+ created_at: String(row.created_at),
555
+ updated_at: String(row.updated_at),
556
+ };
557
+ }
558
+
532
559
  function rowToPalIdentity(row: Record<string, unknown>): PalIdentity {
533
560
  return {
534
561
  id: String(row.id),
@@ -800,6 +827,68 @@ export class MessageStore {
800
827
  return this.getChatById(id)!;
801
828
  }
802
829
 
830
+ createProject(input: { name: string; computerId: string; rootPath: string }): Project {
831
+ const name = input.name.trim();
832
+ const computerId = input.computerId.trim();
833
+ const rootPath = input.rootPath.trim();
834
+ if (!name) throw new Error('project name is required');
835
+ if (!computerId) throw new Error('computer_id is required');
836
+ if (!rootPath) throw new Error('root_path is required');
837
+ if (!isAbsolute(rootPath)) throw new Error('root_path must be absolute');
838
+ const computer = this.getComputer(computerId);
839
+ if (!computer) throw new Error(`computer ${computerId} not found`);
840
+
841
+ const id = crypto.randomUUID();
842
+ this.db.query(`
843
+ INSERT INTO projects (id, name, computer_id, root_path, created_at, updated_at)
844
+ VALUES (?, ?, ?, ?, datetime('now'), datetime('now'))
845
+ `).run(id, name, computerId, rootPath);
846
+ return this.getProject(id)!;
847
+ }
848
+
849
+ getProject(id: string): Project | null {
850
+ const row = this.db.query(`
851
+ SELECT p.*, c.name AS computer_name, COUNT(ch.id) AS room_count
852
+ FROM projects p
853
+ LEFT JOIN computers c ON c.id = p.computer_id
854
+ LEFT JOIN chats ch ON ch.project_id = p.id
855
+ WHERE p.id = ?
856
+ GROUP BY p.id
857
+ `).get(id.trim()) as Record<string, unknown> | null;
858
+ return row ? rowToProject(row) : null;
859
+ }
860
+
861
+ listProjects(limit = 50): Project[] {
862
+ const rows = this.db.query(`
863
+ SELECT p.*, c.name AS computer_name, COUNT(ch.id) AS room_count
864
+ FROM projects p
865
+ LEFT JOIN computers c ON c.id = p.computer_id
866
+ LEFT JOIN chats ch ON ch.project_id = p.id
867
+ GROUP BY p.id
868
+ ORDER BY p.updated_at DESC, p.created_at DESC
869
+ LIMIT ?
870
+ `).all(limitValue(limit, 50)) as Record<string, unknown>[];
871
+ return rows.map(rowToProject);
872
+ }
873
+
874
+ createProjectRoom(input: { projectId: string; name: string; kind?: ChatKind }): Chat {
875
+ const project = this.getProject(input.projectId);
876
+ if (!project) throw new Error(`project ${input.projectId} was not found`);
877
+ const kind = input.kind ?? 'group';
878
+ if (kind !== 'group' && kind !== 'dm') throw new Error('kind must be group or dm');
879
+ const chatName = normalizeChatName(input.name);
880
+ if (!chatName) throw new Error('room name is required');
881
+ if (this.getChatByName(chatName)) throw new Error(`room ${chatName} already exists`);
882
+
883
+ const id = crypto.randomUUID();
884
+ this.db.query(`
885
+ INSERT INTO chats (id, name, kind, provider, capabilities_json, project_id)
886
+ VALUES (?, ?, ?, 'web', ?, ?)
887
+ `).run(id, chatName, kind, kind === 'dm' ? '{"topics":"unsupported"}' : '{"topics":"native"}', project.id);
888
+ this.db.query(`UPDATE projects SET updated_at = datetime('now') WHERE id = ?`).run(project.id);
889
+ return this.getChatById(id)!;
890
+ }
891
+
803
892
  updateRoomDisplayName(roomId: string, displayName: string | null): Chat {
804
893
  const room = this.getChatById(roomId);
805
894
  if (!room) throw new Error(`room ${roomId} was not found`);
@@ -852,6 +941,43 @@ export class MessageStore {
852
941
  return this.db.query('SELECT * FROM chat_stats ORDER BY COALESCE(last_message_at, created_at) DESC').all() as Chat[];
853
942
  }
854
943
 
944
+ listLarkAuthorizedUsers(): LarkAuthorizedUser[] {
945
+ const rows = this.db.query('SELECT * FROM lark_authorized_users ORDER BY COALESCE(display_name, user_id), user_id').all() as Record<string, unknown>[];
946
+ return rows.map(rowToLarkAuthorizedUser);
947
+ }
948
+
949
+ upsertLarkAuthorizedUser(input: { userId: string; displayName?: string | null }): LarkAuthorizedUser {
950
+ const userId = input.userId.trim();
951
+ if (!userId) throw new Error('lark user_id is required');
952
+ const id = crypto.randomUUID();
953
+ this.db.query(`
954
+ INSERT INTO lark_authorized_users (id, user_id, display_name, created_at, updated_at)
955
+ VALUES (?, ?, ?, datetime('now'), datetime('now'))
956
+ ON CONFLICT(user_id) DO UPDATE SET
957
+ display_name = excluded.display_name,
958
+ updated_at = datetime('now')
959
+ `).run(id, userId, input.displayName?.trim() || null);
960
+ return this.getLarkAuthorizedUser(userId)!;
961
+ }
962
+
963
+ getLarkAuthorizedUser(userId: string): LarkAuthorizedUser | null {
964
+ const normalized = userId.trim();
965
+ if (!normalized) return null;
966
+ const row = this.db.query('SELECT * FROM lark_authorized_users WHERE user_id = ?').get(normalized) as Record<string, unknown> | null;
967
+ return row ? rowToLarkAuthorizedUser(row) : null;
968
+ }
969
+
970
+ isLarkAuthorizedUser(userId: string | null | undefined): boolean {
971
+ if (!userId?.trim()) return false;
972
+ return this.getLarkAuthorizedUser(userId) !== null;
973
+ }
974
+
975
+ deleteLarkAuthorizedUser(userId: string): boolean {
976
+ const normalized = userId.trim();
977
+ if (!normalized) return false;
978
+ return this.db.query('DELETE FROM lark_authorized_users WHERE user_id = ?').run(normalized).changes > 0;
979
+ }
980
+
855
981
  listLarkGroupRoomMappings(): LarkGroupRoomMapping[] {
856
982
  const rows = this.db.query(`
857
983
  SELECT DISTINCT
@@ -2146,7 +2272,7 @@ export class MessageStore {
2146
2272
  VALUES (?, ?, ?, 'offline', NULL, NULL, datetime('now'))
2147
2273
  `).run(id, name, hashSecret(apiKey));
2148
2274
 
2149
- const packageName = input.packageName?.trim() || '@slock-ai/daemon@latest';
2275
+ const packageName = input.packageName?.trim() || '@controlflow-ai/daemon@latest';
2150
2276
  const serverUrl = input.serverUrl?.trim() || 'http://127.0.0.1:4127';
2151
2277
  const command = `npx ${packageName} --server-url ${serverUrl} --api-key ${apiKey} # ${name}`;
2152
2278
  return { computer: this.getComputer(id)!, api_key: apiKey, command };
@@ -2167,6 +2293,7 @@ export class MessageStore {
2167
2293
 
2168
2294
  const connectionId = crypto.randomUUID();
2169
2295
  const token = generateConnectionToken();
2296
+ const localControlToken = generateConnectionToken();
2170
2297
  const tokenHash = hashSecret(token);
2171
2298
  const revokedRows = this.db.query(`
2172
2299
  SELECT id FROM computer_connections
@@ -2221,9 +2348,9 @@ export class MessageStore {
2221
2348
  }
2222
2349
  }
2223
2350
  this.db.query(`
2224
- INSERT INTO computer_connections (id, computer_id, token_hash, epoch, status, connected_at, last_heartbeat_at)
2225
- VALUES (?, ?, ?, ?, 'active', datetime('now'), datetime('now'))
2226
- `).run(connectionId, computerId, tokenHash, epoch);
2351
+ INSERT INTO computer_connections (id, computer_id, token_hash, local_control_token, epoch, status, connected_at, last_heartbeat_at)
2352
+ VALUES (?, ?, ?, ?, ?, 'active', datetime('now'), datetime('now'))
2353
+ `).run(connectionId, computerId, tokenHash, localControlToken, epoch);
2227
2354
  this.db.query(`
2228
2355
  INSERT INTO daemon_instances (id, name, host, local_url, server_url, status, last_seen_at)
2229
2356
  VALUES (?, ?, ?, ?, ?, 'online', datetime('now'))
@@ -2254,11 +2381,30 @@ export class MessageStore {
2254
2381
  computer: this.getComputer(computerId)!,
2255
2382
  connection: this.getComputerConnection(connectionId)!,
2256
2383
  token,
2384
+ localControlToken,
2257
2385
  daemon: this.getDaemon(connectionId)!,
2258
2386
  agents: this.listComputerAgentAssignments(computerId),
2259
2387
  };
2260
2388
  }
2261
2389
 
2390
+ getComputerLocalControl(computerId: string): { computer: Computer; connection: ComputerConnection; daemon: DaemonInstance; token: string } | null {
2391
+ const computer = this.getComputer(computerId);
2392
+ if (!computer || computer.status !== 'online' || !computer.active_connection_id) return null;
2393
+ const connection = this.getComputerConnection(computer.active_connection_id);
2394
+ if (!connection || connection.status !== 'active') return null;
2395
+ const row = this.db.query(`
2396
+ SELECT local_control_token
2397
+ FROM computer_connections
2398
+ WHERE id = ? AND computer_id = ? AND status = 'active'
2399
+ LIMIT 1
2400
+ `).get(connection.id, computer.id) as { local_control_token: string | null } | null;
2401
+ const token = row?.local_control_token?.trim();
2402
+ if (!token) return null;
2403
+ const daemon = this.getDaemon(connection.id);
2404
+ if (!daemon || daemon.status !== 'online' || !daemon.local_url.trim()) return null;
2405
+ return { computer, connection, daemon, token };
2406
+ }
2407
+
2262
2408
  closeStaleComputerConnections(timeoutMs: number, now = new Date()): number {
2263
2409
  if (!Number.isFinite(timeoutMs) || timeoutMs < 0) throw new Error('timeoutMs must be non-negative');
2264
2410
  const cutoff = new Date(now.getTime() - timeoutMs).toISOString();
@@ -2431,7 +2577,11 @@ export class MessageStore {
2431
2577
  if (!room) throw new Error(`room ${message.chat_id} was not found`);
2432
2578
  const candidates = new Set<string>();
2433
2579
  if (message.recipient && this.getAgent(message.recipient)) candidates.add(message.recipient);
2580
+ if ((message.mentions ?? []).includes(ALL_AGENTS_MENTION)) {
2581
+ for (const agent of this.listRoomAgents(room)) candidates.add(agent);
2582
+ }
2434
2583
  for (const mention of message.mentions ?? []) {
2584
+ if (mention === ALL_AGENTS_MENTION) continue;
2435
2585
  if (this.getAgent(mention)) candidates.add(mention);
2436
2586
  }
2437
2587
  if (room.kind === 'dm') {
@@ -2460,9 +2610,34 @@ export class MessageStore {
2460
2610
  return deliveries;
2461
2611
  }
2462
2612
 
2613
+ private listRoomAgents(room: Chat): string[] {
2614
+ if (room.provider === 'web') {
2615
+ const rows = this.db.query(`
2616
+ SELECT participant_id AS agent
2617
+ FROM room_participants
2618
+ WHERE room_id = ? AND kind = 'agent' AND status = 'active'
2619
+ `).all(room.id) as Array<{ agent: string }>;
2620
+ return rows.map((row) => row.agent).filter((agent) => Boolean(this.getAgent(agent)));
2621
+ }
2622
+
2623
+ const rows = this.db.query(`
2624
+ SELECT DISTINCT pa.agent AS agent
2625
+ FROM provider_accounts pa
2626
+ INNER JOIN provider_conversations pc ON pc.provider_account_id = pa.id
2627
+ WHERE pa.provider = ? AND pa.status = 'active' AND pc.room_id = ?
2628
+ UNION
2629
+ SELECT DISTINCT ca.agent AS agent
2630
+ FROM channel_accounts ca
2631
+ INNER JOIN channel_conversations cc ON cc.channel_account_id = ca.id
2632
+ WHERE ca.channel = ? AND ca.status = 'active' AND cc.lock_chat_id = ? AND ca.agent IS NOT NULL AND ca.agent != ''
2633
+ `).all(room.provider, room.id, room.provider, room.id) as Array<{ agent: string }>;
2634
+ return rows.map((row) => row.agent).filter((agent) => Boolean(this.getAgent(agent)));
2635
+ }
2636
+
2463
2637
  private shouldCreateDeliveryForAgent(message: Message, room: Chat, agent: string): boolean {
2464
2638
  if (!this.canAgentParticipateInRoom(agent, room)) return false;
2465
- const direct = message.recipient === agent || (message.mentions ?? []).includes(agent);
2639
+ const allAgentsMentioned = (message.mentions ?? []).includes(ALL_AGENTS_MENTION);
2640
+ const direct = allAgentsMentioned || message.recipient === agent || (message.mentions ?? []).includes(agent);
2466
2641
  if (room.kind === 'dm') return direct || message.recipient === null;
2467
2642
  const subscription = this.getAgentRoomSubscription(agent, room.id);
2468
2643
  const mode = subscription?.mode ?? 'mentions';
@@ -0,0 +1,141 @@
1
+ export type LarkRegistrationTenantBrand = 'feishu' | 'lark';
2
+
3
+ export interface LarkRegistrationBegin {
4
+ deviceCode: string;
5
+ url: string;
6
+ expiresIn: number;
7
+ interval: number;
8
+ }
9
+
10
+ export interface LarkRegistrationComplete {
11
+ appId: string;
12
+ appSecret: string;
13
+ tenantBrand: LarkRegistrationTenantBrand;
14
+ userOpenId?: string;
15
+ }
16
+
17
+ export type LarkRegistrationPollResult =
18
+ | { status: 'pending' }
19
+ | { status: 'slow_down'; interval: number }
20
+ | { status: 'complete'; registration: LarkRegistrationComplete }
21
+ | { status: 'denied' | 'expired' | 'error'; message: string };
22
+
23
+ export type RegistrationFetchLike = (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
24
+
25
+ const FEISHU_ACCOUNTS_ORIGIN = 'https://accounts.feishu.cn';
26
+ const LARK_ACCOUNTS_ORIGIN = 'https://accounts.larksuite.com';
27
+ const REGISTRATION_PATH = '/oauth/v1/app/registration';
28
+
29
+ interface RegistrationBeginBody {
30
+ device_code?: unknown;
31
+ verification_uri_complete?: unknown;
32
+ expires_in?: unknown;
33
+ interval?: unknown;
34
+ error?: unknown;
35
+ error_description?: unknown;
36
+ }
37
+
38
+ interface RegistrationPollBody {
39
+ client_id?: unknown;
40
+ client_secret?: unknown;
41
+ user_info?: { tenant_brand?: unknown; open_id?: unknown };
42
+ error?: unknown;
43
+ error_description?: unknown;
44
+ }
45
+
46
+ function sanitizeMessage(message: unknown): string {
47
+ return String(message ?? 'unknown').replace(/[A-Za-z0-9_-]{30,}/g, '***');
48
+ }
49
+
50
+ async function postRegistration(origin: string, params: Record<string, string>, fetchImpl: RegistrationFetchLike): Promise<unknown> {
51
+ const response = await fetchImpl(`${origin}${REGISTRATION_PATH}`, {
52
+ method: 'POST',
53
+ headers: { 'content-type': 'application/x-www-form-urlencoded' },
54
+ body: new URLSearchParams(params).toString(),
55
+ });
56
+ const body = await response.json().catch(() => ({}));
57
+ if (!response.ok && !(body && typeof body === 'object' && 'error' in body)) {
58
+ throw new Error(`registration request failed: HTTP ${response.status}`);
59
+ }
60
+ return body;
61
+ }
62
+
63
+ export async function beginLarkAppRegistration(options: {
64
+ fetchImpl?: RegistrationFetchLike;
65
+ source?: string;
66
+ } = {}): Promise<LarkRegistrationBegin> {
67
+ const fetchImpl = options.fetchImpl ?? fetch;
68
+ const body = await postRegistration(FEISHU_ACCOUNTS_ORIGIN, {
69
+ action: 'begin',
70
+ archetype: 'PersonalAgent',
71
+ auth_method: 'client_secret',
72
+ request_user_info: 'open_id',
73
+ }, fetchImpl) as RegistrationBeginBody;
74
+
75
+ if (typeof body.error === 'string') {
76
+ throw new Error(sanitizeMessage(body.error_description ?? body.error));
77
+ }
78
+ if (typeof body.device_code !== 'string' || typeof body.verification_uri_complete !== 'string') {
79
+ throw new Error('registration begin response did not include device_code and verification_uri_complete');
80
+ }
81
+
82
+ const url = new URL(body.verification_uri_complete);
83
+ url.searchParams.set('from', 'sdk');
84
+ url.searchParams.set('source', `node-sdk/${options.source ?? 'pal'}`);
85
+ url.searchParams.set('tp', 'sdk');
86
+
87
+ return {
88
+ deviceCode: body.device_code,
89
+ url: url.toString(),
90
+ expiresIn: typeof body.expires_in === 'number' ? body.expires_in : 600,
91
+ interval: typeof body.interval === 'number' ? body.interval : 5,
92
+ };
93
+ }
94
+
95
+ export async function pollLarkAppRegistration(options: {
96
+ deviceCode: string;
97
+ fetchImpl?: RegistrationFetchLike;
98
+ origin?: 'feishu' | 'lark';
99
+ }): Promise<LarkRegistrationPollResult> {
100
+ const fetchImpl = options.fetchImpl ?? fetch;
101
+ const origin = options.origin === 'lark' ? LARK_ACCOUNTS_ORIGIN : FEISHU_ACCOUNTS_ORIGIN;
102
+ let body: RegistrationPollBody;
103
+ try {
104
+ body = await postRegistration(origin, {
105
+ action: 'poll',
106
+ device_code: options.deviceCode,
107
+ }, fetchImpl) as RegistrationPollBody;
108
+ } catch (error) {
109
+ return {
110
+ status: 'error',
111
+ message: error instanceof Error ? sanitizeMessage(error.message) : sanitizeMessage(error),
112
+ };
113
+ }
114
+
115
+ if (typeof body.client_id === 'string' && typeof body.client_secret === 'string') {
116
+ const tenantBrand: LarkRegistrationTenantBrand = body.user_info?.tenant_brand === 'lark' ? 'lark' : 'feishu';
117
+ const openId = typeof body.user_info?.open_id === 'string' && body.user_info.open_id.startsWith('ou_')
118
+ ? body.user_info.open_id
119
+ : undefined;
120
+ return {
121
+ status: 'complete',
122
+ registration: {
123
+ appId: body.client_id,
124
+ appSecret: body.client_secret,
125
+ tenantBrand,
126
+ userOpenId: openId,
127
+ },
128
+ };
129
+ }
130
+
131
+ if (body.user_info?.tenant_brand === 'lark' && options.origin !== 'lark') {
132
+ return pollLarkAppRegistration({ ...options, origin: 'lark' });
133
+ }
134
+
135
+ const error = typeof body.error === 'string' ? body.error : '';
136
+ if (error === 'authorization_pending' || !error) return { status: 'pending' };
137
+ if (error === 'slow_down') return { status: 'slow_down', interval: 10 };
138
+ if (error === 'access_denied') return { status: 'denied', message: 'user denied app registration' };
139
+ if (error === 'expired_token') return { status: 'expired', message: 'registration QR code expired' };
140
+ return { status: 'error', message: sanitizeMessage(body.error_description ?? error) };
141
+ }
package/src/lark/cli.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { readFileSync } from 'node:fs';
2
- import { defaultDbPath, defaultServerUrl } from '../config.js';
2
+ import { defaultDbPath } from '../config.js';
3
3
  import { MessageStore } from '../db.js';
4
4
  import {
5
5
  boundAgents,
@@ -11,12 +11,6 @@ import { countInboundEvents, listRecentInboundEvents } from './inbound-events.js
11
11
  import { extractMentionOpenIds, ingestLarkMessage, type LarkMessageEnvelope } from './event-router.js';
12
12
  import { ChatDispatcher, chatKeyOf, parseReceivePolicy, PeriodicQueue, shouldAcceptForAgent, type DispatchInput, type ReceivePolicy } from './dispatcher.js';
13
13
  import { parseRuntimeSpec } from './agent-runtime.js';
14
- import {
15
- formatLarkSetupNextSteps,
16
- persistLarkCredential,
17
- resolveLarkBotInfo,
18
- runInteractiveLarkSetup,
19
- } from './setup.js';
20
14
  import { createLarkApiClient, sendTextMessage, startLarkDaemon } from './ws-daemon.js';
21
15
 
22
16
  export interface LarkCliArgs {
@@ -44,69 +38,6 @@ function flagBool(flags: Record<string, unknown>, key: string): boolean {
44
38
  return flags[key] === true;
45
39
  }
46
40
 
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
41
  export async function runLarkCli(options: RunOptions): Promise<number> {
111
42
  const { argv } = options;
112
43
  const log = options.log ?? {};
@@ -118,58 +49,8 @@ export async function runLarkCli(options: RunOptions): Promise<number> {
118
49
  }
119
50
 
120
51
  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;
52
+ (log.error ?? console.error)('lark setup has been removed. Bind or rebind bots with "bun run console -- agents update --key <agent> --lark-app-id <id> --lark-app-secret <secret> [--rebind-lark]".');
53
+ return 2;
173
54
  }
174
55
 
175
56
  if (sub === 'list') {
@@ -223,17 +104,6 @@ function printLarkUsage(log: NonNullable<RunOptions['log']>): void {
223
104
  (log.log ?? console.log)(`pal lark <subcommand> [flags]
224
105
 
225
106
  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
107
  list [--config <path>]
238
108
  List configured bots (secrets redacted).
239
109
 
@@ -281,7 +151,7 @@ async function runDaemon(argv: LarkCliArgs, log: NonNullable<RunOptions['log']>)
281
151
  }
282
152
  const store = loadLarkCredentials(configPath);
283
153
  if (store.bots.length === 0) {
284
- (log.error ?? console.error)(`No bots configured in ${configPath}. Run pal lark setup first.`);
154
+ (log.error ?? console.error)(`No bots configured in ${configPath}. Bind a bot with "bun run console -- agents update --key <agent> --lark-app-id <id> --lark-app-secret <secret>" first.`);
285
155
  return 2;
286
156
  }
287
157
  let targets = store.bots;
@@ -40,18 +40,51 @@ export function saveLarkCredentials(store: LarkCredentialStore, path: string = d
40
40
  export interface AddCredentialResult {
41
41
  store: LarkCredentialStore;
42
42
  replaced: boolean;
43
+ unbound: LarkCredential[];
43
44
  }
44
45
 
45
- export function upsertCredential(store: LarkCredentialStore, credential: LarkCredential): AddCredentialResult {
46
+ export function findCredentialByAgent(store: LarkCredentialStore, agent: string): LarkCredential | undefined {
47
+ const key = agent.trim();
48
+ if (!key) return undefined;
49
+ return store.bots.find((bot) => bot.agent?.trim() === key);
50
+ }
51
+
52
+ export function unbindCredentialAgent(store: LarkCredentialStore, agent: string): { store: LarkCredentialStore; changed: boolean; unbound?: LarkCredential } {
53
+ const key = agent.trim();
54
+ if (!key) throw new Error('agent is required');
55
+ const index = store.bots.findIndex((bot) => bot.agent?.trim() === key);
56
+ if (index === -1) return { store: { bots: [...store.bots] }, changed: false };
57
+ const next: LarkCredentialStore = { bots: [...store.bots] };
58
+ const existing = next.bots[index]!;
59
+ const { agent: _agent, ...withoutAgent } = existing;
60
+ next.bots[index] = withoutAgent;
61
+ return { store: next, changed: true, unbound: existing };
62
+ }
63
+
64
+ export function upsertCredential(store: LarkCredentialStore, credential: LarkCredential, options: { rebind?: boolean } = {}): AddCredentialResult {
46
65
  validateCredential(credential);
47
66
  const existingIndex = store.bots.findIndex((bot) => bot.appId === credential.appId);
48
67
  const next: LarkCredentialStore = { bots: [...store.bots] };
68
+ const unbound: LarkCredential[] = [];
69
+ const agent = credential.agent?.trim();
70
+ if (agent) {
71
+ const existingAgentIndex = next.bots.findIndex((bot, index) => index !== existingIndex && bot.agent?.trim() === agent);
72
+ if (existingAgentIndex !== -1) {
73
+ const existing = next.bots[existingAgentIndex]!;
74
+ if (!options.rebind) {
75
+ throw new Error(`agent ${agent} is already bound to Lark app ${existing.appId}; pass --rebind-lark to move the binding`);
76
+ }
77
+ const { agent: _agent, ...withoutAgent } = existing;
78
+ next.bots[existingAgentIndex] = withoutAgent;
79
+ unbound.push(existing);
80
+ }
81
+ }
49
82
  if (existingIndex === -1) {
50
83
  next.bots.push(credential);
51
- return { store: next, replaced: false };
84
+ return { store: next, replaced: false, unbound };
52
85
  }
53
86
  next.bots[existingIndex] = credential;
54
- return { store: next, replaced: true };
87
+ return { store: next, replaced: true, unbound };
55
88
  }
56
89
 
57
90
  export function findCredential(store: LarkCredentialStore, appId: string): LarkCredential | undefined {