@controlflow-ai/daemon 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -103,32 +103,24 @@ bun run console -- agents list [--json]
103
103
  # Create or update an agent record
104
104
  bun run console -- agents create --key <agent-key> --name <display-name> [--runtime neeko|coco|codex] [--desc <description>]
105
105
 
106
- # Create/update an agent and optionally assign it to a computer
106
+ # Create/update an agent, optionally assign it to a computer, and optionally set up Lark at the end
107
107
  bun run console -- agents onboard --key <agent-key> --name <display-name> [--runtime codex] [--desc <description>] [--computer-id <machine-id>]
108
+ bun run console -- agents onboard --interactive
108
109
 
109
- # Update only the runtime
110
- bun run console -- agents update --key <agent-key> --runtime neeko|coco|codex
110
+ # Update runtime and/or manage the Lark bot bound to the agent
111
+ bun run console -- agents update --key <agent-key> [--runtime neeko|coco|codex]
112
+ bun run console -- agents update --key <agent-key> --lark-app-id <app-id> --lark-app-secret <app-secret> [--lark-label <name>] [--rebind-lark]
113
+ bun run console -- agents update --key <agent-key> --unbind-lark
114
+ bun run console -- agents update --interactive
111
115
  ```
112
116
 
113
117
  `codex` is the validated runtime for the current demo environment. Do not use `neeko` or `coco` until their binaries and adapters are available on the runtime host.
114
118
 
119
+ `agents onboard --interactive` asks at the end whether to set up a Lark bot. Lark setup can create a Feishu app by QR scan/open-link, or accept a pasted App ID/App Secret. `agents update --interactive` manages Lark binding, rebind, and unbind for existing agents.
120
+
115
121
  ### Lark Bots
116
122
 
117
123
  ```bash
118
- # Configure a bot, resolve its bot open_id, bind it to an agent, and ask the running server to reload Lark
119
- bun run console -- lark setup --app-id <app-id> --app-secret <app-secret> --label <name> --agent <agent-key>
120
-
121
- # Configure a bot and create/update the bound agent in one command
122
- bun run console -- lark setup \
123
- --app-id <app-id> \
124
- --app-secret <app-secret> \
125
- --label <name> \
126
- --agent codex \
127
- --create-agent \
128
- --agent-name "Codex" \
129
- --runtime codex \
130
- --computer-id <machine-id>
131
-
132
124
  # List configured bots; secrets are redacted
133
125
  bun run console -- lark list
134
126
 
@@ -139,7 +131,9 @@ bun run console -- lark events --limit 20
139
131
  bun run console -- lark send --app-id <app-id> --to <receive_id> [--to-type chat_id|open_id|union_id|email|user_id] "Hello"
140
132
  ```
141
133
 
142
- `lark setup` writes `~/.pal/lark.json` or `PAL_LARK_CONFIG` with mode `0600`. By default it calls `POST /api/lark/reload` on the running server after a successful write. Use `--no-reload` when you want to edit or validate the file first, then trigger reload manually:
134
+ Lark bot binding is managed as part of agent settings. `agents update --lark-app-id ... --lark-app-secret ...` validates the credential, resolves `bot open_id`, writes `~/.pal/lark.json` or `PAL_LARK_CONFIG`, and asks the running server to reload Lark integration. An agent may only be bound to one Lark bot at a time; use `--rebind-lark` to move that agent from its previous bot to the new bot, or `--unbind-lark` to clear the binding while keeping the credential.
135
+
136
+ The Lark config file is written with mode `0600`. Use `--no-reload` when you want to edit or validate the file first, then trigger reload manually:
143
137
 
144
138
  ```bash
145
139
  curl -s -X POST http://127.0.0.1:4127/api/lark/reload
@@ -267,9 +261,17 @@ The daemon creates one local API for CLI calls, connects the computer to the ser
267
261
  | `LOCK_DAEMON_URL` | `http://127.0.0.1:4137` | Local daemon API URL used by `bun run cli` |
