@controlflow-ai/daemon 0.1.1 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/console.ts CHANGED
@@ -4,6 +4,9 @@ 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 { defaultLarkConfigPath, findCredentialByAgent, loadLarkCredentials, saveLarkCredentials, unbindCredentialAgent, upsertCredential } from './lark/credentials.js';
8
+ import { registerLarkAppFromDeviceFlow, resolveLarkBotInfo, type LarkBotInfoResult } from './lark/setup.js';
9
+ import type { LarkRegistrationComplete } from './lark/app-registration.js';
7
10
  import type { Computer, ProvisionedComputer } from './types.js';
8
11
 
9
12
  interface Prompt {
@@ -26,11 +29,12 @@ Usage:
26
29
  bun run src/console.ts agents list [--json]
27
30
  bun run src/console.ts agents onboard [--interactive] [--key <agent-key>] [--name <display-name>] [--runtime codex] [--computer-id <machine>]
28
31
  bun run src/console.ts agents create --key <agent-key> --name <display-name> [--runtime neeko|coco|coco-stream-json|codex] [--desc <description>]
29
- bun run src/console.ts agents update --key <agent-key> --runtime neeko|coco|coco-stream-json|codex
32
+ bun run src/console.ts agents update [--interactive] --key <agent-key> [--runtime neeko|coco|coco-stream-json|codex]
33
+ [--lark-app-id <id> --lark-app-secret <secret>] [--lark-label <name>] [--lark-config <path>] [--rebind-lark] [--unbind-lark] [--no-reload]
30
34
  bun run src/console.ts lark-users list [--json]
31
35
  bun run src/console.ts lark-users add [--interactive] --user-id <union-id> [--name <display-name>]
32
36
  bun run src/console.ts lark-users delete --user-id <union-id>
33
- bun run src/console.ts lark <setup|list|daemon|events|send> [flags]
37
+ bun run src/console.ts lark <list|daemon|events|send> [flags]
34
38
 
35
39
  Environment:
36
40
  PAL_SERVER=${defaultServerUrl()}
@@ -44,6 +48,83 @@ async function requestJson<T>(url: string, init?: RequestInit): Promise<T> {
44
48
  return payload.data as T;
45
49
  }
46
50
 
51
+ async function reloadLarkIntegration(serverUrl: string): Promise<void> {
52
+ const response = await fetch(`${serverUrl.replace(/\/$/, '')}/api/lark/reload`, {
53
+ method: 'POST',
54
+ headers: { 'content-type': 'application/json' },
55
+ body: JSON.stringify({}),
56
+ });
57
+ if (!response.ok) {
58
+ const text = await response.text();
59
+ console.warn(`Lark bot config saved, but server reload failed (${response.status}): ${text}`);
60
+ return;
61
+ }
62
+ console.log('Lark integration reloaded.');
63
+ }
64
+
65
+ async function resolveBotInfoForConsole(appId: string, appSecret: string): Promise<LarkBotInfoResult> {
66
+ if (process.env.NODE_ENV === 'test' && process.env.PAL_TEST_LARK_BOT_OPEN_ID) {
67
+ return { ok: true, openId: process.env.PAL_TEST_LARK_BOT_OPEN_ID };
68
+ }
69
+ return resolveLarkBotInfo(appId, appSecret);
70
+ }
71
+
72
+ async function registerLarkAppForConsole(): Promise<LarkRegistrationComplete | null> {
73
+ if (process.env.NODE_ENV === 'test' && process.env.PAL_TEST_LARK_REGISTRATION_APP_ID && process.env.PAL_TEST_LARK_REGISTRATION_APP_SECRET) {
74
+ console.log('Create a Feishu app by scanning this QR code or opening the link:');
75
+ console.log('https://example.test/lark-registration');
76
+ return {
77
+ appId: process.env.PAL_TEST_LARK_REGISTRATION_APP_ID,
78
+ appSecret: process.env.PAL_TEST_LARK_REGISTRATION_APP_SECRET,
79
+ tenantBrand: 'feishu',
80
+ userOpenId: process.env.PAL_TEST_LARK_REGISTRATION_USER_OPEN_ID,
81
+ };
82
+ }
83
+ return registerLarkAppFromDeviceFlow({ log: console });
84
+ }
85
+
86
+ function larkConfigPath(flags: Record<string, string | boolean>): string {
87
+ return flag(flags, 'lark-config') ?? flag(flags, 'config') ?? defaultLarkConfigPath();
88
+ }
89
+
90
+ async function unbindLarkBotFromAgent(serverClient: LockClient, flags: Record<string, string | boolean>, agentKey: string): Promise<void> {
91
+ const configPath = larkConfigPath(flags);
92
+ const store = loadLarkCredentials(configPath);
93
+ const result = unbindCredentialAgent(store, agentKey);
94
+ if (!result.changed) {
95
+ console.log(`Agent ${agentKey} has no Lark bot binding.`);
96
+ return;
97
+ }
98
+ saveLarkCredentials(result.store, configPath);
99
+ console.log(`Lark bot ${result.unbound!.appId} unbound from agent ${agentKey}.`);
100
+ if (!boolFlag(flags, 'no-reload')) await reloadLarkIntegration(serverClient.baseUrl);
101
+ }
102
+
103
+ async function bindLarkBotToAgent(serverClient: LockClient, flags: Record<string, string | boolean>, agentKey: string): Promise<void> {
104
+ const appId = flag(flags, 'lark-app-id') ?? flag(flags, 'app-id');
105
+ const appSecret = flag(flags, 'lark-app-secret') ?? flag(flags, 'app-secret');
106
+ if (!appId && !appSecret) return;
107
+ if (!appId || !appSecret) throw new Error('--lark-app-id and --lark-app-secret are required together');
108
+
109
+ const botInfo = await resolveBotInfoForConsole(appId, appSecret);
110
+ if (!botInfo.ok) throw new Error(`could not resolve Lark bot open_id (${botInfo.error}): ${botInfo.message}`);
111
+
112
+ const configPath = larkConfigPath(flags);
113
+ const store = loadLarkCredentials(configPath);
114
+ const result = upsertCredential(store, {
115
+ appId,
116
+ appSecret,
117
+ label: flag(flags, 'lark-label') ?? flag(flags, 'label'),
118
+ agent: agentKey,
119
+ botOpenId: botInfo.openId,
120
+ }, { rebind: boolFlag(flags, 'rebind-lark') });
121
+ saveLarkCredentials(result.store, configPath);
122
+
123
+ const moved = result.unbound.length > 0 ? `; moved from ${result.unbound.map((bot) => bot.appId).join(', ')}` : '';
124
+ console.log(`Lark bot ${appId} bound to agent ${agentKey}${moved}.`);
125
+ if (!boolFlag(flags, 'no-reload')) await reloadLarkIntegration(serverClient.baseUrl);
126
+ }
127
+
47
128
  function printJson(value: unknown): void {
48
129
  console.log(JSON.stringify(value, null, 2));
49
130
  }
@@ -132,12 +213,35 @@ async function askComputerId(prompt: Prompt, computers: Computer[]): Promise<str
132
213
  }
