@controlflow-ai/daemon 0.1.2 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/README.md +54 -6
  2. package/package.json +3 -1
  3. package/src/agent-avatar.ts +30 -0
  4. package/src/agent-key.ts +28 -0
  5. package/src/agent-permissions.ts +359 -0
  6. package/src/agent-runtime.ts +795 -28
  7. package/src/agent-workspace.ts +183 -0
  8. package/src/app.ts +1970 -79
  9. package/src/args.ts +54 -7
  10. package/src/cli.ts +873 -14
  11. package/src/client.ts +472 -10
  12. package/src/coco.ts +9 -40
  13. package/src/codex.ts +33 -5
  14. package/src/config.ts +28 -4
  15. package/src/console.ts +230 -20
  16. package/src/daemon-client.ts +116 -3
  17. package/src/daemon.ts +936 -98
  18. package/src/db.ts +3128 -122
  19. package/src/delivery-ws.ts +269 -0
  20. package/src/format.ts +4 -1
  21. package/src/lark/cli.ts +3 -3
  22. package/src/lark/event-router.ts +60 -4
  23. package/src/lark/inbound-events.ts +156 -3
  24. package/src/lark/server-integration.ts +659 -111
  25. package/src/lark/ws-daemon.ts +136 -10
  26. package/src/local-api.ts +545 -15
  27. package/src/local-auth.ts +33 -1
  28. package/src/message-attachments.ts +71 -0
  29. package/src/messaging-cli.ts +741 -0
  30. package/src/messaging-status.ts +669 -0
  31. package/src/migrations/024_agents_model.ts +10 -0
  32. package/src/migrations/025_room_archive.ts +44 -0
  33. package/src/migrations/026_project_archive.ts +44 -0
  34. package/src/migrations/027_agent_permission_profiles.ts +16 -0
  35. package/src/migrations/028_lark_websocket_restart_state.ts +16 -0
  36. package/src/migrations/029_held_message_drafts.ts +32 -0
  37. package/src/migrations/030_agent_room_read_state.ts +25 -0
  38. package/src/migrations/031_room_tasks.ts +29 -0
  39. package/src/migrations/032_room_reminders.ts +29 -0
  40. package/src/migrations/033_room_saved_messages.ts +25 -0
  41. package/src/migrations/034_agent_activity_events.ts +27 -0
  42. package/src/migrations/035_agent_avatars.ts +17 -0
  43. package/src/migrations/036_project_agent_defaults.ts +21 -0
  44. package/src/migrations/037_message_attachments.ts +36 -0
  45. package/src/migrations/038_agent_activity_room_scope.ts +64 -0
  46. package/src/migrations/039_message_attachments_path.ts +34 -0
  47. package/src/migrations/040_message_attachments_file_schema.ts +80 -0
  48. package/src/migrations/041_room_system_events.ts +30 -0
  49. package/src/migrations/042_message_attachment_file_kind.ts +52 -0
  50. package/src/migrations/043_room_mode_skill_registry.ts +92 -0
  51. package/src/migrations/044_workflow_runtime.ts +69 -0
  52. package/src/migrations/045_skill_repository_ownership.ts +64 -0
  53. package/src/migrations.ts +69 -1
  54. package/src/neeko.ts +40 -4
  55. package/src/runtime-env.ts +179 -0
  56. package/src/runtime-registry.ts +83 -13
  57. package/src/server.ts +244 -4
  58. package/src/token-file.ts +13 -6
  59. package/src/types.ts +362 -0
  60. package/src/workflow-runtime.ts +275 -0
  61. package/src/web.ts +0 -904
package/src/local-api.ts CHANGED
@@ -1,16 +1,18 @@
1
- import { LockClient } from './client.js';
2
- import { assertLocalRequest, assertLoopbackBindHost } from './local-auth.js';
1
+ import { LockClient, type CreateHandoffRoomRequest } from './client.js';
2
+ import { authenticateLocalRequest, assertLoopbackBindHost, type LocalApiPrincipal, type RuntimeTokenLookup } from './local-auth.js';
3
3
  import { failure, HttpError, json, numberParam, readJson, stringParam } from './http.js';
4
+ import { ALL_AGENTS_MENTION } from './db.js';
4
5
  import { readdir, stat } from 'node:fs/promises';
5
6
  import { homedir } from 'node:os';
6
7
  import { dirname, isAbsolute, join, normalize } from 'node:path';
7
- import type { RemoteFileEntry, RemoteFileList, RunAction } from './types.js';
8
+ import type { HeldDraftStatus, MessageFreshnessInput, RemoteFileEntry, RemoteFileList, RoomParticipant, RoomReminderStatus, RoomTaskStatus, RunAction } from './types.js';
8
9
 
9
10
  interface LocalApiOptions {
10
11
  serverUrl: string;
11
12
  token?: string;
12
- controlToken?: string;
13
- daemonAuth?: ConstructorParameters<typeof LockClient>[1];
13
+ controlToken?: string | (() => string | undefined);
14
+ daemonAuth?: ConstructorParameters<typeof LockClient>[1] | (() => ConstructorParameters<typeof LockClient>[1]);
15
+ runtimeTokenLookup?: RuntimeTokenLookup;
14
16
  }