268
262
  | `LOCK_DAEMON_TOKEN` | token file fallback | Local daemon API bearer token |
269
263
  | `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
264
  | `PAL_LARK_ACTION_REACTION_EMOJI` | `Typing` | Reaction added when Lark delivery is created |
272
265
 
266
+ Lark sender authorization is stored in Pal's database. Manage authorized Lark
267
+ users from the Web Settings Access tab or with:
268
+
269
+ ```bash
270
+ bun run console -- lark-users list
271
+ bun run console -- lark-users add --user-id <union-id> --name "Display Name"
272
+ bun run console -- lark-users delete --user-id <union-id>
273
+ ```
274
+
273
275
  ## Runtime Semantics
274
276
 
275
277
  - Rooms are the primary conversation container; `chat` remains as a compatibility alias in several APIs.
package/package.json CHANGED
@@ -1,22 +1,33 @@
1
1
  {
2
2
  "name": "@controlflow-ai/daemon",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "server": "bun run src/server.ts",
7
7
  "daemon": "bun run src/daemon.ts",
8
8
  "cli": "bun run src/cli.ts",
9
9
  "console": "bun run src/console.ts",
10
+ "web:dev": "vite --config web/app/vite.config.ts --host 127.0.0.1",
11
+ "web:build": "vite build --config web/app/vite.config.ts",
12
+ "web:preview": "vite preview --config web/app/vite.config.ts --host 127.0.0.1",
10
13
  "typecheck": "tsc --noEmit",
11
14
  "test": "bun test",
12
15
  "check": "bun run typecheck && bun test"
13
16
  },
14
17
  "devDependencies": {
15
18
  "@types/bun": "latest",
16
- "typescript": "^5.9.3"
19
+ "@types/qrcode": "^1.5.6",
20
+ "@types/react": "^19.2.15",
21
+ "@types/react-dom": "^19.2.3",
22
+ "@vitejs/plugin-react": "^6.0.2",
23
+ "typescript": "^5.9.3",
24
+ "vite": "^8.0.14"
17
25
  },
18
26
  "dependencies": {
19
- "@larksuiteoapi/node-sdk": "^1.59.0"
27
+ "@larksuiteoapi/node-sdk": "^1.59.0",
28
+ "qrcode": "^1.5.4",
29
+ "react": "^19.2.6",
30
+ "react-dom": "^19.2.6"
20
31
  },
21
32
  "bin": {
22
33
  "daemon": "bin/daemon.js",
@@ -9,6 +9,15 @@ export interface AgentRuntimeRunInput {
9
9
  cwd: string;
10
10
  agentHome?: string;
11
11
  projectCwd?: string;
12
+ projectContext?: {
13
+ id: string;
14
+ name: string;
15
+ rootPath: string;
16
+ computerId: string;
17
+ computerName?: string | null;
18
+ accessible: boolean;
19
+ currentComputerId?: string | null;
20
+ };
12
21
  extraArgs: string[];
13
22
  localDaemonUrl?: string;
14
23
  localDaemonToken?: string;
@@ -69,6 +78,11 @@ export function buildPalPrompt(input: AgentRuntimeRunInput): string {
69
78
  const chatName = sanitizeProviderIds(input.message.chat_name);
70
79
  const sender = sanitizeProviderIds(input.message.sender);
71
80
  const content = sanitizeProviderIds(input.message.content);
81
+ const projectContext = input.projectContext
82
+ ? input.projectContext.accessible
83
+ ? `\nProject context:\n- You are working in project: ${sanitizeProviderIds(input.projectContext.name)}\n- Project computer: ${sanitizeProviderIds(input.projectContext.computerName || input.projectContext.computerId)}\n- Project path: ${input.projectContext.rootPath}\n- This agent is running on the project computer and can use the project workspace path above.`
84
+ : `\nProject context:\n- This room belongs to project: ${sanitizeProviderIds(input.projectContext.name)}\n- Project computer: ${sanitizeProviderIds(input.projectContext.computerName || input.projectContext.computerId)}\n- Project path: ${input.projectContext.rootPath}\n- This agent is currently running on computer: ${sanitizeProviderIds(input.projectContext.currentComputerId || 'unknown')}\n- The computers differ, so the project path is temporarily not accessible from this agent run. Do not claim to inspect or change that path unless access is restored.`
85
+ : '';
72
86
 
73
87
  return `You are ${input.agent}, a long-running PAL coding agent connected to the pal chat server.
