@controlflow-ai/daemon 0.1.1 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/README.md +66 -24
  2. package/package.json +16 -3
  3. package/src/agent-avatar.ts +30 -0
  4. package/src/agent-key.ts +28 -0
  5. package/src/agent-permissions.ts +359 -0
  6. package/src/agent-runtime.ts +810 -28
  7. package/src/agent-workspace.ts +183 -0
  8. package/src/app.ts +2183 -79
  9. package/src/args.ts +54 -7
  10. package/src/cli.ts +873 -14
  11. package/src/client.ts +482 -12
  12. package/src/coco.ts +9 -40
  13. package/src/codex.ts +33 -5
  14. package/src/config.ts +28 -4
  15. package/src/console.ts +460 -26
  16. package/src/daemon-client.ts +116 -3
  17. package/src/daemon.ts +958 -101
  18. package/src/db.ts +3216 -113
  19. package/src/delivery-ws.ts +269 -0
  20. package/src/format.ts +4 -1
  21. package/src/lark/app-registration.ts +141 -0
  22. package/src/lark/cli.ts +7 -137
  23. package/src/lark/credentials.ts +36 -3
  24. package/src/lark/event-router.ts +61 -5
  25. package/src/lark/inbound-events.ts +156 -3
  26. package/src/lark/server-integration.ts +659 -111
  27. package/src/lark/setup.ts +74 -5
  28. package/src/lark/ws-daemon.ts +136 -10
  29. package/src/local-api.ts +611 -14
  30. package/src/local-auth.ts +36 -3
  31. package/src/message-attachments.ts +71 -0
  32. package/src/messaging-cli.ts +741 -0
  33. package/src/messaging-status.ts +669 -0
  34. package/src/migrations/023_projects.ts +65 -0
  35. package/src/migrations/024_agents_model.ts +10 -0
  36. package/src/migrations/025_room_archive.ts +44 -0
  37. package/src/migrations/026_project_archive.ts +44 -0
  38. package/src/migrations/027_agent_permission_profiles.ts +16 -0
  39. package/src/migrations/028_lark_websocket_restart_state.ts +16 -0
  40. package/src/migrations/029_held_message_drafts.ts +32 -0
  41. package/src/migrations/030_agent_room_read_state.ts +25 -0
  42. package/src/migrations/031_room_tasks.ts +29 -0
  43. package/src/migrations/032_room_reminders.ts +29 -0
  44. package/src/migrations/033_room_saved_messages.ts +25 -0
  45. package/src/migrations/034_agent_activity_events.ts +27 -0
  46. package/src/migrations/035_agent_avatars.ts +17 -0
  47. package/src/migrations/036_project_agent_defaults.ts +21 -0
  48. package/src/migrations/037_message_attachments.ts +36 -0
  49. package/src/migrations/038_agent_activity_room_scope.ts +64 -0
  50. package/src/migrations/039_message_attachments_path.ts +34 -0
  51. package/src/migrations/040_message_attachments_file_schema.ts +80 -0
  52. package/src/migrations/041_room_system_events.ts +30 -0
  53. package/src/migrations/042_message_attachment_file_kind.ts +52 -0
  54. package/src/migrations/043_room_mode_skill_registry.ts +92 -0
  55. package/src/migrations/044_workflow_runtime.ts +69 -0
  56. package/src/migrations/045_skill_repository_ownership.ts +64 -0
  57. package/src/migrations.ts +70 -1
  58. package/src/neeko.ts +40 -4
  59. package/src/runtime-env.ts +179 -0
  60. package/src/runtime-registry.ts +83 -13
  61. package/src/server.ts +244 -4
  62. package/src/token-file.ts +13 -6
  63. package/src/types.ts +394 -0
  64. package/src/workflow-runtime.ts +275 -0
  65. package/src/web.ts +0 -904
@@ -3,23 +3,19 @@ import type { AgentRuntime, AgentRuntimeProtocol } from './agent-runtime.js';
3
3
  const supportedProtocols = new Set<AgentRuntimeProtocol>(['json-stream', 'acp']);
4
4
 
