@controlflow-ai/daemon 0.1.0

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 (67) hide show
  1. package/README.md +360 -0
  2. package/bin/console.js +2 -0
  3. package/bin/daemon.js +2 -0
  4. package/bin/pal.js +2 -0
  5. package/bin/server.js +2 -0
  6. package/package.json +31 -0
  7. package/src/agent-runtime.ts +285 -0
  8. package/src/app.ts +745 -0
  9. package/src/args.ts +54 -0
  10. package/src/artifacts.ts +85 -0
  11. package/src/cli.ts +284 -0
  12. package/src/client.ts +310 -0
  13. package/src/coco.ts +52 -0
  14. package/src/codex.ts +41 -0
  15. package/src/coding-agent-runtime.ts +20 -0
  16. package/src/config.ts +106 -0
  17. package/src/console.ts +349 -0
  18. package/src/daemon-client.ts +91 -0
  19. package/src/daemon.ts +580 -0
  20. package/src/db.ts +2830 -0
  21. package/src/failure-message.ts +17 -0
  22. package/src/format.ts +13 -0
  23. package/src/http.ts +55 -0
  24. package/src/lark/agent-runtime.ts +142 -0
  25. package/src/lark/cli.ts +549 -0
  26. package/src/lark/credentials.ts +105 -0
  27. package/src/lark/daemon-integration.ts +108 -0
  28. package/src/lark/dispatcher.ts +374 -0
  29. package/src/lark/event-router.ts +329 -0
  30. package/src/lark/inbound-events.ts +131 -0
  31. package/src/lark/server-integration.ts +445 -0
  32. package/src/lark/setup.ts +326 -0
  33. package/src/lark/ws-daemon.ts +224 -0
  34. package/src/lark-fixture-diagnostics.ts +56 -0
  35. package/src/lark-fixture.ts +277 -0
  36. package/src/local-api.ts +155 -0
  37. package/src/local-auth.ts +45 -0
  38. package/src/migrations/001_initial.ts +61 -0
  39. package/src/migrations/002_daemon_deliveries.ts +52 -0
  40. package/src/migrations/003_sessions_runs.ts +49 -0
  41. package/src/migrations/004_message_idempotency.ts +21 -0
  42. package/src/migrations/005_artifacts.ts +24 -0
  43. package/src/migrations/006_lark_channel_foundation.ts +119 -0
  44. package/src/migrations/007_agents_a0.ts +17 -0
  45. package/src/migrations/008_b0_chat_history.ts +31 -0
  46. package/src/migrations/009_b0_transcript_ingest_seq.ts +35 -0
  47. package/src/migrations/010_b0_transcript_shadow_external_ids.ts +32 -0
  48. package/src/migrations/011_b0_channel_conversation_audit_only.ts +27 -0
  49. package/src/migrations/012_b0_cross_conversation_invariant.ts +45 -0
  50. package/src/migrations/013_b1_0_eng_inbound_raw_events.ts +56 -0
  51. package/src/migrations/014_agents_runtime.ts +10 -0
  52. package/src/migrations/015_agent_runtime_sessions.ts +15 -0
  53. package/src/migrations/016_room_participants.ts +27 -0
  54. package/src/migrations/017_unified_room_delivery.ts +203 -0
  55. package/src/migrations/018_room_display_names.ts +36 -0
  56. package/src/migrations/019_computer_connections.ts +63 -0
  57. package/src/migrations/020_computer_agent_assignments.ts +20 -0
  58. package/src/migrations/021_provider_identity_bindings.ts +32 -0
  59. package/src/migrations.ts +85 -0
  60. package/src/neeko.ts +23 -0
  61. package/src/provider-identity.ts +40 -0
  62. package/src/runtime-registry.ts +41 -0
  63. package/src/server-auth.ts +13 -0
  64. package/src/server.ts +63 -0
  65. package/src/token-file.ts +57 -0
  66. package/src/types.ts +408 -0
  67. package/src/web.ts +565 -0