74
88
 
@@ -79,6 +93,7 @@ Workspace contract:
79
93
  - Your cwd is for identity, memory, recovery state, scratch files, and durable local notes.
80
94
  - Your cwd is not the project repository.
81
95
  - The project workspace for source inspection, code changes, tests, and Git work is: ${projectCwd}
96
+ ${projectContext}
82
97
 
83
98
  Startup recovery:
84
99
  - Read MEMORY.md in your cwd first when it exists.
package/src/app.ts CHANGED
@@ -1,11 +1,18 @@
1
1
  import { artifactHeaders, artifactViewerHtml } from './artifacts.js';
2
+ import { existsSync } from 'node:fs';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { join } from 'node:path';
5
+ import QRCode from 'qrcode';
2
6
  import { MessageStore } from './db.js';
3
7
  import { failure, HttpError, json, numberParam, readJson, stringParam } from './http.js';
4
8
  import { assertServerAuth } from './server-auth.js';
5
9
  import { dashboardHtml } from './web.js';
6
10
  import type { RunAction, RunStatus } from './types.js';
7
11
  import { createLarkApiClient, sendTextMessage } from './lark/ws-daemon.js';
8
- import { boundAgents, loadLarkCredentials, type LarkCredentialStore } from './lark/credentials.js';
12
+ import { boundAgents, defaultLarkConfigPath, loadLarkCredentials, type LarkCredentialStore } from './lark/credentials.js';
13
+ import { persistLarkCredential, resolveLarkBotInfo } from './lark/setup.js';
14
+ import { beginLarkAppRegistration, pollLarkAppRegistration, type LarkRegistrationComplete } from './lark/app-registration.js';
15
+ import { tailscaleAddress } from './network.js';
9
16
 
