@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/db.ts CHANGED
@@ -1,10 +1,14 @@
1
1
  import { Database, type SQLQueryBindings } from 'bun:sqlite';
2
2
  import { createHash, randomBytes } from 'node:crypto';
3
- import { ensureParentDir } from './config.js';
3
+ import { isAbsolute } from 'node:path';
4
+ import { ensureParentDir, homeDir as defaultPalHomeDir } from './config.js';
4
5
  import { runMigrations } from './migrations.js';
5
6
  import { artifactExpiry, generateArtifactToken, hashArtifactToken, validateArtifactContent } from './artifacts.js';
7
+ import { storeMessageAttachmentFile, validateMessageAttachment } from './message-attachments.js';
6
8
  import { palIdentityHandle } from './provider-identity.js';
7
- import type { AgentDefinition, AgentRun, AgentSession, AgentValidationResult, Artifact, ArtifactMetadata, ChannelAccount, ChannelConversation, ChannelMessageMapping, ChannelOutboxRecord, Chat, ChatKind, Computer, ComputerAgentAssignment, ComputerConnection, DaemonInstance, LarkAuthorizedUser, LarkGroupRoomMapping, LockProviderEvidence, LockTranscriptMessage, Message, MessageDelivery, MessageType, AgentRoomSubscription, AgentRoomSubscriptionMode, PendingInboundEvent, ProvisionedComputer, ProviderExternalType, ProviderIdentityBinding, PalIdentity, RoomChannel, RoomParticipant, RoomParticipantKind, RoomParticipantSource, RoomProvider, RunAction, RunStatus, TranscriptReadModel, WorkbenchArtifact } from './types.js';
9
+ import type { AgentActivityEvent, AgentActivityKind, AgentDefinition, AgentDeletionImpact, AgentDeletionLarkBindingImpact, AgentDeletionResult, AgentDeletionRoomImpact, AgentPermissionProfile, AgentRun, AgentSession, AgentValidationResult, AgentWorkbench, AgentWorkbenchCollaboration, AgentWorkbenchLarkBinding, AgentWorkbenchRoom, Artifact, ArtifactMetadata, ChannelAccount, ChannelConversation, ChannelMessageMapping, ChannelOutboxRecord, Chat, ChatKind, ChatStatus, Computer, ComputerAgentAssignment, ComputerConnection, ComputerDeletionImpact, ComputerDeletionResult, ComputerWorkload, DaemonInstance, DeliveryContext, EnabledSkill, HeldDraftStatus, HeldMessageDraft, LarkAuthorizedUser, LarkGroupRoomMapping, LockProviderEvidence, LockTranscriptMessage, Message, MessageAttachment, MessageAttachmentKind, MessageAttachmentMetadata, MessageDelivery, MessageType, AgentRoomSubscription, AgentRoomSubscriptionMode, PendingInboundEvent, Project, ProjectAgentDefault, ProjectStatus, ProvisionedComputer, ProviderExternalType, ProviderIdentityBinding, PalIdentity, RoomAgentActivityStatus, RoomChannel, RoomParticipant, RoomParticipantKind, RoomParticipantSource, RoomProvider, RoomReminder, RoomReminderStatus, RoomSavedMessage, RoomMode, RoomSystemEvent, RoomSystemEventActorKind, RoomSystemEventType, RoomTask, RoomTaskStatus, RunAction, RunStatus, SkillBinding, SkillBindingScope, SkillDefinition, SkillSource, TranscriptReadModel, WorkbenchArtifact, WorkflowNode, WorkflowNodeKind, WorkflowNodeStatus, WorkflowRun, WorkflowRunRecord, WorkflowRunStatus } from './types.js';
10
+ import { defaultAgentPermissionProfile, normalizeExtraWritableRoots, validateAgentPermissionProfile, type AgentPermissionProfile as RuntimeAgentPermissionProfile } from './agent-permissions.js';
11
+ import { normalizeAgentAvatar } from './agent-avatar.js';
8
12
 
9
13
  export const ALL_AGENTS_MENTION = '__pal_all_agents__';
10
14
 
@@ -20,6 +24,16 @@ export interface CreateMessageInput {
20
24
  channelId?: string | null;
21
25
  provider?: RoomProvider;
22
26
  mentions?: string[];
27
+ attachments?: CreateMessageAttachmentInput[];
28
+ }
29
+
30
+ export interface CreateMessageAttachmentInput {
31
+ kind: MessageAttachmentKind;
32
+ mimeType: string;
33
+ filename?: string | null;
34
+ content: Uint8Array;
35
+ sourceProvider?: RoomProvider | null;
36
+ sourceRef?: string | null;
23
37
  }
24
38
 
