@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/server.ts CHANGED
@@ -1,34 +1,101 @@
1
1
  #!/usr/bin/env bun
2
+ import { randomUUID } from 'node:crypto';
2
3
  import { DEFAULT_HOST, DEFAULT_PORT, defaultDbPath } from './config.js';
3
4
  import { MessageStore } from './db.js';
4
- import { handleRequest } from './app.js';
5
+ import { fireDueRoomRemindersForDelivery, handleRequest } from './app.js';
6
+ import { DeliveryWebSocketHub, type DeliveryWebSocketData } from './delivery-ws.js';
5
7
  import { startLarkOnServer } from './lark/server-integration.js';
8
+ import { buildMessagingHealthLogLines, buildMessagingStatus } from './messaging-status.js';
6
9
  import { isLoopbackHost, tailscaleAddress } from './network.js';
10
+ import { assertServerAuth } from './server-auth.js';
7
11
 
8
12
  const dbPath = defaultDbPath();
9
13
  const store = new MessageStore(dbPath);
14
+ const bootId = randomUUID();
15
+ const bootedAt = new Date();
10
16
  const port = Number(process.env.PAL_PORT ?? DEFAULT_PORT);
11
17
  const host = process.env.PAL_HOST ?? DEFAULT_HOST;
12
18
  const tailscaleHost = process.env.PAL_TAILSCALE_HOST ?? tailscaleAddress();
19
+ const larkAutoRestartStaleAfterMs = positiveEnvMs('PAL_LARK_AUTO_RESTART_STALE_AFTER_MS');
20
+ const larkAutoRestartIntervalMs = positiveEnvMs('PAL_LARK_AUTO_RESTART_INTERVAL_MS') ?? Math.min(larkAutoRestartStaleAfterMs ?? 60_000, 60_000);
21
+ const larkAutoRestartMinIntervalMs = positiveEnvMs('PAL_LARK_AUTO_RESTART_MIN_INTERVAL_MS') ?? Math.max(larkAutoRestartStaleAfterMs ?? 0, 5 * 60 * 1000);
22
+ const messagingHealthLogIntervalMs = nonNegativeEnvMs('PAL_MESSAGING_HEALTH_LOG_INTERVAL_MS') ?? 60_000;
23
+ const computerConnectionTimeoutMs = nonNegativeEnvMs('PAL_COMPUTER_CONNECTION_TIMEOUT_MS') ?? 30_000;
24
+ const computerConnectionCleanupIntervalMs = positiveEnvMs('PAL_COMPUTER_CONNECTION_CLEANUP_INTERVAL_MS') ?? Math.min(Math.max(computerConnectionTimeoutMs, 5_000), 60_000);
25
+ const reminderFireIntervalMs = nonNegativeEnvMs('PAL_REMINDER_FIRE_INTERVAL_MS') ?? 1_000;
26
+ const reminderFireLimit = positiveEnvMs('PAL_REMINDER_FIRE_LIMIT') ?? 25;
27
+ const deliveryWs = new DeliveryWebSocketHub(store, { staleConnectionTimeoutMs: computerConnectionTimeoutMs });
28
+ const lastMessagingHealthLogAt = new Map<string, number>();
13
29
 
