@controlflow-ai/daemon 0.1.2 → 0.1.4

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 (62) hide show
  1. package/README.md +54 -6
  2. package/bin/daemon.js +6 -1
  3. package/package.json +3 -1
  4. package/src/agent-avatar.ts +30 -0
  5. package/src/agent-key.ts +28 -0
  6. package/src/agent-permissions.ts +359 -0
  7. package/src/agent-runtime.ts +795 -28
  8. package/src/agent-workspace.ts +183 -0
  9. package/src/app.ts +1970 -79
  10. package/src/args.ts +54 -7
  11. package/src/cli.ts +873 -14
  12. package/src/client.ts +472 -10
  13. package/src/coco.ts +9 -40
  14. package/src/codex.ts +33 -5
  15. package/src/config.ts +28 -4
  16. package/src/console.ts +230 -20
  17. package/src/daemon-client.ts +116 -3
  18. package/src/daemon.ts +937 -99
  19. package/src/db.ts +3128 -122
  20. package/src/delivery-ws.ts +269 -0
  21. package/src/format.ts +4 -1
  22. package/src/lark/cli.ts +3 -3
  23. package/src/lark/event-router.ts +60 -4
  24. package/src/lark/inbound-events.ts +156 -3
  25. package/src/lark/server-integration.ts +659 -111
  26. package/src/lark/ws-daemon.ts +136 -10
  27. package/src/local-api.ts +545 -15
  28. package/src/local-auth.ts +33 -1
  29. package/src/message-attachments.ts +71 -0
  30. package/src/messaging-cli.ts +741 -0
  31. package/src/messaging-status.ts +669 -0
  32. package/src/migrations/024_agents_model.ts +10 -0
  33. package/src/migrations/025_room_archive.ts +44 -0
  34. package/src/migrations/026_project_archive.ts +44 -0
  35. package/src/migrations/027_agent_permission_profiles.ts +16 -0
  36. package/src/migrations/028_lark_websocket_restart_state.ts +16 -0
  37. package/src/migrations/029_held_message_drafts.ts +32 -0
  38. package/src/migrations/030_agent_room_read_state.ts +25 -0
  39. package/src/migrations/031_room_tasks.ts +29 -0
  40. package/src/migrations/032_room_reminders.ts +29 -0
  41. package/src/migrations/033_room_saved_messages.ts +25 -0
  42. package/src/migrations/034_agent_activity_events.ts +27 -0
  43. package/src/migrations/035_agent_avatars.ts +17 -0
  44. package/src/migrations/036_project_agent_defaults.ts +21 -0
  45. package/src/migrations/037_message_attachments.ts +36 -0
  46. package/src/migrations/038_agent_activity_room_scope.ts +64 -0
  47. package/src/migrations/039_message_attachments_path.ts +34 -0
  48. package/src/migrations/040_message_attachments_file_schema.ts +80 -0
  49. package/src/migrations/041_room_system_events.ts +30 -0
  50. package/src/migrations/042_message_attachment_file_kind.ts +52 -0
  51. package/src/migrations/043_room_mode_skill_registry.ts +92 -0
  52. package/src/migrations/044_workflow_runtime.ts +69 -0
  53. package/src/migrations/045_skill_repository_ownership.ts +64 -0
  54. package/src/migrations.ts +69 -1
  55. package/src/neeko.ts +40 -4
  56. package/src/runtime-env.ts +179 -0
  57. package/src/runtime-registry.ts +83 -13
  58. package/src/server.ts +244 -4
  59. package/src/token-file.ts +13 -6
  60. package/src/types.ts +362 -0
  61. package/src/workflow-runtime.ts +275 -0
  62. package/src/web.ts +0 -904
package/src/app.ts CHANGED
@@ -1,18 +1,95 @@
1
1
  import { artifactHeaders, artifactViewerHtml } from './artifacts.js';
2
- import { existsSync } from 'node:fs';
2
+ import { spawnSync } from 'node:child_process';
3
+ import { existsSync, lstatSync, readdirSync, readFileSync, realpathSync, statSync } from 'node:fs';
3
4
  import { fileURLToPath } from 'node:url';
