@controlflow-ai/daemon 0.1.1 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/README.md +66 -24
  2. package/package.json +16 -3
  3. package/src/agent-avatar.ts +30 -0
  4. package/src/agent-key.ts +28 -0
  5. package/src/agent-permissions.ts +359 -0
  6. package/src/agent-runtime.ts +810 -28
  7. package/src/agent-workspace.ts +183 -0
  8. package/src/app.ts +2183 -79
  9. package/src/args.ts +54 -7
  10. package/src/cli.ts +873 -14
  11. package/src/client.ts +482 -12
  12. package/src/coco.ts +9 -40
  13. package/src/codex.ts +33 -5
  14. package/src/config.ts +28 -4
  15. package/src/console.ts +460 -26
  16. package/src/daemon-client.ts +116 -3
  17. package/src/daemon.ts +958 -101
  18. package/src/db.ts +3216 -113
  19. package/src/delivery-ws.ts +269 -0
  20. package/src/format.ts +4 -1
  21. package/src/lark/app-registration.ts +141 -0
  22. package/src/lark/cli.ts +7 -137
  23. package/src/lark/credentials.ts +36 -3
  24. package/src/lark/event-router.ts +61 -5
  25. package/src/lark/inbound-events.ts +156 -3
  26. package/src/lark/server-integration.ts +659 -111
  27. package/src/lark/setup.ts +74 -5
  28. package/src/lark/ws-daemon.ts +136 -10
  29. package/src/local-api.ts +611 -14
  30. package/src/local-auth.ts +36 -3
  31. package/src/message-attachments.ts +71 -0
  32. package/src/messaging-cli.ts +741 -0
  33. package/src/messaging-status.ts +669 -0
  34. package/src/migrations/023_projects.ts +65 -0
  35. package/src/migrations/024_agents_model.ts +10 -0
  36. package/src/migrations/025_room_archive.ts +44 -0
  37. package/src/migrations/026_project_archive.ts +44 -0
  38. package/src/migrations/027_agent_permission_profiles.ts +16 -0
  39. package/src/migrations/028_lark_websocket_restart_state.ts +16 -0
  40. package/src/migrations/029_held_message_drafts.ts +32 -0
  41. package/src/migrations/030_agent_room_read_state.ts +25 -0
  42. package/src/migrations/031_room_tasks.ts +29 -0
  43. package/src/migrations/032_room_reminders.ts +29 -0
  44. package/src/migrations/033_room_saved_messages.ts +25 -0
  45. package/src/migrations/034_agent_activity_events.ts +27 -0
  46. package/src/migrations/035_agent_avatars.ts +17 -0
  47. package/src/migrations/036_project_agent_defaults.ts +21 -0
  48. package/src/migrations/037_message_attachments.ts +36 -0
  49. package/src/migrations/038_agent_activity_room_scope.ts +64 -0
  50. package/src/migrations/039_message_attachments_path.ts +34 -0
  51. package/src/migrations/040_message_attachments_file_schema.ts +80 -0
  52. package/src/migrations/041_room_system_events.ts +30 -0
  53. package/src/migrations/042_message_attachment_file_kind.ts +52 -0
  54. package/src/migrations/043_room_mode_skill_registry.ts +92 -0
  55. package/src/migrations/044_workflow_runtime.ts +69 -0
  56. package/src/migrations/045_skill_repository_ownership.ts +64 -0
  57. package/src/migrations.ts +70 -1
  58. package/src/neeko.ts +40 -4
  59. package/src/runtime-env.ts +179 -0
  60. package/src/runtime-registry.ts +83 -13
  61. package/src/server.ts +244 -4
  62. package/src/token-file.ts +13 -6
  63. package/src/types.ts +394 -0
  64. package/src/workflow-runtime.ts +275 -0
  65. package/src/web.ts +0 -904
package/src/local-api.ts CHANGED
@@ -1,12 +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 type { RunAction } from './types.js';
4
+ import { ALL_AGENTS_MENTION } from './db.js';
5
+ import { readdir, stat } from 'node:fs/promises';
6
+ import { homedir } from 'node:os';
7
+ import { dirname, isAbsolute, join, normalize } from 'node:path';
8
+ import type { HeldDraftStatus, MessageFreshnessInput, RemoteFileEntry, RemoteFileList, RoomParticipant, RoomReminderStatus, RoomTaskStatus, RunAction } from './types.js';
5
9
 