14
- function fetch(request: Request): Promise<Response> | Response {
30
+ function positiveEnvMs(name: string): number | null {
31
+ const raw = process.env[name];
32
+ if (!raw) return null;
33
+ const parsed = Number(raw);
34
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
35
+ }
36
+
37
+ function nonNegativeEnvMs(name: string): number | null {
38
+ const raw = process.env[name];
39
+ if (raw === undefined || raw === '') return null;
40
+ const parsed = Number(raw);
41
+ return Number.isFinite(parsed) && parsed >= 0 ? parsed : null;
42
+ }
43
+
44
+ function fetch(request: Request, server: Bun.Server<DeliveryWebSocketData>): Promise<Response> | Response {
45
+ const upgraded = deliveryWs.handleUpgrade(request, server);
46
+ if (upgraded) return upgraded;
15
47
  const url = new URL(request.url);
48
+ if (request.method === 'GET' && url.pathname === '/api/server/runtime') {
49
+ try {
50
+ assertServerAuth(request);
51
+ } catch (err) {
52
+ const message = err instanceof Error ? err.message : 'unauthorized';
53
+ return new Response(JSON.stringify({ ok: false, error: message }), {
54
+ status: 401,
55
+ headers: { 'content-type': 'application/json; charset=utf-8' },
56
+ });
57
+ }
58
+ return new Response(JSON.stringify({
59
+ ok: true,
60
+ data: {
61
+ boot_id: bootId,
62
+ booted_at: bootedAt.toISOString(),
63
+ uptime_ms: Date.now() - bootedAt.getTime(),
64
+ },
65
+ }), {
66
+ headers: { 'content-type': 'application/json; charset=utf-8' },
67
+ });
68
+ }
69
+ if (request.method === 'GET' && url.pathname === '/api/lark/status') {
70
+ return larkStatus();
71
+ }
16
72
  if (request.method === 'POST' && url.pathname === '/api/lark/reload') {
17
73
  return reloadLarkIntegration();
18
74
  }
19
- return handleRequest(store, request);
75
+ if (request.method === 'POST' && url.pathname === '/api/lark/restart') {
76
+ return restartLarkIntegration(request);
77
+ }
78
+ if (request.method === 'POST' && url.pathname === '/api/lark/probe-event') {
79
+ return probeLarkEvent(request, url);
80
+ }
81
+ return handleRequest(store, request, {
82
+ deliveryNotifier: deliveryWs,
83
+ larkStatusProvider: () => larkIntegration.status(),
84
+ });
20
85
  }
21
86
 
22
87
  const server = Bun.serve({
23
88
  hostname: host,
24
89
  port,
25
90
  fetch,
91
+ websocket: deliveryWs.websocket,
26
92
  });
27
93
  const tailscaleServer = tailscaleHost && isLoopbackHost(host)
28
- ? Bun.serve({ hostname: tailscaleHost, port, fetch })
94
+ ? Bun.serve({ hostname: tailscaleHost, port, fetch, websocket: deliveryWs.websocket })
29
95
  : null;
30
96
 
31
97
  console.log(`pal server listening on http://${server.hostname}:${server.port}`);
98
+ console.log(`[server] boot id=${bootId} started_at=${bootedAt.toISOString()}`);
32
99
  if (tailscaleServer) {
33
100
  console.log(`pal server also listening on Tailscale http://${tailscaleServer.hostname}:${tailscaleServer.port}`);
34
101
  } else if (tailscaleHost) {
@@ -36,15 +103,98 @@ if (tailscaleServer) {
36
103
  }
37
104
  console.log(`database: ${dbPath}`);
38
105
 
106
+ const computerConnectionCleanup = setInterval(() => {
107
+ const closed = deliveryWs.pruneStaleConnections();
108
+ if (closed > 0) {
109
+ console.log(`[server] closed stale computer connection(s): ${closed}`);
110
+ }
111
+ }, computerConnectionCleanupIntervalMs);
112
+ console.log(`[server] computer connection cleanup enabled timeout_ms=${computerConnectionTimeoutMs} interval_ms=${computerConnectionCleanupIntervalMs}`);
113
+
114
+ function fireDueReminderWakeups(): void {
115
+ try {
116
+ const result = fireDueRoomRemindersForDelivery(store, { deliveryNotifier: deliveryWs }, { limit: reminderFireLimit });
117
+ if (result.reminders.length > 0) {
118
+ console.log(`[server] fired room reminders reminders=${result.reminders.length} messages=${result.messages.length} deliveries=${result.deliveries.length}`);
119
+ }
120
+ } catch (error) {
121
+ const message = error instanceof Error ? error.message : String(error);
122
+ console.warn(`[server] room reminder firing failed: ${message}`);
123
+ }
124
+ }
125
+
126
+ const roomReminderFireLoop = reminderFireIntervalMs > 0
127
+ ? setInterval(fireDueReminderWakeups, reminderFireIntervalMs)
128
+ : null;
129
+ if (roomReminderFireLoop) {
130
+ console.log(`[server] room reminder firing enabled interval_ms=${reminderFireIntervalMs} limit=${reminderFireLimit}`);
131
+ fireDueReminderWakeups();
132
+ } else {
133
+ console.log('[server] room reminder firing disabled');
134
+ }
135
+
39
136
  function startLarkIntegration() {
40
137
  return startLarkOnServer({
41
138
  store,
139
+ dbPath,
140
+ deliveryNotifier: deliveryWs,
42
141
  logger: { log: console.log, warn: console.warn, error: console.error },
43
142
  });
44
143
  }
45
144
 
46
145
  // Start Lark bot integration on the server
47
146
  let larkIntegration = startLarkIntegration();
147
+ const larkAutoRestart = larkAutoRestartStaleAfterMs
148
+ ? setInterval(() => {
149
+ const result = larkIntegration.restartStale({
150
+ staleAfterMs: larkAutoRestartStaleAfterMs,
151
+ minRestartIntervalMs: larkAutoRestartMinIntervalMs,
152
+ });
153
+ if (result.restarted.length > 0) {
154
+ console.log(`[server] lark auto-restarted stale websocket(s): ${result.restarted.join(',')}`);
155
+ }
156
+ if (result.skipped_recent?.length) {
157
+ console.log(`[server] lark auto-restart skipped recent websocket(s): ${result.skipped_recent.join(',')}`);
158
+ }
159
+ if (result.skipped_ineffective?.length) {
160
+ console.warn(`[server] lark auto-restart skipped ineffective websocket repair(s): ${result.skipped_ineffective.join(',')}`);
161
+ }
162
+ if (!result.ok) {
163
+ console.warn(`[server] lark auto-restart failed: ${result.error}`);
164
+ }
165
+ logMessagingHealthDiagnostics();
166
+ }, larkAutoRestartIntervalMs)
167
+ : null;
168
+ if (larkAutoRestart) {
169
+ console.log(`[server] lark stale auto-restart enabled stale_after_ms=${larkAutoRestartStaleAfterMs} interval_ms=${larkAutoRestartIntervalMs} min_restart_interval_ms=${larkAutoRestartMinIntervalMs}`);
170
+ }
171
+ const messagingHealthLog = messagingHealthLogIntervalMs > 0
172
+ ? setInterval(logMessagingHealthDiagnostics, messagingHealthLogIntervalMs)
173
+ : null;
174
+ if (messagingHealthLog) {
175
+ console.log(`[server] messaging health logging enabled interval_ms=${messagingHealthLogIntervalMs}`);
176
+ } else {
177
+ console.log('[server] messaging health logging disabled');
178
+ }
179
+
180
+ function logMessagingHealthDiagnostics(): void {
181
+ const now = Date.now();
182
+ const larkBots = larkIntegration.status();
183
+ const deliveryWebSocket = deliveryWs.statsAllConnections();
184
+ const lines = buildMessagingHealthLogLines(buildMessagingStatus({ larkBots, deliveryWebSocket }));
185
+ for (const line of lines) {
186
+ const previous = lastMessagingHealthLogAt.get(line.throttle_key) ?? 0;
187
+ if (now - previous < 5 * 60 * 1000) continue;
188
+ lastMessagingHealthLogAt.set(line.throttle_key, now);
189
+ console.warn(`[server] ${line.message}`);
190
+ }
191
+ }
192
+
193
+ function larkStatus(): Response {
194
+ return new Response(JSON.stringify({ ok: true, data: { bots: larkIntegration.status() } }), {
195
+ headers: { 'content-type': 'application/json' },
196
+ });
197
+ }
48
198
 
49
199
  async function reloadLarkIntegration(): Promise<Response> {
50
200
  const result = larkIntegration.reload();
@@ -56,17 +206,107 @@ async function reloadLarkIntegration(): Promise<Response> {
56
206
  headers: { 'content-type': 'application/json' },
57
207
  });
58
208
  }
209
+
210
+ async function restartLarkIntegration(request: Request): Promise<Response> {
211
+ let appIds: string[] | undefined;
212
+ try {
213
+ const body = request.headers.get('content-type')?.includes('application/json')
214
+ ? await request.json() as { app_id?: unknown; app_ids?: unknown }
215
+ : {};
216
+ if (typeof body.app_id === 'string' && body.app_id.trim()) {
217
+ appIds = [body.app_id.trim()];
218
+ } else if (Array.isArray(body.app_ids)) {
219
+ appIds = body.app_ids.filter((id): id is string => typeof id === 'string' && id.trim().length > 0).map((id) => id.trim());
220
+ }
221
+ } catch {
222
+ return new Response(JSON.stringify({ ok: false, error: 'invalid JSON body' }), {
223
+ status: 400,
224
+ headers: { 'content-type': 'application/json' },
225
+ });
226
+ }
227
+ const result = larkIntegration.restart(appIds);
228
+ if (result.ok && result.restarted.length > 0) {
229
+ console.log(`[server] lark restarted: ${result.restarted.join(',')}`);
230
+ }
231
+ return new Response(JSON.stringify(result.ok ? { ok: true, data: result } : { ok: false, error: result.error, data: { bots: result.bots, restarted: result.restarted, missing: result.missing } }), {
232
+ status: result.ok ? 200 : 500,
233
+ headers: { 'content-type': 'application/json' },
234
+ });
235
+ }
236
+
237
+ async function probeLarkEvent(request: Request, url: URL): Promise<Response> {
238
+ if (!isLoopbackHost(url.hostname)) {
239
+ return new Response(JSON.stringify({ ok: false, error: 'Lark probe is only available through a loopback server URL' }), {
240
+ status: 403,
241
+ headers: { 'content-type': 'application/json' },
242
+ });
243
+ }
244
+ try {
245
+ assertServerAuth(request);
246
+ } catch (err) {
247
+ const message = err instanceof Error ? err.message : 'unauthorized';
248
+ return new Response(JSON.stringify({ ok: false, error: message }), {
249
+ status: 401,
250
+ headers: { 'content-type': 'application/json' },
251
+ });
252
+ }
253
+ let body: { app_id?: unknown; envelope?: unknown; data?: unknown };
254
+ try {
255
+ body = await request.json() as { app_id?: unknown; envelope?: unknown; data?: unknown };
256
+ } catch {
257
+ return new Response(JSON.stringify({ ok: false, error: 'invalid JSON body' }), {
258
+ status: 400,
259
+ headers: { 'content-type': 'application/json' },
260
+ });
261
+ }
262
+ if (typeof body.app_id !== 'string' || !body.app_id.trim()) {
263
+ return new Response(JSON.stringify({ ok: false, error: 'app_id is required' }), {
264
+ status: 400,
265
+ headers: { 'content-type': 'application/json' },
266
+ });
267
+ }
268
+ if (body.data === undefined || body.data === null || typeof body.data !== 'object') {
269
+ return new Response(JSON.stringify({ ok: false, error: 'data object is required' }), {
270
+ status: 400,
271
+ headers: { 'content-type': 'application/json' },
272
+ });
273
+ }
274
+ try {
275
+ const result = await larkIntegration.injectProbeEvent({
276
+ appId: body.app_id,
277
+ envelope: typeof body.envelope === 'string' && body.envelope.trim() ? body.envelope : undefined,
278
+ data: body.data,
279
+ });
280
+ return new Response(JSON.stringify({ ok: true, data: result }), {
281
+ headers: { 'content-type': 'application/json' },
282
+ });
283
+ } catch (err) {
284
+ return new Response(JSON.stringify({ ok: false, error: err instanceof Error ? err.message : String(err) }), {
285
+ status: 400,
286
+ headers: { 'content-type': 'application/json' },
287
+ });
288
+ }
289
+ }
290
+
59
291
  if (larkIntegration.handles.length > 0) {
60
292
  console.log(`[server] lark integrated: ${larkIntegration.handles.map((h) => h.appId).join(',')}`);
61
293
  }
62
294
 
63
295
  process.on('SIGINT', () => {
296
+ if (larkAutoRestart) clearInterval(larkAutoRestart);
297
+ if (messagingHealthLog) clearInterval(messagingHealthLog);
298
+ clearInterval(computerConnectionCleanup);
299
+ deliveryWs.close();
64
300
  larkIntegration.stop();
65
301
  tailscaleServer?.stop();
66
302
  server.stop();
67
303
  process.exit(0);
68
304
  });
69
305
  process.on('SIGTERM', () => {
306
+ if (larkAutoRestart) clearInterval(larkAutoRestart);
307
+ if (messagingHealthLog) clearInterval(messagingHealthLog);
308
+ clearInterval(computerConnectionCleanup);
309
+ deliveryWs.close();
70
310
  larkIntegration.stop();
71
311
  tailscaleServer?.stop();
72
312
  server.stop();
package/src/token-file.ts CHANGED
@@ -5,7 +5,11 @@ import { homeDir } from './config.js';
5
5
 
6
6
  const TOKEN_BYTES = 32;
7
7
 
8
- function assertPrivateDir(path: string): void {
8
+ export interface DaemonTokenFileOptions {
9
+ platform?: NodeJS.Platform;
10
+ }
11
+
12
+ function assertPrivateDir(path: string, platform: NodeJS.Platform): void {
9
13
  if (!existsSync(path)) {
10
14
  mkdirSync(path, { recursive: true, mode: 0o700 });
11
15
  }
@@ -14,6 +18,7 @@ function assertPrivateDir(path: string): void {
14
18
  if (link.isSymbolicLink()) throw new Error(`unsafe token directory: ${path} is a symlink`);
15
19
  const stat = statSync(path);
16
20
  if (!stat.isDirectory()) throw new Error(`unsafe token directory: ${path} is not a directory`);
21
+ if (platform === 'win32') return;
17
22
  if ((stat.mode & 0o777) !== 0o700) {
18
23
  chmodSync(path, 0o700);
19
24
  const fixed = statSync(path);
@@ -21,11 +26,12 @@ function assertPrivateDir(path: string): void {
21
26
  }
22
27
  }
23
28
 
24
- function assertPrivateTokenFile(path: string): void {
29
+ function assertPrivateTokenFile(path: string, platform: NodeJS.Platform): void {
25
30
  const link = lstatSync(path);
26
31
  if (link.isSymbolicLink()) throw new Error(`unsafe token file: ${path} is a symlink`);
27
32
  const stat = statSync(path);
28
33
  if (!stat.isFile()) throw new Error(`unsafe token file: ${path} is not a regular file`);
34
+ if (platform === 'win32') return;
29
35
  if ((stat.mode & 0o177) !== 0) throw new Error(`unsafe token file: ${path} permissions must be 0600`);
30
36
  }
31
37
 
@@ -38,12 +44,13 @@ export function legacyTokenPath(): string {
38
44
  return join(homeDir(), 'daemon-token');
39
45
  }
40
46
 
41
- export function loadOrCreateDaemonToken(path = defaultTokenPath()): string {
47
+ export function loadOrCreateDaemonToken(path = defaultTokenPath(), options: DaemonTokenFileOptions = {}): string {
48
+ const platform = options.platform ?? process.platform;
42
49
  const resolved = resolve(path);
43
- assertPrivateDir(dirname(resolved));
50
+ assertPrivateDir(dirname(resolved), platform);
44
51
 
45
52
  if (existsSync(resolved)) {
46
- assertPrivateTokenFile(resolved);
53
+ assertPrivateTokenFile(resolved, platform);
47
54
  const token = readFileSync(resolved, 'utf8').trim();
48
55
  if (!token) throw new Error(`empty token file: ${resolved}`);
49
56
  return token;
@@ -52,6 +59,6 @@ export function loadOrCreateDaemonToken(path = defaultTokenPath()): string {
52
59
  const token = crypto.getRandomValues(new Uint8Array(TOKEN_BYTES)).reduce((value, byte) => value + byte.toString(16).padStart(2, '0'), '');
53
60
  const fd = openSync(resolved, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY, 0o600);
54
61
  writeFileSync(fd, `${token}\n`);
55
- assertPrivateTokenFile(resolved);
62
+ assertPrivateTokenFile(resolved, platform);
56
63
  return token;
57
64
  }