@controlflow-ai/daemon 0.1.1 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/README.md +66 -24
  2. package/package.json +16 -3
  3. package/src/agent-avatar.ts +30 -0
  4. package/src/agent-key.ts +28 -0
  5. package/src/agent-permissions.ts +359 -0
  6. package/src/agent-runtime.ts +810 -28
  7. package/src/agent-workspace.ts +183 -0
  8. package/src/app.ts +2183 -79
  9. package/src/args.ts +54 -7
  10. package/src/cli.ts +873 -14
  11. package/src/client.ts +482 -12
  12. package/src/coco.ts +9 -40
  13. package/src/codex.ts +33 -5
  14. package/src/config.ts +28 -4
  15. package/src/console.ts +460 -26
  16. package/src/daemon-client.ts +116 -3
  17. package/src/daemon.ts +958 -101
  18. package/src/db.ts +3216 -113
  19. package/src/delivery-ws.ts +269 -0
  20. package/src/format.ts +4 -1
  21. package/src/lark/app-registration.ts +141 -0
  22. package/src/lark/cli.ts +7 -137
  23. package/src/lark/credentials.ts +36 -3
  24. package/src/lark/event-router.ts +61 -5
  25. package/src/lark/inbound-events.ts +156 -3
  26. package/src/lark/server-integration.ts +659 -111
  27. package/src/lark/setup.ts +74 -5
  28. package/src/lark/ws-daemon.ts +136 -10
  29. package/src/local-api.ts +611 -14
  30. package/src/local-auth.ts +36 -3
  31. package/src/message-attachments.ts +71 -0
  32. package/src/messaging-cli.ts +741 -0
  33. package/src/messaging-status.ts +669 -0
  34. package/src/migrations/023_projects.ts +65 -0
  35. package/src/migrations/024_agents_model.ts +10 -0
  36. package/src/migrations/025_room_archive.ts +44 -0
  37. package/src/migrations/026_project_archive.ts +44 -0
  38. package/src/migrations/027_agent_permission_profiles.ts +16 -0
  39. package/src/migrations/028_lark_websocket_restart_state.ts +16 -0
  40. package/src/migrations/029_held_message_drafts.ts +32 -0
  41. package/src/migrations/030_agent_room_read_state.ts +25 -0
  42. package/src/migrations/031_room_tasks.ts +29 -0
  43. package/src/migrations/032_room_reminders.ts +29 -0
  44. package/src/migrations/033_room_saved_messages.ts +25 -0
  45. package/src/migrations/034_agent_activity_events.ts +27 -0
  46. package/src/migrations/035_agent_avatars.ts +17 -0
  47. package/src/migrations/036_project_agent_defaults.ts +21 -0
  48. package/src/migrations/037_message_attachments.ts +36 -0
  49. package/src/migrations/038_agent_activity_room_scope.ts +64 -0
  50. package/src/migrations/039_message_attachments_path.ts +34 -0
  51. package/src/migrations/040_message_attachments_file_schema.ts +80 -0
  52. package/src/migrations/041_room_system_events.ts +30 -0
  53. package/src/migrations/042_message_attachment_file_kind.ts +52 -0
  54. package/src/migrations/043_room_mode_skill_registry.ts +92 -0
  55. package/src/migrations/044_workflow_runtime.ts +69 -0
  56. package/src/migrations/045_skill_repository_ownership.ts +64 -0
  57. package/src/migrations.ts +70 -1
  58. package/src/neeko.ts +40 -4
  59. package/src/runtime-env.ts +179 -0
  60. package/src/runtime-registry.ts +83 -13
  61. package/src/server.ts +244 -4
  62. package/src/token-file.ts +13 -6
  63. package/src/types.ts +394 -0
  64. package/src/workflow-runtime.ts +275 -0
  65. package/src/web.ts +0 -904
package/src/app.ts CHANGED
@@ -1,13 +1,95 @@
1
1
  import { artifactHeaders, artifactViewerHtml } from './artifacts.js';
2
- import { MessageStore } from './db.js';
2
+ import { spawnSync } from 'node:child_process';
3
+ import { existsSync, lstatSync, readdirSync, readFileSync, realpathSync, statSync } from 'node:fs';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { basename, extname, isAbsolute, join, relative, resolve, sep } from 'node:path';
6
+ import QRCode from 'qrcode';
7
+ import { ALL_AGENTS_MENTION, MessageStore } from './db.js';
3
8
  import { failure, HttpError, json, numberParam, readJson, stringParam } from './http.js';
4
9
  import { assertServerAuth } from './server-auth.js';
5
- import { dashboardHtml } from './web.js';
6
- import type { RunAction, RunStatus } from './types.js';
10
+ import type { AgentActivityKind, AgentRoomSubscriptionMode, Chat, RoomMode, RoomReminderStatus, RoomTaskStatus, RunAction, RunStatus, SkillBindingScope, SkillSource, WorkflowRunStatus } from './types.js';
7
11
  import { createLarkApiClient, sendTextMessage } from './lark/ws-daemon.js';
8
- import { boundAgents, defaultLarkConfigPath, loadLarkCredentials, type LarkCredentialStore } from './lark/credentials.js';
12
+ import { boundAgents, defaultLarkConfigPath, loadLarkCredentials, saveLarkCredentials, type LarkCredentialStore, unbindCredentialAgent } from './lark/credentials.js';
9
13
  import { persistLarkCredential, resolveLarkBotInfo } from './lark/setup.js';
14
+ import { beginLarkAppRegistration, pollLarkAppRegistration, type LarkRegistrationComplete } from './lark/app-registration.js';
15
+ import { listRecentInboundEvents, repairInboundEventParseFailures } from './lark/inbound-events.js';
10
16
  import { tailscaleAddress } from './network.js';
17
+ import { allRuntimeModelOptions, validateRuntimeModel } from './runtime-registry.js';
18
+ import { validateAgentPermissionProfile, type AgentPermissionProfile as RuntimeAgentPermissionProfile } from './agent-permissions.js';
19
+ import { uniqueAgentKeyFromDisplayName } from './agent-key.js';
20
+ import { AgentWorkspaceFileError, listAgentWorkspaceFiles, updateAgentWorkspaceFile } from './agent-workspace.js';
21
+ import type { AgentDeletionImpact, AgentDeletionLarkBindingImpact, ChannelAccount, Message, MessageDelivery, MessageFreshnessInput } from './types.js';
22
+ import { buildMessagingHealth, buildMessagingStatus, emptyDeliveryWebSocketSummary, type DeliveryConnectionStats, type DeliveryWebSocketSummary, type LarkMessagingStatus } from './messaging-status.js';
23
+
24
+ interface DeliveryNotifyResult {
25
+ deliveries: number;
26
+ target_connections: number;
27
+ open_sockets: number;
28
+ websocket_frames: number;
29
+ }
30
+
31
+ export interface AppRouteOptions {
32
+ deliveryNotifier?: {
33
+ notifyDeliveries(deliveries: MessageDelivery[]): DeliveryNotifyResult;
34
+ statsForConnection?(connectionId: string): DeliveryConnectionStats;
35
+ statsAllConnections?(): DeliveryWebSocketSummary;
36
+ };
37
+ larkStatusProvider?: () => LarkMessagingStatus[];
38
+ }
39
+
40
+ interface AgentWorkspaceFileUpdateBody {
41
+ path?: unknown;
42
+ content?: unknown;
43
+ }
44
+
45
+ interface HeldDraftReviseBody {
46
+ content?: unknown;
47
+ }
48
+
49
+ function logDeliveryNotifyResult(result: DeliveryNotifyResult | undefined): void {
50
+ if (!result || result.deliveries === 0) return;
51
+ console.log(`[delivery] notify deliveries=${result.deliveries} target_connections=${result.target_connections} open_sockets=${result.open_sockets} websocket_frames=${result.websocket_frames}`);
52
+ }
53
+
54
+ function notifyDeliveries(options: AppRouteOptions, deliveries: MessageDelivery[]): DeliveryNotifyResult {
55
+ const result = options.deliveryNotifier?.notifyDeliveries(deliveries) ?? {
56
+ deliveries: deliveries.filter((delivery) => delivery.status === 'pending').length,
57
+ target_connections: 0,
58
+ open_sockets: 0,
59
+ websocket_frames: 0,
60
+ };
61
+ logDeliveryNotifyResult(result);
62
+ return result;
63
+ }
64
+
65
+ function validateMessageMentions(store: MessageStore, roomId: string, batch: Array<{ mentions?: string[] }>): { invalid_mentions: string[]; available_mentions: string[] } | null {
66
+ const mentionables = store.listMentionableRoomParticipants(roomId);
67
+ const available = mentionables.map((participant) => participant.participant_id);
68
+ const valid = new Set(available);
69
+ valid.add(ALL_AGENTS_MENTION);
70
+ const invalid = new Set<string>();
71
+
72
+ for (const item of batch) {
73
+ for (const rawMention of item.mentions ?? []) {
74
+ const mention = typeof rawMention === 'string' ? rawMention.trim().replace(/^@/, '') : '';
75
+ if (!mention || valid.has(mention)) continue;
76
+ invalid.add(mention);
77
+ }
78
+ }
79
+
80
+ if (invalid.size === 0) return null;
81
+ return { invalid_mentions: [...invalid], available_mentions: available };
82
+ }
83
+
84
+ export function fireDueRoomRemindersForDelivery(
85
+ store: MessageStore,
86
+ options: AppRouteOptions,
87
+ input: { now?: string | Date | null; limit?: number } = {},
88
+ ) {
89
+ const result = store.fireDueRoomReminders(input);
90
+ const notify = notifyDeliveries(options, result.deliveries);
91
+ return { ...result, notify };
92
+ }
11
93
 
12
94
  interface SendBody {
13
95
  chat?: string;
@@ -22,11 +104,277 @@ interface SendBody {
22
104
  type?: 'message' | 'system';
23
105
  idempotency_key?: string | null;
24
106
  mentions?: string[];
107
+ attachments?: AttachmentBody[];
108
+ messages?: Array<{
109
+ parent_id?: number;
110
+ channel_id?: string | null;
111
+ recipient?: string | null;
112
+ content?: string;
113
+ type?: 'message' | 'system';
114
+ idempotency_key?: string | null;
115
+ mentions?: string[];
116
+ attachments?: AttachmentBody[];
117
+ }>;
118
+ freshness?: MessageFreshnessInput;
119
+ }
120
+
121
+ interface AttachmentBody {
122
+ kind?: 'image' | 'file';
123
+ mime_type?: string;
124
+ filename?: string | null;
125
+ content_base64?: string;
126
+ }
127
+
128
+ interface AgentRoomReadBody {
129
+ message_id?: number;
130
+ }
131
+
132
+ interface AgentRoomSubscriptionBody {
133
+ mode?: AgentRoomSubscriptionMode;
134
+ }
135
+
136
+ const agentRoomSubscriptionModes: AgentRoomSubscriptionMode[] = ['all', 'periodic', 'mentions', 'muted', 'off'];
137
+ const agentActivityKinds: AgentActivityKind[] = ['lifecycle', 'working', 'thinking', 'output', 'tool', 'error'];
138
+ const roomTaskStatuses: RoomTaskStatus[] = ['todo', 'in_progress', 'in_review', 'done', 'closed'];
139
+ const roomReminderStatuses: RoomReminderStatus[] = ['scheduled', 'fired', 'canceled'];
140
+ const roomModes: RoomMode[] = ['standard', 'idea_development'];
141
+ const skillBindingScopes: SkillBindingScope[] = ['project', 'room', 'agent'];
142
+ const skillSources: SkillSource[] = ['system', 'user', 'project'];
143
+ const workflowRunStatuses: WorkflowRunStatus[] = ['running', 'completed', 'failed'];
144
+
145
+ function parseAgentRoomSubscriptionMode(value: unknown): AgentRoomSubscriptionMode {
146
+ if (typeof value !== 'string' || !agentRoomSubscriptionModes.includes(value as AgentRoomSubscriptionMode)) {
147
+ throw new HttpError(400, 'INVALID_SUBSCRIPTION_MODE', 'mode must be all, periodic, mentions, muted, or off');
148
+ }
149
+ return value as AgentRoomSubscriptionMode;
150
+ }
151
+
152
+ function parseRoomMode(value: unknown): RoomMode {
153
+ if (value === undefined || value === null || value === '') return 'standard';
154
+ if (typeof value !== 'string' || !roomModes.includes(value as RoomMode)) {
155
+ throw new HttpError(400, 'INVALID_ROOM_MODE', 'mode must be standard or idea_development');
156
+ }
157
+ return value as RoomMode;
158
+ }
159
+
160
+ function parseSkillBindingScope(value: unknown): SkillBindingScope {
161
+ if (typeof value !== 'string' || !skillBindingScopes.includes(value as SkillBindingScope)) {
162
+ throw new HttpError(400, 'INVALID_SKILL_BINDING_SCOPE', 'scope must be project, room, or agent');
163
+ }
164
+ return value as SkillBindingScope;
165
+ }
166
+
167
+ function parseSkillSource(value: unknown): SkillSource {
168
+ if (value === undefined || value === null || value === '') return 'user';
169
+ if (typeof value !== 'string' || !skillSources.includes(value as SkillSource)) {
170
+ throw new HttpError(400, 'INVALID_SKILL_SOURCE', 'source must be system, user, or project');
171
+ }
172
+ return value as SkillSource;
173
+ }
174
+
175
+ function parseRoomTaskStatus(value: unknown): RoomTaskStatus {
176
+ if (typeof value !== 'string' || !roomTaskStatuses.includes(value as RoomTaskStatus)) {
177
+ throw new HttpError(400, 'INVALID_TASK_STATUS', 'status must be todo, in_progress, in_review, done, or closed');
178
+ }
179
+ return value as RoomTaskStatus;
180
+ }
181
+
182
+ function parseRoomReminderStatus(value: unknown): RoomReminderStatus {
183
+ if (typeof value !== 'string' || !roomReminderStatuses.includes(value as RoomReminderStatus)) {
184
+ throw new HttpError(400, 'INVALID_REMINDER_STATUS', 'status must be scheduled, fired, or canceled');
185
+ }
186
+ return value as RoomReminderStatus;
187
+ }
188
+
189
+ function parseWorkflowRunStatus(value: unknown): WorkflowRunStatus {
190
+ if (typeof value !== 'string' || !workflowRunStatuses.includes(value as WorkflowRunStatus)) {
191
+ throw new HttpError(400, 'INVALID_WORKFLOW_RUN_STATUS', 'status must be running, completed, or failed');
192
+ }
193
+ return value as WorkflowRunStatus;
194
+ }
195
+
196
+ function skillStoreHttpError(error: unknown): HttpError | null {
197
+ const message = error instanceof Error ? error.message : String(error);
198
+ if (message === 'SKILL_ALREADY_EXISTS') return new HttpError(409, 'SKILL_ALREADY_EXISTS', 'skill key already exists');
199
+ if (message === 'SKILL_SOURCE_IMMUTABLE') return new HttpError(409, 'SKILL_SOURCE_IMMUTABLE', 'skill source cannot be changed');
200
+ if (message === 'SYSTEM_SKILL_OWNERSHIP_IMMUTABLE') return new HttpError(409, 'SYSTEM_SKILL_OWNERSHIP_IMMUTABLE', 'system skill ownership cannot be changed');
201
+ if (message === 'SKILL_OWNER_IMMUTABLE') return new HttpError(409, 'SKILL_OWNER_IMMUTABLE', 'skill owner cannot be changed');
202
+ if (message === 'SKILL_PROJECT_IMMUTABLE') return new HttpError(409, 'SKILL_PROJECT_IMMUTABLE', 'skill project cannot be changed');
203
+ if (message === 'SYSTEM_SKILL_CREATE_FORBIDDEN') return new HttpError(409, 'SYSTEM_SKILL_CREATE_FORBIDDEN', 'system skills are seeded by PAL migrations');
204
+ if (message === 'SYSTEM_SKILL_READ_ONLY') return new HttpError(409, 'SYSTEM_SKILL_READ_ONLY', 'system skill definitions are read-only');
205
+ if (message === 'INVALID_SKILL_KEY') return new HttpError(400, 'INVALID_SKILL_KEY', 'skill key must start with a letter or number and contain only letters, numbers, underscores, or hyphens');
206
+ if (message === 'owner_user_id is required for user skills') return new HttpError(400, 'MISSING_SKILL_OWNER', message);
207
+ if (message === 'project_id is required for project skills') return new HttpError(400, 'MISSING_SKILL_PROJECT', message);
208
+ return null;
209
+ }
210
+
211
+ async function readOptionalJson<T extends object>(request: Request): Promise<T> {
212
+ const text = await request.text();
213
+ if (!text.trim()) return {} as T;
214
+ try {
215
+ const value = JSON.parse(text) as unknown;
216
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
217
+ throw new HttpError(400, 'BAD_JSON', 'expected a JSON object');
218
+ }
219
+ return value as T;
220
+ } catch (error) {
221
+ if (error instanceof HttpError) throw error;
222
+ throw new HttpError(400, 'BAD_JSON', 'request body must be valid JSON');
223
+ }
224
+ }
225
+
226
+ function parseMessageAttachments(attachments: AttachmentBody[] | undefined) {
227
+ if (!attachments?.length) return [];
228
+ return attachments.map((attachment) => {
229
+ const kind = attachment.kind ?? 'file';
230
+ if (kind !== 'image' && kind !== 'file') throw new HttpError(400, 'UNSUPPORTED_ATTACHMENT_KIND', 'attachment kind must be image or file');
231
+ if (!attachment.mime_type?.trim()) throw new HttpError(400, 'MISSING_ATTACHMENT_MIME', 'attachment mime_type is required');
232
+ if (!attachment.content_base64) throw new HttpError(400, 'MISSING_ATTACHMENT_CONTENT', 'attachment content_base64 is required');
233
+ return {
234
+ kind,
235
+ mimeType: attachment.mime_type,
236
+ filename: attachment.filename,
237
+ content: Uint8Array.from(Buffer.from(attachment.content_base64, 'base64')),
238
+ sourceProvider: 'web' as const,
239
+ };
240
+ });
241
+ }
242
+
243
+ function taskNumberFromPath(value: string | undefined): number {
244
+ const taskNumber = Number(value);
245
+ if (!Number.isInteger(taskNumber) || taskNumber < 1) {
246
+ throw new HttpError(400, 'INVALID_TASK_NUMBER', 'task_number must be a positive integer');
247
+ }
248
+ return taskNumber;
249
+ }
250
+
251
+ function freshnessInput(value: unknown): MessageFreshnessInput | null {
252
+ if (!value || typeof value !== 'object') return null;
253
+ const candidate = value as Partial<MessageFreshnessInput>;
254
+ const baseMessageId = Number(candidate.base_message_id);
255
+ if (!Number.isInteger(baseMessageId) || baseMessageId < 0) {
256
+ throw new HttpError(400, 'INVALID_FRESHNESS', 'freshness.base_message_id must be a non-negative integer');
257
+ }
258
+ if (candidate.on_stale !== 'hold' && candidate.on_stale !== 'send_anyway') {
259
+ throw new HttpError(400, 'INVALID_FRESHNESS', 'freshness.on_stale must be hold or send_anyway');
260
+ }
261
+ return { base_message_id: baseMessageId, on_stale: candidate.on_stale };
262
+ }
263
+
264
+ interface ProbeDeliveryBody {
265
+ agent?: string;
266
+ room?: string;
267
+ sender?: string;
268
+ content?: string;
269
+ idempotency_key?: string | null;
25
270
  }
