@controlflow-ai/daemon 0.1.1 → 0.1.3

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 (65) hide show
  1. package/README.md +66 -24
  2. package/package.json +16 -3
  3. package/src/agent-avatar.ts +30 -0
  4. package/src/agent-key.ts +28 -0
  5. package/src/agent-permissions.ts +359 -0
  6. package/src/agent-runtime.ts +810 -28
  7. package/src/agent-workspace.ts +183 -0
  8. package/src/app.ts +2183 -79
  9. package/src/args.ts +54 -7
  10. package/src/cli.ts +873 -14
  11. package/src/client.ts +482 -12
  12. package/src/coco.ts +9 -40
  13. package/src/codex.ts +33 -5
  14. package/src/config.ts +28 -4
  15. package/src/console.ts +460 -26
  16. package/src/daemon-client.ts +116 -3
  17. package/src/daemon.ts +958 -101
  18. package/src/db.ts +3216 -113
  19. package/src/delivery-ws.ts +269 -0
  20. package/src/format.ts +4 -1
  21. package/src/lark/app-registration.ts +141 -0
  22. package/src/lark/cli.ts +7 -137
  23. package/src/lark/credentials.ts +36 -3
  24. package/src/lark/event-router.ts +61 -5
  25. package/src/lark/inbound-events.ts +156 -3
  26. package/src/lark/server-integration.ts +659 -111
  27. package/src/lark/setup.ts +74 -5
  28. package/src/lark/ws-daemon.ts +136 -10
  29. package/src/local-api.ts +611 -14
  30. package/src/local-auth.ts +36 -3
  31. package/src/message-attachments.ts +71 -0
  32. package/src/messaging-cli.ts +741 -0
  33. package/src/messaging-status.ts +669 -0
  34. package/src/migrations/023_projects.ts +65 -0
  35. package/src/migrations/024_agents_model.ts +10 -0
  36. package/src/migrations/025_room_archive.ts +44 -0
  37. package/src/migrations/026_project_archive.ts +44 -0
  38. package/src/migrations/027_agent_permission_profiles.ts +16 -0
  39. package/src/migrations/028_lark_websocket_restart_state.ts +16 -0
  40. package/src/migrations/029_held_message_drafts.ts +32 -0
  41. package/src/migrations/030_agent_room_read_state.ts +25 -0
  42. package/src/migrations/031_room_tasks.ts +29 -0
  43. package/src/migrations/032_room_reminders.ts +29 -0
  44. package/src/migrations/033_room_saved_messages.ts +25 -0
  45. package/src/migrations/034_agent_activity_events.ts +27 -0
  46. package/src/migrations/035_agent_avatars.ts +17 -0
  47. package/src/migrations/036_project_agent_defaults.ts +21 -0
  48. package/src/migrations/037_message_attachments.ts +36 -0
  49. package/src/migrations/038_agent_activity_room_scope.ts +64 -0
  50. package/src/migrations/039_message_attachments_path.ts +34 -0
  51. package/src/migrations/040_message_attachments_file_schema.ts +80 -0
  52. package/src/migrations/041_room_system_events.ts +30 -0
  53. package/src/migrations/042_message_attachment_file_kind.ts +52 -0
  54. package/src/migrations/043_room_mode_skill_registry.ts +92 -0
  55. package/src/migrations/044_workflow_runtime.ts +69 -0
  56. package/src/migrations/045_skill_repository_ownership.ts +64 -0
  57. package/src/migrations.ts +70 -1
  58. package/src/neeko.ts +40 -4
  59. package/src/runtime-env.ts +179 -0
  60. package/src/runtime-registry.ts +83 -13
  61. package/src/server.ts +244 -4
  62. package/src/token-file.ts +13 -6
  63. package/src/types.ts +394 -0
  64. package/src/workflow-runtime.ts +275 -0
  65. package/src/web.ts +0 -904