10
17
  interface SendBody {
11
18
  chat?: string;
@@ -27,6 +34,12 @@ interface CreateRoomBody {
27
34
  kind?: 'group' | 'dm';
28
35
  }
29
36
 
37
+ interface CreateProjectBody {
38
+ name?: string;
39
+ computer_id?: string;
40
+ root_path?: string;
41
+ }
42
+
30
43
  interface StartRunBody {
31
44
  message_id?: number;
32
45
  agent?: string;
@@ -69,6 +82,25 @@ interface ConnectComputerBody {
69
82
  agents?: Array<{ agent?: string; cwd?: string; capabilities?: Record<string, unknown> }>;
70
83
  }
71
84
 
85
+ async function fetchDaemonJson<T>(input: { localUrl: string; token: string; path: string }): Promise<T> {
86
+ const base = input.localUrl.replace(/\/$/, '');
87
+ const response = await fetch(`${base}${input.path}`, {
88
+ headers: { authorization: `Bearer ${input.token}` },
89
+ });
90
+ const payload = await response.json().catch(() => ({})) as { ok?: boolean; data?: T; message?: string; code?: string };
91
+ if (!response.ok || payload.ok === false) {
92
+ throw new HttpError(response.status || 502, payload.code || 'DAEMON_REQUEST_FAILED', payload.message || 'daemon request failed');
93
+ }
94
+ return (payload.data ?? {}) as T;
95
+ }
96
+
97
+ async function validateRemoteProjectPath(store: MessageStore, computerId: string, rootPath: string): Promise<void> {
98
+ const control = store.getComputerLocalControl(computerId);
99
+ if (!control) throw new HttpError(409, 'COMPUTER_NOT_BROWSABLE', 'computer is not online or does not expose a local filesystem browser');
100
+ const params = new URLSearchParams({ path: rootPath, validate: 'directory', show_hidden: 'true' });
101
+ await fetchDaemonJson({ localUrl: control.daemon.local_url, token: control.token, path: `/local/files?${params}` });
102
+ }
103
+
72
104
  interface AssignAgentBody {
73
105
  computer_id?: string;
74
106
  }
@@ -81,6 +113,20 @@ interface OnboardAgentBody {
81
113
  computer_id?: string;
82
114
  }
83
115
 
116
+ interface LarkSetupBody {
117
+ app_id?: string;
118
+ app_secret?: string;
119
+ registration_id?: string;
120
+ label?: string;
121
+ agent?: string;
122
+ config?: string;
123
+ }
124
+
125
+ interface LarkAuthorizedUserBody {
126
+ user_id?: string;
127
+ display_name?: string | null;
128
+ }
129
+
84
130
  interface CreateDeliveryBody {
85
131
  message_id?: number;
86
132
  agent?: string;
@@ -154,6 +200,89 @@ function html(body: string): Response {
154
200
  return new Response(body, { headers: { 'content-type': 'text/html; charset=utf-8' } });
155
201
  }
156
202
 
203
+ const webDistDir = fileURLToPath(new URL('../web/app/dist/', import.meta.url));
204
+
205
+ interface PendingLarkRegistration {
206
+ id: string;
207
+ deviceCode: string;
208
+ url: string;
209
+ qrDataUrl: string;
210
+ expiresAt: number;
211
+ interval: number;
212
+ completed?: LarkRegistrationComplete;
213
+ }
214
+
215
+ const larkRegistrations = new Map<string, PendingLarkRegistration>();
216
+
217
+ function pruneLarkRegistrations(): void {
218
+ const now = Date.now();
219
+ for (const [id, registration] of larkRegistrations) {
220
+ if (registration.expiresAt < now - 60_000) larkRegistrations.delete(id);
221
+ }
222
+ }
223
+
224
+ function larkRegistrationPublic(entry: PendingLarkRegistration, status: 'pending' | 'complete' = entry.completed ? 'complete' : 'pending') {
225
+ return {
226
+ id: entry.id,
227
+ status,
228
+ url: entry.url,
229
+ qrDataUrl: entry.qrDataUrl,
230
+ expiresAt: new Date(entry.expiresAt).toISOString(),
231
+ interval: entry.interval,
232
+ appId: entry.completed?.appId ?? null,
233
+ tenantBrand: entry.completed?.tenantBrand ?? null,
234
+ userOpenId: entry.completed?.userOpenId ?? null,
235
+ };
236
+ }
237
+
238
+ async function pollStoredLarkRegistration(id: string): Promise<PendingLarkRegistration> {
239
+ pruneLarkRegistrations();
240
+ const entry = larkRegistrations.get(id);
241
+ if (!entry) throw new HttpError(404, 'LARK_REGISTRATION_NOT_FOUND', 'registration was not found or has expired');
242
+ if (entry.completed) return entry;
243
+ if (Date.now() > entry.expiresAt) {
244
+ larkRegistrations.delete(id);
245
+ throw new HttpError(410, 'LARK_REGISTRATION_EXPIRED', 'registration link expired');
246
+ }
247
+ const result = await pollLarkAppRegistration({ deviceCode: entry.deviceCode });
248
+ if (result.status === 'pending') return entry;
249
+ if (result.status === 'slow_down') {
250
+ entry.interval = Math.max(entry.interval + 5, result.interval);
251
+ return entry;
252
+ }
253
+ if (result.status === 'complete') {
254
+ entry.completed = result.registration;
255
+ return entry;
256
+ }
257
+ throw new HttpError(400, 'LARK_REGISTRATION_FAILED', result.message);
258
+ }
259
+
260
+ function webAssetContentType(pathname: string): string {
261
+ if (pathname.endsWith('.js')) return 'text/javascript; charset=utf-8';
262
+ if (pathname.endsWith('.css')) return 'text/css; charset=utf-8';
263
+ if (pathname.endsWith('.svg')) return 'image/svg+xml';
264
+ if (pathname.endsWith('.png')) return 'image/png';
265
+ if (pathname.endsWith('.jpg') || pathname.endsWith('.jpeg')) return 'image/jpeg';
266
+ if (pathname.endsWith('.webp')) return 'image/webp';
267
+ if (pathname.endsWith('.ico')) return 'image/x-icon';
268
+ return 'application/octet-stream';
269
+ }
270
+
271
+ function builtWebIndex(): Response | null {
272
+ const indexPath = join(webDistDir, 'index.html');
273
+ if (!existsSync(indexPath)) return null;
274
+ return new Response(Bun.file(indexPath), { headers: { 'content-type': 'text/html; charset=utf-8' } });
275
+ }
276
+
277
+ function builtWebAsset(pathname: string): Response | null {
278
+ if (!pathname.startsWith('/assets/')) return null;
279
+ const relative = decodeURIComponent(pathname.slice('/assets/'.length));
280
+ if (!relative || relative.includes('..') || relative.includes('/') || relative.includes('\\')) return null;
281
+ const assetPath = join(webDistDir, 'assets', relative);
282
+ if (!existsSync(assetPath)) return null;
283
+ return new Response(Bun.file(assetPath), { headers: { 'content-type': webAssetContentType(assetPath) } });
284
+ }
285
+
157
286
  function daemonAuthFromRequest(request: Request): { computerId: string; connectionId: string; token: string } | null {
158
287
  const computerId = request.headers.get('x-pal-computer-id')?.trim();
159
288
  const connectionId = request.headers.get('x-pal-connection-id')?.trim();
@@ -185,13 +314,29 @@ export function route(store: MessageStore, request: Request): Promise<Response>
185
314
  const { pathname } = url;
186
315
 
187
316
  if (request.method === 'GET' && pathname === '/') {
188
- return html(dashboardHtml());
317
+ return builtWebIndex() ?? html(dashboardHtml());
318
+ }
319
+
320
+ if (request.method === 'GET') {
321
+ const asset = builtWebAsset(pathname);
322
+ if (asset) return asset;
189
323
  }
190
324
 
191
325
  if (request.method === 'GET' && pathname === '/health') {
192
326
  return json({ status: 'ok' });
193
327
  }
194
328
 
329
+ if (request.method === 'GET' && pathname === '/api/server/access') {
330
+ const requestUrl = new URL(request.url);
331
+ const port = requestUrl.port || (requestUrl.protocol === 'https:' ? '443' : '80');
332
+ const tailscaleHost = tailscaleAddress();
333
+ return json({
334
+ localUrl: `${requestUrl.protocol}//127.0.0.1:${port}`,
335
+ tailscaleUrl: tailscaleHost ? `${requestUrl.protocol}//${tailscaleHost}:${port}` : null,
336
+ tailscaleHost,
337
+ });
338
+ }
339
+
195
340
  if (request.method === 'GET' && pathname === '/api/computers') {
196
341
  return json({ computers: store.listComputers(numberParam(url, 'limit', 50)) });
197
342
  }
@@ -225,7 +370,14 @@ export function route(store: MessageStore, request: Request): Promise<Response>
225
370
  capabilities: agent.capabilities,
226
371
  })),
227
372
  });