26
271
 
27
272
  interface CreateRoomBody {
28
273
  name?: string;
29
274
  kind?: 'group' | 'dm';
275
+ mode?: RoomMode;
276
+ }
277
+
278
+ interface HandoffRoomBody {
279
+ source_room_id?: string;
280
+ source_message_id?: number;
281
+ actor?: string;
282
+ purpose?: string;
283
+ name?: string;
284
+ team?: Array<{ agent?: string; mode?: string }>;
285
+ handoff_content?: string;
286
+ }
287
+
288
+ interface UpdateRoomBody {
289
+ status?: 'active' | 'archived';
290
+ }
291
+
292
+ interface CreateRoomTasksBody {
293
+ title?: string;
294
+ tasks?: Array<{ title?: string }>;
295
+ created_by?: string | null;
296
+ source_message_id?: number | null;
297
+ }
298
+
299
+ interface ClaimRoomTaskBody {
300
+ assignee?: string;
301
+ }
302
+
303
+ interface UpdateRoomTaskBody {
304
+ status?: RoomTaskStatus;
305
+ }
306
+
307
+ interface CreateRoomReminderBody {
308
+ msg_id?: number;
309
+ source_message_id?: number;
310
+ title?: string;
311
+ created_by?: string | null;
312
+ fire_at?: string | null;
313
+ delay_seconds?: number | null;
314
+ repeat?: string | null;
315
+ }
316
+
317
+ interface CreateRoomSavedMessageBody {
318
+ msg_id?: number;
319
+ source_message_id?: number;
320
+ saved_by?: string | null;
321
+ note?: string | null;
322
+ }
323
+
324
+ interface FireDueRoomRemindersBody {
325
+ now?: string | null;
326
+ limit?: number | null;
327
+ }
328
+
329
+ interface CreateProjectBody {
330
+ name?: string;
331
+ computer_id?: string;
332
+ root_path?: string;
333
+ }
334
+
335
+ interface UpdateProjectBody {
336
+ status?: 'active' | 'archived';
337
+ }
338
+
339
+ interface ProjectAgentDefaultsBody {
340
+ defaults?: Array<{ agent?: string; mode?: string }>;
341
+ }
342
+
343
+ interface UpsertSkillBody {
344
+ key?: string;
345
+ name?: string;
346
+ description?: string;
347
+ instruction_content?: string;
348
+ status?: 'active' | 'disabled';
349
+ source?: SkillSource;
350
+ owner_user_id?: string | null;
351
+ project_id?: string | null;
352
+ repository_path?: string | null;
353
+ }
354
+
355
+ interface SkillBindingBody {
356
+ scope?: SkillBindingScope;
357
+ scope_id?: string;
358
+ skill_key?: string;
359
+ enabled?: boolean;
360
+ priority?: number;
361
+ }
362
+
363
+ type PalContentFileType = 'markdown' | 'html';
364
+
365
+ interface ProjectPalFileEntry {
366
+ name: string;
367
+ path: string;
368
+ type: 'directory' | 'file';
369
+ file_type?: PalContentFileType;
370
+ size_bytes?: number;
371
+ children?: ProjectPalFileEntry[];
372
+ }
373
+
374
+ interface ProjectPalGitCommitBody {
375
+ message?: string;
376
+ files?: string[];
377
+ expected_head?: string | null;
30
378
  }
31
379
 