@@ -0,0 +1,269 @@
1
+ import type { Server, ServerWebSocket } from 'bun';
2
+ import { MessageStore } from './db.js';
3
+ import { failure, HttpError } from './http.js';
4
+ import type { MessageDelivery } from './types.js';
5
+ import type { DeliveryConnectionStats, DeliveryWebSocketSummary } from './messaging-status.js';
6
+
7
+ export interface DeliveryWebSocketData {
8
+ kind: 'daemon-delivery';
9
+ computerId: string;
10
+ connectionId: string;
11
+ }
12
+
13
+ export type DeliveryWebSocketFrame =
14
+ | { type: 'hello'; computer_id: string; connection_id: string }
15
+ | { type: 'delivery'; delivery: Pick<MessageDelivery, 'id' | 'message_id' | 'chat_id' | 'agent' | 'status'> }
16
+ | { type: 'pending'; agent: string; pending?: number }
17
+ | { type: 'pong'; at: string };
18
+
19
+ export interface DeliveryNotifyResult {
20
+ deliveries: number;
21
+ target_connections: number;
22
+ open_sockets: number;
23
+ websocket_frames: number;
24
+ }
25
+
26
+ export type { DeliveryConnectionStats, DeliveryWebSocketSummary } from './messaging-status.js';
27
+
28
+ export interface DeliveryWebSocketHubOptions {
29
+ logger?: Partial<Pick<Console, 'log' | 'warn'>>;
30
+ staleConnectionTimeoutMs?: number | null;
31
+ }
32
+
33
+ interface DeliveryConnectionTelemetry {
34
+ computerId: string | null;
35
+ lastOpenAt: string | null;
36
+ lastCloseAt: string | null;
37
+ lastPingAt: string | null;
38
+ lastPongAt: string | null;
39
+ lastCloseCode: number | null;
40
+ lastCloseReason: string | null;
41
+ }
42
+
43
+ function connectionAuthFromRequest(request: Request): { computerId: string; connectionId: string; token: string } {
44
+ const url = new URL(request.url);
45
+ const computerId = request.headers.get('x-pal-computer-id')?.trim() || url.searchParams.get('computer_id')?.trim();
46
+ const connectionId = request.headers.get('x-pal-connection-id')?.trim() || url.searchParams.get('connection_id')?.trim();
47
+ const token = request.headers.get('x-pal-connection-token')?.trim() || url.searchParams.get('token')?.trim();
48
+ if (!computerId || !connectionId || !token) {
49
+ throw new HttpError(401, 'MISSING_CONNECTION_AUTH', 'computer connection auth is required');
50
+ }
51
+ return { computerId, connectionId, token };
52
+ }
53
+
54
+ function sendFrame(ws: ServerWebSocket<DeliveryWebSocketData>, frame: DeliveryWebSocketFrame): boolean {
55
+ try {
56
+ return ws.send(JSON.stringify(frame)) > 0;
57
+ } catch {
58
+ return false;
59
+ }
60
+ }
61
+
62
+ export class DeliveryWebSocketHub {
63
+ private readonly socketsByConnection = new Map<string, Set<ServerWebSocket<DeliveryWebSocketData>>>();
64
+ private readonly telemetryByConnection = new Map<string, DeliveryConnectionTelemetry>();
65
+ private readonly log: Pick<Console, 'log' | 'warn'>;
66
+ private readonly staleConnectionTimeoutMs: number | null;
67
+
68
+ constructor(private readonly store: MessageStore, options: DeliveryWebSocketHubOptions = {}) {
69
+ this.log = {
70
+ log: options.logger?.log ?? console.log,
71
+ warn: options.logger?.warn ?? console.warn,
72
+ };
73
+ this.staleConnectionTimeoutMs = typeof options.staleConnectionTimeoutMs === 'number' && Number.isFinite(options.staleConnectionTimeoutMs) && options.staleConnectionTimeoutMs >= 0
74
+ ? options.staleConnectionTimeoutMs
75
+ : null;
76
+ }
77
+
78
+ pruneStaleConnections(now = new Date()): number {
79
+ if (this.staleConnectionTimeoutMs === null) return 0;
80
+ const closed = this.store.closeStaleComputerConnections(this.staleConnectionTimeoutMs, now);
81
+ if (closed > 0) this.log.warn(`[delivery/ws] closed ${closed} stale computer connection(s)`);
82
+ return closed;
83
+ }
84
+
85
+ private telemetry(connectionId: string, computerId: string | null): DeliveryConnectionTelemetry {
86
+ let telemetry = this.telemetryByConnection.get(connectionId);
87
+ if (!telemetry) {
88
+ telemetry = {
89
+ computerId,
90
+ lastOpenAt: null,
91
+ lastCloseAt: null,
92
+ lastPingAt: null,
93
+ lastPongAt: null,
94
+ lastCloseCode: null,
95
+ lastCloseReason: null,
96
+ };
97
+ this.telemetryByConnection.set(connectionId, telemetry);
98
+ } else if (computerId) {
99
+ telemetry.computerId = computerId;
100
+ }
101
+ return telemetry;
102
+ }
103
+
104
+ handleUpgrade(request: Request, server: Server<DeliveryWebSocketData>): Response | null {
105
+ const url = new URL(request.url);
106
+ if (request.method !== 'GET' || url.pathname !== '/api/daemon/ws') return null;
107
+ try {
108
+ const auth = connectionAuthFromRequest(request);
109
+ const connection = this.store.assertActiveComputerConnection({
110
+ computerId: auth.computerId,
111
+ connectionId: auth.connectionId,
112
+ token: auth.token,
113
+ });
114
+ const upgraded = server.upgrade(request, {
115
+ data: {
116
+ kind: 'daemon-delivery',
117
+ computerId: connection.computer_id,
118
+ connectionId: connection.id,
119
+ },
120
+ });
121
+ return upgraded
122
+ ? new Response(null)
123
+ : new Response('websocket upgrade failed', { status: 400 });
124
+ } catch (error) {
125
+ return failure(error);
126
+ }
127
+ }
128
+
129
+ websocket = {
130
+ open: (ws: ServerWebSocket<DeliveryWebSocketData>) => {
131
+ const { connectionId, computerId } = ws.data;
132
+ let sockets = this.socketsByConnection.get(connectionId);
133
+ if (!sockets) {
134
+ sockets = new Set();
135
+ this.socketsByConnection.set(connectionId, sockets);
136
+ }
137
+ sockets.add(ws);
138
+ const telemetry = this.telemetry(connectionId, computerId);
139
+ telemetry.lastOpenAt = new Date().toISOString();
140
+ telemetry.lastCloseAt = null;
141
+ telemetry.lastCloseCode = null;
142
+ telemetry.lastCloseReason = null;
143
+ this.log.log(`[delivery/ws] open computer=${computerId} connection=${connectionId} sockets=${sockets.size}`);
144
+ sendFrame(ws, { type: 'hello', computer_id: computerId, connection_id: connectionId });
145
+ for (const { agent, pending } of this.store.listPendingDeliveryAgentsForConnection(connectionId)) {
146
+ sendFrame(ws, { type: 'pending', agent, pending });
147
+ }
148
+ },
149
+ message: (ws: ServerWebSocket<DeliveryWebSocketData>, data: string | Buffer) => {
150
+ if (String(data) === 'ping') {
151
+ const at = new Date().toISOString();
152
+ const telemetry = this.telemetry(ws.data.connectionId, ws.data.computerId);
153
+ telemetry.lastPingAt = at;
154
+ if (sendFrame(ws, { type: 'pong', at })) {
155
+ telemetry.lastPongAt = at;
156
+ }
157
+ }
158
+ },
159
+ close: (ws: ServerWebSocket<DeliveryWebSocketData>, code: number, reason: string) => {
160
+ const sockets = this.socketsByConnection.get(ws.data.connectionId);
161
+ if (sockets) {
162
+ sockets.delete(ws);
163
+ if (sockets.size === 0) this.socketsByConnection.delete(ws.data.connectionId);
164
+ }
165
+ const telemetry = this.telemetry(ws.data.connectionId, ws.data.computerId);
166
+ telemetry.lastCloseAt = new Date().toISOString();
167
+ telemetry.lastCloseCode = code;
168
+ telemetry.lastCloseReason = reason || null;
169
+ this.log.log(`[delivery/ws] close computer=${ws.data.computerId} connection=${ws.data.connectionId} code=${code} sockets=${sockets?.size ?? 0}`);
170
+ },
171
+ };
172
+
173
+ notifyDeliveries(deliveries: MessageDelivery[]): DeliveryNotifyResult {
174
+ this.pruneStaleConnections();
175
+ const targetConnections = new Set<string>();
176
+ const pendingAgentsByConnection = new Map<string, Set<string>>();
177
+ let pendingDeliveries = 0;
178
+ let openSockets = 0;
179
+ let websocketFrames = 0;
180
+ for (const delivery of deliveries) {
181
+ if (delivery.status !== 'pending') continue;
182
+ pendingDeliveries += 1;
183
+ for (const connectionId of this.store.listOnlineDaemonConnectionsForAgent(delivery.agent)) {
184
+ targetConnections.add(connectionId);
185
+ let pendingAgents = pendingAgentsByConnection.get(connectionId);
186
+ if (!pendingAgents) {
187
+ pendingAgents = new Set();
188
+ pendingAgentsByConnection.set(connectionId, pendingAgents);
189
+ }
190
+ pendingAgents.add(delivery.agent);
191
+ }
192
+ }
193
+
194
+ for (const [connectionId, agents] of pendingAgentsByConnection) {
195
+ const sockets = this.socketsByConnection.get(connectionId);
196
+ if (!sockets?.size) continue;
197
+ openSockets += sockets.size;
198
+ const pendingCounts = new Map(
199
+ this.store.listPendingDeliveryAgentsForConnection(connectionId).map((item) => [item.agent, item.pending]),
200
+ );
201
+ for (const agent of agents) {
202
+ for (const ws of sockets) {
203
+ if (sendFrame(ws, { type: 'pending', agent, pending: pendingCounts.get(agent) ?? 0 })) {
204
+ websocketFrames += 1;
205
+ }
206
+ }
207
+ }
208
+ }
209
+ return {
210
+ deliveries: pendingDeliveries,
211
+ target_connections: targetConnections.size,
212
+ open_sockets: openSockets,
213
+ websocket_frames: websocketFrames,
214
+ };
215
+ }
216
+
217
+ statsForConnection(connectionId: string): DeliveryConnectionStats {
218
+ const connection = this.store.getComputerConnection(connectionId);
219
+ const telemetry = this.telemetryByConnection.get(connectionId);
220
+ return {
221
+ connection_id: connectionId,
222
+ computer_id: connection?.computer_id ?? telemetry?.computerId ?? null,
223
+ open_sockets: this.socketsByConnection.get(connectionId)?.size ?? 0,
224
+ last_open_at: telemetry?.lastOpenAt ?? null,
225
+ last_close_at: telemetry?.lastCloseAt ?? null,
226
+ last_ping_at: telemetry?.lastPingAt ?? null,
227
+ last_pong_at: telemetry?.lastPongAt ?? null,
228
+ last_close_code: telemetry?.lastCloseCode ?? null,
229
+ last_close_reason: telemetry?.lastCloseReason ?? null,
230
+ pending_agents: this.store.listPendingDeliveryAgentsForConnection(connectionId),
231
+ backlog: this.store.listDeliveryBacklogForConnection(connectionId),
232
+ };
233
+ }
234
+
235
+ statsAllConnections(): DeliveryWebSocketSummary {
236
+ this.pruneStaleConnections();
237
+ const connections = this.store.listActiveComputerConnections()
238
+ .map((connection) => this.statsForConnection(connection.id));
239
+ return {
240
+ connections,
241
+ totals: connections.reduce((totals, connection) => {
242
+ totals.connections += 1;
243
+ totals.open_sockets += connection.open_sockets;
244
+ for (const item of connection.backlog) {
245
+ totals.pending_deliveries += item.pending;
246
+ totals.claimed_deliveries += item.claimed;
247
+ totals.processing_completed_deliveries += item.processing_completed;
248
+ totals.expired_active_deliveries += item.expired_active;
249
+ }
250
+ return totals;
251
+ }, {
252
+ connections: 0,
253
+ open_sockets: 0,
254
+ pending_deliveries: 0,
255
+ claimed_deliveries: 0,
256
+ processing_completed_deliveries: 0,
257
+ expired_active_deliveries: 0,
258
+ }),
259
+ };
260
+ }
261
+
262
+ close(): void {
263
+ for (const sockets of this.socketsByConnection.values()) {
264
+ for (const ws of sockets) ws.close(1001, 'server shutting down');
265
+ }
266
+ this.socketsByConnection.clear();
267
+ }
268
+
269
+ }
package/src/format.ts CHANGED
@@ -5,7 +5,10 @@ export function formatMessage(message: Message): string {
5
5
  const chatName = sanitizeProviderIds(message.chat_name);
6
6
  const target = message.parent_id === null ? `#${chatName}` : `#${chatName}:${message.parent_id}`;
7
7
  const to = message.recipient ? ` -> @${sanitizeProviderIds(message.recipient)}` : '';
8
- return `[${message.id} ${target} ${message.created_at}] @${sanitizeProviderIds(message.sender)}${to}: ${sanitizeProviderIds(message.content)}`;
8
+ const mentions = message.mentions?.length
9
+ ? ` -> ${message.mentions.map((mention) => `@${sanitizeProviderIds(mention)}`).join(',')}`
10
+ : '';
11
+ return `[${message.id} ${target} ${message.created_at}] @${sanitizeProviderIds(message.sender)}${to || mentions}: ${sanitizeProviderIds(message.content)}`;
9
12
  }