228
- return json(result, 201);
373
+ return json({
374
+ computer: result.computer,
375
+ connection: result.connection,
376
+ token: result.token,
377
+ local_control_token: result.localControlToken,
378
+ daemon: result.daemon,
379
+ agents: result.agents,
380
+ }, 201);
229
381
  });
230
382
  }
231
383
 
@@ -303,6 +455,37 @@ export function route(store: MessageStore, request: Request): Promise<Response>
303
455
  return json({ rooms: store.listChats() });
304
456
  }
305
457
 
458
+ if (request.method === 'GET' && pathname === '/api/projects') {
459
+ return json({ projects: store.listProjects(numberParam(url, 'limit', 50)) });
460
+ }
461
+
462
+ if (request.method === 'POST' && pathname === '/api/projects') {
463
+ return readJson<CreateProjectBody>(request).then(async (body) => {
464
+ const name = body.name?.trim();
465
+ const computerId = body.computer_id?.trim();
466
+ const rootPath = body.root_path?.trim();
467
+ if (!name) throw new HttpError(400, 'MISSING_PROJECT_NAME', 'name is required');
468
+ if (!computerId) throw new HttpError(400, 'MISSING_COMPUTER_ID', 'computer_id is required');
469
+ if (!rootPath) throw new HttpError(400, 'MISSING_ROOT_PATH', 'root_path is required');
470
+ await validateRemoteProjectPath(store, computerId, rootPath);
471
+ const project = store.createProject({ name, computerId, rootPath });
472
+ return json({ project }, 201);
473
+ });
474
+ }
475
+
476
+ const computerFilesMatch = pathname.match(/^\/api\/computers\/([^/]+)\/files$/);
477
+ if (request.method === 'GET' && computerFilesMatch) {
478
+ const computerId = decodeURIComponent(computerFilesMatch[1]!);
479
+ const control = store.getComputerLocalControl(computerId);
480
+ if (!control) throw new HttpError(409, 'COMPUTER_NOT_BROWSABLE', 'computer is not online or does not expose a local filesystem browser');
481
+ const params = new URLSearchParams();
482
+ const browsePath = stringParam(url, 'path');
483
+ if (browsePath) params.set('path', browsePath);
484
+ if (url.searchParams.get('show_hidden') === 'true') params.set('show_hidden', 'true');
485
+ return fetchDaemonJson({ localUrl: control.daemon.local_url, token: control.token, path: `/local/files${params.size ? `?${params}` : ''}` })
486
+ .then((data) => json(data));
487
+ }
488
+
306
489
  if (request.method === 'POST' && pathname === '/api/rooms') {
307
490
  return readJson<CreateRoomBody>(request).then((body) => {
308
491
  if (!body.name?.trim()) throw new HttpError(400, 'MISSING_ROOM_NAME', 'name is required');
@@ -312,6 +495,20 @@ export function route(store: MessageStore, request: Request): Promise<Response>
312
495
  });
313
496
  }
