@controlflow-ai/daemon 0.1.0 → 0.1.1

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/README.md CHANGED
@@ -267,9 +267,17 @@ The daemon creates one local API for CLI calls, connects the computer to the ser
267
267
  | `LOCK_DAEMON_URL` | `http://127.0.0.1:4137` | Local daemon API URL used by `bun run cli` |
268
268
  | `LOCK_DAEMON_TOKEN` | token file fallback | Local daemon API bearer token |
269
269
  | `PAL_LARK_CONFIG` | `$PAL_HOME/lark.json` | Lark bot credential file |
270
- | `PAL_OWNER_LARK_UNION_ID` | - | Optional Lark sender allowlist for business ingest |
271
270
  | `PAL_LARK_ACTION_REACTION_EMOJI` | `Typing` | Reaction added when Lark delivery is created |
272
271
 
272
+ Lark sender authorization is stored in Pal's database. Manage authorized Lark
273
+ users from the Web Settings Access tab or with:
274
+
275
+ ```bash
276
+ bun run console -- lark-users list
277
+ bun run console -- lark-users add --user-id <union-id> --name "Display Name"
278
+ bun run console -- lark-users delete --user-id <union-id>
279
+ ```
280
+
273
281
  ## Runtime Semantics
274
282
 
275
283
  - Rooms are the primary conversation container; `chat` remains as a compatibility alias in several APIs.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@controlflow-ai/daemon",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "server": "bun run src/server.ts",
package/src/app.ts CHANGED
@@ -5,7 +5,9 @@ import { assertServerAuth } from './server-auth.js';
5
5
  import { dashboardHtml } from './web.js';
6
6
  import type { RunAction, RunStatus } from './types.js';
7
7
  import { createLarkApiClient, sendTextMessage } from './lark/ws-daemon.js';
8
- import { boundAgents, loadLarkCredentials, type LarkCredentialStore } from './lark/credentials.js';
8
+ import { boundAgents, defaultLarkConfigPath, loadLarkCredentials, type LarkCredentialStore } from './lark/credentials.js';
9
+ import { persistLarkCredential, resolveLarkBotInfo } from './lark/setup.js';
10
+ import { tailscaleAddress } from './network.js';
9
11
 
10
12
  interface SendBody {
11
13
  chat?: string;
@@ -81,6 +83,19 @@ interface OnboardAgentBody {
81
83
  computer_id?: string;
82
84
  }
83
85
 
86
+ interface LarkSetupBody {
87
+ app_id?: string;
88
+ app_secret?: string;
89
+ label?: string;
90
+ agent?: string;
91
+ config?: string;
92
+ }
93
+
94
+ interface LarkAuthorizedUserBody {
95
+ user_id?: string;
96
+ display_name?: string | null;
97
+ }
98
+
84
99
  interface CreateDeliveryBody {
85
100
  message_id?: number;
86
101
  agent?: string;
@@ -192,6 +207,17 @@ export function route(store: MessageStore, request: Request): Promise<Response>
192
207
  return json({ status: 'ok' });
193
208
  }
194
209
 
210
+ if (request.method === 'GET' && pathname === '/api/server/access') {
211
+ const requestUrl = new URL(request.url);
212
+ const port = requestUrl.port || (requestUrl.protocol === 'https:' ? '443' : '80');
213
+ const tailscaleHost = tailscaleAddress();
214
+ return json({
215
+ localUrl: `${requestUrl.protocol}//127.0.0.1:${port}`,
216
+ tailscaleUrl: tailscaleHost ? `${requestUrl.protocol}//${tailscaleHost}:${port}` : null,
217
+ tailscaleHost,
218
+ });
219
+ }
220
+
195
221
  if (request.method === 'GET' && pathname === '/api/computers') {
196
222
  return json({ computers: store.listComputers(numberParam(url, 'limit', 50)) });
197
223
  }
@@ -417,6 +443,78 @@ export function route(store: MessageStore, request: Request): Promise<Response>
417
443
  return json({ sessions: store.listSessions(numberParam(url, 'limit', 50)) });
418
444
  }
419
445
 
446
+ if (request.method === 'GET' && pathname === '/api/lark/config') {
447
+ const path = stringParam(url, 'config') ?? defaultLarkConfigPath();
448
+ const credentials = loadLarkCredentials(path);
449
+ return json({
450
+ path,
451
+ bots: credentials.bots.map((bot) => ({
452
+ appId: bot.appId,
453
+ label: bot.label ?? null,
454
+ agent: bot.agent ?? null,
455
+ boundAgents: boundAgents(bot),
456
+ botOpenId: bot.botOpenId ?? null,
457
+ hasSecret: Boolean(bot.appSecret),
458
+ })),
459
+ });
460
+ }
461
+
462
+ if (request.method === 'GET' && pathname === '/api/lark/authorized-users') {
463
+ return json({ users: store.listLarkAuthorizedUsers() });
464
+ }
465
+
466
+ if (request.method === 'POST' && pathname === '/api/lark/authorized-users') {
467
+ return readJson<LarkAuthorizedUserBody>(request).then((body) => {
468
+ const userId = body.user_id?.trim();
469
+ if (!userId) throw new HttpError(400, 'MISSING_USER_ID', 'user_id is required');
470
+ const user = store.upsertLarkAuthorizedUser({ userId, displayName: body.display_name });
471
+ return json({ user }, 201);
472
+ });
473
+ }
474
+
475
+ const larkAuthorizedUserMatch = pathname.match(/^\/api\/lark\/authorized-users\/([^/]+)$/);
476
+ if (request.method === 'DELETE' && larkAuthorizedUserMatch) {
477
+ const userId = decodeURIComponent(larkAuthorizedUserMatch[1]!);
478
+ const deleted = store.deleteLarkAuthorizedUser(userId);
479
+ if (!deleted) throw new HttpError(404, 'LARK_USER_NOT_FOUND', 'authorized Lark user was not found');
480
+ return json({ deleted: true });
481
+ }
482
+
483
+ if (request.method === 'POST' && pathname === '/api/lark/setup') {
484
+ return readJson<LarkSetupBody>(request).then(async (body) => {
485
+ const appId = body.app_id?.trim();
486
+ const appSecret = body.app_secret?.trim();
487
+ const agent = body.agent?.trim();
488
+ const label = body.label?.trim();
489
+ const configPath = body.config?.trim() || defaultLarkConfigPath();
490
+ if (!appId) throw new HttpError(400, 'MISSING_APP_ID', 'app_id is required');
491
+ if (!appSecret) throw new HttpError(400, 'MISSING_APP_SECRET', 'app_secret is required');
492
+ if (!agent) throw new HttpError(400, 'MISSING_AGENT', 'agent is required');
493
+ if (!store.getAgent(agent)) throw new HttpError(404, 'AGENT_NOT_FOUND', `agent ${agent} not found`);
494
+
495
+ const botInfo = await resolveLarkBotInfo(appId, appSecret);
496
+ if (!botInfo.ok) {
497
+ throw new HttpError(400, 'LARK_CREDENTIALS_REJECTED', `could not resolve bot open_id (${botInfo.error}): ${botInfo.message}`);
498
+ }
499
+
500
+ const result = persistLarkCredential({
501
+ appId,
502
+ appSecret,
503
+ label,
504
+ agent,
505
+ botOpenId: botInfo.openId,
506
+ configPath,
507
+ });
508
+ const account = store.registerChannelAccount({
509
+ name: label || botInfo.appName || appId,
510
+ appId,
511
+ botOpenId: botInfo.openId,
512
+ agent,
513
+ });
514
+ return json({ ...result, appName: botInfo.appName ?? null, account }, 201);
515
+ });
516
+ }
517
+
420
518
  const sessionRunsMatch = pathname.match(/^\/api\/sessions\/([^/]+)\/runs$/);