4
- import { join } from 'node:path';
5
+ import { basename, extname, isAbsolute, join, relative, resolve, sep } from 'node:path';
5
6
  import QRCode from 'qrcode';
6
- import { MessageStore } from './db.js';
7
+ import { ALL_AGENTS_MENTION, MessageStore } from './db.js';
7
8
  import { failure, HttpError, json, numberParam, readJson, stringParam } from './http.js';
8
9
  import { assertServerAuth } from './server-auth.js';
9
- import { dashboardHtml } from './web.js';
10
- import type { RunAction, RunStatus } from './types.js';
10
+ import type { AgentActivityKind, AgentRoomSubscriptionMode, Chat, RoomMode, RoomReminderStatus, RoomTaskStatus, RunAction, RunStatus, SkillBindingScope, SkillSource, WorkflowRunStatus } from './types.js';
11
11
  import { createLarkApiClient, sendTextMessage } from './lark/ws-daemon.js';
12
- import { boundAgents, defaultLarkConfigPath, loadLarkCredentials, type LarkCredentialStore } from './lark/credentials.js';
12
+ import { boundAgents, defaultLarkConfigPath, loadLarkCredentials, saveLarkCredentials, type LarkCredentialStore, unbindCredentialAgent } from './lark/credentials.js';
13
13
  import { persistLarkCredential, resolveLarkBotInfo } from './lark/setup.js';
14
14
  import { beginLarkAppRegistration, pollLarkAppRegistration, type LarkRegistrationComplete } from './lark/app-registration.js';
15
+ import { listRecentInboundEvents, repairInboundEventParseFailures } from './lark/inbound-events.js';
15
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
+ }
16
93
 