314
497
 
498
+ const projectRoomsMatch = pathname.match(/^\/api\/projects\/([^/]+)\/rooms$/);
499
+ if (request.method === 'POST' && projectRoomsMatch) {
500
+ return readJson<CreateRoomBody>(request).then((body) => {
501
+ if (!body.name?.trim()) throw new HttpError(400, 'MISSING_ROOM_NAME', 'name is required');
502
+ if (body.kind && body.kind !== 'group' && body.kind !== 'dm') throw new HttpError(400, 'BAD_ROOM_KIND', 'kind must be group or dm');
503
+ const room = store.createProjectRoom({
504
+ projectId: decodeURIComponent(projectRoomsMatch[1]!),
505
+ name: body.name,
506
+ kind: body.kind ?? 'group',
507
+ });
508
+ return json({ room }, 201);
509
+ });
510
+ }
511
+
315
512
  const roomMembersMatch = pathname.match(/^\/api\/rooms\/([^/]+)\/members$/);
316
513
  if (request.method === 'GET' && roomMembersMatch) {
317
514
  const room = store.resolveRoom(decodeURIComponent(roomMembersMatch[1]!));
@@ -417,6 +614,120 @@ export function route(store: MessageStore, request: Request): Promise<Response>
417
614
  return json({ sessions: store.listSessions(numberParam(url, 'limit', 50)) });
418
615
  }
419
616
 