6
10
  interface LocalApiOptions {
7
11
  serverUrl: string;
8
12
  token?: string;
9
- daemonAuth?: ConstructorParameters<typeof LockClient>[1];
13
+ controlToken?: string | (() => string | undefined);
14
+ daemonAuth?: ConstructorParameters<typeof LockClient>[1] | (() => ConstructorParameters<typeof LockClient>[1]);
15
+ runtimeTokenLookup?: RuntimeTokenLookup;
10
16
  }
11
17
 
12
18
  interface ArtifactBody {
@@ -32,16 +38,385 @@ interface SendBody {
32
38
  type?: 'message' | 'system';
33
39
  idempotency_key?: string | null;
34
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;
35
220
  }
36
221
 
37
222
  function routeNotFound(): Response {
38
223
  return Response.json({ ok: false, code: 'NOT_FOUND', message: 'not found' }, { status: 404 });
39
224
  }
40
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
+
359
+ async function canReadDirectory(path: string): Promise<boolean> {
360
+ try {
361
+ await readdir(path);
362
+ return true;
363
+ } catch {
364
+ return false;
365
+ }
366
+ }
367
+
368
+ async function listLocalFiles(input: { path?: string; showHidden?: boolean }): Promise<RemoteFileList> {
369
+ const home = homedir();
370
+ const rawPath = input.path?.trim() || home;
371
+ if (!isAbsolute(rawPath)) throw new HttpError(400, 'PATH_NOT_ABSOLUTE', 'path must be absolute');
372
+ const targetPath = normalize(rawPath);
373
+ const targetStat = await stat(targetPath).catch(() => null);
374
+ if (!targetStat) throw new HttpError(404, 'PATH_NOT_FOUND', 'path was not found');
375
+ if (!targetStat.isDirectory()) throw new HttpError(400, 'PATH_NOT_DIRECTORY', 'path is not a directory');
376
+
377
+ let dirents: Array<{ name: string; isDirectory(): boolean; isFile(): boolean }>;
378
+ try {
379
+ dirents = await readdir(targetPath, { withFileTypes: true }) as Array<{ name: string; isDirectory(): boolean; isFile(): boolean }>;
380
+ } catch {
381
+ throw new HttpError(403, 'PATH_NOT_READABLE', 'directory is not readable');
382
+ }
383
+
384
+ const entries = await Promise.all(dirents
385
+ .filter((entry) => input.showHidden || !entry.name.startsWith('.'))
386
+ .map(async (entry): Promise<RemoteFileEntry> => {
387
+ const entryPath = join(targetPath, entry.name);
388
+ const type = entry.isDirectory() ? 'directory' : entry.isFile() ? 'file' : 'other';
389
+ const readable = type === 'directory' ? await canReadDirectory(entryPath) : false;
390
+ return {
391
+ name: entry.name,
392
+ path: entryPath,
393
+ type,
394
+ hidden: entry.name.startsWith('.'),
395
+ readable,
396
+ selectable: type === 'directory' && readable,
397
+ };
398
+ }));
399
+
400
+ entries.sort((left, right) => {
401
+ if (left.type === 'directory' && right.type !== 'directory') return -1;
402
+ if (left.type !== 'directory' && right.type === 'directory') return 1;
403
+ return left.name.localeCompare(right.name);
404
+ });
405
+
406
+ return {
407
+ path: targetPath,
408
+ parent: targetPath === dirname(targetPath) ? null : dirname(targetPath),
409
+ home,
410
+ entries,
411
+ };
412
+ }
413
+
41
414
  export async function localRoute(request: Request, options: LocalApiOptions): Promise<Response> {
42
415
  try {
43
- assertLocalRequest(request, options.token);
44
- 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);
45
420
  const url = new URL(request.url);
46
421
  const { pathname } = url;
47
422
 
@@ -49,53 +424,264 @@ export async function localRoute(request: Request, options: LocalApiOptions): Pr
49
424
  return json({ status: 'ok', mode: 'daemon' });
50
425
  }
51
426
 
427
+ if (request.method === 'GET' && pathname === '/local/files') {
428
+ requireDaemonPrincipal(principal);
429
+ const files = await listLocalFiles({
430
+ path: stringParam(url, 'path'),
431
+ showHidden: url.searchParams.get('show_hidden') === 'true',
432
+ });
433
+ return json(files);
434
+ }
435
+
52
436
  if (request.method === 'GET' && pathname === '/local/chats') {
437
+ requireDaemonPrincipal(principal);
53
438
  return json({ chats: await client.listChats() });
54
439
  }
55
440
 
56
441
  if (request.method === 'GET' && pathname === '/local/rooms') {
57
- 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);
58
457
  }