32
380
  interface StartRunBody {
@@ -60,6 +408,20 @@ interface ProvisionComputerBody {
60
408
  package_name?: string;
61
409
  }
62
410
 
411
+ interface UpdateComputerBody {
412
+ name?: string;
413
+ }
414
+
415
+ interface RegenerateComputerCommandBody {
416
+ server_url?: string;
417
+ package_name?: string;
418
+ }
419
+
420
+ interface DeleteComputerBody {
421
+ remove_projects?: boolean;
422
+ remove_agents?: boolean;
423
+ }
424
+
63
425
  interface ConnectComputerBody {
64
426
  computer_id?: string;
65
427
  secret?: string;
@@ -71,6 +433,25 @@ interface ConnectComputerBody {
71
433
  agents?: Array<{ agent?: string; cwd?: string; capabilities?: Record<string, unknown> }>;
72
434
  }
73
435
 
436
+ async function fetchDaemonJson<T>(input: { localUrl: string; token: string; path: string }): Promise<T> {
437
+ const base = input.localUrl.replace(/\/$/, '');
438
+ const response = await fetch(`${base}${input.path}`, {
439
+ headers: { authorization: `Bearer ${input.token}` },
440
+ });
441
+ const payload = await response.json().catch(() => ({})) as { ok?: boolean; data?: T; message?: string; code?: string };
442
+ if (!response.ok || payload.ok === false) {
443
+ throw new HttpError(response.status || 502, payload.code || 'DAEMON_REQUEST_FAILED', payload.message || 'daemon request failed');
444
+ }
445
+ return (payload.data ?? {}) as T;
446
+ }
447
+
448
+ async function validateRemoteProjectPath(store: MessageStore, computerId: string, rootPath: string): Promise<void> {
449
+ const control = store.getComputerLocalControl(computerId);
450
+ if (!control) throw new HttpError(409, 'COMPUTER_NOT_BROWSABLE', 'computer is not online or does not expose a local filesystem browser');
451
+ const params = new URLSearchParams({ path: rootPath, validate: 'directory', show_hidden: 'true' });
452
+ await fetchDaemonJson({ localUrl: control.daemon.local_url, token: control.token, path: `/local/files?${params}` });
453
+ }
454
+
74
455
  interface AssignAgentBody {
75
456
  computer_id?: string;
76
457
  }
@@ -79,13 +460,26 @@ interface OnboardAgentBody {
79
460
  agent_key?: string;
80
461
  display_name?: string;
81
462
  runtime?: string | null;
463
+ model?: string | null;
82
464
  description?: string | null;
83
465
  computer_id?: string;
84
466
  }
85
467
 
468
+ interface DeleteAgentBody {
469
+ confirm_delete?: boolean;
470
+ leave_rooms?: boolean;
471
+ unbind_lark?: boolean;
472
+ }
473
+
474
+ interface AgentPermissionsBody {
475
+ filesystemMode?: RuntimeAgentPermissionProfile['filesystemMode'];
476
+ extraWritableRoots?: string[];
477
+ }
478
+
86
479
  interface LarkSetupBody {
87
480
  app_id?: string;
88
481
  app_secret?: string;
482
+ registration_id?: string;
89
483
  label?: string;
90
484
  agent?: string;
91
485
  config?: string;
@@ -96,6 +490,17 @@ interface LarkAuthorizedUserBody {
96
490
  display_name?: string | null;
97
491
  }
98
492
 
493
+ async function checkedRuntimeModel(runtime: string | null | undefined, model: string | null | undefined): Promise<string | null> {
494
+ if (model?.trim() && !runtime?.trim()) {
495
+ throw new HttpError(400, 'MISSING_RUNTIME', 'runtime is required when model is configured');
496
+ }
497
+ try {
498
+ return await validateRuntimeModel(runtime ?? '', model);
499
+ } catch (error) {
500
+ throw new HttpError(400, 'INVALID_MODEL', error instanceof Error ? error.message : String(error));
501
+ }
502
+ }
503
+
99
504
  interface CreateDeliveryBody {
100
505
  message_id?: number;
101
506
  agent?: string;
@@ -106,6 +511,7 @@ interface ClaimDeliveryBody {
106
511
  connection_id?: string | null;
107
512
  computer_id?: string | null;
108
513
  lease_ms?: number;
514
+ steer_run_id?: string | null;
109
515
  }
110
516
 
111
517
  interface SessionBody {
@@ -151,6 +557,13 @@ interface FinishRunBody {
151
557
  output?: string;
152
558
  }
153
559
 
560
+ interface RecordRunActivityBody {
561
+ kind?: AgentActivityKind;
562
+ title?: string;
563
+ detail?: string | null;
564
+ metadata?: Record<string, unknown> | null;
565
+ }
566
+
154
567
  export function resolveLarkOutboundRoute(store: MessageStore, credentials: LarkCredentialStore, sender: string, chatId: string) {
155
568
  for (const bot of credentials.bots) {
156
569
  if (!boundAgents(bot).includes(sender)) continue;
@@ -161,14 +574,469 @@ export function resolveLarkOutboundRoute(store: MessageStore, credentials: LarkC
161
574
  return null;
162
575
  }
163
576
 
577
+ export function resolveLarkMentionTarget(store: MessageStore, account: ChannelAccount, recipient: string | null | undefined): { openId: string; displayName?: string | null } | null {
578
+ const target = recipient?.trim();
579
+ if (!target) return null;
580
+
581
+ const agentBot = store.db.query(`
582
+ SELECT bot_open_id, name
583
+ FROM channel_accounts
584
+ WHERE channel = 'lark'
585
+ AND status = 'active'
586
+ AND agent = ?
587
+ AND bot_open_id IS NOT NULL
588
+ AND bot_open_id != ''
589
+ LIMIT 1
590
+ `).get(target) as { bot_open_id: string; name: string | null } | null;
591
+ if (agentBot) return { openId: agentBot.bot_open_id, displayName: agentBot.name };
592
+
593
+ if (!account.provider_account_id) return null;
594
+ const identity = store.db.query(`
595
+ SELECT b.external_id, i.display_name
596
+ FROM pal_identities i
597
+ INNER JOIN provider_identity_bindings b ON b.identity_id = i.id
598
+ WHERE i.stable_handle = ?
599
+ AND b.provider = 'lark'
600
+ AND b.provider_account_id = ?
601
+ AND b.external_type IN ('user', 'bot')
602
+ LIMIT 1
603
+ `).get(target, account.provider_account_id) as { external_id: string; display_name: string | null } | null;
604
+ if (!identity) return null;
605
+ return { openId: identity.external_id, displayName: identity.display_name };
606
+ }
607
+
164
608
  function routeNotFound(): Response {
165
609
  return Response.json({ ok: false, code: 'NOT_FOUND', message: 'not found' }, { status: 404 });
166
610
  }
167
611
 
612
+ const projectPalSupportedExtensions = new Map<string, PalContentFileType>([
613
+ ['.md', 'markdown'],
614
+ ['.markdown', 'markdown'],
615
+ ['.html', 'html'],
616
+ ['.htm', 'html'],
617
+ ]);
618
+ const projectPalMaxReadBytes = 1024 * 1024;
619
+ const projectPalMaxTreeEntries = 500;
620
+ const projectPalDiffMaxBytes = 512 * 1024;
621
+
622
+ function isPathInside(basePath: string, targetPath: string): boolean {
623
+ const relativePath = relative(basePath, targetPath);
624
+ return relativePath === '' || (relativePath !== '..' && !relativePath.startsWith(`..${sep}`) && !isAbsolute(relativePath));
625
+ }
626
+
627
+ function normalizePalRelativePath(value: string | null | undefined): string {
628
+ const candidate = (value || '').trim().replace(/\\/g, '/');
629
+ if (!candidate || candidate.startsWith('/') || candidate.split('/').some((part) => !part || part === '.' || part === '..')) {
630
+ throw new HttpError(400, 'BAD_PAL_FILE_PATH', 'path must be a relative .pal file path');
631
+ }
632
+ return candidate;
633
+ }
634
+
635
+ function projectPalRoot(projectRootPath: string): { projectRoot: string; palRoot: string; exists: boolean } {
636
+ const projectRoot = realpathSync(projectRootPath);
637
+ const palPath = resolve(projectRootPath, '.pal');
638
+ if (!existsSync(palPath)) return { projectRoot, palRoot: palPath, exists: false };
639
+ const palRoot = realpathSync(palPath);
640
+ if (!isPathInside(projectRoot, palRoot)) {
641
+ throw new HttpError(403, 'PAL_ROOT_OUTSIDE_PROJECT', '.pal must resolve inside the project root');
642
+ }
643
+ const stat = statSync(palRoot);
644
+ if (!stat.isDirectory()) throw new HttpError(400, 'PAL_ROOT_NOT_DIRECTORY', '.pal is not a directory');
645
+ return { projectRoot, palRoot, exists: true };
646
+ }
647
+
648
+ function projectPalFileType(filePath: string): PalContentFileType | null {
649
+ return projectPalSupportedExtensions.get(extname(filePath).toLowerCase()) || null;
650
+ }
651
+
652
+ function isProjectPalMarkdownPath(filePath: string): boolean {
653
+ return projectPalFileType(filePath) === 'markdown';
654
+ }
655
+
656
+ function gitPathJoin(...parts: string[]): string {
657
+ return parts
658
+ .flatMap((part) => part.split(/[\\/]/))
659
+ .filter(Boolean)
660
+ .join('/');
661
+ }
662
+
663
+ function gitCommand(projectRootPath: string, args: string[], options?: { check?: boolean }) {
664
+ const result = spawnSync('git', ['-C', projectRootPath, ...args], { encoding: 'utf8' });
665
+ const stdout = result.stdout || '';
666
+ const stderr = result.stderr || '';
667
+ const status = result.status ?? 1;
668
+ if (options?.check !== false && status !== 0) {
669
+ const message = (stderr || stdout || `git ${args[0] || 'command'} failed`).trim();
670
+ throw new HttpError(409, 'GIT_COMMAND_FAILED', message);
671
+ }
672
+ return { stdout, stderr, status };
673
+ }
674
+
675
+ function projectPalGitInfo(projectRootPath: string) {
676
+ const root = projectPalRoot(projectRootPath);
677
+ const repoRootResult = gitCommand(root.projectRoot, ['rev-parse', '--show-toplevel'], { check: false });
678
+ if (repoRootResult.status !== 0) {
679
+ return {
680
+ root,
681
+ git: {
682
+ available: false,
683
+ repo_root: null,
684
+ head: null,
685
+ branch: null,
686
+ error: (repoRootResult.stderr || repoRootResult.stdout || 'project is not inside a Git repository').trim(),
687
+ dirty: false,
688
+ },
689
+ repoRoot: null,
690
+ palRepoPath: null,
691
+ };
692
+ }
693
+ const repoRoot = realpathSync(repoRootResult.stdout.trim());
694
+ if (!isPathInside(repoRoot, root.projectRoot)) {
695
+ throw new HttpError(403, 'PROJECT_OUTSIDE_GIT_REPO', 'project root must resolve inside its Git repository');
696
+ }
697
+ const headResult = gitCommand(root.projectRoot, ['rev-parse', 'HEAD'], { check: false });
698
+ const branchResult = gitCommand(root.projectRoot, ['branch', '--show-current'], { check: false });
699
+ const palRepoPath = gitPathJoin(relative(repoRoot, root.exists ? root.palRoot : resolve(root.projectRoot, '.pal')));
700
+ return {
701
+ root,
702
+ git: {
703
+ available: true,
704
+ repo_root: repoRoot,
705
+ head: headResult.status === 0 ? headResult.stdout.trim() : null,
706
+ branch: branchResult.status === 0 ? branchResult.stdout.trim() || null : null,
707
+ error: null,
708
+ dirty: false,
709
+ },
710
+ repoRoot,
711
+ palRepoPath,
712
+ };
713
+ }
714
+
715
+ function normalizeGitStatusPath(rawPath: string): string {
716
+ const renamedPath = rawPath.includes(' -> ') ? rawPath.split(' -> ').pop()! : rawPath;
717
+ return renamedPath.trim().replace(/\\/g, '/').replace(/^"|"$/g, '');
718
+ }
719
+
720
+ function projectPalRelativeFromRepoPath(repoPath: string, palRepoPath: string): string | null {
721
+ const normalizedPath = repoPath.replace(/\\/g, '/');
722
+ const normalizedRoot = palRepoPath.replace(/\\/g, '/').replace(/\/$/, '');
723
+ if (normalizedPath === normalizedRoot) return null;
724
+ const prefix = normalizedRoot ? `${normalizedRoot}/` : '';
725
+ if (!normalizedPath.startsWith(prefix)) return null;
726
+ const palRelativePath = normalizedPath.slice(prefix.length);
727
+ if (!palRelativePath || palRelativePath.split('/').some((part) => !part || part === '.' || part === '..')) return null;
728
+ return palRelativePath;
729
+ }
730
+
731
+ function syntheticNewFileDiff(repoPath: string, content: string): string {
732
+ const lines = content.length ? content.split(/\r?\n/) : [];
733
+ if (lines.length && lines[lines.length - 1] === '') lines.pop();
734
+ const body = lines.map((line) => `+${line}`).join('\n');
735
+ return [
736
+ `diff --git a/${repoPath} b/${repoPath}`,
737
+ 'new file mode 100644',
738
+ 'index 0000000..0000000',
739
+ '--- /dev/null',
740
+ `+++ b/${repoPath}`,
741
+ `@@ -0,0 +1,${lines.length} @@`,
742
+ body,
743
+ ].filter(Boolean).join('\n') + '\n';
744
+ }
745
+
746
+ function gitNumstatForPath(projectRootPath: string, repoPath: string): { additions: number; deletions: number } {
747
+ const result = gitCommand(projectRootPath, ['diff', '--numstat', 'HEAD', '--', repoPath], { check: false });
748
+ if (result.status !== 0 || !result.stdout.trim()) return { additions: 0, deletions: 0 };
749
+ const [additions, deletions] = result.stdout.trim().split(/\s+/);
750
+ return {
751
+ additions: Number.parseInt(additions || '0', 10) || 0,
752
+ deletions: Number.parseInt(deletions || '0', 10) || 0,
753
+ };
754
+ }
755
+
756
+ function projectPalGitChanges(projectRootPath: string, options?: { includeDiff?: boolean }) {
757
+ const info = projectPalGitInfo(projectRootPath);
758
+ if (!info.git.available || !info.repoRoot || !info.palRepoPath) {
759
+ return { git: info.git, files: [], file_count: 0, additions: 0, deletions: 0, diff: '' };
760
+ }
761
+
762
+ const statusResult = gitCommand(info.root.projectRoot, ['status', '--porcelain=v1', '--untracked-files=all', '--', info.palRepoPath], { check: false });
763
+ if (statusResult.status !== 0) {
764
+ throw new HttpError(409, 'GIT_STATUS_FAILED', (statusResult.stderr || statusResult.stdout || 'git status failed').trim());
765
+ }
766
+
767
+ const files: Array<{ path: string; repo_path: string; status: string; additions: number; deletions: number }> = [];
768
+ const diffableRepoPaths: string[] = [];
769
+ let diff = '';
770
+
771
+ for (const line of statusResult.stdout.split(/\r?\n/)) {
772
+ if (!line.trim()) continue;
773
+ const code = line.slice(0, 2);
774
+ const repoPath = normalizeGitStatusPath(line.slice(3));
775
+ const palRelativePath = projectPalRelativeFromRepoPath(repoPath, info.palRepoPath);
776
+ if (!palRelativePath || !isProjectPalMarkdownPath(palRelativePath)) continue;
777
+ let status = 'modified';
778
+ if (code.includes('?')) status = 'untracked';
779
+ else if (code.includes('D')) status = 'deleted';
780
+ else if (code.includes('A')) status = 'added';
781
+ else if (code.includes('R')) status = 'renamed';
782
+
783
+ let stats = gitNumstatForPath(info.root.projectRoot, repoPath);
784
+ if (status === 'untracked') {
785
+ const absolutePath = resolve(info.repoRoot, repoPath);
786
+ if (!existsSync(absolutePath) || lstatSync(absolutePath).isSymbolicLink()) continue;
787
+ const realFilePath = realpathSync(absolutePath);
788
+ if (!isPathInside(info.root.palRoot, realFilePath)) continue;
789
+ const content = readFileSync(realFilePath, 'utf8');
790
+ stats = { additions: content ? content.split(/\r?\n/).filter((_, index, lines) => !(index === lines.length - 1 && lines[index] === '')).length : 0, deletions: 0 };
791
+ if (options?.includeDiff) diff += syntheticNewFileDiff(repoPath, content);
792
+ } else {
793
+ diffableRepoPaths.push(repoPath);
794
+ }
795
+ files.push({ path: palRelativePath, repo_path: repoPath, status, additions: stats.additions, deletions: stats.deletions });
796
+ }
797
+
798
+ if (options?.includeDiff && diffableRepoPaths.length) {
799
+ const result = gitCommand(info.root.projectRoot, ['diff', '--no-color', 'HEAD', '--', ...diffableRepoPaths], { check: false });
800
+ if (result.status !== 0) throw new HttpError(409, 'GIT_DIFF_FAILED', (result.stderr || result.stdout || 'git diff failed').trim());
801
+ diff = `${result.stdout}${diff}`;
802
+ }
803
+ if (diff.length > projectPalDiffMaxBytes) {
804
+ diff = `${diff.slice(0, projectPalDiffMaxBytes)}\n... diff truncated at ${projectPalDiffMaxBytes} bytes ...\n`;
805
+ }
806
+
807
+ const additions = files.reduce((sum, file) => sum + file.additions, 0);
808
+ const deletions = files.reduce((sum, file) => sum + file.deletions, 0);
809
+ return {
810
+ git: { ...info.git, dirty: files.length > 0 },
811
+ files: files.sort((left, right) => left.path.localeCompare(right.path)),
812
+ file_count: files.length,
813
+ additions,
814
+ deletions,
815
+ diff,
816
+ };
817
+ }
818
+
819
+ function projectPalCommitPath(projectRootPath: string, requestedPath: string): string {
820
+ const info = projectPalGitInfo(projectRootPath);
821
+ if (!info.git.available || !info.repoRoot || !info.palRepoPath) throw new HttpError(409, 'GIT_NOT_AVAILABLE', info.git.error || 'project is not inside a Git repository');
822
+ const palRelativePath = normalizePalRelativePath(requestedPath);
823
+ if (!isProjectPalMarkdownPath(palRelativePath)) throw new HttpError(400, 'UNSUPPORTED_PAL_COMMIT_FILE', 'only .pal Markdown files can be committed');
824
+ return gitPathJoin(info.palRepoPath, palRelativePath);
825
+ }
826
+
827
+ function listProjectPalFiles(projectRootPath: string): { rootPath: string; entries: ProjectPalFileEntry[]; supportedFileCount: number; truncated: boolean } {
828
+ const root = projectPalRoot(projectRootPath);
829
+ if (!root.exists) return { rootPath: resolve(projectRootPath, '.pal'), entries: [], supportedFileCount: 0, truncated: false };
830
+
831
+ let visited = 0;
832
+ let supportedFileCount = 0;
833
+ const walk = (directory: string): ProjectPalFileEntry[] => {
834
+ if (visited >= projectPalMaxTreeEntries) return [];
835
+ const entries = readdirSync(directory, { withFileTypes: true });
836
+ const tree: ProjectPalFileEntry[] = [];
837
+ for (const entry of entries) {
838
+ if (visited >= projectPalMaxTreeEntries) break;
839
+ const absolutePath = resolve(directory, entry.name);
840
+ const entryStat = lstatSync(absolutePath);
841
+ if (entryStat.isSymbolicLink()) continue;
842
+ const realEntryPath = realpathSync(absolutePath);
843
+ if (!isPathInside(root.palRoot, realEntryPath)) continue;
844
+ visited += 1;
845
+ const relativePath = relative(root.palRoot, realEntryPath).split(sep).join('/');
846
+ if (entry.isDirectory()) {
847
+ const children = walk(realEntryPath);
848
+ if (children.length) tree.push({ name: entry.name, path: relativePath, type: 'directory', children });
849
+ continue;
850
+ }
851
+ if (!entry.isFile()) continue;
852
+ const fileType = projectPalFileType(entry.name);
853
+ if (!fileType) continue;
854
+ supportedFileCount += 1;
855
+ tree.push({ name: entry.name, path: relativePath, type: 'file', file_type: fileType, size_bytes: entryStat.size });
856
+ }
857
+ return tree.sort((left, right) => {
858
+ if (left.type !== right.type) return left.type === 'directory' ? -1 : 1;
859
+ return left.name.localeCompare(right.name);
860
+ });
861
+ };
862
+
863
+ return { rootPath: root.palRoot, entries: walk(root.palRoot), supportedFileCount, truncated: visited >= projectPalMaxTreeEntries };
864
+ }
865
+
866
+ function readProjectPalFile(projectRootPath: string, requestedPath: string | null | undefined) {
867
+ const root = projectPalRoot(projectRootPath);
868
+ if (!root.exists) throw new HttpError(404, 'PAL_ROOT_NOT_FOUND', '.pal directory was not found for this project');
869
+ const relativePath = normalizePalRelativePath(requestedPath);
870
+ const absolutePath = resolve(root.palRoot, relativePath);
871
+ if (!existsSync(absolutePath)) throw new HttpError(404, 'PAL_FILE_NOT_FOUND', '.pal file was not found');
872
+ if (lstatSync(absolutePath).isSymbolicLink()) throw new HttpError(403, 'PAL_FILE_SYMLINK', 'symbolic links are not readable from .pal');
873
+ const realFilePath = realpathSync(absolutePath);
874
+ if (!isPathInside(root.palRoot, realFilePath)) throw new HttpError(403, 'PAL_FILE_OUTSIDE_PROJECT', 'file must resolve inside the project .pal directory');
875
+ const fileStat = statSync(realFilePath);
876
+ if (!fileStat.isFile()) throw new HttpError(400, 'PAL_FILE_NOT_READABLE', 'path is not a readable file');
877
+ if (fileStat.size > projectPalMaxReadBytes) throw new HttpError(413, 'PAL_FILE_TOO_LARGE', '.pal file is too large to preview');
878
+ const fileType = projectPalFileType(realFilePath);
879
+ if (!fileType) throw new HttpError(400, 'UNSUPPORTED_PAL_FILE', 'only Markdown and HTML files can be previewed');
880
+ return {
881
+ name: basename(realFilePath),
882
+ path: relative(root.palRoot, realFilePath).split(sep).join('/'),
883
+ file_type: fileType,
884
+ mime_type: fileType === 'markdown' ? 'text/markdown; charset=utf-8' : 'text/html; charset=utf-8',
885
+ size_bytes: fileStat.size,
886
+ content: readFileSync(realFilePath, 'utf8'),
887
+ };
888
+ }
889
+
168
890
  function html(body: string): Response {
169
891
  return new Response(body, { headers: { 'content-type': 'text/html; charset=utf-8' } });
170
892
  }
171
893
 
894
+ const webDistDir = fileURLToPath(new URL('../web/app/dist/', import.meta.url));
895
+ const projectRootDir = fileURLToPath(new URL('../', import.meta.url));
896
+ let attemptedWebBuild = false;
897
+
898
+ interface PendingLarkRegistration {
899
+ id: string;
900
+ deviceCode: string;
901
+ url: string;
902
+ qrDataUrl: string;
903
+ expiresAt: number;
904
+ interval: number;
905
+ completed?: LarkRegistrationComplete;
906
+ }
907
+
908
+ const larkRegistrations = new Map<string, PendingLarkRegistration>();
909
+
910
+ function credentialBindingsForAgent(credentials: LarkCredentialStore, agentKey: string): AgentDeletionLarkBindingImpact[] {
911
+ return credentials.bots
912
+ .filter((bot) => bot.agent === agentKey)
913
+ .map((bot) => ({ source: 'credential' as const, app_id: bot.appId, label: bot.label ?? null }));
914
+ }
915
+
916
+ function mergeAgentDeletionImpact(storeImpact: AgentDeletionImpact, credentials: LarkCredentialStore, agentKey: string): AgentDeletionImpact {
917
+ const seen = new Set<string>();
918
+ const larkBindings: AgentDeletionLarkBindingImpact[] = [];
919
+ for (const binding of [...storeImpact.lark_bindings, ...credentialBindingsForAgent(credentials, agentKey)]) {
920
+ const key = `${binding.source}:${binding.app_id}`;
921
+ if (seen.has(key)) continue;
922
+ seen.add(key);
923
+ larkBindings.push(binding);
924
+ }
925
+ return { ...storeImpact, lark_bindings: larkBindings };
926
+ }
927
+
928
+ function agentDeletionImpact(store: MessageStore, agentKey: string, configPath: string): AgentDeletionImpact | null {
929
+ const storeImpact = store.getAgentDeletionImpact(agentKey);
930
+ if (!storeImpact) return null;
931
+ try {
932
+ return mergeAgentDeletionImpact(storeImpact, loadLarkCredentials(configPath), agentKey);
933
+ } catch {
934
+ return storeImpact;
935
+ }
936
+ }
937
+
938
+ function pruneLarkRegistrations(): void {
939
+ const now = Date.now();
940
+ for (const [id, registration] of larkRegistrations) {
941
+ if (registration.expiresAt < now - 60_000) larkRegistrations.delete(id);
942
+ }
943
+ }
944
+
945
+ function larkRegistrationPublic(entry: PendingLarkRegistration, status: 'pending' | 'complete' = entry.completed ? 'complete' : 'pending') {
946
+ return {
947
+ id: entry.id,
948
+ status,
949
+ url: entry.url,
950
+ qrDataUrl: entry.qrDataUrl,
951
+ expiresAt: new Date(entry.expiresAt).toISOString(),
952
+ interval: entry.interval,
953
+ appId: entry.completed?.appId ?? null,
954
+ tenantBrand: entry.completed?.tenantBrand ?? null,
955
+ userOpenId: entry.completed?.userOpenId ?? null,
956
+ };
957
+ }
958
+
959
+ async function pollStoredLarkRegistration(id: string): Promise<PendingLarkRegistration> {
960
+ pruneLarkRegistrations();
961
+ const entry = larkRegistrations.get(id);
962
+ if (!entry) throw new HttpError(404, 'LARK_REGISTRATION_NOT_FOUND', 'registration was not found or has expired');
963
+ if (entry.completed) return entry;
964
+ if (Date.now() > entry.expiresAt) {
965
+ larkRegistrations.delete(id);
966
+ throw new HttpError(410, 'LARK_REGISTRATION_EXPIRED', 'registration link expired');
967
+ }
968
+ const result = await pollLarkAppRegistration({ deviceCode: entry.deviceCode });
969
+ if (result.status === 'pending') return entry;
970
+ if (result.status === 'slow_down') {
971
+ entry.interval = Math.max(entry.interval + 5, result.interval);
972
+ return entry;
973
+ }
974
+ if (result.status === 'complete') {
975
+ entry.completed = result.registration;
976
+ return entry;
977
+ }
978
+ throw new HttpError(400, 'LARK_REGISTRATION_FAILED', result.message);
979
+ }
980
+
981
+ function webAssetContentType(pathname: string): string {
982
+ if (pathname.endsWith('.js')) return 'text/javascript; charset=utf-8';
983
+ if (pathname.endsWith('.css')) return 'text/css; charset=utf-8';
984
+ if (pathname.endsWith('.svg')) return 'image/svg+xml';
985
+ if (pathname.endsWith('.png')) return 'image/png';
986
+ if (pathname.endsWith('.jpg') || pathname.endsWith('.jpeg')) return 'image/jpeg';
987
+ if (pathname.endsWith('.webp')) return 'image/webp';
988
+ if (pathname.endsWith('.ico')) return 'image/x-icon';
989
+ return 'application/octet-stream';
990
+ }
991
+
992
+ function builtWebIndex(): Response | null {
993
+ ensureBuiltWeb();
994
+ const indexPath = join(webDistDir, 'index.html');
995
+ if (!existsSync(indexPath)) return null;
996
+ return new Response(Bun.file(indexPath), { headers: { 'content-type': 'text/html; charset=utf-8' } });
997
+ }
998
+
999
+ function ensureBuiltWeb(): void {
1000
+ if (attemptedWebBuild || existsSync(join(webDistDir, 'index.html'))) return;
1001
+ attemptedWebBuild = true;
1002
+ const result = Bun.spawnSync(['bun', 'run', 'web:build'], {
1003
+ cwd: projectRootDir,
1004
+ stdout: 'pipe',
1005
+ stderr: 'pipe',
1006
+ });
1007
+ if (result.exitCode !== 0) {
1008
+ const stderr = new TextDecoder().decode(result.stderr).trim();
1009
+ console.warn(`[web] React build failed${stderr ? `: ${stderr}` : ''}`);
1010
+ }
1011
+ }
1012
+
1013
+ function missingWebBuild(): Response {
1014
+ return html(`<!doctype html>
1015
+ <html lang="en">
1016
+ <head>
1017
+ <meta charset="utf-8">
1018
+ <meta name="viewport" content="width=device-width, initial-scale=1">
1019
+ <title>Pal Web</title>
1020
+ </head>
1021
+ <body>
1022
+ <main>
1023
+ <h1>React web build not found</h1>
1024
+ <p>Run <code>bun run web:build</code> or use <code>bun run web:dev</code> for the Pal web UI.</p>
1025
+ </main>
1026
+ </body>
1027
+ </html>`);
1028
+ }
1029
+
1030
+ function builtWebAsset(pathname: string): Response | null {
1031
+ if (!pathname.startsWith('/assets/')) return null;
1032
+ ensureBuiltWeb();
1033
+ const relative = decodeURIComponent(pathname.slice('/assets/'.length));
1034
+ if (!relative || relative.includes('..') || relative.includes('/') || relative.includes('\\')) return null;
1035
+ const assetPath = join(webDistDir, 'assets', relative);
1036
+ if (!existsSync(assetPath)) return null;
1037
+ return new Response(Bun.file(assetPath), { headers: { 'content-type': webAssetContentType(assetPath) } });
1038
+ }
1039
+
172
1040
  function daemonAuthFromRequest(request: Request): { computerId: string; connectionId: string; token: string } | null {
173
1041
  const computerId = request.headers.get('x-pal-computer-id')?.trim();
174
1042
  const connectionId = request.headers.get('x-pal-connection-id')?.trim();
@@ -195,12 +1063,73 @@ function requireDaemonConnection(store: MessageStore, request: Request): { compu
195
1063
  return connection;
196
1064
  }
197
1065
 
198
- export function route(store: MessageStore, request: Request): Promise<Response> | Response {
1066
+ function requireAgentScopedRead(store: MessageStore, request: Request, url: URL, roomId: string): void {
1067
+ const agent = stringParam(url, 'agent_scope');
1068
+ if (!agent) return;
1069
+ const connection = requireDaemonConnection(store, request);
1070
+ if (!store.daemonHasAgent(connection.connectionId, agent)) {
1071
+ throw new HttpError(403, 'CONNECTION_AGENT_NOT_REGISTERED', 'connection has not registered this agent');
1072
+ }
1073
+ const room = store.getChatById(roomId);
1074
+ if (!room) throw new HttpError(404, 'ROOM_NOT_FOUND', 'room not found');
1075
+ if (!store.canAgentParticipateInRoom(agent, room)) {
1076
+ throw new HttpError(403, 'ROOM_ACCESS_DENIED', 'agent cannot read this room');
1077
+ }
1078
+ }
1079
+
1080
+ async function publishMessageSideEffects(store: MessageStore, options: AppRouteOptions, message: Message, content: string): Promise<{ deliveries: MessageDelivery[]; notify: DeliveryNotifyResult }> {
1081
+ const deliveries = store.resolveDeliveriesForMessage(message.id);
1082
+ const notify = notifyDeliveries(options, deliveries);
1083
+
1084
+ const credentials = loadLarkCredentials();
1085
+ const outboundRoute = resolveLarkOutboundRoute(store, credentials, message.sender, message.chat_id);
1086
+ if (outboundRoute) {
1087
+ const { conversation, bot } = outboundRoute;
1088
+ try {
1089
+ const client = createLarkApiClient(bot.appId, bot.appSecret);
1090
+ const mention = resolveLarkMentionTarget(store, outboundRoute.account, message.recipient);
1091
+ const result = await sendTextMessage({
1092
+ client,
1093
+ receiveIdType: 'chat_id',
1094
+ receiveId: conversation.external_chat_id,
1095
+ text: larkOutboundMessageText(message, content),
1096
+ mention,
1097
+ });
1098
+ console.log(`[lark] forwarded message to chat=${conversation.external_chat_id} messageId=${result.messageId ?? '-'}`);
1099
+ } catch (err) {
1100
+ console.warn(`[lark] failed to forward message to chat=${conversation.external_chat_id}: ${err instanceof Error ? err.message : String(err)}`);
1101
+ }
1102
+ }
1103
+
1104
+ return { deliveries, notify };
1105
+ }
1106
+
1107
+ export function larkOutboundMessageText(message: Message, content: string): string {
1108
+ const attachments = message.attachments ?? [];
1109
+ if (attachments.length === 0) return content;
1110
+ const baseUrl = process.env.PAL_PUBLIC_BASE_URL ?? process.env.PAL_SERVER_PUBLIC_URL ?? '';
1111
+ const lines = attachments.map((attachment) => {
1112
+ const label = attachment.filename || attachment.id;
1113
+ const url = baseUrl
1114
+ ? `${baseUrl.replace(/\/+$/, '')}/api/message-attachments/${encodeURIComponent(attachment.id)}/content`
1115
+ : `/api/message-attachments/${encodeURIComponent(attachment.id)}/content`;
1116
+ return `- ${label} (${attachment.mime_type}, ${attachment.size_bytes} bytes): ${url}`;
1117
+ });
1118
+ const body = content.trim() || '[file]';
1119
+ return `${body}\n\nAttachments:\n${lines.join('\n')}`;
1120
+ }
1121
+
1122
+ export function route(store: MessageStore, request: Request, options: AppRouteOptions = {}): Promise<Response> | Response {
199
1123
  const url = new URL(request.url);
200
1124
  const { pathname } = url;
201
1125
 
202
1126
  if (request.method === 'GET' && pathname === '/') {
203
- return html(dashboardHtml());
1127
+ return builtWebIndex() ?? missingWebBuild();
1128
+ }
1129
+
1130
+ if (request.method === 'GET') {
1131
+ const asset = builtWebAsset(pathname);
1132
+ if (asset) return asset;
204
1133
  }
205
1134
 
206
1135
  if (request.method === 'GET' && pathname === '/health') {
@@ -222,6 +1151,71 @@ export function route(store: MessageStore, request: Request): Promise<Response>
222
1151
  return json({ computers: store.listComputers(numberParam(url, 'limit', 50)) });
223
1152
  }
224
1153
 
1154
+ const computerMatch = pathname.match(/^\/api\/computers\/([^/]+)$/);
1155
+ if (request.method === 'PATCH' && computerMatch) {
1156
+ return readJson<UpdateComputerBody>(request).then((body) => {
1157
+ const name = body.name?.trim();
1158
+ if (!name) throw new HttpError(400, 'MISSING_COMPUTER_NAME', 'name is required');
1159
+ try {
1160
+ const computer = store.updateComputerName(decodeURIComponent(computerMatch[1]!), name);
1161
+ return json({ computer });
1162
+ } catch (error) {
1163
+ const message = error instanceof Error ? error.message : String(error);
1164
+ if (message === 'computer not found') throw new HttpError(404, 'COMPUTER_NOT_FOUND', 'computer not found');
1165
+ throw error;
1166
+ }
1167
+ });
1168
+ }
1169
+
1170
+ const computerReconnectCommandMatch = pathname.match(/^\/api\/computers\/([^/]+)\/reconnect-command$/);
1171
+ if (request.method === 'POST' && computerReconnectCommandMatch) {
1172
+ return readJson<RegenerateComputerCommandBody>(request).then((body) => {
1173
+ try {
1174
+ const result = store.regenerateComputerCommand(decodeURIComponent(computerReconnectCommandMatch[1]!), {
1175
+ serverUrl: body.server_url,
1176
+ packageName: body.package_name,
1177
+ });
1178
+ return json(result);
1179
+ } catch (error) {
1180
+ const message = error instanceof Error ? error.message : String(error);
1181
+ if (message === 'computer not found') throw new HttpError(404, 'COMPUTER_NOT_FOUND', 'computer not found');
1182
+ throw error;
1183
+ }
1184
+ });
1185
+ }
1186
+
1187
+ const computerDeleteImpactMatch = pathname.match(/^\/api\/computers\/([^/]+)\/delete-impact$/);
1188
+ if (request.method === 'GET' && computerDeleteImpactMatch) {
1189
+ const impact = store.getComputerDeletionImpact(decodeURIComponent(computerDeleteImpactMatch[1]!));
1190
+ if (!impact) throw new HttpError(404, 'COMPUTER_NOT_FOUND', 'computer not found');
1191
+ return json({ impact });
1192
+ }
1193
+
1194
+ if (request.method === 'DELETE' && computerMatch) {
1195
+ return readOptionalJson<DeleteComputerBody>(request).then((body) => {
1196
+ try {
1197
+ const deletion = store.deleteComputer(decodeURIComponent(computerMatch[1]!), {
1198
+ removeProjects: body.remove_projects === true,
1199
+ removeAssignments: body.remove_agents === true,
1200
+ });
1201
+ return json({ ...deletion, deleted: true });
1202
+ } catch (error) {
1203
+ const message = error instanceof Error ? error.message : String(error);
1204
+ if (message === 'computer not found') throw new HttpError(404, 'COMPUTER_NOT_FOUND', 'computer not found');
1205
+ if (message === 'COMPUTER_HAS_PROJECTS') throw new HttpError(409, 'COMPUTER_HAS_PROJECTS', 'computer has projects');
1206
+ if (message === 'COMPUTER_HAS_ASSIGNMENTS') throw new HttpError(409, 'COMPUTER_HAS_ASSIGNMENTS', 'computer has agent assignments');
1207
+ throw error;
1208
+ }
1209
+ });
1210
+ }
1211
+
1212
+ const computerWorkloadMatch = pathname.match(/^\/api\/computers\/([^/]+)\/workload$/);
1213
+ if (request.method === 'GET' && computerWorkloadMatch) {
1214
+ const workload = store.getComputerWorkload(decodeURIComponent(computerWorkloadMatch[1]!));
1215
+ if (!workload) throw new HttpError(404, 'COMPUTER_NOT_FOUND', 'computer not found');
1216
+ return json({ workload });
1217
+ }
1218
+
225
1219
  if (request.method === 'POST' && pathname === '/api/computers/provision') {
226
1220
  return readJson<ProvisionComputerBody>(request).then((body) => {
227
1221
  const result = store.provisionComputer({
@@ -251,7 +1245,14 @@ export function route(store: MessageStore, request: Request): Promise<Response>
251
1245
  capabilities: agent.capabilities,
252
1246
  })),
253
1247
  });
254
- return json(result, 201);
1248
+ return json({
1249
+ computer: result.computer,
1250
+ connection: result.connection,
1251
+ token: result.token,
1252
+ local_control_token: result.localControlToken,
1253
+ daemon: result.daemon,
1254
+ agents: result.agents,
1255
+ }, 201);
255
1256
  });
256
1257
  }
257
1258
 
@@ -306,35 +1307,418 @@ export function route(store: MessageStore, request: Request): Promise<Response>
306
1307
  content,
307
1308
  ttlSeconds: body.ttl_seconds,
308
1309
  });
309
- return json({ artifact: result.artifact, token: result.token, url: `/artifacts/${result.token}` }, 201);
1310
+ return json({ artifact: result.artifact, token: result.token, url: `/artifacts/${result.token}` }, 201);
1311
+ });
1312
+ }
1313
+
1314
+ if (request.method === 'POST' && pathname === '/api/artifacts/cleanup') {
1315
+ assertServerAuth(request);
1316
+ return json({ deleted: store.cleanupArtifacts() });
1317
+ }
1318
+
1319
+ const artifactRevokeMatch = pathname.match(/^\/api\/artifacts\/([^/]+)\/revoke$/);
1320
+ if (request.method === 'POST' && artifactRevokeMatch) {
1321
+ assertServerAuth(request);
1322
+ return json({ artifact: store.revokeArtifact(artifactRevokeMatch[1]!) });
1323
+ }
1324
+
1325
+ if (request.method === 'GET' && pathname === '/api/chats') {
1326
+ return json({ chats: store.listChats() });
1327
+ }
1328
+
1329
+ if (request.method === 'GET' && pathname === '/api/rooms') {
1330
+ const scopedAgent = stringParam(url, 'agent_scope');
1331
+ if (scopedAgent) {
1332
+ const connection = requireDaemonConnection(store, request);
1333
+ if (!store.daemonHasAgent(connection.connectionId, scopedAgent)) {
1334
+ throw new HttpError(403, 'CONNECTION_AGENT_NOT_REGISTERED', 'connection has not registered this agent');
1335
+ }
1336
+ return json({ rooms: store.listChatsForAgent(scopedAgent) });
1337
+ }
1338
+ return json({ rooms: store.listChats() });
1339
+ }
1340
+
1341
+ if (request.method === 'GET' && pathname === '/api/projects') {
1342
+ return json({ projects: store.listProjects(numberParam(url, 'limit', 50)) });
1343
+ }
1344
+
1345
+ if (request.method === 'POST' && pathname === '/api/projects') {
1346
+ return readJson<CreateProjectBody>(request).then(async (body) => {
1347
+ const name = body.name?.trim();
1348
+ const computerId = body.computer_id?.trim();
1349
+ const rootPath = body.root_path?.trim();
1350
+ if (!name) throw new HttpError(400, 'MISSING_PROJECT_NAME', 'name is required');
1351
+ if (!computerId) throw new HttpError(400, 'MISSING_COMPUTER_ID', 'computer_id is required');
1352
+ if (!rootPath) throw new HttpError(400, 'MISSING_ROOT_PATH', 'root_path is required');
1353
+ await validateRemoteProjectPath(store, computerId, rootPath);
1354
+ const project = store.createProject({ name, computerId, rootPath });
1355
+ return json({ project }, 201);
1356
+ });
1357
+ }
1358
+
1359
+ const projectMatch = pathname.match(/^\/api\/projects\/([^/]+)$/);
1360
+ if (request.method === 'PATCH' && projectMatch) {
1361
+ return readJson<UpdateProjectBody>(request).then((body) => {
1362
+ const project = store.getProject(decodeURIComponent(projectMatch[1]!));
1363
+ if (!project) throw new HttpError(404, 'PROJECT_NOT_FOUND', 'project not found');
1364
+ if (body.status !== 'active' && body.status !== 'archived') throw new HttpError(400, 'BAD_PROJECT_STATUS', 'status must be active or archived');
1365
+ return json({ project: store.updateProjectStatus(project.id, body.status) });
1366
+ });
1367
+ }
1368
+
1369
+ if (request.method === 'DELETE' && projectMatch) {
1370
+ const project = store.getProject(decodeURIComponent(projectMatch[1]!));
1371
+ if (!project) throw new HttpError(404, 'PROJECT_NOT_FOUND', 'project not found');
1372
+ try {
1373
+ const deleted = store.deleteProject(project.id);
1374
+ return json({ project: deleted, deleted: true });
1375
+ } catch (error) {
1376
+ const message = error instanceof Error ? error.message : String(error);
1377
+ if (message.startsWith('PROJECT_NOT_ARCHIVED')) throw new HttpError(409, 'PROJECT_NOT_ARCHIVED', 'project must be archived before deletion');
1378
+ throw error;
1379
+ }
1380
+ }
1381
+
1382
+ const projectAgentDefaultsMatch = pathname.match(/^\/api\/projects\/([^/]+)\/agent-defaults$/);
1383
+ if (request.method === 'GET' && projectAgentDefaultsMatch) {
1384
+ const project = store.getProject(decodeURIComponent(projectAgentDefaultsMatch[1]!));
1385
+ if (!project) throw new HttpError(404, 'PROJECT_NOT_FOUND', 'project not found');
1386
+ return json({ project, defaults: store.listProjectAgentDefaults(project.id) });
1387
+ }
1388
+
1389
+ if (request.method === 'PUT' && projectAgentDefaultsMatch) {
1390
+ return readJson<ProjectAgentDefaultsBody>(request).then((body) => {
1391
+ const project = store.getProject(decodeURIComponent(projectAgentDefaultsMatch[1]!));
1392
+ if (!project) throw new HttpError(404, 'PROJECT_NOT_FOUND', 'project not found');
1393
+ const defaults = body.defaults;
1394
+ if (!Array.isArray(defaults)) throw new HttpError(400, 'BAD_PROJECT_AGENT_DEFAULTS', 'defaults must be an array');
1395
+ return json({
1396
+ project,
1397
+ defaults: store.setProjectAgentDefaults(project.id, defaults.map((item) => {
1398
+ if (!item.agent?.trim()) throw new HttpError(400, 'MISSING_AGENT', 'agent is required');
1399
+ return { agent: item.agent, mode: parseAgentRoomSubscriptionMode(item.mode ?? 'mentions') };
1400
+ })),
1401
+ });
1402
+ });
1403
+ }
1404
+
1405
+ const computerFilesMatch = pathname.match(/^\/api\/computers\/([^/]+)\/files$/);
1406
+ if (request.method === 'GET' && computerFilesMatch) {
1407
+ const computerId = decodeURIComponent(computerFilesMatch[1]!);
1408
+ const control = store.getComputerLocalControl(computerId);
1409
+ if (!control) throw new HttpError(409, 'COMPUTER_NOT_BROWSABLE', 'computer is not online or does not expose a local filesystem browser');
1410
+ const params = new URLSearchParams();
1411
+ const browsePath = stringParam(url, 'path');
1412
+ if (browsePath) params.set('path', browsePath);
1413
+ if (url.searchParams.get('show_hidden') === 'true') params.set('show_hidden', 'true');
1414
+ return fetchDaemonJson({ localUrl: control.daemon.local_url, token: control.token, path: `/local/files${params.size ? `?${params}` : ''}` })
1415
+ .then((data) => json(data));
1416
+ }
1417
+
1418
+ if (request.method === 'POST' && pathname === '/api/rooms') {
1419
+ return readJson<CreateRoomBody>(request).then((body) => {
1420
+ if (!body.name?.trim()) throw new HttpError(400, 'MISSING_ROOM_NAME', 'name is required');
1421
+ if (body.kind && body.kind !== 'group') throw new HttpError(400, 'BAD_ROOM_KIND', 'web rooms are always group rooms');
1422
+ const existing = store.getChatByName(body.name);
1423
+ if (existing) throw new HttpError(409, 'ROOM_NAME_EXISTS', `room name already exists: ${existing.name}`);
1424
+ const room = store.getOrCreateChat(body.name, 'group');
1425
+ return json({ room }, 201);
1426
+ });
1427
+ }
1428
+
1429
+ if (request.method === 'POST' && pathname === '/api/rooms/handoff') {
1430
+ return readJson<HandoffRoomBody>(request).then(async (body) => {
1431
+ const sourceRoomId = body.source_room_id?.trim();
1432
+ const actor = body.actor?.trim();
1433
+ const purpose = body.purpose?.trim();
1434
+ const roomName = body.name?.trim();
1435
+ if (!sourceRoomId) throw new HttpError(400, 'MISSING_SOURCE_ROOM', 'source_room_id is required');
1436
+ if (!actor) throw new HttpError(400, 'MISSING_ACTOR', 'actor is required');
1437
+ if (!purpose) throw new HttpError(400, 'MISSING_PURPOSE', 'purpose is required');
1438
+ if (!roomName) throw new HttpError(400, 'MISSING_ROOM_NAME', 'name is required');
1439
+ if (!store.getAgent(actor)) throw new HttpError(404, 'AGENT_NOT_FOUND', 'actor agent not found');
1440
+
1441
+ const sourceRoom = store.getChatById(sourceRoomId);
1442
+ if (!sourceRoom) throw new HttpError(404, 'SOURCE_ROOM_NOT_FOUND', 'source room not found');
1443
+ if (!store.canAgentParticipateInRoom(actor, sourceRoom)) {
1444
+ throw new HttpError(403, 'ROOM_ACCESS_DENIED', 'actor cannot read the source room');
1445
+ }
1446
+ if (body.source_message_id !== undefined) {
1447
+ const sourceMessage = store.getMessage(Number(body.source_message_id));
1448
+ if (!sourceMessage || sourceMessage.chat_id !== sourceRoom.id) {
1449
+ throw new HttpError(404, 'SOURCE_MESSAGE_NOT_FOUND', 'source message was not found in source room');
1450
+ }
1451
+ }
1452
+
1453
+ const teamWasExplicit = Array.isArray(body.team);
1454
+ const team = teamWasExplicit
1455
+ ? body.team!.map((item) => {
1456
+ if (!item.agent?.trim()) throw new HttpError(400, 'MISSING_AGENT', 'team agent is required');
1457
+ return { agent: item.agent.trim(), mode: parseAgentRoomSubscriptionMode(item.mode ?? 'mentions') };
1458
+ })
1459
+ : sourceRoom.project_id
1460
+ ? null
1461
+ : store.listAgentRoomSubscriptions(sourceRoom.id).map((item) => ({ agent: item.agent, mode: item.mode }));
1462
+
1463
+ let room: Chat | null;
1464
+ if (sourceRoom.project_id) {
1465
+ room = store.createProjectRoom({ projectId: sourceRoom.project_id, name: roomName, kind: 'group' });
1466
+ } else {
1467
+ if (store.getChatByName(roomName)) throw new HttpError(409, 'ROOM_NAME_EXISTS', `room name already exists: ${roomName}`);
1468
+ room = store.getOrCreateChat(roomName, 'group');
1469
+ }
1470
+ if (!room) throw new HttpError(500, 'ROOM_CREATE_FAILED', 'room was not created');
1471
+
1472
+ if (teamWasExplicit || team) {
1473
+ const desired = new Map((team ?? []).map((item) => [item.agent, item.mode]));
1474
+ for (const existing of store.listAgentRoomSubscriptions(room.id)) {
1475
+ if (!desired.has(existing.agent)) store.leaveAgentRoom({ roomId: room.id, agent: existing.agent });
1476
+ }
1477
+ for (const [agent, mode] of desired) {
1478
+ store.inviteAgentToRoom({ roomId: room.id, agent, mode });
1479
+ }
1480
+ }
1481
+
1482
+ const content = body.handoff_content?.trim();
1483
+ if (content) {
1484
+ const message = store.createMessage({
1485
+ chatId: room.id,
1486
+ sender: 'system',
1487
+ content,
1488
+ type: 'system',
1489
+ mentions: [actor],
1490
+ });
1491
+ const sideEffects = await publishMessageSideEffects(store, options, message, content);
1492
+ return json({ room: store.getChatById(room.id), message, deliveries: sideEffects.deliveries, notify: sideEffects.notify }, 201);
1493
+ }
1494
+ return json({ room: store.getChatById(room.id), message: null, deliveries: [], notify: null }, 201);
1495
+ });
1496
+ }
1497
+
1498
+ const roomMatch = pathname.match(/^\/api\/rooms\/([^/]+)$/);
1499
+ if (request.method === 'PATCH' && roomMatch) {
1500
+ return readJson<UpdateRoomBody>(request).then((body) => {
1501
+ const room = store.resolveRoom(decodeURIComponent(roomMatch[1]!));
1502
+ if (!room) throw new HttpError(404, 'ROOM_NOT_FOUND', 'room not found');
1503
+ if (room.provider !== 'web') throw new HttpError(403, 'EXTERNAL_ROOM_STATUS_READ_ONLY', 'external provider rooms cannot be archived from Web');
1504
+ if (body.status !== 'active' && body.status !== 'archived') throw new HttpError(400, 'BAD_ROOM_STATUS', 'status must be active or archived');
1505
+ return json({ room: store.updateRoomStatus(room.id, body.status) });
1506
+ });
1507
+ }
1508
+
1509
+ if (request.method === 'DELETE' && roomMatch) {
1510
+ const room = store.resolveRoom(decodeURIComponent(roomMatch[1]!));
1511
+ if (!room) throw new HttpError(404, 'ROOM_NOT_FOUND', 'room not found');
1512
+ try {
1513
+ const deleted = store.deleteRoom(room.id);
1514
+ return json({ room: deleted, deleted: true });
1515
+ } catch (error) {
1516
+ const message = error instanceof Error ? error.message : String(error);
1517
+ if (message.startsWith('EXTERNAL_ROOM_DELETE_READ_ONLY')) throw new HttpError(403, 'EXTERNAL_ROOM_DELETE_READ_ONLY', 'external provider rooms cannot be deleted from Web');
1518
+ if (message.startsWith('ROOM_NOT_ARCHIVED')) throw new HttpError(409, 'ROOM_NOT_ARCHIVED', 'room must be archived before deletion');
1519
+ throw error;
1520
+ }
1521
+ }
1522
+
1523
+ const roomProjectPalMatch = pathname.match(/^\/api\/rooms\/([^/]+)\/project-pal$/);
1524
+ if (request.method === 'GET' && roomProjectPalMatch) {
1525
+ const room = store.resolveRoom(decodeURIComponent(roomProjectPalMatch[1]!));
1526
+ if (!room) throw new HttpError(404, 'ROOM_NOT_FOUND', 'room not found');
1527
+ if (!room.project_id || !room.project_root_path) throw new HttpError(404, 'ROOM_PROJECT_NOT_FOUND', 'room is not bound to a project');
1528
+ const files = listProjectPalFiles(room.project_root_path);
1529
+ return json({
1530
+ room,
1531
+ project: {
1532
+ id: room.project_id,
1533
+ name: room.project_name,
1534
+ root_path: room.project_root_path,
1535
+ },
1536
+ pal_root_path: files.rootPath,
1537
+ entries: files.entries,
1538
+ supported_file_count: files.supportedFileCount,
1539
+ truncated: files.truncated,
1540
+ });
1541
+ }
1542
+
1543
+ const roomProjectPalContentMatch = pathname.match(/^\/api\/rooms\/([^/]+)\/project-pal\/content$/);
1544
+ if (request.method === 'GET' && roomProjectPalContentMatch) {
1545
+ const room = store.resolveRoom(decodeURIComponent(roomProjectPalContentMatch[1]!));
1546
+ if (!room) throw new HttpError(404, 'ROOM_NOT_FOUND', 'room not found');
1547
+ if (!room.project_id || !room.project_root_path) throw new HttpError(404, 'ROOM_PROJECT_NOT_FOUND', 'room is not bound to a project');
1548
+ return json({
1549
+ room,
1550
+ file: readProjectPalFile(room.project_root_path, stringParam(url, 'path')),
1551
+ });
1552
+ }
1553
+
1554
+ const roomProjectPalGitStatusMatch = pathname.match(/^\/api\/rooms\/([^/]+)\/project-pal\/git-status$/);
1555
+ if (request.method === 'GET' && roomProjectPalGitStatusMatch) {
1556
+ const room = store.resolveRoom(decodeURIComponent(roomProjectPalGitStatusMatch[1]!));
1557
+ if (!room) throw new HttpError(404, 'ROOM_NOT_FOUND', 'room not found');
1558
+ if (!room.project_id || !room.project_root_path) throw new HttpError(404, 'ROOM_PROJECT_NOT_FOUND', 'room is not bound to a project');
1559
+ return json({
1560
+ room,
1561
+ changes: projectPalGitChanges(room.project_root_path),
1562
+ });
1563
+ }
1564
+
1565
+ const roomProjectPalGitDiffMatch = pathname.match(/^\/api\/rooms\/([^/]+)\/project-pal\/git-diff$/);
1566
+ if (request.method === 'GET' && roomProjectPalGitDiffMatch) {
1567
+ const room = store.resolveRoom(decodeURIComponent(roomProjectPalGitDiffMatch[1]!));
1568
+ if (!room) throw new HttpError(404, 'ROOM_NOT_FOUND', 'room not found');
1569
+ if (!room.project_id || !room.project_root_path) throw new HttpError(404, 'ROOM_PROJECT_NOT_FOUND', 'room is not bound to a project');
1570
+ return json({
1571
+ room,
1572
+ changes: projectPalGitChanges(room.project_root_path, { includeDiff: true }),
1573
+ });
1574
+ }
1575
+
1576
+ const roomProjectPalGitCommitMatch = pathname.match(/^\/api\/rooms\/([^/]+)\/project-pal\/git-commit$/);
1577
+ if (request.method === 'POST' && roomProjectPalGitCommitMatch) {
1578
+ return readJson<ProjectPalGitCommitBody>(request).then((body) => {
1579
+ const room = store.resolveRoom(decodeURIComponent(roomProjectPalGitCommitMatch[1]!));
1580
+ if (!room) throw new HttpError(404, 'ROOM_NOT_FOUND', 'room not found');
1581
+ if (!room.project_id || !room.project_root_path) throw new HttpError(404, 'ROOM_PROJECT_NOT_FOUND', 'room is not bound to a project');
1582
+ const projectRootPath = room.project_root_path;
1583
+ const message = body.message?.trim();
1584
+ if (!message) throw new HttpError(400, 'MISSING_COMMIT_MESSAGE', 'commit message is required');
1585
+
1586
+ const info = projectPalGitInfo(projectRootPath);
1587
+ if (!info.git.available || !info.git.head) throw new HttpError(409, 'GIT_NOT_AVAILABLE', info.git.error || 'project is not inside a Git repository with a HEAD commit');
1588
+ const expectedHead = body.expected_head?.trim();
1589
+ if (expectedHead && expectedHead !== info.git.head) throw new HttpError(409, 'GIT_HEAD_CHANGED', 'Git HEAD changed; refresh changes before committing');
1590
+
1591
+ const selectedPaths = body.files?.length
1592
+ ? body.files
1593
+ : projectPalGitChanges(projectRootPath).files.map((file) => file.path);
1594
+ const repoPaths = Array.from(new Set(selectedPaths.map((filePath) => projectPalCommitPath(projectRootPath, filePath))));
1595
+ if (!repoPaths.length) throw new HttpError(400, 'NO_COMMIT_FILES', 'no .pal Markdown files were selected');
1596
+
1597
+ const status = projectPalGitChanges(projectRootPath);
1598
+ const dirtyRepoPaths = new Set(status.files.map((file) => file.repo_path));
1599
+ const filteredRepoPaths = repoPaths.filter((repoPath) => dirtyRepoPaths.has(repoPath));
1600
+ if (!filteredRepoPaths.length) throw new HttpError(409, 'NO_COMMIT_CHANGES', 'selected .pal Markdown files have no changes to commit');
1601
+
1602
+ gitCommand(projectRootPath, ['add', '--', ...filteredRepoPaths]);
1603
+ gitCommand(projectRootPath, ['commit', '-m', message, '--', ...filteredRepoPaths]);
1604
+ const committedHead = gitCommand(projectRootPath, ['rev-parse', 'HEAD']).stdout.trim();
1605
+ return json({
1606
+ room,
1607
+ commit: {
1608
+ previous_head: info.git.head,
1609
+ head: committedHead,
1610
+ message,
1611
+ files: filteredRepoPaths,
1612
+ },
1613
+ changes: projectPalGitChanges(projectRootPath, { includeDiff: true }),
1614
+ });
310
1615
  });
311
1616
  }
312
1617
 
313
- if (request.method === 'POST' && pathname === '/api/artifacts/cleanup') {
314
- assertServerAuth(request);
315
- return json({ deleted: store.cleanupArtifacts() });
1618
+ const projectRoomsMatch = pathname.match(/^\/api\/projects\/([^/]+)\/rooms$/);
1619
+ if (request.method === 'POST' && projectRoomsMatch) {
1620
+ return readJson<CreateRoomBody>(request).then((body) => {
1621
+ if (!body.name?.trim()) throw new HttpError(400, 'MISSING_ROOM_NAME', 'name is required');
1622
+ if (body.kind && body.kind !== 'group') throw new HttpError(400, 'BAD_ROOM_KIND', 'web rooms are always group rooms');
1623
+ const room = store.createProjectRoom({
1624
+ projectId: decodeURIComponent(projectRoomsMatch[1]!),
1625
+ name: body.name,
1626
+ kind: 'group',
1627
+ mode: parseRoomMode(body.mode),
1628
+ });
1629
+ return json({ room }, 201);
1630
+ });
316
1631
  }
317
1632
 
318
- const artifactRevokeMatch = pathname.match(/^\/api\/artifacts\/([^/]+)\/revoke$/);
319
- if (request.method === 'POST' && artifactRevokeMatch) {
320
- assertServerAuth(request);
321
- return json({ artifact: store.revokeArtifact(artifactRevokeMatch[1]!) });
1633
+ if (request.method === 'GET' && pathname === '/api/skills') {
1634
+ const source = stringParam(url, 'source') as SkillSource | undefined;
1635
+ const scope = stringParam(url, 'scope') as SkillBindingScope | undefined;
1636
+ const scopeId = stringParam(url, 'scope_id');
1637
+ return json({
1638
+ skills: store.listSkillDefinitions({
1639
+ source: source ? parseSkillSource(source) : undefined,
1640
+ ownerUserId: url.searchParams.has('owner_user_id') ? stringParam(url, 'owner_user_id') : undefined,
1641
+ projectId: url.searchParams.has('project_id') ? stringParam(url, 'project_id') : undefined,
1642
+ includeDisabled: stringParam(url, 'include_disabled') === 'true',
1643
+ }),
1644
+ bindings: store.listSkillBindings({
1645
+ scope: scope ? parseSkillBindingScope(scope) : undefined,
1646
+ scopeId: scopeId ?? undefined,
1647
+ }),
1648
+ enabled_skills: store.listEnabledSkills({
1649
+ projectId: stringParam(url, 'enabled_project_id'),
1650
+ roomId: stringParam(url, 'enabled_room_id'),
1651
+ agent: stringParam(url, 'enabled_agent'),
1652
+ }),
1653
+ native_runtime_warning: 'PAL-managed skills are injected by PAL for project, room, and agent context. Native runtime-discovered global skills from Codex, OpenCode, or other adapters may still be loaded by those runtimes and may not be fully blockable from PAL Web; keep native global skill directories minimal.',
1654
+ activation_scopes: skillBindingScopes,
1655
+ repository_sources: skillSources,
1656
+ });
322
1657
  }
323
1658
 
324
- if (request.method === 'GET' && pathname === '/api/chats') {
325
- return json({ chats: store.listChats() });
1659
+ if (request.method === 'POST' && pathname === '/api/skills') {
1660
+ return readJson<UpsertSkillBody>(request).then((body) => {
1661
+ try {
1662
+ const source = parseSkillSource(body.source);
1663
+ const skill = store.createSkillDefinition({
1664
+ key: body.key ?? '',
1665
+ name: body.name ?? '',
1666
+ description: body.description ?? '',
1667
+ instructionContent: body.instruction_content ?? '',
1668
+ status: body.status,
1669
+ source,
1670
+ ownerUserId: source === 'user' ? body.owner_user_id ?? 'owner' : body.owner_user_id ?? null,
1671
+ projectId: body.project_id ?? null,
1672
+ repositoryPath: body.repository_path ?? null,
1673
+ });
1674
+ return json({ skill }, 201);
1675
+ } catch (error) {
1676
+ throw skillStoreHttpError(error) ?? error;
1677
+ }
1678
+ });
326
1679
  }
327
1680
 
328
- if (request.method === 'GET' && pathname === '/api/rooms') {
329
- return json({ rooms: store.listChats() });
1681
+ const skillDefinitionMatch = pathname.match(/^\/api\/skills\/([^/]+)$/);
1682
+ if (request.method === 'PATCH' && skillDefinitionMatch) {
1683
+ return readJson<UpsertSkillBody>(request).then((body) => {
1684
+ try {
1685
+ const skill = store.updateSkillDefinition(decodeURIComponent(skillDefinitionMatch[1]!), {
1686
+ name: body.name,
1687
+ description: body.description,
1688
+ instructionContent: body.instruction_content,
1689
+ status: body.status,
1690
+ ownerUserId: body.owner_user_id,
1691
+ projectId: body.project_id,
1692
+ repositoryPath: body.repository_path,
1693
+ });
1694
+ return json({ skill });
1695
+ } catch (error) {
1696
+ throw skillStoreHttpError(error) ?? error;
1697
+ }
1698
+ });
330
1699
  }
331
1700
 
332
- if (request.method === 'POST' && pathname === '/api/rooms') {
333
- return readJson<CreateRoomBody>(request).then((body) => {
334
- if (!body.name?.trim()) throw new HttpError(400, 'MISSING_ROOM_NAME', 'name is required');
335
- if (body.kind && body.kind !== 'group' && body.kind !== 'dm') throw new HttpError(400, 'BAD_ROOM_KIND', 'kind must be group or dm');
336
- const room = store.getOrCreateChat(body.name, body.kind ?? 'group');
337
- return json({ room }, 201);
1701
+ if (request.method === 'PUT' && pathname === '/api/skill-bindings') {
1702
+ return readJson<SkillBindingBody>(request).then((body) => {
1703
+ const binding = store.upsertSkillBinding({
1704
+ scope: parseSkillBindingScope(body.scope),
1705
+ scopeId: body.scope_id ?? '',
1706
+ skillKey: body.skill_key ?? '',
1707
+ enabled: body.enabled,
1708
+ priority: Number.isInteger(body.priority) ? body.priority : undefined,
1709
+ });
1710
+ return json({ binding });
1711
+ });
1712
+ }
1713
+
1714
+ if (request.method === 'DELETE' && pathname === '/api/skill-bindings') {
1715
+ return readJson<SkillBindingBody>(request).then((body) => {
1716
+ const deleted = store.deleteSkillBinding({
1717
+ scope: parseSkillBindingScope(body.scope),
1718
+ scopeId: body.scope_id ?? '',
1719
+ skillKey: body.skill_key ?? '',
1720
+ });
1721
+ return json({ deleted });
338
1722
  });
339
1723
  }
340
1724
 
@@ -345,6 +1729,12 @@ export function route(store: MessageStore, request: Request): Promise<Response>
345
1729
  return json({
346
1730
  room,
347
1731
  participants: store.listRoomParticipants(room.id),
1732
+ agent_subscriptions: store.listAgentRoomSubscriptions(room.id),
1733
+ enabled_skills: store.listEnabledSkills({
1734
+ projectId: room.project_id,
1735
+ roomId: room.id,
1736
+ agent: stringParam(url, 'agent'),
1737
+ }),
348
1738
  completeness: 'Human members come from lark_member_api snapshots. Bot members are known/observed only; Feishu member-list API filters bots.',
349
1739
  });
350
1740
  }
@@ -362,6 +1752,18 @@ export function route(store: MessageStore, request: Request): Promise<Response>
362
1752
  });
363
1753
  }
364
1754
 
1755
+ const roomAgentActivityMatch = pathname.match(/^\/api\/rooms\/([^/]+)\/agent-activity$/);
1756
+ if (request.method === 'GET' && roomAgentActivityMatch) {
1757
+ const room = store.resolveRoom(decodeURIComponent(roomAgentActivityMatch[1]!));
1758
+ if (!room) throw new HttpError(404, 'ROOM_NOT_FOUND', 'room not found');
1759
+ const agent = stringParam(url, 'agent');
1760
+ return json({
1761
+ room,
1762
+ activity: store.listRoomAgentActivity(room.id),
1763
+ events: agent ? store.listActivityForRoomAgent(room.id, agent, numberParam(url, 'limit', 80)) : [],
1764
+ });
1765
+ }
1766
+
365
1767
  const transcriptReadMatch = pathname.match(/^\/api\/transcripts\/([^/]+)\/messages$/);
366
1768
  if (request.method === 'GET' && transcriptReadMatch) {
367
1769
  return json({ messages: store.listTranscriptMessagesReadOnly(transcriptReadMatch[1]!, numberParam(url, 'limit', 50)) });
@@ -371,17 +1773,85 @@ export function route(store: MessageStore, request: Request): Promise<Response>
371
1773
  return json({ agents: store.listAgents(numberParam(url, 'limit', 50)) });
372
1774
  }
373
1775
 
1776
+ const agentWorkbenchMatch = pathname.match(/^\/api\/agents\/([^/]+)\/workbench$/);
1777
+ if (request.method === 'GET' && agentWorkbenchMatch) {
1778
+ const workbench = store.getAgentWorkbench(decodeURIComponent(agentWorkbenchMatch[1]!));
1779
+ if (!workbench) throw new HttpError(404, 'AGENT_NOT_FOUND', 'agent not found');
1780
+ return json({ workbench });
1781
+ }
1782
+
1783
+ const agentWorkspaceFilesMatch = pathname.match(/^\/api\/agents\/([^/]+)\/workspace-files$/);
1784
+ if (request.method === 'GET' && agentWorkspaceFilesMatch) {
1785
+ const agent = store.getAgent(decodeURIComponent(agentWorkspaceFilesMatch[1]!));
1786
+ if (!agent) throw new HttpError(404, 'AGENT_NOT_FOUND', 'agent not found');
1787
+ return json({ files: listAgentWorkspaceFiles(agent) });
1788
+ }
1789
+
1790
+ if (request.method === 'PUT' && agentWorkspaceFilesMatch) {
1791
+ return readJson<AgentWorkspaceFileUpdateBody>(request).then((body) => {
1792
+ const agent = store.getAgent(decodeURIComponent(agentWorkspaceFilesMatch[1]!));
1793
+ if (!agent) throw new HttpError(404, 'AGENT_NOT_FOUND', 'agent not found');
1794
+ if (typeof body.path !== 'string' || typeof body.content !== 'string') {
1795
+ throw new HttpError(400, 'WORKSPACE_FILE_UPDATE_INVALID', 'workspace file update requires path and content');
1796
+ }
1797
+ try {
1798
+ return json({ file: updateAgentWorkspaceFile(agent, body.path, body.content) });
1799
+ } catch (err) {
1800
+ if (err instanceof AgentWorkspaceFileError) {
1801
+ throw new HttpError(err.status, err.code, err.message);
1802
+ }
1803
+ throw err;
1804
+ }
1805
+ });
1806
+ }
1807
+
1808
+ const agentPermissionsMatch = pathname.match(/^\/api\/agents\/([^/]+)\/permissions$/);
1809
+ if (request.method === 'GET' && agentPermissionsMatch) {
1810
+ const agentKey = decodeURIComponent(agentPermissionsMatch[1]!);
1811
+ if (!store.getAgent(agentKey)) throw new HttpError(404, 'AGENT_NOT_FOUND', `agent ${agentKey} not found`);
1812
+ return json({ profile: store.getAgentPermissionProfile(agentKey) });
1813
+ }
1814
+
1815
+ if (request.method === 'PUT' && agentPermissionsMatch) {
1816
+ return readJson<AgentPermissionsBody>(request).then((body) => {
1817
+ const agentKey = decodeURIComponent(agentPermissionsMatch[1]!);
1818
+ if (!store.getAgent(agentKey)) throw new HttpError(404, 'AGENT_NOT_FOUND', `agent ${agentKey} not found`);
1819
+ const validation = validateAgentPermissionProfile(body);
1820
+ if (!validation.ok) {
1821
+ throw new HttpError(400, 'PERMISSION_PROFILE_INVALID', validation.diagnostics.filter((diagnostic) => diagnostic.level === 'error').map((diagnostic) => diagnostic.message).join('; '));
1822
+ }
1823
+ const profile = store.upsertAgentPermissionProfile(agentKey, validation.profile);
1824
+ return json({ profile, warnings: validation.warnings });
1825
+ });
1826
+ }
1827
+
1828
+ const agentPermissionsValidateMatch = pathname.match(/^\/api\/agents\/([^/]+)\/permissions\/validate$/);
1829
+ if (request.method === 'POST' && agentPermissionsValidateMatch) {
1830
+ return readJson<AgentPermissionsBody>(request).then((body) => {
1831
+ const agentKey = decodeURIComponent(agentPermissionsValidateMatch[1]!);
1832
+ if (!store.getAgent(agentKey)) throw new HttpError(404, 'AGENT_NOT_FOUND', `agent ${agentKey} not found`);
1833
+ const validation = validateAgentPermissionProfile(body, { checkFilesystem: url.searchParams.get('check_filesystem') === '1' });
1834
+ return json(validation);
1835
+ });
1836
+ }
1837
+
1838
+ if (request.method === 'GET' && pathname === '/api/runtime-models') {
1839
+ return allRuntimeModelOptions().then((runtimes) => json({ runtimes }));
1840
+ }
1841
+
374
1842
  if (request.method === 'POST' && pathname === '/api/agents/onboard') {
375
- return readJson<OnboardAgentBody>(request).then((body) => {
376
- const agentKey = body.agent_key?.trim();
1843
+ return readJson<OnboardAgentBody>(request).then(async (body) => {
377
1844
  const displayName = body.display_name?.trim();
378
- if (!agentKey) throw new HttpError(400, 'MISSING_AGENT_KEY', 'agent_key is required');
379
1845
  if (!displayName) throw new HttpError(400, 'MISSING_DISPLAY_NAME', 'display_name is required');
1846
+ const agentKey = body.agent_key?.trim() || uniqueAgentKeyFromDisplayName(displayName, store);
1847
+ if (store.getAgent(agentKey)) throw new HttpError(409, 'AGENT_KEY_EXISTS', `agent ${agentKey} already exists`);
1848
+ const runtime = body.runtime ?? 'codex';
380
1849
  const agent = store.upsertAgent({
381
1850
  agent_key: agentKey,
382
1851
  display_name: displayName,
383
1852
  description: body.description ?? null,
384
- runtime: body.runtime ?? 'codex',
1853
+ runtime,
1854
+ model: await checkedRuntimeModel(runtime, body.model),
385
1855
  });
386
1856
  const assignment = body.computer_id?.trim()
387
1857
  ? store.assignAgentToComputer({ agent: agentKey, computerId: body.computer_id })
@@ -391,33 +1861,97 @@ export function route(store: MessageStore, request: Request): Promise<Response>
391
1861
  }
392
1862
 
393
1863
  if (request.method === 'POST' && pathname === '/api/agents') {
394
- return readJson<Record<string, unknown>>(request).then((body) => {
1864
+ return readJson<Record<string, unknown>>(request).then(async (body) => {
395
1865
  const validation = store.validateAgentSchema(body);
396
1866
  if (!validation.ok) {
397
1867
  throw new HttpError(400, 'VALIDATION_ERROR', validation.diagnostics.map((d) => `${d.code}: ${d.message}`).join('; '));
398
1868
  }
1869
+ if (store.getAgent(String(body.agent_key))) throw new HttpError(409, 'AGENT_KEY_EXISTS', `agent ${String(body.agent_key)} already exists`);
1870
+ const runtime = body.runtime === null ? null : typeof body.runtime === 'string' ? body.runtime : null;
1871
+ const model = body.model === null ? null : typeof body.model === 'string' ? body.model : null;
399
1872
  const agent = store.upsertAgent({
400
1873
  id: typeof body.id === 'string' ? body.id : undefined,
401
1874
  agent_key: String(body.agent_key),
402
1875
  display_name: String(body.display_name),
403
1876
  description: body.description === null ? null : typeof body.description === 'string' ? body.description : null,
404
- runtime: body.runtime === null ? null : typeof body.runtime === 'string' ? body.runtime : null,
1877
+ runtime,
1878
+ model: await checkedRuntimeModel(runtime, model),
405
1879
  });
406
1880
  return json({ agent }, 201);
407
1881
  });
408
1882
  }
409
1883
 
1884
+ const agentDmMatch = pathname.match(/^\/api\/agents\/([^/]+)\/dm$/);
1885
+ if (request.method === 'POST' && agentDmMatch) {
1886
+ const agentKey = decodeURIComponent(agentDmMatch[1]!);
1887
+ if (!store.getAgent(agentKey)) throw new HttpError(404, 'AGENT_NOT_FOUND', `agent ${agentKey} not found`);
1888
+ try {
1889
+ return json({ room: store.getOrCreateAgentDm(agentKey) }, 201);
1890
+ } catch (error) {
1891
+ const message = error instanceof Error ? error.message : String(error);
1892
+ if (message.includes('already exists and is not a web DM')) throw new HttpError(409, 'ROOM_NAME_EXISTS', message);
1893
+ throw error;
1894
+ }
1895
+ }
1896
+
410
1897
  const agentPatchMatch = pathname.match(/^\/api\/agents\/([^/]+)$/);
1898
+ const agentDeleteImpactMatch = pathname.match(/^\/api\/agents\/([^/]+)\/delete-impact$/);
1899
+ if (request.method === 'GET' && agentDeleteImpactMatch) {
1900
+ const agentKey = decodeURIComponent(agentDeleteImpactMatch[1]!);
1901
+ if (!store.getAgent(agentKey)) throw new HttpError(404, 'AGENT_NOT_FOUND', `agent ${agentKey} not found`);
1902
+ const configPath = stringParam(url, 'lark_config') ?? stringParam(url, 'config') ?? defaultLarkConfigPath();
1903
+ return json({ impact: agentDeletionImpact(store, agentKey, configPath) });
1904
+ }
1905
+
1906
+ if (request.method === 'DELETE' && agentPatchMatch) {
1907
+ return readJson<DeleteAgentBody>(request).then((body) => {
1908
+ const agentKey = decodeURIComponent(agentPatchMatch[1]!);
1909
+ if (!store.getAgent(agentKey)) throw new HttpError(404, 'AGENT_NOT_FOUND', `agent ${agentKey} not found`);
1910
+ if (body.confirm_delete !== true) throw new HttpError(400, 'DELETE_CONFIRMATION_REQUIRED', 'agent deletion requires explicit confirmation');
1911
+
1912
+ const configPath = stringParam(url, 'lark_config') ?? stringParam(url, 'config') ?? defaultLarkConfigPath();
1913
+ const impact = agentDeletionImpact(store, agentKey, configPath);
1914
+ if (!impact) throw new HttpError(404, 'AGENT_NOT_FOUND', `agent ${agentKey} not found`);
1915
+ if (impact.rooms.length > 0 && body.leave_rooms !== true) {
1916
+ throw new HttpError(400, 'DELETE_CLEANUP_REQUIRED', 'agent deletion requires leave_rooms cleanup confirmation');
1917
+ }
1918
+ if (impact.lark_bindings.length > 0 && body.unbind_lark !== true) {
1919
+ throw new HttpError(400, 'DELETE_CLEANUP_REQUIRED', 'agent deletion requires unbind_lark cleanup confirmation');
1920
+ }
1921
+ const deletion = store.deleteAgent(agentKey);
1922
+ if (!deletion) throw new HttpError(404, 'AGENT_NOT_FOUND', `agent ${agentKey} not found`);
1923
+
1924
+ let lark: { changed: boolean; unbound?: { appId: string }; error?: string } = { changed: false };
1925
+ try {
1926
+ const credentials = loadLarkCredentials(configPath);
1927
+ const unbound = unbindCredentialAgent(credentials, agentKey);
1928
+ if (unbound.changed) saveLarkCredentials(unbound.store, configPath);
1929
+ lark = { changed: unbound.changed, unbound: unbound.unbound };
1930
+ } catch (error) {
1931
+ lark = { changed: false, error: error instanceof Error ? error.message : String(error) };
1932
+ }
1933
+
1934
+ return json({ impact, deletion, lark: { config_path: configPath, unbound: lark.changed, app_id: lark.unbound?.appId ?? null, error: lark.error ?? null } });
1935
+ });
1936
+ }
1937
+
411
1938
  if (request.method === 'PATCH' && agentPatchMatch) {
412
- return readJson<Record<string, unknown>>(request).then((body) => {
413
- const agentKey = agentPatchMatch[1]!;
1939
+ return readJson<Record<string, unknown>>(request).then(async (body) => {
1940
+ const agentKey = decodeURIComponent(agentPatchMatch[1]!);
414
1941
  const existing = store.getAgent(agentKey);
415
1942
  if (!existing) throw new HttpError(404, 'AGENT_NOT_FOUND', `agent ${agentKey} not found`);
416
1943
 
417
- if ('runtime' in body) {
1944
+ if ('runtime' in body || 'model' in body) {
418
1945
  const runtime = body.runtime === null ? null : typeof body.runtime === 'string' ? body.runtime : undefined;
419
- if (runtime !== undefined) {
420
- const updated = store.updateAgentRuntime(agentKey, runtime);
1946
+ const model = body.model === null ? null : typeof body.model === 'string' ? body.model : undefined;
1947
+ if (runtime !== undefined || model !== undefined) {
1948
+ const nextRuntime = runtime ?? existing.runtime ?? '';
1949
+ const runtimeChanged = runtime !== undefined && runtime !== existing.runtime;
1950
+ const updated = store.updateAgentRuntimeConfig(agentKey, {
1951
+ ...(runtime !== undefined ? { runtime } : {}),
1952
+ ...(model !== undefined ? { model: await checkedRuntimeModel(nextRuntime, model) } : {}),
1953
+ ...(model === undefined && runtimeChanged ? { model: null } : {}),
1954
+ });
421
1955
  return json({ agent: updated });
422
1956
  }
423
1957
  }
@@ -429,7 +1963,8 @@ export function route(store: MessageStore, request: Request): Promise<Response>
429
1963
  const agentAssignmentMatch = pathname.match(/^\/api\/agents\/([^/]+)\/assignment$/);
430
1964
  if (request.method === 'POST' && agentAssignmentMatch) {
431
1965
  return readJson<AssignAgentBody>(request).then((body) => {
432
- const agentKey = agentAssignmentMatch[1]!;
1966
+ const agentKey = decodeURIComponent(agentAssignmentMatch[1]!);
1967
+ if (!store.getAgent(agentKey)) throw new HttpError(404, 'AGENT_NOT_FOUND', `agent ${agentKey} not found`);
433
1968
  if (!body.computer_id?.trim()) throw new HttpError(400, 'MISSING_COMPUTER_ID', 'computer_id is required');
434
1969
  const assignment = store.assignAgentToComputer({
435
1970
  agent: agentKey,
@@ -439,6 +1974,13 @@ export function route(store: MessageStore, request: Request): Promise<Response>
439
1974
  });
440
1975
  }
441
1976
 
1977
+ if (request.method === 'DELETE' && agentAssignmentMatch) {
1978
+ const agentKey = decodeURIComponent(agentAssignmentMatch[1]!);
1979
+ if (!store.getAgent(agentKey)) throw new HttpError(404, 'AGENT_NOT_FOUND', `agent ${agentKey} not found`);
1980
+ const removed = store.unassignAgentFromComputer(agentKey);
1981
+ return json({ assignment: null, removed });
1982
+ }
1983
+
442
1984
  if (request.method === 'GET' && pathname === '/api/sessions') {
443
1985
  return json({ sessions: store.listSessions(numberParam(url, 'limit', 50)) });
444
1986
  }
@@ -459,6 +2001,37 @@ export function route(store: MessageStore, request: Request): Promise<Response>
459
2001
  });
460
2002
  }
461
2003
 
2004
+ if (request.method === 'POST' && pathname === '/api/lark/registration') {
2005
+ return (async () => {
2006
+ pruneLarkRegistrations();
2007
+ const begin = await beginLarkAppRegistration({ source: 'pal-web' });
2008
+ const id = crypto.randomUUID();
2009
+ const qrDataUrl = await QRCode.toDataURL(begin.url, {
2010
+ margin: 1,
2011
+ width: 220,
2012
+ color: { dark: '#181714', light: '#ffffff' },
2013
+ });
2014
+ const entry: PendingLarkRegistration = {
2015
+ id,
2016
+ deviceCode: begin.deviceCode,
2017
+ url: begin.url,
2018
+ qrDataUrl,
2019
+ expiresAt: Date.now() + begin.expiresIn * 1000,
2020
+ interval: begin.interval,
2021
+ };
2022
+ larkRegistrations.set(id, entry);
2023
+ return json(larkRegistrationPublic(entry), 201);
2024
+ })();
2025
+ }
2026
+
2027
+ const larkRegistrationMatch = pathname.match(/^\/api\/lark\/registration\/([^/]+)$/);
2028
+ if (request.method === 'GET' && larkRegistrationMatch) {
2029
+ return (async () => {
2030
+ const entry = await pollStoredLarkRegistration(decodeURIComponent(larkRegistrationMatch[1]!));
2031
+ return json(larkRegistrationPublic(entry));
2032
+ })();
2033
+ }
2034
+
462
2035
  if (request.method === 'GET' && pathname === '/api/lark/authorized-users') {
463
2036
  return json({ users: store.listLarkAuthorizedUsers() });
464
2037
  }
@@ -482,11 +2055,21 @@ export function route(store: MessageStore, request: Request): Promise<Response>
482
2055
 
483
2056
  if (request.method === 'POST' && pathname === '/api/lark/setup') {
484
2057
  return readJson<LarkSetupBody>(request).then(async (body) => {
485
- const appId = body.app_id?.trim();
486
- const appSecret = body.app_secret?.trim();
2058
+ const registrationId = body.registration_id?.trim();
2059
+ let appId = body.app_id?.trim();
2060
+ let appSecret = body.app_secret?.trim();
487
2061
  const agent = body.agent?.trim();
488
2062
  const label = body.label?.trim();
489
2063
  const configPath = body.config?.trim() || defaultLarkConfigPath();
2064
+ if (registrationId) {
2065
+ const registration = await pollStoredLarkRegistration(registrationId);
2066
+ if (!registration.completed) throw new HttpError(409, 'LARK_REGISTRATION_PENDING', 'registration has not completed yet');
2067
+ if (registration.completed.tenantBrand === 'lark') {
2068
+ throw new HttpError(400, 'LARK_TENANT_UNSUPPORTED', 'Lark international tenants are not supported yet; use a Feishu tenant');
2069
+ }
2070
+ appId = registration.completed.appId;
2071
+ appSecret = registration.completed.appSecret;
2072
+ }
490
2073
  if (!appId) throw new HttpError(400, 'MISSING_APP_ID', 'app_id is required');
491
2074
  if (!appSecret) throw new HttpError(400, 'MISSING_APP_SECRET', 'app_secret is required');
492
2075
  if (!agent) throw new HttpError(400, 'MISSING_AGENT', 'agent is required');
@@ -511,6 +2094,7 @@ export function route(store: MessageStore, request: Request): Promise<Response>
511
2094
  botOpenId: botInfo.openId,
512
2095
  agent,
513
2096
  });
2097
+ if (registrationId) larkRegistrations.delete(registrationId);
514
2098
  return json({ ...result, appName: botInfo.appName ?? null, account }, 201);
515
2099
  });
516
2100
  }
@@ -532,6 +2116,241 @@ export function route(store: MessageStore, request: Request): Promise<Response>
532
2116
  });
533
2117
  }
534
2118
 
2119
+ const roomLeaveMatch = pathname.match(/^\/api\/rooms\/([^/]+)\/agents\/([^/]+)$/);
2120
+ if (request.method === 'DELETE' && roomLeaveMatch) {
2121
+ const room = store.resolveRoom(decodeURIComponent(roomLeaveMatch[1]!));
2122
+ if (!room) throw new HttpError(404, 'ROOM_NOT_FOUND', 'room not found');
2123
+ const agent = decodeURIComponent(roomLeaveMatch[2]!);
2124
+ const result = store.leaveAgentRoom({ roomId: room.id, agent });
2125
+ return json(result);
2126
+ }
2127
+
2128
+ const roomAgentReadMatch = pathname.match(/^\/api\/rooms\/([^/]+)\/agents\/([^/]+)\/read$/);
2129
+ if (request.method === 'POST' && roomAgentReadMatch) {
2130
+ return readJson<AgentRoomReadBody>(request).then((body) => {
2131
+ const room = store.resolveRoom(decodeURIComponent(roomAgentReadMatch[1]!));
2132
+ if (!room) throw new HttpError(404, 'ROOM_NOT_FOUND', 'room not found');
2133
+ const agent = decodeURIComponent(roomAgentReadMatch[2]!);
2134
+ if (!store.getAgent(agent)) throw new HttpError(404, 'AGENT_NOT_FOUND', 'agent not found');
2135
+ try {
2136
+ const workbenchRoom = store.markAgentRoomRead({ roomId: room.id, agent, messageId: body.message_id });
2137
+ return json({ room: workbenchRoom });
2138
+ } catch (error) {
2139
+ const message = error instanceof Error ? error.message : String(error);
2140
+ if (message === 'ROOM_ACCESS_DENIED') throw new HttpError(403, 'ROOM_ACCESS_DENIED', 'agent cannot read this room');
2141
+ if (message === 'INVALID_READ_MESSAGE_ID') throw new HttpError(400, 'INVALID_READ_MESSAGE_ID', 'message_id must be a non-negative integer');
2142
+ throw error;
2143
+ }
2144
+ });
2145
+ }
2146
+
2147
+ const roomAgentRestartMatch = pathname.match(/^\/api\/rooms\/([^/]+)\/agents\/([^/]+)\/restart$/);
2148
+ if (request.method === 'POST' && roomAgentRestartMatch) {
2149
+ const room = store.resolveRoom(decodeURIComponent(roomAgentRestartMatch[1]!));
2150
+ if (!room) throw new HttpError(404, 'ROOM_NOT_FOUND', 'room not found');
2151
+ const agent = decodeURIComponent(roomAgentRestartMatch[2]!);
2152
+ if (!store.getAgent(agent)) throw new HttpError(404, 'AGENT_NOT_FOUND', 'agent not found');
2153
+ try {
2154
+ const run = store.requestRoomAgentRunAction({ roomId: room.id, agent, action: 'restart' });
2155
+ return json({ run });
2156
+ } catch (error) {
2157
+ const message = error instanceof Error ? error.message : String(error);
2158
+ if (message.includes('running run for agent') && message.includes('was not found')) {
2159
+ throw new HttpError(404, 'RUN_NOT_FOUND', 'no running runtime for this agent in this room');
2160
+ }
2161
+ throw error;
2162
+ }
2163
+ }
2164
+
2165
+ const roomAgentSubscriptionMatch = pathname.match(/^\/api\/rooms\/([^/]+)\/agents\/([^/]+)\/subscription$/);
2166
+ if (request.method === 'PATCH' && roomAgentSubscriptionMatch) {
2167
+ return readJson<AgentRoomSubscriptionBody>(request).then((body) => {
2168
+ const room = store.resolveRoom(decodeURIComponent(roomAgentSubscriptionMatch[1]!));
2169
+ if (!room) throw new HttpError(404, 'ROOM_NOT_FOUND', 'room not found');
2170
+ const agent = decodeURIComponent(roomAgentSubscriptionMatch[2]!);
2171
+ if (!store.getAgent(agent)) throw new HttpError(404, 'AGENT_NOT_FOUND', 'agent not found');
2172
+ const mode = parseAgentRoomSubscriptionMode(body.mode);
2173
+ try {
2174
+ return json(store.updateAgentRoomSubscriptionMode({ roomId: room.id, agent, mode }));
2175
+ } catch (error) {
2176
+ const message = error instanceof Error ? error.message : String(error);
2177
+ if (message === 'ROOM_ACCESS_DENIED') throw new HttpError(403, 'ROOM_ACCESS_DENIED', 'agent cannot update this room subscription');
2178
+ throw error;
2179
+ }
2180
+ });
2181
+ }
2182
+
2183
+ const roomTasksMatch = pathname.match(/^\/api\/rooms\/([^/]+)\/tasks$/);
2184
+ if (request.method === 'GET' && roomTasksMatch) {
2185
+ const room = store.resolveRoom(decodeURIComponent(roomTasksMatch[1]!));
2186
+ if (!room) throw new HttpError(404, 'ROOM_NOT_FOUND', 'room not found');
2187
+ const statusParam = stringParam(url, 'status') ?? 'all';
2188
+ const status = statusParam === 'all' ? 'all' : parseRoomTaskStatus(statusParam);
2189
+ return json({ tasks: store.listRoomTasks(room.id, status, numberParam(url, 'limit', 50)) });
2190
+ }
2191
+
2192
+ if (request.method === 'POST' && roomTasksMatch) {
2193
+ return readJson<CreateRoomTasksBody>(request).then((body) => {
2194
+ const room = store.resolveRoom(decodeURIComponent(roomTasksMatch[1]!));
2195
+ if (!room) throw new HttpError(404, 'ROOM_NOT_FOUND', 'room not found');
2196
+ const titles = body.tasks?.map((task) => task.title ?? '') ?? [body.title ?? ''];
2197
+ try {
2198
+ return json({
2199
+ tasks: store.createRoomTasks({
2200
+ roomId: room.id,
2201
+ titles,
2202
+ createdBy: body.created_by,
2203
+ sourceMessageId: body.source_message_id,
2204
+ }),
2205
+ }, 201);
2206
+ } catch (error) {
2207
+ const message = error instanceof Error ? error.message : String(error);
2208
+ if (message === 'task title is required') throw new HttpError(400, 'MISSING_TASK_TITLE', 'task title is required');
2209
+ if (message === 'INVALID_SOURCE_MESSAGE_ID') throw new HttpError(400, 'INVALID_SOURCE_MESSAGE_ID', 'source_message_id must be a positive integer');
2210
+ if (message === 'SOURCE_MESSAGE_NOT_FOUND') throw new HttpError(404, 'SOURCE_MESSAGE_NOT_FOUND', 'source message not found in room');
2211
+ throw error;
2212
+ }
2213
+ });
2214
+ }
2215
+
2216
+ const roomTaskClaimMatch = pathname.match(/^\/api\/rooms\/([^/]+)\/tasks\/([^/]+)\/claim$/);
2217
+ if (request.method === 'POST' && roomTaskClaimMatch) {
2218
+ return readJson<ClaimRoomTaskBody>(request).then((body) => {
2219
+ const room = store.resolveRoom(decodeURIComponent(roomTaskClaimMatch[1]!));
2220
+ if (!room) throw new HttpError(404, 'ROOM_NOT_FOUND', 'room not found');
2221
+ if (!body.assignee?.trim()) throw new HttpError(400, 'MISSING_ASSIGNEE', 'assignee is required');
2222
+ const task = store.claimRoomTask({
2223
+ roomId: room.id,
2224
+ taskNumber: taskNumberFromPath(roomTaskClaimMatch[2]),
2225
+ assignee: body.assignee,
2226
+ });
2227
+ if (!task) throw new HttpError(404, 'TASK_NOT_FOUND', 'task not found');
2228
+ return json({ task });
2229
+ });
2230
+ }
2231
+
2232
+ const roomTaskUnclaimMatch = pathname.match(/^\/api\/rooms\/([^/]+)\/tasks\/([^/]+)\/unclaim$/);
2233
+ if (request.method === 'POST' && roomTaskUnclaimMatch) {
2234
+ const room = store.resolveRoom(decodeURIComponent(roomTaskUnclaimMatch[1]!));
2235
+ if (!room) throw new HttpError(404, 'ROOM_NOT_FOUND', 'room not found');
2236
+ const task = store.unclaimRoomTask(room.id, taskNumberFromPath(roomTaskUnclaimMatch[2]));
2237
+ if (!task) throw new HttpError(404, 'TASK_NOT_FOUND', 'task not found');
2238
+ return json({ task });
2239
+ }
2240
+
2241
+ const roomTaskUpdateMatch = pathname.match(/^\/api\/rooms\/([^/]+)\/tasks\/([^/]+)$/);
2242
+ if (request.method === 'PATCH' && roomTaskUpdateMatch) {
2243
+ return readJson<UpdateRoomTaskBody>(request).then((body) => {
2244
+ const room = store.resolveRoom(decodeURIComponent(roomTaskUpdateMatch[1]!));
2245
+ if (!room) throw new HttpError(404, 'ROOM_NOT_FOUND', 'room not found');
2246
+ const task = store.updateRoomTaskStatus({
2247
+ roomId: room.id,
2248
+ taskNumber: taskNumberFromPath(roomTaskUpdateMatch[2]),
2249
+ status: parseRoomTaskStatus(body.status),
2250
+ });
2251
+ if (!task) throw new HttpError(404, 'TASK_NOT_FOUND', 'task not found');
2252
+ return json({ task });
2253
+ });
2254
+ }
2255
+
2256
+ if (request.method === 'GET' && pathname === '/api/reminders') {
2257
+ const statusParam = stringParam(url, 'status') ?? 'scheduled';
2258
+ const status = statusParam === 'all' ? 'all' : parseRoomReminderStatus(statusParam);
2259
+ return json({
2260
+ reminders: store.listRoomReminders({
2261
+ status,
2262
+ createdBy: stringParam(url, 'created_by'),
2263
+ roomId: stringParam(url, 'room_id') ?? stringParam(url, 'chat_id'),
2264
+ limit: numberParam(url, 'limit', 50),
2265
+ }),
2266
+ });
2267
+ }
2268
+
2269
+ if (request.method === 'POST' && pathname === '/api/reminders/fire-due') {
2270
+ assertServerAuth(request);
2271
+ return readJson<FireDueRoomRemindersBody>(request).then((body) => {
2272
+ try {
2273
+ return json(fireDueRoomRemindersForDelivery(store, options, {
2274
+ now: body.now,
2275
+ limit: body.limit ?? undefined,
2276
+ }));
2277
+ } catch (error) {
2278
+ const message = error instanceof Error ? error.message : String(error);
2279
+ if (message === 'INVALID_REMINDER_TIME') throw new HttpError(400, 'INVALID_REMINDER_TIME', 'now must be a valid time');
2280
+ throw error;
2281
+ }
2282
+ });
2283
+ }
2284
+
2285
+ if (request.method === 'POST' && pathname === '/api/reminders') {
2286
+ return readJson<CreateRoomReminderBody>(request).then((body) => {
2287
+ const sourceMessageId = Number(body.msg_id ?? body.source_message_id);
2288
+ try {
2289
+ const reminder = store.createRoomReminder({
2290
+ sourceMessageId,
2291
+ title: body.title ?? '',
2292
+ createdBy: body.created_by,
2293
+ fireAt: body.fire_at,
2294
+ delaySeconds: body.delay_seconds,
2295
+ repeat: body.repeat,
2296
+ });
2297
+ return json({ reminder }, 201);
2298
+ } catch (error) {
2299
+ const message = error instanceof Error ? error.message : String(error);
2300
+ if (message === 'reminder title is required') throw new HttpError(400, 'MISSING_REMINDER_TITLE', 'title is required');
2301
+ if (message === 'INVALID_REMINDER_TIME') throw new HttpError(400, 'INVALID_REMINDER_TIME', 'fire_at or delay_seconds must be a valid time');
2302
+ if (message === 'INVALID_REMINDER_REPEAT') throw new HttpError(400, 'INVALID_REMINDER_REPEAT', 'repeat must be hourly, daily, weekly, or every:<number><s|m|h|d|w>');
2303
+ if (message === 'INVALID_SOURCE_MESSAGE_ID') throw new HttpError(400, 'INVALID_SOURCE_MESSAGE_ID', 'msg_id must be a positive integer');
2304
+ if (message === 'SOURCE_MESSAGE_NOT_FOUND') throw new HttpError(404, 'SOURCE_MESSAGE_NOT_FOUND', 'source message not found');
2305
+ throw error;
2306
+ }
2307
+ });
2308
+ }
2309
+
2310
+ const reminderCancelMatch = pathname.match(/^\/api\/reminders\/([^/]+)\/cancel$/);
2311
+ if (request.method === 'POST' && reminderCancelMatch) {
2312
+ const reminder = store.cancelRoomReminder(decodeURIComponent(reminderCancelMatch[1]!));
2313
+ if (!reminder) throw new HttpError(404, 'REMINDER_NOT_FOUND', 'reminder not found');
2314
+ return json({ reminder });
2315
+ }
2316
+
2317
+ if (request.method === 'GET' && pathname === '/api/saved-messages') {
2318
+ return json({
2319
+ saved_messages: store.listRoomSavedMessages({
2320
+ roomId: stringParam(url, 'room_id') ?? stringParam(url, 'chat_id'),
2321
+ savedBy: stringParam(url, 'saved_by'),
2322
+ limit: numberParam(url, 'limit', 50),
2323
+ }),
2324
+ });
2325
+ }
2326
+
2327
+ if (request.method === 'POST' && pathname === '/api/saved-messages') {
2328
+ return readJson<CreateRoomSavedMessageBody>(request).then((body) => {
2329
+ const sourceMessageId = Number(body.msg_id ?? body.source_message_id);
2330
+ try {
2331
+ const result = store.saveRoomMessage({
2332
+ sourceMessageId,
2333
+ savedBy: body.saved_by ?? '',
2334
+ note: body.note,
2335
+ });
2336
+ return json({ saved_message: result.savedMessage }, result.created ? 201 : 200);
2337
+ } catch (error) {
2338
+ const message = error instanceof Error ? error.message : String(error);
2339
+ if (message === 'saved_by is required') throw new HttpError(400, 'MISSING_SAVED_BY', 'saved_by is required');
2340
+ if (message === 'INVALID_SOURCE_MESSAGE_ID') throw new HttpError(400, 'INVALID_SOURCE_MESSAGE_ID', 'msg_id must be a positive integer');
2341
+ if (message === 'SOURCE_MESSAGE_NOT_FOUND') throw new HttpError(404, 'SOURCE_MESSAGE_NOT_FOUND', 'source message not found');
2342
+ throw error;
2343
+ }
2344
+ });
2345
+ }
2346
+
2347
+ const savedMessageDeleteMatch = pathname.match(/^\/api\/saved-messages\/([^/]+)\/delete$/);
2348
+ if (request.method === 'POST' && savedMessageDeleteMatch) {
2349
+ const savedMessage = store.removeRoomSavedMessage(decodeURIComponent(savedMessageDeleteMatch[1]!));
2350
+ if (!savedMessage) throw new HttpError(404, 'SAVED_MESSAGE_NOT_FOUND', 'saved message not found');
2351
+ return json({ saved_message: savedMessage });
2352
+ }
2353
+
535
2354
  const roomTopicMatch = pathname.match(/^\/api\/rooms\/([^/]+)\/topics$/);
536
2355
  if (request.method === 'POST' && roomTopicMatch) {
537
2356
  return readJson<{ name?: string; created_by?: string | null }>(request).then((body) => {
@@ -544,17 +2363,37 @@ export function route(store: MessageStore, request: Request): Promise<Response>
544
2363
  }
545
2364
 
546
2365
  if (request.method === 'GET' && pathname === '/api/messages') {
2366
+ const chatName = stringParam(url, 'chat');
2367
+ const chatId = stringParam(url, 'chat_id');
2368
+ const scopedAgent = stringParam(url, 'agent_scope');
2369
+ if (scopedAgent) {
2370
+ const room = chatId ? store.getChatById(chatId) : chatName ? store.resolveRoom(chatName) : null;
2371
+ if (!room) throw new HttpError(400, 'MISSING_ROOM_SCOPE', 'agent-scoped message reads require chat_id or chat');
2372
+ requireAgentScopedRead(store, request, url, room.id);
2373
+ }
547
2374
  const messages = store.listMessages({
548
- chatName: stringParam(url, 'chat'),
549
- chatId: stringParam(url, 'chat_id'),
2375
+ chatName,
2376
+ chatId,
550
2377
  parentId: numberParam(url, 'parent_id'),
551
2378
  after: numberParam(url, 'after'),
2379
+ before: numberParam(url, 'before'),
552
2380
  limit: numberParam(url, 'limit', 50),
553
2381
  q: stringParam(url, 'q'),
554
2382
  });
555
2383
  return json({ messages });
556
2384
  }
557
2385
 
2386
+ if (request.method === 'GET' && pathname === '/api/messages/context') {
2387
+ const agent = stringParam(url, 'agent');
2388
+ const chatId = stringParam(url, 'chat_id');
2389
+ const messageId = numberParam(url, 'message_id');
2390
+ if (!agent) throw new HttpError(400, 'MISSING_AGENT', 'agent is required');
2391
+ if (!chatId) throw new HttpError(400, 'MISSING_CHAT_ID', 'chat_id is required');
2392
+ if (messageId === undefined) throw new HttpError(400, 'MISSING_MESSAGE_ID', 'message_id is required');
2393
+ requireAgentScopedRead(store, request, new URL(`${url.origin}${url.pathname}?agent_scope=${encodeURIComponent(agent)}`), chatId);
2394
+ return json({ context: store.getDeliveryContext({ agent, chatId, messageId, limit: numberParam(url, 'limit', 50) }) });
2395
+ }
2396
+
558
2397
  if (request.method === 'GET' && pathname === '/api/inbox') {
559
2398
  const agent = stringParam(url, 'agent');
560
2399
  if (!agent) throw new HttpError(400, 'MISSING_AGENT', 'agent is required');
@@ -584,17 +2423,126 @@ export function route(store: MessageStore, request: Request): Promise<Response>
584
2423
  if (request.method === 'GET' && pathname === '/api/deliveries') {
585
2424
  const agent = stringParam(url, 'agent');
586
2425
  if (agent) {
587
- return json({ deliveries: store.listDeliveries(agent, stringParam(url, 'status') ?? 'pending', numberParam(url, 'limit', 50)) });
2426
+ return json({
2427
+ deliveries: store.listDeliveries(agent, stringParam(url, 'status') ?? 'pending', numberParam(url, 'limit', 50), {
2428
+ distinctChat: url.searchParams.get('distinct_chat') === 'true',
2429
+ excludeRunningComputerId: stringParam(url, 'exclude_running_computer_id'),
2430
+ connectionId: stringParam(url, 'connection_id'),
2431
+ }),
2432
+ });
588
2433
  }
589
2434
  return json({ deliveries: store.listAllDeliveries(numberParam(url, 'limit', 50)) });
590
2435
  }
591
2436
 
2437
+ if (request.method === 'GET' && pathname === '/api/deliveries/pending-agents') {
2438
+ const connection = requireDaemonConnection(store, request);
2439
+ return json({ agents: store.listPendingDeliveryAgentsForConnection(connection.connectionId) });
2440
+ }
2441
+
2442
+ if (request.method === 'GET' && pathname === '/api/deliveries/backlog') {
2443
+ const connection = requireDaemonConnection(store, request);
2444
+ return json({ backlog: store.listDeliveryBacklogForConnection(connection.connectionId) });
2445
+ }
2446
+
2447
+ if (request.method === 'GET' && pathname === '/api/daemon/ws/status') {
2448
+ const connection = requireDaemonConnection(store, request);
2449
+ const stats = options.deliveryNotifier?.statsForConnection?.(connection.connectionId) ?? {
2450
+ connection_id: connection.connectionId,
2451
+ computer_id: connection.computerId,
2452
+ open_sockets: 0,
2453
+ last_open_at: null,
2454
+ last_close_at: null,
2455
+ last_ping_at: null,
2456
+ last_pong_at: null,
2457
+ last_close_code: null,
2458
+ last_close_reason: null,
2459
+ pending_agents: store.listPendingDeliveryAgentsForConnection(connection.connectionId),
2460
+ backlog: store.listDeliveryBacklogForConnection(connection.connectionId),
2461
+ };
2462
+ return json({ websocket: stats });
2463
+ }
2464
+
2465
+ const deliveryGetMatch = pathname.match(/^\/api\/deliveries\/([^/]+)$/);
2466
+ if (request.method === 'GET' && deliveryGetMatch) {
2467
+ const delivery = store.getDelivery(deliveryGetMatch[1]!);
2468
+ if (!delivery) throw new HttpError(404, 'DELIVERY_NOT_FOUND', 'delivery not found');
2469
+ return json({ delivery });
2470
+ }
2471
+
2472
+ if (request.method === 'GET' && pathname === '/api/messaging/status') {
2473
+ const larkBots = options.larkStatusProvider?.() ?? [];
2474
+ const deliveryWebSocket = options.deliveryNotifier?.statsAllConnections?.() ?? emptyDeliveryWebSocketSummary();
2475
+ return json(buildMessagingStatus({ larkBots, deliveryWebSocket }));
2476
+ }
2477
+
2478
+ if (request.method === 'GET' && pathname === '/api/messaging/health') {
2479
+ const larkBots = options.larkStatusProvider?.() ?? [];
2480
+ const deliveryWebSocket = options.deliveryNotifier?.statsAllConnections?.() ?? emptyDeliveryWebSocketSummary();
2481
+ return json(buildMessagingHealth(buildMessagingStatus({ larkBots, deliveryWebSocket })));
2482
+ }
2483
+
2484
+ if (request.method === 'POST' && pathname === '/api/messaging/probe-delivery') {
2485
+ assertServerAuth(request);
2486
+ return readJson<ProbeDeliveryBody>(request).then((body) => {
2487
+ const agent = body.agent?.trim() || 'lock';
2488
+ if (!store.getAgent(agent)) throw new HttpError(404, 'AGENT_NOT_FOUND', `agent not found: ${agent}`);
2489
+ const roomName = body.room?.trim() || `pal-delivery-probe-${agent}`;
2490
+ const sender = body.sender?.trim() || 'pal-probe';
2491
+ if (sender === agent) throw new HttpError(400, 'PROBE_SENDER_AGENT_CONFLICT', 'probe sender must differ from agent');
2492
+ const token = crypto.randomUUID();
2493
+ const content = body.content?.trim() || `PAL delivery probe ${token}. Reply with probe-ok ${token}.`;
2494
+ const idempotencyKey = body.idempotency_key?.trim() || `pal.probe.delivery:${agent}:${token}`;
2495
+ const room = store.getOrCreateChat(roomName, 'group');
2496
+ store.inviteAgentToRoom({ roomId: room.id, agent, mode: 'mentions' });
2497
+ const message = store.createMessage({
2498
+ chatId: room.id,
2499
+ sender,
2500
+ recipient: agent,
2501
+ content,
2502
+ idempotencyKey,
2503
+ mentions: [agent],
2504
+ });
2505
+ const deliveries = store.resolveDeliveriesForMessage(message.id);
2506
+ const notify = notifyDeliveries(options, deliveries);
2507
+ return json({ message, deliveries, notify, probe: { token, agent, room: room.name } }, 201);
2508
+ });
2509
+ }
2510
+
2511
+ if (request.method === 'GET' && pathname === '/api/lark/events/recent') {
2512
+ const limit = numberParam(url, 'limit', 20);
2513
+ const rows = listRecentInboundEvents(store.db, limit);
2514
+ return json({
2515
+ events: rows.map((event) => ({
2516
+ id: event.id,
2517
+ received_at: event.received_at,
2518
+ app_id: event.app_id,
2519
+ event_type: event.event_type,
2520
+ event_id: event.event_id,
2521
+ parse_ok: event.parse_ok,
2522
+ bytes: event.raw_body_bytes?.byteLength ?? 0,
2523
+ is_probe: event.event_type.startsWith('pal.probe.') || event.event_id.startsWith('pal.probe:'),
2524
+ })),
2525
+ });
2526
+ }
2527
+
2528
+ if (request.method === 'POST' && pathname === '/api/lark/events/repair-parse') {
2529
+ assertServerAuth(request);
2530
+ return readJson<{ app_id?: unknown; limit?: unknown; dry_run?: unknown }>(request).then(async (body) => {
2531
+ const appId = typeof body.app_id === 'string' && body.app_id.trim() ? body.app_id.trim() : undefined;
2532
+ const limit = typeof body.limit === 'number' && Number.isFinite(body.limit) ? body.limit : undefined;
2533
+ const dryRun = body.dry_run === undefined ? true : body.dry_run !== false;
2534
+ const result = await repairInboundEventParseFailures(store.db, { appId, limit, dryRun });
2535
+ return json(result);
2536
+ });
2537
+ }
2538
+
592
2539
  if (request.method === 'POST' && pathname === '/api/deliveries') {
593
2540
  return readJson<CreateDeliveryBody>(request).then((body) => {
594
2541
  if (!body.message_id) throw new HttpError(400, 'MISSING_MESSAGE_ID', 'message_id is required');
595
2542
  if (!body.agent?.trim()) throw new HttpError(400, 'MISSING_AGENT', 'agent is required');
596
2543
  const delivery = store.createDelivery({ messageId: body.message_id, agent: body.agent });
597
- return json({ delivery }, 201);
2544
+ const notify = notifyDeliveries(options, [delivery]);
2545
+ return json({ delivery, notify }, 201);
598
2546
  });
599
2547
  }
600
2548
 
@@ -609,6 +2557,7 @@ export function route(store: MessageStore, request: Request): Promise<Response>
609
2557
  connectionId: connection?.connectionId ?? body.connection_id,
610
2558
  computerId: connection?.computerId ?? body.computer_id,
611
2559
  leaseMs: body.lease_ms,
2560
+ steerRunId: body.steer_run_id,
612
2561
  });
613
2562
  return json({ delivery });
614
2563
  });
@@ -669,6 +2618,23 @@ export function route(store: MessageStore, request: Request): Promise<Response>
669
2618
  });
670
2619
  }
671
2620
 
2621
+ const deliveryProcessingMatch = pathname.match(/^\/api\/deliveries\/([^/]+)\/processing-completed$/);
2622
+ if (request.method === 'POST' && deliveryProcessingMatch) {
2623
+ return readJson<FinishDeliveryBody>(request).then((body) => {
2624
+ const connection = requireDaemonConnection(store, request);
2625
+ const ownerId = connection?.connectionId ?? body.connection_id ?? body.daemon_id;
2626
+ if (!ownerId?.trim()) throw new HttpError(400, 'MISSING_DAEMON_ID', 'daemon_id or connection auth is required');
2627
+ if (!body.claim_token?.trim()) throw new HttpError(400, 'MISSING_CLAIM_TOKEN', 'claim_token is required');
2628
+ const delivery = store.markDeliveryProcessingCompleted(deliveryProcessingMatch[1]!, {
2629
+ daemonId: ownerId,
2630
+ connectionId: connection?.connectionId ?? body.connection_id,
2631
+ claimToken: body.claim_token,
2632
+ runId: body.run_id,
2633
+ });
2634
+ return json({ delivery });
2635
+ });
2636
+ }
2637
+
672
2638
  const deliveryFailMatch = pathname.match(/^\/api\/deliveries\/([^/]+)\/fail$/);
673
2639
  if (request.method === 'POST' && deliveryFailMatch) {
674
2640
  return readJson<FinishDeliveryBody>(request).then((body) => {
@@ -691,19 +2657,49 @@ export function route(store: MessageStore, request: Request): Promise<Response>
691
2657
  return readJson<SendBody>(request).then(async (body) => {
692
2658
  const connection = assertDaemonConnection(store, request);
693
2659
  const sender = body.sender?.trim();
694
- const content = body.content?.trim();
695
2660
  if (!sender) throw new HttpError(400, 'MISSING_SENDER', 'sender is required');
696
- if (!content) throw new HttpError(400, 'MISSING_CONTENT', 'content is required');
2661
+ const batch = body.messages?.length
2662
+ ? body.messages.map((item) => ({
2663
+ parent_id: item.parent_id ?? body.parent_id,
2664
+ channel_id: item.channel_id ?? body.channel_id,
2665
+ recipient: item.recipient ?? body.recipient,
2666
+ content: item.content?.trim() ?? '',
2667
+ type: item.type ?? body.type,
2668
+ idempotency_key: item.idempotency_key,
2669
+ mentions: [...(body.mentions ?? []), ...(item.mentions ?? [])],
2670
+ attachments: parseMessageAttachments(item.attachments ?? body.attachments),
2671
+ }))
2672
+ : [{
2673
+ parent_id: body.parent_id,
2674
+ channel_id: body.channel_id,
2675
+ recipient: body.recipient,
2676
+ content: body.content?.trim() ?? '',
2677
+ type: body.type,
2678
+ idempotency_key: body.idempotency_key,
2679
+ mentions: body.mentions,
2680
+ attachments: parseMessageAttachments(body.attachments),
2681
+ }];
2682
+ if (batch.length === 0 || batch.some((item) => !item.content && item.attachments.length === 0)) {
2683
+ throw new HttpError(400, 'MISSING_CONTENT', 'content or attachments are required');
2684
+ }
697
2685
 
698
- const targetRoom = body.parent_id !== undefined
699
- ? store.getMessage(body.parent_id)?.chat_id
2686
+ const firstParentId = batch.find((item) => item.parent_id !== undefined)?.parent_id;
2687
+ const targetRoom = firstParentId !== undefined
2688
+ ? store.getMessage(firstParentId)?.chat_id
700
2689
  : body.room_id ?? body.chat_id;
701
2690
  const target = targetRoom
702
2691
  ? store.getChatById(targetRoom)
703
2692
  : (body.room || body.chat) ? store.resolveRoom(body.room ?? body.chat ?? '') : null;
2693
+ if (target?.status === 'archived') {
2694
+ throw new HttpError(403, 'ROOM_ARCHIVED', 'archived rooms are read-only until restored');
2695
+ }
704
2696
  if (target && !store.canSendWebMessageToRoom({ room: target, sender })) {
705
2697
  throw new HttpError(403, 'EXTERNAL_ROOM_READ_ONLY', 'external provider rooms are read-only for web human senders');
706
2698
  }
2699
+ if (target) {
2700
+ const mentionValidation = validateMessageMentions(store, target.id, batch);
2701
+ if (mentionValidation) return json(mentionValidation);
2702
+ }
707
2703
  if (store.getAgent(sender) || store.hasDaemonAgent(sender)) {
708
2704
  if (!connection) throw new HttpError(401, 'CONNECTION_REVOKED', 'computer connection auth is required for agent-authored messages');
709
2705
  if (!store.daemonHasAgent(connection.connectionId, sender)) {
@@ -711,47 +2707,108 @@ export function route(store: MessageStore, request: Request): Promise<Response>
711
2707
  }
712
2708
  }
713
2709
 
714
- const message = store.createMessage({
715
- chatId: body.room_id ?? body.chat_id,
716
- chatName: body.room ?? body.chat,
717
- parentId: body.parent_id,
718
- channelId: body.channel_id,
719
- sender,
720
- recipient: body.recipient,
721
- content,
722
- type: body.type,
723
- idempotencyKey: body.idempotency_key,
724
- mentions: body.mentions,
725
- });
726
- const deliveries = store.resolveDeliveriesForMessage(message.id);
727
-
728
- // If this chat has a bound Lark channel, forward bound agent replies to Lark.
729
- const credentials = loadLarkCredentials();
730
- const outboundRoute = resolveLarkOutboundRoute(store, credentials, sender, message.chat_id);
731
- if (outboundRoute) {
732
- const { conversation, bot } = outboundRoute;
733
- try {
734
- const client = createLarkApiClient(bot.appId, bot.appSecret);
735
- const result = await sendTextMessage({
736
- client,
737
- receiveIdType: 'chat_id',
738
- receiveId: conversation.external_chat_id,
739
- text: content,
2710
+ const freshness = freshnessInput(body.freshness);
2711
+ if (freshness?.on_stale === 'hold') {
2712
+ if (!target) throw new HttpError(400, 'MISSING_ROOM_SCOPE', 'freshness hold requires an existing room or parent message');
2713
+ if (!store.getAgent(sender)) throw new HttpError(400, 'FRESHNESS_AGENT_REQUIRED', 'freshness hold requires an agent sender');
2714
+ const latestMessageId = store.getLatestMessageIdForFreshness(target.id, sender);
2715
+ if (latestMessageId > freshness.base_message_id) {
2716
+ const drafts = batch.map((item) => store.createHeldMessageDraft({
2717
+ chatId: target.id,
2718
+ agent: sender,
2719
+ sender,
2720
+ content: item.content,
2721
+ mentions: item.mentions,
2722
+ baseMessageId: freshness.base_message_id,
2723
+ latestMessageIdAtHold: latestMessageId,
2724
+ holdReason: 'stale_room',
2725
+ }));
2726
+ const interveningMessages = store.listInterveningMessagesForFreshness({
2727
+ chatId: target.id,
2728
+ baseMessageId: freshness.base_message_id,
2729
+ sender,
2730
+ limit: 50,
740
2731
  });
741
- console.log(`[lark] forwarded message to chat=${conversation.external_chat_id} messageId=${result.messageId ?? '-'}`);
742
- } catch (err) {
743
- console.warn(`[lark] failed to forward message to chat=${conversation.external_chat_id}: ${err instanceof Error ? err.message : String(err)}`);
2732
+ return json({ draft: drafts[0], drafts, intervening_messages: interveningMessages }, 202);
744
2733
  }
745
2734
  }
746
2735
 
747
- return json({ message, deliveries }, 201);
2736
+ const messages = [];
2737
+ const deliveries = [];
2738
+ const notify = [];
2739
+ for (const item of batch) {
2740
+ const message = store.createMessage({
2741
+ chatId: body.room_id ?? body.chat_id,
2742
+ chatName: body.room ?? body.chat,
2743
+ parentId: item.parent_id,
2744
+ channelId: item.channel_id,
2745
+ sender,
2746
+ recipient: item.recipient,
2747
+ content: item.content,
2748
+ type: item.type,
2749
+ idempotencyKey: item.idempotency_key,
2750
+ mentions: item.mentions,
2751
+ attachments: item.attachments,
2752
+ });
2753
+ messages.push(message);
2754
+ const sideEffects = await publishMessageSideEffects(store, options, message, item.content);
2755
+ deliveries.push(...sideEffects.deliveries);
2756
+ notify.push(sideEffects.notify);
2757
+ }
2758
+ return json({ message: messages[0], messages, deliveries, notify: batch.length === 1 ? notify[0] : notify }, 201);
748
2759
  });
749
2760
  }
750
2761
 
2762
+ const heldDraftMatch = pathname.match(/^\/api\/held-drafts\/([^/]+)\/(abandon|send-anyway|revise)$/);
2763
+ if (request.method === 'POST' && heldDraftMatch) {
2764
+ return (async () => {
2765
+ const draftId = heldDraftMatch[1]!;
2766
+ const action = heldDraftMatch[2]!;
2767
+ try {
2768
+ if (action === 'abandon') {
2769
+ const draft = store.abandonHeldMessageDraft(draftId);
2770
+ if (!draft) throw new HttpError(404, 'HELD_DRAFT_NOT_FOUND', 'held draft not found');
2771
+ return json({ draft });
2772
+ }
2773
+ if (action === 'revise') {
2774
+ const body = await readJson<HeldDraftReviseBody>(request);
2775
+ const content = typeof body.content === 'string' ? body.content.trim() : '';
2776
+ if (!content) throw new HttpError(400, 'MISSING_CONTENT', 'content is required');
2777
+ const result = store.reviseHeldMessageDraft(draftId, content);
2778
+ if (!result) throw new HttpError(404, 'HELD_DRAFT_NOT_FOUND', 'held draft not found');
2779
+ const { deliveries, notify } = await publishMessageSideEffects(store, options, result.message, result.message.content);
2780
+ return json({ draft: result.draft, message: result.message, deliveries, notify });
2781
+ }
2782
+ const result = store.sendHeldMessageDraftAnyway(draftId);
2783
+ if (!result) throw new HttpError(404, 'HELD_DRAFT_NOT_FOUND', 'held draft not found');
2784
+ const { deliveries, notify } = await publishMessageSideEffects(store, options, result.message, result.message.content);
2785
+ return json({ draft: result.draft, message: result.message, deliveries, notify });
2786
+ } catch (err) {
2787
+ if (err instanceof Error && err.message === 'HELD_DRAFT_ALREADY_RESOLVED') {
2788
+ throw new HttpError(409, 'HELD_DRAFT_ALREADY_RESOLVED', 'held draft is already resolved');
2789
+ }
2790
+ throw err;
2791
+ }
2792
+ })();
2793
+ }
2794
+
751
2795
  if (request.method === 'GET' && pathname === '/api/runs') {
752
2796
  return json({ runs: store.listRuns(numberParam(url, 'limit', 50)) });
753
2797
  }
754
2798
 
2799
+ if (request.method === 'GET' && pathname === '/api/workflows') {
2800
+ const statusParam = stringParam(url, 'status') ?? 'all';
2801
+ const status = statusParam === 'all' ? 'all' : parseWorkflowRunStatus(statusParam);
2802
+ return json({ workflows: store.listWorkflowRuns({ status, limit: numberParam(url, 'limit', 50) }) });
2803
+ }
2804
+
2805
+ const workflowRecordMatch = pathname.match(/^\/api\/workflows\/([^/]+)$/);
2806
+ if (request.method === 'GET' && workflowRecordMatch) {
2807
+ const workflow = store.getWorkflowRunRecord(decodeURIComponent(workflowRecordMatch[1]!));
2808
+ if (!workflow) throw new HttpError(404, 'WORKFLOW_RUN_NOT_FOUND', 'workflow run not found');
2809
+ return json({ workflow });
2810
+ }
2811
+
755
2812
  if (request.method === 'POST' && pathname === '/api/runs') {
756
2813
  return readJson<StartRunBody>(request).then((body) => {
757
2814
  const connection = requireDaemonConnection(store, request);
@@ -786,9 +2843,34 @@ export function route(store: MessageStore, request: Request): Promise<Response>
786
2843
  if (request.method === 'GET' && messageMatch) {
787
2844
  const message = store.getMessage(Number(messageMatch[1]));
788
2845
  if (!message) throw new HttpError(404, 'MESSAGE_NOT_FOUND', 'message not found');
2846
+ requireAgentScopedRead(store, request, url, message.chat_id);
789
2847
  return json({ message });
790
2848
  }
791
2849
 
2850
+ const messageAttachmentContentMatch = pathname.match(/^\/api\/message-attachments\/([^/]+)\/content$/);
2851
+ if (request.method === 'GET' && messageAttachmentContentMatch) {
2852
+ const attachment = store.getMessageAttachment(decodeURIComponent(messageAttachmentContentMatch[1]!));
2853
+ if (!attachment) throw new HttpError(404, 'ATTACHMENT_NOT_FOUND', 'message attachment not found');
2854
+ const message = store.getMessage(attachment.message_id);
2855
+ if (!message) throw new HttpError(404, 'MESSAGE_NOT_FOUND', 'message not found');
2856
+ requireAgentScopedRead(store, request, url, message.chat_id);
2857
+ let content: Buffer;
2858
+ try {
2859
+ content = readFileSync(attachment.path);
2860
+ } catch {
2861
+ throw new HttpError(404, 'ATTACHMENT_FILE_NOT_FOUND', 'message attachment file not found');
2862
+ }
2863
+ const body = content.buffer.slice(content.byteOffset, content.byteOffset + content.byteLength) as ArrayBuffer;
2864
+ return new Response(body, {
2865
+ headers: {
2866
+ 'content-type': attachment.mime_type,
2867
+ 'content-length': String(attachment.size_bytes),
2868
+ 'content-disposition': `${url.searchParams.has('download') ? 'attachment' : 'inline'}; filename="${attachment.filename.replace(/"/g, '')}"`,
2869
+ 'cache-control': 'private, no-store',
2870
+ },
2871
+ });
2872
+ }
2873
+
792
2874
  const runMatch = pathname.match(/^\/api\/runs\/([^/]+)$/);
793
2875
  if (request.method === 'GET' && runMatch) {
794
2876
  const run = store.getRun(runMatch[1]!);
@@ -825,6 +2907,28 @@ export function route(store: MessageStore, request: Request): Promise<Response>
825
2907
  });
826
2908
  }
827
2909
 
2910
+ const activityMatch = pathname.match(/^\/api\/runs\/([^/]+)\/activity$/);
2911
+ if (request.method === 'POST' && activityMatch) {
2912
+ return readJson<RecordRunActivityBody>(request).then((body) => {
2913
+ const connection = requireDaemonConnection(store, request);
2914
+ const runBefore = store.getRun(activityMatch[1]!);
2915
+ if (!runBefore) throw new HttpError(404, 'RUN_NOT_FOUND', 'run not found');
2916
+ if (connection && runBefore.connection_id !== connection.connectionId) throw new HttpError(403, 'RUN_CONNECTION_MISMATCH', 'run belongs to another connection');
2917
+ if (!body.kind || !agentActivityKinds.includes(body.kind)) {
2918
+ throw new HttpError(400, 'INVALID_ACTIVITY_KIND', `kind must be ${agentActivityKinds.join(', ')}`);
2919
+ }
2920
+ if (!body.title?.trim()) throw new HttpError(400, 'MISSING_TITLE', 'title is required');
2921
+ const event = store.recordAgentActivity({
2922
+ runId: runBefore.id,
2923
+ kind: body.kind,
2924
+ title: body.title,
2925
+ detail: body.detail,
2926
+ metadata: body.metadata,
2927
+ });
2928
+ return json({ event }, 201);
2929
+ });
2930
+ }
2931
+
828
2932
  const actionMatch = pathname.match(/^\/api\/runs\/([^/]+)\/(kill|restart)$/);
829
2933
  if (request.method === 'POST' && actionMatch) {
830
2934
  const run = store.requestRunAction(actionMatch[1]!, actionMatch[2] as RunAction);
@@ -834,9 +2938,9 @@ export function route(store: MessageStore, request: Request): Promise<Response>
834
2938
  return routeNotFound();
835
2939
  }
836
2940
 
837
- export async function handleRequest(store: MessageStore, request: Request): Promise<Response> {
2941
+ export async function handleRequest(store: MessageStore, request: Request, options: AppRouteOptions = {}): Promise<Response> {
838
2942
  try {
839
- return await route(store, request);
2943
+ return await route(store, request, options);
840
2944
  } catch (error) {
841
2945
  return failure(error);
842
2946
  }