@controlflow-ai/daemon 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/README.md +360 -0
  2. package/bin/console.js +2 -0
  3. package/bin/daemon.js +2 -0
  4. package/bin/pal.js +2 -0
  5. package/bin/server.js +2 -0
  6. package/package.json +31 -0
  7. package/src/agent-runtime.ts +285 -0
  8. package/src/app.ts +745 -0
  9. package/src/args.ts +54 -0
  10. package/src/artifacts.ts +85 -0
  11. package/src/cli.ts +284 -0
  12. package/src/client.ts +310 -0
  13. package/src/coco.ts +52 -0
  14. package/src/codex.ts +41 -0
  15. package/src/coding-agent-runtime.ts +20 -0
  16. package/src/config.ts +106 -0
  17. package/src/console.ts +349 -0
  18. package/src/daemon-client.ts +91 -0
  19. package/src/daemon.ts +580 -0
  20. package/src/db.ts +2830 -0
  21. package/src/failure-message.ts +17 -0
  22. package/src/format.ts +13 -0
  23. package/src/http.ts +55 -0
  24. package/src/lark/agent-runtime.ts +142 -0
  25. package/src/lark/cli.ts +549 -0
  26. package/src/lark/credentials.ts +105 -0
  27. package/src/lark/daemon-integration.ts +108 -0
  28. package/src/lark/dispatcher.ts +374 -0
  29. package/src/lark/event-router.ts +329 -0
  30. package/src/lark/inbound-events.ts +131 -0
  31. package/src/lark/server-integration.ts +445 -0
  32. package/src/lark/setup.ts +326 -0
  33. package/src/lark/ws-daemon.ts +224 -0
  34. package/src/lark-fixture-diagnostics.ts +56 -0
  35. package/src/lark-fixture.ts +277 -0
  36. package/src/local-api.ts +155 -0
  37. package/src/local-auth.ts +45 -0
  38. package/src/migrations/001_initial.ts +61 -0
  39. package/src/migrations/002_daemon_deliveries.ts +52 -0
  40. package/src/migrations/003_sessions_runs.ts +49 -0
  41. package/src/migrations/004_message_idempotency.ts +21 -0
  42. package/src/migrations/005_artifacts.ts +24 -0
  43. package/src/migrations/006_lark_channel_foundation.ts +119 -0
  44. package/src/migrations/007_agents_a0.ts +17 -0
  45. package/src/migrations/008_b0_chat_history.ts +31 -0
  46. package/src/migrations/009_b0_transcript_ingest_seq.ts +35 -0
  47. package/src/migrations/010_b0_transcript_shadow_external_ids.ts +32 -0
  48. package/src/migrations/011_b0_channel_conversation_audit_only.ts +27 -0
  49. package/src/migrations/012_b0_cross_conversation_invariant.ts +45 -0
  50. package/src/migrations/013_b1_0_eng_inbound_raw_events.ts +56 -0
  51. package/src/migrations/014_agents_runtime.ts +10 -0
  52. package/src/migrations/015_agent_runtime_sessions.ts +15 -0
  53. package/src/migrations/016_room_participants.ts +27 -0
  54. package/src/migrations/017_unified_room_delivery.ts +203 -0
  55. package/src/migrations/018_room_display_names.ts +36 -0
  56. package/src/migrations/019_computer_connections.ts +63 -0
  57. package/src/migrations/020_computer_agent_assignments.ts +20 -0
  58. package/src/migrations/021_provider_identity_bindings.ts +32 -0
  59. package/src/migrations.ts +85 -0
  60. package/src/neeko.ts +23 -0
  61. package/src/provider-identity.ts +40 -0
  62. package/src/runtime-registry.ts +41 -0
  63. package/src/server-auth.ts +13 -0
  64. package/src/server.ts +63 -0
  65. package/src/token-file.ts +57 -0
  66. package/src/types.ts +408 -0
  67. package/src/web.ts +565 -0