10
13
 
11
14
  export function formatMessages(messages: Message[]): string {
@@ -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;
@@ -325,17 +195,17 @@ async function runDaemon(argv: LarkCliArgs, log: NonNullable<RunOptions['log']>)
325
195
  let resolvedRuntime: string | null = null;
326
196
  if (agentSpec) {
327
197
  const directRuntime = agentSpec.split(':')[0];
328
- if (directRuntime === 'neeko' || directRuntime === 'coco' || directRuntime === 'coco-stream-json' || directRuntime === 'codex') {
198
+ if (directRuntime === 'neeko' || directRuntime === 'coco' || directRuntime === 'codex') {
329
199
  resolvedRuntime = directRuntime;
330
200
  } else {
331
201
  resolvedRuntime = msgStore.getAgentRuntime(agentSpec);
332
202
  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.`);
203
+ (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|codex" to configure.`);
334
204
  resolvedRuntime = 'neeko';
335
205
  }
336
206
  }
337
207
  }
338
- const isDeliveryAgent = resolvedRuntime === 'neeko' || resolvedRuntime === 'coco' || resolvedRuntime === 'coco-stream-json' || resolvedRuntime === 'codex';
208
+ const isDeliveryAgent = resolvedRuntime === 'neeko' || resolvedRuntime === 'coco' || resolvedRuntime === 'codex';
339
209
 
340
210
  const dispatchers = new Map<string, { dispatcher: ChatDispatcher; periodic: PeriodicQueue | null; chatIdToLarkChatId: Map<string, string> }>();
341
211
  if (agentSpec && !isDeliveryAgent) {
@@ -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 {