59
458
 
60
459
  const roomMembersMatch = pathname.match(/^\/local\/rooms\/([^/]+)\/members$/);
61
460
  if (request.method === 'GET' && roomMembersMatch) {
62
- 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));
63
463
  }
64
464
 
65
465
 
66
466
  const roomInviteMatch = pathname.match(/^\/local\/rooms\/([^/]+)\/agents$/);
67
467
  if (request.method === 'POST' && roomInviteMatch) {
468
+ requireDaemonPrincipal(principal);
68
469
  const body = await readJson<{ agent?: string; mode?: string }>(request);
69
470
  return json(await client.inviteAgentToRoom(decodeURIComponent(roomInviteMatch[1]!), { agent: body.agent ?? '', mode: body.mode as never }), 201);
70
471
  }
71
472
 
72
473
  const roomTopicMatch = pathname.match(/^\/local\/rooms\/([^/]+)\/topics$/);
73
474
  if (request.method === 'POST' && roomTopicMatch) {
475
+ requireDaemonPrincipal(principal);
74
476
  const body = await readJson<{ name?: string; created_by?: string | null }>(request);
75
477
  if (!body.name?.trim()) throw new HttpError(400, 'MISSING_TOPIC_NAME', 'name is required');
76
478
  const channel = await client.createTopic(decodeURIComponent(roomTopicMatch[1]!), { name: body.name, created_by: body.created_by });
77
479
  return json({ channel }, 201);
78
480
  }
79
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
+
80
651
  if (request.method === 'GET' && pathname === '/local/messages') {
81
652
  const params = new URLSearchParams();
82
653
  for (const key of ['chat', 'room', 'chat_id', 'room_id', 'parent_id', 'channel_id', 'after', 'limit', 'q']) {
83
654
  const value = url.searchParams.get(key);
84
655
  if (value !== null) params.set(key, value);
85
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
+ }
86
663
  return json({ messages: await client.getMessages(params) });
87
664
  }
88
665
 
89
666
  const messageMatch = pathname.match(/^\/local\/messages\/(\d+)$/);
90
667
  if (request.method === 'GET' && messageMatch) {
91
- 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) });
92
669
  }
93
670
 
94
671
  if (request.method === 'POST' && pathname === '/local/messages') {
95
672
  const body = await readJson<SendBody>(request);
673
+ requireMessageWritePrincipal(principal, body);
96
674
  if (!body.sender?.trim()) throw new HttpError(400, 'MISSING_SENDER', 'sender is required');
97
- if (!body.content?.trim()) throw new HttpError(400, 'MISSING_CONTENT', 'content is required');
98
- 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({
99
685
  chat: body.room ?? body.chat,
100
686
  room: body.room,
101
687
  chat_id: body.room_id ?? body.chat_id,
@@ -104,19 +690,29 @@ export async function localRoute(request: Request, options: LocalApiOptions): Pr
104
690
  channel_id: body.channel_id,
105
691
  sender: body.sender,
106
692
  recipient: body.recipient,
107
- content: body.content,
693
+ content: body.content ?? '',
108
694
  type: body.type,
109
695
  idempotency_key: body.idempotency_key,
110
- 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,
111
705
  });
112
- return json({ message }, 201);
706
+ return json(result, result.message ? 201 : 202);
113
707
  }
114
708
 
115
709
  if (request.method === 'GET' && pathname === '/local/runs') {
710
+ requireDaemonPrincipal(principal);
116
711
  return json({ runs: await client.listRuns() });
117
712
  }
118
713
 
119
714
  if (request.method === 'POST' && pathname === '/local/artifacts') {
715
+ requireDaemonPrincipal(principal);
120
716
  const body = await readJson<ArtifactBody>(request);
121
717
  if (body.source_path || body.path) throw new HttpError(400, 'PATH_NOT_ALLOWED', 'artifact upload must include content, not a path');
122
718
  if (!body.content_base64) throw new HttpError(400, 'MISSING_CONTENT', 'content_base64 is required');
@@ -133,6 +729,7 @@ export async function localRoute(request: Request, options: LocalApiOptions): Pr
133
729
 
134
730
  const actionMatch = pathname.match(/^\/local\/runs\/([^/]+)\/(kill|restart)$/);
135
731
  if (request.method === 'POST' && actionMatch) {
732
+ requireDaemonPrincipal(principal);
136
733
  const run = await client.requestRunAction(actionMatch[1]!, actionMatch[2] as RunAction);
137
734
  return json({ run });
138
735
  }