package/src/app.ts ADDED
@@ -0,0 +1,745 @@
1
+ import { artifactHeaders, artifactViewerHtml } from './artifacts.js';
2
+ import { MessageStore } from './db.js';
3
+ import { failure, HttpError, json, numberParam, readJson, stringParam } from './http.js';
4
+ import { assertServerAuth } from './server-auth.js';
5
+ import { dashboardHtml } from './web.js';
6
+ import type { RunAction, RunStatus } from './types.js';
7
+ import { createLarkApiClient, sendTextMessage } from './lark/ws-daemon.js';
8
+ import { boundAgents, loadLarkCredentials, type LarkCredentialStore } from './lark/credentials.js';
9
+
10
+ interface SendBody {
11
+ chat?: string;
12
+ room?: string;
13
+ chat_id?: string;
14
+ room_id?: string;
15
+ parent_id?: number;
16
+ channel_id?: string | null;
17
+ sender?: string;
18
+ recipient?: string | null;
19
+ content?: string;
20
+ type?: 'message' | 'system';
21
+ idempotency_key?: string | null;
22
+ mentions?: string[];
23
+ }
24
+
25
+ interface CreateRoomBody {
26
+ name?: string;
27
+ kind?: 'group' | 'dm';
28
+ }
29
+
30
+ interface StartRunBody {
31
+ message_id?: number;
32
+ agent?: string;
33
+ cwd?: string;
34
+ attempt?: number;
35
+ pid?: number | null;
36
+ session_id?: string | null;
37
+ trigger_message_id?: number | null;
38
+ daemon_id?: string | null;
39
+ connection_id?: string | null;
40
+ computer_id?: string | null;
41
+ runtime_provider?: string | null;
42
+ runtime_invocation_id?: string | null;
43
+ delivery_id?: string | null;
44
+ }
45
+
46
+ interface RegisterDaemonBody {
47
+ id?: string;
48
+ name?: string;
49
+ host?: string;
50
+ local_url?: string;
51
+ server_url?: string;
52
+ agents?: Array<{ agent?: string; cwd?: string; capabilities?: Record<string, unknown> }>;
53
+ }
54
+
55
+ interface ProvisionComputerBody {
56
+ name?: string;
57
+ server_url?: string;
58
+ package_name?: string;
59
+ }
60
+
61
+ interface ConnectComputerBody {
62
+ computer_id?: string;
63
+ secret?: string;
64
+ api_key?: string;
65
+ name?: string;
66
+ host?: string;
67
+ local_url?: string;
68
+ server_url?: string;
69
+ agents?: Array<{ agent?: string; cwd?: string; capabilities?: Record<string, unknown> }>;
70
+ }
71
+
72
+ interface AssignAgentBody {
73
+ computer_id?: string;
74
+ }
75
+
76
+ interface OnboardAgentBody {
77
+ agent_key?: string;
78
+ display_name?: string;
79
+ runtime?: string | null;
80
+ description?: string | null;
81
+ computer_id?: string;
82
+ }
83
+
84
+ interface CreateDeliveryBody {
85
+ message_id?: number;
86
+ agent?: string;
87
+ }
88
+
89
+ interface ClaimDeliveryBody {
90
+ daemon_id?: string;
91
+ connection_id?: string | null;
92
+ computer_id?: string | null;
93
+ lease_ms?: number;
94
+ }
95
+
96
+ interface SessionBody {
97
+ chat_id?: string;
98
+ agent?: string;
99
+ daemon_id?: string;
100
+ connection_id?: string | null;
101
+ computer_id?: string | null;
102
+ runtime_provider?: string | null;
103
+ cwd?: string;
104
+ last_message_id?: number | null;
105
+ }
106
+
107
+ interface RuntimeSessionBody {
108
+ runtime_session_id?: string;
109
+ }
110
+
111
+ interface FinishDeliveryBody {
112
+ daemon_id?: string;
113
+ connection_id?: string | null;
114
+ claim_token?: string;
115
+ run_id?: string;
116
+ error?: string;
117
+ }
118
+
119
+ interface ArtifactBody {
120
+ content_base64?: string;
121
+ mime_type?: string;
122
+ title?: string;
123
+ filename?: string;
124
+ ttl_seconds?: number;
125
+ source_path?: string;
126
+ path?: string;
127
+ }
128
+
129
+ interface RunPidBody {
130
+ pid?: number | null;
131
+ }
132
+
133
+ interface FinishRunBody {
134
+ status?: RunStatus;
135
+ exit_code?: number | null;
136
+ output?: string;
137
+ }
138
+
139
+ export function resolveLarkOutboundRoute(store: MessageStore, credentials: LarkCredentialStore, sender: string, chatId: string) {
140
+ for (const bot of credentials.bots) {
141
+ if (!boundAgents(bot).includes(sender)) continue;
142
+ const larkChannel = store.findLarkChannelForChat(chatId, bot.appId);
143
+ if (!larkChannel) continue;
144
+ return { ...larkChannel, bot };
145
+ }
146
+ return null;
147
+ }
148
+
149
+ function routeNotFound(): Response {
150
+ return Response.json({ ok: false, code: 'NOT_FOUND', message: 'not found' }, { status: 404 });
151
+ }
152
+
153
+ function html(body: string): Response {
154
+ return new Response(body, { headers: { 'content-type': 'text/html; charset=utf-8' } });
155
+ }
156
+
157
+ function daemonAuthFromRequest(request: Request): { computerId: string; connectionId: string; token: string } | null {
158
+ const computerId = request.headers.get('x-pal-computer-id')?.trim();
159
+ const connectionId = request.headers.get('x-pal-connection-id')?.trim();
160
+ const token = request.headers.get('x-pal-connection-token')?.trim();
161
+ if (!computerId && !connectionId && !token) return null;
162
+ if (!computerId || !connectionId || !token) throw new HttpError(401, 'MISSING_CONNECTION_AUTH', 'computer connection auth is incomplete');
163
+ return { computerId, connectionId, token };
164
+ }
165
+
166
+ function assertDaemonConnection(store: MessageStore, request: Request): { computerId: string; connectionId: string } | null {
167
+ const auth = daemonAuthFromRequest(request);
168
+ if (!auth) return null;
169
+ try {
170
+ const connection = store.assertActiveComputerConnection(auth);
171
+ return { computerId: connection.computer_id, connectionId: connection.id };
172
+ } catch {
173
+ throw new HttpError(401, 'CONNECTION_REVOKED', 'computer connection is not active');
174
+ }
175
+ }
176
+
177
+ function requireDaemonConnection(store: MessageStore, request: Request): { computerId: string; connectionId: string } {
178
+ const connection = assertDaemonConnection(store, request);
179
+ if (!connection) throw new HttpError(401, 'CONNECTION_REVOKED', 'computer connection auth is required');
180
+ return connection;
181
+ }
182
+
183
+ export function route(store: MessageStore, request: Request): Promise<Response> | Response {
184
+ const url = new URL(request.url);
185
+ const { pathname } = url;
186
+
187
+ if (request.method === 'GET' && pathname === '/') {
188
+ return html(dashboardHtml());
189
+ }
190
+
191
+ if (request.method === 'GET' && pathname === '/health') {
192
+ return json({ status: 'ok' });
193
+ }
194
+
195
+ if (request.method === 'GET' && pathname === '/api/computers') {
196
+ return json({ computers: store.listComputers(numberParam(url, 'limit', 50)) });
197
+ }
198
+
199
+ if (request.method === 'POST' && pathname === '/api/computers/provision') {
200
+ return readJson<ProvisionComputerBody>(request).then((body) => {
201
+ const result = store.provisionComputer({
202
+ name: body.name,
203
+ serverUrl: body.server_url,
204
+ packageName: body.package_name,
205
+ });
206
+ return json(result, 201);
207
+ });
208
+ }
209
+
210
+ if (request.method === 'POST' && pathname === '/api/computers/connect') {
211
+ return readJson<ConnectComputerBody>(request).then((body) => {
212
+ if (!body.api_key?.trim() && !body.computer_id?.trim()) throw new HttpError(400, 'MISSING_COMPUTER_ID', 'computer_id or api_key is required');
213
+ if (!body.api_key?.trim() && !body.secret?.trim()) throw new HttpError(400, 'MISSING_COMPUTER_SECRET', 'secret or api_key is required');
214
+ const result = store.connectComputer({
215
+ computerId: body.computer_id,
216
+ secret: body.secret,
217
+ apiKey: body.api_key,
218
+ name: body.name,
219
+ host: body.host,
220
+ localUrl: body.local_url,
221
+ serverUrl: body.server_url,
222
+ agents: body.agents?.map((agent) => ({
223
+ agent: agent.agent ?? '',
224
+ cwd: agent.cwd,
225
+ capabilities: agent.capabilities,
226
+ })),
227
+ });
228
+ return json(result, 201);
229
+ });
230
+ }
231
+
232
+ const computerHeartbeatMatch = pathname.match(/^\/api\/computers\/([^/]+)\/heartbeat$/);
233
+ if (request.method === 'POST' && computerHeartbeatMatch) {
234
+ const auth = daemonAuthFromRequest(request);
235
+ if (!auth || auth.computerId !== computerHeartbeatMatch[1]) {
236
+ throw new HttpError(401, 'CONNECTION_REVOKED', 'computer connection is not active');
237
+ }
238
+ try {
239
+ return json(store.heartbeatComputer(auth));
240
+ } catch {
241
+ throw new HttpError(401, 'CONNECTION_REVOKED', 'computer connection is not active');
242
+ }
243
+ }
244
+
245
+ const artifactViewMatch = pathname.match(/^\/artifacts\/([a-f0-9]{64})$/);
246
+ if (request.method === 'GET' && artifactViewMatch) {
247
+ const artifact = store.getArtifactByToken(artifactViewMatch[1]!);
248
+ if (!artifact) return routeNotFound();
249
+ return new Response(artifactViewerHtml(artifact.content, artifact.mime_type), { headers: artifactHeaders() });
250
+ }
251
+
252
+ if (request.method === 'GET' && pathname === '/api/workbench/artifacts') {
253
+ return json({ artifacts: store.listWorkbenchArtifacts(numberParam(url, 'limit', 50)) });
254
+ }
255
+
256
+ if (request.method === 'GET' && pathname === '/api/artifacts') {
257
+ assertServerAuth(request);
258
+ return json({ artifacts: store.listArtifacts(numberParam(url, 'limit', 50)) });
259
+ }
260
+
261
+ if (request.method === 'POST' && pathname === '/api/artifacts') {
262
+ try {
263
+ assertServerAuth(request);
264
+ } catch (error) {
265
+ if (error instanceof HttpError) {
266
+ if (!assertDaemonConnection(store, request)) throw error;
267
+ } else {
268
+ throw error;
269
+ }
270
+ }
271
+ return readJson<ArtifactBody>(request).then((body) => {
272
+ if (body.source_path || body.path) throw new HttpError(400, 'PATH_NOT_ALLOWED', 'artifact upload must include content, not a path');
273
+ if (!body.content_base64) throw new HttpError(400, 'MISSING_CONTENT', 'content_base64 is required');
274
+ if (!body.mime_type?.trim()) throw new HttpError(400, 'MISSING_MIME', 'mime_type is required');
275
+ const content = Uint8Array.from(Buffer.from(body.content_base64, 'base64'));
276
+ const result = store.createArtifact({
277
+ title: body.title,
278
+ filename: body.filename,
279
+ mimeType: body.mime_type,
280
+ content,
281
+ ttlSeconds: body.ttl_seconds,
282
+ });
283
+ return json({ artifact: result.artifact, token: result.token, url: `/artifacts/${result.token}` }, 201);
284
+ });
285
+ }
286
+
287
+ if (request.method === 'POST' && pathname === '/api/artifacts/cleanup') {
288
+ assertServerAuth(request);
289
+ return json({ deleted: store.cleanupArtifacts() });
290
+ }
291
+
292
+ const artifactRevokeMatch = pathname.match(/^\/api\/artifacts\/([^/]+)\/revoke$/);
293
+ if (request.method === 'POST' && artifactRevokeMatch) {
294
+ assertServerAuth(request);
295
+ return json({ artifact: store.revokeArtifact(artifactRevokeMatch[1]!) });
296
+ }
297
+
298
+ if (request.method === 'GET' && pathname === '/api/chats') {
299
+ return json({ chats: store.listChats() });
300
+ }
301
+
302
+ if (request.method === 'GET' && pathname === '/api/rooms') {
303
+ return json({ rooms: store.listChats() });
304
+ }
305
+
306
+ if (request.method === 'POST' && pathname === '/api/rooms') {
307
+ return readJson<CreateRoomBody>(request).then((body) => {
308
+ if (!body.name?.trim()) throw new HttpError(400, 'MISSING_ROOM_NAME', 'name is required');
309
+ if (body.kind && body.kind !== 'group' && body.kind !== 'dm') throw new HttpError(400, 'BAD_ROOM_KIND', 'kind must be group or dm');
310
+ const room = store.getOrCreateChat(body.name, body.kind ?? 'group');
311
+ return json({ room }, 201);
312
+ });
313
+ }
314
+
315
+ const roomMembersMatch = pathname.match(/^\/api\/rooms\/([^/]+)\/members$/);
316
+ if (request.method === 'GET' && roomMembersMatch) {
317
+ const room = store.resolveRoom(decodeURIComponent(roomMembersMatch[1]!));
318
+ if (!room) throw new HttpError(404, 'ROOM_NOT_FOUND', 'room not found');
319
+ return json({
320
+ room,
321
+ participants: store.listRoomParticipants(room.id),
322
+ completeness: 'Human members come from lark_member_api snapshots. Bot members are known/observed only; Feishu member-list API filters bots.',
323
+ });
324
+ }
325
+
326
+ const roomMentionablesMatch = pathname.match(/^\/api\/rooms\/([^/]+)\/mentionables$/);
327
+ if (request.method === 'GET' && roomMentionablesMatch) {
328
+ const room = store.resolveRoom(decodeURIComponent(roomMentionablesMatch[1]!));
329
+ if (!room) throw new HttpError(404, 'ROOM_NOT_FOUND', 'room not found');
330
+ return json({
331
+ room,
332
+ participants: store.listMentionableRoomParticipants(room.id),
333
+ completeness: room.provider === 'lark'
334
+ ? 'Feishu completion currently includes observed members and known bots only. Full member completion requires im:chat.members:read.'
335
+ : 'Web rooms include active room participants and local agents.',
336
+ });
337
+ }
338
+
339
+ const transcriptReadMatch = pathname.match(/^\/api\/transcripts\/([^/]+)\/messages$/);
340
+ if (request.method === 'GET' && transcriptReadMatch) {
341
+ return json({ messages: store.listTranscriptMessagesReadOnly(transcriptReadMatch[1]!, numberParam(url, 'limit', 50)) });
342
+ }
343
+
344
+ if (request.method === 'GET' && pathname === '/api/agents') {
345
+ return json({ agents: store.listAgents(numberParam(url, 'limit', 50)) });
346
+ }
347
+
348
+ if (request.method === 'POST' && pathname === '/api/agents/onboard') {
349
+ return readJson<OnboardAgentBody>(request).then((body) => {
350
+ const agentKey = body.agent_key?.trim();
351
+ const displayName = body.display_name?.trim();
352
+ if (!agentKey) throw new HttpError(400, 'MISSING_AGENT_KEY', 'agent_key is required');
353
+ if (!displayName) throw new HttpError(400, 'MISSING_DISPLAY_NAME', 'display_name is required');
354
+ const agent = store.upsertAgent({
355
+ agent_key: agentKey,
356
+ display_name: displayName,
357
+ description: body.description ?? null,
358
+ runtime: body.runtime ?? 'codex',
359
+ });
360
+ const assignment = body.computer_id?.trim()
361
+ ? store.assignAgentToComputer({ agent: agentKey, computerId: body.computer_id })
362
+ : null;
363
+ return json({ agent, assignment }, 201);
364
+ });
365
+ }
366
+
367
+ if (request.method === 'POST' && pathname === '/api/agents') {
368
+ return readJson<Record<string, unknown>>(request).then((body) => {
369
+ const validation = store.validateAgentSchema(body);
370
+ if (!validation.ok) {
371
+ throw new HttpError(400, 'VALIDATION_ERROR', validation.diagnostics.map((d) => `${d.code}: ${d.message}`).join('; '));
372
+ }
373
+ const agent = store.upsertAgent({
374
+ id: typeof body.id === 'string' ? body.id : undefined,
375
+ agent_key: String(body.agent_key),
376
+ display_name: String(body.display_name),
377
+ description: body.description === null ? null : typeof body.description === 'string' ? body.description : null,
378
+ runtime: body.runtime === null ? null : typeof body.runtime === 'string' ? body.runtime : null,
379
+ });
380
+ return json({ agent }, 201);
381
+ });
382
+ }
383
+
384
+ const agentPatchMatch = pathname.match(/^\/api\/agents\/([^/]+)$/);
385
+ if (request.method === 'PATCH' && agentPatchMatch) {
386
+ return readJson<Record<string, unknown>>(request).then((body) => {
387
+ const agentKey = agentPatchMatch[1]!;
388
+ const existing = store.getAgent(agentKey);
389
+ if (!existing) throw new HttpError(404, 'AGENT_NOT_FOUND', `agent ${agentKey} not found`);
390
+
391
+ if ('runtime' in body) {
392
+ const runtime = body.runtime === null ? null : typeof body.runtime === 'string' ? body.runtime : undefined;
393
+ if (runtime !== undefined) {
394
+ const updated = store.updateAgentRuntime(agentKey, runtime);
395
+ return json({ agent: updated });
396
+ }
397
+ }
398
+
399
+ return json({ agent: existing });
400
+ });
401
+ }
402
+
403
+ const agentAssignmentMatch = pathname.match(/^\/api\/agents\/([^/]+)\/assignment$/);
404
+ if (request.method === 'POST' && agentAssignmentMatch) {
405
+ return readJson<AssignAgentBody>(request).then((body) => {
406
+ const agentKey = agentAssignmentMatch[1]!;
407
+ if (!body.computer_id?.trim()) throw new HttpError(400, 'MISSING_COMPUTER_ID', 'computer_id is required');
408
+ const assignment = store.assignAgentToComputer({
409
+ agent: agentKey,
410
+ computerId: body.computer_id,
411
+ });
412
+ return json({ assignment }, 201);
413
+ });
414
+ }
415
+
416
+ if (request.method === 'GET' && pathname === '/api/sessions') {
417
+ return json({ sessions: store.listSessions(numberParam(url, 'limit', 50)) });
418
+ }
419
+
420
+ const sessionRunsMatch = pathname.match(/^\/api\/sessions\/([^/]+)\/runs$/);
421
+ if (request.method === 'GET' && sessionRunsMatch) {
422
+ return json({ runs: store.listRunsForSession(sessionRunsMatch[1]!, numberParam(url, 'limit', 50)) });
423
+ }
424
+
425
+ const roomInviteMatch = pathname.match(/^\/api\/rooms\/([^/]+)\/agents$/);
426
+ if (request.method === 'POST' && roomInviteMatch) {
427
+ return readJson<{ agent?: string; mode?: string }>(request).then((body) => {
428
+ const room = store.resolveRoom(decodeURIComponent(roomInviteMatch[1]!));
429
+ if (!room) throw new HttpError(404, 'ROOM_NOT_FOUND', 'room not found');
430
+ if (!body.agent?.trim()) throw new HttpError(400, 'MISSING_AGENT', 'agent is required');
431
+ const mode = body.mode as import('./types.js').AgentRoomSubscriptionMode | undefined;
432
+ const result = store.inviteAgentToRoom({ roomId: room.id, agent: body.agent, mode });
433
+ return json(result, 201);
434
+ });
435
+ }
436
+
437
+ const roomTopicMatch = pathname.match(/^\/api\/rooms\/([^/]+)\/topics$/);
438
+ if (request.method === 'POST' && roomTopicMatch) {
439
+ return readJson<{ name?: string; created_by?: string | null }>(request).then((body) => {
440
+ const room = store.resolveRoom(decodeURIComponent(roomTopicMatch[1]!));
441
+ if (!room) throw new HttpError(404, 'ROOM_NOT_FOUND', 'room not found');
442
+ if (!body.name?.trim()) throw new HttpError(400, 'MISSING_TOPIC_NAME', 'name is required');
443
+ const channel = store.createRoomChannel({ roomId: room.id, name: body.name, createdBy: body.created_by });
444
+ return json({ channel }, 201);
445
+ });
446
+ }
447
+
448
+ if (request.method === 'GET' && pathname === '/api/messages') {
449
+ const messages = store.listMessages({
450
+ chatName: stringParam(url, 'chat'),
451
+ chatId: stringParam(url, 'chat_id'),
452
+ parentId: numberParam(url, 'parent_id'),
453
+ after: numberParam(url, 'after'),
454
+ limit: numberParam(url, 'limit', 50),
455
+ q: stringParam(url, 'q'),
456
+ });
457
+ return json({ messages });
458
+ }
459
+
460
+ if (request.method === 'GET' && pathname === '/api/inbox') {
461
+ const agent = stringParam(url, 'agent');
462
+ if (!agent) throw new HttpError(400, 'MISSING_AGENT', 'agent is required');
463
+ const messages = store.listInbox(agent, numberParam(url, 'after', 0), numberParam(url, 'limit', 50));
464
+ return json({ messages });
465
+ }
466
+
467
+ if (request.method === 'POST' && pathname === '/api/daemons') {
468
+ return readJson<RegisterDaemonBody>(request).then((body) => {
469
+ if (!body.name?.trim()) throw new HttpError(400, 'MISSING_NAME', 'name is required');
470
+ const daemon = store.registerDaemon({
471
+ id: body.id,
472
+ name: body.name,
473
+ host: body.host,
474
+ localUrl: body.local_url,
475
+ serverUrl: body.server_url,
476
+ agents: body.agents?.map((agent) => ({
477
+ agent: agent.agent ?? '',
478
+ cwd: agent.cwd,
479
+ capabilities: agent.capabilities,
480
+ })),
481
+ });
482
+ return json({ daemon }, 201);
483
+ });
484
+ }
485
+
486
+ if (request.method === 'GET' && pathname === '/api/deliveries') {
487
+ const agent = stringParam(url, 'agent');
488
+ if (agent) {
489
+ return json({ deliveries: store.listDeliveries(agent, stringParam(url, 'status') ?? 'pending', numberParam(url, 'limit', 50)) });
490
+ }
491
+ return json({ deliveries: store.listAllDeliveries(numberParam(url, 'limit', 50)) });
492
+ }
493
+
494
+ if (request.method === 'POST' && pathname === '/api/deliveries') {
495
+ return readJson<CreateDeliveryBody>(request).then((body) => {
496
+ if (!body.message_id) throw new HttpError(400, 'MISSING_MESSAGE_ID', 'message_id is required');
497
+ if (!body.agent?.trim()) throw new HttpError(400, 'MISSING_AGENT', 'agent is required');
498
+ const delivery = store.createDelivery({ messageId: body.message_id, agent: body.agent });
499
+ return json({ delivery }, 201);
500
+ });
501
+ }
502
+
503
+ const deliveryClaimMatch = pathname.match(/^\/api\/deliveries\/([^/]+)\/claim$/);
504
+ if (request.method === 'POST' && deliveryClaimMatch) {
505
+ return readJson<ClaimDeliveryBody>(request).then((body) => {
506
+ const connection = requireDaemonConnection(store, request);
507
+ const ownerId = connection?.connectionId ?? body.connection_id ?? body.daemon_id;
508
+ if (!ownerId?.trim()) throw new HttpError(400, 'MISSING_DAEMON_ID', 'daemon_id or connection auth is required');
509
+ const delivery = store.claimDelivery(deliveryClaimMatch[1]!, {
510
+ daemonId: ownerId,
511
+ connectionId: connection?.connectionId ?? body.connection_id,
512
+ computerId: connection?.computerId ?? body.computer_id,
513
+ leaseMs: body.lease_ms,
514
+ });
515
+ return json({ delivery });
516
+ });
517
+ }
518
+
519
+ if (request.method === 'POST' && pathname === '/api/sessions') {
520
+ return readJson<SessionBody>(request).then((body) => {
521
+ if (!body.chat_id?.trim()) throw new HttpError(400, 'MISSING_CHAT_ID', 'chat_id is required');
522
+ if (!body.agent?.trim()) throw new HttpError(400, 'MISSING_AGENT', 'agent is required');
523
+ const connection = requireDaemonConnection(store, request);
524
+ const ownerId = connection?.connectionId ?? body.connection_id ?? body.daemon_id;
525
+ if (!ownerId?.trim()) throw new HttpError(400, 'MISSING_DAEMON_ID', 'daemon_id or connection auth is required');
526
+ const daemon = store.getDaemon(ownerId);
527
+ if (!daemon) throw new HttpError(404, 'DAEMON_NOT_FOUND', 'daemon was not registered');
528
+ if (!store.daemonHasAgent(ownerId, body.agent)) {
529
+ throw new HttpError(400, 'DAEMON_AGENT_NOT_REGISTERED', 'daemon has not registered this agent');
530
+ }
531
+ const session = store.getOrCreateSession({
532
+ chatId: body.chat_id,
533
+ agent: body.agent,
534
+ daemonId: ownerId,
535
+ cwd: body.cwd ?? '',
536
+ lastMessageId: body.last_message_id,
537
+ computerId: connection?.computerId ?? body.computer_id,
538
+ runtimeProvider: body.runtime_provider,
539
+ });
540
+ return json({ session }, 201);
541
+ });
542
+ }
543
+
544
+ const runtimeSessionMatch = pathname.match(/^\/api\/sessions\/([^/]+)\/runtime-session$/);
545
+ if (request.method === 'POST' && runtimeSessionMatch) {
546
+ return readJson<RuntimeSessionBody>(request).then((body) => {
547
+ const connection = requireDaemonConnection(store, request);
548
+ const existing = store.getSession(runtimeSessionMatch[1]!);
549
+ if (!existing) throw new HttpError(404, 'SESSION_NOT_FOUND', 'session was not found');
550
+ if (existing.daemon_id !== connection.connectionId) throw new HttpError(403, 'SESSION_CONNECTION_MISMATCH', 'session belongs to another connection');
551
+ if (!body.runtime_session_id?.trim()) throw new HttpError(400, 'MISSING_RUNTIME_SESSION_ID', 'runtime_session_id is required');
552
+ const session = store.updateSessionRuntimeSessionId(runtimeSessionMatch[1]!, body.runtime_session_id);
553
+ return json({ session });
554
+ });
555
+ }
556
+
557
+ const deliveryAckMatch = pathname.match(/^\/api\/deliveries\/([^/]+)\/ack$/);
558
+ if (request.method === 'POST' && deliveryAckMatch) {
559
+ return readJson<FinishDeliveryBody>(request).then((body) => {
560
+ const connection = requireDaemonConnection(store, request);
561
+ const ownerId = connection?.connectionId ?? body.connection_id ?? body.daemon_id;
562
+ if (!ownerId?.trim()) throw new HttpError(400, 'MISSING_DAEMON_ID', 'daemon_id or connection auth is required');
563
+ if (!body.claim_token?.trim()) throw new HttpError(400, 'MISSING_CLAIM_TOKEN', 'claim_token is required');
564
+ const delivery = store.ackDelivery(deliveryAckMatch[1]!, {
565
+ daemonId: ownerId,
566
+ connectionId: connection?.connectionId ?? body.connection_id,
567
+ claimToken: body.claim_token,
568
+ runId: body.run_id,
569
+ });
570
+ return json({ delivery });
571
+ });
572
+ }
573
+
574
+ const deliveryFailMatch = pathname.match(/^\/api\/deliveries\/([^/]+)\/fail$/);
575
+ if (request.method === 'POST' && deliveryFailMatch) {
576
+ return readJson<FinishDeliveryBody>(request).then((body) => {
577
+ const connection = requireDaemonConnection(store, request);
578
+ const ownerId = connection?.connectionId ?? body.connection_id ?? body.daemon_id;
579
+ if (!ownerId?.trim()) throw new HttpError(400, 'MISSING_DAEMON_ID', 'daemon_id or connection auth is required');
580
+ if (!body.claim_token?.trim()) throw new HttpError(400, 'MISSING_CLAIM_TOKEN', 'claim_token is required');
581
+ const delivery = store.failDelivery(deliveryFailMatch[1]!, {
582
+ daemonId: ownerId,
583
+ connectionId: connection?.connectionId ?? body.connection_id,
584
+ claimToken: body.claim_token,
585
+ runId: body.run_id,
586
+ error: body.error,
587
+ });
588
+ return json({ delivery });
589
+ });
590
+ }
591
+
592
+ if (request.method === 'POST' && pathname === '/api/messages') {
593
+ return readJson<SendBody>(request).then(async (body) => {
594
+ const connection = assertDaemonConnection(store, request);
595
+ const sender = body.sender?.trim();
596
+ const content = body.content?.trim();
597
+ if (!sender) throw new HttpError(400, 'MISSING_SENDER', 'sender is required');
598
+ if (!content) throw new HttpError(400, 'MISSING_CONTENT', 'content is required');
599
+
600
+ const targetRoom = body.parent_id !== undefined
601
+ ? store.getMessage(body.parent_id)?.chat_id
602
+ : body.room_id ?? body.chat_id;
603
+ const target = targetRoom
604
+ ? store.getChatById(targetRoom)
605
+ : (body.room || body.chat) ? store.resolveRoom(body.room ?? body.chat ?? '') : null;
606
+ if (target && !store.canSendWebMessageToRoom({ room: target, sender })) {
607
+ throw new HttpError(403, 'EXTERNAL_ROOM_READ_ONLY', 'external provider rooms are read-only for web human senders');
608
+ }
609
+ if (store.getAgent(sender) || store.hasDaemonAgent(sender)) {
610
+ if (!connection) throw new HttpError(401, 'CONNECTION_REVOKED', 'computer connection auth is required for agent-authored messages');
611
+ if (!store.daemonHasAgent(connection.connectionId, sender)) {
612
+ throw new HttpError(403, 'CONNECTION_AGENT_NOT_REGISTERED', 'connection has not registered this agent');
613
+ }
614
+ }
615
+
616
+ const message = store.createMessage({
617
+ chatId: body.room_id ?? body.chat_id,
618
+ chatName: body.room ?? body.chat,
619
+ parentId: body.parent_id,
620
+ channelId: body.channel_id,
621
+ sender,
622
+ recipient: body.recipient,
623
+ content,
624
+ type: body.type,
625
+ idempotencyKey: body.idempotency_key,
626
+ mentions: body.mentions,
627
+ });
628
+ const deliveries = store.resolveDeliveriesForMessage(message.id);
629
+
630
+ // If this chat has a bound Lark channel, forward bound agent replies to Lark.
631
+ const credentials = loadLarkCredentials();
632
+ const outboundRoute = resolveLarkOutboundRoute(store, credentials, sender, message.chat_id);
633
+ if (outboundRoute) {
634
+ const { conversation, bot } = outboundRoute;
635
+ try {
636
+ const client = createLarkApiClient(bot.appId, bot.appSecret);
637
+ const result = await sendTextMessage({
638
+ client,
639
+ receiveIdType: 'chat_id',
640
+ receiveId: conversation.external_chat_id,
641
+ text: content,
642
+ });
643
+ console.log(`[lark] forwarded message to chat=${conversation.external_chat_id} messageId=${result.messageId ?? '-'}`);
644
+ } catch (err) {
645
+ console.warn(`[lark] failed to forward message to chat=${conversation.external_chat_id}: ${err instanceof Error ? err.message : String(err)}`);
646
+ }
647
+ }
648
+
649
+ return json({ message, deliveries }, 201);
650
+ });
651
+ }
652
+
653
+ if (request.method === 'GET' && pathname === '/api/runs') {
654
+ return json({ runs: store.listRuns(numberParam(url, 'limit', 50)) });
655
+ }
656
+
657
+ if (request.method === 'POST' && pathname === '/api/runs') {
658
+ return readJson<StartRunBody>(request).then((body) => {
659
+ const connection = requireDaemonConnection(store, request);
660
+ if (!body.message_id) throw new HttpError(400, 'MISSING_MESSAGE_ID', 'message_id is required');
661
+ if (!body.agent?.trim()) throw new HttpError(400, 'MISSING_AGENT', 'agent is required');
662
+ if (!body.cwd?.trim()) throw new HttpError(400, 'MISSING_CWD', 'cwd is required');
663
+ const ownerId = connection?.connectionId ?? body.connection_id ?? body.daemon_id;
664
+ if (!ownerId?.trim()) throw new HttpError(400, 'MISSING_DAEMON_ID', 'daemon_id or connection auth is required');
665
+ if (!store.daemonHasAgent(ownerId, body.agent)) {
666
+ throw new HttpError(400, 'DAEMON_AGENT_NOT_REGISTERED', 'daemon has not registered this agent');
667
+ }
668
+ const run = store.startRun({
669
+ messageId: body.message_id,
670
+ agent: body.agent,
671
+ cwd: body.cwd,
672
+ attempt: body.attempt ?? 1,
673
+ pid: body.pid ?? null,
674
+ sessionId: body.session_id,
675
+ triggerMessageId: body.trigger_message_id,
676
+ daemonId: ownerId,
677
+ connectionId: connection?.connectionId ?? body.connection_id,
678
+ computerId: connection?.computerId ?? body.computer_id,
679
+ runtimeProvider: body.runtime_provider,
680
+ runtimeInvocationId: body.runtime_invocation_id,
681
+ deliveryId: body.delivery_id,
682
+ });
683
+ return json({ run }, 201);
684
+ });
685
+ }
686
+
687
+ const messageMatch = pathname.match(/^\/api\/messages\/(\d+)$/);
688
+ if (request.method === 'GET' && messageMatch) {
689
+ const message = store.getMessage(Number(messageMatch[1]));
690
+ if (!message) throw new HttpError(404, 'MESSAGE_NOT_FOUND', 'message not found');
691
+ return json({ message });
692
+ }
693
+
694
+ const runMatch = pathname.match(/^\/api\/runs\/([^/]+)$/);
695
+ if (request.method === 'GET' && runMatch) {
696
+ const run = store.getRun(runMatch[1]!);
697
+ if (!run) throw new HttpError(404, 'RUN_NOT_FOUND', 'run not found');
698
+ return json({ run });
699
+ }
700
+
701
+ const pidMatch = pathname.match(/^\/api\/runs\/([^/]+)\/pid$/);
702
+ if (request.method === 'POST' && pidMatch) {
703
+ return readJson<RunPidBody>(request).then((body) => {
704
+ const connection = requireDaemonConnection(store, request);
705
+ const runBefore = store.getRun(pidMatch[1]!);
706
+ if (!runBefore) throw new HttpError(404, 'RUN_NOT_FOUND', 'run not found');
707
+ if (connection && runBefore?.connection_id !== connection.connectionId) throw new HttpError(403, 'RUN_CONNECTION_MISMATCH', 'run belongs to another connection');
708
+ store.setRunPid(pidMatch[1]!, body.pid ?? null);
709
+ const run = store.getRun(pidMatch[1]!);
710
+ if (!run) throw new HttpError(404, 'RUN_NOT_FOUND', 'run not found');
711
+ return json({ run });
712
+ });
713
+ }
714
+
715
+ const finishMatch = pathname.match(/^\/api\/runs\/([^/]+)\/finish$/);
716
+ if (request.method === 'POST' && finishMatch) {
717
+ return readJson<FinishRunBody>(request).then((body) => {
718
+ const connection = requireDaemonConnection(store, request);
719
+ const runBefore = store.getRun(finishMatch[1]!);
720
+ if (!runBefore) throw new HttpError(404, 'RUN_NOT_FOUND', 'run not found');
721
+ if (connection && runBefore?.connection_id !== connection.connectionId) throw new HttpError(403, 'RUN_CONNECTION_MISMATCH', 'run belongs to another connection');
722
+ if (!body.status) throw new HttpError(400, 'MISSING_STATUS', 'status is required');
723
+ store.finishRun(finishMatch[1]!, { status: body.status, exitCode: body.exit_code, output: body.output });
724
+ const run = store.getRun(finishMatch[1]!);
725
+ if (!run) throw new HttpError(404, 'RUN_NOT_FOUND', 'run not found');
726
+ return json({ run });
727
+ });
728
+ }
729
+
730
+ const actionMatch = pathname.match(/^\/api\/runs\/([^/]+)\/(kill|restart)$/);
731
+ if (request.method === 'POST' && actionMatch) {
732
+ const run = store.requestRunAction(actionMatch[1]!, actionMatch[2] as RunAction);
733
+ return json({ run });
734
+ }
735
+
736
+ return routeNotFound();
737
+ }
738
+
739
+ export async function handleRequest(store: MessageStore, request: Request): Promise<Response> {
740
+ try {
741
+ return await route(store, request);
742
+ } catch (error) {
743
+ return failure(error);
744
+ }
745
+ }