@controlflow-ai/daemon 0.1.2 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/README.md +54 -6
  2. package/bin/daemon.js +6 -1
  3. package/package.json +3 -1
  4. package/src/agent-avatar.ts +30 -0
  5. package/src/agent-key.ts +28 -0
  6. package/src/agent-permissions.ts +359 -0
  7. package/src/agent-runtime.ts +795 -28
  8. package/src/agent-workspace.ts +183 -0
  9. package/src/app.ts +1970 -79
  10. package/src/args.ts +54 -7
  11. package/src/cli.ts +873 -14
  12. package/src/client.ts +472 -10
  13. package/src/coco.ts +9 -40
  14. package/src/codex.ts +33 -5
  15. package/src/config.ts +28 -4
  16. package/src/console.ts +230 -20
  17. package/src/daemon-client.ts +116 -3
  18. package/src/daemon.ts +937 -99
  19. package/src/db.ts +3128 -122
  20. package/src/delivery-ws.ts +269 -0
  21. package/src/format.ts +4 -1
  22. package/src/lark/cli.ts +3 -3
  23. package/src/lark/event-router.ts +60 -4
  24. package/src/lark/inbound-events.ts +156 -3
  25. package/src/lark/server-integration.ts +659 -111
  26. package/src/lark/ws-daemon.ts +136 -10
  27. package/src/local-api.ts +545 -15
  28. package/src/local-auth.ts +33 -1
  29. package/src/message-attachments.ts +71 -0
  30. package/src/messaging-cli.ts +741 -0
  31. package/src/messaging-status.ts +669 -0
  32. package/src/migrations/024_agents_model.ts +10 -0
  33. package/src/migrations/025_room_archive.ts +44 -0
  34. package/src/migrations/026_project_archive.ts +44 -0
  35. package/src/migrations/027_agent_permission_profiles.ts +16 -0
  36. package/src/migrations/028_lark_websocket_restart_state.ts +16 -0
  37. package/src/migrations/029_held_message_drafts.ts +32 -0
  38. package/src/migrations/030_agent_room_read_state.ts +25 -0
  39. package/src/migrations/031_room_tasks.ts +29 -0
  40. package/src/migrations/032_room_reminders.ts +29 -0
  41. package/src/migrations/033_room_saved_messages.ts +25 -0
  42. package/src/migrations/034_agent_activity_events.ts +27 -0
  43. package/src/migrations/035_agent_avatars.ts +17 -0
  44. package/src/migrations/036_project_agent_defaults.ts +21 -0
  45. package/src/migrations/037_message_attachments.ts +36 -0
  46. package/src/migrations/038_agent_activity_room_scope.ts +64 -0
  47. package/src/migrations/039_message_attachments_path.ts +34 -0
  48. package/src/migrations/040_message_attachments_file_schema.ts +80 -0
  49. package/src/migrations/041_room_system_events.ts +30 -0
  50. package/src/migrations/042_message_attachment_file_kind.ts +52 -0
  51. package/src/migrations/043_room_mode_skill_registry.ts +92 -0
  52. package/src/migrations/044_workflow_runtime.ts +69 -0
  53. package/src/migrations/045_skill_repository_ownership.ts +64 -0
  54. package/src/migrations.ts +69 -1
  55. package/src/neeko.ts +40 -4
  56. package/src/runtime-env.ts +179 -0
  57. package/src/runtime-registry.ts +83 -13
  58. package/src/server.ts +244 -4
  59. package/src/token-file.ts +13 -6
  60. package/src/types.ts +362 -0
  61. package/src/workflow-runtime.ts +275 -0
  62. package/src/web.ts +0 -904
package/src/db.ts CHANGED
@@ -1,11 +1,14 @@
1
1
  import { Database, type SQLQueryBindings } from 'bun:sqlite';
2
2
  import { createHash, randomBytes } from 'node:crypto';
3
3
  import { isAbsolute } from 'node:path';
4
- import { ensureParentDir } from './config.js';
4
+ import { ensureParentDir, homeDir as defaultPalHomeDir } from './config.js';
5
5
  import { runMigrations } from './migrations.js';
6
6
  import { artifactExpiry, generateArtifactToken, hashArtifactToken, validateArtifactContent } from './artifacts.js';
7
+ import { storeMessageAttachmentFile, validateMessageAttachment } from './message-attachments.js';
7
8
  import { palIdentityHandle } from './provider-identity.js';
8
- 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, Project, 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';
9
12
 
10
13
  export const ALL_AGENTS_MENTION = '__pal_all_agents__';
11
14
 
@@ -21,6 +24,16 @@ export interface CreateMessageInput {
21
24
  channelId?: string | null;
22
25
  provider?: RoomProvider;
23
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;
24
37
  }
25
38
 