25
39
  export interface ListMessagesInput {
@@ -27,10 +41,36 @@ export interface ListMessagesInput {
27
41
  chatName?: string;
28
42
  parentId?: number | null;
29
43
  after?: number;
44
+ before?: number;
30
45
  limit?: number;
31
46
  q?: string;
32
47
  }
33
48
 
49
+ export interface UpdateAgentRoomSubscriptionInput {
50
+ roomId: string;
51
+ agent: string;
52
+ mode: AgentRoomSubscriptionMode;
53
+ actorKind?: RoomSystemEventActorKind;
54
+ actorId?: string | null;
55
+ }
56
+
57
+ export interface RecordRoomSystemEventInput {
58
+ roomId: string;
59
+ eventType: RoomSystemEventType;
60
+ actorKind?: RoomSystemEventActorKind;
61
+ actorId?: string | null;
62
+ subjectAgent?: string | null;
63
+ metadata?: Record<string, unknown> | null;
64
+ content?: string;
65
+ deliveryAgents?: string[];
66
+ }
67
+
68
+ export interface MarkAgentRoomReadInput {
69
+ roomId: string;
70
+ agent: string;
71
+ messageId?: number;
72
+ }
73
+
34
74
  export interface StartRunInput {
35
75
  messageId: number;
36
76
  agent: string;
@@ -53,6 +93,14 @@ export interface FinishRunInput {
53
93
  output?: string;
54
94
  }
55
95
 
96
+ export interface RecordAgentActivityInput {
97
+ runId: string;
98
+ kind: AgentActivityKind;
99
+ title: string;
100
+ detail?: string | null;
101
+ metadata?: Record<string, unknown> | null;
102
+ }
103
+
56
104
  export interface RegisterDaemonInput {
57
105
  id?: string;
58
106
  name: string;
@@ -85,6 +133,7 @@ export interface ConnectComputerResult {
85
133
  computer: Computer;
86
134
  connection: ComputerConnection;
87
135
  token: string;
136
+ localControlToken: string;
88
137
  daemon: DaemonInstance;
89
138
  agents: ComputerAgentAssignment[];
90
139
  }
@@ -95,16 +144,155 @@ export interface ProvisionComputerInput {
95
144
  packageName?: string;
96
145
  }
97
146
 
147
+ export interface RegenerateComputerCommandInput {
148
+ serverUrl?: string;
149
+ packageName?: string;
150
+ }
151
+
98
152
  export interface CreateDeliveryInput {
99
153
  messageId: number;
100
154
  agent: string;
101
155
  }
102
156
 
157
+ function daemonCommandPrefix(input?: string): string {
158
+ const raw = input?.trim() || 'bun run daemon';
159
+ if (/\s/.test(raw) || raw.startsWith('./') || raw.startsWith('/') || raw.startsWith('node ')) return raw;
160
+ return `npx ${raw}`;
161
+ }
162
+
163
+ function shellValue(value: string): string {
164
+ if (/^[A-Za-z0-9_./:@-]+$/.test(value)) return value;
165
+ return `'${value.replace(/'/g, `'\\''`)}'`;
166
+ }
167
+
168
+ function daemonStartCommand(input: { commandPrefix?: string; serverUrl: string; apiKey: string; name: string }): string {
169
+ return `PAL_SERVER=${shellValue(input.serverUrl)} PAL_API_KEY=${shellValue(input.apiKey)} ${daemonCommandPrefix(input.commandPrefix)} # ${input.name}`;
170
+ }
171
+
172
+ export interface CreateHeldMessageDraftInput {
173
+ chatId: string;
174
+ agent: string;
175
+ sender: string;
176
+ content: string;
177
+ mentions?: string[];
178
+ baseMessageId: number;
179
+ latestMessageIdAtHold: number;
180
+ holdReason: string;
181
+ }
182
+
183
+ export interface CreateRoomTasksInput {
184
+ roomId: string;
185
+ titles: string[];
186
+ createdBy?: string | null;
187
+ sourceMessageId?: number | null;
188
+ }
189
+
190
+ export interface ClaimRoomTaskInput {
191
+ roomId: string;
192
+ taskNumber: number;
193
+ assignee: string;
194
+ }
195
+
196
+ export interface UpdateRoomTaskInput {
197
+ roomId: string;
198
+ taskNumber: number;
199
+ status: RoomTaskStatus;
200
+ }
201
+
202
+ export interface CreateRoomReminderInput {
203
+ sourceMessageId: number;
204
+ title: string;
205
+ createdBy?: string | null;
206
+ fireAt?: string | null;
207
+ delaySeconds?: number | null;
208
+ repeat?: string | null;
209
+ }
210
+
211
+ export interface ListRoomRemindersInput {
212
+ roomId?: string | null;
213
+ status?: RoomReminderStatus | 'all';
214
+ createdBy?: string | null;
215
+ limit?: number;
216
+ }
217
+
218
+ export interface CreateRoomSavedMessageInput {
219
+ sourceMessageId: number;
220
+ savedBy: string;
221
+ note?: string | null;
222
+ }
223
+
224
+ export interface ListRoomSavedMessagesInput {
225
+ roomId?: string | null;
226
+ savedBy?: string | null;
227
+ limit?: number;
228
+ }
229
+
230
+ export interface CreateWorkflowRunInput {
231
+ filePath: string;
232
+ goal?: string | null;
233
+ createdBy?: string | null;
234
+ }
235
+
236
+ export interface FinishWorkflowRunInput {
237
+ status: WorkflowRunStatus;
238
+ finalOutput?: Record<string, unknown> | null;
239
+ error?: string | null;
240
+ }
241
+
242
+ export interface CreateWorkflowNodeInput {
243
+ runId: string;
244
+ parentId?: string | null;
245
+ kind: WorkflowNodeKind;
246
+ title: string;
247
+ role?: string | null;
248
+ status?: WorkflowNodeStatus;
249
+ context?: Record<string, unknown> | null;
250
+ instruction?: string | null;
251
+ capabilities?: string[];
252
+ outputContract?: Record<string, unknown> | null;
253
+ output?: Record<string, unknown> | null;
254
+ evidence?: Record<string, unknown> | null;
255
+ taskId?: string | null;
256
+ messageId?: number | null;
257
+ }
258
+
259
+ export interface UpdateWorkflowNodeInput {
260
+ status?: WorkflowNodeStatus;
261
+ output?: Record<string, unknown> | null;
262
+ evidence?: Record<string, unknown> | null;
263
+ taskId?: string | null;
264
+ messageId?: number | null;
265
+ }
266
+
267
+ export interface SaveRoomMessageResult {
268
+ savedMessage: RoomSavedMessage;
269
+ created: boolean;
270
+ }
271
+
272
+ export interface FireDueRoomRemindersInput {
273
+ now?: string | Date | null;
274
+ limit?: number;
275
+ }
276
+
277
+ export interface FiredRoomReminder {
278
+ reminder: RoomReminder;
279
+ message: Message;
280
+ deliveries: MessageDelivery[];
281
+ }
282
+
283
+ export interface FireDueRoomRemindersResult {
284
+ reminders: RoomReminder[];
285
+ messages: Message[];
286
+ deliveries: MessageDelivery[];
287
+ fired: FiredRoomReminder[];
288
+ }
289
+
103
290
  export interface ClaimDeliveryInput {
104
291
  daemonId: string;
105
292
  connectionId?: string | null;
106
293
  computerId?: string | null;
107
294
  leaseMs?: number;
295
+ steerRunId?: string | null;
108
296
  }
109
297
 
110
298
  export interface FinishDeliveryInput {
@@ -115,6 +303,22 @@ export interface FinishDeliveryInput {
115
303
  error?: string;
116
304
  }
117
305
 
306
+ export interface DeliveryBacklogSummary {
307
+ agent: string;
308
+ pending: number;
309
+ claimed: number;
310
+ processing_completed: number;
311
+ expired_active: number;
312
+ }
313
+
314
+ export interface LarkWebsocketRestartState {
315
+ app_id: string;
316
+ restart_count: number;
317
+ last_restart_at: string | null;
318
+ last_restart_reason: string | null;
319
+ updated_at: string;
320
+ }
321
+
118
322
  export interface CreateArtifactInput {
119
323
  title?: string;
120
324
  filename?: string;
@@ -250,7 +454,7 @@ export interface UpsertRoomParticipantInput {
250
454
  source: RoomParticipantSource;
251
455
  }
252
456
 
253
- const agentFieldAllowlist = new Set(['id', 'agent_key', 'display_name', 'description', 'runtime', 'created_at', 'updated_at']);
457
+ const agentFieldAllowlist = new Set(['id', 'agent_key', 'display_name', 'description', 'runtime', 'model', 'created_at', 'updated_at']);
254
458
  const forbiddenAgentFieldPattern = /provider|adapter|enabled|disabled|status|state|mode|config|capabilities|route|routing|binding|permission|owner|workspace|daemon|delivery|channel|lark|acl|authz/i;
255
459
 
256
460
  export type ChannelMessageConflictCode =
@@ -313,6 +517,156 @@ function normalizeChatName(name?: string): string {
313
517
  return stripped;
314
518
  }
315
519
 
520
+ const roomTaskStatuses: RoomTaskStatus[] = ['todo', 'in_progress', 'in_review', 'done', 'closed'];
521
+ const roomReminderStatuses: RoomReminderStatus[] = ['scheduled', 'fired', 'canceled'];
522
+ const roomModes: RoomMode[] = ['standard', 'idea_development'];
523
+ const skillSources: SkillSource[] = ['system', 'user', 'project'];
524
+ const skillBindingScopes: SkillBindingScope[] = ['project', 'room', 'agent'];
525
+ const skillStatuses: SkillDefinition['status'][] = ['active', 'disabled'];
526
+ const workflowRunStatuses: WorkflowRunStatus[] = ['running', 'completed', 'failed'];
527
+ const workflowNodeKinds: WorkflowNodeKind[] = ['phase', 'agent', 'task', 'message', 'final'];
528
+ const workflowNodeStatuses: WorkflowNodeStatus[] = ['pending', 'running', 'done', 'failed'];
529
+
530
+ function assertRoomMode(value: string): RoomMode {
531
+ if (!roomModes.includes(value as RoomMode)) throw new Error('INVALID_ROOM_MODE');
532
+ return value as RoomMode;
533
+ }
534
+
535
+ function assertSkillBindingScope(value: string): SkillBindingScope {
536
+ if (!skillBindingScopes.includes(value as SkillBindingScope)) throw new Error('INVALID_SKILL_BINDING_SCOPE');
537
+ return value as SkillBindingScope;
538
+ }
539
+
540
+ function assertSkillSource(value: string): SkillSource {
541
+ if (!skillSources.includes(value as SkillSource)) throw new Error('INVALID_SKILL_SOURCE');
542
+ return value as SkillSource;
543
+ }
544
+
545
+ function assertSkillStatus(value: string): SkillDefinition['status'] {
546
+ if (!skillStatuses.includes(value as SkillDefinition['status'])) throw new Error('INVALID_SKILL_STATUS');
547
+ return value as SkillDefinition['status'];
548
+ }
549
+
550
+ function assertRoomTaskStatus(value: string): RoomTaskStatus {
551
+ if (!roomTaskStatuses.includes(value as RoomTaskStatus)) throw new Error('INVALID_TASK_STATUS');
552
+ return value as RoomTaskStatus;
553
+ }
554
+
555
+ function assertRoomReminderStatus(value: string): RoomReminderStatus {
556
+ if (!roomReminderStatuses.includes(value as RoomReminderStatus)) throw new Error('INVALID_REMINDER_STATUS');
557
+ return value as RoomReminderStatus;
558
+ }
559
+
560
+ function assertWorkflowRunStatus(value: string): WorkflowRunStatus {
561
+ if (!workflowRunStatuses.includes(value as WorkflowRunStatus)) throw new Error('INVALID_WORKFLOW_RUN_STATUS');
562
+ return value as WorkflowRunStatus;
563
+ }
564
+
565
+ function assertWorkflowNodeKind(value: string): WorkflowNodeKind {
566
+ if (!workflowNodeKinds.includes(value as WorkflowNodeKind)) throw new Error('INVALID_WORKFLOW_NODE_KIND');
567
+ return value as WorkflowNodeKind;
568
+ }
569
+
570
+ function assertWorkflowNodeStatus(value: string): WorkflowNodeStatus {
571
+ if (!workflowNodeStatuses.includes(value as WorkflowNodeStatus)) throw new Error('INVALID_WORKFLOW_NODE_STATUS');
572
+ return value as WorkflowNodeStatus;
573
+ }
574
+
575
+ function jsonRecordOrNull(value: unknown): Record<string, unknown> | null {
576
+ if (value === null || value === undefined || value === '') return null;
577
+ try {
578
+ const parsed = JSON.parse(String(value)) as unknown;
579
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null;
580
+ return parsed as Record<string, unknown>;
581
+ } catch {
582
+ return null;
583
+ }
584
+ }
585
+
586
+ function jsonStringArray(value: unknown): string[] {
587
+ if (value === null || value === undefined || value === '') return [];
588
+ try {
589
+ const parsed = JSON.parse(String(value)) as unknown;
590
+ if (!Array.isArray(parsed)) return [];
591
+ return parsed.filter((entry): entry is string => typeof entry === 'string');
592
+ } catch {
593
+ return [];
594
+ }
595
+ }
596
+
597
+ function normalizeReminderFireAt(input: { fireAt?: string | null; delaySeconds?: number | null }): string {
598
+ if (input.fireAt?.trim()) {
599
+ const date = new Date(input.fireAt);
600
+ if (Number.isNaN(date.getTime())) throw new Error('INVALID_REMINDER_TIME');
601
+ return date.toISOString();
602
+ }
603
+ if (input.delaySeconds !== undefined && input.delaySeconds !== null) {
604
+ if (!Number.isFinite(input.delaySeconds) || input.delaySeconds < 0) throw new Error('INVALID_REMINDER_TIME');
605
+ return new Date(Date.now() + input.delaySeconds * 1000).toISOString();
606
+ }
607
+ throw new Error('INVALID_REMINDER_TIME');
608
+ }
609
+
610
+ function normalizeReminderFireNow(value?: string | Date | null): string {
611
+ if (value === undefined || value === null) return new Date().toISOString();
612
+ const date = value instanceof Date ? value : new Date(value);
613
+ if (Number.isNaN(date.getTime())) throw new Error('INVALID_REMINDER_TIME');
614
+ return date.toISOString();
615
+ }
616
+
617
+ const reminderRepeatAliases = new Map<string, string>([
618
+ ['hourly', 'every:1h'],
619
+ ['daily', 'every:1d'],
620
+ ['weekly', 'every:1w'],
621
+ ]);
622
+
623
+ const reminderRepeatUnitMs: Record<string, number> = {
624
+ s: 1000,
625
+ m: 60 * 1000,
626
+ h: 60 * 60 * 1000,
627
+ d: 24 * 60 * 60 * 1000,
628
+ w: 7 * 24 * 60 * 60 * 1000,
629
+ };
630
+
631
+ function normalizeReminderRepeat(value?: string | null): string | null {
632
+ const raw = value?.trim().toLowerCase() || '';
633
+ if (!raw || raw === 'none' || raw === 'off') return null;
634
+ const aliased = reminderRepeatAliases.get(raw) ?? raw;
635
+ const match = aliased.match(/^every:(\d+)([smhdw])$/);
636
+ if (!match) throw new Error('INVALID_REMINDER_REPEAT');
637
+ const count = Number(match[1]);
638
+ const unit = match[2]!;
639
+ if (!Number.isInteger(count) || count < 1) throw new Error('INVALID_REMINDER_REPEAT');
640
+ return `every:${count}${unit}`;
641
+ }
642
+
643
+ function reminderRepeatMs(repeat: string): number {
644
+ const normalized = normalizeReminderRepeat(repeat);
645
+ if (!normalized) throw new Error('INVALID_REMINDER_REPEAT');
646
+ const match = normalized.match(/^every:(\d+)([smhdw])$/)!;
647
+ return Number(match[1]) * reminderRepeatUnitMs[match[2]!]!;
648
+ }
649
+
650
+ function nextReminderFireAt(reminder: RoomReminder, now: string): string {
651
+ const fireMs = new Date(reminder.fire_at).getTime();
652
+ const nowMs = new Date(now).getTime();
653
+ const intervalMs = reminderRepeatMs(reminder.repeat ?? '');
654
+ if (Number.isNaN(fireMs) || Number.isNaN(nowMs)) throw new Error('INVALID_REMINDER_TIME');
655
+ const periods = Math.max(1, Math.floor((nowMs - fireMs) / intervalMs) + 1);
656
+ return new Date(fireMs + periods * intervalMs).toISOString();
657
+ }
658
+
659
+ function reminderFireIdempotencyKey(reminder: RoomReminder): string {
660
+ return reminder.repeat ? `room-reminder:${reminder.id}:fire:${reminder.fire_at}` : `room-reminder:${reminder.id}:fire`;
661
+ }
662
+
663
+ function reminderFireMessageContent(reminder: RoomReminder, source: Message | null): string {
664
+ const sourceLine = source
665
+ ? `Source message #${source.id} from @${source.sender}: ${source.content}`
666
+ : `Source message #${reminder.source_message_id}`;
667
+ return `Reminder: ${reminder.title}\n${sourceLine}`;
668
+ }
669
+
316
670
  function parseCapabilities(value: unknown): { topics: 'native' | 'unsupported' } {
317
671
  if (typeof value !== 'string') return { topics: 'native' };
318
672
  try {
@@ -332,12 +686,32 @@ function generateConnectionToken(): string {
332
686
  }
333
687
 
334
688
  function rowToAgent(row: Record<string, unknown>): AgentDefinition {
689
+ const agentKey = String(row.agent_key);
690
+ const displayName = String(row.display_name);
335
691
  return {
336
692
  id: String(row.id),
337
- agent_key: String(row.agent_key),
338
- display_name: String(row.display_name),
693
+ agent_key: agentKey,
694
+ display_name: displayName,
339
695
  description: row.description === null ? null : String(row.description),
340
696
  runtime: row.runtime === null ? null : String(row.runtime),
697
+ model: row.model === null || row.model === undefined ? null : String(row.model),
698
+ avatar: normalizeAgentAvatar(row.avatar, agentKey, displayName),
699
+ created_at: String(row.created_at),
700
+ updated_at: String(row.updated_at),
701
+ };
702
+ }
703
+
704
+ function rowToAgentPermissionProfile(row: Record<string, unknown>): AgentPermissionProfile {
705
+ let roots: string[] = [];
706
+ try {
707
+ roots = normalizeExtraWritableRoots(JSON.parse(String(row.extra_writable_roots_json ?? '[]')));
708
+ } catch {
709
+ roots = [];
710
+ }
711
+ return {
712
+ agent_key: String(row.agent_key),
713
+ filesystem_mode: String(row.filesystem_mode) as AgentPermissionProfile['filesystem_mode'],
714
+ extra_writable_roots: roots,
341
715
  created_at: String(row.created_at),
342
716
  updated_at: String(row.updated_at),
343
717
  };
@@ -348,6 +722,7 @@ function rowToComputerAgentAssignment(row: Record<string, unknown>): ComputerAge
348
722
  agent: String(row.agent),
349
723
  display_name: String(row.display_name),
350
724
  runtime: row.runtime === null ? null : String(row.runtime),
725
+ model: row.model === null || row.model === undefined ? null : String(row.model),
351
726
  computer_id: String(row.computer_id),
352
727
  cwd: String(row.cwd ?? ''),
353
728
  status: String(row.status) as ComputerAgentAssignment['status'],
@@ -356,6 +731,71 @@ function rowToComputerAgentAssignment(row: Record<string, unknown>): ComputerAge
356
731
  };
357
732
  }
358
733
 
734
+ function rowToProject(row: Record<string, unknown>): Project {
735
+ return {
736
+ id: String(row.id),
737
+ name: String(row.name),
738
+ computer_id: String(row.computer_id),
739
+ computer_name: row.computer_name === null || row.computer_name === undefined ? null : String(row.computer_name),
740
+ root_path: String(row.root_path),
741
+ status: row.status === null || row.status === undefined ? 'active' : String(row.status) as Project['status'],
742
+ room_count: Number(row.room_count ?? 0),
743
+ created_at: String(row.created_at),
744
+ updated_at: String(row.updated_at),
745
+ };
746
+ }
747
+
748
+ function rowToProjectAgentDefault(row: Record<string, unknown>): ProjectAgentDefault {
749
+ return {
750
+ id: String(row.id),
751
+ project_id: String(row.project_id),
752
+ agent: String(row.agent),
753
+ display_name: row.display_name === null || row.display_name === undefined ? null : String(row.display_name),
754
+ mode: String(row.mode) as AgentRoomSubscriptionMode,
755
+ created_at: String(row.created_at),
756
+ updated_at: String(row.updated_at),
757
+ };
758
+ }
759
+
760
+ function rowToSkillDefinition(row: Record<string, unknown>): SkillDefinition {
761
+ return {
762
+ key: String(row.key),
763
+ name: String(row.name),
764
+ description: String(row.description),
765
+ instruction_content: String(row.instruction_content),
766
+ status: String(row.status) as SkillDefinition['status'],
767
+ version: String(row.version),
768
+ source: String(row.source ?? 'system') as SkillSource,
769
+ owner_user_id: row.owner_user_id === null || row.owner_user_id === undefined ? null : String(row.owner_user_id),
770
+ project_id: row.project_id === null || row.project_id === undefined ? null : String(row.project_id),
771
+ repository_path: row.repository_path === null || row.repository_path === undefined ? null : String(row.repository_path),
772
+ created_at: String(row.created_at),
773
+ updated_at: String(row.updated_at),
774
+ };
775
+ }
776
+
777
+ function rowToSkillBinding(row: Record<string, unknown>): SkillBinding {
778
+ return {
779
+ id: String(row.id),
780
+ scope: String(row.scope) as SkillBindingScope,
781
+ scope_id: String(row.scope_id),
782
+ skill_key: String(row.skill_key),
783
+ enabled: Number(row.enabled) === 1,
784
+ priority: Number(row.priority),
785
+ created_at: String(row.created_at),
786
+ updated_at: String(row.updated_at),
787
+ };
788
+ }
789
+
790
+ function rowToEnabledSkill(row: Record<string, unknown>): EnabledSkill {
791
+ return {
792
+ ...rowToSkillDefinition(row),
793
+ binding_scope: String(row.binding_scope) as SkillBindingScope,
794
+ binding_scope_id: String(row.binding_scope_id),
795
+ binding_priority: Number(row.binding_priority),
796
+ };
797
+ }
798
+
359
799
  function rowToMessage(row: Record<string, unknown>): Message {
360
800
  return {
361
801
  id: Number(row.id),
@@ -372,6 +812,161 @@ function rowToMessage(row: Record<string, unknown>): Message {
372
812
  channel_id: row.channel_id === null || row.channel_id === undefined ? null : String(row.channel_id),
373
813
  provider: row.provider === null || row.provider === undefined ? undefined : String(row.provider) as RoomProvider,
374
814
  mentions: parseMentionsJson(row.mentions_json),
815
+ attachments: [],
816
+ };
817
+ }
818
+
819
+ function rowToRoomSystemEvent(row: Record<string, unknown>): RoomSystemEvent {
820
+ let metadata: Record<string, unknown> = {};
821
+ try {
822
+ const parsed = JSON.parse(String(row.metadata_json ?? '{}')) as unknown;
823
+ metadata = parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed as Record<string, unknown> : {};
824
+ } catch {
825
+ metadata = {};
826
+ }
827
+ return {
828
+ id: String(row.id),
829
+ room_id: String(row.room_id),
830
+ message_id: Number(row.message_id),
831
+ event_type: String(row.event_type) as RoomSystemEventType,
832
+ actor_kind: String(row.actor_kind) as RoomSystemEventActorKind,
833
+ actor_id: row.actor_id === null || row.actor_id === undefined ? null : String(row.actor_id),
834
+ subject_agent: row.subject_agent === null || row.subject_agent === undefined ? null : String(row.subject_agent),
835
+ metadata,
836
+ created_at: String(row.created_at),
837
+ };
838
+ }
839
+
840
+ function rowToMessageAttachmentMetadata(row: Record<string, unknown>): MessageAttachmentMetadata {
841
+ return {
842
+ id: String(row.id),
843
+ message_id: Number(row.message_id),
844
+ kind: String(row.kind) as MessageAttachmentKind,
845
+ mime_type: String(row.mime_type),
846
+ filename: String(row.filename ?? ''),
847
+ size_bytes: Number(row.size_bytes),
848
+ path: String(row.path ?? ''),
849
+ source_provider: row.source_provider === null || row.source_provider === undefined ? null : String(row.source_provider) as RoomProvider,
850
+ created_at: String(row.created_at),
851
+ };
852
+ }
853
+
854
+ function rowToMessageAttachment(row: Record<string, unknown>): MessageAttachment {
855
+ return {
856
+ ...rowToMessageAttachmentMetadata(row),
857
+ source_ref: row.source_ref === null || row.source_ref === undefined ? null : String(row.source_ref),
858
+ };
859
+ }
860
+
861
+ function rowToHeldMessageDraft(row: Record<string, unknown>): HeldMessageDraft {
862
+ return {
863
+ id: String(row.id),
864
+ chat_id: String(row.chat_id),
865
+ chat_name: String(row.chat_name ?? ''),
866
+ chat_display_name: row.chat_display_name === null || row.chat_display_name === undefined ? null : String(row.chat_display_name),
867
+ agent: String(row.agent),
868
+ sender: String(row.sender),
869
+ content: String(row.content),
870
+ mentions: parseMentionsJson(row.mentions_json),
871
+ base_message_id: Number(row.base_message_id),
872
+ latest_message_id_at_hold: Number(row.latest_message_id_at_hold),
873
+ status: String(row.status) as HeldDraftStatus,
874
+ hold_reason: String(row.hold_reason),
875
+ hold_count: Number(row.hold_count),
876
+ intervening_message_count: Number(row.intervening_message_count ?? 0),
877
+ resolved_message_id: row.resolved_message_id === null || row.resolved_message_id === undefined ? null : Number(row.resolved_message_id),
878
+ created_at: String(row.created_at),
879
+ updated_at: String(row.updated_at),
880
+ resolved_at: row.resolved_at === null || row.resolved_at === undefined ? null : String(row.resolved_at),
881
+ };
882
+ }
883
+
884
+ function rowToRoomTask(row: Record<string, unknown>): RoomTask {
885
+ return {
886
+ id: String(row.id),
887
+ room_id: String(row.room_id),
888
+ room_name: String(row.room_name ?? ''),
889
+ task_number: Number(row.task_number),
890
+ title: String(row.title),
891
+ status: String(row.status) as RoomTaskStatus,
892
+ assignee: row.assignee === null || row.assignee === undefined ? null : String(row.assignee),
893
+ created_by: row.created_by === null || row.created_by === undefined ? null : String(row.created_by),
894
+ source_message_id: row.source_message_id === null || row.source_message_id === undefined ? null : Number(row.source_message_id),
895
+ created_at: String(row.created_at),
896
+ updated_at: String(row.updated_at),
897
+ completed_at: row.completed_at === null || row.completed_at === undefined ? null : String(row.completed_at),
898
+ };
899
+ }
900
+
901
+ function rowToRoomReminder(row: Record<string, unknown>): RoomReminder {
902
+ return {
903
+ id: String(row.id),
904
+ room_id: String(row.room_id),
905
+ room_name: String(row.room_name ?? ''),
906
+ source_message_id: Number(row.source_message_id),
907
+ title: String(row.title),
908
+ status: String(row.status) as RoomReminderStatus,
909
+ created_by: row.created_by === null || row.created_by === undefined ? null : String(row.created_by),
910
+ fire_at: String(row.fire_at),
911
+ repeat: row.repeat === null || row.repeat === undefined ? null : String(row.repeat),
912
+ fired_at: row.fired_at === null || row.fired_at === undefined ? null : String(row.fired_at),
913
+ canceled_at: row.canceled_at === null || row.canceled_at === undefined ? null : String(row.canceled_at),
914
+ created_at: String(row.created_at),
915
+ updated_at: String(row.updated_at),
916
+ };
917
+ }
918
+
919
+ function rowToRoomSavedMessage(row: Record<string, unknown>): RoomSavedMessage {
920
+ return {
921
+ id: String(row.id),
922
+ room_id: String(row.room_id),
923
+ room_name: String(row.room_name ?? ''),
924
+ message_id: Number(row.message_id),
925
+ message_sender: String(row.message_sender),
926
+ message_content: String(row.message_content),
927
+ message_created_at: String(row.message_created_at),
928
+ saved_by: String(row.saved_by),
929
+ note: row.note === null || row.note === undefined ? null : String(row.note),
930
+ created_at: String(row.created_at),
931
+ updated_at: String(row.updated_at),
932
+ };
933
+ }
934
+
935
+ function rowToWorkflowRun(row: Record<string, unknown>): WorkflowRun {
936
+ return {
937
+ id: String(row.id),
938
+ file_path: String(row.file_path),
939
+ goal: row.goal === null || row.goal === undefined ? null : String(row.goal),
940
+ status: String(row.status) as WorkflowRunStatus,
941
+ created_by: row.created_by === null || row.created_by === undefined ? null : String(row.created_by),
942
+ final_output: jsonRecordOrNull(row.final_output_json),
943
+ error: row.error === null || row.error === undefined ? null : String(row.error),
944
+ created_at: String(row.created_at),
945
+ updated_at: String(row.updated_at),
946
+ completed_at: row.completed_at === null || row.completed_at === undefined ? null : String(row.completed_at),
947
+ };
948
+ }
949
+
950
+ function rowToWorkflowNode(row: Record<string, unknown>): WorkflowNode {
951
+ return {
952
+ id: String(row.id),
953
+ run_id: String(row.run_id),
954
+ parent_id: row.parent_id === null || row.parent_id === undefined ? null : String(row.parent_id),
955
+ kind: String(row.kind) as WorkflowNodeKind,
956
+ title: String(row.title),
957
+ role: row.role === null || row.role === undefined ? null : String(row.role),
958
+ status: String(row.status) as WorkflowNodeStatus,
959
+ context: jsonRecordOrNull(row.context_json),
960
+ instruction: row.instruction === null || row.instruction === undefined ? null : String(row.instruction),
961
+ capabilities: jsonStringArray(row.capabilities_json),
962
+ output_contract: jsonRecordOrNull(row.output_contract_json),
963
+ output: jsonRecordOrNull(row.output_json),
964
+ evidence: jsonRecordOrNull(row.evidence_json),
965
+ task_id: row.task_id === null || row.task_id === undefined ? null : String(row.task_id),
966
+ message_id: row.message_id === null || row.message_id === undefined ? null : Number(row.message_id),
967
+ created_at: String(row.created_at),
968
+ updated_at: String(row.updated_at),
969
+ completed_at: row.completed_at === null || row.completed_at === undefined ? null : String(row.completed_at),
375
970
  };
376
971
  }
377
972
 
@@ -401,6 +996,28 @@ function rowToRun(row: Record<string, unknown>): AgentRun {
401
996
  };
402
997
  }
403
998
 
999
+ function rowToActivityEvent(row: Record<string, unknown>): AgentActivityEvent {
1000
+ let metadata: Record<string, unknown> = {};
1001
+ try {
1002
+ const parsed = JSON.parse(String(row.metadata ?? '{}')) as unknown;
1003
+ metadata = parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed as Record<string, unknown> : {};
1004
+ } catch {
1005
+ metadata = {};
1006
+ }
1007
+ return {
1008
+ id: String(row.id),
1009
+ run_id: String(row.run_id),
1010
+ session_id: row.session_id === null || row.session_id === undefined ? null : String(row.session_id),
1011
+ agent: String(row.agent),
1012
+ room_id: String(row.room_id ?? row.chat_id),
1013
+ kind: String(row.kind) as AgentActivityKind,
1014
+ title: String(row.title),
1015
+ detail: String(row.detail ?? ''),
1016
+ metadata,
1017
+ created_at: String(row.created_at),
1018
+ };
1019
+ }
1020
+
404
1021
  function rowToSession(row: Record<string, unknown>): AgentSession {
405
1022
  return {
406
1023
  id: String(row.id),
@@ -460,6 +1077,7 @@ function rowToDelivery(row: Record<string, unknown>): MessageDelivery {
460
1077
  id: String(row.id),
461
1078
  message_id: Number(row.message_id),
462
1079
  chat_id: String(row.chat_id),
1080
+ ...(row.chat_name === undefined ? {} : { chat_name: row.chat_name === null ? null : String(row.chat_name) }),
463
1081
  agent: String(row.agent),
464
1082
  status: String(row.status) as MessageDelivery['status'],
465
1083
  daemon_id: row.daemon_id === null ? null : String(row.daemon_id),
@@ -603,6 +1221,8 @@ function rowToAgentRoomSubscription(row: Record<string, unknown>): AgentRoomSubs
603
1221
  room_id: String(row.room_id),
604
1222
  channel_id: row.channel_id === null || row.channel_id === undefined ? null : String(row.channel_id),
605
1223
  mode: String(row.mode) as AgentRoomSubscriptionMode,
1224
+ last_read_message_id: row.last_read_message_id === null || row.last_read_message_id === undefined ? 0 : Number(row.last_read_message_id),
1225
+ last_read_at: row.last_read_at === null || row.last_read_at === undefined ? null : String(row.last_read_at),
606
1226
  created_at: String(row.created_at),
607
1227
  updated_at: String(row.updated_at),
608
1228
  };
@@ -703,7 +1323,41 @@ function normalizeMentionsInput(value: string[] | undefined): string[] {
703
1323
  seen.add(trimmed);
704
1324
  result.push(trimmed);
705
1325
  }
706
- return result;
1326
+ return result;
1327
+ }
1328
+
1329
+ function normalizeSystemEventActorKind(value?: RoomSystemEventActorKind): RoomSystemEventActorKind {
1330
+ if (value === 'user' || value === 'agent' || value === 'system') return value;
1331
+ return 'system';
1332
+ }
1333
+
1334
+ function normalizeSystemEventMetadata(value?: Record<string, unknown> | null): Record<string, unknown> {
1335
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return {};
1336
+ return value;
1337
+ }
1338
+
1339
+ function systemEventContent(input: {
1340
+ eventType: RoomSystemEventType;
1341
+ actorKind: RoomSystemEventActorKind;
1342
+ actorId: string | null;
1343
+ subjectAgent: string | null;
1344
+ metadata: Record<string, unknown>;
1345
+ }): string {
1346
+ const actor = input.actorId ? ` by ${input.actorKind}:${input.actorId}` : '';
1347
+ const agent = input.subjectAgent ? `@${input.subjectAgent}` : 'An agent';
1348
+ switch (input.eventType) {
1349
+ case 'agent_invited': {
1350
+ const mode = typeof input.metadata.mode === 'string' ? input.metadata.mode : 'mentions';
1351
+ return `System event: ${agent} was invited to this room with receive mode "${mode}"${actor}.`;
1352
+ }
1353
+ case 'agent_removed':
1354
+ return `System event: ${agent} was removed from this room${actor}.`;
1355
+ case 'agent_receive_mode_changed': {
1356
+ const oldMode = typeof input.metadata.old_mode === 'string' ? input.metadata.old_mode : 'unknown';
1357
+ const newMode = typeof input.metadata.new_mode === 'string' ? input.metadata.new_mode : 'unknown';
1358
+ return `System event: ${agent} changed room receive mode from "${oldMode}" to "${newMode}"${actor}.`;
1359
+ }
1360
+ }
707
1361
  }
708
1362
 
709
1363
  function rowToTranscriptReadModel(row: Record<string, unknown>): TranscriptReadModel {
@@ -788,8 +1442,10 @@ function limitValue(value: number | undefined, fallback: number): number {
788
1442
 
789
1443
  export class MessageStore {
790
1444
  readonly db: Database;
1445
+ private readonly palHome: string;
791
1446
 
792
- constructor(path: string) {
1447
+ constructor(path: string, options: { palHome?: string } = {}) {
1448
+ this.palHome = options.palHome ?? defaultPalHomeDir();
793
1449
  ensureParentDir(path);
794
1450
  this.db = new Database(path);
795
1451
  this.db.exec('PRAGMA journal_mode = WAL');
@@ -802,6 +1458,43 @@ export class MessageStore {
802
1458
  this.db.close();
803
1459
  }
804
1460
 
1461
+ getLarkWebsocketRestartState(appId: string): LarkWebsocketRestartState | null {
1462
+ const key = appId.trim();
1463
+ if (!key) return null;
1464
+ const row = this.db.query(`
1465
+ SELECT app_id, restart_count, last_restart_at, last_restart_reason, updated_at
1466
+ FROM lark_websocket_restart_state
1467
+ WHERE app_id = ?
1468
+ LIMIT 1
1469
+ `).get(key) as LarkWebsocketRestartState | null;
1470
+ if (!row) return null;
1471
+ return {
1472
+ app_id: String(row.app_id),
1473
+ restart_count: Number(row.restart_count),
1474
+ last_restart_at: row.last_restart_at === null ? null : String(row.last_restart_at),
1475
+ last_restart_reason: row.last_restart_reason === null ? null : String(row.last_restart_reason),
1476
+ updated_at: String(row.updated_at),
1477
+ };
1478
+ }
1479
+
1480
+ recordLarkWebsocketRestart(input: { appId: string; restartedAt: string; reason: string }): LarkWebsocketRestartState {
1481
+ const appId = input.appId.trim();
1482
+ if (!appId) throw new Error('appId is required');
1483
+ const restartedAt = input.restartedAt.trim();
1484
+ if (!restartedAt) throw new Error('restartedAt is required');
1485
+ const reason = input.reason.trim() || 'unknown';
1486
+ this.db.query(`
1487
+ INSERT INTO lark_websocket_restart_state (app_id, restart_count, last_restart_at, last_restart_reason, updated_at)
1488
+ VALUES (?, 1, ?, ?, datetime('now'))
1489
+ ON CONFLICT(app_id) DO UPDATE SET
1490
+ restart_count = lark_websocket_restart_state.restart_count + 1,
1491
+ last_restart_at = excluded.last_restart_at,
1492
+ last_restart_reason = excluded.last_restart_reason,
1493
+ updated_at = datetime('now')
1494
+ `).run(appId, restartedAt, reason);
1495
+ return this.getLarkWebsocketRestartState(appId)!;
1496
+ }
1497
+
805
1498
  getOrCreateChat(name: string, kind: ChatKind = 'group'): Chat {
806
1499
  const chatName = normalizeChatName(name);
807
1500
  const existing = this.db.query('SELECT * FROM chat_stats WHERE name = ?').get(chatName) as Chat | null;
@@ -812,6 +1505,448 @@ export class MessageStore {
812
1505
  return this.getChatById(id)!;
813
1506
  }
814
1507
 
1508
+ createProject(input: { name: string; computerId: string; rootPath: string }): Project {
1509
+ const name = input.name.trim();
1510
+ const computerId = input.computerId.trim();
1511
+ const rootPath = input.rootPath.trim();
1512
+ if (!name) throw new Error('project name is required');
1513
+ if (!computerId) throw new Error('computer_id is required');
1514
+ if (!rootPath) throw new Error('root_path is required');
1515
+ if (!isAbsolute(rootPath)) throw new Error('root_path must be absolute');
1516
+ const computer = this.getComputer(computerId);
1517
+ if (!computer) throw new Error(`computer ${computerId} not found`);
1518
+
1519
+ const id = crypto.randomUUID();
1520
+ this.db.query(`
1521
+ INSERT INTO projects (id, name, computer_id, root_path, created_at, updated_at)
1522
+ VALUES (?, ?, ?, ?, datetime('now'), datetime('now'))
1523
+ `).run(id, name, computerId, rootPath);
1524
+ return this.getProject(id)!;
1525
+ }
1526
+
1527
+ deleteProject(projectId: string): Project | null {
1528
+ const project = this.getProject(projectId);
1529
+ if (!project) return null;
1530
+ if (project.status !== 'archived') throw new Error('PROJECT_NOT_ARCHIVED: project must be archived before deletion');
1531
+ this.db.transaction(() => {
1532
+ this.db.query('DELETE FROM chats WHERE project_id = ?').run(project.id);
1533
+ this.db.query('DELETE FROM projects WHERE id = ?').run(project.id);
1534
+ })();
1535
+ return project;
1536
+ }
1537
+
1538
+ getOrCreateAgentDm(agentKey: string): Chat {
1539
+ const key = agentKey.trim();
1540
+ if (!key) throw new Error('agent is required');
1541
+ const agent = this.getAgent(key);
1542
+ if (!agent) throw new Error(`agent ${key} not found`);
1543
+ const chatName = normalizeChatName(`dm-owner-${key}`);
1544
+ const existing = this.getChatByName(chatName);
1545
+ if (existing && (existing.provider !== 'web' || existing.kind !== 'dm')) {
1546
+ throw new Error(`room ${chatName} already exists and is not a web DM`);
1547
+ }
1548
+
1549
+ let room = existing;
1550
+ if (!room) {
1551
+ const id = crypto.randomUUID();
1552
+ this.db.query(`
1553
+ INSERT INTO chats (id, name, display_name, kind, provider, dm_type, capabilities_json)
1554
+ VALUES (?, ?, ?, 'dm', 'web', 'user_agent', '{"topics":"unsupported"}')
1555
+ `).run(id, chatName, agent.display_name);
1556
+ room = this.getChatById(id)!;
1557
+ } else {
1558
+ this.db.query(`
1559
+ UPDATE chats
1560
+ SET display_name = ?, status = 'active'
1561
+ WHERE id = ?
1562
+ `).run(agent.display_name, room.id);
1563
+ room = this.getChatById(room.id)!;
1564
+ }
1565
+
1566
+ this.inviteAgentToRoom({ roomId: room.id, agent: key, mode: 'all' });
1567
+ return this.getChatById(room.id)!;
1568
+ }
1569
+
1570
+ getProject(id: string): Project | null {
1571
+ const row = this.db.query(`
1572
+ SELECT p.*, c.name AS computer_name, COUNT(ch.id) AS room_count
1573
+ FROM projects p
1574
+ LEFT JOIN computers c ON c.id = p.computer_id
1575
+ LEFT JOIN chats ch ON ch.project_id = p.id
1576
+ WHERE p.id = ?
1577
+ GROUP BY p.id
1578
+ `).get(id.trim()) as Record<string, unknown> | null;
1579
+ return row ? rowToProject(row) : null;
1580
+ }
1581
+
1582
+ private getWorkbenchProjectForAgent(agentKey: string, assignment: ComputerAgentAssignment | null): Project | null {
1583
+ const roomProject = this.db.query(`
1584
+ SELECT p.*, c.name AS computer_name, COUNT(ch.id) AS room_count
1585
+ FROM room_participants rp
1586
+ JOIN chats agent_room ON agent_room.id = rp.room_id
1587
+ JOIN projects p ON p.id = agent_room.project_id
1588
+ LEFT JOIN computers c ON c.id = p.computer_id
1589
+ LEFT JOIN chats ch ON ch.project_id = p.id
1590
+ WHERE rp.participant_id = ? AND rp.kind = 'agent' AND rp.status = 'active' AND p.status = 'active'
1591
+ GROUP BY p.id
1592
+ ORDER BY datetime(p.updated_at) DESC, p.name ASC
1593
+ LIMIT 1
1594
+ `).get(agentKey.trim()) as Record<string, unknown> | null;
1595
+ if (roomProject) return rowToProject(roomProject);
1596
+ if (!assignment) return null;
1597
+ const assignedProject = this.db.query(`
1598
+ SELECT p.*, c.name AS computer_name, COUNT(ch.id) AS room_count
1599
+ FROM projects p
1600
+ LEFT JOIN computers c ON c.id = p.computer_id
1601
+ LEFT JOIN chats ch ON ch.project_id = p.id
1602
+ WHERE p.computer_id = ? AND p.status = 'active'
1603
+ GROUP BY p.id
1604
+ ORDER BY datetime(p.updated_at) DESC, p.name ASC
1605
+ LIMIT 1
1606
+ `).get(assignment.computer_id) as Record<string, unknown> | null;
1607
+ return assignedProject ? rowToProject(assignedProject) : null;
1608
+ }
1609
+
1610
+ listProjects(limit = 50): Project[] {
1611
+ const rows = this.db.query(`
1612
+ SELECT p.*, c.name AS computer_name, COUNT(ch.id) AS room_count
1613
+ FROM projects p
1614
+ LEFT JOIN computers c ON c.id = p.computer_id
1615
+ LEFT JOIN chats ch ON ch.project_id = p.id
1616
+ GROUP BY p.id
1617
+ ORDER BY p.updated_at DESC, p.created_at DESC
1618
+ LIMIT ?
1619
+ `).all(limitValue(limit, 50)) as Record<string, unknown>[];
1620
+ return rows.map(rowToProject);
1621
+ }
1622
+
1623
+ listProjectsForComputer(computerId: string): Project[] {
1624
+ const rows = this.db.query(`
1625
+ SELECT p.*, c.name AS computer_name, COUNT(ch.id) AS room_count
1626
+ FROM projects p
1627
+ LEFT JOIN computers c ON c.id = p.computer_id
1628
+ LEFT JOIN chats ch ON ch.project_id = p.id
1629
+ WHERE p.computer_id = ?
1630
+ GROUP BY p.id
1631
+ ORDER BY p.status ASC, p.updated_at DESC, p.created_at DESC
1632
+ `).all(computerId.trim()) as Record<string, unknown>[];
1633
+ return rows.map(rowToProject);
1634
+ }
1635
+
1636
+ updateProjectStatus(projectId: string, status: ProjectStatus): Project {
1637
+ if (status !== 'active' && status !== 'archived') throw new Error('project status must be active or archived');
1638
+ const project = this.getProject(projectId);
1639
+ if (!project) throw new Error(`project ${projectId} was not found`);
1640
+ this.db.query(`UPDATE projects SET status = ?, updated_at = datetime('now') WHERE id = ?`).run(status, project.id);
1641
+ return this.getProject(project.id)!;
1642
+ }
1643
+
1644
+ listProjectAgentDefaults(projectId: string): ProjectAgentDefault[] {
1645
+ const project = this.getProject(projectId);
1646
+ if (!project) throw new Error(`project ${projectId} was not found`);
1647
+ const rows = this.db.query(`
1648
+ SELECT pad.*, a.display_name
1649
+ FROM project_agent_defaults pad
1650
+ LEFT JOIN agents a ON a.agent_key = pad.agent
1651
+ WHERE pad.project_id = ?
1652
+ ORDER BY pad.agent ASC
1653
+ `).all(project.id) as Record<string, unknown>[];
1654
+ return rows.map(rowToProjectAgentDefault);
1655
+ }
1656
+
1657
+ setProjectAgentDefaults(projectId: string, defaults: Array<{ agent: string; mode: AgentRoomSubscriptionMode }>): ProjectAgentDefault[] {
1658
+ const project = this.getProject(projectId);
1659
+ if (!project) throw new Error(`project ${projectId} was not found`);
1660
+ const normalized = new Map<string, AgentRoomSubscriptionMode>();
1661
+ for (const item of defaults) {
1662
+ const agent = item.agent.trim();
1663
+ if (!agent) continue;
1664
+ if (!this.getAgent(agent)) throw new Error(`agent ${agent} not found`);
1665
+ normalized.set(agent, item.mode);
1666
+ }
1667
+
1668
+ this.db.transaction(() => {
1669
+ const agents = [...normalized.keys()];
1670
+ if (agents.length) {
1671
+ const placeholders = agents.map(() => '?').join(', ');
1672
+ this.db.query(`DELETE FROM project_agent_defaults WHERE project_id = ? AND agent NOT IN (${placeholders})`).run(project.id, ...agents);
1673
+ } else {
1674
+ this.db.query(`DELETE FROM project_agent_defaults WHERE project_id = ?`).run(project.id);
1675
+ }
1676
+ for (const [agent, mode] of normalized) {
1677
+ this.db.query(`
1678
+ INSERT INTO project_agent_defaults (id, project_id, agent, mode, created_at, updated_at)
1679
+ VALUES (?, ?, ?, ?, datetime('now'), datetime('now'))
1680
+ ON CONFLICT(project_id, agent) DO UPDATE SET
1681
+ mode = excluded.mode,
1682
+ updated_at = datetime('now')
1683
+ `).run(crypto.randomUUID(), project.id, agent, mode);
1684
+ }
1685
+ this.db.query(`UPDATE projects SET updated_at = datetime('now') WHERE id = ?`).run(project.id);
1686
+ })();
1687
+
1688
+ return this.listProjectAgentDefaults(project.id);
1689
+ }
1690
+
1691
+ getSkillDefinition(key: string): SkillDefinition | null {
1692
+ const row = this.db.query('SELECT * FROM skill_definitions WHERE key = ?').get(key.trim()) as Record<string, unknown> | null;
1693
+ return row ? rowToSkillDefinition(row) : null;
1694
+ }
1695
+
1696
+ listSkillDefinitions(input: { source?: SkillSource; ownerUserId?: string | null; projectId?: string | null; includeDisabled?: boolean } = {}): SkillDefinition[] {
1697
+ const where: string[] = [];
1698
+ const params: SQLQueryBindings[] = [];
1699
+ if (input.source) {
1700
+ where.push('source = ?');
1701
+ params.push(assertSkillSource(input.source));
1702
+ }
1703
+ if (input.ownerUserId !== undefined) {
1704
+ if (input.ownerUserId === null) {
1705
+ where.push('owner_user_id IS NULL');
1706
+ } else {
1707
+ where.push('owner_user_id = ?');
1708
+ params.push(input.ownerUserId.trim());
1709
+ }
1710
+ }
1711
+ if (input.projectId !== undefined) {
1712
+ if (input.projectId === null) {
1713
+ where.push('project_id IS NULL');
1714
+ } else {
1715
+ where.push('project_id = ?');
1716
+ params.push(input.projectId.trim());
1717
+ }
1718
+ }
1719
+ if (!input.includeDisabled) where.push("status = 'active'");
1720
+ const rows = this.db.query(`
1721
+ SELECT * FROM skill_definitions
1722
+ ${where.length ? `WHERE ${where.join(' AND ')}` : ''}
1723
+ ORDER BY source ASC, owner_user_id ASC, project_id ASC, key ASC
1724
+ `).all(...params) as Record<string, unknown>[];
1725
+ return rows.map(rowToSkillDefinition);
1726
+ }
1727
+
1728
+ upsertSkillDefinition(input: {
1729
+ key: string;
1730
+ name: string;
1731
+ description?: string;
1732
+ instructionContent: string;
1733
+ status?: SkillDefinition['status'];
1734
+ version?: string;
1735
+ source?: SkillSource;
1736
+ ownerUserId?: string | null;
1737
+ projectId?: string | null;
1738
+ repositoryPath?: string | null;
1739
+ }): SkillDefinition {
1740
+ const key = input.key.trim();
1741
+ if (!/^[a-zA-Z0-9][a-zA-Z0-9_-]{1,80}$/.test(key)) throw new Error('INVALID_SKILL_KEY');
1742
+ const name = input.name.trim();
1743
+ if (!name) throw new Error('skill name is required');
1744
+ const instructionContent = input.instructionContent.trim();
1745
+ if (!instructionContent) throw new Error('skill instruction_content is required');
1746
+ const source = assertSkillSource(input.source ?? 'user');
1747
+ const status = assertSkillStatus(input.status ?? 'active');
1748
+ const version = input.version?.trim() || '1';
1749
+ const ownerUserId = input.ownerUserId?.trim() || null;
1750
+ const projectId = input.projectId?.trim() || null;
1751
+ if (source === 'user' && !ownerUserId) throw new Error('owner_user_id is required for user skills');
1752
+ if (source === 'project' && !projectId) throw new Error('project_id is required for project skills');
1753
+ const repositoryPath = input.repositoryPath?.trim() || (source === 'user' ? `user/${ownerUserId}/${key}` : source === 'project' ? `project/${projectId}/${key}` : null);
1754
+ const existing = this.getSkillDefinition(key);
1755
+ if (existing) {
1756
+ if (existing.source !== source) throw new Error('SKILL_SOURCE_IMMUTABLE');
1757
+ if (existing.source === 'system' && (ownerUserId || projectId || repositoryPath)) {
1758
+ throw new Error('SYSTEM_SKILL_OWNERSHIP_IMMUTABLE');
1759
+ }
1760
+ if (existing.source === 'user' && existing.owner_user_id !== ownerUserId) throw new Error('SKILL_OWNER_IMMUTABLE');
1761
+ if (existing.source === 'project' && existing.project_id !== projectId) throw new Error('SKILL_PROJECT_IMMUTABLE');
1762
+ }
1763
+ this.db.query(`
1764
+ INSERT INTO skill_definitions (
1765
+ key, name, description, instruction_content, status, version, source, owner_user_id, project_id, repository_path, created_at, updated_at
1766
+ )
1767
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'), datetime('now'))
1768
+ ON CONFLICT(key) DO UPDATE SET
1769
+ name = excluded.name,
1770
+ description = excluded.description,
1771
+ instruction_content = excluded.instruction_content,
1772
+ status = excluded.status,
1773
+ version = excluded.version,
1774
+ source = excluded.source,
1775
+ owner_user_id = excluded.owner_user_id,
1776
+ project_id = excluded.project_id,
1777
+ repository_path = excluded.repository_path,
1778
+ updated_at = datetime('now')
1779
+ `).run(
1780
+ key,
1781
+ name,
1782
+ input.description?.trim() || '',
1783
+ instructionContent,
1784
+ status,
1785
+ version,
1786
+ source,
1787
+ ownerUserId,
1788
+ projectId,
1789
+ repositoryPath,
1790
+ );
1791
+ const skill = this.getSkillDefinition(key);
1792
+ if (!skill) throw new Error('SKILL_NOT_FOUND');
1793
+ return skill;
1794
+ }
1795
+
1796
+ createSkillDefinition(input: {
1797
+ key: string;
1798
+ name: string;
1799
+ description?: string;
1800
+ instructionContent: string;
1801
+ status?: SkillDefinition['status'];
1802
+ source?: SkillSource;
1803
+ ownerUserId?: string | null;
1804
+ projectId?: string | null;
1805
+ repositoryPath?: string | null;
1806
+ }): SkillDefinition {
1807
+ const key = input.key.trim();
1808
+ if (this.getSkillDefinition(key)) throw new Error('SKILL_ALREADY_EXISTS');
1809
+ if ((input.source ?? 'user') === 'system') throw new Error('SYSTEM_SKILL_CREATE_FORBIDDEN');
1810
+ return this.upsertSkillDefinition(input);
1811
+ }
1812
+
1813
+ updateSkillDefinition(key: string, input: {
1814
+ name?: string;
1815
+ description?: string;
1816
+ instructionContent?: string;
1817
+ status?: SkillDefinition['status'];
1818
+ ownerUserId?: string | null;
1819
+ projectId?: string | null;
1820
+ repositoryPath?: string | null;
1821
+ }): SkillDefinition {
1822
+ const existing = this.getSkillDefinition(key);
1823
+ if (!existing) throw new Error(`skill ${key} was not found`);
1824
+ if (existing.source === 'system') throw new Error('SYSTEM_SKILL_READ_ONLY');
1825
+ return this.upsertSkillDefinition({
1826
+ key: existing.key,
1827
+ name: input.name ?? existing.name,
1828
+ description: input.description ?? existing.description,
1829
+ instructionContent: input.instructionContent ?? existing.instruction_content,
1830
+ status: input.status ?? existing.status,
1831
+ version: existing.version,
1832
+ source: existing.source,
1833
+ ownerUserId: input.ownerUserId === undefined ? existing.owner_user_id : input.ownerUserId,
1834
+ projectId: input.projectId === undefined ? existing.project_id : input.projectId,
1835
+ repositoryPath: input.repositoryPath === undefined ? existing.repository_path : input.repositoryPath,
1836
+ });
1837
+ }
1838
+
1839
+ upsertSkillBinding(input: { scope: SkillBindingScope; scopeId: string; skillKey: string; enabled?: boolean; priority?: number }): SkillBinding {
1840
+ const scope = assertSkillBindingScope(input.scope);
1841
+ const scopeId = input.scopeId.trim();
1842
+ const skillKey = input.skillKey.trim();
1843
+ if (!scopeId) throw new Error('scope_id is required');
1844
+ if (!skillKey) throw new Error('skill_key is required');
1845
+ if (!this.getSkillDefinition(skillKey)) throw new Error(`skill ${skillKey} was not found`);
1846
+ const enabled = input.enabled === false ? 0 : 1;
1847
+ const priority = Number.isInteger(input.priority) ? Number(input.priority) : 100;
1848
+ const id = crypto.randomUUID();
1849
+ this.db.query(`
1850
+ INSERT INTO skill_bindings (id, scope, scope_id, skill_key, enabled, priority, created_at, updated_at)
1851
+ VALUES (?, ?, ?, ?, ?, ?, datetime('now'), datetime('now'))
1852
+ ON CONFLICT(scope, scope_id, skill_key) DO UPDATE SET
1853
+ enabled = excluded.enabled,
1854
+ priority = excluded.priority,
1855
+ updated_at = datetime('now')
1856
+ `).run(id, scope, scopeId, skillKey, enabled, priority);
1857
+ const row = this.db.query(`
1858
+ SELECT * FROM skill_bindings
1859
+ WHERE scope = ? AND scope_id = ? AND skill_key = ?
1860
+ `).get(scope, scopeId, skillKey) as Record<string, unknown> | null;
1861
+ if (!row) throw new Error('SKILL_BINDING_NOT_FOUND');
1862
+ return rowToSkillBinding(row);
1863
+ }
1864
+
1865
+ listSkillBindings(input: { scope?: SkillBindingScope; scopeId?: string } = {}): SkillBinding[] {
1866
+ const params: SQLQueryBindings[] = [];
1867
+ let where = '';
1868
+ if (input.scope) {
1869
+ where += ' WHERE scope = ?';
1870
+ params.push(assertSkillBindingScope(input.scope));
1871
+ }
1872
+ if (input.scopeId?.trim()) {
1873
+ where += where ? ' AND scope_id = ?' : ' WHERE scope_id = ?';
1874
+ params.push(input.scopeId.trim());
1875
+ }
1876
+ const rows = this.db.query(`
1877
+ SELECT * FROM skill_bindings
1878
+ ${where}
1879
+ ORDER BY scope ASC, scope_id ASC, priority ASC, skill_key ASC
1880
+ `).all(...params) as Record<string, unknown>[];
1881
+ return rows.map(rowToSkillBinding);
1882
+ }
1883
+
1884
+ deleteSkillBinding(input: { scope: SkillBindingScope; scopeId: string; skillKey: string }): boolean {
1885
+ const scope = assertSkillBindingScope(input.scope);
1886
+ const scopeId = input.scopeId.trim();
1887
+ const skillKey = input.skillKey.trim();
1888
+ if (!scopeId) throw new Error('scope_id is required');
1889
+ if (!skillKey) throw new Error('skill_key is required');
1890
+ const result = this.db.query('DELETE FROM skill_bindings WHERE scope = ? AND scope_id = ? AND skill_key = ?').run(scope, scopeId, skillKey);
1891
+ return result.changes > 0;
1892
+ }
1893
+
1894
+ listEnabledSkills(input: { projectId?: string | null; roomId?: string | null; agent?: string | null }): EnabledSkill[] {
1895
+ const orderedScopes: Array<{ scope: SkillBindingScope; scopeId: string; rank: number }> = [];
1896
+ if (input.projectId?.trim()) orderedScopes.push({ scope: 'project', scopeId: input.projectId.trim(), rank: 0 });
1897
+ if (input.roomId?.trim()) orderedScopes.push({ scope: 'room', scopeId: input.roomId.trim(), rank: 1 });
1898
+ if (input.agent?.trim()) orderedScopes.push({ scope: 'agent', scopeId: input.agent.trim(), rank: 2 });
1899
+ if (orderedScopes.length === 0) return [];
1900
+
1901
+ const rows: EnabledSkill[] = [];
1902
+ for (const scope of orderedScopes) {
1903
+ const scopeRows = this.db.query(`
1904
+ SELECT
1905
+ sd.*,
1906
+ sb.scope AS binding_scope,
1907
+ sb.scope_id AS binding_scope_id,
1908
+ sb.priority AS binding_priority
1909
+ FROM skill_bindings sb
1910
+ JOIN skill_definitions sd ON sd.key = sb.skill_key
1911
+ WHERE sb.scope = ?
1912
+ AND sb.scope_id = ?
1913
+ AND sb.enabled = 1
1914
+ AND sd.status = 'active'
1915
+ ORDER BY sb.priority ASC, sd.key ASC
1916
+ `).all(scope.scope, scope.scopeId) as Record<string, unknown>[];
1917
+ rows.push(...scopeRows.map(rowToEnabledSkill));
1918
+ }
1919
+ return rows;
1920
+ }
1921
+
1922
+ createProjectRoom(input: { projectId: string; name: string; kind?: ChatKind; mode?: RoomMode }): Chat {
1923
+ const project = this.getProject(input.projectId);
1924
+ if (!project) throw new Error(`project ${input.projectId} was not found`);
1925
+ if (project.status === 'archived') throw new Error('PROJECT_ARCHIVED: archived projects cannot create rooms');
1926
+ const kind = input.kind ?? 'group';
1927
+ if (kind !== 'group' && kind !== 'dm') throw new Error('kind must be group or dm');
1928
+ const mode = assertRoomMode(input.mode ?? 'standard');
1929
+ const chatName = normalizeChatName(input.name);
1930
+ if (!chatName) throw new Error('room name is required');
1931
+ if (this.getChatByName(chatName)) throw new Error(`room ${chatName} already exists`);
1932
+
1933
+ const id = crypto.randomUUID();
1934
+ this.db.transaction(() => {
1935
+ this.db.query(`
1936
+ INSERT INTO chats (id, name, kind, provider, capabilities_json, project_id, mode)
1937
+ VALUES (?, ?, ?, 'web', ?, ?, ?)
1938
+ `).run(id, chatName, kind, kind === 'dm' ? '{"topics":"unsupported"}' : '{"topics":"native"}', project.id, mode);
1939
+ if (mode === 'idea_development') {
1940
+ this.upsertSkillBinding({ scope: 'room', scopeId: id, skillKey: 'idea_development', enabled: true, priority: 100 });
1941
+ }
1942
+ for (const defaultMember of this.listProjectAgentDefaults(project.id)) {
1943
+ this.inviteAgentToRoom({ roomId: id, agent: defaultMember.agent, mode: defaultMember.mode });
1944
+ }
1945
+ this.db.query(`UPDATE projects SET updated_at = datetime('now') WHERE id = ?`).run(project.id);
1946
+ })();
1947
+ return this.getChatById(id)!;
1948
+ }
1949
+
815
1950
  updateRoomDisplayName(roomId: string, displayName: string | null): Chat {
816
1951
  const room = this.getChatById(roomId);
817
1952
  if (!room) throw new Error(`room ${roomId} was not found`);
@@ -820,11 +1955,20 @@ export class MessageStore {
820
1955
  return this.getChatById(room.id)!;
821
1956
  }
822
1957
 
1958
+ updateRoomStatus(roomId: string, status: ChatStatus): Chat {
1959
+ if (status !== 'active' && status !== 'archived') throw new Error('room status must be active or archived');
1960
+ const room = this.getChatById(roomId);
1961
+ if (!room) throw new Error(`room ${roomId} was not found`);
1962
+ if (room.provider !== 'web') throw new Error('EXTERNAL_ROOM_STATUS_READ_ONLY: external provider rooms cannot be archived from Web');
1963
+ this.db.query('UPDATE chats SET status = ? WHERE id = ?').run(status, room.id);
1964
+ return this.getChatById(room.id)!;
1965
+ }
1966
+
823
1967
  backfillLarkDmDisplayNames(): number {
824
1968
  const result = this.db.query(`
825
1969
  UPDATE chats
826
1970
  SET display_name = (
827
- SELECT 'DM with ' || rp.display_name
1971
+ SELECT rp.display_name
828
1972
  FROM room_participants rp
829
1973
  WHERE rp.room_id = chats.id
830
1974
  AND rp.kind = 'bot'
@@ -836,7 +1980,7 @@ export class MessageStore {
836
1980
  )
837
1981
  WHERE provider = 'lark'
838
1982
  AND kind = 'dm'
839
- AND (display_name IS NULL OR trim(display_name) = '' OR display_name LIKE 'lark:%')
1983
+ AND (display_name IS NULL OR trim(display_name) = '' OR display_name LIKE 'lark:%' OR display_name LIKE 'DM with %')
840
1984
  AND EXISTS (
841
1985
  SELECT 1
842
1986
  FROM room_participants rp
@@ -850,6 +1994,15 @@ export class MessageStore {
850
1994
  return result.changes;
851
1995
  }
852
1996
 
1997
+ deleteRoom(roomId: string): Chat | null {
1998
+ const room = this.getChatById(roomId);
1999
+ if (!room) return null;
2000
+ if (room.provider !== 'web') throw new Error('EXTERNAL_ROOM_DELETE_READ_ONLY: external provider rooms cannot be deleted from Web');
2001
+ if (room.status !== 'archived') throw new Error('ROOM_NOT_ARCHIVED: room must be archived before deletion');
2002
+ this.db.query('DELETE FROM chats WHERE id = ?').run(room.id);
2003
+ return room;
2004
+ }
2005
+
853
2006
  getChatById(id: string): Chat | null {
854
2007
  const row = this.db.query('SELECT * FROM chat_stats WHERE id = ?').get(id) as Chat | null;
855
2008
  return row ?? null;
@@ -864,6 +2017,10 @@ export class MessageStore {
864
2017
  return this.db.query('SELECT * FROM chat_stats ORDER BY COALESCE(last_message_at, created_at) DESC').all() as Chat[];
865
2018
  }
866
2019
 
2020
+ listChatsForAgent(agent: string): Chat[] {
2021
+ return this.listChats().filter((room) => this.canAgentParticipateInRoom(agent.trim(), room));
2022
+ }
2023
+
867
2024
  listLarkAuthorizedUsers(): LarkAuthorizedUser[] {
868
2025
  const rows = this.db.query('SELECT * FROM lark_authorized_users ORDER BY COALESCE(display_name, user_id), user_id').all() as Record<string, unknown>[];
869
2026
  return rows.map(rowToLarkAuthorizedUser);
@@ -1012,24 +2169,115 @@ export class MessageStore {
1012
2169
  return Number(row.id ?? 0);
1013
2170
  }
1014
2171
 
2172
+ private systemEventDeliveryAgents(room: Chat, extraAgents: string[] = []): string[] {
2173
+ const candidates = new Set<string>();
2174
+ for (const agent of this.listRoomAgents(room)) {
2175
+ const mode = this.getAgentRoomSubscription(agent, room.id)?.mode ?? 'mentions';
2176
+ if (mode !== 'off' && mode !== 'muted') candidates.add(agent);
2177
+ }
2178
+ for (const agent of extraAgents) {
2179
+ const normalized = agent.trim();
2180
+ if (normalized && this.getAgent(normalized)) candidates.add(normalized);
2181
+ }
2182
+ return [...candidates].sort((left, right) => left.localeCompare(right));
2183
+ }
2184
+
2185
+ recordRoomSystemEvent(input: RecordRoomSystemEventInput): { event: RoomSystemEvent; message: Message; deliveries: MessageDelivery[] } {
2186
+ const room = this.getChatById(input.roomId);
2187
+ if (!room) throw new Error(`room ${input.roomId} was not found`);
2188
+ const actorKind = normalizeSystemEventActorKind(input.actorKind);
2189
+ const actorId = input.actorId?.trim() || null;
2190
+ const subjectAgent = input.subjectAgent?.trim() || null;
2191
+ const metadata = normalizeSystemEventMetadata(input.metadata);
2192
+ const content = input.content?.trim() || systemEventContent({
2193
+ eventType: input.eventType,
2194
+ actorKind,
2195
+ actorId,
2196
+ subjectAgent,
2197
+ metadata,
2198
+ });
2199
+
2200
+ return this.db.transaction(() => {
2201
+ const message = this.createMessage({
2202
+ chatId: room.id,
2203
+ sender: 'system',
2204
+ content,
2205
+ type: 'system',
2206
+ mentions: subjectAgent ? [subjectAgent] : [],
2207
+ });
2208
+ const id = crypto.randomUUID();
2209
+ this.db.query(`
2210
+ INSERT INTO room_system_events (id, room_id, message_id, event_type, actor_kind, actor_id, subject_agent, metadata_json, created_at)
2211
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))
2212
+ `).run(id, room.id, message.id, input.eventType, actorKind, actorId, subjectAgent, JSON.stringify(metadata));
2213
+
2214
+ const deliveryAgents = input.deliveryAgents ?? this.systemEventDeliveryAgents(room);
2215
+ const deliveries: MessageDelivery[] = [];
2216
+ for (const agent of normalizeMentionsInput(deliveryAgents)) {
2217
+ if (agent === message.sender) continue;
2218
+ try {
2219
+ deliveries.push(this.createDelivery({ messageId: message.id, agent }));
2220
+ } catch {
2221
+ // Delivery eligibility is best-effort here; the event record and message remain the source of truth.
2222
+ }
2223
+ }
2224
+ return { event: this.getRoomSystemEvent(id)!, message, deliveries };
2225
+ })();
2226
+ }
2227
+
2228
+ getRoomSystemEvent(id: string): RoomSystemEvent | null {
2229
+ const row = this.db.query('SELECT * FROM room_system_events WHERE id = ?').get(id) as Record<string, unknown> | null;
2230
+ return row ? rowToRoomSystemEvent(row) : null;
2231
+ }
2232
+
2233
+ getRoomSystemEventForMessage(messageId: number): RoomSystemEvent | null {
2234
+ const row = this.db.query('SELECT * FROM room_system_events WHERE message_id = ?').get(messageId) as Record<string, unknown> | null;
2235
+ return row ? rowToRoomSystemEvent(row) : null;
2236
+ }
2237
+
1015
2238
  inviteAgentToRoom(input: { roomId: string; agent: string; mode?: AgentRoomSubscriptionMode }): { participant: RoomParticipant; subscription: AgentRoomSubscription } {
1016
2239
  const room = this.getChatById(input.roomId);
1017
2240
  if (!room) throw new Error(`room ${input.roomId} was not found`);
1018
2241
  const agent = input.agent.trim();
1019
2242
  if (!agent) throw new Error('agent is required');
1020
- const participant = this.upsertRoomParticipant({
1021
- roomId: room.id,
1022
- participantId: agent,
1023
- kind: 'agent',
1024
- displayName: this.getAgent(agent)?.display_name ?? agent,
1025
- source: 'local_agent',
1026
- });
1027
- this.db.query(`
1028
- UPDATE room_participants
1029
- SET delivery_cursor_message_id = COALESCE(delivery_cursor_message_id, ?), updated_at = datetime('now')
1030
- WHERE room_id = ? AND participant_id = ?
1031
- `).run(this.getLatestMessageId(room.id), room.id, agent);
1032
- return { participant, subscription: this.upsertAgentRoomSubscription({ agent, roomId: room.id, mode: input.mode ?? 'mentions' }) };
2243
+ const mode = input.mode ?? 'mentions';
2244
+ let result!: { participant: RoomParticipant; subscription: AgentRoomSubscription };
2245
+ this.db.transaction(() => {
2246
+ const existingParticipant = this.getRoomParticipant(room.id, agent);
2247
+ const existingSubscription = this.getAgentRoomSubscription(agent, room.id);
2248
+ const participant = this.upsertRoomParticipant({
2249
+ roomId: room.id,
2250
+ participantId: agent,
2251
+ kind: 'agent',
2252
+ displayName: this.getAgent(agent)?.display_name ?? agent,
2253
+ source: 'local_agent',
2254
+ });
2255
+ this.db.query(`
2256
+ UPDATE room_participants
2257
+ SET delivery_cursor_message_id = COALESCE(delivery_cursor_message_id, ?), updated_at = datetime('now')
2258
+ WHERE room_id = ? AND participant_id = ?
2259
+ `).run(this.getLatestMessageId(room.id), room.id, agent);
2260
+ const subscription = this.upsertAgentRoomSubscription({ agent, roomId: room.id, mode });
2261
+ if (existingParticipant?.status !== 'active') {
2262
+ this.recordRoomSystemEvent({
2263
+ roomId: room.id,
2264
+ eventType: 'agent_invited',
2265
+ subjectAgent: agent,
2266
+ metadata: { mode },
2267
+ deliveryAgents: [],
2268
+ });
2269
+ } else if (existingSubscription && existingSubscription.mode !== mode) {
2270
+ this.recordRoomSystemEvent({
2271
+ roomId: room.id,
2272
+ eventType: 'agent_receive_mode_changed',
2273
+ subjectAgent: agent,
2274
+ metadata: { old_mode: existingSubscription.mode, new_mode: mode },
2275
+ deliveryAgents: this.systemEventDeliveryAgents(room),
2276
+ });
2277
+ }
2278
+ result = { participant: this.getRoomParticipant(room.id, agent) ?? participant, subscription };
2279
+ })();
2280
+ return result;
1033
2281
  }
1034
2282
 
1035
2283
  upsertAgentRoomSubscription(input: { agent: string; roomId: string; mode: AgentRoomSubscriptionMode; channelId?: string | null }): AgentRoomSubscription {
@@ -1048,10 +2296,11 @@ export class MessageStore {
1048
2296
  return this.getAgentRoomSubscription(agent, room.id)!;
1049
2297
  }
1050
2298
  const id = crypto.randomUUID();
2299
+ const latestMessageId = this.getLatestMessageId(room.id);
1051
2300
  this.db.query(`
1052
- INSERT INTO agent_room_subscriptions (id, agent, room_id, channel_id, mode, created_at, updated_at)
1053
- VALUES (?, ?, ?, ?, ?, datetime('now'), datetime('now'))
1054
- `).run(id, agent, room.id, channelId, input.mode);
2301
+ INSERT INTO agent_room_subscriptions (id, agent, room_id, channel_id, mode, last_read_message_id, created_at, updated_at)
2302
+ VALUES (?, ?, ?, ?, ?, ?, datetime('now'), datetime('now'))
2303
+ `).run(id, agent, room.id, channelId, input.mode, latestMessageId);
1055
2304
  const row = this.db.query('SELECT * FROM agent_room_subscriptions WHERE id = ?').get(id) as Record<string, unknown>;
1056
2305
  return rowToAgentRoomSubscription(row);
1057
2306
  }
@@ -1065,6 +2314,208 @@ export class MessageStore {
1065
2314
  return row ? rowToAgentRoomSubscription(row) : null;
1066
2315
  }
1067
2316
 
2317
+ listAgentRoomSubscriptions(roomId: string): AgentRoomSubscription[] {
2318
+ const rows = this.db.query(`
2319
+ SELECT *
2320
+ FROM agent_room_subscriptions
2321
+ WHERE room_id = ? AND channel_id IS NULL
2322
+ ORDER BY agent ASC
2323
+ `).all(roomId) as Record<string, unknown>[];
2324
+ return rows.map(rowToAgentRoomSubscription);
2325
+ }
2326
+
2327
+ private unreadCountForAgentRoom(agent: string, roomId: string, lastReadMessageId: number): number {
2328
+ const row = this.db.query(`
2329
+ SELECT COUNT(*) AS count
2330
+ FROM messages
2331
+ WHERE chat_id = ? AND id > ? AND sender != ?
2332
+ `).get(roomId, lastReadMessageId, agent.trim()) as { count: number };
2333
+ return Number(row.count ?? 0);
2334
+ }
2335
+
2336
+ private taskCountsForRoom(roomId: string): { task_count: number; open_task_count: number } {
2337
+ const row = this.db.query(`
2338
+ SELECT
2339
+ COUNT(*) AS task_count,
2340
+ SUM(CASE WHEN status NOT IN ('done', 'closed') THEN 1 ELSE 0 END) AS open_task_count
2341
+ FROM room_tasks
2342
+ WHERE room_id = ?
2343
+ `).get(roomId) as { task_count: number; open_task_count: number | null };
2344
+ return {
2345
+ task_count: Number(row.task_count ?? 0),
2346
+ open_task_count: Number(row.open_task_count ?? 0),
2347
+ };
2348
+ }
2349
+
2350
+ private scheduledReminderCountForAgentRoom(agent: string, roomId: string): number {
2351
+ const row = this.db.query(`
2352
+ SELECT COUNT(*) AS count
2353
+ FROM room_reminders
2354
+ WHERE room_id = ? AND created_by = ? AND status = 'scheduled'
2355
+ `).get(roomId, agent.trim()) as { count: number };
2356
+ return Number(row.count ?? 0);
2357
+ }
2358
+
2359
+ private savedMessageCountForAgentRoom(agent: string, roomId: string): number {
2360
+ const row = this.db.query(`
2361
+ SELECT COUNT(*) AS count
2362
+ FROM room_saved_messages
2363
+ WHERE room_id = ? AND saved_by = ?
2364
+ `).get(roomId, agent.trim()) as { count: number };
2365
+ return Number(row.count ?? 0);
2366
+ }
2367
+
2368
+ private buildAgentWorkbenchRoom(agentKey: string, row: Record<string, unknown>): AgentWorkbenchRoom {
2369
+ const key = agentKey.trim();
2370
+ const room = row as unknown as Chat;
2371
+ const participant = this.getRoomParticipant(room.id, key);
2372
+ const subscription = this.getAgentRoomSubscription(key, room.id);
2373
+ const latestMessageId = this.getLatestMessageId(room.id);
2374
+ const lastReadMessageId = subscription?.last_read_message_id ?? participant?.delivery_cursor_message_id ?? 0;
2375
+ const heldDraftCount = this.db.query(`
2376
+ SELECT COUNT(*) AS count
2377
+ FROM held_message_drafts
2378
+ WHERE agent = ? AND chat_id = ? AND status = 'held'
2379
+ `).get(key, room.id) as { count: number };
2380
+ const taskCounts = this.taskCountsForRoom(room.id);
2381
+ return {
2382
+ ...room,
2383
+ participant,
2384
+ subscription,
2385
+ held_draft_count: Number(heldDraftCount.count ?? 0),
2386
+ latest_message_id: latestMessageId,
2387
+ last_read_message_id: lastReadMessageId,
2388
+ last_read_at: subscription?.last_read_at ?? null,
2389
+ unread_count: this.unreadCountForAgentRoom(key, room.id, lastReadMessageId),
2390
+ is_followed: subscription ? !['muted', 'off'].includes(subscription.mode) : false,
2391
+ task_count: taskCounts.task_count,
2392
+ open_task_count: taskCounts.open_task_count,
2393
+ scheduled_reminder_count: this.scheduledReminderCountForAgentRoom(key, room.id),
2394
+ saved_message_count: this.savedMessageCountForAgentRoom(key, room.id),
2395
+ };
2396
+ }
2397
+
2398
+ getAgentWorkbenchRoom(agentKey: string, roomId: string): AgentWorkbenchRoom | null {
2399
+ const key = agentKey.trim();
2400
+ if (!key) throw new Error('agent is required');
2401
+ const row = this.db.query('SELECT * FROM chat_stats WHERE id = ?').get(roomId) as Record<string, unknown> | null;
2402
+ return row ? this.buildAgentWorkbenchRoom(key, row) : null;
2403
+ }
2404
+
2405
+ listAgentWorkbenchRooms(agentKey: string): AgentWorkbenchRoom[] {
2406
+ const key = agentKey.trim();
2407
+ const rows = this.db.query(`
2408
+ SELECT c.*
2409
+ FROM room_participants rp
2410
+ JOIN chat_stats c ON c.id = rp.room_id
2411
+ WHERE rp.participant_id = ? AND rp.kind = 'agent' AND rp.status = 'active'
2412
+ ORDER BY COALESCE(c.display_name, c.name) ASC
2413
+ `).all(key) as Record<string, unknown>[];
2414
+ return rows.map((row) => this.buildAgentWorkbenchRoom(key, row));
2415
+ }
2416
+
2417
+ updateAgentRoomSubscriptionMode(input: UpdateAgentRoomSubscriptionInput): { subscription: AgentRoomSubscription; room: AgentWorkbenchRoom } {
2418
+ const agent = input.agent.trim();
2419
+ if (!agent) throw new Error('agent is required');
2420
+ const room = this.getChatById(input.roomId);
2421
+ if (!room) throw new Error(`room ${input.roomId} was not found`);
2422
+ if (!this.getAgent(agent)) throw new Error(`agent ${agent} not found`);
2423
+ if (!this.canAgentParticipateInRoom(agent, room)) throw new Error('ROOM_ACCESS_DENIED');
2424
+ const existing = this.getAgentRoomSubscription(agent, room.id);
2425
+ const subscription = this.upsertAgentRoomSubscription({ agent, roomId: room.id, mode: input.mode });
2426
+ if ((existing?.mode ?? null) !== subscription.mode) {
2427
+ this.recordRoomSystemEvent({
2428
+ roomId: room.id,
2429
+ eventType: 'agent_receive_mode_changed',
2430
+ actorKind: input.actorKind,
2431
+ actorId: input.actorId,
2432
+ subjectAgent: agent,
2433
+ metadata: { old_mode: existing?.mode ?? null, new_mode: subscription.mode },
2434
+ deliveryAgents: this.systemEventDeliveryAgents(room),
2435
+ });
2436
+ }
2437
+ return { subscription, room: this.getAgentWorkbenchRoom(agent, room.id)! };
2438
+ }
2439
+
2440
+ markAgentRoomRead(input: MarkAgentRoomReadInput): AgentWorkbenchRoom {
2441
+ const agent = input.agent.trim();
2442
+ if (!agent) throw new Error('agent is required');
2443
+ const room = this.getChatById(input.roomId);
2444
+ if (!room) throw new Error(`room ${input.roomId} was not found`);
2445
+ if (!this.getAgent(agent)) throw new Error(`agent ${agent} not found`);
2446
+ if (!this.canAgentParticipateInRoom(agent, room)) throw new Error('ROOM_ACCESS_DENIED');
2447
+
2448
+ const existing = this.getAgentRoomSubscription(agent, room.id)
2449
+ ?? this.upsertAgentRoomSubscription({ agent, roomId: room.id, mode: 'mentions' });
2450
+ const latestMessageId = this.getLatestMessageId(room.id);
2451
+ const requestedMessageId = input.messageId === undefined ? latestMessageId : Number(input.messageId);
2452
+ if (!Number.isInteger(requestedMessageId) || requestedMessageId < 0) {
2453
+ throw new Error('INVALID_READ_MESSAGE_ID');
2454
+ }
2455
+ const nextReadMessageId = Math.max(existing.last_read_message_id, Math.min(requestedMessageId, latestMessageId));
2456
+ this.db.query(`
2457
+ UPDATE agent_room_subscriptions
2458
+ SET last_read_message_id = ?, last_read_at = datetime('now'), updated_at = datetime('now')
2459
+ WHERE id = ?
2460
+ `).run(nextReadMessageId, existing.id);
2461
+ return this.getAgentWorkbenchRoom(agent, room.id)!;
2462
+ }
2463
+
2464
+ leaveAgentRoom(input: { roomId: string; agent: string }): { room: Chat; agent: string; participant_removed: boolean; subscriptions_removed: number; active_deliveries_canceled: number } {
2465
+ const room = this.getChatById(input.roomId);
2466
+ if (!room) throw new Error(`room ${input.roomId} was not found`);
2467
+ const agent = input.agent.trim();
2468
+ if (!agent) throw new Error('agent is required');
2469
+ if (!this.getAgent(agent)) throw new Error(`agent ${agent} not found`);
2470
+
2471
+ let participantRemoved = false;
2472
+ let subscriptionsRemoved = 0;
2473
+ let activeDeliveriesCanceled = 0;
2474
+ let removalMessageId = 0;
2475
+
2476
+ this.db.transaction(() => {
2477
+ const existingParticipant = this.getRoomParticipant(room.id, agent);
2478
+ if (existingParticipant?.status === 'active') {
2479
+ const eventResult = this.recordRoomSystemEvent({
2480
+ roomId: room.id,
2481
+ eventType: 'agent_removed',
2482
+ subjectAgent: agent,
2483
+ deliveryAgents: this.systemEventDeliveryAgents(room, [agent]),
2484
+ });
2485
+ removalMessageId = eventResult.message.id;
2486
+ }
2487
+
2488
+ const participantResult = this.db.query(`
2489
+ UPDATE room_participants
2490
+ SET status = 'removed', updated_at = datetime('now')
2491
+ WHERE room_id = ? AND participant_id = ? AND kind = 'agent' AND status = 'active'
2492
+ `).run(room.id, agent);
2493
+ participantRemoved = participantResult.changes > 0;
2494
+
2495
+ const subscriptionResult = this.db.query(`
2496
+ DELETE FROM agent_room_subscriptions
2497
+ WHERE room_id = ? AND agent = ?
2498
+ `).run(room.id, agent);
2499
+ subscriptionsRemoved = subscriptionResult.changes;
2500
+
2501
+ const deliveryResult = this.db.query(`
2502
+ UPDATE message_deliveries
2503
+ SET status = 'canceled', last_error = CASE WHEN last_error = '' THEN 'agent left room' ELSE last_error END
2504
+ WHERE chat_id = ? AND agent = ? AND status IN ('pending', 'claimed', 'processing_completed')
2505
+ AND message_id != ?
2506
+ `).run(room.id, agent, removalMessageId);
2507
+ activeDeliveriesCanceled = deliveryResult.changes;
2508
+ })();
2509
+
2510
+ return {
2511
+ room,
2512
+ agent,
2513
+ participant_removed: participantRemoved,
2514
+ subscriptions_removed: subscriptionsRemoved,
2515
+ active_deliveries_canceled: activeDeliveriesCanceled,
2516
+ };
2517
+ }
2518
+
1068
2519
  createRoomChannel(input: { roomId: string; name: string; createdBy?: string | null; externalRef?: string | null }): RoomChannel {
1069
2520
  const room = this.getChatById(input.roomId);
1070
2521
  if (!room) throw new Error(`room ${input.roomId} was not found`);
@@ -1085,7 +2536,7 @@ export class MessageStore {
1085
2536
 
1086
2537
  listAgents(limit = 50): AgentDefinition[] {
1087
2538
  const rows = this.db.query(`
1088
- SELECT id, agent_key, display_name, description, runtime, created_at, updated_at
2539
+ SELECT id, agent_key, display_name, description, runtime, model, avatar, created_at, updated_at
1089
2540
  FROM agents
1090
2541
  ORDER BY agent_key ASC
1091
2542
  LIMIT ?
@@ -1097,7 +2548,7 @@ export class MessageStore {
1097
2548
  const key = identifier.trim();
1098
2549
  if (!key) return null;
1099
2550
  const row = this.db.query(`
1100
- SELECT id, agent_key, display_name, description, runtime, created_at, updated_at
2551
+ SELECT id, agent_key, display_name, description, runtime, model, avatar, created_at, updated_at
1101
2552
  FROM agents
1102
2553
  WHERE id = ? OR agent_key = ?
1103
2554
  LIMIT 1
@@ -1112,39 +2563,316 @@ export class MessageStore {
1112
2563
  return row?.runtime ?? null;
1113
2564
  }
1114
2565
 
1115
- upsertAgent(input: { id?: string; agent_key: string; display_name: string; description?: string | null; runtime?: string | null }): AgentDefinition {
2566
+ getAgentPermissionProfile(agentKey: string): AgentPermissionProfile {
2567
+ const key = agentKey.trim();
2568
+ if (!key) throw new Error('agent_key is required');
2569
+ if (!this.getAgent(key)) throw new Error(`agent ${key} not found`);
2570
+ const row = this.db.query(`
2571
+ SELECT * FROM agent_permission_profiles WHERE agent_key = ? LIMIT 1
2572
+ `).get(key) as Record<string, unknown> | null;
2573
+ if (row) return rowToAgentPermissionProfile(row);
2574
+ const now = new Date().toISOString();
2575
+ return {
2576
+ agent_key: key,
2577
+ filesystem_mode: defaultAgentPermissionProfile.filesystemMode,
2578
+ extra_writable_roots: [...defaultAgentPermissionProfile.extraWritableRoots],
2579
+ created_at: now,
2580
+ updated_at: now,
2581
+ };
2582
+ }
2583
+
2584
+ upsertAgentPermissionProfile(agentKey: string, input: RuntimeAgentPermissionProfile): AgentPermissionProfile {
2585
+ const key = agentKey.trim();
2586
+ if (!key) throw new Error('agent_key is required');
2587
+ if (!this.getAgent(key)) throw new Error(`agent ${key} not found`);
2588
+ const validation = validateAgentPermissionProfile(input);
2589
+ if (!validation.ok) {
2590
+ throw new Error(validation.diagnostics.filter((diagnostic) => diagnostic.level === 'error').map((diagnostic) => diagnostic.message).join('; '));
2591
+ }
2592
+ this.db.query(`
2593
+ INSERT INTO agent_permission_profiles (agent_key, filesystem_mode, extra_writable_roots_json, created_at, updated_at)
2594
+ VALUES (?, ?, ?, datetime('now'), datetime('now'))
2595
+ ON CONFLICT(agent_key) DO UPDATE SET
2596
+ filesystem_mode = excluded.filesystem_mode,
2597
+ extra_writable_roots_json = excluded.extra_writable_roots_json,
2598
+ updated_at = datetime('now')
2599
+ `).run(key, validation.profile.filesystemMode, JSON.stringify(validation.profile.extraWritableRoots));
2600
+ return this.getAgentPermissionProfile(key);
2601
+ }
2602
+
2603
+ upsertAgent(input: { id?: string; agent_key: string; display_name: string; description?: string | null; runtime?: string | null; model?: string | null; avatar?: string | null }): AgentDefinition {
1116
2604
  const id = input.id?.trim() || crypto.randomUUID();
1117
2605
  const agentKey = input.agent_key.trim();
1118
2606
  const displayName = input.display_name.trim();
1119
2607
  if (!agentKey) throw new Error('agent_key is required');
1120
2608
  if (!displayName) throw new Error('display_name is required');
1121
2609
 
2610
+ const avatar = normalizeAgentAvatar(input.avatar, agentKey, displayName);
2611
+
1122
2612
  this.db.query(`
1123
- INSERT INTO agents (id, agent_key, display_name, description, runtime, created_at, updated_at)
1124
- VALUES (?, ?, ?, ?, ?, datetime('now'), datetime('now'))
2613
+ INSERT INTO agents (id, agent_key, display_name, description, runtime, model, avatar, created_at, updated_at)
2614
+ VALUES (?, ?, ?, ?, ?, ?, ?, datetime('now'), datetime('now'))
1125
2615
  ON CONFLICT(agent_key) DO UPDATE SET
1126
2616
  display_name = excluded.display_name,
1127
2617
  description = COALESCE(excluded.description, agents.description),
1128
2618
  runtime = COALESCE(excluded.runtime, agents.runtime),
2619
+ model = COALESCE(excluded.model, agents.model),
2620
+ avatar = COALESCE(NULLIF(agents.avatar, ''), excluded.avatar),
1129
2621
  updated_at = datetime('now')
1130
- `).run(id, agentKey, displayName, input.description ?? null, input.runtime ?? null);
2622
+ `).run(id, agentKey, displayName, input.description ?? null, input.runtime ?? null, input.model ?? null, avatar);
1131
2623
 
1132
2624
  return this.getAgent(agentKey)!;
1133
2625
  }
1134
2626
 
1135
2627
  updateAgentRuntime(agentKey: string, runtime: string | null): AgentDefinition | null {
2628
+ return this.updateAgentRuntimeConfig(agentKey, { runtime });
2629
+ }
2630
+
2631
+ updateAgentRuntimeConfig(agentKey: string, input: { runtime?: string | null; model?: string | null }): AgentDefinition | null {
1136
2632
  const key = agentKey.trim();
1137
2633
  if (!key) throw new Error('agent_key is required');
1138
2634
  const existing = this.getAgent(key);
1139
2635
  if (!existing) return null;
1140
2636
 
2637
+ const runtime = Object.prototype.hasOwnProperty.call(input, 'runtime') ? input.runtime ?? null : existing.runtime;
2638
+ const model = Object.prototype.hasOwnProperty.call(input, 'model') ? input.model ?? null : existing.model;
1141
2639
  this.db.query(`
1142
- UPDATE agents SET runtime = ?, updated_at = datetime('now') WHERE agent_key = ?
1143
- `).run(runtime, key);
2640
+ UPDATE agents SET runtime = ?, model = ?, updated_at = datetime('now') WHERE agent_key = ?
2641
+ `).run(runtime, model, key);
1144
2642
 
1145
2643
  return this.getAgent(key);
1146
2644
  }
1147
2645
 
2646
+ listAgentWorkbenchLarkBindings(agentKey: string): AgentWorkbenchLarkBinding[] {
2647
+ const key = agentKey.trim();
2648
+ const rows = this.db.query(`
2649
+ SELECT 'provider_account' AS source,
2650
+ external_account_id AS app_id,
2651
+ NULL AS label,
2652
+ bot_external_user_id AS bot_open_id,
2653
+ status
2654
+ FROM provider_accounts
2655
+ WHERE provider = 'lark' AND agent = ?
2656
+ UNION ALL
2657
+ SELECT 'channel_account' AS source,
2658
+ app_id,
2659
+ name AS label,
2660
+ bot_open_id,
2661
+ status
2662
+ FROM channel_accounts
2663
+ WHERE channel = 'lark' AND agent = ?
2664
+ ORDER BY app_id ASC, source ASC
2665
+ `).all(key, key) as Array<Record<string, unknown>>;
2666
+ return rows.map((row): AgentWorkbenchLarkBinding => ({
2667
+ source: String(row.source) as AgentWorkbenchLarkBinding['source'],
2668
+ app_id: String(row.app_id),
2669
+ label: row.label === null || row.label === undefined ? null : String(row.label),
2670
+ bot_open_id: row.bot_open_id === null || row.bot_open_id === undefined ? null : String(row.bot_open_id),
2671
+ status: String(row.status) as AgentWorkbenchLarkBinding['status'],
2672
+ }));
2673
+ }
2674
+
2675
+ getAgentWorkbenchCollaboration(agentKey: string, limit = 20): AgentWorkbenchCollaboration {
2676
+ const key = agentKey.trim();
2677
+ if (!key) throw new Error('agent_key is required');
2678
+ const cappedLimit = limitValue(limit, 20);
2679
+ const taskRows = this.db.query(`
2680
+ SELECT rt.*, c.name AS room_name
2681
+ FROM room_tasks rt
2682
+ JOIN chats c ON c.id = rt.room_id
2683
+ JOIN room_participants rp ON rp.room_id = rt.room_id
2684
+ WHERE rp.participant_id = ?
2685
+ AND rp.kind = 'agent'
2686
+ AND rp.status = 'active'
2687
+ AND rt.status NOT IN ('done', 'closed')
2688
+ AND (rt.assignee = ? OR rt.created_by = ?)
2689
+ ORDER BY CASE WHEN rt.assignee = ? THEN 0 ELSE 1 END, rt.updated_at DESC, rt.task_number ASC
2690
+ LIMIT ?
2691
+ `).all(key, key, key, key, cappedLimit) as Record<string, unknown>[];
2692
+ const reminderRows = this.db.query(`
2693
+ SELECT rr.*, c.name AS room_name
2694
+ FROM room_reminders rr
2695
+ JOIN chats c ON c.id = rr.room_id
2696
+ JOIN room_participants rp ON rp.room_id = rr.room_id
2697
+ WHERE rp.participant_id = ?
2698
+ AND rp.kind = 'agent'
2699
+ AND rp.status = 'active'
2700
+ AND rr.created_by = ?
2701
+ AND rr.status = 'scheduled'
2702
+ ORDER BY rr.fire_at ASC, rr.created_at ASC
2703
+ LIMIT ?
2704
+ `).all(key, key, cappedLimit) as Record<string, unknown>[];
2705
+ const savedRows = this.db.query(`
2706
+ SELECT
2707
+ rsm.*,
2708
+ c.name AS room_name,
2709
+ m.sender AS message_sender,
2710
+ m.content AS message_content,
2711
+ m.created_at AS message_created_at
2712
+ FROM room_saved_messages rsm
2713
+ JOIN chats c ON c.id = rsm.room_id
2714
+ JOIN messages m ON m.id = rsm.message_id
2715
+ JOIN room_participants rp ON rp.room_id = rsm.room_id
2716
+ WHERE rp.participant_id = ?
2717
+ AND rp.kind = 'agent'
2718
+ AND rp.status = 'active'
2719
+ AND rsm.saved_by = ?
2720
+ ORDER BY rsm.updated_at DESC, rsm.created_at DESC
2721
+ LIMIT ?
2722
+ `).all(key, key, cappedLimit) as Record<string, unknown>[];
2723
+
2724
+ return {
2725
+ tasks: taskRows.map(rowToRoomTask),
2726
+ reminders: reminderRows.map(rowToRoomReminder),
2727
+ saved_messages: savedRows.map(rowToRoomSavedMessage),
2728
+ };
2729
+ }
2730
+
2731
+ getAgentWorkbench(agentKey: string): AgentWorkbench | null {
2732
+ const key = agentKey.trim();
2733
+ if (!key) throw new Error('agent_key is required');
2734
+ const agent = this.getAgent(key);
2735
+ if (!agent) return null;
2736
+ const assignment = this.getComputerAgentAssignment(agent.agent_key);
2737
+ return {
2738
+ agent,
2739
+ permissions: this.getAgentPermissionProfile(agent.agent_key),
2740
+ assignment,
2741
+ machine: assignment ? this.getComputer(assignment.computer_id) : null,
2742
+ project: this.getWorkbenchProjectForAgent(agent.agent_key, assignment),
2743
+ rooms: this.listAgentWorkbenchRooms(agent.agent_key),
2744
+ inbox: this.listInbox(agent.agent_key, 0, 20),
2745
+ deliveries: this.listRecentDeliveriesForAgent(agent.agent_key, 20),
2746
+ runs: this.listRunsForAgent(agent.agent_key, 10),
2747
+ activity: this.listActivityForAgent(agent.agent_key, 80),
2748
+ sessions: this.listSessionsForAgent(agent.agent_key, 10),
2749
+ held_drafts: this.listHeldMessageDrafts(agent.agent_key, 'held', 20),
2750
+ collaboration: this.getAgentWorkbenchCollaboration(agent.agent_key),
2751
+ lark_bindings: this.listAgentWorkbenchLarkBindings(agent.agent_key),
2752
+ };
2753
+ }
2754
+
2755
+ getAgentDeletionImpact(agentKey: string): AgentDeletionImpact | null {
2756
+ const key = agentKey.trim();
2757
+ if (!key) throw new Error('agent_key is required');
2758
+ const agent = this.getAgent(key);
2759
+ if (!agent) return null;
2760
+ const rooms = this.db.query(`
2761
+ SELECT c.id, c.name, c.display_name, c.kind, c.provider, c.status
2762
+ FROM room_participants rp
2763
+ INNER JOIN chat_stats c ON c.id = rp.room_id
2764
+ WHERE rp.participant_id = ? AND rp.kind = 'agent' AND rp.status = 'active'
2765
+ ORDER BY COALESCE(c.display_name, c.name) ASC
2766
+ `).all(key) as Array<Record<string, unknown>>;
2767
+ const providerBindings = this.db.query(`
2768
+ SELECT 'provider_account' AS source, external_account_id AS app_id, NULL AS label
2769
+ FROM provider_accounts
2770
+ WHERE provider = 'lark' AND agent = ? AND status = 'active'
2771
+ UNION ALL
2772
+ SELECT 'channel_account' AS source, app_id, name AS label
2773
+ FROM channel_accounts
2774
+ WHERE channel = 'lark' AND agent = ? AND status = 'active'
2775
+ ORDER BY app_id ASC, source ASC
2776
+ `).all(key, key) as Array<Record<string, unknown>>;
2777
+ return {
2778
+ agent,
2779
+ rooms: rooms.map((row): AgentDeletionRoomImpact => ({
2780
+ id: String(row.id),
2781
+ name: String(row.name),
2782
+ display_name: row.display_name === null || row.display_name === undefined ? null : String(row.display_name),
2783
+ kind: String(row.kind) as ChatKind,
2784
+ provider: String(row.provider) as RoomProvider,
2785
+ status: String(row.status) as ChatStatus,
2786
+ })),
2787
+ lark_bindings: providerBindings.map((row): AgentDeletionLarkBindingImpact => ({
2788
+ source: String(row.source) as AgentDeletionLarkBindingImpact['source'],
2789
+ app_id: String(row.app_id),
2790
+ label: row.label === null || row.label === undefined ? null : String(row.label),
2791
+ })),
2792
+ };
2793
+ }
2794
+
2795
+ deleteAgent(agentKey: string): AgentDeletionResult | null {
2796
+ const key = agentKey.trim();
2797
+ if (!key) throw new Error('agent_key is required');
2798
+ const existing = this.getAgent(key);
2799
+ if (!existing) return null;
2800
+
2801
+ const result: AgentDeletionResult = {
2802
+ agent: existing,
2803
+ rooms_left: 0,
2804
+ subscriptions_removed: 0,
2805
+ assignments_removed: 0,
2806
+ daemon_registrations_removed: 0,
2807
+ provider_accounts_disabled: 0,
2808
+ channel_accounts_disabled: 0,
2809
+ active_deliveries_canceled: 0,
2810
+ running_runs_marked_kill: 0,
2811
+ };
2812
+
2813
+ this.db.transaction(() => {
2814
+ const roomsResult = this.db.query(`
2815
+ UPDATE room_participants
2816
+ SET status = 'removed', updated_at = datetime('now')
2817
+ WHERE participant_id = ? AND kind = 'agent' AND status = 'active'
2818
+ `).run(key);
2819
+ result.rooms_left = roomsResult.changes;
2820
+
2821
+ const subscriptionsResult = this.db.query(`
2822
+ DELETE FROM agent_room_subscriptions
2823
+ WHERE agent = ?
2824
+ `).run(key);
2825
+ result.subscriptions_removed = subscriptionsResult.changes;
2826
+
2827
+ const assignmentsResult = this.db.query(`
2828
+ DELETE FROM computer_agent_assignments
2829
+ WHERE agent = ?
2830
+ `).run(key);
2831
+ result.assignments_removed = assignmentsResult.changes;
2832
+
2833
+ const daemonResult = this.db.query(`
2834
+ DELETE FROM daemon_agents
2835
+ WHERE agent = ?
2836
+ `).run(key);
2837
+ result.daemon_registrations_removed = daemonResult.changes;
2838
+
2839
+ const providerResult = this.db.query(`
2840
+ UPDATE provider_accounts
2841
+ SET status = 'disabled', updated_at = datetime('now')
2842
+ WHERE agent = ? AND status = 'active'
2843
+ `).run(key);
2844
+ result.provider_accounts_disabled = providerResult.changes;
2845
+
2846
+ const channelResult = this.db.query(`
2847
+ UPDATE channel_accounts
2848
+ SET status = 'disabled', updated_at = datetime('now')
2849
+ WHERE agent = ? AND status = 'active'
2850
+ `).run(key);
2851
+ result.channel_accounts_disabled = channelResult.changes;
2852
+
2853
+ const deliveryResult = this.db.query(`
2854
+ UPDATE message_deliveries
2855
+ SET status = 'canceled', last_error = CASE WHEN last_error = '' THEN 'agent deleted' ELSE last_error END
2856
+ WHERE agent = ? AND status IN ('pending', 'claimed', 'processing_completed')
2857
+ `).run(key);
2858
+ result.active_deliveries_canceled = deliveryResult.changes;
2859
+
2860
+ const runResult = this.db.query(`
2861
+ UPDATE agent_runs
2862
+ SET action = 'kill', updated_at = datetime('now')
2863
+ WHERE agent = ? AND status = 'running'
2864
+ `).run(key);
2865
+ result.running_runs_marked_kill = runResult.changes;
2866
+
2867
+ this.db.query(`
2868
+ DELETE FROM agents
2869
+ WHERE agent_key = ?
2870
+ `).run(key);
2871
+ })();
2872
+
2873
+ return result;
2874
+ }
2875
+
1148
2876
  assignAgentToComputer(input: { agent: string; computerId: string }): ComputerAgentAssignment {
1149
2877
  const agent = input.agent.trim();
1150
2878
  const computerId = input.computerId.trim();
@@ -1166,9 +2894,19 @@ export class MessageStore {
1166
2894
  return this.getComputerAgentAssignment(agent)!;
1167
2895
  }
1168
2896
 
2897
+ unassignAgentFromComputer(agent: string): boolean {
2898
+ const key = agent.trim();
2899
+ if (!key) throw new Error('agent is required');
2900
+ const result = this.db.query(`
2901
+ DELETE FROM computer_agent_assignments
2902
+ WHERE agent = ?
2903
+ `).run(key);
2904
+ return result.changes > 0;
2905
+ }
2906
+
1169
2907
  getComputerAgentAssignment(agent: string): ComputerAgentAssignment | null {
1170
2908
  const row = this.db.query(`
1171
- SELECT caa.*, a.display_name, a.runtime
2909
+ SELECT caa.*, a.display_name, a.runtime, a.model
1172
2910
  FROM computer_agent_assignments caa
1173
2911
  JOIN agents a ON a.agent_key = caa.agent
1174
2912
  WHERE caa.agent = ?
@@ -1179,7 +2917,7 @@ export class MessageStore {
1179
2917
 
1180
2918
  listComputerAgentAssignments(computerId: string): ComputerAgentAssignment[] {
1181
2919
  const rows = this.db.query(`
1182
- SELECT caa.*, a.display_name, a.runtime
2920
+ SELECT caa.*, a.display_name, a.runtime, a.model
1183
2921
  FROM computer_agent_assignments caa
1184
2922
  JOIN agents a ON a.agent_key = caa.agent
1185
2923
  WHERE caa.computer_id = ? AND caa.status = 'active'
@@ -1220,6 +2958,10 @@ export class MessageStore {
1220
2958
  diagnostics.push({ level: 'error', code: 'INVALID_FIELD', message: 'runtime must be a string or null' });
1221
2959
  }
1222
2960
 
2961
+ if ('model' in input && input.model !== null && typeof input.model !== 'string') {
2962
+ diagnostics.push({ level: 'error', code: 'INVALID_FIELD', message: 'model must be a string or null' });
2963
+ }
2964
+
1223
2965
  for (const key of ['created_at', 'updated_at']) {
1224
2966
  if (key in input && typeof input[key] !== 'string') {
1225
2967
  diagnostics.push({ level: 'error', code: 'INVALID_FIELD', message: `${key} must be a string when provided` });
@@ -2028,9 +3770,13 @@ export class MessageStore {
2028
3770
 
2029
3771
  createMessage(input: CreateMessageInput): Message {
2030
3772
  const sender = input.sender.trim();
2031
- const content = input.content.trim();
3773
+ const attachments = input.attachments ?? [];
3774
+ const content = input.content.trim() || (attachments.length > 0 ? (attachments.some((attachment) => attachment.kind === 'image') ? '[image]' : '[file]') : '');
2032
3775
  if (!sender) throw new Error('sender is required');
2033
3776
  if (!content) throw new Error('content is required');
3777
+ for (const attachment of attachments) {
3778
+ validateMessageAttachment({ kind: attachment.kind, mimeType: attachment.mimeType, content: attachment.content });
3779
+ }
2034
3780
 
2035
3781
  let chat: Chat | null = null;
2036
3782
  let parentId: number | null = null;
@@ -2061,7 +3807,7 @@ export class MessageStore {
2061
3807
  JOIN chats c ON c.id = m.chat_id
2062
3808
  WHERE m.idempotency_key = ?
2063
3809
  `).get(idempotencyKey) as Record<string, unknown> | null;
2064
- if (existing) return rowToMessage(existing);
3810
+ if (existing) return this.attachMessageAttachments([rowToMessage(existing)])[0]!;
2065
3811
  }
2066
3812
 
2067
3813
  const mentions = normalizeMentionsInput(input.mentions);
@@ -2072,79 +3818,863 @@ export class MessageStore {
2072
3818
  if (channel.room_id !== chat.id) throw new Error(`channel ${channelId} does not belong to room ${chat.id}`);
2073
3819
  }
2074
3820
  const provider = input.provider ?? chat.provider ?? 'web';
2075
- const result = this.db.query(`
2076
- INSERT INTO messages (chat_id, parent_id, depth, sender, recipient, content, type, idempotency_key, channel_id, provider, mentions_json)
2077
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
3821
+ const id = this.db.transaction(() => {
3822
+ const result = this.db.query(`
3823
+ INSERT INTO messages (chat_id, parent_id, depth, sender, recipient, content, type, idempotency_key, channel_id, provider, mentions_json)
3824
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
3825
+ `).run(
3826
+ chat.id,
3827
+ parentId,
3828
+ depth,
3829
+ sender,
3830
+ input.recipient?.trim() || null,
3831
+ content,
3832
+ input.type ?? 'message',
3833
+ idempotencyKey,
3834
+ channelId,
3835
+ provider,
3836
+ JSON.stringify(mentions),
3837
+ );
3838
+ const messageId = Number(result.lastInsertRowid);
3839
+ for (const attachment of attachments) {
3840
+ const attachmentId = crypto.randomUUID();
3841
+ const stored = storeMessageAttachmentFile({
3842
+ palHome: this.palHome,
3843
+ roomId: chat.id,
3844
+ messageId,
3845
+ attachmentId,
3846
+ filename: attachment.filename,
3847
+ mimeType: attachment.mimeType,
3848
+ content: attachment.content,
3849
+ });
3850
+ this.db.query(`
3851
+ INSERT INTO message_attachments (id, message_id, kind, mime_type, filename, size_bytes, path, source_provider, source_ref)
3852
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
3853
+ `).run(
3854
+ attachmentId,
3855
+ messageId,
3856
+ attachment.kind,
3857
+ attachment.mimeType,
3858
+ stored.filename,
3859
+ attachment.content.length,
3860
+ stored.path,
3861
+ attachment.sourceProvider ?? provider,
3862
+ attachment.sourceRef?.trim() || null,
3863
+ );
3864
+ }
3865
+ return messageId;
3866
+ })();
3867
+ return this.getMessage(id)!;
3868
+ }
3869
+
3870
+ private attachMessageAttachments(messages: Message[]): Message[] {
3871
+ if (messages.length === 0) return messages;
3872
+ const ids = messages.map((message) => message.id);
3873
+ const placeholders = ids.map(() => '?').join(', ');
3874
+ const rows = this.db.query(`
3875
+ SELECT id, message_id, kind, mime_type, filename, size_bytes, path, source_provider, created_at
3876
+ FROM message_attachments
3877
+ WHERE message_id IN (${placeholders})
3878
+ ORDER BY message_id ASC, id ASC
3879
+ `).all(...ids) as Record<string, unknown>[];
3880
+ const byMessage = new Map<number, MessageAttachmentMetadata[]>();
3881
+ for (const row of rows) {
3882
+ const attachment = rowToMessageAttachmentMetadata(row);
3883
+ const list = byMessage.get(attachment.message_id) ?? [];
3884
+ list.push(attachment);
3885
+ byMessage.set(attachment.message_id, list);
3886
+ }
3887
+ return messages.map((message) => ({ ...message, attachments: byMessage.get(message.id) ?? [] }));
3888
+ }
3889
+
3890
+ listMessageAttachments(messageId: number): MessageAttachmentMetadata[] {
3891
+ const rows = this.db.query(`
3892
+ SELECT id, message_id, kind, mime_type, filename, size_bytes, path, source_provider, created_at
3893
+ FROM message_attachments
3894
+ WHERE message_id = ?
3895
+ ORDER BY id ASC
3896
+ `).all(messageId) as Record<string, unknown>[];
3897
+ return rows.map(rowToMessageAttachmentMetadata);
3898
+ }
3899
+
3900
+ addMessageAttachment(messageId: number, attachment: CreateMessageAttachmentInput): MessageAttachmentMetadata {
3901
+ const message = this.getMessage(messageId);
3902
+ if (!message) throw new Error(`message ${messageId} was not found`);
3903
+ validateMessageAttachment({ kind: attachment.kind, mimeType: attachment.mimeType, content: attachment.content });
3904
+ const id = crypto.randomUUID();
3905
+ const stored = storeMessageAttachmentFile({
3906
+ palHome: this.palHome,
3907
+ roomId: message.chat_id,
3908
+ messageId,
3909
+ attachmentId: id,
3910
+ filename: attachment.filename,
3911
+ mimeType: attachment.mimeType,
3912
+ content: attachment.content,
3913
+ });
3914
+ this.db.query(`
3915
+ INSERT INTO message_attachments (id, message_id, kind, mime_type, filename, size_bytes, path, source_provider, source_ref)
3916
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
3917
+ `).run(
3918
+ id,
3919
+ messageId,
3920
+ attachment.kind,
3921
+ attachment.mimeType,
3922
+ stored.filename,
3923
+ attachment.content.length,
3924
+ stored.path,
3925
+ attachment.sourceProvider ?? message.provider ?? null,
3926
+ attachment.sourceRef?.trim() || null,
3927
+ );
3928
+ return this.listMessageAttachments(messageId).find((item) => item.id === id)!;
3929
+ }
3930
+
3931
+ getMessageAttachment(id: string): MessageAttachment | null {
3932
+ const row = this.db.query('SELECT * FROM message_attachments WHERE id = ?').get(id.trim()) as Record<string, unknown> | null;
3933
+ return row ? rowToMessageAttachment(row) : null;
3934
+ }
3935
+
3936
+ getMessage(id: number): Message | null {
3937
+ const row = this.db.query(`
3938
+ SELECT m.*, c.name AS chat_name
3939
+ FROM messages m
3940
+ JOIN chats c ON c.id = m.chat_id
3941
+ WHERE m.id = ?
3942
+ `).get(id) as Record<string, unknown> | null;
3943
+ return row ? this.attachMessageAttachments([rowToMessage(row)])[0]! : null;
3944
+ }
3945
+
3946
+ listMessages(input: ListMessagesInput = {}): Message[] {
3947
+ const conditions: string[] = [];
3948
+ const params: SQLQueryBindings[] = [];
3949
+
3950
+ if (input.chatId) {
3951
+ conditions.push('m.chat_id = ?');
3952
+ params.push(input.chatId);
3953
+ } else if (input.chatName) {
3954
+ conditions.push('c.name = ?');
3955
+ params.push(normalizeChatName(input.chatName));
3956
+ }
3957
+
3958
+ if (input.parentId !== undefined) {
3959
+ if (input.parentId === null) {
3960
+ conditions.push('m.parent_id IS NULL');
3961
+ } else {
3962
+ conditions.push('(m.id = ? OR m.parent_id = ?)');
3963
+ params.push(input.parentId, input.parentId);
3964
+ }
3965
+ }
3966
+
3967
+ if (input.after !== undefined) {
3968
+ conditions.push('m.id > ?');
3969
+ params.push(input.after);
3970
+ }
3971
+
3972
+ if (input.before !== undefined) {
3973
+ conditions.push('m.id < ?');
3974
+ params.push(input.before);
3975
+ }
3976
+
3977
+ if (input.q) {
3978
+ conditions.push('m.content LIKE ?');
3979
+ params.push(`%${input.q}%`);
3980
+ }
3981
+
3982
+ const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';
3983
+ const limit = limitValue(input.limit, 50);
3984
+ const useLatestWindow = input.after === undefined && input.q === undefined;
3985
+ const rows = useLatestWindow
3986
+ ? this.db.query(`
3987
+ SELECT *
3988
+ FROM (
3989
+ SELECT m.*, c.name AS chat_name
3990
+ FROM messages m
3991
+ JOIN chats c ON c.id = m.chat_id
3992
+ ${where}
3993
+ ORDER BY m.id DESC
3994
+ LIMIT ?
3995
+ )
3996
+ ORDER BY id ASC
3997
+ `).all(...params, limit) as Record<string, unknown>[]
3998
+ : this.db.query(`
3999
+ SELECT m.*, c.name AS chat_name
4000
+ FROM messages m
4001
+ JOIN chats c ON c.id = m.chat_id
4002
+ ${where}
4003
+ ORDER BY m.id ASC
4004
+ LIMIT ?
4005
+ `).all(...params, limit) as Record<string, unknown>[];
4006
+
4007
+ return this.attachMessageAttachments(rows.map(rowToMessage));
4008
+ }
4009
+
4010
+ getLatestMessageIdForFreshness(chatId: string, sender: string): number {
4011
+ const row = this.db.query(`
4012
+ SELECT COALESCE(MAX(id), 0) AS latest
4013
+ FROM messages
4014
+ WHERE chat_id = ? AND sender != ?
4015
+ `).get(chatId, sender.trim()) as { latest: number } | null;
4016
+ return Number(row?.latest ?? 0);
4017
+ }
4018
+
4019
+ listInterveningMessagesForFreshness(input: { chatId: string; baseMessageId: number; sender: string; limit?: number }): Message[] {
4020
+ const rows = this.db.query(`
4021
+ SELECT m.*, c.name AS chat_name
4022
+ FROM messages m
4023
+ JOIN chats c ON c.id = m.chat_id
4024
+ WHERE m.chat_id = ?
4025
+ AND m.id > ?
4026
+ AND m.sender != ?
4027
+ ORDER BY m.id ASC
4028
+ LIMIT ?
4029
+ `).all(input.chatId, input.baseMessageId, input.sender.trim(), limitValue(input.limit, 50)) as Record<string, unknown>[];
4030
+ return this.attachMessageAttachments(rows.map(rowToMessage));
4031
+ }
4032
+
4033
+ listRoomTasks(roomId: string, status: RoomTaskStatus | 'all' = 'all', limit = 50): RoomTask[] {
4034
+ const room = this.getChatById(roomId);
4035
+ if (!room) throw new Error(`room ${roomId} was not found`);
4036
+ const conditions = ['rt.room_id = ?'];
4037
+ const params: SQLQueryBindings[] = [room.id];
4038
+ if (status !== 'all') {
4039
+ conditions.push('rt.status = ?');
4040
+ params.push(assertRoomTaskStatus(status));
4041
+ }
4042
+ const rows = this.db.query(`
4043
+ SELECT rt.*, c.name AS room_name
4044
+ FROM room_tasks rt
4045
+ JOIN chats c ON c.id = rt.room_id
4046
+ WHERE ${conditions.join(' AND ')}
4047
+ ORDER BY rt.task_number ASC
4048
+ LIMIT ?
4049
+ `).all(...params, limitValue(limit, 50)) as Record<string, unknown>[];
4050
+ return rows.map(rowToRoomTask);
4051
+ }
4052
+
4053
+ getRoomTask(roomId: string, taskNumber: number): RoomTask | null {
4054
+ const row = this.db.query(`
4055
+ SELECT rt.*, c.name AS room_name
4056
+ FROM room_tasks rt
4057
+ JOIN chats c ON c.id = rt.room_id
4058
+ WHERE rt.room_id = ? AND rt.task_number = ?
4059
+ `).get(roomId, taskNumber) as Record<string, unknown> | null;
4060
+ return row ? rowToRoomTask(row) : null;
4061
+ }
4062
+
4063
+ createRoomTasks(input: CreateRoomTasksInput): RoomTask[] {
4064
+ const room = this.getChatById(input.roomId);
4065
+ if (!room) throw new Error(`room ${input.roomId} was not found`);
4066
+ const titles = input.titles.map((title) => title.trim()).filter(Boolean);
4067
+ if (!titles.length) throw new Error('task title is required');
4068
+
4069
+ const sourceMessageId = input.sourceMessageId === undefined ? null : input.sourceMessageId;
4070
+ if (sourceMessageId !== null) {
4071
+ if (!Number.isInteger(sourceMessageId) || sourceMessageId < 1) throw new Error('INVALID_SOURCE_MESSAGE_ID');
4072
+ const sourceMessage = this.getMessage(sourceMessageId);
4073
+ if (!sourceMessage || sourceMessage.chat_id !== room.id) throw new Error('SOURCE_MESSAGE_NOT_FOUND');
4074
+ }
4075
+ const createdBy = input.createdBy?.trim() || null;
4076
+
4077
+ return this.db.transaction(() => {
4078
+ const nextRow = this.db.query(`
4079
+ SELECT COALESCE(MAX(task_number), 0) + 1 AS task_number
4080
+ FROM room_tasks
4081
+ WHERE room_id = ?
4082
+ `).get(room.id) as { task_number: number };
4083
+ let taskNumber = Number(nextRow.task_number ?? 1);
4084
+ const created: RoomTask[] = [];
4085
+ for (const title of titles) {
4086
+ const id = crypto.randomUUID();
4087
+ this.db.query(`
4088
+ INSERT INTO room_tasks (id, room_id, task_number, title, status, assignee, created_by, source_message_id, created_at, updated_at)
4089
+ VALUES (?, ?, ?, ?, 'todo', NULL, ?, ?, datetime('now'), datetime('now'))
4090
+ `).run(id, room.id, taskNumber, title, createdBy, sourceMessageId);
4091
+ created.push(this.getRoomTask(room.id, taskNumber)!);
4092
+ taskNumber += 1;
4093
+ }
4094
+ return created;
4095
+ })();
4096
+ }
4097
+
4098
+ claimRoomTask(input: ClaimRoomTaskInput): RoomTask | null {
4099
+ const room = this.getChatById(input.roomId);
4100
+ if (!room) throw new Error(`room ${input.roomId} was not found`);
4101
+ const assignee = input.assignee.trim();
4102
+ if (!assignee) throw new Error('assignee is required');
4103
+ if (!Number.isInteger(input.taskNumber) || input.taskNumber < 1) throw new Error('INVALID_TASK_NUMBER');
4104
+ const existing = this.getRoomTask(room.id, input.taskNumber);
4105
+ if (!existing) return null;
4106
+ this.db.query(`
4107
+ UPDATE room_tasks
4108
+ SET assignee = ?, status = 'in_progress', completed_at = NULL, updated_at = datetime('now')
4109
+ WHERE room_id = ? AND task_number = ?
4110
+ `).run(assignee, room.id, input.taskNumber);
4111
+ return this.getRoomTask(room.id, input.taskNumber);
4112
+ }
4113
+
4114
+ unclaimRoomTask(roomId: string, taskNumber: number): RoomTask | null {
4115
+ const room = this.getChatById(roomId);
4116
+ if (!room) throw new Error(`room ${roomId} was not found`);
4117
+ if (!Number.isInteger(taskNumber) || taskNumber < 1) throw new Error('INVALID_TASK_NUMBER');
4118
+ const existing = this.getRoomTask(room.id, taskNumber);
4119
+ if (!existing) return null;
4120
+ this.db.query(`
4121
+ UPDATE room_tasks
4122
+ SET assignee = NULL, status = 'todo', completed_at = NULL, updated_at = datetime('now')
4123
+ WHERE room_id = ? AND task_number = ?
4124
+ `).run(room.id, taskNumber);
4125
+ return this.getRoomTask(room.id, taskNumber);
4126
+ }
4127
+
4128
+ updateRoomTaskStatus(input: UpdateRoomTaskInput): RoomTask | null {
4129
+ const room = this.getChatById(input.roomId);
4130
+ if (!room) throw new Error(`room ${input.roomId} was not found`);
4131
+ if (!Number.isInteger(input.taskNumber) || input.taskNumber < 1) throw new Error('INVALID_TASK_NUMBER');
4132
+ const status = assertRoomTaskStatus(input.status);
4133
+ const existing = this.getRoomTask(room.id, input.taskNumber);
4134
+ if (!existing) return null;
4135
+ const completedAtSql = status === 'done' || status === 'closed'
4136
+ ? "COALESCE(completed_at, datetime('now'))"
4137
+ : 'NULL';
4138
+ this.db.query(`
4139
+ UPDATE room_tasks
4140
+ SET status = ?, completed_at = ${completedAtSql}, updated_at = datetime('now')
4141
+ WHERE room_id = ? AND task_number = ?
4142
+ `).run(status, room.id, input.taskNumber);
4143
+ return this.getRoomTask(room.id, input.taskNumber);
4144
+ }
4145
+
4146
+ getRoomReminder(id: string): RoomReminder | null {
4147
+ const row = this.db.query(`
4148
+ SELECT rr.*, c.name AS room_name
4149
+ FROM room_reminders rr
4150
+ JOIN chats c ON c.id = rr.room_id
4151
+ WHERE rr.id = ?
4152
+ `).get(id) as Record<string, unknown> | null;
4153
+ return row ? rowToRoomReminder(row) : null;
4154
+ }
4155
+
4156
+ listRoomReminders(input: ListRoomRemindersInput = {}): RoomReminder[] {
4157
+ const conditions: string[] = [];
4158
+ const params: SQLQueryBindings[] = [];
4159
+ if (input.roomId?.trim()) {
4160
+ const room = this.resolveRoom(input.roomId);
4161
+ if (!room) throw new Error(`room ${input.roomId} was not found`);
4162
+ conditions.push('rr.room_id = ?');
4163
+ params.push(room.id);
4164
+ }
4165
+ if ((input.status ?? 'scheduled') !== 'all') {
4166
+ conditions.push('rr.status = ?');
4167
+ params.push(assertRoomReminderStatus(input.status ?? 'scheduled'));
4168
+ }
4169
+ if (input.createdBy?.trim()) {
4170
+ conditions.push('rr.created_by = ?');
4171
+ params.push(input.createdBy.trim());
4172
+ }
4173
+ const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';
4174
+ const rows = this.db.query(`
4175
+ SELECT rr.*, c.name AS room_name
4176
+ FROM room_reminders rr
4177
+ JOIN chats c ON c.id = rr.room_id
4178
+ ${where}
4179
+ ORDER BY rr.fire_at ASC, rr.created_at ASC
4180
+ LIMIT ?
4181
+ `).all(...params, limitValue(input.limit, 50)) as Record<string, unknown>[];
4182
+ return rows.map(rowToRoomReminder);
4183
+ }
4184
+
4185
+ createRoomReminder(input: CreateRoomReminderInput): RoomReminder {
4186
+ if (!Number.isInteger(input.sourceMessageId) || input.sourceMessageId < 1) throw new Error('INVALID_SOURCE_MESSAGE_ID');
4187
+ const sourceMessage = this.getMessage(input.sourceMessageId);
4188
+ if (!sourceMessage) throw new Error('SOURCE_MESSAGE_NOT_FOUND');
4189
+ const title = input.title.trim();
4190
+ if (!title) throw new Error('reminder title is required');
4191
+ const fireAt = normalizeReminderFireAt(input);
4192
+ const createdBy = input.createdBy?.trim() || null;
4193
+ const repeat = normalizeReminderRepeat(input.repeat);
4194
+ const id = crypto.randomUUID();
4195
+ this.db.query(`
4196
+ INSERT INTO room_reminders (id, room_id, source_message_id, title, status, created_by, fire_at, repeat, created_at, updated_at)
4197
+ VALUES (?, ?, ?, ?, 'scheduled', ?, ?, ?, datetime('now'), datetime('now'))
4198
+ `).run(id, sourceMessage.chat_id, sourceMessage.id, title, createdBy, fireAt, repeat);
4199
+ return this.getRoomReminder(id)!;
4200
+ }
4201
+
4202
+ cancelRoomReminder(id: string): RoomReminder | null {
4203
+ const reminderId = id.trim();
4204
+ if (!reminderId) return null;
4205
+ const existing = this.getRoomReminder(reminderId);
4206
+ if (!existing) return null;
4207
+ if (existing.status === 'scheduled') {
4208
+ this.db.query(`
4209
+ UPDATE room_reminders
4210
+ SET status = 'canceled', canceled_at = datetime('now'), updated_at = datetime('now')
4211
+ WHERE id = ?
4212
+ `).run(reminderId);
4213
+ }
4214
+ return this.getRoomReminder(reminderId);
4215
+ }
4216
+
4217
+ getRoomSavedMessage(id: string): RoomSavedMessage | null {
4218
+ const savedMessageId = id.trim();
4219
+ if (!savedMessageId) return null;
4220
+ const row = this.db.query(`
4221
+ SELECT
4222
+ rsm.*,
4223
+ c.name AS room_name,
4224
+ m.sender AS message_sender,
4225
+ m.content AS message_content,
4226
+ m.created_at AS message_created_at
4227
+ FROM room_saved_messages rsm
4228
+ JOIN chats c ON c.id = rsm.room_id
4229
+ JOIN messages m ON m.id = rsm.message_id
4230
+ WHERE rsm.id = ?
4231
+ `).get(savedMessageId) as Record<string, unknown> | null;
4232
+ return row ? rowToRoomSavedMessage(row) : null;
4233
+ }
4234
+
4235
+ listRoomSavedMessages(input: ListRoomSavedMessagesInput = {}): RoomSavedMessage[] {
4236
+ const conditions: string[] = [];
4237
+ const params: SQLQueryBindings[] = [];
4238
+ if (input.roomId?.trim()) {
4239
+ const room = this.resolveRoom(input.roomId);
4240
+ if (!room) throw new Error(`room ${input.roomId} was not found`);
4241
+ conditions.push('rsm.room_id = ?');
4242
+ params.push(room.id);
4243
+ }
4244
+ if (input.savedBy?.trim()) {
4245
+ conditions.push('rsm.saved_by = ?');
4246
+ params.push(input.savedBy.trim());
4247
+ }
4248
+ const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';
4249
+ const rows = this.db.query(`
4250
+ SELECT
4251
+ rsm.*,
4252
+ c.name AS room_name,
4253
+ m.sender AS message_sender,
4254
+ m.content AS message_content,
4255
+ m.created_at AS message_created_at
4256
+ FROM room_saved_messages rsm
4257
+ JOIN chats c ON c.id = rsm.room_id
4258
+ JOIN messages m ON m.id = rsm.message_id
4259
+ ${where}
4260
+ ORDER BY rsm.created_at DESC, rsm.updated_at DESC
4261
+ LIMIT ?
4262
+ `).all(...params, limitValue(input.limit, 50)) as Record<string, unknown>[];
4263
+ return rows.map(rowToRoomSavedMessage);
4264
+ }
4265
+
4266
+ saveRoomMessage(input: CreateRoomSavedMessageInput): SaveRoomMessageResult {
4267
+ if (!Number.isInteger(input.sourceMessageId) || input.sourceMessageId < 1) throw new Error('INVALID_SOURCE_MESSAGE_ID');
4268
+ const sourceMessage = this.getMessage(input.sourceMessageId);
4269
+ if (!sourceMessage) throw new Error('SOURCE_MESSAGE_NOT_FOUND');
4270
+ const savedBy = input.savedBy.trim();
4271
+ if (!savedBy) throw new Error('saved_by is required');
4272
+ const note = input.note?.trim() || null;
4273
+ const existing = this.db.query(`
4274
+ SELECT id
4275
+ FROM room_saved_messages
4276
+ WHERE message_id = ? AND saved_by = ?
4277
+ `).get(sourceMessage.id, savedBy) as { id: string } | null;
4278
+ if (existing) {
4279
+ this.db.query(`
4280
+ UPDATE room_saved_messages
4281
+ SET note = ?, updated_at = datetime('now')
4282
+ WHERE id = ?
4283
+ `).run(note, existing.id);
4284
+ return { savedMessage: this.getRoomSavedMessage(existing.id)!, created: false };
4285
+ }
4286
+ const id = crypto.randomUUID();
4287
+ this.db.query(`
4288
+ INSERT INTO room_saved_messages (id, room_id, message_id, saved_by, note, created_at, updated_at)
4289
+ VALUES (?, ?, ?, ?, ?, datetime('now'), datetime('now'))
4290
+ `).run(id, sourceMessage.chat_id, sourceMessage.id, savedBy, note);
4291
+ return { savedMessage: this.getRoomSavedMessage(id)!, created: true };
4292
+ }
4293
+
4294
+ removeRoomSavedMessage(id: string): RoomSavedMessage | null {
4295
+ const savedMessage = this.getRoomSavedMessage(id);
4296
+ if (!savedMessage) return null;
4297
+ this.db.query('DELETE FROM room_saved_messages WHERE id = ?').run(savedMessage.id);
4298
+ return savedMessage;
4299
+ }
4300
+
4301
+ createWorkflowRun(input: CreateWorkflowRunInput): WorkflowRun {
4302
+ const filePath = input.filePath.trim();
4303
+ if (!filePath) throw new Error('workflow file_path is required');
4304
+ const id = crypto.randomUUID();
4305
+ this.db.query(`
4306
+ INSERT INTO workflow_runs (id, file_path, goal, status, created_by, created_at, updated_at)
4307
+ VALUES (?, ?, ?, 'running', ?, datetime('now'), datetime('now'))
4308
+ `).run(id, filePath, input.goal?.trim() || null, input.createdBy?.trim() || null);
4309
+ return this.getWorkflowRun(id)!;
4310
+ }
4311
+
4312
+ getWorkflowRun(id: string): WorkflowRun | null {
4313
+ const row = this.db.query('SELECT * FROM workflow_runs WHERE id = ?').get(id.trim()) as Record<string, unknown> | null;
4314
+ return row ? rowToWorkflowRun(row) : null;
4315
+ }
4316
+
4317
+ listWorkflowRuns(input: { status?: WorkflowRunStatus | 'all'; limit?: number } = {}): WorkflowRun[] {
4318
+ const status = input.status ?? 'all';
4319
+ const rows = status === 'all'
4320
+ ? this.db.query(`
4321
+ SELECT * FROM workflow_runs
4322
+ ORDER BY datetime(updated_at) DESC, datetime(created_at) DESC
4323
+ LIMIT ?
4324
+ `).all(limitValue(input.limit, 50)) as Record<string, unknown>[]
4325
+ : this.db.query(`
4326
+ SELECT * FROM workflow_runs
4327
+ WHERE status = ?
4328
+ ORDER BY datetime(updated_at) DESC, datetime(created_at) DESC
4329
+ LIMIT ?
4330
+ `).all(assertWorkflowRunStatus(status), limitValue(input.limit, 50)) as Record<string, unknown>[];
4331
+ return rows.map(rowToWorkflowRun);
4332
+ }
4333
+
4334
+ finishWorkflowRun(id: string, input: FinishWorkflowRunInput): WorkflowRun {
4335
+ const run = this.getWorkflowRun(id);
4336
+ if (!run) throw new Error(`workflow run ${id} was not found`);
4337
+ const status = assertWorkflowRunStatus(input.status);
4338
+ const finalOutput = input.finalOutput === undefined ? run.final_output : input.finalOutput;
4339
+ const error = input.error === undefined ? run.error : input.error;
4340
+ const completedAtSql = status === 'running' ? 'NULL' : "COALESCE(completed_at, datetime('now'))";
4341
+ this.db.query(`
4342
+ UPDATE workflow_runs
4343
+ SET status = ?,
4344
+ final_output_json = ?,
4345
+ error = ?,
4346
+ completed_at = ${completedAtSql},
4347
+ updated_at = datetime('now')
4348
+ WHERE id = ?
4349
+ `).run(status, finalOutput ? JSON.stringify(finalOutput) : null, error?.trim() || null, run.id);
4350
+ return this.getWorkflowRun(run.id)!;
4351
+ }
4352
+
4353
+ createWorkflowNode(input: CreateWorkflowNodeInput): WorkflowNode {
4354
+ const run = this.getWorkflowRun(input.runId);
4355
+ if (!run) throw new Error(`workflow run ${input.runId} was not found`);
4356
+ const parentId = input.parentId?.trim() || null;
4357
+ if (parentId) {
4358
+ const parent = this.getWorkflowNode(parentId);
4359
+ if (!parent) throw new Error(`workflow parent node ${parentId} was not found`);
4360
+ if (parent.run_id !== run.id) throw new Error('WORKFLOW_PARENT_RUN_MISMATCH');
4361
+ }
4362
+ const title = input.title.trim();
4363
+ if (!title) throw new Error('workflow node title is required');
4364
+ const status = assertWorkflowNodeStatus(input.status ?? 'pending');
4365
+ const id = crypto.randomUUID();
4366
+ const completedAtSql = status === 'done' || status === 'failed' ? "datetime('now')" : 'NULL';
4367
+ this.db.query(`
4368
+ INSERT INTO workflow_nodes (
4369
+ id, run_id, parent_id, kind, title, role, status, context_json, instruction,
4370
+ capabilities_json, output_contract_json, output_json, evidence_json, task_id,
4371
+ message_id, created_at, updated_at, completed_at
4372
+ )
4373
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'), datetime('now'), ${completedAtSql})
2078
4374
  `).run(
2079
- chat.id,
4375
+ id,
4376
+ run.id,
2080
4377
  parentId,
2081
- depth,
2082
- sender,
2083
- input.recipient?.trim() || null,
2084
- content,
2085
- input.type ?? 'message',
2086
- idempotencyKey,
2087
- channelId,
2088
- provider,
2089
- JSON.stringify(mentions),
4378
+ assertWorkflowNodeKind(input.kind),
4379
+ title,
4380
+ input.role?.trim() || null,
4381
+ status,
4382
+ input.context ? JSON.stringify(input.context) : null,
4383
+ input.instruction?.trim() || null,
4384
+ JSON.stringify(input.capabilities ?? []),
4385
+ input.outputContract ? JSON.stringify(input.outputContract) : null,
4386
+ input.output ? JSON.stringify(input.output) : null,
4387
+ input.evidence ? JSON.stringify(input.evidence) : null,
4388
+ input.taskId?.trim() || null,
4389
+ input.messageId ?? null,
2090
4390
  );
4391
+ return this.getWorkflowNode(id)!;
4392
+ }
2091
4393
 
2092
- const id = Number(result.lastInsertRowid);
2093
- return this.getMessage(id)!;
4394
+ getWorkflowNode(id: string): WorkflowNode | null {
4395
+ const row = this.db.query('SELECT * FROM workflow_nodes WHERE id = ?').get(id.trim()) as Record<string, unknown> | null;
4396
+ return row ? rowToWorkflowNode(row) : null;
2094
4397
  }
2095
4398
 
2096
- getMessage(id: number): Message | null {
2097
- const row = this.db.query(`
2098
- SELECT m.*, c.name AS chat_name
2099
- FROM messages m
2100
- JOIN chats c ON c.id = m.chat_id
2101
- WHERE m.id = ?
2102
- `).get(id) as Record<string, unknown> | null;
2103
- return row ? rowToMessage(row) : null;
4399
+ updateWorkflowNode(id: string, input: UpdateWorkflowNodeInput): WorkflowNode {
4400
+ const node = this.getWorkflowNode(id);
4401
+ if (!node) throw new Error(`workflow node ${id} was not found`);
4402
+ const status = input.status ? assertWorkflowNodeStatus(input.status) : node.status;
4403
+ const completedAtSql = status === 'done' || status === 'failed'
4404
+ ? "COALESCE(completed_at, datetime('now'))"
4405
+ : 'NULL';
4406
+ const output = input.output === undefined ? node.output : input.output;
4407
+ const evidence = input.evidence === undefined ? node.evidence : input.evidence;
4408
+ const taskId = input.taskId === undefined ? node.task_id : input.taskId;
4409
+ const messageId = input.messageId === undefined ? node.message_id : input.messageId;
4410
+ this.db.query(`
4411
+ UPDATE workflow_nodes
4412
+ SET status = ?,
4413
+ output_json = ?,
4414
+ evidence_json = ?,
4415
+ task_id = ?,
4416
+ message_id = ?,
4417
+ completed_at = ${completedAtSql},
4418
+ updated_at = datetime('now')
4419
+ WHERE id = ?
4420
+ `).run(
4421
+ status,
4422
+ output ? JSON.stringify(output) : null,
4423
+ evidence ? JSON.stringify(evidence) : null,
4424
+ taskId?.trim() || null,
4425
+ messageId ?? null,
4426
+ node.id,
4427
+ );
4428
+ return this.getWorkflowNode(node.id)!;
2104
4429
  }
2105
4430
 
2106
- listMessages(input: ListMessagesInput = {}): Message[] {
2107
- const conditions: string[] = [];
2108
- const params: SQLQueryBindings[] = [];
4431
+ listWorkflowNodes(runId: string): WorkflowNode[] {
4432
+ const run = this.getWorkflowRun(runId);
4433
+ if (!run) throw new Error(`workflow run ${runId} was not found`);
4434
+ const rows = this.db.query(`
4435
+ SELECT *
4436
+ FROM workflow_nodes
4437
+ WHERE run_id = ?
4438
+ ORDER BY datetime(created_at) ASC, rowid ASC
4439
+ `).all(run.id) as Record<string, unknown>[];
4440
+ return rows.map(rowToWorkflowNode);
4441
+ }
2109
4442
 
2110
- if (input.chatId) {
2111
- conditions.push('m.chat_id = ?');
2112
- params.push(input.chatId);
2113
- } else if (input.chatName) {
2114
- conditions.push('c.name = ?');
2115
- params.push(normalizeChatName(input.chatName));
2116
- }
4443
+ getWorkflowRunRecord(id: string): WorkflowRunRecord | null {
4444
+ const run = this.getWorkflowRun(id);
4445
+ if (!run) return null;
4446
+ return { run, nodes: this.listWorkflowNodes(run.id) };
4447
+ }
2117
4448
 
2118
- if (input.parentId !== undefined) {
2119
- if (input.parentId === null) {
2120
- conditions.push('m.parent_id IS NULL');
2121
- } else {
2122
- conditions.push('(m.id = ? OR m.parent_id = ?)');
2123
- params.push(input.parentId, input.parentId);
4449
+ fireDueRoomReminders(input: FireDueRoomRemindersInput = {}): FireDueRoomRemindersResult {
4450
+ const now = normalizeReminderFireNow(input.now);
4451
+ const due = this.db.query(`
4452
+ SELECT rr.*, c.name AS room_name
4453
+ FROM room_reminders rr
4454
+ JOIN chats c ON c.id = rr.room_id
4455
+ WHERE rr.status = 'scheduled' AND rr.fire_at <= ?
4456
+ ORDER BY rr.fire_at ASC, rr.created_at ASC
4457
+ LIMIT ?
4458
+ `).all(now, limitValue(input.limit, 25)) as Record<string, unknown>[];
4459
+
4460
+ return this.db.transaction(() => {
4461
+ const fired: FiredRoomReminder[] = [];
4462
+ const messages: Message[] = [];
4463
+ const deliveries: MessageDelivery[] = [];
4464
+
4465
+ for (const row of due) {
4466
+ const reminder = this.getRoomReminder(String(row.id));
4467
+ if (!reminder || reminder.status !== 'scheduled' || reminder.fire_at > now) continue;
4468
+ const source = this.getMessage(reminder.source_message_id);
4469
+ const recipient = reminder.created_by?.trim() || null;
4470
+ const message = this.createMessage({
4471
+ chatId: reminder.room_id,
4472
+ sender: 'pal',
4473
+ recipient,
4474
+ content: reminderFireMessageContent(reminder, source),
4475
+ type: 'system',
4476
+ idempotencyKey: reminderFireIdempotencyKey(reminder),
4477
+ mentions: recipient ? [recipient] : [],
4478
+ });
4479
+ const reminderDeliveries = this.resolveDeliveriesForMessage(message.id);
4480
+ const updated = reminder.repeat
4481
+ ? this.db.query(`
4482
+ UPDATE room_reminders
4483
+ SET fire_at = ?, fired_at = ?, updated_at = ?
4484
+ WHERE id = ? AND status = 'scheduled'
4485
+ `).run(nextReminderFireAt(reminder, now), now, now, reminder.id)
4486
+ : this.db.query(`
4487
+ UPDATE room_reminders
4488
+ SET status = 'fired', fired_at = ?, updated_at = ?
4489
+ WHERE id = ? AND status = 'scheduled'
4490
+ `).run(now, now, reminder.id);
4491
+ if (updated.changes === 0) continue;
4492
+ const firedReminder = this.getRoomReminder(reminder.id)!;
4493
+ fired.push({ reminder: firedReminder, message, deliveries: reminderDeliveries });
4494
+ messages.push(message);
4495
+ deliveries.push(...reminderDeliveries);
2124
4496
  }
2125
- }
2126
4497
 
2127
- if (input.after !== undefined) {
2128
- conditions.push('m.id > ?');
2129
- params.push(input.after);
2130
- }
4498
+ return {
4499
+ fired,
4500
+ reminders: fired.map((item) => item.reminder),
4501
+ messages,
4502
+ deliveries,
4503
+ };
4504
+ })();
4505
+ }
2131
4506
 
2132
- if (input.q) {
2133
- conditions.push('m.content LIKE ?');
2134
- params.push(`%${input.q}%`);
2135
- }
4507
+ createHeldMessageDraft(input: CreateHeldMessageDraftInput): HeldMessageDraft {
4508
+ const room = this.getChatById(input.chatId);
4509
+ if (!room) throw new Error(`chat ${input.chatId} was not found`);
4510
+ const agent = input.agent.trim();
4511
+ const sender = input.sender.trim();
4512
+ const content = input.content.trim();
4513
+ if (!agent) throw new Error('agent is required');
4514
+ if (!sender) throw new Error('sender is required');
4515
+ if (!content) throw new Error('content is required');
4516
+ if (!this.getAgent(agent)) throw new Error(`agent ${agent} not found`);
4517
+ const id = crypto.randomUUID();
4518
+ this.db.query(`
4519
+ INSERT INTO held_message_drafts (id, chat_id, agent, sender, content, mentions_json, base_message_id, latest_message_id_at_hold, status, hold_reason)
4520
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'held', ?)
4521
+ `).run(
4522
+ id,
4523
+ room.id,
4524
+ agent,
4525
+ sender,
4526
+ content,
4527
+ JSON.stringify(normalizeMentionsInput(input.mentions)),
4528
+ input.baseMessageId,
4529
+ input.latestMessageIdAtHold,
4530
+ input.holdReason,
4531
+ );
4532
+ return this.getHeldMessageDraft(id)!;
4533
+ }
2136
4534
 
2137
- const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';
4535
+ getHeldMessageDraft(id: string): HeldMessageDraft | null {
4536
+ const row = this.db.query(`
4537
+ SELECT hmd.*, c.name AS chat_name, c.display_name AS chat_display_name,
4538
+ (
4539
+ SELECT COUNT(*)
4540
+ FROM messages m
4541
+ WHERE m.chat_id = hmd.chat_id
4542
+ AND m.id > hmd.base_message_id
4543
+ AND m.id <= hmd.latest_message_id_at_hold
4544
+ AND m.sender != hmd.sender
4545
+ ) AS intervening_message_count
4546
+ FROM held_message_drafts hmd
4547
+ JOIN chats c ON c.id = hmd.chat_id
4548
+ WHERE hmd.id = ?
4549
+ LIMIT 1
4550
+ `).get(id.trim()) as Record<string, unknown> | null;
4551
+ return row ? rowToHeldMessageDraft(row) : null;
4552
+ }
4553
+
4554
+ listHeldMessageDrafts(agent: string, status: HeldDraftStatus = 'held', limit = 50): HeldMessageDraft[] {
2138
4555
  const rows = this.db.query(`
2139
- SELECT m.*, c.name AS chat_name
2140
- FROM messages m
2141
- JOIN chats c ON c.id = m.chat_id
2142
- ${where}
2143
- ORDER BY m.id ASC
4556
+ SELECT hmd.*, c.name AS chat_name, c.display_name AS chat_display_name,
4557
+ (
4558
+ SELECT COUNT(*)
4559
+ FROM messages m
4560
+ WHERE m.chat_id = hmd.chat_id
4561
+ AND m.id > hmd.base_message_id
4562
+ AND m.id <= hmd.latest_message_id_at_hold
4563
+ AND m.sender != hmd.sender
4564
+ ) AS intervening_message_count
4565
+ FROM held_message_drafts hmd
4566
+ JOIN chats c ON c.id = hmd.chat_id
4567
+ WHERE hmd.agent = ? AND hmd.status = ?
4568
+ ORDER BY datetime(hmd.created_at) DESC, hmd.id ASC
2144
4569
  LIMIT ?
2145
- `).all(...params, limitValue(input.limit, 50)) as Record<string, unknown>[];
4570
+ `).all(agent.trim(), status, limitValue(limit, 50)) as Record<string, unknown>[];
4571
+ return rows.map(rowToHeldMessageDraft);
4572
+ }
4573
+
4574
+ abandonHeldMessageDraft(id: string): HeldMessageDraft | null {
4575
+ const existing = this.getHeldMessageDraft(id);
4576
+ if (!existing) return null;
4577
+ if (existing.status !== 'held') throw new Error('HELD_DRAFT_ALREADY_RESOLVED');
4578
+ const result = this.db.query(`
4579
+ UPDATE held_message_drafts
4580
+ SET status = 'abandoned', updated_at = datetime('now'), resolved_at = datetime('now')
4581
+ WHERE id = ? AND status = 'held'
4582
+ `).run(existing.id);
4583
+ if (result.changes === 0) throw new Error('HELD_DRAFT_ALREADY_RESOLVED');
4584
+ return this.getHeldMessageDraft(existing.id)!;
4585
+ }
4586
+
4587
+ sendHeldMessageDraftAnyway(id: string): { draft: HeldMessageDraft; message: Message } | null {
4588
+ return this.db.transaction(() => {
4589
+ const existing = this.getHeldMessageDraft(id);
4590
+ if (!existing) return null;
4591
+ if (existing.status !== 'held') throw new Error('HELD_DRAFT_ALREADY_RESOLVED');
4592
+ const message = this.createMessage({
4593
+ chatId: existing.chat_id,
4594
+ sender: existing.sender,
4595
+ content: existing.content,
4596
+ mentions: existing.mentions,
4597
+ });
4598
+ const result = this.db.query(`
4599
+ UPDATE held_message_drafts
4600
+ SET status = 'sent_anyway',
4601
+ resolved_message_id = ?,
4602
+ updated_at = datetime('now'),
4603
+ resolved_at = datetime('now')
4604
+ WHERE id = ? AND status = 'held'
4605
+ `).run(message.id, existing.id);
4606
+ if (result.changes === 0) throw new Error('HELD_DRAFT_ALREADY_RESOLVED');
4607
+ return { draft: this.getHeldMessageDraft(existing.id)!, message };
4608
+ })();
4609
+ }
4610
+
4611
+ reviseHeldMessageDraft(id: string, content: string): { draft: HeldMessageDraft; message: Message } | null {
4612
+ const revisedContent = content.trim();
4613
+ if (!revisedContent) throw new Error('content is required');
4614
+ return this.db.transaction(() => {
4615
+ const existing = this.getHeldMessageDraft(id);
4616
+ if (!existing) return null;
4617
+ if (existing.status !== 'held') throw new Error('HELD_DRAFT_ALREADY_RESOLVED');
4618
+ const message = this.createMessage({
4619
+ chatId: existing.chat_id,
4620
+ sender: existing.sender,
4621
+ content: revisedContent,
4622
+ mentions: existing.mentions,
4623
+ });
4624
+ const result = this.db.query(`
4625
+ UPDATE held_message_drafts
4626
+ SET status = 'sent_anyway',
4627
+ resolved_message_id = ?,
4628
+ updated_at = datetime('now'),
4629
+ resolved_at = datetime('now')
4630
+ WHERE id = ? AND status = 'held'
4631
+ `).run(message.id, existing.id);
4632
+ if (result.changes === 0) throw new Error('HELD_DRAFT_ALREADY_RESOLVED');
4633
+ return { draft: this.getHeldMessageDraft(existing.id)!, message };
4634
+ })();
4635
+ }
2146
4636
 
2147
- return rows.map(rowToMessage);
4637
+ getDeliveryContext(input: { agent: string; chatId: string; messageId: number; limit?: number }): DeliveryContext {
4638
+ const room = this.getChatById(input.chatId);
4639
+ if (!room) throw new Error(`room ${input.chatId} was not found`);
4640
+ const trigger = this.getMessage(input.messageId);
4641
+ if (!trigger) throw new Error(`message ${input.messageId} was not found`);
4642
+ if (trigger.chat_id !== room.id) throw new Error(`message ${input.messageId} does not belong to room ${room.id}`);
4643
+
4644
+ const participant = this.getRoomParticipant(room.id, input.agent);
4645
+ const cursor = participant?.delivery_cursor_message_id ?? 0;
4646
+ const previous = this.db.query(`
4647
+ SELECT COALESCE(MAX(message_id), 0) AS message_id
4648
+ FROM message_deliveries
4649
+ WHERE agent = ? AND chat_id = ? AND message_id < ?
4650
+ `).get(input.agent, room.id, trigger.id) as { message_id: number } | null;
4651
+ const startMessageId = Math.max(Number(cursor ?? 0), Number(previous?.message_id ?? 0), 0);
4652
+ const total = this.db.query(`
4653
+ SELECT COUNT(*) AS count
4654
+ FROM messages
4655
+ WHERE chat_id = ? AND id > ? AND id <= ?
4656
+ `).get(room.id, startMessageId, trigger.id) as { count: number };
4657
+ const limit = limitValue(input.limit, 50);
4658
+ const rows = this.db.query(`
4659
+ SELECT *
4660
+ FROM (
4661
+ SELECT m.*, c.name AS chat_name
4662
+ FROM messages m
4663
+ JOIN chats c ON c.id = m.chat_id
4664
+ WHERE m.chat_id = ? AND m.id > ? AND m.id <= ?
4665
+ ORDER BY m.id DESC
4666
+ LIMIT ?
4667
+ )
4668
+ ORDER BY id ASC
4669
+ `).all(room.id, startMessageId, trigger.id, limit) as Record<string, unknown>[];
4670
+ const totalCount = Number(total.count ?? 0);
4671
+ return {
4672
+ startMessageId,
4673
+ totalCount,
4674
+ omittedCount: Math.max(0, totalCount - rows.length),
4675
+ messages: this.attachMessageAttachments(rows.map(rowToMessage)),
4676
+ savedMessages: this.listRoomSavedMessages({ roomId: room.id, limit: 10 }),
4677
+ };
2148
4678
  }
2149
4679
 
2150
4680
  listInbox(agent: string, after = 0, limit = 50): Message[] {
@@ -2159,7 +4689,7 @@ export class MessageStore {
2159
4689
  LIMIT ?
2160
4690
  `).all(after, agent, agent, limitValue(limit, 50)) as Record<string, unknown>[];
2161
4691
 
2162
- return rows.map(rowToMessage);
4692
+ return this.attachMessageAttachments(rows.map(rowToMessage));
2163
4693
  }
2164
4694
 
2165
4695
  getComputer(id: string): Computer | null {
@@ -2176,6 +4706,79 @@ export class MessageStore {
2176
4706
  return rows.map(rowToComputer);
2177
4707
  }
2178
4708
 
4709
+ updateComputerName(id: string, name: string): Computer {
4710
+ const computerId = id.trim();
4711
+ const displayName = name.trim();
4712
+ if (!computerId) throw new Error('computer id is required');
4713
+ if (!displayName) throw new Error('computer name is required');
4714
+ this.db.query(`
4715
+ UPDATE computers
4716
+ SET name = ?, updated_at = datetime('now')
4717
+ WHERE id = ?
4718
+ `).run(displayName, computerId);
4719
+ const computer = this.getComputer(computerId);
4720
+ if (!computer) throw new Error('computer not found');
4721
+ return computer;
4722
+ }
4723
+
4724
+ regenerateComputerCommand(id: string, input: RegenerateComputerCommandInput = {}): ProvisionedComputer {
4725
+ const computer = this.getComputer(id);
4726
+ if (!computer) throw new Error('computer not found');
4727
+ const apiKey = `sk_machine_${randomBytes(32).toString('hex')}`;
4728
+ this.db.query(`
4729
+ UPDATE computers
4730
+ SET credential_hash = ?, updated_at = datetime('now')
4731
+ WHERE id = ?
4732
+ `).run(hashSecret(apiKey), computer.id);
4733
+ const serverUrl = input.serverUrl?.trim() || 'http://127.0.0.1:4127';
4734
+ const command = daemonStartCommand({ commandPrefix: input.packageName, serverUrl, apiKey, name: computer.name });
4735
+ return { computer: this.getComputer(computer.id)!, api_key: apiKey, command };
4736
+ }
4737
+
4738
+ getComputerDeletionImpact(id: string): ComputerDeletionImpact | null {
4739
+ const computer = this.getComputer(id);
4740
+ if (!computer) return null;
4741
+ return {
4742
+ computer,
4743
+ projects: this.listProjectsForComputer(computer.id),
4744
+ assignments: this.listComputerAgentAssignments(computer.id),
4745
+ };
4746
+ }
4747
+
4748
+ deleteComputer(id: string, options: { removeProjects?: boolean; removeAssignments?: boolean } = {}): ComputerDeletionResult {
4749
+ const computer = this.getComputer(id);
4750
+ if (!computer) throw new Error('computer not found');
4751
+ const impact = this.getComputerDeletionImpact(computer.id)!;
4752
+ if (impact.projects.length > 0 && options.removeProjects !== true) throw new Error('COMPUTER_HAS_PROJECTS');
4753
+ if (impact.assignments.length > 0 && options.removeAssignments !== true) throw new Error('COMPUTER_HAS_ASSIGNMENTS');
4754
+ let projectsRemoved = 0;
4755
+ let assignmentsRemoved = 0;
4756
+ let connectionsRemoved = 0;
4757
+ this.db.transaction(() => {
4758
+ if (options.removeProjects === true) {
4759
+ const projectIds = impact.projects.map((project) => project.id);
4760
+ for (const projectId of projectIds) {
4761
+ this.db.query('DELETE FROM chats WHERE project_id = ?').run(projectId);
4762
+ const result = this.db.query('DELETE FROM projects WHERE id = ?').run(projectId);
4763
+ projectsRemoved += result.changes;
4764
+ }
4765
+ }
4766
+ if (options.removeAssignments === true) {
4767
+ const result = this.db.query('DELETE FROM computer_agent_assignments WHERE computer_id = ?').run(computer.id);
4768
+ assignmentsRemoved += result.changes;
4769
+ }
4770
+ const connectionsResult = this.db.query('DELETE FROM computer_connections WHERE computer_id = ?').run(computer.id);
4771
+ connectionsRemoved += connectionsResult.changes;
4772
+ this.db.query('DELETE FROM computers WHERE id = ?').run(computer.id);
4773
+ })();
4774
+ return {
4775
+ computer,
4776
+ projects_removed: projectsRemoved,
4777
+ assignments_removed: assignmentsRemoved,
4778
+ connections_removed: connectionsRemoved,
4779
+ };
4780
+ }
4781
+
2179
4782
  private getComputerByCredential(secret: string): Computer | null {
2180
4783
  const row = this.db.query('SELECT * FROM computers WHERE credential_hash = ? ORDER BY created_at DESC LIMIT 1').get(hashSecret(secret.trim())) as Record<string, unknown> | null;
2181
4784
  return row ? rowToComputer(row) : null;
@@ -2186,6 +4789,17 @@ export class MessageStore {
2186
4789
  return row ? rowToComputerConnection(row) : null;
2187
4790
  }
2188
4791
 
4792
+ listActiveComputerConnections(): ComputerConnection[] {
4793
+ const rows = this.db.query(`
4794
+ SELECT cc.*
4795
+ FROM computer_connections cc
4796
+ JOIN computers c ON c.active_connection_id = cc.id
4797
+ WHERE cc.status = 'active' AND c.status = 'online'
4798
+ ORDER BY datetime(cc.last_heartbeat_at) DESC, cc.id ASC
4799
+ `).all() as Record<string, unknown>[];
4800
+ return rows.map(rowToComputerConnection);
4801
+ }
4802
+
2189
4803
  provisionComputer(input: ProvisionComputerInput = {}): ProvisionedComputer {
2190
4804
  const id = `machine_${randomBytes(8).toString('hex')}`;
2191
4805
  const apiKey = `sk_machine_${randomBytes(32).toString('hex')}`;
@@ -2195,9 +4809,8 @@ export class MessageStore {
2195
4809
  VALUES (?, ?, ?, 'offline', NULL, NULL, datetime('now'))
2196
4810
  `).run(id, name, hashSecret(apiKey));
2197
4811
 
2198
- const packageName = input.packageName?.trim() || '@controlflow-ai/daemon@latest';
2199
4812
  const serverUrl = input.serverUrl?.trim() || 'http://127.0.0.1:4127';
2200
- const command = `npx ${packageName} --server-url ${serverUrl} --api-key ${apiKey} # ${name}`;
4813
+ const command = daemonStartCommand({ commandPrefix: input.packageName, serverUrl, apiKey, name });
2201
4814
  return { computer: this.getComputer(id)!, api_key: apiKey, command };
2202
4815
  }
2203
4816
 
@@ -2209,13 +4822,14 @@ export class MessageStore {
2209
4822
  if (!computerId) throw new Error('computer_id is required');
2210
4823
  if (!secret) throw new Error('computer secret is required');
2211
4824
  const credentialHash = hashSecret(secret);
2212
- const existing = this.db.query('SELECT credential_hash FROM computers WHERE id = ?').get(computerId) as { credential_hash: string } | null;
4825
+ const existing = this.db.query('SELECT credential_hash, name FROM computers WHERE id = ?').get(computerId) as { credential_hash: string; name: string } | null;
2213
4826
  if (existing && existing.credential_hash !== credentialHash) {
2214
4827
  throw new Error('invalid computer credential');
2215
4828
  }
2216
4829
 
2217
4830
  const connectionId = crypto.randomUUID();
2218
4831
  const token = generateConnectionToken();
4832
+ const localControlToken = generateConnectionToken();
2219
4833
  const tokenHash = hashSecret(token);
2220
4834
  const revokedRows = this.db.query(`
2221
4835
  SELECT id FROM computer_connections
@@ -2224,7 +4838,8 @@ export class MessageStore {
2224
4838
  const revokedConnectionIds = revokedRows.map((row) => row.id);
2225
4839
  const epochRow = this.db.query('SELECT COALESCE(MAX(epoch), 0) + 1 AS epoch FROM computer_connections WHERE computer_id = ?').get(computerId) as { epoch: number };
2226
4840
  const epoch = Number(epochRow.epoch);
2227
- const name = input.name?.trim() || computerId;
4841
+ const providedName = input.name?.trim() || null;
4842
+ const name = providedName ?? existing?.name ?? computerId;
2228
4843
 
2229
4844
  this.db.transaction(() => {
2230
4845
  if (!existing) {
@@ -2235,9 +4850,13 @@ export class MessageStore {
2235
4850
  } else {
2236
4851
  this.db.query(`
2237
4852
  UPDATE computers
2238
- SET name = ?, status = 'online', active_connection_id = ?, last_seen_at = datetime('now'), updated_at = datetime('now')
4853
+ SET name = CASE WHEN ? IS NOT NULL THEN ? ELSE name END,
4854
+ status = 'online',
4855
+ active_connection_id = ?,
4856
+ last_seen_at = datetime('now'),
4857
+ updated_at = datetime('now')
2239
4858
  WHERE id = ?
2240
- `).run(name, connectionId, computerId);
4859
+ `).run(providedName, providedName, connectionId, computerId);
2241
4860
  }
2242
4861
  this.db.query(`
2243
4862
  UPDATE computer_connections
@@ -2270,9 +4889,9 @@ export class MessageStore {
2270
4889
  }
2271
4890
  }
2272
4891
  this.db.query(`
2273
- INSERT INTO computer_connections (id, computer_id, token_hash, epoch, status, connected_at, last_heartbeat_at)
2274
- VALUES (?, ?, ?, ?, 'active', datetime('now'), datetime('now'))
2275
- `).run(connectionId, computerId, tokenHash, epoch);
4892
+ INSERT INTO computer_connections (id, computer_id, token_hash, local_control_token, epoch, status, connected_at, last_heartbeat_at)
4893
+ VALUES (?, ?, ?, ?, ?, 'active', datetime('now'), datetime('now'))
4894
+ `).run(connectionId, computerId, tokenHash, localControlToken, epoch);
2276
4895
  this.db.query(`
2277
4896
  INSERT INTO daemon_instances (id, name, host, local_url, server_url, status, last_seen_at)
2278
4897
  VALUES (?, ?, ?, ?, ?, 'online', datetime('now'))
@@ -2303,11 +4922,45 @@ export class MessageStore {
2303
4922
  computer: this.getComputer(computerId)!,
2304
4923
  connection: this.getComputerConnection(connectionId)!,
2305
4924
  token,
4925
+ localControlToken,
2306
4926
  daemon: this.getDaemon(connectionId)!,
2307
4927
  agents: this.listComputerAgentAssignments(computerId),
2308
4928
  };
2309
4929
  }
2310
4930
 
4931
+ getComputerLocalControl(computerId: string): { computer: Computer; connection: ComputerConnection; daemon: DaemonInstance; token: string } | null {
4932
+ const computer = this.getComputer(computerId);
4933
+ if (!computer || computer.status !== 'online' || !computer.active_connection_id) return null;
4934
+ const connection = this.getComputerConnection(computer.active_connection_id);
4935
+ if (!connection || connection.status !== 'active') return null;
4936
+ const row = this.db.query(`
4937
+ SELECT local_control_token
4938
+ FROM computer_connections
4939
+ WHERE id = ? AND computer_id = ? AND status = 'active'
4940
+ LIMIT 1
4941
+ `).get(connection.id, computer.id) as { local_control_token: string | null } | null;
4942
+ const token = row?.local_control_token?.trim();
4943
+ if (!token) return null;
4944
+ const daemon = this.getDaemon(connection.id);
4945
+ if (!daemon || daemon.status !== 'online' || !daemon.local_url.trim()) return null;
4946
+ return { computer, connection, daemon, token };
4947
+ }
4948
+
4949
+ getComputerWorkload(computerId: string): ComputerWorkload | null {
4950
+ const computer = this.getComputer(computerId);
4951
+ if (!computer) return null;
4952
+ const connection = computer.active_connection_id ? this.getComputerConnection(computer.active_connection_id) : null;
4953
+ const daemon = connection ? this.getDaemon(connection.id) : null;
4954
+ return {
4955
+ computer,
4956
+ connection,
4957
+ daemon,
4958
+ assignments: this.listComputerAgentAssignments(computer.id),
4959
+ runs: this.listRunsForComputer(computer.id, 20),
4960
+ deliveries: this.listActiveDeliveriesForComputer(computer.id, 20),
4961
+ };
4962
+ }
4963
+
2311
4964
  closeStaleComputerConnections(timeoutMs: number, now = new Date()): number {
2312
4965
  if (!Number.isFinite(timeoutMs) || timeoutMs < 0) throw new Error('timeoutMs must be non-negative');
2313
4966
  const cutoff = new Date(now.getTime() - timeoutMs).toISOString();
@@ -2317,7 +4970,6 @@ export class MessageStore {
2317
4970
  JOIN computers c ON c.active_connection_id = cc.id
2318
4971
  WHERE cc.status = 'active' AND datetime(cc.last_heartbeat_at) < datetime(?)
2319
4972
  `).all(cutoff) as Array<{ id: string; computer_id: string }>;
2320
- if (staleRows.length === 0) return 0;
2321
4973
  this.db.transaction(() => {
2322
4974
  for (const row of staleRows) {
2323
4975
  this.db.query(`
@@ -2330,7 +4982,103 @@ export class MessageStore {
2330
4982
  SET status = 'offline', active_connection_id = NULL, updated_at = datetime('now')
2331
4983
  WHERE id = ? AND active_connection_id = ?
2332
4984
  `).run(row.computer_id, row.id);
4985
+ this.db.query(`
4986
+ UPDATE agent_runs
4987
+ SET status = 'killed',
4988
+ action = NULL,
4989
+ ended_at = COALESCE(ended_at, datetime('now')),
4990
+ updated_at = datetime('now'),
4991
+ output = CASE WHEN output = '' THEN 'computer connection heartbeat timed out' ELSE output END
4992
+ WHERE computer_id = ? AND status = 'running'
4993
+ `).run(row.computer_id);
4994
+ this.db.query(`
4995
+ UPDATE message_deliveries
4996
+ SET status = 'pending',
4997
+ daemon_id = NULL,
4998
+ connection_id = NULL,
4999
+ claim_token = NULL,
5000
+ lease_until = NULL,
5001
+ run_id = NULL,
5002
+ last_error = '',
5003
+ claimed_at = NULL
5004
+ WHERE status IN ('claimed', 'processing_completed')
5005
+ AND (
5006
+ connection_id = ?
5007
+ OR (connection_id IS NULL AND daemon_id = ?)
5008
+ )
5009
+ `).run(row.id, row.id);
2333
5010
  }
5011
+ this.db.query(`
5012
+ UPDATE agent_runs
5013
+ SET status = 'killed',
5014
+ action = NULL,
5015
+ ended_at = COALESCE(ended_at, datetime('now')),
5016
+ updated_at = datetime('now'),
5017
+ output = CASE WHEN output = '' THEN 'computer connection is no longer active' ELSE output END
5018
+ WHERE status = 'running'
5019
+ AND (
5020
+ (
5021
+ connection_id IS NOT NULL
5022
+ AND NOT EXISTS (
5023
+ SELECT 1
5024
+ FROM computer_connections cc
5025
+ JOIN computers c ON c.active_connection_id = cc.id
5026
+ WHERE cc.id = agent_runs.connection_id
5027
+ AND cc.status = 'active'
5028
+ AND c.status = 'online'
5029
+ )
5030
+ )
5031
+ OR (
5032
+ connection_id IS NULL
5033
+ AND computer_id IS NOT NULL
5034
+ AND NOT EXISTS (
5035
+ SELECT 1
5036
+ FROM computers c
5037
+ JOIN computer_connections cc ON cc.id = c.active_connection_id
5038
+ WHERE c.id = agent_runs.computer_id
5039
+ AND c.status = 'online'
5040
+ AND cc.status = 'active'
5041
+ )
5042
+ )
5043
+ )
5044
+ `).run();
5045
+ this.db.query(`
5046
+ UPDATE message_deliveries
5047
+ SET status = 'pending',
5048
+ daemon_id = NULL,
5049
+ connection_id = NULL,
5050
+ claim_token = NULL,
5051
+ lease_until = NULL,
5052
+ run_id = NULL,
5053
+ last_error = '',
5054
+ claimed_at = NULL
5055
+ WHERE status IN ('claimed', 'processing_completed')
5056
+ AND (
5057
+ (
5058
+ connection_id IS NOT NULL
5059
+ AND NOT EXISTS (
5060
+ SELECT 1
5061
+ FROM computer_connections cc
5062
+ JOIN computers c ON c.active_connection_id = cc.id
5063
+ WHERE cc.id = message_deliveries.connection_id
5064
+ AND cc.status = 'active'
5065
+ AND c.status = 'online'
5066
+ )
5067
+ )
5068
+ OR (
5069
+ connection_id IS NULL
5070
+ AND daemon_id IN (SELECT id FROM computer_connections)
5071
+ AND NOT EXISTS (
5072
+ SELECT 1
5073
+ FROM computer_connections cc
5074
+ JOIN computers c ON c.active_connection_id = cc.id
5075
+ WHERE cc.id = message_deliveries.daemon_id
5076
+ AND cc.status = 'active'
5077
+ AND c.status = 'online'
5078
+ )
5079
+ )
5080
+ )
5081
+ `).run();
2334
5082
  })();
2335
5083
  return staleRows.length;
2336
5084
  }
@@ -2420,6 +5168,114 @@ export class MessageStore {
2420
5168
  return row !== null;
2421
5169
  }
2422
5170
 
5171
+ listDaemonAgents(daemonId: string): string[] {
5172
+ const rows = this.db.query(`
5173
+ SELECT agent FROM daemon_agents
5174
+ WHERE daemon_id = ? AND status = 'online'
5175
+ ORDER BY agent ASC
5176
+ `).all(daemonId) as Array<{ agent: string }>;
5177
+ return rows.map((row) => row.agent);
5178
+ }
5179
+
5180
+ listPendingDeliveryAgentsForConnection(connectionId: string): Array<{ agent: string; pending: number }> {
5181
+ this.releaseExpiredDeliveries();
5182
+ const rows = this.db.query(`
5183
+ SELECT md.agent AS agent, COUNT(*) AS pending
5184
+ FROM message_deliveries md
5185
+ JOIN daemon_agents da ON da.agent = md.agent
5186
+ JOIN computer_connections cc ON cc.id = da.daemon_id
5187
+ JOIN computers c ON c.active_connection_id = cc.id
5188
+ WHERE da.daemon_id = ?
5189
+ AND da.status = 'online'
5190
+ AND cc.status = 'active'
5191
+ AND c.status = 'online'
5192
+ AND md.status = 'pending'
5193
+ GROUP BY md.agent
5194
+ ORDER BY MIN(datetime(md.created_at)) ASC, md.agent ASC
5195
+ `).all(connectionId.trim()) as Array<{ agent: string; pending: number }>;
5196
+ return rows.map((row) => ({ agent: row.agent, pending: Number(row.pending) }));
5197
+ }
5198
+
5199
+ listDeliveryBacklogForConnection(connectionId: string): DeliveryBacklogSummary[] {
5200
+ this.releaseExpiredDeliveries();
5201
+ const rows = this.db.query(`
5202
+ SELECT
5203
+ md.agent AS agent,
5204
+ SUM(CASE WHEN md.status = 'pending' THEN 1 ELSE 0 END) AS pending,
5205
+ SUM(CASE WHEN md.status = 'claimed' THEN 1 ELSE 0 END) AS claimed,
5206
+ SUM(CASE WHEN md.status = 'processing_completed' THEN 1 ELSE 0 END) AS processing_completed,
5207
+ SUM(CASE
5208
+ WHEN md.status IN ('claimed', 'processing_completed')
5209
+ AND md.lease_until IS NOT NULL
5210
+ AND datetime(md.lease_until) <= datetime('now')
5211
+ THEN 1 ELSE 0
5212
+ END) AS expired_active
5213
+ FROM message_deliveries md
5214
+ JOIN daemon_agents da ON da.agent = md.agent
5215
+ JOIN computer_connections cc ON cc.id = da.daemon_id
5216
+ JOIN computers c ON c.active_connection_id = cc.id
5217
+ WHERE da.daemon_id = ?
5218
+ AND da.status = 'online'
5219
+ AND cc.status = 'active'
5220
+ AND c.status = 'online'
5221
+ AND md.status IN ('pending', 'claimed', 'processing_completed')
5222
+ GROUP BY md.agent
5223
+ ORDER BY MIN(datetime(md.created_at)) ASC, md.agent ASC
5224
+ `).all(connectionId.trim()) as Array<{
5225
+ agent: string;
5226
+ pending: number;
5227
+ claimed: number;
5228
+ processing_completed: number;
5229
+ expired_active: number;
5230
+ }>;
5231
+ return rows.map((row) => ({
5232
+ agent: row.agent,
5233
+ pending: Number(row.pending),
5234
+ claimed: Number(row.claimed),
5235
+ processing_completed: Number(row.processing_completed),
5236
+ expired_active: Number(row.expired_active),
5237
+ }));
5238
+ }
5239
+
5240
+ releaseExpiredDeliveries(now = new Date()): number {
5241
+ const result = this.db.query(`
5242
+ UPDATE message_deliveries
5243
+ SET status = 'pending',
5244
+ daemon_id = NULL,
5245
+ connection_id = NULL,
5246
+ claim_token = NULL,
5247
+ lease_until = NULL,
5248
+ run_id = NULL,
5249
+ last_error = '',
5250
+ claimed_at = NULL
5251
+ WHERE status IN ('claimed', 'processing_completed')
5252
+ AND lease_until IS NOT NULL
5253
+ AND datetime(lease_until) <= datetime(?)
5254
+ AND NOT EXISTS (
5255
+ SELECT 1
5256
+ FROM agent_runs r
5257
+ WHERE r.delivery_id = message_deliveries.id
5258
+ AND r.status = 'running'
5259
+ )
5260
+ `).run(now.toISOString());
5261
+ return result.changes;
5262
+ }
5263
+
5264
+ listOnlineDaemonConnectionsForAgent(agent: string): string[] {
5265
+ const rows = this.db.query(`
5266
+ SELECT da.daemon_id AS connection_id
5267
+ FROM daemon_agents da
5268
+ JOIN computer_connections cc ON cc.id = da.daemon_id
5269
+ JOIN computers c ON c.active_connection_id = cc.id
5270
+ WHERE da.agent = ?
5271
+ AND da.status = 'online'
5272
+ AND cc.status = 'active'
5273
+ AND c.status = 'online'
5274
+ ORDER BY datetime(da.last_seen_at) DESC
5275
+ `).all(agent.trim()) as Array<{ connection_id: string }>;
5276
+ return rows.map((row) => row.connection_id);
5277
+ }
5278
+
2423
5279
  hasDaemonAgent(agent: string): boolean {
2424
5280
  const row = this.db.query(`
2425
5281
  SELECT 1 FROM daemon_agents
@@ -2429,13 +5285,90 @@ export class MessageStore {
2429
5285
  return row !== null;
2430
5286
  }
2431
5287
 
2432
- listDeliveries(agent: string, status = 'pending', limit = 50): MessageDelivery[] {
5288
+ listDeliveries(agent: string, status = 'pending', limit = 50, options: { distinctChat?: boolean; excludeRunningComputerId?: string | null; connectionId?: string | null } = {}): MessageDelivery[] {
5289
+ if (status === 'pending') this.releaseExpiredDeliveries();
5290
+ const excludeRunningComputerId = options.excludeRunningComputerId?.trim() || null;
5291
+ const rows = options.distinctChat
5292
+ ? excludeRunningComputerId
5293
+ ? this.db.query(`
5294
+ SELECT md.*
5295
+ FROM message_deliveries md
5296
+ INNER JOIN (
5297
+ SELECT pending.chat_id, MIN(pending.rowid) AS first_rowid
5298
+ FROM message_deliveries pending
5299
+ WHERE pending.agent = ? AND pending.status = ?
5300
+ AND NOT EXISTS (
5301
+ SELECT 1
5302
+ FROM agent_runs ar
5303
+ WHERE ar.computer_id = ?
5304
+ AND ar.agent = pending.agent
5305
+ AND ar.status = 'running'
5306
+ AND EXISTS (
5307
+ SELECT 1
5308
+ FROM messages running_message
5309
+ WHERE running_message.id = ar.message_id
5310
+ AND running_message.chat_id = pending.chat_id
5311
+ )
5312
+ )
5313
+ GROUP BY pending.chat_id
5314
+ ) first ON md.rowid = first.first_rowid
5315
+ WHERE md.agent = ? AND md.status = ?
5316
+ ORDER BY datetime(md.created_at) ASC, md.rowid ASC
5317
+ LIMIT ?
5318
+ `).all(agent, status, excludeRunningComputerId, agent, status, limitValue(limit, 50)) as Record<string, unknown>[]
5319
+ : this.db.query(`
5320
+ SELECT md.*
5321
+ FROM message_deliveries md
5322
+ INNER JOIN (
5323
+ SELECT chat_id, MIN(rowid) AS first_rowid
5324
+ FROM message_deliveries
5325
+ WHERE agent = ? AND status = ?
5326
+ GROUP BY chat_id
5327
+ ) first ON md.rowid = first.first_rowid
5328
+ WHERE md.agent = ? AND md.status = ?
5329
+ ORDER BY datetime(md.created_at) ASC, md.rowid ASC
5330
+ LIMIT ?
5331
+ `).all(agent, status, agent, status, limitValue(limit, 50)) as Record<string, unknown>[]
5332
+ : this.db.query(`
5333
+ SELECT * FROM message_deliveries
5334
+ WHERE agent = ? AND status = ?
5335
+ ORDER BY datetime(created_at) ASC, rowid ASC
5336
+ LIMIT ?
5337
+ `).all(agent, status, limitValue(limit, 50)) as Record<string, unknown>[];
5338
+ return rows.map(rowToDelivery);
5339
+ }
5340
+
5341
+ listRecentDeliveriesForAgent(agent: string, limit = 20): MessageDelivery[] {
5342
+ this.releaseExpiredDeliveries();
2433
5343
  const rows = this.db.query(`
2434
- SELECT * FROM message_deliveries
2435
- WHERE agent = ? AND status = ?
2436
- ORDER BY datetime(created_at) ASC
5344
+ SELECT *
5345
+ FROM message_deliveries
5346
+ WHERE agent = ?
5347
+ ORDER BY datetime(created_at) DESC, rowid DESC
5348
+ LIMIT ?
5349
+ `).all(agent.trim(), limitValue(limit, 20)) as Record<string, unknown>[];
5350
+ return rows.map(rowToDelivery);
5351
+ }
5352
+
5353
+ listActiveDeliveriesForComputer(computerId: string, limit = 20): MessageDelivery[] {
5354
+ this.releaseExpiredDeliveries();
5355
+ const rows = this.db.query(`
5356
+ SELECT md.*, c.name AS chat_name
5357
+ FROM message_deliveries md
5358
+ JOIN chats c ON c.id = md.chat_id
5359
+ WHERE md.status IN ('pending', 'claimed', 'processing_completed')
5360
+ AND (
5361
+ md.connection_id IN (SELECT id FROM computer_connections WHERE computer_id = ?)
5362
+ OR md.daemon_id IN (SELECT id FROM computer_connections WHERE computer_id = ?)
5363
+ OR (md.status = 'pending' AND md.agent IN (
5364
+ SELECT agent
5365
+ FROM computer_agent_assignments
5366
+ WHERE computer_id = ? AND status = 'active'
5367
+ ))
5368
+ )
5369
+ ORDER BY datetime(md.created_at) DESC, md.rowid DESC
2437
5370
  LIMIT ?
2438
- `).all(agent, status, limitValue(limit, 50)) as Record<string, unknown>[];
5371
+ `).all(computerId.trim(), computerId.trim(), computerId.trim(), limitValue(limit, 20)) as Record<string, unknown>[];
2439
5372
  return rows.map(rowToDelivery);
2440
5373
  }
2441
5374
 
@@ -2567,11 +5500,22 @@ export class MessageStore {
2567
5500
  }
2568
5501
 
2569
5502
  canSendWebMessageToRoom(input: { room: Chat; sender: string }): boolean {
5503
+ if (input.room.status === 'archived') return false;
2570
5504
  if (input.room.provider === 'web') return true;
2571
5505
  if (!this.getAgent(input.sender)) return false;
2572
5506
  if (input.room.provider === 'lark') {
2573
- const route = this.findLarkChannelForChat(input.room.id);
2574
- return route?.account.agent === input.sender;
5507
+ const row = this.db.query(`
5508
+ SELECT 1
5509
+ FROM channel_conversations cc
5510
+ JOIN channel_accounts ca ON ca.id = cc.channel_account_id
5511
+ WHERE cc.lock_chat_id = ?
5512
+ AND cc.audit_only = 0
5513
+ AND ca.channel = 'lark'
5514
+ AND ca.status = 'active'
5515
+ AND ca.agent = ?
5516
+ LIMIT 1
5517
+ `).get(input.room.id, input.sender) as Record<string, unknown> | null;
5518
+ return row !== null;
2575
5519
  }
2576
5520
  return false;
2577
5521
  }
@@ -2655,6 +5599,17 @@ export class MessageStore {
2655
5599
  return rows.map(rowToSession);
2656
5600
  }
2657
5601
 
5602
+ listSessionsForAgent(agent: string, limit = 10): AgentSession[] {
5603
+ const rows = this.db.query(`
5604
+ SELECT *
5605
+ FROM agent_sessions
5606
+ WHERE agent = ?
5607
+ ORDER BY datetime(updated_at) DESC, datetime(created_at) DESC
5608
+ LIMIT ?
5609
+ `).all(agent.trim(), limitValue(limit, 10)) as Record<string, unknown>[];
5610
+ return rows.map(rowToSession);
5611
+ }
5612
+
2658
5613
  listRunsForSession(sessionId: string, limit = 50): AgentRun[] {
2659
5614
  const rows = this.db.query(`
2660
5615
  SELECT * FROM agent_runs
@@ -2665,7 +5620,108 @@ export class MessageStore {
2665
5620
  return rows.map(rowToRun);
2666
5621
  }
2667
5622
 
5623
+ recordAgentActivity(input: RecordAgentActivityInput): AgentActivityEvent {
5624
+ const run = this.getRun(input.runId);
5625
+ if (!run) throw new Error(`run ${input.runId} was not found`);
5626
+ const message = this.getMessage(run.message_id);
5627
+ if (!message) throw new Error(`message ${run.message_id} was not found`);
5628
+ const id = crypto.randomUUID();
5629
+ this.db.query(`
5630
+ INSERT INTO agent_activity_events (id, run_id, session_id, agent, room_id, kind, title, detail, metadata)
5631
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
5632
+ `).run(
5633
+ id,
5634
+ run.id,
5635
+ run.session_id,
5636
+ run.agent,
5637
+ message.chat_id,
5638
+ input.kind,
5639
+ input.title.trim().slice(0, 160) || input.kind,
5640
+ (input.detail ?? '').trim().slice(0, 4000),
5641
+ JSON.stringify(input.metadata ?? {}),
5642
+ );
5643
+ return this.getAgentActivityEvent(id)!;
5644
+ }
5645
+
5646
+ getAgentActivityEvent(id: string): AgentActivityEvent | null {
5647
+ const row = this.db.query('SELECT * FROM agent_activity_events WHERE id = ?').get(id) as Record<string, unknown> | null;
5648
+ return row ? rowToActivityEvent(row) : null;
5649
+ }
5650
+
5651
+ listActivityForRun(runId: string, limit = 100): AgentActivityEvent[] {
5652
+ const rows = this.db.query(`
5653
+ SELECT * FROM agent_activity_events
5654
+ WHERE run_id = ?
5655
+ ORDER BY datetime(created_at) ASC, rowid ASC
5656
+ LIMIT ?
5657
+ `).all(runId, limitValue(limit, 100)) as Record<string, unknown>[];
5658
+ return rows.map(rowToActivityEvent);
5659
+ }
5660
+
5661
+ listActivityForAgent(agent: string, limit = 100): AgentActivityEvent[] {
5662
+ const rows = this.db.query(`
5663
+ SELECT * FROM agent_activity_events
5664
+ WHERE agent = ?
5665
+ ORDER BY datetime(created_at) DESC, rowid DESC
5666
+ LIMIT ?
5667
+ `).all(agent.trim(), limitValue(limit, 100)) as Record<string, unknown>[];
5668
+ return rows.map(rowToActivityEvent);
5669
+ }
5670
+
5671
+ listActivityForRoomAgent(roomId: string, agent: string, limit = 100): AgentActivityEvent[] {
5672
+ const room = this.getChatById(roomId);
5673
+ if (!room) return [];
5674
+ const rows = this.db.query(`
5675
+ SELECT *
5676
+ FROM agent_activity_events
5677
+ WHERE room_id = ? AND agent = ?
5678
+ ORDER BY datetime(created_at) DESC, rowid DESC
5679
+ LIMIT ?
5680
+ `).all(room.id, agent.trim(), limitValue(limit, 100)) as Record<string, unknown>[];
5681
+ return rows.map(rowToActivityEvent);
5682
+ }
5683
+
5684
+ listRoomAgentActivity(roomId: string): RoomAgentActivityStatus[] {
5685
+ const room = this.getChatById(roomId);
5686
+ if (!room) return [];
5687
+ const rows = this.db.query(`
5688
+ SELECT
5689
+ ar.id AS run_id,
5690
+ ar.agent,
5691
+ COALESCE(ae.kind, 'running') AS kind,
5692
+ COALESCE(ae.title, 'Working') AS title,
5693
+ COALESCE(ae.created_at, ar.updated_at) AS updated_at
5694
+ FROM agent_runs ar
5695
+ INNER JOIN messages m ON m.id = ar.message_id
5696
+ LEFT JOIN agent_activity_events ae ON ae.id = (
5697
+ SELECT latest.id
5698
+ FROM agent_activity_events latest
5699
+ WHERE latest.run_id = ar.id AND latest.room_id = m.chat_id
5700
+ ORDER BY datetime(latest.created_at) DESC, latest.rowid DESC
5701
+ LIMIT 1
5702
+ )
5703
+ WHERE m.chat_id = ? AND ar.status = 'running'
5704
+ AND (
5705
+ ae.id IS NULL
5706
+ OR NOT (
5707
+ ae.kind = 'lifecycle'
5708
+ AND lower(trim(ae.title)) IN ('step completed', 'runtime completed', 'run completed')
5709
+ )
5710
+ )
5711
+ ORDER BY datetime(updated_at) DESC, ar.agent ASC
5712
+ `).all(room.id) as Record<string, unknown>[];
5713
+ return rows.map((row) => ({
5714
+ agent: String(row.agent),
5715
+ run_id: String(row.run_id),
5716
+ status: 'working',
5717
+ kind: String(row.kind) as RoomAgentActivityStatus['kind'],
5718
+ title: String(row.title),
5719
+ updated_at: String(row.updated_at),
5720
+ }));
5721
+ }
5722
+
2668
5723
  claimDelivery(id: string, input: ClaimDeliveryInput): MessageDelivery {
5724
+ this.releaseExpiredDeliveries();
2669
5725
  const claimToken = crypto.randomUUID();
2670
5726
  const leaseMs = input.leaseMs ?? 30_000;
2671
5727
  const leaseUntil = new Date(Date.now() + leaseMs).toISOString();
@@ -2673,13 +5729,19 @@ export class MessageStore {
2673
5729
  if (!delivery) throw new Error(`pending delivery ${id} was not found`);
2674
5730
  if (input.computerId) {
2675
5731
  const busy = this.db.query(`
2676
- SELECT 1
5732
+ SELECT r.id, r.connection_id
2677
5733
  FROM agent_runs r
2678
5734
  JOIN agent_sessions s ON s.id = r.session_id
2679
5735
  WHERE r.computer_id = ? AND r.agent = ? AND s.chat_id = ? AND r.status = 'running'
2680
5736
  LIMIT 1
2681
5737
  `).get(input.computerId, delivery.agent, delivery.chat_id) as Record<string, unknown> | null;
2682
- if (busy) throw new Error(`runtime invocation already running for computer ${input.computerId} agent ${delivery.agent} chat ${delivery.chat_id}`);
5738
+ if (busy) {
5739
+ const matchesSteerRun = input.steerRunId && String(busy.id) === input.steerRunId;
5740
+ const matchesConnection = !input.connectionId || !busy.connection_id || String(busy.connection_id) === input.connectionId;
5741
+ if (!matchesSteerRun || !matchesConnection) {
5742
+ throw new Error(`runtime invocation already running for computer ${input.computerId} agent ${delivery.agent} chat ${delivery.chat_id}`);
5743
+ }
5744
+ }
2683
5745
  }
2684
5746
  const connectionId = input.connectionId ?? null;
2685
5747
  const result = this.db.query(`
@@ -2803,6 +5865,25 @@ export class MessageStore {
2803
5865
  return this.getRun(runId)!;
2804
5866
  }
2805
5867
 
5868
+ requestRoomAgentRunAction(input: { roomId: string; agent: string; action: RunAction }): AgentRun {
5869
+ const room = this.getChatById(input.roomId);
5870
+ if (!room) throw new Error(`room ${input.roomId} was not found`);
5871
+ const agent = input.agent.trim();
5872
+ if (!agent) throw new Error('agent is required');
5873
+ const row = this.db.query(`
5874
+ SELECT ar.*
5875
+ FROM agent_runs ar
5876
+ JOIN messages m ON m.id = ar.message_id
5877
+ WHERE ar.status = 'running'
5878
+ AND ar.agent = ?
5879
+ AND m.chat_id = ?
5880
+ ORDER BY datetime(ar.updated_at) DESC, datetime(ar.started_at) DESC, ar.rowid DESC
5881
+ LIMIT 1
5882
+ `).get(agent, room.id) as Record<string, unknown> | null;
5883
+ if (!row) throw new Error(`running run for agent ${agent} in room ${room.id} was not found`);
5884
+ return this.requestRunAction(String(row.id), input.action);
5885
+ }
5886
+
2806
5887
  clearRunAction(runId: string): void {
2807
5888
  this.db.query(`
2808
5889
  UPDATE agent_runs
@@ -2825,6 +5906,28 @@ export class MessageStore {
2825
5906
  return rows.map(rowToRun);
2826
5907
  }
2827
5908
 
5909
+ listRunsForAgent(agent: string, limit = 10): AgentRun[] {
5910
+ const rows = this.db.query(`
5911
+ SELECT *
5912
+ FROM agent_runs
5913
+ WHERE agent = ?
5914
+ ORDER BY datetime(updated_at) DESC, datetime(started_at) DESC
5915
+ LIMIT ?
5916
+ `).all(agent.trim(), limitValue(limit, 10)) as Record<string, unknown>[];
5917
+ return rows.map(rowToRun);
5918
+ }
5919
+
5920
+ listRunsForComputer(computerId: string, limit = 20): AgentRun[] {
5921
+ const rows = this.db.query(`
5922
+ SELECT *
5923
+ FROM agent_runs
5924
+ WHERE computer_id = ?
5925
+ ORDER BY datetime(updated_at) DESC, datetime(started_at) DESC
5926
+ LIMIT ?
5927
+ `).all(computerId.trim(), limitValue(limit, 20)) as Record<string, unknown>[];
5928
+ return rows.map(rowToRun);
5929
+ }
5930
+
2828
5931
  createArtifact(input: CreateArtifactInput): { artifact: ArtifactMetadata; token: string } {
2829
5932
  validateArtifactContent(input.mimeType, input.content);
2830
5933
  const id = crypto.randomUUID();