15
17
 
16
18
  interface ArtifactBody {
@@ -36,12 +38,324 @@ interface SendBody {
36
38
  type?: 'message' | 'system';
37
39
  idempotency_key?: string | null;
38
40
  mentions?: string[];
41
+ mention_lint?: 'strict' | 'literal' | 'infer';
42
+ attachments?: Array<{
43
+ kind?: 'image' | 'file';
44
+ mime_type?: string;
45
+ filename?: string | null;
46
+ content_base64?: string;
47
+ }>;
48
+ messages?: Array<{
49
+ parent_id?: number;
50
+ channel_id?: string | null;
51
+ recipient?: string | null;
52
+ content?: string;
53
+ type?: 'message' | 'system';
54
+ idempotency_key?: string | null;
55
+ mentions?: string[];
56
+ attachments?: Array<{
57
+ kind?: 'image' | 'file';
58
+ mime_type?: string;
59
+ filename?: string | null;
60
+ content_base64?: string;
61
+ }>;
62
+ }>;
63
+ freshness?: MessageFreshnessInput;
64
+ }
65
+
66
+ interface MentionLintResult {
67
+ missing_mentions: string[];
68
+ available_mentions: string[];
69
+ message: string;
70
+ }
71
+
72
+ function normalizeSendAttachments(attachments: SendBody['attachments']) {
73
+ return attachments?.map((attachment) => ({
74
+ kind: attachment.kind === 'image' ? 'image' as const : 'file' as const,
75
+ mime_type: attachment.mime_type ?? '',
76
+ filename: attachment.filename,
77
+ content_base64: attachment.content_base64 ?? '',
78
+ }));
79
+ }
80
+
81
+ function normalizeMentionValue(value: unknown): string {
82
+ if (typeof value !== 'string') return '';
83
+ const mention = value.trim().replace(/^@/, '');
84
+ const lower = mention.toLowerCase();
85
+ if (lower === 'all' || lower === '_all' || mention === '所有人' || mention === ALL_AGENTS_MENTION) return ALL_AGENTS_MENTION;
86
+ return mention;
87
+ }
88
+
89
+ function mentionBoundaryChar(value: string | undefined): boolean {
90
+ if (!value) return true;
91
+ return !/[\p{Letter}\p{Number}._-]/u.test(value);
92
+ }
93
+
94
+ function textMentionsKey(content: string, key: string): boolean {
95
+ if (!key) return false;
96
+ let start = 0;
97
+ const needle = `@${key}`;
98
+ while (start < content.length) {
99
+ const index = content.indexOf(needle, start);
100
+ if (index < 0) return false;
101
+ const previous = index > 0 ? content[index - 1] : undefined;
102
+ const next = content[index + needle.length];
103
+ if (mentionBoundaryChar(previous) && mentionBoundaryChar(next)) return true;
104
+ start = index + 1;
105
+ }
106
+ return false;
107
+ }
108
+
109
+ function textMentionsAllAgents(content: string): boolean {
110
+ return /(^|[^\p{Letter}\p{Number}._-])@(?:all|_all|所有人)(?=$|[^\p{Letter}\p{Number}._-])/iu.test(content);
111
+ }
112
+
113
+ function lintAgentTextMentions(input: {
114
+ sender: string;
115
+ items: Array<{ content?: string; mentions?: string[] }>;
116
+ participants: RoomParticipant[];
117
+ }): MentionLintResult | null {
118
+ const agentKeys = input.participants
119
+ .filter((participant) => participant.kind === 'agent' && participant.status === 'active')
120
+ .map((participant) => participant.participant_id)
121
+ .filter(Boolean);
122
+ if (!agentKeys.includes(input.sender)) return null;
123
+ const available = agentKeys;
124
+ const missing = new Set<string>();
125
+
126
+ for (const item of input.items) {
127
+ const content = item.content ?? '';
128
+ if (!content) continue;
129
+ const explicitMentions = new Set((item.mentions ?? []).map(normalizeMentionValue).filter(Boolean));
130
+ if (textMentionsAllAgents(content) && !explicitMentions.has(ALL_AGENTS_MENTION)) {
131
+ missing.add(ALL_AGENTS_MENTION);
132
+ }
133
+ for (const key of agentKeys) {
134
+ if (key === input.sender) continue;
135
+ if (explicitMentions.has(key)) continue;
136
+ if (textMentionsKey(content, key)) missing.add(key);
137
+ }
138
+ }
139
+
140
+ if (missing.size === 0) return null;
141
+ const display = [...missing].map((mention) => mention === ALL_AGENTS_MENTION ? 'all' : mention);
142
+ return {
143
+ missing_mentions: [...missing],
144
+ available_mentions: available,
145
+ message: `Message text contains ${display.map((mention) => `@${mention}`).join(', ')} but no matching --mention flag was provided.`,
146
+ };
147
+ }
148
+
149
+ async function lintLocalSendMentions(client: LockClient, principal: LocalApiPrincipal, body: SendBody): Promise<MentionLintResult | null> {
150
+ if (body.mention_lint === 'literal') return null;
151
+ const sender = body.sender?.trim() ?? '';
152
+ if (!sender) return null;
153
+ const roomRef = principal.kind === 'agent'
154
+ ? principal.chatId
155
+ : body.room_id ?? body.chat_id ?? body.room ?? body.chat;
156
+ if (!roomRef) return null;
157
+ let members: Awaited<ReturnType<LockClient['listRoomMembers']>>;
158
+ try {
159
+ members = await client.listRoomMembers(roomRef);
160
+ } catch (error) {
161
+ if (error instanceof HttpError && error.code === 'ROOM_NOT_FOUND') return null;
162
+ throw error;
163
+ }
164
+ const items = body.messages?.length
165
+ ? body.messages.map((message) => ({
166
+ content: message.content ?? '',
167
+ mentions: [...(body.mentions ?? []), ...(message.mentions ?? [])],
168
+ }))
169
+ : [{ content: body.content ?? '', mentions: body.mentions ?? [] }];
170
+ return lintAgentTextMentions({ sender, items, participants: members.participants });
171
+ }
172
+
173
+ function appendInferredMentions(base: string[] | undefined, missing: string[]): string[] {
174
+ const out: string[] = [];
175
+ const seen = new Set<string>();
176
+ for (const value of [...(base ?? []), ...missing]) {
177
+ const normalized = normalizeMentionValue(value);
178
+ if (!normalized || seen.has(normalized)) continue;
179
+ seen.add(normalized);
180
+ out.push(normalized);
181
+ }
182
+ return out;
183
+ }
184
+
185
+ interface CreateRoomTasksBody {
186
+ title?: string;
187
+ tasks?: Array<{ title?: string }>;
188
+ created_by?: string | null;
189
+ source_message_id?: number | null;
190
+ }
191
+
192
+ interface ClaimRoomTaskBody {
193
+ assignee?: string;
194
+ }
195
+
196
+ interface UpdateRoomTaskBody {
197
+ status?: RoomTaskStatus;
198
+ }
199
+
200
+ interface CreateRoomReminderBody {
201
+ msg_id?: number;
202
+ title?: string;
203
+ created_by?: string | null;
204
+ fire_at?: string | null;
205
+ delay_seconds?: number | null;
206
+ repeat?: string | null;
207
+ }
208
+
209
+ interface CreateRoomSavedMessageBody {
210
+ msg_id?: number;
211
+ source_message_id?: number;
212
+ saved_by?: string | null;
213
+ note?: string | null;
214
+ }
215
+
216
+ interface LocalHandoffRoomBody extends Partial<CreateHandoffRoomRequest> {}
217
+
218
+ interface ReviseHeldDraftBody {
219
+ content?: string;
39
220
  }
40
221
 
41
222
  function routeNotFound(): Response {
42
223
  return Response.json({ ok: false, code: 'NOT_FOUND', message: 'not found' }, { status: 404 });
43
224
  }
44
225
 
226
+ function requireDaemonPrincipal(principal: LocalApiPrincipal): void {
227
+ if (principal.kind !== 'daemon') {
228
+ throw new HttpError(403, 'LOCAL_AGENT_FORBIDDEN', 'agent-scoped token is not allowed for this local API route');
229
+ }
230
+ }
231
+
232
+ function requireMessageWritePrincipal(principal: LocalApiPrincipal, body: SendBody): void {
233
+ if (principal.kind === 'daemon') return;
234
+ const sender = body.sender?.trim();
235
+ const chatId = body.room_id ?? body.chat_id;
236
+ if (sender !== principal.agent) {
237
+ throw new HttpError(403, 'LOCAL_AGENT_FORBIDDEN', 'agent-scoped token can only send as its own agent');
238
+ }
239
+ if (!chatId || chatId !== principal.chatId) {
240
+ throw new HttpError(403, 'LOCAL_AGENT_FORBIDDEN', 'agent-scoped token can only send to its active room by chat_id');
241
+ }
242
+ }
243
+
244
+ function requireAgentRoomScope(principal: LocalApiPrincipal, roomRef: string): void {
245
+ if (principal.kind === 'daemon') return;
246
+ if (roomRef !== principal.chatId) {
247
+ throw new HttpError(403, 'LOCAL_AGENT_FORBIDDEN', 'agent-scoped token can only manage tasks in its active room by chat_id');
248
+ }
249
+ }
250
+
251
+ function requireAgentHandoffCreate(principal: LocalApiPrincipal, body: LocalHandoffRoomBody): void {
252
+ if (principal.kind === 'daemon') return;
253
+ if ((body.actor?.trim() || '') !== principal.agent) {
254
+ throw new HttpError(403, 'LOCAL_AGENT_FORBIDDEN', 'agent-scoped token can only create handoff rooms as its own agent');
255
+ }
256
+ if ((body.source_room_id?.trim() || '') !== principal.chatId) {
257
+ throw new HttpError(403, 'LOCAL_AGENT_FORBIDDEN', 'agent-scoped token can only create handoff rooms from its active room');
258
+ }
259
+ }
260
+
261
+ function requireAgentTaskCreator(principal: LocalApiPrincipal, roomRef: string, body: CreateRoomTasksBody): void {
262
+ requireAgentRoomScope(principal, roomRef);
263
+ if (principal.kind === 'daemon') return;
264
+ if ((body.created_by?.trim() || '') !== principal.agent) {
265
+ throw new HttpError(403, 'LOCAL_AGENT_FORBIDDEN', 'agent-scoped token can only create tasks as its own agent');
266
+ }
267
+ }
268
+
269
+ function requireAgentTaskAssignee(principal: LocalApiPrincipal, roomRef: string, body: ClaimRoomTaskBody): void {
270
+ requireAgentRoomScope(principal, roomRef);
271
+ if (principal.kind === 'daemon') return;
272
+ if ((body.assignee?.trim() || '') !== principal.agent) {
273
+ throw new HttpError(403, 'LOCAL_AGENT_FORBIDDEN', 'agent-scoped token can only claim tasks as its own agent');
274
+ }
275
+ }
276
+
277
+ function requireAgentReminderCreator(principal: LocalApiPrincipal, body: CreateRoomReminderBody): void {
278
+ if (principal.kind === 'daemon') return;
279
+ if ((body.created_by?.trim() || '') !== principal.agent) {
280
+ throw new HttpError(403, 'LOCAL_AGENT_FORBIDDEN', 'agent-scoped token can only schedule reminders as its own agent');
281
+ }
282
+ }
283
+
284
+ async function requireAgentReminderSource(client: LockClient, principal: LocalApiPrincipal, sourceMessageId: number): Promise<void> {
285
+ if (principal.kind === 'daemon') return;
286
+ const message = await client.getMessage(sourceMessageId, principal.agent);
287
+ if (message.chat_id !== principal.chatId) {
288
+ throw new HttpError(403, 'LOCAL_AGENT_FORBIDDEN', 'agent-scoped token can only schedule reminders from its active room');
289
+ }
290
+ }
291
+
292
+ async function requireAgentReminderAccess(client: LockClient, principal: LocalApiPrincipal, reminderId: string): Promise<void> {
293
+ if (principal.kind === 'daemon') return;
294
+ const reminders = await client.listReminders({
295
+ status: 'all',
296
+ created_by: principal.agent,
297
+ room_id: principal.chatId,
298
+ limit: 500,
299
+ });
300
+ if (!reminders.some((reminder) => reminder.id === reminderId)) {
301
+ throw new HttpError(403, 'LOCAL_AGENT_FORBIDDEN', 'agent-scoped token can only cancel reminders it scheduled in its active room');
302
+ }
303
+ }
304
+
305
+ function requireAgentSavedMessageCreator(principal: LocalApiPrincipal, body: CreateRoomSavedMessageBody): void {
306
+ if (principal.kind === 'daemon') return;
307
+ if ((body.saved_by?.trim() || '') !== principal.agent) {
308
+ throw new HttpError(403, 'LOCAL_AGENT_FORBIDDEN', 'agent-scoped token can only save messages as its own agent');
309
+ }
310
+ }
311
+
312
+ async function requireAgentSavedMessageSource(client: LockClient, principal: LocalApiPrincipal, sourceMessageId: number): Promise<void> {
313
+ if (principal.kind === 'daemon') return;
314
+ const message = await client.getMessage(sourceMessageId, principal.agent);
315
+ if (message.chat_id !== principal.chatId) {
316
+ throw new HttpError(403, 'LOCAL_AGENT_FORBIDDEN', 'agent-scoped token can only save messages from its active room');
317
+ }
318
+ }
319
+
320
+ async function requireAgentSavedMessageAccess(client: LockClient, principal: LocalApiPrincipal, savedMessageId: string): Promise<void> {
321
+ if (principal.kind === 'daemon') return;
322
+ const savedMessages = await client.listSavedMessages({
323
+ saved_by: principal.agent,
324
+ room_id: principal.chatId,
325
+ limit: 500,
326
+ });
327
+ if (!savedMessages.some((savedMessage) => savedMessage.id === savedMessageId)) {
328
+ throw new HttpError(403, 'LOCAL_AGENT_FORBIDDEN', 'agent-scoped token can only remove messages it saved in its active room');
329
+ }
330
+ }
331
+
332
+ async function requireAgentHeldDraftAccess(client: LockClient, principal: LocalApiPrincipal, draftId: string): Promise<void> {
333
+ if (principal.kind === 'daemon') return;
334
+ const drafts = await client.listHeldDrafts({
335
+ agent: principal.agent,
336
+ room_id: principal.chatId,
337
+ status: 'held',
338
+ limit: 500,
339
+ });
340
+ if (!drafts.some((draft) => draft.id === draftId)) {
341
+ throw new HttpError(403, 'LOCAL_AGENT_FORBIDDEN', 'agent-scoped token can only manage held drafts it owns in its active room');
342
+ }
343
+ }
344
+
345
+ async function listRoomsForPrincipal(client: LockClient, principal: LocalApiPrincipal) {
346
+ return principal.kind === 'agent' ? client.listRooms(principal.agent) : client.listRooms();
347
+ }
348
+
349
+ async function resolveRoomForPrincipal(client: LockClient, principal: LocalApiPrincipal, roomRef: string): Promise<string> {
350
+ if (principal.kind === 'daemon') return roomRef;
351
+ const rooms = await listRoomsForPrincipal(client, principal);
352
+ const room = rooms.find((candidate) => candidate.id === roomRef || candidate.name === roomRef);
353
+ if (!room) {
354
+ throw new HttpError(403, 'LOCAL_AGENT_FORBIDDEN', 'agent-scoped token can only read rooms it can participate in');
355
+ }
356
+ return room.id;
357
+ }
358
+
45
359
  async function canReadDirectory(path: string): Promise<boolean> {
46
360
  try {
47
361
  await readdir(path);
@@ -99,8 +413,10 @@ async function listLocalFiles(input: { path?: string; showHidden?: boolean }): P
99
413
 
100
414
  export async function localRoute(request: Request, options: LocalApiOptions): Promise<Response> {
101
415
  try {
102
- assertLocalRequest(request, [options.token, options.controlToken].filter((item): item is string => Boolean(item)));
103
- const client = new LockClient(options.serverUrl, options.daemonAuth ?? null);
416
+ const controlToken = typeof options.controlToken === 'function' ? options.controlToken() : options.controlToken;
417
+ const principal = authenticateLocalRequest(request, [options.token, controlToken].filter((item): item is string => Boolean(item)), options.runtimeTokenLookup);
418
+ const daemonAuth = typeof options.daemonAuth === 'function' ? options.daemonAuth() : options.daemonAuth;
419
+ const client = new LockClient(options.serverUrl, daemonAuth ?? null);
104
420
  const url = new URL(request.url);
105
421
  const { pathname } = url;
106
422
 
@@ -109,6 +425,7 @@ export async function localRoute(request: Request, options: LocalApiOptions): Pr
109
425
  }
110
426
 
111
427
  if (request.method === 'GET' && pathname === '/local/files') {
428
+ requireDaemonPrincipal(principal);
112
429
  const files = await listLocalFiles({
113
430
  path: stringParam(url, 'path'),
114
431
  showHidden: url.searchParams.get('show_hidden') === 'true',
@@ -117,52 +434,254 @@ export async function localRoute(request: Request, options: LocalApiOptions): Pr
117
434
  }
118
435
 
119
436
  if (request.method === 'GET' && pathname === '/local/chats') {
437
+ requireDaemonPrincipal(principal);
120
438
  return json({ chats: await client.listChats() });
121
439
  }
122
440
 
123
441
  if (request.method === 'GET' && pathname === '/local/rooms') {
124
- return json({ rooms: await client.listRooms() });
442
+ return json({ rooms: await listRoomsForPrincipal(client, principal) });
443
+ }
444
+
445
+ if (request.method === 'POST' && pathname === '/local/rooms/handoff') {
446
+ const body = await readJson<LocalHandoffRoomBody>(request);
447
+ requireAgentHandoffCreate(principal, body);
448
+ return json(await client.createHandoffRoom({
449
+ source_room_id: body.source_room_id ?? '',
450
+ source_message_id: body.source_message_id,
451
+ actor: body.actor ?? '',
452
+ purpose: body.purpose ?? '',
453
+ name: body.name ?? '',
454
+ team: body.team,
455
+ handoff_content: body.handoff_content,
456
+ }), 201);
125
457
  }
126
458
 
127
459
  const roomMembersMatch = pathname.match(/^\/local\/rooms\/([^/]+)\/members$/);
128
460
  if (request.method === 'GET' && roomMembersMatch) {
129
- return json(await client.listRoomMembers(decodeURIComponent(roomMembersMatch[1]!)));
461
+ const roomRef = await resolveRoomForPrincipal(client, principal, decodeURIComponent(roomMembersMatch[1]!));
462
+ return json(await client.listRoomMembers(roomRef));
130
463
  }
131
464
 
132
465
 
133
466
  const roomInviteMatch = pathname.match(/^\/local\/rooms\/([^/]+)\/agents$/);
134
467
  if (request.method === 'POST' && roomInviteMatch) {
468
+ requireDaemonPrincipal(principal);
135
469
  const body = await readJson<{ agent?: string; mode?: string }>(request);
136
470
  return json(await client.inviteAgentToRoom(decodeURIComponent(roomInviteMatch[1]!), { agent: body.agent ?? '', mode: body.mode as never }), 201);
137
471
  }
138
472
 
139
473
  const roomTopicMatch = pathname.match(/^\/local\/rooms\/([^/]+)\/topics$/);
140
474
  if (request.method === 'POST' && roomTopicMatch) {
475
+ requireDaemonPrincipal(principal);
141
476
  const body = await readJson<{ name?: string; created_by?: string | null }>(request);
142
477
  if (!body.name?.trim()) throw new HttpError(400, 'MISSING_TOPIC_NAME', 'name is required');
143
478
  const channel = await client.createTopic(decodeURIComponent(roomTopicMatch[1]!), { name: body.name, created_by: body.created_by });
144
479
  return json({ channel }, 201);
145
480
  }
146
481
 
482
+ const roomTasksMatch = pathname.match(/^\/local\/rooms\/([^/]+)\/tasks$/);
483
+ if (request.method === 'GET' && roomTasksMatch) {
484
+ const roomRef = decodeURIComponent(roomTasksMatch[1]!);
485
+ requireAgentRoomScope(principal, roomRef);
486
+ return json({
487
+ tasks: await client.listRoomTasks(
488
+ roomRef,
489
+ (stringParam(url, 'status') ?? 'all') as RoomTaskStatus | 'all',
490
+ numberParam(url, 'limit', 50),
491
+ ),
492
+ });
493
+ }
494
+
495
+ if (request.method === 'POST' && roomTasksMatch) {
496
+ const roomRef = decodeURIComponent(roomTasksMatch[1]!);
497
+ const body = await readJson<CreateRoomTasksBody>(request);
498
+ requireAgentTaskCreator(principal, roomRef, body);
499
+ return json({
500
+ tasks: await client.createRoomTasks(roomRef, {
501
+ title: body.title,
502
+ tasks: body.tasks?.map((task) => ({ title: task.title ?? '' })),
503
+ created_by: body.created_by,
504
+ source_message_id: body.source_message_id,
505
+ }),
506
+ }, 201);
507
+ }
508
+
509
+ const roomTaskClaimMatch = pathname.match(/^\/local\/rooms\/([^/]+)\/tasks\/([^/]+)\/claim$/);
510
+ if (request.method === 'POST' && roomTaskClaimMatch) {
511
+ const roomRef = decodeURIComponent(roomTaskClaimMatch[1]!);
512
+ const body = await readJson<ClaimRoomTaskBody>(request);
513
+ requireAgentTaskAssignee(principal, roomRef, body);
514
+ return json({ task: await client.claimRoomTask(roomRef, Number(roomTaskClaimMatch[2]), { assignee: body.assignee ?? '' }) });
515
+ }
516
+
517
+ const roomTaskUnclaimMatch = pathname.match(/^\/local\/rooms\/([^/]+)\/tasks\/([^/]+)\/unclaim$/);
518
+ if (request.method === 'POST' && roomTaskUnclaimMatch) {
519
+ const roomRef = decodeURIComponent(roomTaskUnclaimMatch[1]!);
520
+ requireAgentRoomScope(principal, roomRef);
521
+ return json({ task: await client.unclaimRoomTask(roomRef, Number(roomTaskUnclaimMatch[2])) });
522
+ }
523
+
524
+ const roomTaskStatusMatch = pathname.match(/^\/local\/rooms\/([^/]+)\/tasks\/([^/]+)$/);
525
+ if (request.method === 'PATCH' && roomTaskStatusMatch) {
526
+ const roomRef = decodeURIComponent(roomTaskStatusMatch[1]!);
527
+ requireAgentRoomScope(principal, roomRef);
528
+ const body = await readJson<UpdateRoomTaskBody>(request);
529
+ return json({ task: await client.updateRoomTaskStatus(roomRef, Number(roomTaskStatusMatch[2]), body.status as RoomTaskStatus) });
530
+ }
531
+
532
+ const roomAgentRestartMatch = pathname.match(/^\/local\/rooms\/([^/]+)\/agents\/([^/]+)\/restart$/);
533
+ if (request.method === 'POST' && roomAgentRestartMatch) {
534
+ const roomRef = decodeURIComponent(roomAgentRestartMatch[1]!);
535
+ requireAgentRoomScope(principal, roomRef);
536
+ const restartAgent = decodeURIComponent(roomAgentRestartMatch[2]!);
537
+ if (principal.kind === 'agent' && restartAgent !== principal.agent) {
538
+ throw new HttpError(403, 'LOCAL_AGENT_FORBIDDEN', 'agent-scoped token can only restart its own runtime');
539
+ }
540
+ return json({ run: await client.restartRoomAgent(roomRef, restartAgent) });
541
+ }
542
+
543
+ if (request.method === 'GET' && pathname === '/local/reminders') {
544
+ return json({
545
+ reminders: await client.listReminders({
546
+ status: (stringParam(url, 'status') ?? 'scheduled') as RoomReminderStatus | 'all',
547
+ created_by: principal.kind === 'agent' ? principal.agent : stringParam(url, 'created_by'),
548
+ room_id: principal.kind === 'agent'
549
+ ? principal.chatId
550
+ : stringParam(url, 'room_id') ?? stringParam(url, 'chat_id'),
551
+ limit: numberParam(url, 'limit', 50),
552
+ }),
553
+ });
554
+ }
555
+
556
+ if (request.method === 'POST' && pathname === '/local/reminders') {
557
+ const body = await readJson<CreateRoomReminderBody>(request);
558
+ requireAgentReminderCreator(principal, body);
559
+ const sourceMessageId = Number(body.msg_id);
560
+ if (!Number.isInteger(sourceMessageId) || sourceMessageId < 1) {
561
+ throw new HttpError(400, 'INVALID_SOURCE_MESSAGE_ID', 'msg_id must be a positive integer');
562
+ }
563
+ await requireAgentReminderSource(client, principal, sourceMessageId);
564
+ return json({
565
+ reminder: await client.scheduleReminder({
566
+ msg_id: sourceMessageId,
567
+ title: body.title ?? '',
568
+ created_by: body.created_by,
569
+ fire_at: body.fire_at,
570
+ delay_seconds: body.delay_seconds,
571
+ repeat: body.repeat,
572
+ }),
573
+ }, 201);
574
+ }
575
+
576
+ const reminderCancelMatch = pathname.match(/^\/local\/reminders\/([^/]+)\/cancel$/);
577
+ if (request.method === 'POST' && reminderCancelMatch) {
578
+ const reminderId = decodeURIComponent(reminderCancelMatch[1]!);
579
+ await requireAgentReminderAccess(client, principal, reminderId);
580
+ return json({ reminder: await client.cancelReminder(reminderId) });
581
+ }
582
+
583
+ if (request.method === 'GET' && pathname === '/local/saved-messages') {
584
+ return json({
585
+ saved_messages: await client.listSavedMessages({
586
+ saved_by: principal.kind === 'agent' ? principal.agent : stringParam(url, 'saved_by'),
587
+ room_id: principal.kind === 'agent'
588
+ ? principal.chatId
589
+ : stringParam(url, 'room_id') ?? stringParam(url, 'chat_id'),
590
+ limit: numberParam(url, 'limit', 50),
591
+ }),
592
+ });
593
+ }
594
+
595
+ if (request.method === 'POST' && pathname === '/local/saved-messages') {
596
+ const body = await readJson<CreateRoomSavedMessageBody>(request);
597
+ requireAgentSavedMessageCreator(principal, body);
598
+ const sourceMessageId = Number(body.msg_id ?? body.source_message_id);
599
+ if (!Number.isInteger(sourceMessageId) || sourceMessageId < 1) {
600
+ throw new HttpError(400, 'INVALID_SOURCE_MESSAGE_ID', 'msg_id must be a positive integer');
601
+ }
602
+ await requireAgentSavedMessageSource(client, principal, sourceMessageId);
603
+ return json({
604
+ saved_message: await client.saveMessage({
605
+ msg_id: sourceMessageId,
606
+ saved_by: body.saved_by ?? '',
607
+ note: body.note,
608
+ }),
609
+ }, 201);
610
+ }
611
+
612
+ const savedMessageDeleteMatch = pathname.match(/^\/local\/saved-messages\/([^/]+)\/delete$/);
613
+ if (request.method === 'POST' && savedMessageDeleteMatch) {
614
+ const savedMessageId = decodeURIComponent(savedMessageDeleteMatch[1]!);
615
+ await requireAgentSavedMessageAccess(client, principal, savedMessageId);
616
+ return json({ saved_message: await client.removeSavedMessage(savedMessageId) });
617
+ }
618
+
619
+ if (request.method === 'GET' && pathname === '/local/held-drafts') {
620
+ const agent = principal.kind === 'agent'
621
+ ? principal.agent
622
+ : stringParam(url, 'agent') ?? stringParam(url, 'from') ?? stringParam(url, 'sender');
623
+ if (!agent) throw new HttpError(400, 'MISSING_AGENT', 'held draft list requires agent');
624
+ return json({
625
+ drafts: await client.listHeldDrafts({
626
+ agent,
627
+ room_id: principal.kind === 'agent'
628
+ ? principal.chatId
629
+ : stringParam(url, 'room_id') ?? stringParam(url, 'chat_id'),
630
+ status: (stringParam(url, 'status') ?? 'held') as HeldDraftStatus | 'all',
631
+ limit: numberParam(url, 'limit', 50),
632
+ }),
633
+ });
634
+ }
635
+
636
+ const heldDraftActionMatch = pathname.match(/^\/local\/held-drafts\/([^/]+)\/(abandon|stay-silent|send-anyway|revise)$/);
637
+ if (request.method === 'POST' && heldDraftActionMatch) {
638
+ const draftId = decodeURIComponent(heldDraftActionMatch[1]!);
639
+ const action = heldDraftActionMatch[2]!;
640
+ await requireAgentHeldDraftAccess(client, principal, draftId);
641
+ if (action === 'revise') {
642
+ const body = await readJson<ReviseHeldDraftBody>(request);
643
+ return json(await client.reviseHeldDraft(draftId, body.content ?? ''));
644
+ }
645
+ if (action === 'send-anyway') {
646
+ return json(await client.sendHeldDraftAnyway(draftId));
647
+ }
648
+ return json(await client.abandonHeldDraft(draftId));
649
+ }
650
+
147
651
  if (request.method === 'GET' && pathname === '/local/messages') {
148
652
  const params = new URLSearchParams();
149
653
  for (const key of ['chat', 'room', 'chat_id', 'room_id', 'parent_id', 'channel_id', 'after', 'limit', 'q']) {
150
654
  const value = url.searchParams.get(key);
151
655
  if (value !== null) params.set(key, value);
152
656
  }
657
+ if (principal.kind === 'agent') {
658
+ const chatId = params.get('chat_id') ?? params.get('room_id');
659
+ const chat = params.get('chat') ?? params.get('room');
660
+ if (!chatId && !chat) throw new HttpError(400, 'MISSING_ROOM_SCOPE', 'agent-scoped message reads require chat_id or chat');
661
+ params.set('agent_scope', principal.agent);
662
+ }
153
663
  return json({ messages: await client.getMessages(params) });
154
664
  }
155
665
 
156
666
  const messageMatch = pathname.match(/^\/local\/messages\/(\d+)$/);
157
667
  if (request.method === 'GET' && messageMatch) {
158
- return json({ message: await client.getMessage(Number(messageMatch[1])) });
668
+ return json({ message: await client.getMessage(Number(messageMatch[1]), principal.kind === 'agent' ? principal.agent : undefined) });
159
669
  }
160
670
 
161
671
  if (request.method === 'POST' && pathname === '/local/messages') {
162
672
  const body = await readJson<SendBody>(request);
673
+ requireMessageWritePrincipal(principal, body);
163
674
  if (!body.sender?.trim()) throw new HttpError(400, 'MISSING_SENDER', 'sender is required');
164
- if (!body.content?.trim()) throw new HttpError(400, 'MISSING_CONTENT', 'content is required');
165
- const message = await client.sendMessage({
675
+ const hasContent = body.content?.trim()
676
+ || body.attachments?.length
677
+ || body.messages?.some((message) => message.content?.trim() || message.attachments?.length);
678
+ if (!hasContent) throw new HttpError(400, 'MISSING_CONTENT', 'content or attachments are required');
679
+ const mentionLint = await lintLocalSendMentions(client, principal, body);
680
+ if (mentionLint && body.mention_lint !== 'infer') {
681
+ return json({ mention_lint: mentionLint });
682
+ }
683
+ const inferredMentions = mentionLint?.missing_mentions ?? [];
684
+ const result = await client.sendMessageResult({
166
685
  chat: body.room ?? body.chat,
167
686
  room: body.room,
168
687
  chat_id: body.room_id ?? body.chat_id,
@@ -171,19 +690,29 @@ export async function localRoute(request: Request, options: LocalApiOptions): Pr
171
690
  channel_id: body.channel_id,
172
691
  sender: body.sender,
173
692
  recipient: body.recipient,
174
- content: body.content,
693
+ content: body.content ?? '',
175
694
  type: body.type,
176
695
  idempotency_key: body.idempotency_key,
177
- mentions: body.mentions,
696
+ mentions: appendInferredMentions(body.mentions, inferredMentions),
697
+ attachments: normalizeSendAttachments(body.attachments),
698
+ messages: body.messages?.map((message) => ({
699
+ ...message,
700
+ content: message.content ?? '',
701
+ mentions: appendInferredMentions(message.mentions, inferredMentions),
702
+ attachments: normalizeSendAttachments(message.attachments),
703
+ })),
704
+ freshness: body.freshness,
178
705
  });
179
- return json({ message }, 201);
706
+ return json(result, result.message ? 201 : 202);
180
707
  }
181
708
 
182
709
  if (request.method === 'GET' && pathname === '/local/runs') {
710
+ requireDaemonPrincipal(principal);
183
711
  return json({ runs: await client.listRuns() });
184
712
  }
185
713
 
186
714
  if (request.method === 'POST' && pathname === '/local/artifacts') {
715
+ requireDaemonPrincipal(principal);
187
716
  const body = await readJson<ArtifactBody>(request);
188
717
  if (body.source_path || body.path) throw new HttpError(400, 'PATH_NOT_ALLOWED', 'artifact upload must include content, not a path');
189
718
  if (!body.content_base64) throw new HttpError(400, 'MISSING_CONTENT', 'content_base64 is required');
@@ -200,6 +729,7 @@ export async function localRoute(request: Request, options: LocalApiOptions): Pr
200
729
 
201
730
  const actionMatch = pathname.match(/^\/local\/runs\/([^/]+)\/(kill|restart)$/);
202
731
  if (request.method === 'POST' && actionMatch) {
732
+ requireDaemonPrincipal(principal);
203
733
  const run = await client.requestRunAction(actionMatch[1]!, actionMatch[2] as RunAction);
204
734
  return json({ run });
205
735
  }