26
39
  export interface ListMessagesInput {
@@ -28,10 +41,36 @@ export interface ListMessagesInput {
28
41
  chatName?: string;
29
42
  parentId?: number | null;
30
43
  after?: number;
44
+ before?: number;
31
45
  limit?: number;
32
46
  q?: string;
33
47
  }
34
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
+
35
74
  export interface StartRunInput {
36
75
  messageId: number;
37
76
  agent: string;
@@ -54,6 +93,14 @@ export interface FinishRunInput {
54
93
  output?: string;
55
94
  }
56
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
+
57
104
  export interface RegisterDaemonInput {
58
105
  id?: string;
59
106
  name: string;
@@ -97,16 +144,155 @@ export interface ProvisionComputerInput {
97
144
  packageName?: string;
98
145
  }
99
146
 
147
+ export interface RegenerateComputerCommandInput {
148
+ serverUrl?: string;
149
+ packageName?: string;
150
+ }
151
+
100
152
  export interface CreateDeliveryInput {
101
153
  messageId: number;
102
154
  agent: string;
103
155
  }
104
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
+
105
290
  export interface ClaimDeliveryInput {
106
291
  daemonId: string;
107
292
  connectionId?: string | null;
108
293
  computerId?: string | null;
109
294
  leaseMs?: number;
295
+ steerRunId?: string | null;
110
296
  }
111
297
 
112
298
  export interface FinishDeliveryInput {
@@ -117,6 +303,22 @@ export interface FinishDeliveryInput {
117
303
  error?: string;
118
304
  }
119
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
+
120
322
  export interface CreateArtifactInput {
121
323
  title?: string;
122
324
  filename?: string;
@@ -252,7 +454,7 @@ export interface UpsertRoomParticipantInput {
252
454
  source: RoomParticipantSource;
253
455
  }
254
456
 
255
- 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']);
256
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;
257
459
 
258
460
  export type ChannelMessageConflictCode =
@@ -315,6 +517,156 @@ function normalizeChatName(name?: string): string {
315
517
  return stripped;
316
518
  }
317
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
+
318
670
  function parseCapabilities(value: unknown): { topics: 'native' | 'unsupported' } {
319
671
  if (typeof value !== 'string') return { topics: 'native' };
320
672
  try {
@@ -334,12 +686,32 @@ function generateConnectionToken(): string {
334
686
  }
335
687
 
336
688
  function rowToAgent(row: Record<string, unknown>): AgentDefinition {
689
+ const agentKey = String(row.agent_key);
690
+ const displayName = String(row.display_name);
337
691
  return {
338
692
  id: String(row.id),
339
- agent_key: String(row.agent_key),
340
- display_name: String(row.display_name),
693
+ agent_key: agentKey,
694
+ display_name: displayName,
341
695
  description: row.description === null ? null : String(row.description),
342
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,
343
715
  created_at: String(row.created_at),
344
716
  updated_at: String(row.updated_at),
345
717
  };
@@ -350,6 +722,7 @@ function rowToComputerAgentAssignment(row: Record<string, unknown>): ComputerAge
350
722
  agent: String(row.agent),
351
723
  display_name: String(row.display_name),
352
724
  runtime: row.runtime === null ? null : String(row.runtime),
725
+ model: row.model === null || row.model === undefined ? null : String(row.model),
353
726
  computer_id: String(row.computer_id),
354
727
  cwd: String(row.cwd ?? ''),
355
728
  status: String(row.status) as ComputerAgentAssignment['status'],
@@ -365,12 +738,64 @@ function rowToProject(row: Record<string, unknown>): Project {
365
738
  computer_id: String(row.computer_id),
366
739
  computer_name: row.computer_name === null || row.computer_name === undefined ? null : String(row.computer_name),
367
740
  root_path: String(row.root_path),
741
+ status: row.status === null || row.status === undefined ? 'active' : String(row.status) as Project['status'],
368
742
  room_count: Number(row.room_count ?? 0),
369
743
  created_at: String(row.created_at),
370
744
  updated_at: String(row.updated_at),
371
745
  };
372
746
  }
373
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
+
374
799
  function rowToMessage(row: Record<string, unknown>): Message {
375
800
  return {
376
801
  id: Number(row.id),
@@ -387,6 +812,161 @@ function rowToMessage(row: Record<string, unknown>): Message {
387
812
  channel_id: row.channel_id === null || row.channel_id === undefined ? null : String(row.channel_id),
388
813
  provider: row.provider === null || row.provider === undefined ? undefined : String(row.provider) as RoomProvider,
389
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),
390
970
  };
391
971
  }
392
972
 
@@ -416,6 +996,28 @@ function rowToRun(row: Record<string, unknown>): AgentRun {
416
996
  };
417
997
  }
418
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
+
419
1021
  function rowToSession(row: Record<string, unknown>): AgentSession {
420
1022
  return {
421
1023
  id: String(row.id),
@@ -475,6 +1077,7 @@ function rowToDelivery(row: Record<string, unknown>): MessageDelivery {
475
1077
  id: String(row.id),
476
1078
  message_id: Number(row.message_id),
477
1079
  chat_id: String(row.chat_id),
1080
+ ...(row.chat_name === undefined ? {} : { chat_name: row.chat_name === null ? null : String(row.chat_name) }),
478
1081
  agent: String(row.agent),
479
1082
  status: String(row.status) as MessageDelivery['status'],
480
1083
  daemon_id: row.daemon_id === null ? null : String(row.daemon_id),
@@ -618,6 +1221,8 @@ function rowToAgentRoomSubscription(row: Record<string, unknown>): AgentRoomSubs
618
1221
  room_id: String(row.room_id),
619
1222
  channel_id: row.channel_id === null || row.channel_id === undefined ? null : String(row.channel_id),
620
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),
621
1226
  created_at: String(row.created_at),
622
1227
  updated_at: String(row.updated_at),
623
1228
  };
@@ -721,6 +1326,40 @@ function normalizeMentionsInput(value: string[] | undefined): string[] {
721
1326
  return result;
722
1327
  }
723
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
+ }
1361
+ }
1362
+
724
1363
  function rowToTranscriptReadModel(row: Record<string, unknown>): TranscriptReadModel {
725
1364
  return {
726
1365
  id: String(row.id),
@@ -803,8 +1442,10 @@ function limitValue(value: number | undefined, fallback: number): number {
803
1442
 
804
1443
  export class MessageStore {
805
1444
  readonly db: Database;
1445
+ private readonly palHome: string;
806
1446
 
807
- constructor(path: string) {
1447
+ constructor(path: string, options: { palHome?: string } = {}) {
1448
+ this.palHome = options.palHome ?? defaultPalHomeDir();
808
1449
  ensureParentDir(path);
809
1450
  this.db = new Database(path);
810
1451
  this.db.exec('PRAGMA journal_mode = WAL');
@@ -817,6 +1458,43 @@ export class MessageStore {
817
1458
  this.db.close();
818
1459
  }
819
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
+
820
1498
  getOrCreateChat(name: string, kind: ChatKind = 'group'): Chat {
821
1499
  const chatName = normalizeChatName(name);
822
1500
  const existing = this.db.query('SELECT * FROM chat_stats WHERE name = ?').get(chatName) as Chat | null;
@@ -846,6 +1524,49 @@ export class MessageStore {
846
1524
  return this.getProject(id)!;
847
1525
  }
848
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
+
849
1570
  getProject(id: string): Project | null {
850
1571
  const row = this.db.query(`
851
1572
  SELECT p.*, c.name AS computer_name, COUNT(ch.id) AS room_count
@@ -858,6 +1579,34 @@ export class MessageStore {
858
1579
  return row ? rowToProject(row) : null;
859
1580
  }
860
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
+
861
1610
  listProjects(limit = 50): Project[] {
862
1611
  const rows = this.db.query(`
863
1612
  SELECT p.*, c.name AS computer_name, COUNT(ch.id) AS room_count
@@ -871,21 +1620,330 @@ export class MessageStore {
871
1620
  return rows.map(rowToProject);
872
1621
  }
873
1622
 
874
- createProjectRoom(input: { projectId: string; name: string; kind?: ChatKind }): Chat {
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 {
875
1923
  const project = this.getProject(input.projectId);
876
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');
877
1926
  const kind = input.kind ?? 'group';
878
1927
  if (kind !== 'group' && kind !== 'dm') throw new Error('kind must be group or dm');
1928
+ const mode = assertRoomMode(input.mode ?? 'standard');
879
1929
  const chatName = normalizeChatName(input.name);
880
1930
  if (!chatName) throw new Error('room name is required');
881
1931
  if (this.getChatByName(chatName)) throw new Error(`room ${chatName} already exists`);
882
1932
 
883
1933
  const id = crypto.randomUUID();
884
- this.db.query(`
885
- INSERT INTO chats (id, name, kind, provider, capabilities_json, project_id)
886
- VALUES (?, ?, ?, 'web', ?, ?)
887
- `).run(id, chatName, kind, kind === 'dm' ? '{"topics":"unsupported"}' : '{"topics":"native"}', project.id);
888
- this.db.query(`UPDATE projects SET updated_at = datetime('now') WHERE id = ?`).run(project.id);
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
+ })();
889
1947
  return this.getChatById(id)!;
890
1948
  }
891
1949
 
@@ -897,11 +1955,20 @@ export class MessageStore {
897
1955
  return this.getChatById(room.id)!;
898
1956
  }
899
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
+
900
1967
  backfillLarkDmDisplayNames(): number {
901
1968
  const result = this.db.query(`
902
1969
  UPDATE chats
903
1970
  SET display_name = (
904
- SELECT 'DM with ' || rp.display_name
1971
+ SELECT rp.display_name
905
1972
  FROM room_participants rp
906
1973
  WHERE rp.room_id = chats.id
907
1974
  AND rp.kind = 'bot'
@@ -913,7 +1980,7 @@ export class MessageStore {
913
1980
  )
914
1981
  WHERE provider = 'lark'
915
1982
  AND kind = 'dm'
916
- 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 %')
917
1984
  AND EXISTS (
918
1985
  SELECT 1
919
1986
  FROM room_participants rp
@@ -927,6 +1994,15 @@ export class MessageStore {
927
1994
  return result.changes;
928
1995
  }
929
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
+
930
2006
  getChatById(id: string): Chat | null {
931
2007
  const row = this.db.query('SELECT * FROM chat_stats WHERE id = ?').get(id) as Chat | null;
932
2008
  return row ?? null;
@@ -941,6 +2017,10 @@ export class MessageStore {
941
2017
  return this.db.query('SELECT * FROM chat_stats ORDER BY COALESCE(last_message_at, created_at) DESC').all() as Chat[];
942
2018
  }
943
2019
 
2020
+ listChatsForAgent(agent: string): Chat[] {
2021
+ return this.listChats().filter((room) => this.canAgentParticipateInRoom(agent.trim(), room));
2022
+ }
2023
+
944
2024
  listLarkAuthorizedUsers(): LarkAuthorizedUser[] {
945
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>[];
946
2026
  return rows.map(rowToLarkAuthorizedUser);
@@ -1089,24 +2169,115 @@ export class MessageStore {
1089
2169
  return Number(row.id ?? 0);
1090
2170
  }
1091
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
+
1092
2238
  inviteAgentToRoom(input: { roomId: string; agent: string; mode?: AgentRoomSubscriptionMode }): { participant: RoomParticipant; subscription: AgentRoomSubscription } {
1093
2239
  const room = this.getChatById(input.roomId);
1094
2240
  if (!room) throw new Error(`room ${input.roomId} was not found`);
1095
2241
  const agent = input.agent.trim();
1096
2242
  if (!agent) throw new Error('agent is required');
1097
- const participant = this.upsertRoomParticipant({
1098
- roomId: room.id,
1099
- participantId: agent,
1100
- kind: 'agent',
1101
- displayName: this.getAgent(agent)?.display_name ?? agent,
1102
- source: 'local_agent',
1103
- });
1104
- this.db.query(`
1105
- UPDATE room_participants
1106
- SET delivery_cursor_message_id = COALESCE(delivery_cursor_message_id, ?), updated_at = datetime('now')
1107
- WHERE room_id = ? AND participant_id = ?
1108
- `).run(this.getLatestMessageId(room.id), room.id, agent);
1109
- 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;
1110
2281
  }
1111
2282
 
1112
2283
  upsertAgentRoomSubscription(input: { agent: string; roomId: string; mode: AgentRoomSubscriptionMode; channelId?: string | null }): AgentRoomSubscription {
@@ -1125,10 +2296,11 @@ export class MessageStore {
1125
2296
  return this.getAgentRoomSubscription(agent, room.id)!;
1126
2297
  }
1127
2298
  const id = crypto.randomUUID();
2299
+ const latestMessageId = this.getLatestMessageId(room.id);
1128
2300
  this.db.query(`
1129
- INSERT INTO agent_room_subscriptions (id, agent, room_id, channel_id, mode, created_at, updated_at)
1130
- VALUES (?, ?, ?, ?, ?, datetime('now'), datetime('now'))
1131
- `).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);
1132
2304
  const row = this.db.query('SELECT * FROM agent_room_subscriptions WHERE id = ?').get(id) as Record<string, unknown>;
1133
2305
  return rowToAgentRoomSubscription(row);
1134
2306
  }
@@ -1142,6 +2314,208 @@ export class MessageStore {
1142
2314
  return row ? rowToAgentRoomSubscription(row) : null;
1143
2315
  }
1144
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
+
1145
2519
  createRoomChannel(input: { roomId: string; name: string; createdBy?: string | null; externalRef?: string | null }): RoomChannel {
1146
2520
  const room = this.getChatById(input.roomId);
1147
2521
  if (!room) throw new Error(`room ${input.roomId} was not found`);
@@ -1162,7 +2536,7 @@ export class MessageStore {
1162
2536
 
1163
2537
  listAgents(limit = 50): AgentDefinition[] {
1164
2538
  const rows = this.db.query(`
1165
- 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
1166
2540
  FROM agents
1167
2541
  ORDER BY agent_key ASC
1168
2542
  LIMIT ?
@@ -1174,7 +2548,7 @@ export class MessageStore {
1174
2548
  const key = identifier.trim();
1175
2549
  if (!key) return null;
1176
2550
  const row = this.db.query(`
1177
- 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
1178
2552
  FROM agents
1179
2553
  WHERE id = ? OR agent_key = ?
1180
2554
  LIMIT 1
@@ -1182,46 +2556,323 @@ export class MessageStore {
1182
2556
  return row ? rowToAgent(row) : null;
1183
2557
  }
1184
2558
 
1185
- getAgentRuntime(agentKey: string): string | null {
1186
- const row = this.db.query(`
1187
- SELECT runtime FROM agents WHERE agent_key = ? LIMIT 1
1188
- `).get(agentKey.trim()) as { runtime: string | null } | null;
1189
- return row?.runtime ?? null;
2559
+ getAgentRuntime(agentKey: string): string | null {
2560
+ const row = this.db.query(`
2561
+ SELECT runtime FROM agents WHERE agent_key = ? LIMIT 1
2562
+ `).get(agentKey.trim()) as { runtime: string | null } | null;
2563
+ return row?.runtime ?? null;
2564
+ }
2565
+
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);
1190
2601
  }
1191
2602
 
1192
- upsertAgent(input: { id?: string; agent_key: string; display_name: string; description?: string | null; runtime?: string | null }): AgentDefinition {
2603
+ upsertAgent(input: { id?: string; agent_key: string; display_name: string; description?: string | null; runtime?: string | null; model?: string | null; avatar?: string | null }): AgentDefinition {
1193
2604
  const id = input.id?.trim() || crypto.randomUUID();
1194
2605
  const agentKey = input.agent_key.trim();
1195
2606
  const displayName = input.display_name.trim();
1196
2607
  if (!agentKey) throw new Error('agent_key is required');
1197
2608
  if (!displayName) throw new Error('display_name is required');
1198
2609
 
2610
+ const avatar = normalizeAgentAvatar(input.avatar, agentKey, displayName);
2611
+
1199
2612
  this.db.query(`
1200
- INSERT INTO agents (id, agent_key, display_name, description, runtime, created_at, updated_at)
1201
- 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'))
1202
2615
  ON CONFLICT(agent_key) DO UPDATE SET
1203
2616
  display_name = excluded.display_name,
1204
2617
  description = COALESCE(excluded.description, agents.description),
1205
2618
  runtime = COALESCE(excluded.runtime, agents.runtime),
2619
+ model = COALESCE(excluded.model, agents.model),
2620
+ avatar = COALESCE(NULLIF(agents.avatar, ''), excluded.avatar),
1206
2621
  updated_at = datetime('now')
1207
- `).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);
1208
2623
 
1209
2624
  return this.getAgent(agentKey)!;
1210
2625
  }
1211
2626
 
1212
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 {
1213
2632
  const key = agentKey.trim();
1214
2633
  if (!key) throw new Error('agent_key is required');
1215
2634
  const existing = this.getAgent(key);
1216
2635
  if (!existing) return null;
1217
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;
1218
2639
  this.db.query(`
1219
- UPDATE agents SET runtime = ?, updated_at = datetime('now') WHERE agent_key = ?
1220
- `).run(runtime, key);
2640
+ UPDATE agents SET runtime = ?, model = ?, updated_at = datetime('now') WHERE agent_key = ?
2641
+ `).run(runtime, model, key);
1221
2642
 
1222
2643
  return this.getAgent(key);
1223
2644
  }
1224
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
+
1225
2876
  assignAgentToComputer(input: { agent: string; computerId: string }): ComputerAgentAssignment {
1226
2877
  const agent = input.agent.trim();
1227
2878
  const computerId = input.computerId.trim();
@@ -1243,9 +2894,19 @@ export class MessageStore {
1243
2894
  return this.getComputerAgentAssignment(agent)!;
1244
2895
  }
1245
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
+
1246
2907
  getComputerAgentAssignment(agent: string): ComputerAgentAssignment | null {
1247
2908
  const row = this.db.query(`
1248
- SELECT caa.*, a.display_name, a.runtime
2909
+ SELECT caa.*, a.display_name, a.runtime, a.model
1249
2910
  FROM computer_agent_assignments caa
1250
2911
  JOIN agents a ON a.agent_key = caa.agent
1251
2912
  WHERE caa.agent = ?
@@ -1256,7 +2917,7 @@ export class MessageStore {
1256
2917
 
1257
2918
  listComputerAgentAssignments(computerId: string): ComputerAgentAssignment[] {
1258
2919
  const rows = this.db.query(`
1259
- SELECT caa.*, a.display_name, a.runtime
2920
+ SELECT caa.*, a.display_name, a.runtime, a.model
1260
2921
  FROM computer_agent_assignments caa
1261
2922
  JOIN agents a ON a.agent_key = caa.agent
1262
2923
  WHERE caa.computer_id = ? AND caa.status = 'active'
@@ -1297,6 +2958,10 @@ export class MessageStore {
1297
2958
  diagnostics.push({ level: 'error', code: 'INVALID_FIELD', message: 'runtime must be a string or null' });
1298
2959
  }
1299
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
+
1300
2965
  for (const key of ['created_at', 'updated_at']) {
1301
2966
  if (key in input && typeof input[key] !== 'string') {
1302
2967
  diagnostics.push({ level: 'error', code: 'INVALID_FIELD', message: `${key} must be a string when provided` });
@@ -2105,9 +3770,13 @@ export class MessageStore {
2105
3770
 
2106
3771
  createMessage(input: CreateMessageInput): Message {
2107
3772
  const sender = input.sender.trim();
2108
- 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]') : '');
2109
3775
  if (!sender) throw new Error('sender is required');
2110
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
+ }
2111
3780
 
2112
3781
  let chat: Chat | null = null;
2113
3782
  let parentId: number | null = null;
@@ -2138,7 +3807,7 @@ export class MessageStore {
2138
3807
  JOIN chats c ON c.id = m.chat_id
2139
3808
  WHERE m.idempotency_key = ?
2140
3809
  `).get(idempotencyKey) as Record<string, unknown> | null;
2141
- if (existing) return rowToMessage(existing);
3810
+ if (existing) return this.attachMessageAttachments([rowToMessage(existing)])[0]!;
2142
3811
  }
2143
3812
 
2144
3813
  const mentions = normalizeMentionsInput(input.mentions);
@@ -2148,80 +3817,864 @@ export class MessageStore {
2148
3817
  if (!channel) throw new Error(`channel ${channelId} was not found`);
2149
3818
  if (channel.room_id !== chat.id) throw new Error(`channel ${channelId} does not belong to room ${chat.id}`);
2150
3819
  }
2151
- const provider = input.provider ?? chat.provider ?? 'web';
2152
- const result = this.db.query(`
2153
- INSERT INTO messages (chat_id, parent_id, depth, sender, recipient, content, type, idempotency_key, channel_id, provider, mentions_json)
2154
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
3820
+ const provider = input.provider ?? chat.provider ?? 'web';
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})
2155
4374
  `).run(
2156
- chat.id,
4375
+ id,
4376
+ run.id,
2157
4377
  parentId,
2158
- depth,
2159
- sender,
2160
- input.recipient?.trim() || null,
2161
- content,
2162
- input.type ?? 'message',
2163
- idempotencyKey,
2164
- channelId,
2165
- provider,
2166
- 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,
2167
4390
  );
2168
-
2169
- const id = Number(result.lastInsertRowid);
2170
- return this.getMessage(id)!;
4391
+ return this.getWorkflowNode(id)!;
2171
4392
  }
2172
4393
 
2173
- getMessage(id: number): Message | null {
2174
- const row = this.db.query(`
2175
- SELECT m.*, c.name AS chat_name
2176
- FROM messages m
2177
- JOIN chats c ON c.id = m.chat_id
2178
- WHERE m.id = ?
2179
- `).get(id) as Record<string, unknown> | null;
2180
- return row ? rowToMessage(row) : null;
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;
2181
4397
  }
2182
4398
 
2183
- listMessages(input: ListMessagesInput = {}): Message[] {
2184
- const conditions: string[] = [];
2185
- const params: SQLQueryBindings[] = [];
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)!;
4429
+ }
2186
4430
 
2187
- if (input.chatId) {
2188
- conditions.push('m.chat_id = ?');
2189
- params.push(input.chatId);
2190
- } else if (input.chatName) {
2191
- conditions.push('c.name = ?');
2192
- params.push(normalizeChatName(input.chatName));
2193
- }
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
+ }
4442
+
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
+ }
4448
+
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>[];
2194
4459
 
2195
- if (input.parentId !== undefined) {
2196
- if (input.parentId === null) {
2197
- conditions.push('m.parent_id IS NULL');
2198
- } else {
2199
- conditions.push('(m.id = ? OR m.parent_id = ?)');
2200
- params.push(input.parentId, input.parentId);
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);
2201
4496
  }
2202
- }
2203
4497
 
2204
- if (input.after !== undefined) {
2205
- conditions.push('m.id > ?');
2206
- params.push(input.after);
2207
- }
4498
+ return {
4499
+ fired,
4500
+ reminders: fired.map((item) => item.reminder),
4501
+ messages,
4502
+ deliveries,
4503
+ };
4504
+ })();
4505
+ }
2208
4506
 
2209
- if (input.q) {
2210
- conditions.push('m.content LIKE ?');
2211
- params.push(`%${input.q}%`);
2212
- }
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
+ }
2213
4534
 
2214
- 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[] {
2215
4555
  const rows = this.db.query(`
2216
- SELECT m.*, c.name AS chat_name
2217
- FROM messages m
2218
- JOIN chats c ON c.id = m.chat_id
2219
- ${where}
2220
- 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
2221
4569
  LIMIT ?
2222
- `).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
+ }
2223
4610
 
2224
- return rows.map(rowToMessage);
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
+ }
4636
+
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
+ };
2225
4678
  }
2226
4679
 
2227
4680
  listInbox(agent: string, after = 0, limit = 50): Message[] {
@@ -2236,7 +4689,7 @@ export class MessageStore {
2236
4689
  LIMIT ?
2237
4690
  `).all(after, agent, agent, limitValue(limit, 50)) as Record<string, unknown>[];
2238
4691
 
2239
- return rows.map(rowToMessage);
4692
+ return this.attachMessageAttachments(rows.map(rowToMessage));
2240
4693
  }
2241
4694
 
2242
4695
  getComputer(id: string): Computer | null {
@@ -2253,6 +4706,79 @@ export class MessageStore {
2253
4706
  return rows.map(rowToComputer);
2254
4707
  }
2255
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
+
2256
4782
  private getComputerByCredential(secret: string): Computer | null {
2257
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;
2258
4784
  return row ? rowToComputer(row) : null;
@@ -2263,6 +4789,17 @@ export class MessageStore {
2263
4789
  return row ? rowToComputerConnection(row) : null;
2264
4790
  }
2265
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
+
2266
4803
  provisionComputer(input: ProvisionComputerInput = {}): ProvisionedComputer {
2267
4804
  const id = `machine_${randomBytes(8).toString('hex')}`;
2268
4805
  const apiKey = `sk_machine_${randomBytes(32).toString('hex')}`;
@@ -2272,9 +4809,8 @@ export class MessageStore {
2272
4809
  VALUES (?, ?, ?, 'offline', NULL, NULL, datetime('now'))
2273
4810
  `).run(id, name, hashSecret(apiKey));
2274
4811
 
2275
- const packageName = input.packageName?.trim() || '@controlflow-ai/daemon@latest';
2276
4812
  const serverUrl = input.serverUrl?.trim() || 'http://127.0.0.1:4127';
2277
- const command = `npx ${packageName} --server-url ${serverUrl} --api-key ${apiKey} # ${name}`;
4813
+ const command = daemonStartCommand({ commandPrefix: input.packageName, serverUrl, apiKey, name });
2278
4814
  return { computer: this.getComputer(id)!, api_key: apiKey, command };
2279
4815
  }
2280
4816
 
@@ -2286,7 +4822,7 @@ export class MessageStore {
2286
4822
  if (!computerId) throw new Error('computer_id is required');
2287
4823
  if (!secret) throw new Error('computer secret is required');
2288
4824
  const credentialHash = hashSecret(secret);
2289
- 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;
2290
4826
  if (existing && existing.credential_hash !== credentialHash) {
2291
4827
  throw new Error('invalid computer credential');
2292
4828
  }
@@ -2302,7 +4838,8 @@ export class MessageStore {
2302
4838
  const revokedConnectionIds = revokedRows.map((row) => row.id);
2303
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 };
2304
4840
  const epoch = Number(epochRow.epoch);
2305
- const name = input.name?.trim() || computerId;
4841
+ const providedName = input.name?.trim() || null;
4842
+ const name = providedName ?? existing?.name ?? computerId;
2306
4843
 
2307
4844
  this.db.transaction(() => {
2308
4845
  if (!existing) {
@@ -2313,9 +4850,13 @@ export class MessageStore {
2313
4850
  } else {
2314
4851
  this.db.query(`
2315
4852
  UPDATE computers
2316
- 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')
2317
4858
  WHERE id = ?
2318
- `).run(name, connectionId, computerId);
4859
+ `).run(providedName, providedName, connectionId, computerId);
2319
4860
  }
2320
4861
  this.db.query(`
2321
4862
  UPDATE computer_connections
@@ -2405,6 +4946,21 @@ export class MessageStore {
2405
4946
  return { computer, connection, daemon, token };
2406
4947
  }
2407
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
+
2408
4964
  closeStaleComputerConnections(timeoutMs: number, now = new Date()): number {
2409
4965
  if (!Number.isFinite(timeoutMs) || timeoutMs < 0) throw new Error('timeoutMs must be non-negative');
2410
4966
  const cutoff = new Date(now.getTime() - timeoutMs).toISOString();
@@ -2414,7 +4970,6 @@ export class MessageStore {
2414
4970
  JOIN computers c ON c.active_connection_id = cc.id
2415
4971
  WHERE cc.status = 'active' AND datetime(cc.last_heartbeat_at) < datetime(?)
2416
4972
  `).all(cutoff) as Array<{ id: string; computer_id: string }>;
2417
- if (staleRows.length === 0) return 0;
2418
4973
  this.db.transaction(() => {
2419
4974
  for (const row of staleRows) {
2420
4975
  this.db.query(`
@@ -2427,7 +4982,103 @@ export class MessageStore {
2427
4982
  SET status = 'offline', active_connection_id = NULL, updated_at = datetime('now')
2428
4983
  WHERE id = ? AND active_connection_id = ?
2429
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);
2430
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();
2431
5082
  })();
2432
5083
  return staleRows.length;
2433
5084
  }
@@ -2517,6 +5168,114 @@ export class MessageStore {
2517
5168
  return row !== null;
2518
5169
  }
2519
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
+
2520
5279
  hasDaemonAgent(agent: string): boolean {
2521
5280
  const row = this.db.query(`
2522
5281
  SELECT 1 FROM daemon_agents
@@ -2526,13 +5285,90 @@ export class MessageStore {
2526
5285
  return row !== null;
2527
5286
  }
2528
5287
 
2529
- 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();
2530
5343
  const rows = this.db.query(`
2531
- SELECT * FROM message_deliveries
2532
- WHERE agent = ? AND status = ?
2533
- 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
2534
5370
  LIMIT ?
2535
- `).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>[];
2536
5372
  return rows.map(rowToDelivery);
2537
5373
  }
2538
5374
 
@@ -2664,11 +5500,22 @@ export class MessageStore {
2664
5500
  }
2665
5501
 
2666
5502
  canSendWebMessageToRoom(input: { room: Chat; sender: string }): boolean {
5503
+ if (input.room.status === 'archived') return false;
2667
5504
  if (input.room.provider === 'web') return true;
2668
5505
  if (!this.getAgent(input.sender)) return false;
2669
5506
  if (input.room.provider === 'lark') {
2670
- const route = this.findLarkChannelForChat(input.room.id);
2671
- 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;
2672
5519
  }
2673
5520
  return false;
2674
5521
  }
@@ -2752,6 +5599,17 @@ export class MessageStore {
2752
5599
  return rows.map(rowToSession);
2753
5600
  }
2754
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
+
2755
5613
  listRunsForSession(sessionId: string, limit = 50): AgentRun[] {
2756
5614
  const rows = this.db.query(`
2757
5615
  SELECT * FROM agent_runs
@@ -2762,7 +5620,108 @@ export class MessageStore {
2762
5620
  return rows.map(rowToRun);
2763
5621
  }
2764
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
+
2765
5723
  claimDelivery(id: string, input: ClaimDeliveryInput): MessageDelivery {
5724
+ this.releaseExpiredDeliveries();
2766
5725
  const claimToken = crypto.randomUUID();
2767
5726
  const leaseMs = input.leaseMs ?? 30_000;
2768
5727
  const leaseUntil = new Date(Date.now() + leaseMs).toISOString();
@@ -2770,13 +5729,19 @@ export class MessageStore {
2770
5729
  if (!delivery) throw new Error(`pending delivery ${id} was not found`);
2771
5730
  if (input.computerId) {
2772
5731
  const busy = this.db.query(`
2773
- SELECT 1
5732
+ SELECT r.id, r.connection_id
2774
5733
  FROM agent_runs r
2775
5734
  JOIN agent_sessions s ON s.id = r.session_id
2776
5735
  WHERE r.computer_id = ? AND r.agent = ? AND s.chat_id = ? AND r.status = 'running'
2777
5736
  LIMIT 1
2778
5737
  `).get(input.computerId, delivery.agent, delivery.chat_id) as Record<string, unknown> | null;
2779
- 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
+ }
2780
5745
  }
2781
5746
  const connectionId = input.connectionId ?? null;
2782
5747
  const result = this.db.query(`
@@ -2900,6 +5865,25 @@ export class MessageStore {
2900
5865
  return this.getRun(runId)!;
2901
5866
  }
2902
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
+
2903
5887
  clearRunAction(runId: string): void {
2904
5888
  this.db.query(`
2905
5889
  UPDATE agent_runs
@@ -2922,6 +5906,28 @@ export class MessageStore {
2922
5906
  return rows.map(rowToRun);
2923
5907
  }
2924
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
+
2925
5931
  createArtifact(input: CreateArtifactInput): { artifact: ArtifactMetadata; token: string } {
2926
5932
  validateArtifactContent(input.mimeType, input.content);
2927
5933
  const id = crypto.randomUUID();