package/src/args.ts ADDED
@@ -0,0 +1,54 @@
1
+ export interface ParsedArgs {
2
+ command: string;
3
+ values: string[];
4
+ flags: Record<string, string | boolean>;
5
+ }
6
+
7
+ export function parseArgs(argv = process.argv.slice(2)): ParsedArgs {
8
+ const [command = 'help', ...rest] = argv;
9
+ const values: string[] = [];
10
+ const flags: Record<string, string | boolean> = {};
11
+
12
+ for (let index = 0; index < rest.length; index += 1) {
13
+ const arg = rest[index];
14
+ if (!arg.startsWith('--')) {
15
+ values.push(arg);
16
+ continue;
17
+ }
18
+
19
+ const raw = arg.slice(2);
20
+ const eq = raw.indexOf('=');
21
+ if (eq >= 0) {
22
+ flags[raw.slice(0, eq)] = raw.slice(eq + 1);
23
+ continue;
24
+ }
25
+
26
+ const next = rest[index + 1];
27
+ if (next && !next.startsWith('--')) {
28
+ flags[raw] = next;
29
+ index += 1;
30
+ } else {
31
+ flags[raw] = true;
32
+ }
33
+ }
34
+
35
+ return { command, values, flags };
36
+ }
37
+
38
+ export function flag(flags: Record<string, string | boolean>, name: string, fallback?: string): string | undefined {
39
+ const value = flags[name];
40
+ if (typeof value === 'string') return value;
41
+ return fallback;
42
+ }
43
+
44
+ export function boolFlag(flags: Record<string, string | boolean>, name: string): boolean {
45
+ return flags[name] === true || flags[name] === 'true';
46
+ }
47
+
48
+ export function numberFlag(flags: Record<string, string | boolean>, name: string, fallback?: number): number | undefined {
49
+ const value = flag(flags, name);
50
+ if (value === undefined) return fallback;
51
+ const parsed = Number(value);
52
+ if (!Number.isFinite(parsed)) throw new Error(`--${name} must be a number`);
53
+ return parsed;
54
+ }
@@ -0,0 +1,85 @@
1
+ import { createHash, randomBytes } from 'node:crypto';
2
+ import { HttpError } from './http.js';
3
+
4
+ export const MAX_ARTIFACT_BYTES = 10 * 1024 * 1024;
5
+ export const DEFAULT_TTL_SECONDS = 24 * 60 * 60;
6
+ export const MAX_TTL_SECONDS = 7 * 24 * 60 * 60;
7
+
8
+ const ALLOWED_MIME_TYPES = new Set(['text/html', 'text/plain', 'application/json']);
9
+
10
+ export function generateArtifactToken(): string {
11
+ return randomBytes(32).toString('hex');
12
+ }
13
+
14
+ export function hashArtifactToken(token: string): string {
15
+ return createHash('sha256').update(token).digest('hex');
16
+ }
17
+
18
+ export function artifactExpiry(ttlSeconds?: number): string {
19
+ const ttl = ttlSeconds ?? DEFAULT_TTL_SECONDS;
20
+ if (!Number.isFinite(ttl) || ttl <= 0) throw new HttpError(400, 'BAD_TTL', 'ttl_seconds must be positive');
21
+ if (ttl > MAX_TTL_SECONDS) throw new HttpError(400, 'TTL_TOO_LONG', 'ttl_seconds exceeds maximum');
22
+ return new Date(Date.now() + ttl * 1000).toISOString();
23
+ }
24
+
25
+ function looksLikeSvg(content: Uint8Array): boolean {
26
+ const prefix = new TextDecoder().decode(content.slice(0, Math.min(content.length, 512))).trimStart().toLowerCase();
27
+ return prefix.startsWith('<svg') || prefix.includes('<svg ');
28
+ }
29
+
30
+ function looksLikeHtml(content: Uint8Array): boolean {
31
+ const prefix = new TextDecoder().decode(content.slice(0, Math.min(content.length, 512))).trimStart().toLowerCase();
32
+ return prefix.startsWith('<!doctype html') || prefix.startsWith('<html') || prefix.includes('<script') || prefix.includes('<body');
33
+ }
34
+
35
+ function looksLikeJson(content: Uint8Array): boolean {
36
+ const value = new TextDecoder().decode(content).trim();
37
+ if (!value) return false;
38
+ try {
39
+ JSON.parse(value);
40
+ return true;
41
+ } catch {
42
+ return false;
43
+ }
44
+ }
45
+
46
+ export function validateArtifactContent(mimeType: string, content: Uint8Array): void {
47
+ if (!ALLOWED_MIME_TYPES.has(mimeType)) throw new HttpError(400, 'UNSUPPORTED_MIME', 'unsupported artifact MIME type');
48
+ if (content.length === 0) throw new HttpError(400, 'EMPTY_ARTIFACT', 'artifact content is required');
49
+ if (content.length > MAX_ARTIFACT_BYTES) throw new HttpError(400, 'ARTIFACT_TOO_LARGE', 'artifact exceeds size limit');
50
+ if (looksLikeSvg(content)) throw new HttpError(400, 'SVG_NOT_SUPPORTED', 'SVG artifacts are not supported');
51
+
52
+ if (mimeType === 'application/json' && !looksLikeJson(content)) {
53
+ throw new HttpError(400, 'MIME_CONFLICT', 'declared JSON artifact is not valid JSON');
54
+ }
55
+
56
+ if (mimeType === 'text/plain' && looksLikeHtml(content)) {
57
+ throw new HttpError(400, 'MIME_CONFLICT', 'HTML content must be declared as text/html');
58
+ }
59
+ }
60
+
61
+ export function artifactHeaders(): HeadersInit {
62
+ return {
63
+ 'content-type': 'text/html; charset=utf-8',
64
+ 'content-security-policy': "sandbox allow-scripts; default-src 'none'; script-src 'none'; connect-src 'none'; img-src data: blob:; style-src 'none'; form-action 'none'; base-uri 'none'; frame-ancestors 'none'",
65
+ 'x-content-type-options': 'nosniff',
66
+ 'referrer-policy': 'no-referrer',
67
+ 'cache-control': 'private, no-store',
68
+ };
69
+ }
70
+
71
+ function escapeHtml(value: string): string {
72
+ return value.replace(/[&<>"']/g, (char) => ({
73
+ '&': '&amp;',
74
+ '<': '&lt;',
75
+ '>': '&gt;',
76
+ '"': '&quot;',
77
+ "'": '&#39;',
78
+ }[char]!));
79
+ }
80
+
81
+ export function artifactViewerHtml(content: Uint8Array, mimeType: string): string {
82
+ const text = new TextDecoder().decode(content);
83
+ const srcdoc = mimeType === 'text/html' ? text : `<pre>${escapeHtml(text)}</pre>`;
84
+ return `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Artifact</title><style>html,body,iframe{width:100%;height:100%;margin:0;border:0}</style></head><body><iframe sandbox="allow-scripts" referrerpolicy="no-referrer" srcdoc="${escapeHtml(srcdoc)}"></iframe></body></html>`;
85
+ }
package/src/cli.ts ADDED
@@ -0,0 +1,284 @@
1
+ #!/usr/bin/env bun
2
+ import { readFileSync, statSync } from 'node:fs';
3
+ import { parseArgs, flag, numberFlag } from './args.js';
4
+ import { DaemonClient } from './daemon-client.js';
5
+ import { defaultDaemonUrl } from './config.js';
6
+ import { formatMessages } from './format.js';
7
+ import { sanitizeProviderIds } from './provider-identity.js';
8
+
9
+ function usage(): string {
10
+ return `pal cli (agent CLI)
11
+
12
+ Usage:
13
+ bun run src/cli.ts send --room general --from alice [--to codex] [--channel <channel-id>] <message>
14
+ bun run src/cli.ts topics create --room general <name>
15
+ bun run src/cli.ts rooms invite --room general --agent codex [--mode mentions|all|periodic|muted|off]
16
+ bun run src/cli.ts debate start --chat general --a palbeta --b clawed [--turns 6] <topic>
17
+ bun run src/cli.ts rooms list [--json]
18
+ bun run src/cli.ts rooms members --room <room-id-or-name> [--json]
19
+ bun run src/cli.ts messages list [--room general] [--topic 1] [--after 0] [--limit 50] [--q text] [--json]
20
+ bun run src/cli.ts messages show <message-id> [--json]
21
+ bun run src/cli.ts runs [--json]
22
+ bun run src/cli.ts run-action <run-id> kill|restart
23
+ bun run src/cli.ts chats [--json]
24
+ bun run src/cli.ts health
25
+ bun run src/cli.ts http file <path> [--mime <type>] [--title <title>] [--ttl <seconds>]
26
+
27
+ Environment:
28
+ LOCK_DAEMON_URL=${defaultDaemonUrl()}
29
+ LOCK_DAEMON_TOKEN=<token>
30
+ `;
31
+ }
32
+
33
+ async function readStdin(): Promise<string> {
34
+ const chunks: Uint8Array[] = [];
35
+ for await (const chunk of Bun.stdin.stream()) {
36
+ chunks.push(chunk);
37
+ }
38
+ return Buffer.concat(chunks).toString('utf8');
39
+ }
40
+
41
+ function positionalContent(values: string[]): string {
42
+ return values.join(' ').trim();
43
+ }
44
+
45
+ function printJson(value: unknown): void {
46
+ console.log(JSON.stringify(value, null, 2));
47
+ }
48
+
49
+ function looksLikeId(value: string): boolean {
50
+ return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value);
51
+ }
52
+
53
+ function buildDebateOpening(input: { topic: string; firstAgent: string; secondAgent: string; turns: number; chat: string }): string {
54
+ return [
55
+ 'Start an agent-to-agent debate in this room.',
56
+ `Debate topic: ${input.topic}`,
57
+ `Participants: @${input.firstAgent} and @${input.secondAgent}.`,
58
+ `Turn budget: ${input.turns} total agent turns.`,
59
+ `You are @${input.firstAgent}. Make the first argument, then send the next turn to @${input.secondAgent} with: bun run src/cli.ts send --chat ${input.chat} --from ${input.firstAgent} --to ${input.secondAgent} <message>`,
60
+ `Every later turn should address the other agent with --to so their daemon runtime is triggered. Stop naturally when the turn budget is reached.`,
61
+ ].join('\n');
62
+ }
63
+
64
+ async function main(): Promise<void> {
65
+ const args = parseArgs();
66
+ const daemonClient = new DaemonClient(flag(args.flags, 'daemon'));
67
+
68
+ if (args.command === 'help' || args.flags.help) {
69
+ console.log(usage());
70
+ return;
71
+ }
72
+
73
+ if (args.command === 'health') {
74
+ printJson(await daemonClient.health());
75
+ return;
76
+ }
77
+
78
+ if (args.command === 'chats') {
79
+ const chats = await daemonClient.listChats();
80
+ if (args.flags.json) {
81
+ printJson(chats);
82
+ } else {
83
+ for (const chat of chats) {
84
+ console.log(`#${chat.name} messages=${chat.message_count} last=${chat.last_message_at ?? '-'}`);
85
+ }
86
+ }
87
+ return;
88
+ }
89
+
90
+ if (args.command === 'rooms') {
91
+ const sub = args.values[0] ?? 'list';
92
+ if (sub === 'list') {
93
+ const rooms = await daemonClient.listRooms();
94
+ if (args.flags.json) {
95
+ printJson(rooms);
96
+ } else {
97
+ for (const room of rooms) {
98
+ console.log(`${room.id} #${room.name} kind=${room.kind} messages=${room.message_count} last=${room.last_message_at ?? '-'}`);
99
+ }
100
+ }
101
+ return;
102
+ }
103
+ if (sub === 'invite') {
104
+ const roomRef = flag(args.flags, 'room') ?? args.values[1];
105
+ const agent = flag(args.flags, 'agent') ?? args.values[2];
106
+ if (!roomRef) throw new Error('rooms invite requires --room <room-id-or-name>');
107
+ if (!agent) throw new Error('rooms invite requires --agent <agent-key>');
108
+ const result = await daemonClient.inviteAgentToRoom(roomRef, { agent, mode: flag(args.flags, 'mode') as never });
109
+ printJson(result);
110
+ return;
111
+ }
112
+ if (sub === 'members') {
113
+ const roomRef = flag(args.flags, 'room') ?? args.values[1];
114
+ if (!roomRef) throw new Error('rooms members requires --room <room-id-or-name>');
115
+ const result = await daemonClient.listRoomMembers(roomRef);
116
+ if (args.flags.json) {
117
+ printJson(result);
118
+ } else {
119
+ console.log(`#${result.room.name} (${result.room.id})`);
120
+ for (const participant of result.participants) {
121
+ const name = participant.display_name ? ` ${sanitizeProviderIds(participant.display_name)}` : '';
122
+ console.log(`${participant.kind.padEnd(5)} ${sanitizeProviderIds(participant.participant_id)}${name} source=${participant.source} last=${participant.last_seen_at}`);
123
+ }
124
+ console.log(`completeness: ${result.completeness}`);
125
+ }
126
+ return;
127
+ }
128
+ throw new Error(`unknown rooms subcommand: ${sub}`);
129
+ }
130
+
131
+ if (args.command === 'runs') {
132
+ const runs = await daemonClient.listRuns();
133
+ if (args.flags.json) {
134
+ printJson(runs);
135
+ } else {
136
+ for (const run of runs) {
137
+ console.log(`${run.id} status=${run.status} agent=${run.agent} message=${run.message_id} attempt=${run.attempt} pid=${run.pid ?? '-'}`);
138
+ }
139
+ }
140
+ return;
141
+ }
142
+
143
+ if (args.command === 'run-action') {
144
+ const [runId, action] = args.values;
145
+ if (!runId) throw new Error('run id is required');
146
+ if (action !== 'kill' && action !== 'restart') throw new Error('action must be kill or restart');
147
+ const run = await daemonClient.requestRunAction(runId, action);
148
+ printJson(run);
149
+ return;
150
+ }
151
+
152
+ if (args.command === 'debate') {
153
+ const sub = args.values[0];
154
+ if (sub !== 'start') throw new Error('usage: debate start --chat <room> --a <agent> --b <agent> [--turns N] <topic>');
155
+ const firstAgent = flag(args.flags, 'a') ?? flag(args.flags, 'agent-a');
156
+ const secondAgent = flag(args.flags, 'b') ?? flag(args.flags, 'agent-b');
157
+ const chat = flag(args.flags, 'chat') ?? 'general';
158
+ const turns = numberFlag(args.flags, 'turns', 6)!;
159
+ const topic = args.values.slice(1).join(' ').trim() || flag(args.flags, 'topic');
160
+ if (!firstAgent) throw new Error('debate start requires --a <agent>');
161
+ if (!secondAgent) throw new Error('debate start requires --b <agent>');
162
+ if (firstAgent === secondAgent) throw new Error('debate agents must be different');
163
+ if (!Number.isInteger(turns) || turns < 2) throw new Error('debate --turns must be an integer >= 2');
164
+ if (!topic) throw new Error('debate start requires a topic');
165
+
166
+ const message = await daemonClient.sendMessage({
167
+ chat,
168
+ sender: 'debate',
169
+ recipient: firstAgent,
170
+ content: buildDebateOpening({ topic, firstAgent, secondAgent, turns, chat }),
171
+ });
172
+
173
+ if (args.flags.json) {
174
+ printJson(message);
175
+ } else {
176
+ console.log(formatMessages([message]));
177
+ }
178
+ return;
179
+ }
180
+
181
+ if (args.command === 'topics') {
182
+ const sub = args.values[0];
183
+ if (sub !== 'create') throw new Error('usage: topics create --room <room> <name>');
184
+ const room = flag(args.flags, 'room');
185
+ const name = args.values.slice(1).join(' ').trim() || flag(args.flags, 'name');
186
+ if (!room) throw new Error('topics create requires --room <room>');
187
+ if (!name) throw new Error('topics create requires a topic name');
188
+ const channel = await daemonClient.createTopic(room, { name, created_by: flag(args.flags, 'from') ?? flag(args.flags, 'sender') ?? process.env.USER ?? 'human' });
189
+ if (args.flags.json) printJson(channel);
190
+ else console.log(`${channel.id} topic=${channel.name} room=${channel.room_id}`);
191
+ return;
192
+ }
193
+
194
+ if (args.command === 'http') {
195
+ const [kind, filePath] = args.values;
196
+ if (kind !== 'file') throw new Error('usage: http file <path>');
197
+ if (!filePath) throw new Error('file path is required');
198
+ const stat = statSync(filePath);
199
+ if (!stat.isFile()) throw new Error('path must be a file');
200
+ const bytes = readFileSync(filePath);
201
+ const mimeType = flag(args.flags, 'mime') ?? (filePath.endsWith('.html') ? 'text/html' : 'text/plain');
202
+ const artifact = await daemonClient.createArtifact({
203
+ content_base64: Buffer.from(bytes).toString('base64'),
204
+ mime_type: mimeType,
205
+ filename: filePath.split('/').pop(),
206
+ title: flag(args.flags, 'title'),
207
+ ttl_seconds: numberFlag(args.flags, 'ttl'),
208
+ });
209
+ printJson(artifact);
210
+ return;
211
+ }
212
+
213
+ if (args.command === 'send') {
214
+ const file = flag(args.flags, 'file');
215
+ const inlineContent = positionalContent(args.values);
216
+ const fileContent = !inlineContent && file ? readFileSync(file, 'utf8').trim() : '';
217
+ const stdinContent = !inlineContent && !fileContent && !process.stdin.isTTY ? (await readStdin()).trim() : '';
218
+ const content = inlineContent || fileContent || stdinContent;
219
+ const sender = flag(args.flags, 'from') ?? flag(args.flags, 'sender') ?? process.env.USER ?? 'human';
220
+ if (!content) throw new Error('message content is required');
221
+
222
+ const message = await daemonClient.sendMessage({
223
+ chat: flag(args.flags, 'room') ?? flag(args.flags, 'chat') ?? 'general',
224
+ room: flag(args.flags, 'room'),
225
+ parent_id: numberFlag(args.flags, 'parent'),
226
+ channel_id: flag(args.flags, 'channel') ?? flag(args.flags, 'channel-id') ?? null,
227
+ sender,
228
+ recipient: flag(args.flags, 'to') ?? flag(args.flags, 'recipient'),
229
+ content,
230
+ });
231
+
232
+ if (args.flags.json) {
233
+ printJson(message);
234
+ } else {
235
+ console.log(formatMessages([message]));
236
+ }
237
+ return;
238
+ }
239
+
240
+ if (args.command === 'messages') {
241
+ const sub = args.values[0];
242
+ if (sub === 'show') {
243
+ const id = Number(args.values[1]);
244
+ if (!Number.isFinite(id)) throw new Error('messages show requires <message-id>');
245
+ const message = await daemonClient.getMessage(id);
246
+ if (args.flags.json) {
247
+ printJson(message);
248
+ } else {
249
+ console.log(formatMessages([message]));
250
+ }
251
+ return;
252
+ }
253
+
254
+ const params = new URLSearchParams();
255
+ const room = flag(args.flags, 'room');
256
+ const chat = flag(args.flags, 'chat') ?? (room && !looksLikeId(room) ? room : undefined);
257
+ const chatId = flag(args.flags, 'chat-id') ?? (room && looksLikeId(room) ? room : undefined);
258
+ const q = flag(args.flags, 'q');
259
+ if (chat) params.set('chat', chat);
260
+ if (chatId) params.set('chat_id', chatId);
261
+ if (q) params.set('q', q);
262
+ const parent = numberFlag(args.flags, 'topic') ?? numberFlag(args.flags, 'parent');
263
+ const after = numberFlag(args.flags, 'after');
264
+ const limit = numberFlag(args.flags, 'limit', 50);
265
+ if (parent !== undefined) params.set('parent_id', String(parent));
266
+ if (after !== undefined) params.set('after', String(after));
267
+ if (limit !== undefined) params.set('limit', String(limit));
268
+
269
+ const messages = await daemonClient.getMessages(params);
270
+ if (args.flags.json) {
271
+ printJson(messages);
272
+ } else {
273
+ console.log(formatMessages(messages));
274
+ }
275
+ return;
276
+ }
277
+
278
+ throw new Error(`unknown command: ${args.command}\n\n${usage()}`);
279
+ }
280
+
281
+ main().catch((error) => {
282
+ console.error(error instanceof Error ? error.message : String(error));
283
+ process.exit(1);
284
+ });