17
94
  interface SendBody {
18
95
  chat?: string;
@@ -27,11 +104,226 @@ interface SendBody {
27
104
  type?: 'message' | 'system';
28
105
  idempotency_key?: string | null;
29
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;
30
270
  }
31
271
 
32
272
  interface CreateRoomBody {
33
273
  name?: string;
34
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;
35
327
  }
36
328
 
37
329
  interface CreateProjectBody {
@@ -40,6 +332,51 @@ interface CreateProjectBody {
40
332
  root_path?: string;
41
333
  }
42
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;
378
+ }
379
+
43
380
  interface StartRunBody {
44
381
  message_id?: number;
45
382
  agent?: string;
@@ -71,6 +408,20 @@ interface ProvisionComputerBody {
71
408
  package_name?: string;
72
409
  }
73
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
+
74
425
  interface ConnectComputerBody {
75
426
  computer_id?: string;
76
427
  secret?: string;
@@ -109,10 +460,22 @@ interface OnboardAgentBody {
109
460
  agent_key?: string;
110
461
  display_name?: string;
111
462
  runtime?: string | null;
463
+ model?: string | null;
112
464
  description?: string | null;
113
465
  computer_id?: string;
114
466
  }
115
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
+
116
479
  interface LarkSetupBody {
117
480
  app_id?: string;
118
481
  app_secret?: string;
@@ -127,6 +490,17 @@ interface LarkAuthorizedUserBody {
127
490
  display_name?: string | null;
128
491
  }
129
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
+
130
504
  interface CreateDeliveryBody {
131
505
  message_id?: number;
132
506
  agent?: string;
@@ -137,6 +511,7 @@ interface ClaimDeliveryBody {
137
511
  connection_id?: string | null;
138
512
  computer_id?: string | null;
139
513
  lease_ms?: number;
514
+ steer_run_id?: string | null;
140
515
  }
141
516
 
142
517
  interface SessionBody {
@@ -182,6 +557,13 @@ interface FinishRunBody {
182
557
  output?: string;
183
558
  }
184
559
 
560
+ interface RecordRunActivityBody {
561
+ kind?: AgentActivityKind;
562
+ title?: string;
563
+ detail?: string | null;
564
+ metadata?: Record<string, unknown> | null;
565
+ }
566
+
185
567
  export function resolveLarkOutboundRoute(store: MessageStore, credentials: LarkCredentialStore, sender: string, chatId: string) {
186
568
  for (const bot of credentials.bots) {
187
569
  if (!boundAgents(bot).includes(sender)) continue;
@@ -192,15 +574,326 @@ export function resolveLarkOutboundRoute(store: MessageStore, credentials: LarkC
192
574
  return null;
193
575
  }
194
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
+
195
608
  function routeNotFound(): Response {
196
609
  return Response.json({ ok: false, code: 'NOT_FOUND', message: 'not found' }, { status: 404 });
197
610
  }
198
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
+
199
890
  function html(body: string): Response {
200
891
  return new Response(body, { headers: { 'content-type': 'text/html; charset=utf-8' } });
201
892
  }
202
893
 
203
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;
204
897
 
205
898
  interface PendingLarkRegistration {
206
899
  id: string;
@@ -214,6 +907,34 @@ interface PendingLarkRegistration {
214
907
 
215
908
  const larkRegistrations = new Map<string, PendingLarkRegistration>();
216
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
+
217
938
  function pruneLarkRegistrations(): void {
218
939
  const now = Date.now();
219
940
  for (const [id, registration] of larkRegistrations) {
@@ -269,13 +990,46 @@ function webAssetContentType(pathname: string): string {
269
990
  }
270
991
 
271
992
  function builtWebIndex(): Response | null {
993
+ ensureBuiltWeb();
272
994
  const indexPath = join(webDistDir, 'index.html');
273
995
  if (!existsSync(indexPath)) return null;
274
996
  return new Response(Bun.file(indexPath), { headers: { 'content-type': 'text/html; charset=utf-8' } });
275
997
  }
276
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
+
277
1030
  function builtWebAsset(pathname: string): Response | null {
278
1031
  if (!pathname.startsWith('/assets/')) return null;
1032
+ ensureBuiltWeb();
279
1033
  const relative = decodeURIComponent(pathname.slice('/assets/'.length));
280
1034
  if (!relative || relative.includes('..') || relative.includes('/') || relative.includes('\\')) return null;
281
1035
  const assetPath = join(webDistDir, 'assets', relative);
@@ -309,12 +1063,68 @@ function requireDaemonConnection(store: MessageStore, request: Request): { compu
309
1063
  return connection;
310
1064
  }
311
1065
 
312
- 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 {
313
1123
  const url = new URL(request.url);
314
1124
  const { pathname } = url;
315
1125
 
316
1126
  if (request.method === 'GET' && pathname === '/') {
317
- return builtWebIndex() ?? html(dashboardHtml());
1127
+ return builtWebIndex() ?? missingWebBuild();
318
1128
  }
319
1129
 
320
1130
  if (request.method === 'GET') {
@@ -341,6 +1151,71 @@ export function route(store: MessageStore, request: Request): Promise<Response>
341
1151
  return json({ computers: store.listComputers(numberParam(url, 'limit', 50)) });
342
1152
  }
343
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
+
344
1219
  if (request.method === 'POST' && pathname === '/api/computers/provision') {
345
1220
  return readJson<ProvisionComputerBody>(request).then((body) => {
346
1221
  const result = store.provisionComputer({
@@ -452,6 +1327,14 @@ export function route(store: MessageStore, request: Request): Promise<Response>
452
1327
  }
453
1328
 
454
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
+ }
455
1338
  return json({ rooms: store.listChats() });
456
1339
  }
457
1340
 
@@ -473,25 +1356,262 @@ export function route(store: MessageStore, request: Request): Promise<Response>
473
1356
  });
474
1357
  }
475
1358
 
476
- const computerFilesMatch = pathname.match(/^\/api\/computers\/([^/]+)\/files$/);
477
- if (request.method === 'GET' && computerFilesMatch) {
478
- const computerId = decodeURIComponent(computerFilesMatch[1]!);
479
- const control = store.getComputerLocalControl(computerId);
480
- if (!control) throw new HttpError(409, 'COMPUTER_NOT_BROWSABLE', 'computer is not online or does not expose a local filesystem browser');
481
- const params = new URLSearchParams();
482
- const browsePath = stringParam(url, 'path');
483
- if (browsePath) params.set('path', browsePath);
484
- if (url.searchParams.get('show_hidden') === 'true') params.set('show_hidden', 'true');
485
- return fetchDaemonJson({ localUrl: control.daemon.local_url, token: control.token, path: `/local/files${params.size ? `?${params}` : ''}` })
486
- .then((data) => json(data));
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
+ });
487
1563
  }
488
1564
 
489
- if (request.method === 'POST' && pathname === '/api/rooms') {
490
- return readJson<CreateRoomBody>(request).then((body) => {
491
- if (!body.name?.trim()) throw new HttpError(400, 'MISSING_ROOM_NAME', 'name is required');
492
- if (body.kind && body.kind !== 'group' && body.kind !== 'dm') throw new HttpError(400, 'BAD_ROOM_KIND', 'kind must be group or dm');
493
- const room = store.getOrCreateChat(body.name, body.kind ?? 'group');
494
- return json({ room }, 201);
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
+ });
495
1615
  });
496
1616
  }
497
1617
 
@@ -499,16 +1619,109 @@ export function route(store: MessageStore, request: Request): Promise<Response>
499
1619
  if (request.method === 'POST' && projectRoomsMatch) {
500
1620
  return readJson<CreateRoomBody>(request).then((body) => {
501
1621
  if (!body.name?.trim()) throw new HttpError(400, 'MISSING_ROOM_NAME', 'name is required');
502
- if (body.kind && body.kind !== 'group' && body.kind !== 'dm') throw new HttpError(400, 'BAD_ROOM_KIND', 'kind must be group or dm');
1622
+ if (body.kind && body.kind !== 'group') throw new HttpError(400, 'BAD_ROOM_KIND', 'web rooms are always group rooms');
503
1623
  const room = store.createProjectRoom({
504
1624
  projectId: decodeURIComponent(projectRoomsMatch[1]!),
505
1625
  name: body.name,
506
- kind: body.kind ?? 'group',
1626
+ kind: 'group',
1627
+ mode: parseRoomMode(body.mode),
507
1628
  });
508
1629
  return json({ room }, 201);
509
1630
  });
510
1631
  }
511
1632
 
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
+ });
1657
+ }
1658
+
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
+ });
1679
+ }
1680
+
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
+ });
1699
+ }
1700
+
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 });
1722
+ });
1723
+ }
1724
+
512
1725
  const roomMembersMatch = pathname.match(/^\/api\/rooms\/([^/]+)\/members$/);