617
+ if (request.method === 'GET' && pathname === '/api/lark/config') {
618
+ const path = stringParam(url, 'config') ?? defaultLarkConfigPath();
619
+ const credentials = loadLarkCredentials(path);
620
+ return json({
621
+ path,
622
+ bots: credentials.bots.map((bot) => ({
623
+ appId: bot.appId,
624
+ label: bot.label ?? null,
625
+ agent: bot.agent ?? null,
626
+ boundAgents: boundAgents(bot),
627
+ botOpenId: bot.botOpenId ?? null,
628
+ hasSecret: Boolean(bot.appSecret),
629
+ })),
630
+ });
631
+ }
632
+
633
+ if (request.method === 'POST' && pathname === '/api/lark/registration') {
634
+ return (async () => {
635
+ pruneLarkRegistrations();
636
+ const begin = await beginLarkAppRegistration({ source: 'pal-web' });
637
+ const id = crypto.randomUUID();
638
+ const qrDataUrl = await QRCode.toDataURL(begin.url, {
639
+ margin: 1,
640
+ width: 220,
641
+ color: { dark: '#181714', light: '#ffffff' },
642
+ });
643
+ const entry: PendingLarkRegistration = {
644
+ id,
645
+ deviceCode: begin.deviceCode,
646
+ url: begin.url,
647
+ qrDataUrl,
648
+ expiresAt: Date.now() + begin.expiresIn * 1000,
649
+ interval: begin.interval,
650
+ };
651
+ larkRegistrations.set(id, entry);
652
+ return json(larkRegistrationPublic(entry), 201);
653
+ })();
654
+ }
655
+
656
+ const larkRegistrationMatch = pathname.match(/^\/api\/lark\/registration\/([^/]+)$/);
657
+ if (request.method === 'GET' && larkRegistrationMatch) {
658
+ return (async () => {
659
+ const entry = await pollStoredLarkRegistration(decodeURIComponent(larkRegistrationMatch[1]!));
660
+ return json(larkRegistrationPublic(entry));
661
+ })();
662
+ }
663
+
664
+ if (request.method === 'GET' && pathname === '/api/lark/authorized-users') {
665
+ return json({ users: store.listLarkAuthorizedUsers() });
666
+ }
667
+
668
+ if (request.method === 'POST' && pathname === '/api/lark/authorized-users') {
669
+ return readJson<LarkAuthorizedUserBody>(request).then((body) => {
670
+ const userId = body.user_id?.trim();
671
+ if (!userId) throw new HttpError(400, 'MISSING_USER_ID', 'user_id is required');
672
+ const user = store.upsertLarkAuthorizedUser({ userId, displayName: body.display_name });
673
+ return json({ user }, 201);
674
+ });
675
+ }
676
+
677
+ const larkAuthorizedUserMatch = pathname.match(/^\/api\/lark\/authorized-users\/([^/]+)$/);
678
+ if (request.method === 'DELETE' && larkAuthorizedUserMatch) {
679
+ const userId = decodeURIComponent(larkAuthorizedUserMatch[1]!);
680
+ const deleted = store.deleteLarkAuthorizedUser(userId);
681
+ if (!deleted) throw new HttpError(404, 'LARK_USER_NOT_FOUND', 'authorized Lark user was not found');
682
+ return json({ deleted: true });
683
+ }
684
+
685
+ if (request.method === 'POST' && pathname === '/api/lark/setup') {
686
+ return readJson<LarkSetupBody>(request).then(async (body) => {
687
+ const registrationId = body.registration_id?.trim();
688
+ let appId = body.app_id?.trim();
689
+ let appSecret = body.app_secret?.trim();
690
+ const agent = body.agent?.trim();
691
+ const label = body.label?.trim();
692
+ const configPath = body.config?.trim() || defaultLarkConfigPath();
693
+ if (registrationId) {
694
+ const registration = await pollStoredLarkRegistration(registrationId);
695
+ if (!registration.completed) throw new HttpError(409, 'LARK_REGISTRATION_PENDING', 'registration has not completed yet');
696
+ if (registration.completed.tenantBrand === 'lark') {
697
+ throw new HttpError(400, 'LARK_TENANT_UNSUPPORTED', 'Lark international tenants are not supported yet; use a Feishu tenant');
698
+ }
699
+ appId = registration.completed.appId;
700
+ appSecret = registration.completed.appSecret;
701
+ }
702
+ if (!appId) throw new HttpError(400, 'MISSING_APP_ID', 'app_id is required');
703
+ if (!appSecret) throw new HttpError(400, 'MISSING_APP_SECRET', 'app_secret is required');
704
+ if (!agent) throw new HttpError(400, 'MISSING_AGENT', 'agent is required');
705
+ if (!store.getAgent(agent)) throw new HttpError(404, 'AGENT_NOT_FOUND', `agent ${agent} not found`);
706
+
707
+ const botInfo = await resolveLarkBotInfo(appId, appSecret);
708
+ if (!botInfo.ok) {
709
+ throw new HttpError(400, 'LARK_CREDENTIALS_REJECTED', `could not resolve bot open_id (${botInfo.error}): ${botInfo.message}`);
710
+ }
711
+
712
+ const result = persistLarkCredential({
713
+ appId,
714
+ appSecret,
715
+ label,
716
+ agent,
717
+ botOpenId: botInfo.openId,
718
+ configPath,
719
+ });
720
+ const account = store.registerChannelAccount({
721
+ name: label || botInfo.appName || appId,
722
+ appId,
723
+ botOpenId: botInfo.openId,
724
+ agent,
725
+ });
726
+ if (registrationId) larkRegistrations.delete(registrationId);
727
+ return json({ ...result, appName: botInfo.appName ?? null, account }, 201);
728
+ });
729
+ }
730
+
420
731
  const sessionRunsMatch = pathname.match(/^\/api\/sessions\/([^/]+)\/runs$/);