133
214
  }
134
215
 
216
+ async function listAgentRecords(serverClient: LockClient): Promise<Array<Record<string, unknown>>> {
217
+ const response = await fetch(`${serverClient.baseUrl}/api/agents`);
218
+ const payload = await response.json() as { data?: { agents: Array<Record<string, unknown>> }; message?: string };
219
+ if (!response.ok) throw new Error(payload.message ?? `list failed: ${response.status}`);
220
+ return payload.data?.agents ?? [];
221
+ }
222
+
223
+ async function askAgentKey(prompt: Prompt, agents: Array<Record<string, unknown>>, defaultValue = ''): Promise<string> {
224
+ if (agents.length === 0) throw new Error('No agents found. Set up an agent first with "bun run console -- agents onboard".');
225
+
226
+ console.log('Agents:');
227
+ agents.forEach((agent, index) => {
228
+ console.log(` ${index + 1}. ${agent.display_name} (${agent.agent_key}) runtime=${agent.runtime ?? '-'}`);
229
+ });
230
+
231
+ while (true) {
232
+ const answer = await ask(prompt, 'Choose agent', defaultValue);
233
+ const selected = agents[Number(answer) - 1] ?? agents.find((agent) => agent.agent_key === answer);
234
+ if (selected?.agent_key) return String(selected.agent_key);
235
+ console.log('Choose a listed number or agent key.');
236
+ }
237
+ }
238
+
135
239
  async function collectComputerOnboardInput(serverClient: LockClient, flags: Record<string, string | boolean>): Promise<{ name: string; serverUrl: string; packageName: string }> {
136
240
  const hasAnyProvisionFlag = Boolean(flag(flags, 'name') || flag(flags, 'server-url') || flag(flags, 'package-name'));
137
241
  const interactive = boolFlag(flags, 'interactive') || (!hasAnyProvisionFlag && process.stdin.isTTY === true);
138
242
  const defaultName = flag(flags, 'name') ?? 'Local computer';
139
243
  const defaultServerUrl = flag(flags, 'server-url') ?? serverClient.baseUrl;
140
- const defaultPackageName = flag(flags, 'package-name') ?? '@slock-ai/daemon@latest';
244
+ const defaultPackageName = flag(flags, 'package-name') ?? '@controlflow-ai/daemon@latest';
141
245
 
142
246
  if (!interactive) {
143
247
  return {
@@ -176,7 +280,56 @@ function printProvisionedComputer(provisioned: ProvisionedComputer): void {
176
280
  console.log(provisioned.command);
177
281
  }
178
282
 
179
- async function collectOnboardInput(serverClient: LockClient, flags: Record<string, string | boolean>): Promise<{ agentKey: string; displayName: string; runtime: string; desc: string | null; computerId: string | undefined }> {
283
+ async function collectLarkBindFlags(prompt: Prompt, flags: Record<string, string | boolean>, agentKey: string, existingAppId?: string): Promise<Record<string, string | boolean>> {
284
+ const configPath = larkConfigPath(flags);
285
+ console.log('Lark bot setup');
286
+ console.log(' 1. Scan or open link to create app');
287
+ console.log(' 2. Paste App ID / App Secret');
288
+ const method = await ask(prompt, 'Setup method', '1');
289
+
290
+ let appId = flag(flags, 'lark-app-id') ?? flag(flags, 'app-id');
291
+ let appSecret = flag(flags, 'lark-app-secret') ?? flag(flags, 'app-secret');
292
+ let scannedUserOpenId: string | undefined;
293
+ if (method !== '2' && (!appId || !appSecret)) {
294
+ const registration = await registerLarkAppForConsole().catch((error) => {
295
+ console.warn(`App registration failed: ${error instanceof Error ? error.message : String(error)}`);
296
+ return null;
297
+ });
298
+ if (registration?.tenantBrand === 'lark') {
299
+ throw new Error('Lark international tenants are not supported yet; use a Feishu tenant');
300
+ }
301
+ if (registration) {
302
+ appId = registration.appId;
303
+ appSecret = registration.appSecret;
304
+ scannedUserOpenId = registration.userOpenId;
305
+ console.log(`App created: ${appId}`);
306
+ } else {
307
+ console.warn('Falling back to manual App ID / App Secret entry.');
308
+ }
309
+ }
310
+
311
+ appId = appId || await askRequired(prompt, 'Lark App ID', existingAppId ?? '');
312
+ appSecret = appSecret || await askRequired(prompt, 'Lark App Secret');
313
+ const label = await ask(prompt, 'Bot label (optional)', flag(flags, 'lark-label') ?? flag(flags, 'label') ?? '');
314
+ if (scannedUserOpenId) {
315
+ console.log(`Scanner open_id ${scannedUserOpenId} was detected; add it under Lark authorized users if this user should operate Pal.`);
316
+ }
317
+
318
+ const isChangingBot = Boolean(existingAppId && existingAppId !== appId);
319
+ const rebind = boolFlag(flags, 'rebind-lark') || (isChangingBot && await askYesNo(prompt, `Move ${agentKey} from ${existingAppId} to ${appId}?`, false));
320
+ if (isChangingBot && !rebind) throw new Error('Lark bot rebind cancelled');
321
+
322
+ return {
323
+ ...flags,
324
+ 'lark-app-id': appId,
325
+ 'lark-app-secret': appSecret,
326
+ 'lark-config': configPath,
327
+ ...(label ? { 'lark-label': label } : {}),
328
+ ...(rebind ? { 'rebind-lark': true } : {}),
329
+ };
330
+ }
331
+
332
+ async function collectOnboardInput(serverClient: LockClient, flags: Record<string, string | boolean>): Promise<{ agentKey: string; displayName: string; runtime: string; desc: string | null; computerId: string | undefined; larkFlags?: Record<string, string | boolean> }> {
180
333
  const hasRequiredFlags = Boolean(flag(flags, 'key') && flag(flags, 'name'));
181
334
  const interactive = boolFlag(flags, 'interactive') || (!hasRequiredFlags && process.stdin.isTTY === true);
182
335
  if (!interactive) {
@@ -187,6 +340,7 @@ async function collectOnboardInput(serverClient: LockClient, flags: Record<strin
187
340
  runtime: flag(flags, 'runtime') ?? 'codex',
188
341
  desc: flag(flags, 'desc') ?? null,
189
342
  computerId: flag(flags, 'computer-id'),
343
+ larkFlags: flag(flags, 'lark-app-id') || flag(flags, 'app-id') ? flags : undefined,
190
344
  };
191
345
  }
192
346
 
@@ -210,12 +364,68 @@ async function collectOnboardInput(serverClient: LockClient, flags: Record<strin
210
364
  console.log(` description: ${desc || '-'}`);
211
365
  console.log(` computer_id: ${computerId || '-'}`);
212
366
  if (!await askYesNo(prompt, 'Create/update this agent?', true)) throw new Error('onboarding cancelled');
367
+ const shouldSetupLark = await askYesNo(prompt, 'Set up a Lark bot for this agent?', false);
368
+ const larkFlags = shouldSetupLark ? await collectLarkBindFlags(prompt, flags, agentKey) : undefined;
213
369
  return {
214
370
  agentKey,
215
371
  displayName,
216
372
  runtime,
217
373
  desc: desc || null,
218
374
  computerId: computerId || undefined,
375
+ larkFlags,
376
+ };
377
+ } finally {
378
+ prompt.close();
379
+ }
380
+ }
381
+
382
+ async function collectAgentUpdateInput(serverClient: LockClient, flags: Record<string, string | boolean>): Promise<{ agentKey: string; flags: Record<string, string | boolean> }> {
383
+ if (flag(flags, 'runtime')) throw new Error('agents update --interactive currently supports only Lark bot binding or unbinding');
384
+
385
+ const prompt = await createPrompt();
386
+ try {
387
+ console.log('Agent Lark settings');
388
+ const agents = await listAgentRecords(serverClient);
389
+ const agentKey = await askAgentKey(prompt, agents, flag(flags, 'key') ?? (agents.length === 1 ? '1' : ''));
390
+ const configPath = larkConfigPath(flags);
391
+ const store = loadLarkCredentials(configPath);
392
+ const existing = findCredentialByAgent(store, agentKey);
393
+ if (existing) {
394
+ console.log(`Current Lark bot: ${existing.label ?? existing.appId} (${existing.appId})`);
395
+ } else {
396
+ console.log('Current Lark bot: -');
397
+ }
398
+
399
+ const defaultAction = existing ? '1' : '1';
400
+ console.log('Lark action:');
401
+ console.log(` 1. ${existing ? 'Bind/rebind bot' : 'Bind bot'}`);
402
+ if (existing) console.log(' 2. Unbind bot');
403
+ const action = await ask(prompt, 'Choose action', defaultAction);
404
+ if (existing && (action === '2' || action.toLowerCase() === 'unbind')) {
405
+ if (!await askYesNo(prompt, `Unbind ${existing.appId} from ${agentKey}?`, false)) throw new Error('Lark bot unbind cancelled');
406
+ return {
407
+ agentKey,
408
+ flags: {
409
+ ...flags,
410
+ 'lark-config': configPath,
411
+ 'unbind-lark': true,
412
+ },
413
+ };
414
+ }
415
+
416
+ const larkFlags = await collectLarkBindFlags(prompt, flags, agentKey, existing?.appId);
417
+
418
+ console.log('');
419
+ console.log('Summary:');
420
+ console.log(` agent_key: ${agentKey}`);
421
+ console.log(` lark_app_id: ${flag(larkFlags, 'lark-app-id')}`);
422
+ console.log(` lark_label: ${flag(larkFlags, 'lark-label') || '-'}`);
423
+ console.log(` rebind: ${boolFlag(larkFlags, 'rebind-lark') ? 'yes' : 'no'}`);
424
+ if (!await askYesNo(prompt, 'Bind this Lark bot?', true)) throw new Error('Lark bot binding cancelled');
425
+
426
+ return {
427
+ agentKey,
428
+ flags: larkFlags,
219
429
  };
220
430
  } finally {
221
431
  prompt.close();
@@ -366,10 +576,7 @@ async function main(): Promise<void> {
366
576
  const sub = args.values[0];
367
577
 
368
578
  if (sub === 'list') {
369
- const response = await fetch(`${serverClient.baseUrl}/api/agents`);
370
- const payload = await response.json() as { data?: { agents: Array<Record<string, unknown>> }; message?: string };
371
- if (!response.ok) throw new Error(payload.message ?? `list failed: ${response.status}`);
372
- const agents = payload.data?.agents ?? [];
579
+ const agents = await listAgentRecords(serverClient);
373
580
  if (args.flags.json) {
374
581
  printJson(agents);
375
582
  } else {
@@ -405,7 +612,7 @@ async function main(): Promise<void> {
405
612
  }
406
613
 
407
614
  if (sub === 'onboard') {
408
- const { agentKey, displayName, runtime, desc, computerId } = await collectOnboardInput(serverClient, args.flags);
615
+ const { agentKey, displayName, runtime, desc, computerId, larkFlags } = await collectOnboardInput(serverClient, args.flags);
409
616
  if (!agentKey) throw new Error('agent key is required');
410
617
  if (!displayName) throw new Error('display name is required');
411
618
 
@@ -422,24 +629,41 @@ async function main(): Promise<void> {
422
629
  });
423
630
  const payload = await response.json() as { data?: Record<string, unknown>; message?: string };
424
631
  if (!response.ok) throw new Error(payload.message ?? `onboard failed: ${response.status}`);
632
+ if (larkFlags) await bindLarkBotToAgent(serverClient, larkFlags, agentKey);
425
633
  printJson(payload.data);
426
634
  return;
427
635
  }
428
636
 
429
637
  if (sub === 'update') {
430
- const agentKey = flag(args.flags, 'key');
638
+ const updateInput = boolFlag(args.flags, 'interactive')
639
+ ? await collectAgentUpdateInput(serverClient, args.flags)
640
+ : { agentKey: flag(args.flags, 'key') ?? '', flags: args.flags };
641
+ const agentKey = updateInput.agentKey;
642
+ const updateFlags = updateInput.flags;
431
643
  const runtime = flag(args.flags, 'runtime');
432
644
  if (!agentKey) throw new Error('--key is required');
433
- if (!runtime) throw new Error('--runtime is required');
645
+ const hasLarkBinding = Boolean(flag(updateFlags, 'lark-app-id') || flag(updateFlags, 'app-id') || flag(updateFlags, 'lark-app-secret') || flag(updateFlags, 'app-secret'));
646
+ const hasLarkUnbind = boolFlag(updateFlags, 'unbind-lark');
647
+ if (!runtime && !hasLarkBinding && !hasLarkUnbind) throw new Error('--runtime, --lark-app-id/--lark-app-secret, or --unbind-lark is required');
648
+
649
+ let agent: Record<string, unknown> | undefined;
650
+ if (runtime) {
651
+ const response = await fetch(`${serverClient.baseUrl}/api/agents/${encodeURIComponent(agentKey)}`, {
652
+ method: 'PATCH',
653
+ headers: { 'content-type': 'application/json' },
654
+ body: JSON.stringify({ runtime }),
655
+ });
656
+ const payload = await response.json() as { data?: { agent: Record<string, unknown> }; message?: string };
657
+ if (!response.ok) throw new Error(payload.message ?? `update failed: ${response.status}`);
658
+ agent = payload.data?.agent;
659
+ } else {
660
+ agent = (await listAgentRecords(serverClient)).find((candidate) => candidate.agent_key === agentKey);
661
+ if (!agent) throw new Error(`agent ${agentKey} not found`);
662
+ }
434
663
 
435
- const response = await fetch(`${serverClient.baseUrl}/api/agents/${encodeURIComponent(agentKey)}`, {
436
- method: 'PATCH',
437
- headers: { 'content-type': 'application/json' },
438
- body: JSON.stringify({ runtime }),
439
- });
440
- const payload = await response.json() as { data?: { agent: Record<string, unknown> }; message?: string };
441
- if (!response.ok) throw new Error(payload.message ?? `update failed: ${response.status}`);
442
- printJson(payload.data?.agent);
664
+ if (hasLarkUnbind) await unbindLarkBotFromAgent(serverClient, updateFlags, agentKey);
665
+ else await bindLarkBotToAgent(serverClient, updateFlags, agentKey);
666
+ printJson(agent);
443
667
  return;
444
668
  }
445
669
 
package/src/daemon.ts CHANGED
@@ -153,12 +153,22 @@ async function executeRun(client: LockClient, delivery: MessageDelivery, message
153
153
  console.log(`${logPrefix} run=${run.id} status=${run.status}`);
154
154
 
155
155
  const { runAgentRuntime } = await import('./agent-runtime.js');
156
- const roomParticipants = await client.listRoomMembers(message.chat_id)
157
- .then((result) => result.participants)
156
+ const roomSnapshot = await client.listRoomMembers(message.chat_id)
157
+ .then((result) => result)
158
158
  .catch((error) => {
159
159
  console.warn(`${logPrefix} room participant snapshot unavailable: ${error instanceof Error ? error.message : String(error)}`);
160
- return [];
160
+ return null;
161
161
  });
162
+ const roomParticipants = roomSnapshot?.participants ?? [];
163
+ const roomProject = roomSnapshot?.room.project_id ? {
164
+ id: roomSnapshot.room.project_id,
165
+ name: roomSnapshot.room.project_name ?? roomSnapshot.room.project_id,
166
+ rootPath: roomSnapshot.room.project_root_path ?? '',
167
+ computerId: roomSnapshot.room.project_computer_id ?? '',
168
+ computerName: roomSnapshot.room.project_computer_name,
169
+ } : null;
170
+ const projectAccessible = Boolean(roomProject && options.computerId && roomProject.computerId === options.computerId);
171
+ const effectiveProjectCwd = projectAccessible && roomProject?.rootPath ? roomProject.rootPath : options.projectCwd;
162
172
  const recentMessages = await client.getMessages(new URLSearchParams({
163
173
  chat_id: message.chat_id,
164
174
  after: String(Math.max(0, message.id - 50)),
@@ -176,7 +186,12 @@ async function executeRun(client: LockClient, delivery: MessageDelivery, message
176
186
  message,
177
187
  cwd: options.agentHome,
178
188
  agentHome: options.agentHome,
179
- projectCwd: options.projectCwd,
189
+ projectCwd: effectiveProjectCwd,
190
+ projectContext: roomProject ? {
191
+ ...roomProject,
192
+ accessible: projectAccessible,
193
+ currentComputerId: options.computerId,
194
+ } : undefined,
180
195
  extraArgs: options.extraArgs,
181
196
  localDaemonUrl: options.localDaemonUrl,
182
197
  localDaemonToken: options.localDaemonToken,
@@ -377,6 +392,7 @@ async function reconcileManagedAgents(input: {
377
392
  computerId: string;
378
393
  serverUrl: string;
379
394
  defaultCwd: string;
395
+ localUrl?: string;
380
396
  }): Promise<void> {
381
397
  const desiredAssignedAgents = new Set(input.assignments.map((assignment) => assignment.agent));
382
398
 
@@ -403,6 +419,7 @@ async function reconcileManagedAgents(input: {
403
419
  await input.client.registerDaemon({
404
420
  id: input.daemonId,
405
421
  name: input.computerId,
422
+ local_url: input.localUrl,
406
423
  server_url: input.serverUrl,
407
424
  agents: daemonAgentPayload(input.managedAgents),
408
425
  });
@@ -473,6 +490,8 @@ async function main(): Promise<void> {
473
490
 
474
491
  const daemonAuth = { computer_id: connected.computer.id, connection_id: connected.connection.id, token: connected.token };
475
492
  const client = new LockClient(serverUrl, daemonAuth);
493
+ const localServer = startLocalApi({ host: localHost, port: localPort, serverUrl, token: localToken, controlToken: connected.local_control_token, daemonAuth });
494
+ const localUrl = `http://${localServer.hostname}:${localServer.port}`;
476
495
  await reconcileManagedAgents({
477
496
  assignments: connected.agents ?? [],
478
497
  explicitAgents,
@@ -483,6 +502,7 @@ async function main(): Promise<void> {
483
502
  computerId: connected.computer.id,
484
503
  serverUrl,
485
504
  defaultCwd: cwd,
505
+ localUrl,
486
506
  });
487
507
  if (managedAgents.size === 0) {
488
508
  console.log(`[daemon] no agents currently assigned to computer ${connected.computer.id}; waiting for assignments`);
@@ -491,8 +511,6 @@ async function main(): Promise<void> {
491
511
  let connectionRevoked = false;
492
512
  const heartbeatMs = numberFlag(args.flags, 'heartbeat-interval', 5000)!;
493
513
 
494
- const localServer = startLocalApi({ host: localHost, port: localPort, serverUrl, token: localToken, daemonAuth });
495
-
496
514
  console.log(`pal daemon computer=${connected.computer.id} connection=${connected.connection.id} agents=${Array.from(managedAgents.values()).map((managed) => `${managed.agent}:${managed.runtimeProvider}`).join(',') || 'none'} server=${serverUrl}`);
497
515
  console.log(`local api=http://${localServer.hostname}:${localServer.port} token=${localToken ? 'set' : 'missing'}`);
498
516
  console.log(`state=${statePath} lastSeenId=${state.lastSeenId}`);
@@ -525,6 +543,7 @@ async function main(): Promise<void> {
525
543
  computerId: connected.computer.id,
526
544
  serverUrl,
527
545
  defaultCwd: cwd,
546
+ localUrl,
528
547
  });
529
548
  writeState(statePath, state);
530
549
  } catch (error) {
package/src/db.ts CHANGED
@@ -1,10 +1,11 @@
1
1
  import { Database, type SQLQueryBindings } from 'bun:sqlite';
2
2
  import { createHash, randomBytes } from 'node:crypto';
3
+ import { isAbsolute } from 'node:path';
3
4
  import { ensureParentDir } from './config.js';
4
5
  import { runMigrations } from './migrations.js';
5
6
  import { artifactExpiry, generateArtifactToken, hashArtifactToken, validateArtifactContent } from './artifacts.js';
6
7
  import { palIdentityHandle } from './provider-identity.js';
7
- import type { AgentDefinition, AgentRun, AgentSession, AgentValidationResult, Artifact, ArtifactMetadata, ChannelAccount, ChannelConversation, ChannelMessageMapping, ChannelOutboxRecord, Chat, ChatKind, Computer, ComputerAgentAssignment, ComputerConnection, DaemonInstance, 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
+ import type { AgentDefinition, AgentRun, AgentSession, AgentValidationResult, Artifact, ArtifactMetadata, ChannelAccount, ChannelConversation, ChannelMessageMapping, ChannelOutboxRecord, Chat, ChatKind, Computer, ComputerAgentAssignment, ComputerConnection, DaemonInstance, LarkAuthorizedUser, LarkGroupRoomMapping, LockProviderEvidence, LockTranscriptMessage, Message, MessageDelivery, MessageType, AgentRoomSubscription, AgentRoomSubscriptionMode, PendingInboundEvent, Project, ProvisionedComputer, ProviderExternalType, ProviderIdentityBinding, PalIdentity, RoomChannel, RoomParticipant, RoomParticipantKind, RoomParticipantSource, RoomProvider, RunAction, RunStatus, TranscriptReadModel, WorkbenchArtifact } from './types.js';
8
9
 
9
10
  export const ALL_AGENTS_MENTION = '__pal_all_agents__';
10
11
 
@@ -85,6 +86,7 @@ export interface ConnectComputerResult {
85
86
  computer: Computer;
86
87
  connection: ComputerConnection;
87
88
  token: string;
89
+ localControlToken: string;
88
90
  daemon: DaemonInstance;
89
91
  agents: ComputerAgentAssignment[];
90
92
  }
@@ -356,6 +358,19 @@ function rowToComputerAgentAssignment(row: Record<string, unknown>): ComputerAge
356
358
  };
357
359
  }
358
360
 
361
+ function rowToProject(row: Record<string, unknown>): Project {
362
+ return {
363
+ id: String(row.id),
364
+ name: String(row.name),
365
+ computer_id: String(row.computer_id),
366
+ computer_name: row.computer_name === null || row.computer_name === undefined ? null : String(row.computer_name),
367
+ root_path: String(row.root_path),
368
+ room_count: Number(row.room_count ?? 0),
369
+ created_at: String(row.created_at),
370
+ updated_at: String(row.updated_at),
371
+ };
372
+ }
373
+
359
374
  function rowToMessage(row: Record<string, unknown>): Message {
360
375
  return {
361
376
  id: Number(row.id),
@@ -812,6 +827,68 @@ export class MessageStore {
812
827
  return this.getChatById(id)!;
813
828
  }
814
829
 
830
+ createProject(input: { name: string; computerId: string; rootPath: string }): Project {
831
+ const name = input.name.trim();
832
+ const computerId = input.computerId.trim();
833
+ const rootPath = input.rootPath.trim();
834
+ if (!name) throw new Error('project name is required');
835
+ if (!computerId) throw new Error('computer_id is required');
836
+ if (!rootPath) throw new Error('root_path is required');
837
+ if (!isAbsolute(rootPath)) throw new Error('root_path must be absolute');
838
+ const computer = this.getComputer(computerId);
839
+ if (!computer) throw new Error(`computer ${computerId} not found`);
840
+
841
+ const id = crypto.randomUUID();
842
+ this.db.query(`
843
+ INSERT INTO projects (id, name, computer_id, root_path, created_at, updated_at)
844
+ VALUES (?, ?, ?, ?, datetime('now'), datetime('now'))
845
+ `).run(id, name, computerId, rootPath);
846
+ return this.getProject(id)!;
847
+ }
848
+
849
+ getProject(id: string): Project | null {
850
+ const row = this.db.query(`
851
+ SELECT p.*, c.name AS computer_name, COUNT(ch.id) AS room_count
852
+ FROM projects p
853
+ LEFT JOIN computers c ON c.id = p.computer_id
854
+ LEFT JOIN chats ch ON ch.project_id = p.id
855
+ WHERE p.id = ?
856
+ GROUP BY p.id
857
+ `).get(id.trim()) as Record<string, unknown> | null;
858
+ return row ? rowToProject(row) : null;
859
+ }
860
+
861
+ listProjects(limit = 50): Project[] {
862
+ const rows = this.db.query(`
863
+ SELECT p.*, c.name AS computer_name, COUNT(ch.id) AS room_count
864
+ FROM projects p
865
+ LEFT JOIN computers c ON c.id = p.computer_id
866
+ LEFT JOIN chats ch ON ch.project_id = p.id
867
+ GROUP BY p.id
868
+ ORDER BY p.updated_at DESC, p.created_at DESC
869
+ LIMIT ?
870
+ `).all(limitValue(limit, 50)) as Record<string, unknown>[];
871
+ return rows.map(rowToProject);
872
+ }
873
+
874
+ createProjectRoom(input: { projectId: string; name: string; kind?: ChatKind }): Chat {
875
+ const project = this.getProject(input.projectId);
876
+ if (!project) throw new Error(`project ${input.projectId} was not found`);
877
+ const kind = input.kind ?? 'group';
878
+ if (kind !== 'group' && kind !== 'dm') throw new Error('kind must be group or dm');
879
+ const chatName = normalizeChatName(input.name);
880
+ if (!chatName) throw new Error('room name is required');
881
+ if (this.getChatByName(chatName)) throw new Error(`room ${chatName} already exists`);
882
+
883
+ const id = crypto.randomUUID();
884
+ this.db.query(`
885
+ INSERT INTO chats (id, name, kind, provider, capabilities_json, project_id)
886
+ VALUES (?, ?, ?, 'web', ?, ?)
887
+ `).run(id, chatName, kind, kind === 'dm' ? '{"topics":"unsupported"}' : '{"topics":"native"}', project.id);
888
+ this.db.query(`UPDATE projects SET updated_at = datetime('now') WHERE id = ?`).run(project.id);
889
+ return this.getChatById(id)!;
890
+ }
891
+
815
892
  updateRoomDisplayName(roomId: string, displayName: string | null): Chat {
816
893
  const room = this.getChatById(roomId);
817
894
  if (!room) throw new Error(`room ${roomId} was not found`);
@@ -2216,6 +2293,7 @@ export class MessageStore {
2216
2293
 
2217
2294
  const connectionId = crypto.randomUUID();
2218
2295
  const token = generateConnectionToken();
2296
+ const localControlToken = generateConnectionToken();
2219
2297
  const tokenHash = hashSecret(token);
2220
2298
  const revokedRows = this.db.query(`
2221
2299
  SELECT id FROM computer_connections
@@ -2270,9 +2348,9 @@ export class MessageStore {
2270
2348
  }
2271
2349
  }
2272
2350
  this.db.query(`
2273
- INSERT INTO computer_connections (id, computer_id, token_hash, epoch, status, connected_at, last_heartbeat_at)
2274
- VALUES (?, ?, ?, ?, 'active', datetime('now'), datetime('now'))
2275
- `).run(connectionId, computerId, tokenHash, epoch);
2351
+ INSERT INTO computer_connections (id, computer_id, token_hash, local_control_token, epoch, status, connected_at, last_heartbeat_at)
2352
+ VALUES (?, ?, ?, ?, ?, 'active', datetime('now'), datetime('now'))
2353
+ `).run(connectionId, computerId, tokenHash, localControlToken, epoch);
2276
2354
  this.db.query(`
2277
2355
  INSERT INTO daemon_instances (id, name, host, local_url, server_url, status, last_seen_at)
2278
2356
  VALUES (?, ?, ?, ?, ?, 'online', datetime('now'))
@@ -2303,11 +2381,30 @@ export class MessageStore {
2303
2381
  computer: this.getComputer(computerId)!,
2304
2382
  connection: this.getComputerConnection(connectionId)!,
2305
2383
  token,
2384
+ localControlToken,
2306
2385
  daemon: this.getDaemon(connectionId)!,
2307
2386
  agents: this.listComputerAgentAssignments(computerId),
2308
2387
  };
2309
2388
  }
2310
2389
 
2390
+ getComputerLocalControl(computerId: string): { computer: Computer; connection: ComputerConnection; daemon: DaemonInstance; token: string } | null {
2391
+ const computer = this.getComputer(computerId);
2392
+ if (!computer || computer.status !== 'online' || !computer.active_connection_id) return null;
2393
+ const connection = this.getComputerConnection(computer.active_connection_id);
2394
+ if (!connection || connection.status !== 'active') return null;
2395
+ const row = this.db.query(`
2396
+ SELECT local_control_token
2397
+ FROM computer_connections
2398
+ WHERE id = ? AND computer_id = ? AND status = 'active'
2399
+ LIMIT 1
2400
+ `).get(connection.id, computer.id) as { local_control_token: string | null } | null;
2401
+ const token = row?.local_control_token?.trim();
2402
+ if (!token) return null;
2403
+ const daemon = this.getDaemon(connection.id);
2404
+ if (!daemon || daemon.status !== 'online' || !daemon.local_url.trim()) return null;
2405
+ return { computer, connection, daemon, token };
2406
+ }
2407
+
2311
2408
  closeStaleComputerConnections(timeoutMs: number, now = new Date()): number {
2312
2409
  if (!Number.isFinite(timeoutMs) || timeoutMs < 0) throw new Error('timeoutMs must be non-negative');
2313
2410
  const cutoff = new Date(now.getTime() - timeoutMs).toISOString();