513
1726
  if (request.method === 'GET' && roomMembersMatch) {
514
1727
  const room = store.resolveRoom(decodeURIComponent(roomMembersMatch[1]!));
@@ -516,6 +1729,12 @@ export function route(store: MessageStore, request: Request): Promise<Response>
516
1729
  return json({
517
1730
  room,
518
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
+ }),
519
1738
  completeness: 'Human members come from lark_member_api snapshots. Bot members are known/observed only; Feishu member-list API filters bots.',
520
1739
  });
521
1740
  }
@@ -533,6 +1752,18 @@ export function route(store: MessageStore, request: Request): Promise<Response>
533
1752
  });
534
1753
  }
535
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
+
536
1767
  const transcriptReadMatch = pathname.match(/^\/api\/transcripts\/([^/]+)\/messages$/);
537
1768
  if (request.method === 'GET' && transcriptReadMatch) {
538
1769
  return json({ messages: store.listTranscriptMessagesReadOnly(transcriptReadMatch[1]!, numberParam(url, 'limit', 50)) });
@@ -542,17 +1773,85 @@ export function route(store: MessageStore, request: Request): Promise<Response>
542
1773
  return json({ agents: store.listAgents(numberParam(url, 'limit', 50)) });
543
1774
  }
544
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
+
545
1842
  if (request.method === 'POST' && pathname === '/api/agents/onboard') {
546
- return readJson<OnboardAgentBody>(request).then((body) => {
547
- const agentKey = body.agent_key?.trim();
1843
+ return readJson<OnboardAgentBody>(request).then(async (body) => {
548
1844
  const displayName = body.display_name?.trim();
549
- if (!agentKey) throw new HttpError(400, 'MISSING_AGENT_KEY', 'agent_key is required');
550
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';
551
1849
  const agent = store.upsertAgent({
552
1850
  agent_key: agentKey,
553
1851
  display_name: displayName,
554
1852
  description: body.description ?? null,
555
- runtime: body.runtime ?? 'codex',
1853
+ runtime,
1854
+ model: await checkedRuntimeModel(runtime, body.model),
556
1855
  });
557
1856
  const assignment = body.computer_id?.trim()
558
1857
  ? store.assignAgentToComputer({ agent: agentKey, computerId: body.computer_id })
@@ -562,33 +1861,97 @@ export function route(store: MessageStore, request: Request): Promise<Response>
562
1861
  }
563
1862
 
564
1863
  if (request.method === 'POST' && pathname === '/api/agents') {
565
- return readJson<Record<string, unknown>>(request).then((body) => {
1864
+ return readJson<Record<string, unknown>>(request).then(async (body) => {
566
1865
  const validation = store.validateAgentSchema(body);
567
1866
  if (!validation.ok) {
568
1867
  throw new HttpError(400, 'VALIDATION_ERROR', validation.diagnostics.map((d) => `${d.code}: ${d.message}`).join('; '));
569
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;
570
1872
  const agent = store.upsertAgent({
571
1873
  id: typeof body.id === 'string' ? body.id : undefined,
572
1874
  agent_key: String(body.agent_key),
573
1875
  display_name: String(body.display_name),
574
1876
  description: body.description === null ? null : typeof body.description === 'string' ? body.description : null,
575
- runtime: body.runtime === null ? null : typeof body.runtime === 'string' ? body.runtime : null,
1877
+ runtime,
1878
+ model: await checkedRuntimeModel(runtime, model),
576
1879
  });
577
1880
  return json({ agent }, 201);
578
1881
  });
579
1882
  }
580
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
+
581
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
+
582
1938
  if (request.method === 'PATCH' && agentPatchMatch) {
583
- return readJson<Record<string, unknown>>(request).then((body) => {
584
- const agentKey = agentPatchMatch[1]!;
1939
+ return readJson<Record<string, unknown>>(request).then(async (body) => {
1940
+ const agentKey = decodeURIComponent(agentPatchMatch[1]!);
585
1941
  const existing = store.getAgent(agentKey);
586
1942
  if (!existing) throw new HttpError(404, 'AGENT_NOT_FOUND', `agent ${agentKey} not found`);
587
1943
 
588
- if ('runtime' in body) {
1944
+ if ('runtime' in body || 'model' in body) {
589
1945
  const runtime = body.runtime === null ? null : typeof body.runtime === 'string' ? body.runtime : undefined;
590
- if (runtime !== undefined) {
591
- 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
+ });
592
1955
  return json({ agent: updated });
593
1956
  }
594
1957
  }
@@ -600,7 +1963,8 @@ export function route(store: MessageStore, request: Request): Promise<Response>
600
1963
  const agentAssignmentMatch = pathname.match(/^\/api\/agents\/([^/]+)\/assignment$/);
601
1964
  if (request.method === 'POST' && agentAssignmentMatch) {
602
1965
  return readJson<AssignAgentBody>(request).then((body) => {
603
- 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`);
604
1968
  if (!body.computer_id?.trim()) throw new HttpError(400, 'MISSING_COMPUTER_ID', 'computer_id is required');
605
1969
  const assignment = store.assignAgentToComputer({
606
1970
  agent: agentKey,
@@ -610,6 +1974,13 @@ export function route(store: MessageStore, request: Request): Promise<Response>
610
1974
  });
611
1975
  }
612
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
+
613
1984
  if (request.method === 'GET' && pathname === '/api/sessions') {
614
1985
  return json({ sessions: store.listSessions(numberParam(url, 'limit', 50)) });
615
1986
  }
@@ -745,6 +2116,241 @@ export function route(store: MessageStore, request: Request): Promise<Response>
745
2116
  });
746
2117
  }
747
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
+
748
2354
  const roomTopicMatch = pathname.match(/^\/api\/rooms\/([^/]+)\/topics$/);
749
2355
  if (request.method === 'POST' && roomTopicMatch) {
750
2356
  return readJson<{ name?: string; created_by?: string | null }>(request).then((body) => {
@@ -757,17 +2363,37 @@ export function route(store: MessageStore, request: Request): Promise<Response>
757
2363
  }
758
2364
 
759
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
+ }
760
2374
  const messages = store.listMessages({
761
- chatName: stringParam(url, 'chat'),
762
- chatId: stringParam(url, 'chat_id'),
2375
+ chatName,
2376
+ chatId,
763
2377
  parentId: numberParam(url, 'parent_id'),
764
2378
  after: numberParam(url, 'after'),
2379
+ before: numberParam(url, 'before'),
765
2380
  limit: numberParam(url, 'limit', 50),
766
2381
  q: stringParam(url, 'q'),
767
2382
  });
768
2383
  return json({ messages });
769
2384
  }
770
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
+
771
2397
  if (request.method === 'GET' && pathname === '/api/inbox') {
772
2398
  const agent = stringParam(url, 'agent');
773
2399
  if (!agent) throw new HttpError(400, 'MISSING_AGENT', 'agent is required');
@@ -797,17 +2423,126 @@ export function route(store: MessageStore, request: Request): Promise<Response>
797
2423
  if (request.method === 'GET' && pathname === '/api/deliveries') {
798
2424
  const agent = stringParam(url, 'agent');
799
2425
  if (agent) {
800
- 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
+ });
801
2433
  }
802
2434
  return json({ deliveries: store.listAllDeliveries(numberParam(url, 'limit', 50)) });
803
2435
  }
804
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
+
805
2539
  if (request.method === 'POST' && pathname === '/api/deliveries') {
806
2540
  return readJson<CreateDeliveryBody>(request).then((body) => {
807
2541
  if (!body.message_id) throw new HttpError(400, 'MISSING_MESSAGE_ID', 'message_id is required');
808
2542
  if (!body.agent?.trim()) throw new HttpError(400, 'MISSING_AGENT', 'agent is required');
809
2543
  const delivery = store.createDelivery({ messageId: body.message_id, agent: body.agent });
810
- return json({ delivery }, 201);
2544
+ const notify = notifyDeliveries(options, [delivery]);
2545
+ return json({ delivery, notify }, 201);
811
2546
  });
812
2547
  }
813
2548
 
@@ -822,6 +2557,7 @@ export function route(store: MessageStore, request: Request): Promise<Response>
822
2557
  connectionId: connection?.connectionId ?? body.connection_id,
823
2558
  computerId: connection?.computerId ?? body.computer_id,
824
2559
  leaseMs: body.lease_ms,
2560
+ steerRunId: body.steer_run_id,
825
2561
  });
826
2562
  return json({ delivery });
827
2563
  });
@@ -882,6 +2618,23 @@ export function route(store: MessageStore, request: Request): Promise<Response>
882
2618
  });
883
2619
  }
884
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
+
885
2638
  const deliveryFailMatch = pathname.match(/^\/api\/deliveries\/([^/]+)\/fail$/);
886
2639
  if (request.method === 'POST' && deliveryFailMatch) {
887
2640
  return readJson<FinishDeliveryBody>(request).then((body) => {
@@ -904,19 +2657,49 @@ export function route(store: MessageStore, request: Request): Promise<Response>
904
2657
  return readJson<SendBody>(request).then(async (body) => {
905
2658
  const connection = assertDaemonConnection(store, request);
906
2659
  const sender = body.sender?.trim();
907
- const content = body.content?.trim();
908
2660
  if (!sender) throw new HttpError(400, 'MISSING_SENDER', 'sender is required');
909
- 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
+ }
910
2685
 
911
- const targetRoom = body.parent_id !== undefined
912
- ? 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
913
2689
  : body.room_id ?? body.chat_id;
914
2690
  const target = targetRoom
915
2691
  ? store.getChatById(targetRoom)
916
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
+ }
917
2696
  if (target && !store.canSendWebMessageToRoom({ room: target, sender })) {
918
2697
  throw new HttpError(403, 'EXTERNAL_ROOM_READ_ONLY', 'external provider rooms are read-only for web human senders');
919
2698
  }
2699
+ if (target) {
2700
+ const mentionValidation = validateMessageMentions(store, target.id, batch);
2701
+ if (mentionValidation) return json(mentionValidation);
2702
+ }
920
2703
  if (store.getAgent(sender) || store.hasDaemonAgent(sender)) {
921
2704
  if (!connection) throw new HttpError(401, 'CONNECTION_REVOKED', 'computer connection auth is required for agent-authored messages');
922
2705
  if (!store.daemonHasAgent(connection.connectionId, sender)) {
@@ -924,47 +2707,108 @@ export function route(store: MessageStore, request: Request): Promise<Response>
924
2707
  }
925
2708
  }
926
2709
 
927
- const message = store.createMessage({
928
- chatId: body.room_id ?? body.chat_id,
929
- chatName: body.room ?? body.chat,
930
- parentId: body.parent_id,
931
- channelId: body.channel_id,
932
- sender,
933
- recipient: body.recipient,
934
- content,
935
- type: body.type,
936
- idempotencyKey: body.idempotency_key,
937
- mentions: body.mentions,
938
- });
939
- const deliveries = store.resolveDeliveriesForMessage(message.id);
940
-
941
- // If this chat has a bound Lark channel, forward bound agent replies to Lark.
942
- const credentials = loadLarkCredentials();
943
- const outboundRoute = resolveLarkOutboundRoute(store, credentials, sender, message.chat_id);
944
- if (outboundRoute) {
945
- const { conversation, bot } = outboundRoute;
946
- try {
947
- const client = createLarkApiClient(bot.appId, bot.appSecret);
948
- const result = await sendTextMessage({
949
- client,
950
- receiveIdType: 'chat_id',
951
- receiveId: conversation.external_chat_id,
952
- 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,
953
2731
  });
954
- console.log(`[lark] forwarded message to chat=${conversation.external_chat_id} messageId=${result.messageId ?? '-'}`);
955
- } catch (err) {
956
- 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);
957
2733
  }
958
2734
  }
959
2735
 
960
- 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);
961
2759
  });
962
2760
  }
963
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
+
964
2795
  if (request.method === 'GET' && pathname === '/api/runs') {
965
2796
  return json({ runs: store.listRuns(numberParam(url, 'limit', 50)) });
966
2797
  }
967
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
+
968
2812
  if (request.method === 'POST' && pathname === '/api/runs') {
969
2813
  return readJson<StartRunBody>(request).then((body) => {
970
2814
  const connection = requireDaemonConnection(store, request);
@@ -999,9 +2843,34 @@ export function route(store: MessageStore, request: Request): Promise<Response>
999
2843
  if (request.method === 'GET' && messageMatch) {
1000
2844
  const message = store.getMessage(Number(messageMatch[1]));
1001
2845
  if (!message) throw new HttpError(404, 'MESSAGE_NOT_FOUND', 'message not found');
2846
+ requireAgentScopedRead(store, request, url, message.chat_id);
1002
2847
  return json({ message });
1003
2848
  }
1004
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
+
1005
2874
  const runMatch = pathname.match(/^\/api\/runs\/([^/]+)$/);
1006
2875
  if (request.method === 'GET' && runMatch) {
1007
2876
  const run = store.getRun(runMatch[1]!);
@@ -1038,6 +2907,28 @@ export function route(store: MessageStore, request: Request): Promise<Response>
1038
2907
  });
1039
2908
  }
1040
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
+
1041
2932
  const actionMatch = pathname.match(/^\/api\/runs\/([^/]+)\/(kill|restart)$/);
1042
2933
  if (request.method === 'POST' && actionMatch) {
1043
2934
  const run = store.requestRunAction(actionMatch[1]!, actionMatch[2] as RunAction);
@@ -1047,9 +2938,9 @@ export function route(store: MessageStore, request: Request): Promise<Response>
1047
2938
  return routeNotFound();
1048
2939
  }
1049
2940
 
1050
- export async function handleRequest(store: MessageStore, request: Request): Promise<Response> {
2941
+ export async function handleRequest(store: MessageStore, request: Request, options: AppRouteOptions = {}): Promise<Response> {
1051
2942
  try {
1052
- return await route(store, request);
2943
+ return await route(store, request, options);
1053
2944
  } catch (error) {
1054
2945
  return failure(error);
1055
2946
  }