421
732
  if (request.method === 'GET' && sessionRunsMatch) {
422
733
  return json({ runs: store.listRunsForSession(sessionRunsMatch[1]!, numberParam(url, 'limit', 50)) });
package/src/client.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { defaultServerToken, defaultServerUrl } from './config.js';
2
2
  import { serverAuthHeaders } from './server-auth.js';
3
- import type { AgentRoomSubscriptionMode, AgentRun, AgentSession, ApiResponse, Chat, Computer, ComputerAgentAssignment, ComputerConnection, DaemonInstance, Message, MessageDelivery, ProvisionedComputer, RoomChannel, RoomParticipant, RunAction } from './types.js';
3
+ import type { AgentRoomSubscriptionMode, AgentRun, AgentSession, ApiResponse, Chat, Computer, ComputerAgentAssignment, ComputerConnection, DaemonInstance, Message, MessageDelivery, ProvisionedComputer, RemoteFileList, RoomChannel, RoomParticipant, RunAction } from './types.js';
4
4
 
5
5
  export interface SendRequest {
6
6
  chat?: string;
@@ -182,8 +182,16 @@ export class LockClient {
182
182
  return data.computers;
183
183
  }
184
184
 
185
- async connectComputer(input: ConnectComputerRequest): Promise<{ computer: Computer; connection: ComputerConnection; token: string; daemon: DaemonInstance; agents: ComputerAgentAssignment[] }> {
186
- const data = await this.post<{ computer: Computer; connection: ComputerConnection; token: string; daemon: DaemonInstance; agents: ComputerAgentAssignment[] }>('/api/computers/connect', input);
185
+ async connectComputer(input: ConnectComputerRequest): Promise<{ computer: Computer; connection: ComputerConnection; token: string; local_control_token?: string; daemon: DaemonInstance; agents: ComputerAgentAssignment[] }> {
186
+ const data = await this.post<{ computer: Computer; connection: ComputerConnection; token: string; local_control_token?: string; daemon: DaemonInstance; agents: ComputerAgentAssignment[] }>('/api/computers/connect', input);
187
+ return data;
188
+ }
189
+
190
+ async listComputerFiles(computerId: string, input: { path?: string; show_hidden?: boolean } = {}): Promise<RemoteFileList> {
191
+ const params = new URLSearchParams();
192
+ if (input.path) params.set('path', input.path);
193
+ if (input.show_hidden) params.set('show_hidden', 'true');
194
+ const data = await this.get<RemoteFileList>(`/api/computers/${encodeURIComponent(computerId)}/files${params.size ? `?${params}` : ''}`);
187
195
  return data;
188
196
  }
189
197