5
5
  const runtimeFactories = {
6
- codex: async (agentUuid: string): Promise<AgentRuntime> => {
6
+ codex: async (agentUuid: string, model: string | null): Promise<AgentRuntime> => {
7
7
  const { makeCodexRuntime } = await import('./codex.js');
8
- return makeCodexRuntime(agentUuid);
8
+ return makeCodexRuntime(agentUuid, model);
9
9
  },
10
- coco: async (agentUuid: string): Promise<AgentRuntime> => {
10
+ coco: async (agentUuid: string, model: string | null): Promise<AgentRuntime> => {
11
11
  const { makeCocoRuntime } = await import('./coco.js');
12
- return makeCocoRuntime(agentUuid);
12
+ return makeCocoRuntime(agentUuid, model);
13
13
  },
14
- 'coco-stream-json': async (agentUuid: string): Promise<AgentRuntime> => {
15
- const { makeCocoStreamJsonRuntime } = await import('./coco.js');
16
- return makeCocoStreamJsonRuntime(agentUuid);
17
- },
18
- neeko: async (agentUuid: string): Promise<AgentRuntime> => {
14
+ neeko: async (agentUuid: string, model: string | null): Promise<AgentRuntime> => {
19
15
  const { makeNeekoRuntime } = await import('./neeko.js');
20
- return makeNeekoRuntime(agentUuid);
16
+ return makeNeekoRuntime(agentUuid, model);
21
17
  },
22
- } satisfies Record<string, (agentUuid: string) => Promise<AgentRuntime>>;
18
+ } satisfies Record<string, (agentUuid: string, model: string | null) => Promise<AgentRuntime>>;
23
19
 
24
20
  export type RuntimeName = keyof typeof runtimeFactories;
25
21
 
@@ -27,13 +23,87 @@ export function knownRuntimeNames(): RuntimeName[] {
27
23
  return Object.keys(runtimeFactories) as RuntimeName[];
28
24
  }
29
25
 
30
- export async function resolveRuntimeDriver(runtimeName: string, agentUuid: string): Promise<AgentRuntime> {
26
+ export interface RuntimeModelLookup {
27
+ models: string[];
28
+ error: string | null;
29
+ }
30
+
31
+ function modelCommand(runtimeName: string): { command: string; args: string[] } {
32
+ if (runtimeName === 'codex') return { command: 'codex', args: ['debug', 'models'] };
33
+ if (runtimeName === 'coco') return { command: 'coco', args: ['models'] };
34
+ if (runtimeName === 'neeko') return { command: 'neeko', args: ['models'] };
35
+ throw new Error(`Unsupported runtime '${runtimeName}'. Supported runtimes: ${knownRuntimeNames().join(', ')}.`);
36
+ }
37
+
38
+ function stripAnsi(value: string): string {
39
+ return value.replace(/\x1B\[[0-?]*[ -/]*[@-~]/g, '');
40
+ }
41
+
42
+ function uniqueNonEmpty(values: string[]): string[] {
43
+ return Array.from(new Set(values.map((value) => value.trim()).filter(Boolean)));
44
+ }
45
+
46
+ function parseModelOutput(runtimeName: string, stdout: string): string[] {
47
+ const clean = stripAnsi(stdout).trim();
48
+ if (!clean) return [];
49
+ if (runtimeName === 'codex') {
50
+ const parsed = JSON.parse(clean) as { models?: Array<{ slug?: unknown; id?: unknown; name?: unknown }> };
51
+ return uniqueNonEmpty((parsed.models ?? []).map((model) => String(model.slug ?? model.id ?? model.name ?? '')));
52
+ }
53
+ return uniqueNonEmpty(clean.split(/\r?\n/).filter((line) => !/^\s*(available\s+)?models\s*:?\s*$/i.test(line)));
54
+ }
55
+
56
+ export async function runtimeModelOptions(runtimeName: string): Promise<string[]> {
57
+ const runtime = runtimeName.trim();
58
+ modelCommand(runtime);
59
+ const { command, args } = modelCommand(runtime);
60
+ const proc = Bun.spawn([command, ...args], {
61
+ stdout: 'pipe',
62
+ stderr: 'pipe',
63
+ env: process.env,
64
+ });
65
+ const [stdout, stderr, exitCode] = await Promise.all([
66
+ new Response(proc.stdout).text(),
67
+ new Response(proc.stderr).text(),
68
+ proc.exited,
69
+ ]);
70
+ if (exitCode !== 0) {
71
+ const detail = stderr.trim() || stdout.trim() || `exit ${exitCode}`;
72
+ throw new Error(`Could not load models for runtime '${runtime}': ${detail}`);
73
+ }
74
+ return parseModelOutput(runtime, stdout);
75
+ }
76
+
77
+ export async function lookupRuntimeModels(runtimeName: string): Promise<RuntimeModelLookup> {
78
+ try {
79
+ return { models: await runtimeModelOptions(runtimeName), error: null };
80
+ } catch (error) {
81
+ return { models: [], error: error instanceof Error ? error.message : String(error) };
82
+ }
83
+ }
84
+
85
+ export async function allRuntimeModelOptions(): Promise<Record<RuntimeName, RuntimeModelLookup>> {
86
+ const entries = await Promise.all(knownRuntimeNames().map(async (name) => [name, await lookupRuntimeModels(name)] as const));
87
+ return Object.fromEntries(entries) as Record<RuntimeName, RuntimeModelLookup>;
88
+ }
89
+
90
+ export async function validateRuntimeModel(runtimeName: string, model: string | null | undefined): Promise<string | null> {
91
+ const trimmed = model?.trim() || null;
92
+ if (!trimmed) return null;
93
+ const options = await runtimeModelOptions(runtimeName);
94
+ if (!options.includes(trimmed)) {
95
+ throw new Error(`Unsupported model '${trimmed}' for runtime '${runtimeName}'. Supported models: ${options.join(', ')}.`);
96
+ }
97
+ return trimmed;
98
+ }
99
+
100
+ export async function resolveRuntimeDriver(runtimeName: string, agentUuid: string, model?: string | null): Promise<AgentRuntime> {
31
101
  const factory = runtimeFactories[runtimeName as RuntimeName];
32
102
  if (!factory) {
33
103
  throw new Error(`Unsupported runtime '${runtimeName}'. Supported runtimes: ${knownRuntimeNames().join(', ')}.`);
34
104
  }
35
105
 
36
- const driver = await factory(agentUuid);
106
+ const driver = await factory(agentUuid, model?.trim() || null);
37
107
  if (!supportedProtocols.has(driver.capabilities.protocol)) {
38
108
  throw new Error(`Runtime '${runtimeName}' uses unsupported protocol '${driver.capabilities.protocol}'. Supported protocols: ${Array.from(supportedProtocols).join(', ')}.`);
39
109
  }
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
  }