421
519
  if (request.method === 'GET' && sessionRunsMatch) {
422
520
  return json({ runs: store.listRunsForSession(sessionRunsMatch[1]!, numberParam(url, 'limit', 50)) });
package/src/console.ts CHANGED
@@ -4,7 +4,7 @@ import { boolFlag, parseArgs, flag, numberFlag } from './args.js';
4
4
  import { LockClient } from './client.js';
5
5
  import { defaultServerUrl } from './config.js';
6
6
  import { formatMessages } from './format.js';
7
- import type { Computer } from './types.js';
7
+ import type { Computer, ProvisionedComputer } from './types.js';
8
8
 
9
9
  interface Prompt {
10
10
  askLine(label: string): Promise<string>;
@@ -21,10 +21,15 @@ Usage:
21
21
  bun run src/console.ts inbox --agent neeko [--after 0] [--limit 50] [--json]
22
22
  bun run src/console.ts runs [--json]
23
23
  bun run src/console.ts run-action <run-id> kill|restart
24
+ bun run src/console.ts computers list [--json]
25
+ bun run src/console.ts computer onboard [--interactive] [--name <display-name>] [--server-url <url>] [--package-name <npm-package>]
24
26
  bun run src/console.ts agents list [--json]
25
27
  bun run src/console.ts agents onboard [--interactive] [--key <agent-key>] [--name <display-name>] [--runtime codex] [--computer-id <machine>]
26
28
  bun run src/console.ts agents create --key <agent-key> --name <display-name> [--runtime neeko|coco|coco-stream-json|codex] [--desc <description>]
27
29
  bun run src/console.ts agents update --key <agent-key> --runtime neeko|coco|coco-stream-json|codex
30
+ bun run src/console.ts lark-users list [--json]
31
+ bun run src/console.ts lark-users add [--interactive] --user-id <union-id> [--name <display-name>]
32
+ bun run src/console.ts lark-users delete --user-id <union-id>
28
33
  bun run src/console.ts lark <setup|list|daemon|events|send> [flags]
29
34
 
30
35
  Environment:
@@ -32,6 +37,13 @@ Environment:
32
37
  `;
33
38
  }
34
39
 
40
+ async function requestJson<T>(url: string, init?: RequestInit): Promise<T> {
41
+ const response = await fetch(url, init);
42
+ const payload = await response.json() as { data?: T; message?: string; code?: string };
43
+ if (!response.ok) throw new Error(payload.message ?? payload.code ?? `request failed: ${response.status}`);
44
+ return payload.data as T;
45
+ }
46
+
35
47
  function printJson(value: unknown): void {
36
48
  console.log(JSON.stringify(value, null, 2));
37
49
  }
@@ -120,6 +132,50 @@ async function askComputerId(prompt: Prompt, computers: Computer[]): Promise<str
120
132
  }
121
133
  }
122
134
 
135
+ async function collectComputerOnboardInput(serverClient: LockClient, flags: Record<string, string | boolean>): Promise<{ name: string; serverUrl: string; packageName: string }> {
136
+ const hasAnyProvisionFlag = Boolean(flag(flags, 'name') || flag(flags, 'server-url') || flag(flags, 'package-name'));
137
+ const interactive = boolFlag(flags, 'interactive') || (!hasAnyProvisionFlag && process.stdin.isTTY === true);
138
+ const defaultName = flag(flags, 'name') ?? 'Local computer';
139
+ const defaultServerUrl = flag(flags, 'server-url') ?? serverClient.baseUrl;
140
+ const defaultPackageName = flag(flags, 'package-name') ?? '@slock-ai/daemon@latest';
141
+
142
+ if (!interactive) {
143
+ return {
144
+ name: defaultName,
145
+ serverUrl: defaultServerUrl,
146
+ packageName: defaultPackageName,
147
+ };
148
+ }
149
+
150
+ const prompt = await createPrompt();
151
+ try {
152
+ console.log('Computer onboarding');
153
+ console.log('Provision a computer credential and daemon start command.');
154
+ const name = await askRequired(prompt, 'Computer display name', defaultName);
155
+ const serverUrl = await askRequired(prompt, 'Server URL', defaultServerUrl);
156
+ const packageName = await askRequired(prompt, 'Daemon package', defaultPackageName);
157
+ console.log('');
158
+ console.log('Summary:');
159
+ console.log(` name: ${name}`);
160
+ console.log(` server_url: ${serverUrl}`);
161
+ console.log(` package_name: ${packageName}`);
162
+ if (!await askYesNo(prompt, 'Provision this computer?', true)) throw new Error('computer onboarding cancelled');
163
+ return { name, serverUrl, packageName };
164
+ } finally {
165
+ prompt.close();
166
+ }
167
+ }
168
+
169
+ function printProvisionedComputer(provisioned: ProvisionedComputer): void {
170
+ console.log('Computer onboarded');
171
+ console.log(` id: ${provisioned.computer.id}`);
172
+ console.log(` name: ${provisioned.computer.name}`);
173
+ console.log(` status: ${provisioned.computer.status}`);
174
+ console.log('');
175
+ console.log('Daemon command:');
176
+ console.log(provisioned.command);
177
+ }
178
+
123
179
  async function collectOnboardInput(serverClient: LockClient, flags: Record<string, string | boolean>): Promise<{ agentKey: string; displayName: string; runtime: string; desc: string | null; computerId: string | undefined }> {
124
180
  const hasRequiredFlags = Boolean(flag(flags, 'key') && flag(flags, 'name'));
125
181
  const interactive = boolFlag(flags, 'interactive') || (!hasRequiredFlags && process.stdin.isTTY === true);
@@ -166,6 +222,31 @@ async function collectOnboardInput(serverClient: LockClient, flags: Record<strin
166
222
  }
167
223
  }
168
224
 
225
+ async function collectLarkUserInput(flags: Record<string, string | boolean>): Promise<{ userId: string; displayName: string | null }> {
226
+ const userIdFlag = flag(flags, 'user-id') ?? flag(flags, 'id');
227
+ const interactive = boolFlag(flags, 'interactive') || (!userIdFlag && process.stdin.isTTY === true);
228
+ if (!interactive) {
229
+ if (!userIdFlag) throw new Error('lark-users add requires --user-id when not running interactively');
230
+ return { userId: userIdFlag, displayName: flag(flags, 'name') ?? null };
231
+ }
232
+
233
+ const prompt = await createPrompt();
234
+ try {
235
+ console.log('Lark authorized user');
236
+ const userId = await askRequired(prompt, 'Lark user ID', userIdFlag ?? '');
237
+ const displayName = await ask(prompt, 'Display name (optional)', flag(flags, 'name') ?? '');
238
+ console.log('');
239
+ console.log('Summary:');
240
+ console.log(` user_id: ${userId}`);
241
+ console.log(` display_name: ${displayName || '-'}`);
242
+ if (!await askYesNo(prompt, 'Authorize this Lark user?', true)) throw new Error('lark user add cancelled');
243
+ return { userId, displayName: displayName || null };
244
+ } finally {
245
+ prompt.close();
246
+ }
247
+ }
248
+
249
+
169
250
  async function main(): Promise<void> {
170
251
  const args = parseArgs();
171
252
  const serverClient = new LockClient(flag(args.flags, 'server'));
@@ -247,6 +328,40 @@ async function main(): Promise<void> {
247
328
  return;
248
329
  }
249
330
 
331
+ if (args.command === 'computer' || args.command === 'computers') {
332
+ const sub = args.values[0];
333
+
334
+ if (sub === 'list') {
335
+ const computers = await serverClient.listComputers();
336
+ if (args.flags.json) {
337
+ printJson(computers);
338
+ } else {
339
+ for (const computer of computers) {
340
+ const seen = computer.last_seen_at ? ` last_seen=${computer.last_seen_at}` : '';
341
+ console.log(`${computer.id} name="${computer.name}" status=${computer.status}${seen}`);
342
+ }
343
+ }
344
+ return;
345
+ }
346
+
347
+ if (sub === 'onboard') {
348
+ const input = await collectComputerOnboardInput(serverClient, args.flags);
349
+ const provisioned = await serverClient.provisionComputer({
350
+ name: input.name,
351
+ server_url: input.serverUrl,
352
+ package_name: input.packageName,
353
+ });
354
+ if (args.flags.json) {
355
+ printJson(provisioned);
356
+ } else {
357
+ printProvisionedComputer(provisioned);
358
+ }
359
+ return;
360
+ }
361
+
362
+ throw new Error(`unknown ${args.command} subcommand: ${sub ?? '(none)'}\n\n${usage()}`);
363
+ }
364
+
250
365
  if (args.command === 'agents') {
251
366
  const sub = args.values[0];
252
367
 
@@ -331,6 +446,46 @@ async function main(): Promise<void> {
331
446
  throw new Error(`unknown agents subcommand: ${sub ?? '(none)'}\n\n${usage()}`);
332
447
  }
333
448
 
449
+ if (args.command === 'lark-users') {
450
+ const sub = args.values[0];
451
+
452
+ if (sub === 'list') {
453
+ const data = await requestJson<{ users: Array<Record<string, unknown>> }>(`${serverClient.baseUrl}/api/lark/authorized-users`);
454
+ if (args.flags.json) {
455
+ printJson(data.users);
456
+ } else if (data.users.length === 0) {
457
+ console.log('No authorized Lark users.');
458
+ } else {
459
+ for (const user of data.users) {
460
+ console.log(`${user.user_id} name="${user.display_name ?? '-'}" added=${user.created_at}`);
461
+ }
462
+ }
463
+ return;
464
+ }
465
+
466
+ if (sub === 'add') {
467
+ const input = await collectLarkUserInput(args.flags);
468
+ const data = await requestJson<{ user: Record<string, unknown> }>(`${serverClient.baseUrl}/api/lark/authorized-users`, {
469
+ method: 'POST',
470
+ headers: { 'content-type': 'application/json' },
471
+ body: JSON.stringify({ user_id: input.userId, display_name: input.displayName }),
472
+ });
473
+ if (args.flags.json) printJson(data.user);
474
+ else console.log(`Authorized Lark user ${data.user.user_id}`);
475
+ return;
476
+ }
477
+
478
+ if (sub === 'delete' || sub === 'remove') {
479
+ const userId = flag(args.flags, 'user-id') ?? flag(args.flags, 'id') ?? args.values[1];
480
+ if (!userId) throw new Error('--user-id is required');
481
+ await requestJson<{ deleted: boolean }>(`${serverClient.baseUrl}/api/lark/authorized-users/${encodeURIComponent(userId)}`, { method: 'DELETE' });
482
+ console.log(`Deleted Lark user ${userId}`);
483
+ return;
484
+ }
485
+
486
+ throw new Error(`unknown lark-users subcommand: ${sub ?? '(none)'}\n\n${usage()}`);
487
+ }
488
+
334
489
  if (args.command === 'lark') {
335
490
  const { runLarkCli } = await import('./lark/cli.js');
336
491
  const code = await runLarkCli({
package/src/db.ts CHANGED
@@ -4,7 +4,9 @@ import { ensureParentDir } from './config.js';
4
4
  import { runMigrations } from './migrations.js';
5
5
  import { artifactExpiry, generateArtifactToken, hashArtifactToken, validateArtifactContent } from './artifacts.js';
6
6
  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';
7
+ 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, ProvisionedComputer, ProviderExternalType, ProviderIdentityBinding, PalIdentity, RoomChannel, RoomParticipant, RoomParticipantKind, RoomParticipantSource, RoomProvider, RunAction, RunStatus, TranscriptReadModel, WorkbenchArtifact } from './types.js';
8
+
9
+ export const ALL_AGENTS_MENTION = '__pal_all_agents__';
8
10
 
9
11
  export interface CreateMessageInput {
10
12
  chatId?: string;
@@ -529,6 +531,16 @@ function rowToChannelAccount(row: Record<string, unknown>): ChannelAccount {
529
531
  };
530
532
  }
531
533
 
534
+ function rowToLarkAuthorizedUser(row: Record<string, unknown>): LarkAuthorizedUser {
535
+ return {
536
+ id: String(row.id),
537
+ user_id: String(row.user_id),
538
+ display_name: row.display_name === null || row.display_name === undefined ? null : String(row.display_name),
539
+ created_at: String(row.created_at),
540
+ updated_at: String(row.updated_at),
541
+ };
542
+ }
543
+
532
544
  function rowToPalIdentity(row: Record<string, unknown>): PalIdentity {
533
545
  return {
534
546
  id: String(row.id),
@@ -852,6 +864,43 @@ export class MessageStore {
852
864
  return this.db.query('SELECT * FROM chat_stats ORDER BY COALESCE(last_message_at, created_at) DESC').all() as Chat[];
853
865
  }
854
866
 
867
+ listLarkAuthorizedUsers(): LarkAuthorizedUser[] {
868
+ const rows = this.db.query('SELECT * FROM lark_authorized_users ORDER BY COALESCE(display_name, user_id), user_id').all() as Record<string, unknown>[];
869
+ return rows.map(rowToLarkAuthorizedUser);
870
+ }
871
+
872
+ upsertLarkAuthorizedUser(input: { userId: string; displayName?: string | null }): LarkAuthorizedUser {
873
+ const userId = input.userId.trim();
874
+ if (!userId) throw new Error('lark user_id is required');
875
+ const id = crypto.randomUUID();
876
+ this.db.query(`
877
+ INSERT INTO lark_authorized_users (id, user_id, display_name, created_at, updated_at)
878
+ VALUES (?, ?, ?, datetime('now'), datetime('now'))
879
+ ON CONFLICT(user_id) DO UPDATE SET
880
+ display_name = excluded.display_name,
881
+ updated_at = datetime('now')
882
+ `).run(id, userId, input.displayName?.trim() || null);
883
+ return this.getLarkAuthorizedUser(userId)!;
884
+ }
885
+
886
+ getLarkAuthorizedUser(userId: string): LarkAuthorizedUser | null {
887
+ const normalized = userId.trim();
888
+ if (!normalized) return null;
889
+ const row = this.db.query('SELECT * FROM lark_authorized_users WHERE user_id = ?').get(normalized) as Record<string, unknown> | null;
890
+ return row ? rowToLarkAuthorizedUser(row) : null;
891
+ }
892
+
893
+ isLarkAuthorizedUser(userId: string | null | undefined): boolean {
894
+ if (!userId?.trim()) return false;
895
+ return this.getLarkAuthorizedUser(userId) !== null;
896
+ }
897
+
898
+ deleteLarkAuthorizedUser(userId: string): boolean {
899
+ const normalized = userId.trim();
900
+ if (!normalized) return false;
901
+ return this.db.query('DELETE FROM lark_authorized_users WHERE user_id = ?').run(normalized).changes > 0;
902
+ }
903
+
855
904
  listLarkGroupRoomMappings(): LarkGroupRoomMapping[] {
856
905
  const rows = this.db.query(`
857
906
  SELECT DISTINCT
@@ -2146,7 +2195,7 @@ export class MessageStore {
2146
2195
  VALUES (?, ?, ?, 'offline', NULL, NULL, datetime('now'))
2147
2196
  `).run(id, name, hashSecret(apiKey));
2148
2197
 
2149
- const packageName = input.packageName?.trim() || '@slock-ai/daemon@latest';
2198
+ const packageName = input.packageName?.trim() || '@controlflow-ai/daemon@latest';
2150
2199
  const serverUrl = input.serverUrl?.trim() || 'http://127.0.0.1:4127';
2151
2200
  const command = `npx ${packageName} --server-url ${serverUrl} --api-key ${apiKey} # ${name}`;
2152
2201
  return { computer: this.getComputer(id)!, api_key: apiKey, command };
@@ -2431,7 +2480,11 @@ export class MessageStore {
2431
2480
  if (!room) throw new Error(`room ${message.chat_id} was not found`);
2432
2481
  const candidates = new Set<string>();
2433
2482
  if (message.recipient && this.getAgent(message.recipient)) candidates.add(message.recipient);
2483
+ if ((message.mentions ?? []).includes(ALL_AGENTS_MENTION)) {
2484
+ for (const agent of this.listRoomAgents(room)) candidates.add(agent);
2485
+ }
2434
2486
  for (const mention of message.mentions ?? []) {
2487
+ if (mention === ALL_AGENTS_MENTION) continue;
2435
2488
  if (this.getAgent(mention)) candidates.add(mention);
2436
2489
  }
2437
2490
  if (room.kind === 'dm') {
@@ -2460,9 +2513,34 @@ export class MessageStore {
2460
2513
  return deliveries;
2461
2514
  }
2462
2515
 
2516
+ private listRoomAgents(room: Chat): string[] {
2517
+ if (room.provider === 'web') {
2518
+ const rows = this.db.query(`
2519
+ SELECT participant_id AS agent
2520
+ FROM room_participants
2521
+ WHERE room_id = ? AND kind = 'agent' AND status = 'active'
2522
+ `).all(room.id) as Array<{ agent: string }>;
2523
+ return rows.map((row) => row.agent).filter((agent) => Boolean(this.getAgent(agent)));
2524
+ }
2525
+
2526
+ const rows = this.db.query(`
2527
+ SELECT DISTINCT pa.agent AS agent
2528
+ FROM provider_accounts pa
2529
+ INNER JOIN provider_conversations pc ON pc.provider_account_id = pa.id
2530
+ WHERE pa.provider = ? AND pa.status = 'active' AND pc.room_id = ?
2531
+ UNION
2532
+ SELECT DISTINCT ca.agent AS agent
2533
+ FROM channel_accounts ca
2534
+ INNER JOIN channel_conversations cc ON cc.channel_account_id = ca.id
2535
+ WHERE ca.channel = ? AND ca.status = 'active' AND cc.lock_chat_id = ? AND ca.agent IS NOT NULL AND ca.agent != ''
2536
+ `).all(room.provider, room.id, room.provider, room.id) as Array<{ agent: string }>;
2537
+ return rows.map((row) => row.agent).filter((agent) => Boolean(this.getAgent(agent)));
2538
+ }
2539
+
2463
2540
  private shouldCreateDeliveryForAgent(message: Message, room: Chat, agent: string): boolean {
2464
2541
  if (!this.canAgentParticipateInRoom(agent, room)) return false;
2465
- const direct = message.recipient === agent || (message.mentions ?? []).includes(agent);
2542
+ const allAgentsMentioned = (message.mentions ?? []).includes(ALL_AGENTS_MENTION);
2543
+ const direct = allAgentsMentioned || message.recipient === agent || (message.mentions ?? []).includes(agent);
2466
2544
  if (room.kind === 'dm') return direct || message.recipient === null;
2467
2545
  const subscription = this.getAgentRoomSubscription(agent, room.id);
2468
2546
  const mode = subscription?.mode ?? 'mentions';
@@ -1,4 +1,5 @@
1
1
  import type { MessageStore, CreateMessageInput } from '../db.js';
2
+ import { ALL_AGENTS_MENTION } from '../db.js';
2
3
  import type { Message } from '../types.js';
3
4
  import { palIdentityHandle } from '../provider-identity.js';
4
5
 
@@ -82,6 +83,22 @@ export function extractMentionOpenIds(envelope: LarkMessageEnvelope): string[] {
82
83
  return ids;
83
84
  }
84
85
 
86
+ function isAllMention(mention: NonNullable<NonNullable<LarkMessageEnvelope['message']>['mentions']>[number]): boolean {
87
+ const key = mention.key?.trim().toLowerCase();
88
+ const name = mention.name?.trim().toLowerCase();
89
+ const openId = mention.id?.open_id?.trim().toLowerCase();
90
+ return key === '@all' || key === 'all' || name === 'all' || name === '所有人' || openId === 'all';
91
+ }
92
+
93
+ export function mentionsAllAgents(envelope: LarkMessageEnvelope): boolean {
94
+ const mentions = envelope.message?.mentions ?? [];
95
+ for (const mention of mentions) {
96
+ if (isAllMention(mention)) return true;
97
+ }
98
+ const raw = parseLarkTextContent(envelope.message?.content, envelope.message?.message_type).toLowerCase();
99
+ return /(^|\s)@(all|所有人)(\s|$)/u.test(raw);
100
+ }
101
+
85
102
  function normalizeMappedMentions(values: Array<string | null>): string[] {
86
103
  const out: string[] = [];
87
104
  const seen = new Set<string>();
@@ -124,8 +141,11 @@ export function mapLarkMessageToCreateInput(input: MapLarkMessageInput): MapLark
124
141
  const text = parseLarkTextContent(msg.content, msg.message_type);
125
142
  if (!text.trim()) return { status: 'skipped', reason: 'empty_text' };
126
143
 
127
- const firstMention = msg.mentions?.find((m) => m.id?.open_id)?.id?.open_id;
128
- const mappedMentions = normalizeMappedMentions(msg.mentions?.map((m) => m.id?.open_id ? input.recipientByMentionOpenId?.get(m.id.open_id) ?? null : null) ?? []);
144
+ const firstMention = msg.mentions?.find((m) => m.id?.open_id && !isAllMention(m))?.id?.open_id;
145
+ const mappedMentions = normalizeMappedMentions([
146
+ ...(mentionsAllAgents(envelope) ? [ALL_AGENTS_MENTION] : []),
147
+ ...(msg.mentions?.map((m) => m.id?.open_id ? input.recipientByMentionOpenId?.get(m.id.open_id) ?? null : null) ?? []),
148
+ ]);
129
149
  const recipient = input.recipientOverride !== undefined
130
150
  ? input.recipientOverride
131
151
  : firstMention ? input.recipientByMentionOpenId?.get(firstMention) ?? firstMention : null;
@@ -52,10 +52,6 @@ function mentionsBotLabel(envelope: LarkMessageEnvelope, bot: LarkCredential): b
52
52
  }
53
53
 
54
54
 
55
- function configuredOwnerUnionId(): string | null {
56
- return process.env.PAL_OWNER_LARK_UNION_ID?.trim() || process.env.PAL_LARK_OWNER_UNION_ID?.trim() || null;
57
- }
58
-
59
55
  function senderUnionId(envelope: LarkMessageEnvelope): string | null {
60
56
  return envelope.sender?.sender_id?.union_id?.trim() || null;
61
57
  }
@@ -261,18 +257,15 @@ export function startLarkOnServer(options: LarkServerIntegrationOptions): LarkSe
261
257
  if (envelope === 'im.message.receive_v1') {
262
258
  try {
263
259
  const larkEnvelope = data as LarkMessageEnvelope;
264
- const ownerUnionId = configuredOwnerUnionId();
265
- if (ownerUnionId) {
266
- const identity = await resolveSenderUnionId({ bot, envelope: larkEnvelope, store: msgStore });
267
- if (identity.status === 'pending') {
268
- msgStore.recordPendingInboundEvent({ rawEventId: storeResult.event_id, provider: 'lark', reason: 'missing_union_id', error: identity.reason });
269
- log.warn(`[lark/${bot.appId}] sender union_id lookup pending: ${identity.reason}`);
270
- return;
271
- }
272
- if (identity.status !== 'resolved' || identity.unionId !== ownerUnionId) {
273
- log.log(`[lark/${bot.appId}] business ingest skipped sender_union=${identity.status === 'resolved' ? identity.unionId : '-'} owner_configured=true`);
274
- return;
275
- }
260
+ const identity = await resolveSenderUnionId({ bot, envelope: larkEnvelope, store: msgStore });
261
+ if (identity.status === 'pending') {
262
+ msgStore.recordPendingInboundEvent({ rawEventId: storeResult.event_id, provider: 'lark', reason: 'missing_lark_sender_user_id', error: identity.reason });
263
+ log.warn(`[lark/${bot.appId}] sender union_id lookup pending: ${identity.reason}`);
264
+ return;
265
+ }
266
+ if (identity.status !== 'resolved' || !msgStore.isLarkAuthorizedUser(identity.unionId)) {
267
+ log.log(`[lark/${bot.appId}] business ingest skipped sender_union=${identity.status === 'resolved' ? identity.unionId : '-'} authorized=false`);
268
+ return;
276
269
  }
277
270
  const agents = boundAgents(bot);
278
271
  const mentionOpenIds = extractMentionOpenIds(larkEnvelope);
@@ -0,0 +1,16 @@
1
+ import type { Database } from 'bun:sqlite';
2
+
3
+ export const version = 22;
4
+ export const name = 'lark_authorized_users';
5
+
6
+ export function up(db: Database): void {
7
+ db.exec(`
8
+ CREATE TABLE IF NOT EXISTS lark_authorized_users (
9
+ id TEXT PRIMARY KEY,
10
+ user_id TEXT NOT NULL UNIQUE,
11
+ display_name TEXT,
12
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
13
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
14
+ );
15
+ `);
16
+ }
package/src/migrations.ts CHANGED
@@ -20,6 +20,7 @@ import * as roomDisplayNames from './migrations/018_room_display_names.js';
20
20
  import * as computerConnections from './migrations/019_computer_connections.js';
21
21
  import * as computerAgentAssignments from './migrations/020_computer_agent_assignments.js';
22
22
  import * as providerIdentityBindings from './migrations/021_provider_identity_bindings.js';
23
+ import * as larkAuthorizedUsers from './migrations/022_lark_authorized_users.js';
23
24
 
24
25
  interface Migration {
25
26
  version: number;
@@ -27,7 +28,7 @@ interface Migration {
27
28
  up(db: Database): void;
28
29
  }
29
30
 
30
- const migrations: Migration[] = [initial, daemonDeliveries, sessionsRuns, messageIdempotency, artifacts, larkChannelFoundation, agentsA0, b0ChatHistory, b0TranscriptIngestSeq, b0TranscriptShadowExternalIds, b0ChannelConversationAuditOnly, b0CrossConversationInvariant, b10EngInboundRawEvents, agentsRuntime, agentRuntimeSessions, roomParticipants, unifiedRoomDelivery, roomDisplayNames, computerConnections, computerAgentAssignments, providerIdentityBindings].sort((a, b) => a.version - b.version);
31
+ const migrations: Migration[] = [initial, daemonDeliveries, sessionsRuns, messageIdempotency, artifacts, larkChannelFoundation, agentsA0, b0ChatHistory, b0TranscriptIngestSeq, b0TranscriptShadowExternalIds, b0ChannelConversationAuditOnly, b0CrossConversationInvariant, b10EngInboundRawEvents, agentsRuntime, agentRuntimeSessions, roomParticipants, unifiedRoomDelivery, roomDisplayNames, computerConnections, computerAgentAssignments, providerIdentityBindings, larkAuthorizedUsers].sort((a, b) => a.version - b.version);
31
32
 
32
33
  function assertContiguousMigrations(): void {
33
34
  for (let index = 0; index < migrations.length; index += 1) {
package/src/network.ts ADDED
@@ -0,0 +1,24 @@
1
+ import { networkInterfaces } from 'node:os';
2
+
3
+ export function tailscaleAddress(): string | null {
4
+ const interfaces = networkInterfaces();
5
+ for (const [name, entries] of Object.entries(interfaces)) {
6
+ for (const entry of entries ?? []) {
7
+ if (entry.family !== 'IPv4' || entry.internal) continue;
8
+ if (name.toLowerCase().startsWith('tailscale') || isTailscaleIPv4(entry.address)) {
9
+ return entry.address;
10
+ }
11
+ }
12
+ }
13
+ return null;
14
+ }
15
+
16
+ function isTailscaleIPv4(address: string): boolean {
17
+ const parts = address.split('.').map((part) => Number(part));
18
+ if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) return false;
19
+ return parts[0] === 100 && parts[1]! >= 64 && parts[1]! <= 127;
20
+ }
21
+
22
+ export function isLoopbackHost(host: string): boolean {
23
+ return host === '127.0.0.1' || host === 'localhost' || host === '::1';
24
+ }
package/src/server.ts CHANGED
@@ -3,25 +3,37 @@ import { DEFAULT_HOST, DEFAULT_PORT, defaultDbPath } from './config.js';
3
3
  import { MessageStore } from './db.js';
4
4
  import { handleRequest } from './app.js';
5
5
  import { startLarkOnServer } from './lark/server-integration.js';
6
+ import { isLoopbackHost, tailscaleAddress } from './network.js';
6
7
 
7
8
  const dbPath = defaultDbPath();
8
9
  const store = new MessageStore(dbPath);
9
10
  const port = Number(process.env.PAL_PORT ?? DEFAULT_PORT);
10
11
  const host = process.env.PAL_HOST ?? DEFAULT_HOST;
12
+ const tailscaleHost = process.env.PAL_TAILSCALE_HOST ?? tailscaleAddress();
13
+
14
+ function fetch(request: Request): Promise<Response> | Response {
15
+ const url = new URL(request.url);
16
+ if (request.method === 'POST' && url.pathname === '/api/lark/reload') {
17
+ return reloadLarkIntegration();
18
+ }
19
+ return handleRequest(store, request);
20
+ }
11
21
 
12
22
  const server = Bun.serve({
13
23
  hostname: host,
14
24
  port,
15
- async fetch(request) {
16
- const url = new URL(request.url);
17
- if (request.method === 'POST' && url.pathname === '/api/lark/reload') {
18
- return reloadLarkIntegration();
19
- }
20
- return handleRequest(store, request);
21
- },
25
+ fetch,
22
26
  });
27
+ const tailscaleServer = tailscaleHost && isLoopbackHost(host)
28
+ ? Bun.serve({ hostname: tailscaleHost, port, fetch })
29
+ : null;
23
30
 
24
31
  console.log(`pal server listening on http://${server.hostname}:${server.port}`);
32
+ if (tailscaleServer) {
33
+ console.log(`pal server also listening on Tailscale http://${tailscaleServer.hostname}:${tailscaleServer.port}`);
34
+ } else if (tailscaleHost) {
35
+ console.log(`pal server Tailscale address: http://${tailscaleHost}:${server.port}`);
36
+ }
25
37
  console.log(`database: ${dbPath}`);
26
38
 
27
39
  function startLarkIntegration() {
@@ -50,11 +62,13 @@ if (larkIntegration.handles.length > 0) {
50
62
 
51
63
  process.on('SIGINT', () => {
52
64
  larkIntegration.stop();
65
+ tailscaleServer?.stop();
53
66
  server.stop();
54
67
  process.exit(0);
55
68
  });
56
69
  process.on('SIGTERM', () => {
57
70
  larkIntegration.stop();
71
+ tailscaleServer?.stop();
58
72
  server.stop();
59
73
  process.exit(0);
60
74
  });
package/src/types.ts CHANGED
@@ -228,6 +228,14 @@ export interface ChannelAccount {
228
228
  updated_at: string;
229
229
  }
230
230
 
231
+ export interface LarkAuthorizedUser {
232
+ id: string;
233
+ user_id: string;
234
+ display_name: string | null;
235
+ created_at: string;
236
+ updated_at: string;
237
+ }
238
+
231
239
  export interface PalIdentity {
232
240
  id: string;
233
241
  kind: PalIdentityKind;
package/src/web.ts CHANGED
@@ -63,8 +63,9 @@ export function dashboardHtml(): string {
63
63
  grid-template-columns: 300px minmax(360px, 1fr) 330px;
64
64
  gap: 14px;
65
65
  width: min(1540px, calc(100vw - 28px));
66
- min-height: calc(100vh - 28px);
66
+ height: calc(100vh - 28px);
67
67
  margin: 14px auto;
68
+ overflow: hidden;
68
69
  }
69
70
  .panel {
70
71
  min-width: 0;
@@ -115,7 +116,7 @@ export function dashboardHtml(): string {
115
116
  }
116
117
  .pill.good { color: var(--active); border-color: #a7d7c4; background: var(--active-soft); }
117
118
  .pill.blue { color: var(--blue); border-color: #b7c8f6; background: var(--blue-soft); }
118
- .chat { display: grid; grid-template-rows: auto 1fr auto; overflow: hidden; }
119
+ .chat { display: grid; grid-template-rows: auto minmax(0, 1fr) auto; overflow: hidden; min-height: 0; }
119
120
  .panel-head { display: flex; align-items: center; justify-content: space-between; gap: 14px; min-height: 74px; min-width: 0; background: var(--panel); }
120
121
  .panel-head > div { min-width: 0; }
121
122
  .panel-head h2 { margin: 0; font-size: 22px; letter-spacing: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
@@ -138,6 +139,94 @@ export function dashboardHtml(): string {
138
139
  .split { display: grid; grid-template-columns: minmax(0, 1fr) 120px; gap: 8px; }
139
140
  .toolbox { display: grid; gap: 8px; background: var(--panel-2); }
140
141
  .section-title { margin: 0; color: var(--muted); font-size: 12px; font-weight: 900; letter-spacing: .12em; text-transform: uppercase; }
142
+ .field { display: grid; gap: 5px; }
143
+ .field span { color: var(--muted); font-size: 11px; font-weight: 900; text-transform: uppercase; letter-spacing: .08em; }
144
+ .settings-trigger { white-space: nowrap; }
145
+ .settings-backdrop {
146
+ position: fixed;
147
+ inset: 0;
148
+ z-index: 40;
149
+ display: none;
150
+ padding: 18px;
151
+ background: rgba(24, 23, 20, .28);
152
+ }
153
+ .settings-backdrop.open { display: grid; place-items: center; }
154
+ .settings-dialog {
155
+ display: grid;
156
+ grid-template-rows: auto 1fr;
157
+ width: min(1120px, 100%);
158
+ max-height: min(860px, calc(100vh - 36px));
159
+ border: 1px solid var(--ink);
160
+ background: var(--panel);
161
+ box-shadow: var(--shadow);
162
+ overflow: hidden;
163
+ }
164
+ .settings-head {
165
+ display: flex;
166
+ justify-content: space-between;
167
+ gap: 14px;
168
+ padding: 16px;
169
+ border-bottom: 1px solid var(--line);
170
+ background: #fffdf8;
171
+ }
172
+ .settings-head h2 { margin: 0; font-size: 24px; letter-spacing: 0; }
173
+ .settings-body {
174
+ display: grid;
175
+ grid-template-columns: 210px minmax(0, 1fr);
176
+ min-height: 0;
177
+ overflow: hidden;
178
+ }
179
+ .settings-nav {
180
+ display: grid;
181
+ align-content: start;
182
+ gap: 8px;
183
+ padding: 14px;
184
+ border-right: 1px solid var(--line);
185
+ background: var(--panel-2);
186
+ overflow: auto;
187
+ }
188
+ .settings-tab {
189
+ width: 100%;
190
+ background: transparent;
191
+ color: var(--ink);
192
+ border-color: var(--line);
193
+ text-align: left;
194
+ }
195
+ .settings-tab.active { background: var(--ink); color: #fffaf1; border-color: var(--ink); }
196
+ .settings-content { overflow: auto; padding: 16px; }
197
+ .settings-pane { display: none; gap: 12px; }
198
+ .settings-pane.active { display: grid; }
199
+ .settings-section-head {
200
+ display: flex;
201
+ align-items: start;
202
+ justify-content: space-between;
203
+ gap: 12px;
204
+ padding-bottom: 10px;
205
+ border-bottom: 1px solid var(--line);
206
+ }
207
+ .settings-section-head h3 { margin: 0; font-size: 18px; letter-spacing: 0; }
208
+ .settings-section-head .meta { max-width: 620px; }
209
+ .setup-grid { display: grid; grid-template-columns: minmax(0, 1fr); gap: 12px; }
210
+ .setup-panel { display: grid; gap: 8px; padding: 12px; border: 1px solid var(--line); background: #fffdf8; }
211
+ .setup-panel.collapsed { display: none; }
212
+ .setup-panel .section-title { color: var(--ink); }
213
+ .setup-actions { display: grid; grid-template-columns: 1fr auto; gap: 8px; }
214
+ .summary-panel { display: grid; gap: 8px; padding: 12px; border: 1px solid var(--line); background: var(--active-soft); }
215
+ .settings-list { display: grid; gap: 8px; }
216
+ .settings-row {
217
+ display: grid;
218
+ grid-template-columns: minmax(0, 1fr) auto;
219
+ gap: 10px;
220
+ align-items: start;
221
+ padding: 11px;
222
+ border: 1px solid var(--line);
223
+ background: #fffdf8;
224
+ }
225
+ .settings-row strong { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
226
+ .settings-row .meta { overflow-wrap: anywhere; word-break: break-word; }
227
+ .command-wrap { display: grid; gap: 7px; }
228
+ .command-wrap textarea { font-family: var(--mono); font-size: 11px; min-height: 104px; }
229
+ .secret-note { color: var(--faint); font-size: 11px; line-height: 1.4; }
141
230
  .empty, .error, .readonly-note { border: 1px dashed var(--line); padding: 18px; text-align: center; background: rgba(255, 253, 248, .65); }
142
231
  .error { display: none; color: var(--danger); border-color: rgba(163, 58, 43, .35); background: #fff1ee; }
143
232
  .readonly-note { color: var(--muted); line-height: 1.5; }
@@ -188,12 +277,14 @@ export function dashboardHtml(): string {
188
277
  .app { grid-template-columns: 280px minmax(0, 1fr); }
189
278
  .inspector { grid-column: 1 / -1; grid-template-rows: auto auto; }
190
279
  .inspector .agents { max-height: 320px; }
280
+ .settings-body, .setup-grid, .settings-row { grid-template-columns: 1fr; }
281
+ .settings-nav { grid-template-columns: repeat(2, minmax(0, 1fr)); border-right: 0; border-bottom: 1px solid var(--line); }
191
282
  }
192
283
  @media (max-width: 760px) {
193
- .app { width: 100%; min-height: 100vh; margin: 0; grid-template-columns: 1fr; }
284
+ .app { width: 100%; height: 100vh; margin: 0; grid-template-columns: 1fr; overflow: auto; }
194
285
  .sidebar, .inspector { max-height: none; }
195
- .chat { min-height: 68vh; }
196
- .composer-controls, .split, .form-row { grid-template-columns: 1fr; }
286
+ .chat { height: 100vh; min-height: 0; }
287
+ .composer-controls, .split, .form-row, .setup-actions { grid-template-columns: 1fr; }
197
288
  .message { max-width: 100%; }
198
289
  }
199
290
  </style>
@@ -246,6 +337,7 @@ export function dashboardHtml(): string {
246
337
  <h2>Agents</h2>
247
338
  <div class="meta">Use runtime=codex for executable demo agents.</div>
248
339
  </div>
340
+ <button id="open-settings" class="secondary settings-trigger" type="button">Settings</button>
249
341
  </header>
250
342
  <div class="toolbox">
251
343
  <form id="invite-form" class="split">
@@ -259,29 +351,135 @@ export function dashboardHtml(): string {
259
351
  </select>
260
352
  <button>Invite</button>
261
353
  </form>
262
- <form id="agent-form" class="create-agent">
263
- <p class="section-title">Create or Update Agent</p>
264
- <input id="agent-key" placeholder="codex" autocomplete="off" required>
265
- <input id="agent-name" placeholder="Codex" autocomplete="off" required>
266
- <input id="agent-runtime" placeholder="codex" autocomplete="off">
267
- <input id="agent-computer" placeholder="machine id" autocomplete="off">
268
- <button>Create agent</button>
269
- </form>
270
- <form id="computer-form" class="create-agent">
271
- <p class="section-title">Provision Computer</p>
272
- <input id="computer-name" placeholder="Bill's Team" autocomplete="off">
273
- <button>Generate command</button>
274
- <textarea id="computer-command" readonly rows="4" placeholder="Daemon command"></textarea>
275
- </form>
276
354
  <div id="members" class="members"></div>
277
355
  </div>
278
356
  <div id="agents" class="agents"></div>
279
357
  </aside>
280
358
  </main>
359
+ <section id="settings-backdrop" class="settings-backdrop" aria-hidden="true">
360
+ <div class="settings-dialog" role="dialog" aria-modal="true" aria-labelledby="settings-title">
361
+ <header class="settings-head">
362
+ <div>
363
+ <h2 id="settings-title">Settings</h2>
364
+ <div class="meta">Configure access, agents, computers, and Feishu/Lark from one place.</div>
365
+ </div>
366
+ <button id="close-settings" class="secondary icon" type="button" aria-label="Close settings">×</button>
367
+ </header>
368
+ <div class="settings-body">
369
+ <nav class="settings-nav" aria-label="Settings sections">
370
+ <button class="settings-tab active" type="button" data-settings-tab="access">Access</button>
371
+ <button class="settings-tab" type="button" data-settings-tab="agents">Agents</button>
372
+ <button class="settings-tab" type="button" data-settings-tab="computers">Computers</button>
373
+ <button class="settings-tab" type="button" data-settings-tab="lark">Lark</button>
374
+ </nav>
375
+ <div class="settings-content">
376
+ <section id="settings-access" class="settings-pane active">
377
+ <div class="summary-panel">
378
+ <p class="section-title">Server Access</p>
379
+ <div id="server-access" class="meta">Listening locally. Tailscale address is detected on load.</div>
380
+ </div>
381
+ <div class="settings-section-head">
382
+ <div>
383
+ <h3>Lark Users</h3>
384
+ <div class="meta">Only listed Lark user IDs may trigger inbound bot handling.</div>
385
+ </div>
386
+ <button class="secondary" type="button" data-add-panel="lark-user-form">Add User</button>
387
+ </div>
388
+ <div class="setup-grid">
389
+ <div id="settings-lark-user-list" class="settings-list"></div>
390
+ <form id="lark-user-form" class="setup-panel collapsed">
391
+ <p class="section-title">Authorized Lark User</p>
392
+ <label class="field"><span>User ID</span><input id="lark-user-id" placeholder="on_xxx union id" autocomplete="off" required></label>
393
+ <label class="field"><span>Display name</span><input id="lark-user-name" placeholder="Optional" autocomplete="off"></label>
394
+ <div class="setup-actions">
395
+ <button>Save user</button>
396
+ <button class="secondary" type="button" data-cancel-panel="lark-user-form">Cancel</button>
397
+ </div>
398
+ </form>
399
+ </div>
400
+ </section>
401
+ <section id="settings-agents" class="settings-pane">
402
+ <div class="settings-section-head">
403
+ <div>
404
+ <h3>Agents</h3>
405
+ <div class="meta">Manage logical agents and assign them to an available computer.</div>
406
+ </div>
407
+ <button class="secondary" type="button" data-add-panel="agent-form">Add Agent</button>
408
+ </div>
409
+ <div class="setup-grid">
410
+ <div id="settings-agent-list" class="settings-list"></div>
411
+ <form id="agent-form" class="setup-panel collapsed">
412
+ <p class="section-title">Agent Onboard</p>
413
+ <label class="field"><span>Key</span><input id="agent-key" placeholder="codex" autocomplete="off" required></label>
414
+ <label class="field"><span>Name</span><input id="agent-name" placeholder="Codex" autocomplete="off" required></label>
415
+ <label class="field"><span>Runtime</span><select id="agent-runtime"><option value="codex">codex</option><option value="neeko">neeko</option><option value="coco">coco</option><option value="coco-stream-json">coco-stream-json</option></select></label>
416
+ <label class="field"><span>Computer</span><select id="agent-computer"><option value="">No assignment</option></select></label>
417
+ <label class="field"><span>Description</span><input id="agent-desc" placeholder="Optional" autocomplete="off"></label>
418
+ <div class="setup-actions">
419
+ <button>Onboard agent</button>
420
+ <button class="secondary" type="button" data-cancel-panel="agent-form">Cancel</button>
421
+ </div>
422
+ </form>
423
+ </div>
424
+ </section>
425
+ <section id="settings-computers" class="settings-pane">
426
+ <div class="settings-section-head">
427
+ <div>
428
+ <h3>Computers</h3>
429
+ <div class="meta">Provision daemon credentials and see connected machines.</div>
430
+ </div>
431
+ <button class="secondary" type="button" data-add-panel="computer-form">Add Computer</button>
432
+ </div>
433
+ <div class="setup-grid">
434
+ <div id="settings-computer-list" class="settings-list"></div>
435
+ <form id="computer-form" class="setup-panel collapsed">
436
+ <p class="section-title">Computer Onboard</p>
437
+ <label class="field"><span>Name</span><input id="computer-name" placeholder="Local computer" autocomplete="off"></label>
438
+ <label class="field"><span>Server URL</span><input id="computer-server" autocomplete="off"></label>
439
+ <label class="field"><span>Daemon package</span><input id="computer-package" value="@controlflow-ai/daemon@latest" autocomplete="off"></label>
440
+ <div class="setup-actions">
441
+ <button>Generate command</button>
442
+ <button id="copy-command" class="secondary" type="button">Copy</button>
443
+ </div>
444
+ <button class="secondary" type="button" data-cancel-panel="computer-form">Cancel</button>
445
+ <div class="command-wrap">
446
+ <textarea id="computer-command" readonly rows="4" placeholder="Daemon command"></textarea>
447
+ </div>
448
+ </form>
449
+ </div>
450
+ </section>
451
+ <section id="settings-lark" class="settings-pane">
452
+ <div class="settings-section-head">
453
+ <div>
454
+ <h3>Lark</h3>
455
+ <div class="meta">Bind Feishu/Lark bot credentials to a Pal agent. Secrets stay in the local runtime profile.</div>
456
+ </div>
457
+ <button class="secondary" type="button" data-add-panel="lark-form">Add Lark Bot</button>
458
+ </div>
459
+ <div class="setup-grid">
460
+ <div id="settings-lark-list" class="settings-list"></div>
461
+ <form id="lark-form" class="setup-panel collapsed">
462
+ <p class="section-title">Lark Setup</p>
463
+ <label class="field"><span>Bind agent</span><select id="lark-agent"></select></label>
464
+ <label class="field"><span>Label</span><input id="lark-label" placeholder="Team bot" autocomplete="off"></label>
465
+ <label class="field"><span>App ID</span><input id="lark-app-id" placeholder="cli_xxx" autocomplete="off" required></label>
466
+ <label class="field"><span>App Secret</span><input id="lark-app-secret" type="password" autocomplete="off" required></label>
467
+ <div class="secret-note">Secret is sent only to this local Pal server, validated with Feishu, and stored in the local Lark config file.</div>
468
+ <div class="setup-actions">
469
+ <button>Save Lark bot</button>
470
+ <button class="secondary" type="button" data-cancel-panel="lark-form">Cancel</button>
471
+ </div>
472
+ </form>
473
+ </div>
474
+ </section>
475
+ </div>
476
+ </div>
477
+ </div>
478
+ </section>
281
479
  <div id="error" class="error" role="alert"></div>
282
480
  <div id="toast" class="toast" role="status"></div>
283
481
  <script>
284
- const state = { rooms: [], agents: [], members: [], mentionables: [], messages: [], selectedRoomId: null, mentionIndex: 0 };
482
+ const state = { rooms: [], agents: [], computers: [], lark: null, larkUsers: [], serverAccess: null, members: [], mentionables: [], messages: [], selectedRoomId: null, mentionIndex: 0 };
285
483
  const root = (id) => document.getElementById(id);
286
484
  const escapeHtml = (value) => String(value ?? '').replace(/[&<>'"]/g, (char) => ({ '&':'&amp;', '<':'&lt;', '>':'&gt;', "'":'&#39;', '"':'&quot;' }[char]));
287
485
  async function api(path, options) {
@@ -358,11 +556,58 @@ export function dashboardHtml(): string {
358
556
  }
359
557
  function renderAgents() {
360
558
  root('invite-agent').innerHTML = state.agents.length ? state.agents.map((agent) => '<option value="' + escapeHtml(agent.agent_key) + '">' + escapeHtml(agent.display_name) + ' · ' + escapeHtml(agent.agent_key) + '</option>').join('') : '<option value="">No agents</option>';
559
+ root('lark-agent').innerHTML = state.agents.length ? state.agents.map((agent) => '<option value="' + escapeHtml(agent.agent_key) + '">' + escapeHtml(agent.display_name) + ' · ' + escapeHtml(agent.agent_key) + '</option>').join('') : '<option value="">Onboard an agent first</option>';
361
560
  root('agents').innerHTML = state.agents.length ? state.agents.map((agent) => (
362
561
  '<article class="agent"><div class="agent-title"><span>' + escapeHtml(agent.display_name) + '</span>' + pill(agent.runtime || 'no runtime', agent.runtime === 'codex' ? 'good' : '') + '</div>' +
363
562
  '<div class="pills">' + pill(agent.agent_key) + pill(agent.id) + '</div>' +
364
563
  '<div class="meta">' + escapeHtml(agent.description || 'No description') + '</div></article>'
365
564
  )).join('') : empty('No agents yet. Create codex to start.');
565
+ root('settings-agent-list').innerHTML = state.agents.length
566
+ ? state.agents.map((agent) => '<article class="settings-row"><div><strong>' + escapeHtml(agent.display_name) + '</strong><div class="meta">' + escapeHtml(agent.description || 'No description') + '</div><div class="pills">' + pill(agent.agent_key, 'blue') + pill(agent.runtime || 'no runtime', agent.runtime === 'codex' ? 'good' : '') + '</div></div></article>').join('')
567
+ : empty('No agents yet.');
568
+ }
569
+ function renderComputers() {
570
+ root('agent-computer').innerHTML = '<option value="">No assignment</option>' + state.computers.map((computer) => '<option value="' + escapeHtml(computer.id) + '">' + escapeHtml(computer.name) + ' · ' + escapeHtml(computer.id) + '</option>').join('');
571
+ root('settings-computer-list').innerHTML = state.computers.length
572
+ ? state.computers.map((computer) => '<article class="settings-row"><div><strong>' + escapeHtml(computer.name) + '</strong><div class="meta">' + escapeHtml(computer.id) + '</div><div class="pills">' + pill(computer.status, computer.status === 'online' ? 'good' : '') + (computer.last_seen_at ? pill('last seen ' + computer.last_seen_at) : '') + '</div></div></article>').join('')
573
+ : empty('No computers yet.');
574
+ }
575
+ function renderLarkConfig() {
576
+ const el = root('settings-lark-list');
577
+ if (!el) return;
578
+ const bots = state.lark?.bots || [];
579
+ el.innerHTML = bots.length
580
+ ? bots.map((bot) => '<article class="settings-row"><div><strong>' + escapeHtml(bot.label || bot.appId) + '</strong><div class="meta">' + escapeHtml(bot.appId) + '</div><div class="pills">' + pill('@' + (bot.agent || '-'), 'blue') + pill(bot.botOpenId ? 'open_id resolved' : 'open_id missing', bot.botOpenId ? 'good' : '') + pill(bot.hasSecret ? 'secret stored' : 'secret missing') + '</div></div></article>').join('') + '<div class="meta">Config: ' + escapeHtml(state.lark.path || '') + '</div>'
581
+ : empty('No Lark bots configured yet.');
582
+ }
583
+ function renderLarkUsers() {
584
+ const el = root('settings-lark-user-list');
585
+ if (!el) return;
586
+ el.innerHTML = state.larkUsers.length
587
+ ? state.larkUsers.map((user) => '<article class="settings-row"><div><strong>' + escapeHtml(user.display_name || user.user_id) + '</strong><div class="meta">' + escapeHtml(user.user_id) + '</div><div class="pills">' + pill('authorized', 'good') + '</div></div><button class="secondary" type="button" data-delete-lark-user="' + escapeHtml(user.user_id) + '">Delete</button></article>').join('')
588
+ : empty('No authorized Lark users. Inbound bot messages will be ignored.');
589
+ [...el.querySelectorAll('[data-delete-lark-user]')].forEach((button) => {
590
+ button.addEventListener('click', async () => {
591
+ const userId = button.dataset.deleteLarkUser;
592
+ if (!userId) return;
593
+ const deleted = await api('/api/lark/authorized-users/' + encodeURIComponent(userId), { method: 'DELETE' }).catch((error) => { showError(error); return null; });
594
+ if (!deleted) return;
595
+ await loadLarkUsers();
596
+ showToast('Lark user removed');
597
+ });
598
+ });
599
+ }
600
+ function renderServerAccess() {
601
+ const access = state.serverAccess;
602
+ const el = root('server-access');
603
+ if (!el) return;
604
+ if (!access) {
605
+ el.textContent = 'Listening locally. Tailscale address is detected on load.';
606
+ return;
607
+ }
608
+ el.textContent = access.tailscaleUrl
609
+ ? 'Listening on local ' + access.localUrl + ' and Tailscale ' + access.tailscaleUrl
610
+ : 'Listening on local ' + access.localUrl + '. No Tailscale interface detected.';
366
611
  }
367
612
  function renderMembers() {
368
613
  root('members').innerHTML = state.members.length ? state.members.map((member) => (
@@ -370,6 +615,25 @@ export function dashboardHtml(): string {
370
615
  '<div class="pills">' + pill(member.source) + pill(member.status) + '</div></article>'
371
616
  )).join('') : empty('No known members in this room.');
372
617
  }
618
+ function openSettings(tab) {
619
+ root('settings-backdrop').classList.add('open');
620
+ root('settings-backdrop').setAttribute('aria-hidden', 'false');
621
+ selectSettingsTab(tab || 'access');
622
+ }
623
+ function closeSettings() {
624
+ root('settings-backdrop').classList.remove('open');
625
+ root('settings-backdrop').setAttribute('aria-hidden', 'true');
626
+ }
627
+ function selectSettingsTab(tab) {
628
+ document.querySelectorAll('.settings-tab').forEach((button) => button.classList.toggle('active', button.dataset.settingsTab === tab));
629
+ document.querySelectorAll('.settings-pane').forEach((pane) => pane.classList.toggle('active', pane.id === 'settings-' + tab));
630
+ }
631
+ function showPanel(id) {
632
+ root(id)?.classList.remove('collapsed');
633
+ }
634
+ function hidePanel(id) {
635
+ root(id)?.classList.add('collapsed');
636
+ }
373
637
  function currentMentionQuery() {
374
638
  const input = root('message-content');
375
639
  if (!input) return null;
@@ -461,6 +725,26 @@ export function dashboardHtml(): string {
461
725
  state.agents = data.agents || [];
462
726
  renderAgents();
463
727
  }
728
+ async function loadComputers() {
729
+ const data = await api('/api/computers');
730
+ state.computers = data.computers || [];
731
+ renderComputers();
732
+ }
733
+ async function loadLarkConfig() {
734
+ const data = await api('/api/lark/config');
735
+ state.lark = data;
736
+ renderLarkConfig();
737
+ }
738
+ async function loadLarkUsers() {
739
+ const data = await api('/api/lark/authorized-users');
740
+ state.larkUsers = data.users || [];
741
+ renderLarkUsers();
742
+ }
743
+ async function loadServerAccess() {
744
+ const data = await api('/api/server/access');
745
+ state.serverAccess = data;
746
+ renderServerAccess();
747
+ }
464
748
  async function loadMessages() {
465
749
  const room = activeRoom();
466
750
  if (!room) {
@@ -483,7 +767,7 @@ export function dashboardHtml(): string {
483
767
  renderMembers();
484
768
  }
485
769
  async function refresh() {
486
- await Promise.all([loadRooms(), loadAgents()]);
770
+ await Promise.all([loadRooms(), loadAgents(), loadComputers(), loadLarkConfig(), loadLarkUsers(), loadServerAccess()]);
487
771
  await loadMessages();
488
772
  }
489
773
  window.selectRoom = async (id) => {
@@ -492,6 +776,23 @@ export function dashboardHtml(): string {
492
776
  await loadMessages().catch(showError);
493
777
  };
494
778
  root('refresh').addEventListener('click', () => refresh().catch(showError));
779
+ root('open-settings').addEventListener('click', () => openSettings('access'));
780
+ root('close-settings').addEventListener('click', closeSettings);
781
+ root('settings-backdrop').addEventListener('mousedown', (event) => {
782
+ if (event.target === root('settings-backdrop')) closeSettings();
783
+ });
784
+ document.addEventListener('keydown', (event) => {
785
+ if (event.key === 'Escape' && root('settings-backdrop').classList.contains('open')) closeSettings();
786
+ });
787
+ document.querySelectorAll('.settings-tab').forEach((button) => {
788
+ button.addEventListener('click', () => selectSettingsTab(button.dataset.settingsTab));
789
+ });
790
+ document.querySelectorAll('[data-add-panel]').forEach((button) => {
791
+ button.addEventListener('click', () => showPanel(button.dataset.addPanel));
792
+ });
793
+ document.querySelectorAll('[data-cancel-panel]').forEach((button) => {
794
+ button.addEventListener('click', () => hidePanel(button.dataset.cancelPanel));
795
+ });
495
796
  root('room-form').addEventListener('submit', async (event) => {
496
797
  event.preventDefault();
497
798
  const name = root('room-name').value.trim();
@@ -507,29 +808,67 @@ export function dashboardHtml(): string {
507
808
  event.preventDefault();
508
809
  const agent_key = root('agent-key').value.trim();
509
810
  const display_name = root('agent-name').value.trim();
510
- const runtime = root('agent-runtime').value.trim() || null;
511
- const computer_id = root('agent-computer').value.trim();
811
+ const runtime = root('agent-runtime').value.trim() || 'codex';
812
+ const computer_id = root('agent-computer').value.trim() || undefined;
813
+ const description = root('agent-desc').value.trim() || null;
512
814
  if (!agent_key || !display_name) return;
513
- const saved = await api('/api/agents', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ agent_key, display_name, runtime }) }).catch((error) => { showError(error); return null; });
815
+ const saved = await api('/api/agents/onboard', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ agent_key, display_name, runtime, description, computer_id }) }).catch((error) => { showError(error); return null; });
514
816
  if (!saved) return;
515
- if (computer_id) {
516
- await api('/api/agents/' + encodeURIComponent(agent_key) + '/assignment', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ computer_id }) }).catch((error) => { showError(error); return null; });
517
- }
518
817
  root('agent-key').value = '';
519
818
  root('agent-name').value = '';
520
- root('agent-runtime').value = '';
819
+ root('agent-runtime').value = 'codex';
521
820
  root('agent-computer').value = '';
821
+ root('agent-desc').value = '';
522
822
  await loadAgents();
823
+ hidePanel('agent-form');
523
824
  showToast('Agent saved: @' + agent_key);
524
825
  });
525
826
  root('computer-form').addEventListener('submit', async (event) => {
526
827
  event.preventDefault();
527
828
  const name = root('computer-name').value.trim();
528
- const data = await api('/api/computers/provision', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ name: name || undefined, server_url: location.origin }) }).catch((error) => { showError(error); return null; });
829
+ const server_url = root('computer-server').value.trim() || location.origin;
830
+ const package_name = root('computer-package').value.trim() || undefined;
831
+ const data = await api('/api/computers/provision', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ name: name || undefined, server_url, package_name }) }).catch((error) => { showError(error); return null; });
529
832
  if (!data) return;
530
833
  root('computer-command').value = data.command;
834
+ await loadComputers();
531
835
  showToast('Computer provisioned: ' + data.computer.id);
532
836
  });
837
+ root('copy-command').addEventListener('click', async () => {
838
+ const command = root('computer-command').value;
839
+ if (!command) return;
840
+ await navigator.clipboard?.writeText(command).catch(() => null);
841
+ showToast('Daemon command copied');
842
+ });
843
+ root('computer-server').value = location.origin;
844
+ root('lark-user-form').addEventListener('submit', async (event) => {
845
+ event.preventDefault();
846
+ const user_id = root('lark-user-id').value.trim();
847
+ const display_name = root('lark-user-name').value.trim() || null;
848
+ if (!user_id) return;
849
+ const saved = await api('/api/lark/authorized-users', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ user_id, display_name }) }).catch((error) => { showError(error); return null; });
850
+ if (!saved) return;
851
+ root('lark-user-id').value = '';
852
+ root('lark-user-name').value = '';
853
+ await loadLarkUsers();
854
+ hidePanel('lark-user-form');
855
+ showToast('Lark user authorized');
856
+ });
857
+ root('lark-form').addEventListener('submit', async (event) => {
858
+ event.preventDefault();
859
+ const agent = root('lark-agent').value;
860
+ const app_id = root('lark-app-id').value.trim();
861
+ const app_secret = root('lark-app-secret').value.trim();
862
+ const label = root('lark-label').value.trim() || undefined;
863
+ if (!agent || !app_id || !app_secret) return;
864
+ const data = await api('/api/lark/setup', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ agent, app_id, app_secret, label }) }).catch((error) => { showError(error); return null; });
865
+ if (!data) return;
866
+ root('lark-app-secret').value = '';
867
+ await loadLarkConfig();
868
+ const reload = await api('/api/lark/reload', { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' }).catch((error) => ({ reloadError: error.message }));
869
+ if (!reload.reloadError) hidePanel('lark-form');
870
+ showToast(reload.reloadError ? 'Lark saved, reload failed: ' + reload.reloadError : 'Lark bot saved and reloaded: ' + data.appId);
871
+ });
533
872
  root('invite-form').addEventListener('submit', async (event) => {
534
873
  event.preventDefault();
535
874
  const room = activeRoom();