@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.
- package/README.md +360 -0
- package/bin/console.js +2 -0
- package/bin/daemon.js +2 -0
- package/bin/pal.js +2 -0
- package/bin/server.js +2 -0
- package/package.json +31 -0
- package/src/agent-runtime.ts +285 -0
- package/src/app.ts +745 -0
- package/src/args.ts +54 -0
- package/src/artifacts.ts +85 -0
- package/src/cli.ts +284 -0
- package/src/client.ts +310 -0
- package/src/coco.ts +52 -0
- package/src/codex.ts +41 -0
- package/src/coding-agent-runtime.ts +20 -0
- package/src/config.ts +106 -0
- package/src/console.ts +349 -0
- package/src/daemon-client.ts +91 -0
- package/src/daemon.ts +580 -0
- package/src/db.ts +2830 -0
- package/src/failure-message.ts +17 -0
- package/src/format.ts +13 -0
- package/src/http.ts +55 -0
- package/src/lark/agent-runtime.ts +142 -0
- package/src/lark/cli.ts +549 -0
- package/src/lark/credentials.ts +105 -0
- package/src/lark/daemon-integration.ts +108 -0
- package/src/lark/dispatcher.ts +374 -0
- package/src/lark/event-router.ts +329 -0
- package/src/lark/inbound-events.ts +131 -0
- package/src/lark/server-integration.ts +445 -0
- package/src/lark/setup.ts +326 -0
- package/src/lark/ws-daemon.ts +224 -0
- package/src/lark-fixture-diagnostics.ts +56 -0
- package/src/lark-fixture.ts +277 -0
- package/src/local-api.ts +155 -0
- package/src/local-auth.ts +45 -0
- package/src/migrations/001_initial.ts +61 -0
- package/src/migrations/002_daemon_deliveries.ts +52 -0
- package/src/migrations/003_sessions_runs.ts +49 -0
- package/src/migrations/004_message_idempotency.ts +21 -0
- package/src/migrations/005_artifacts.ts +24 -0
- package/src/migrations/006_lark_channel_foundation.ts +119 -0
- package/src/migrations/007_agents_a0.ts +17 -0
- package/src/migrations/008_b0_chat_history.ts +31 -0
- package/src/migrations/009_b0_transcript_ingest_seq.ts +35 -0
- package/src/migrations/010_b0_transcript_shadow_external_ids.ts +32 -0
- package/src/migrations/011_b0_channel_conversation_audit_only.ts +27 -0
- package/src/migrations/012_b0_cross_conversation_invariant.ts +45 -0
- package/src/migrations/013_b1_0_eng_inbound_raw_events.ts +56 -0
- package/src/migrations/014_agents_runtime.ts +10 -0
- package/src/migrations/015_agent_runtime_sessions.ts +15 -0
- package/src/migrations/016_room_participants.ts +27 -0
- package/src/migrations/017_unified_room_delivery.ts +203 -0
- package/src/migrations/018_room_display_names.ts +36 -0
- package/src/migrations/019_computer_connections.ts +63 -0
- package/src/migrations/020_computer_agent_assignments.ts +20 -0
- package/src/migrations/021_provider_identity_bindings.ts +32 -0
- package/src/migrations.ts +85 -0
- package/src/neeko.ts +23 -0
- package/src/provider-identity.ts +40 -0
- package/src/runtime-registry.ts +41 -0
- package/src/server-auth.ts +13 -0
- package/src/server.ts +63 -0
- package/src/token-file.ts +57 -0
- package/src/types.ts +408 -0
- package/src/web.ts +565 -0
package/src/daemon.ts
ADDED
|
@@ -0,0 +1,580 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { parseArgs, boolFlag, flag, numberFlag } from './args.js';
|
|
4
|
+
import { LockClient } from './client.js';
|
|
5
|
+
import { DEFAULT_DAEMON_PORT, DEFAULT_HOST, agentHomePath, defaultDaemonToken, defaultServerUrl, defaultStatePath, ensureAgentHome, ensureParentDir, ensurePrivatePalCliBin } from './config.js';
|
|
6
|
+
import { writeFailureMessage } from './failure-message.js';
|
|
7
|
+
import { formatMessage } from './format.js';
|
|
8
|
+
import { startLocalApi } from './local-api.js';
|
|
9
|
+
import { loadOrCreateDaemonToken } from './token-file.js';
|
|
10
|
+
import type { ComputerAgentAssignment, Message, MessageDelivery } from './types.js';
|
|
11
|
+
import type { AgentRuntime } from './agent-runtime.js';
|
|
12
|
+
import { knownRuntimeNames, resolveRuntimeDriver } from './runtime-registry.js';
|
|
13
|
+
|
|
14
|
+
interface DaemonState {
|
|
15
|
+
daemonId: string;
|
|
16
|
+
lastSeenId: number;
|
|
17
|
+
agentUuid?: string;
|
|
18
|
+
agents?: Record<string, { agentUuid: string; lastSeenId?: number }>;
|
|
19
|
+
computerId?: string;
|
|
20
|
+
connectionId?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface ManagedAgent {
|
|
24
|
+
agent: string;
|
|
25
|
+
runtimeProvider: string;
|
|
26
|
+
agentUuid: string;
|
|
27
|
+
agentHome: string;
|
|
28
|
+
projectCwd: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function usage(): string {
|
|
32
|
+
return `pal daemon
|
|
33
|
+
|
|
34
|
+
Usage:
|
|
35
|
+
npx @controlflow-ai/daemon --api-key sk_machine_... [--server ${defaultServerUrl()}] [--cwd .] [--interval 1500] [--dry-run]
|
|
36
|
+
npx @controlflow-ai/daemon --agent codex --computer-id host --computer-secret secret [--server ${defaultServerUrl()}] [--cwd .]
|
|
37
|
+
|
|
38
|
+
The daemon connects one computer to the server, claims deliveries for assigned agents, and starts one agent run for each claimed delivery.
|
|
39
|
+
Runs have no default timeout; use the web control page or API to kill/restart a run.
|
|
40
|
+
`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function defaultState(): DaemonState {
|
|
44
|
+
return { daemonId: crypto.randomUUID(), lastSeenId: 0 };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function readState(path: string): DaemonState {
|
|
48
|
+
if (!existsSync(path)) return defaultState();
|
|
49
|
+
try {
|
|
50
|
+
const state = JSON.parse(readFileSync(path, 'utf8')) as Partial<DaemonState>;
|
|
51
|
+
return {
|
|
52
|
+
daemonId: state.daemonId || crypto.randomUUID(),
|
|
53
|
+
lastSeenId: Number.isFinite(state.lastSeenId) ? Number(state.lastSeenId) : 0,
|
|
54
|
+
agentUuid: state.agentUuid,
|
|
55
|
+
agents: state.agents,
|
|
56
|
+
computerId: state.computerId,
|
|
57
|
+
connectionId: state.connectionId,
|
|
58
|
+
};
|
|
59
|
+
} catch {
|
|
60
|
+
return defaultState();
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function ensureAgentUuid(state: DaemonState, agent: string): string {
|
|
65
|
+
state.agents ??= {};
|
|
66
|
+
state.agents[agent] ??= { agentUuid: Object.keys(state.agents).length === 0 && state.agentUuid ? state.agentUuid : crypto.randomUUID(), lastSeenId: 0 };
|
|
67
|
+
if (!state.agentUuid && Object.keys(state.agents).length === 1) {
|
|
68
|
+
state.agentUuid = state.agents[agent].agentUuid;
|
|
69
|
+
}
|
|
70
|
+
return state.agents[agent].agentUuid;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function writeState(path: string, state: DaemonState): void {
|
|
74
|
+
ensureParentDir(path);
|
|
75
|
+
writeFileSync(path, `${JSON.stringify(state, null, 2)}\n`);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function sleep(ms: number): Promise<void> {
|
|
79
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function resolveRuntime(agent: string, serverUrl: string, agentUuid: string): Promise<AgentRuntime> {
|
|
83
|
+
let configuredRuntime: string | null | undefined;
|
|
84
|
+
try {
|
|
85
|
+
const response = await fetch(`${serverUrl.replace(/\/$/, '')}/api/agents`);
|
|
86
|
+
if (response.ok) {
|
|
87
|
+
const payload = await response.json() as { data?: { agents: Array<{ agent_key: string; runtime: string | null }> } };
|
|
88
|
+
const configured = payload.data?.agents.find((a) => a.agent_key === agent);
|
|
89
|
+
configuredRuntime = configured?.runtime;
|
|
90
|
+
}
|
|
91
|
+
} catch (error) {
|
|
92
|
+
throw new Error(`Could not resolve runtime for agent '${agent}' from ${serverUrl}: ${error instanceof Error ? error.message : String(error)}`);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (!configuredRuntime) {
|
|
96
|
+
throw new Error(`Agent '${agent}' has no runtime configured. Configure it via POST /api/agents or PATCH /api/agents/<key>. Supported runtimes: ${knownRuntimeNames().join(', ')}.`);
|
|
97
|
+
}
|
|
98
|
+
return resolveRuntimeDriver(configuredRuntime, agentUuid);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async function executeRun(client: LockClient, delivery: MessageDelivery, message: Message, options: {
|
|
102
|
+
agent: string;
|
|
103
|
+
daemonId: string;
|
|
104
|
+
serverUrl: string;
|
|
105
|
+
agentHome: string;
|
|
106
|
+
projectCwd: string;
|
|
107
|
+
extraArgs: string[];
|
|
108
|
+
localDaemonUrl?: string;
|
|
109
|
+
localDaemonToken?: string;
|
|
110
|
+
privateCliBinDir?: string;
|
|
111
|
+
dryRun: boolean;
|
|
112
|
+
attempt: number;
|
|
113
|
+
agentUuid: string;
|
|
114
|
+
computerId?: string | null;
|
|
115
|
+
connectionId?: string | null;
|
|
116
|
+
runtimeProvider: string;
|
|
117
|
+
isConnectionRevoked?: () => boolean;
|
|
118
|
+
abortSignal?: AbortSignal;
|
|
119
|
+
}): Promise<'done' | 'restart'> {
|
|
120
|
+
if (!delivery.claim_token) throw new Error(`delivery ${delivery.id} is not claimed`);
|
|
121
|
+
|
|
122
|
+
const runtime = await resolveRuntime(options.agent, options.serverUrl, options.agentUuid);
|
|
123
|
+
const logPrefix = `[${runtime.name}]`;
|
|
124
|
+
|
|
125
|
+
console.log(`${logPrefix} session getOrCreate chat=${message.chat_id} agent=${options.agent} daemon=${options.daemonId} agentHome=${options.agentHome} projectCwd=${options.projectCwd}`);
|
|
126
|
+
const session = await client.getOrCreateSession({
|
|
127
|
+
chat_id: message.chat_id,
|
|
128
|
+
agent: options.agent,
|
|
129
|
+
daemon_id: options.daemonId,
|
|
130
|
+
connection_id: options.connectionId,
|
|
131
|
+
computer_id: options.computerId,
|
|
132
|
+
runtime_provider: options.runtimeProvider,
|
|
133
|
+
cwd: options.agentHome,
|
|
134
|
+
last_message_id: message.id,
|
|
135
|
+
});
|
|
136
|
+
console.log(`${logPrefix} session=${session.id} last_message_id=${session.last_message_id} runtime_session_id=${session.runtime_session_id ?? '-'}`);
|
|
137
|
+
|
|
138
|
+
console.log(`${logPrefix} startRun message=${message.id} agent=${options.agent} attempt=${options.attempt} session=${session.id} delivery=${delivery.id}`);
|
|
139
|
+
const run = await client.startRun({
|
|
140
|
+
message_id: message.id,
|
|
141
|
+
agent: options.agent,
|
|
142
|
+
cwd: options.agentHome,
|
|
143
|
+
attempt: options.attempt,
|
|
144
|
+
pid: null,
|
|
145
|
+
session_id: session.id,
|
|
146
|
+
trigger_message_id: message.id,
|
|
147
|
+
daemon_id: options.daemonId,
|
|
148
|
+
connection_id: options.connectionId,
|
|
149
|
+
computer_id: options.computerId,
|
|
150
|
+
runtime_provider: options.runtimeProvider,
|
|
151
|
+
delivery_id: delivery.id,
|
|
152
|
+
});
|
|
153
|
+
console.log(`${logPrefix} run=${run.id} status=${run.status}`);
|
|
154
|
+
|
|
155
|
+
const { runAgentRuntime } = await import('./agent-runtime.js');
|
|
156
|
+
const roomParticipants = await client.listRoomMembers(message.chat_id)
|
|
157
|
+
.then((result) => result.participants)
|
|
158
|
+
.catch((error) => {
|
|
159
|
+
console.warn(`${logPrefix} room participant snapshot unavailable: ${error instanceof Error ? error.message : String(error)}`);
|
|
160
|
+
return [];
|
|
161
|
+
});
|
|
162
|
+
const recentMessages = await client.getMessages(new URLSearchParams({
|
|
163
|
+
chat_id: message.chat_id,
|
|
164
|
+
after: String(Math.max(0, message.id - 50)),
|
|
165
|
+
limit: '50',
|
|
166
|
+
})).catch((error) => {
|
|
167
|
+
console.warn(`${logPrefix} recent room history unavailable: ${error instanceof Error ? error.message : String(error)}`);
|
|
168
|
+
return [];
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
try {
|
|
172
|
+
console.log(`${logPrefix} spawn agent=${options.agent} chat=${message.chat_name} message=${message.id} dryRun=${options.dryRun}`);
|
|
173
|
+
const result = await runAgentRuntime(runtime, {
|
|
174
|
+
agent: options.agent,
|
|
175
|
+
serverUrl: options.serverUrl,
|
|
176
|
+
message,
|
|
177
|
+
cwd: options.agentHome,
|
|
178
|
+
agentHome: options.agentHome,
|
|
179
|
+
projectCwd: options.projectCwd,
|
|
180
|
+
extraArgs: options.extraArgs,
|
|
181
|
+
localDaemonUrl: options.localDaemonUrl,
|
|
182
|
+
localDaemonToken: options.localDaemonToken,
|
|
183
|
+
privateCliBinDir: options.privateCliBinDir,
|
|
184
|
+
palCliCommand: 'pal',
|
|
185
|
+
runtimeSessionId: session.runtime_session_id,
|
|
186
|
+
roomParticipants,
|
|
187
|
+
recentMessages,
|
|
188
|
+
dryRun: options.dryRun,
|
|
189
|
+
signal: options.abortSignal,
|
|
190
|
+
onStart: async (pid) => {
|
|
191
|
+
console.log(`${logPrefix} run=${run.id} pid=${pid}`);
|
|
192
|
+
await client.updateRunPid(run.id, pid);
|
|
193
|
+
},
|
|
194
|
+
getAction: async () => {
|
|
195
|
+
if (options.isConnectionRevoked?.()) return 'kill';
|
|
196
|
+
const latest = await client.getRun(run.id);
|
|
197
|
+
if (latest.action) {
|
|
198
|
+
console.log(`${logPrefix} run=${run.id} action=${latest.action}`);
|
|
199
|
+
}
|
|
200
|
+
return latest.action;
|
|
201
|
+
},
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
if (result.runtimeSessionId && result.runtimeSessionId !== session.runtime_session_id) {
|
|
205
|
+
console.log(`${logPrefix} session=${session.id} runtime_session_id=${result.runtimeSessionId}`);
|
|
206
|
+
await client.updateSessionRuntimeSessionId(session.id, { runtime_session_id: result.runtimeSessionId });
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
console.log(`${logPrefix} run=${run.id} exitCode=${result.exitCode} stoppedByAction=${result.stoppedByAction ?? '-'} outputLen=${result.output.length}`);
|
|
210
|
+
if (options.isConnectionRevoked?.()) {
|
|
211
|
+
console.log(`${logPrefix} run=${run.id} stopped after connection revocation; skipping revoked server writes`);
|
|
212
|
+
return 'done';
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (options.dryRun && result.output.trim()) {
|
|
216
|
+
console.log(`${logPrefix} dry-run sending output back to chat`);
|
|
217
|
+
await client.sendMessage({
|
|
218
|
+
chat: message.chat_name,
|
|
219
|
+
parent_id: message.parent_id ?? message.id,
|
|
220
|
+
sender: options.agent,
|
|
221
|
+
content: result.output.trim(),
|
|
222
|
+
type: 'system',
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
if (result.stoppedByAction === 'restart') {
|
|
227
|
+
console.log(`${logPrefix} run=${run.id} finishing with restart`);
|
|
228
|
+
await client.finishRun(run.id, { status: 'restarted', exit_code: result.exitCode, output: result.output });
|
|
229
|
+
return 'restart';
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
if (result.stoppedByAction === 'kill') {
|
|
233
|
+
console.log(`${logPrefix} run=${run.id} finishing with killed`);
|
|
234
|
+
await client.finishRun(run.id, { status: 'killed', exit_code: result.exitCode, output: result.output });
|
|
235
|
+
await client.ackDelivery(delivery.id, { daemon_id: options.daemonId, connection_id: options.connectionId, claim_token: delivery.claim_token, run_id: run.id });
|
|
236
|
+
return 'done';
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
if (result.exitCode === 0) {
|
|
240
|
+
console.log(`${logPrefix} run=${run.id} finishing with completed`);
|
|
241
|
+
await client.finishRun(run.id, { status: 'completed', exit_code: result.exitCode, output: result.output });
|
|
242
|
+
await client.ackDelivery(delivery.id, { daemon_id: options.daemonId, connection_id: options.connectionId, claim_token: delivery.claim_token, run_id: run.id });
|
|
243
|
+
return 'done';
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const output = `${runtime.name} run failed with exit ${result.exitCode}\n${result.output}`.trim();
|
|
247
|
+
console.log(`${logPrefix} run=${run.id} finishing with failed: ${output.slice(0, 200)}`);
|
|
248
|
+
await client.finishRun(run.id, { status: 'failed', exit_code: result.exitCode, output });
|
|
249
|
+
await client.failDelivery(delivery.id, { daemon_id: options.daemonId, connection_id: options.connectionId, claim_token: delivery.claim_token, run_id: run.id, error: output });
|
|
250
|
+
await writeFailureMessage(client, message, options.agent, output);
|
|
251
|
+
return 'done';
|
|
252
|
+
} catch (error) {
|
|
253
|
+
const output = error instanceof Error ? error.message : String(error);
|
|
254
|
+
console.error(`${logPrefix} run=${run.id} exception: ${output}`);
|
|
255
|
+
if (options.isConnectionRevoked?.()) {
|
|
256
|
+
console.log(`${logPrefix} run=${run.id} exception after connection revocation; skipping revoked server writes`);
|
|
257
|
+
return 'done';
|
|
258
|
+
}
|
|
259
|
+
await client.finishRun(run.id, { status: 'failed', output });
|
|
260
|
+
await client.failDelivery(delivery.id, { daemon_id: options.daemonId, connection_id: options.connectionId, claim_token: delivery.claim_token, run_id: run.id, error: output });
|
|
261
|
+
await writeFailureMessage(client, message, options.agent, output);
|
|
262
|
+
return 'done';
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
async function handleDelivery(client: LockClient, delivery: MessageDelivery, options: {
|
|
267
|
+
agent: string;
|
|
268
|
+
daemonId: string;
|
|
269
|
+
serverUrl: string;
|
|
270
|
+
agentHome: string;
|
|
271
|
+
projectCwd: string;
|
|
272
|
+
extraArgs: string[];
|
|
273
|
+
localDaemonUrl?: string;
|
|
274
|
+
localDaemonToken?: string;
|
|
275
|
+
privateCliBinDir?: string;
|
|
276
|
+
dryRun: boolean;
|
|
277
|
+
agentUuid: string;
|
|
278
|
+
computerId?: string | null;
|
|
279
|
+
connectionId?: string | null;
|
|
280
|
+
runtimeProvider: string;
|
|
281
|
+
isConnectionRevoked?: () => boolean;
|
|
282
|
+
abortSignal?: AbortSignal;
|
|
283
|
+
}): Promise<void> {
|
|
284
|
+
console.log(`[daemon] handleDelivery delivery=${delivery.id} message=${delivery.message_id} attempts=${delivery.attempts}`);
|
|
285
|
+
const message = await client.getMessage(delivery.message_id);
|
|
286
|
+
console.log(`[daemon] claimed delivery ${delivery.id} for ${formatMessage(message)}`);
|
|
287
|
+
|
|
288
|
+
let attempt = Math.max(delivery.attempts, 1);
|
|
289
|
+
while (true) {
|
|
290
|
+
console.log(`[daemon] executeRun delivery=${delivery.id} attempt=${attempt}`);
|
|
291
|
+
const outcome = await executeRun(client, delivery, message, { ...options, attempt });
|
|
292
|
+
console.log(`[daemon] executeRun delivery=${delivery.id} outcome=${outcome}`);
|
|
293
|
+
if (outcome !== 'restart') return;
|
|
294
|
+
attempt += 1;
|
|
295
|
+
console.log(`[daemon] restarting message ${message.id} for ${options.agent}, attempt ${attempt}`);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
async function discoverDeliveries(client: LockClient, state: DaemonState, agent: string): Promise<void> {
|
|
300
|
+
console.log(`[daemon] discoverDeliveries agent=${agent} after=${state.lastSeenId}`);
|
|
301
|
+
const messages = await client.getInbox(agent, state.lastSeenId, 20);
|
|
302
|
+
console.log(`[daemon] discoverDeliveries found ${messages.length} messages`);
|
|
303
|
+
for (const message of messages) {
|
|
304
|
+
console.log(`[daemon] createDelivery message=${message.id} agent=${agent}`);
|
|
305
|
+
await client.createDelivery({ message_id: message.id, agent });
|
|
306
|
+
state.lastSeenId = Math.max(state.lastSeenId, message.id);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
async function processPendingDeliveries(client: LockClient, options: {
|
|
311
|
+
agent: string;
|
|
312
|
+
daemonId: string;
|
|
313
|
+
serverUrl: string;
|
|
314
|
+
agentHome: string;
|
|
315
|
+
projectCwd: string;
|
|
316
|
+
extraArgs: string[];
|
|
317
|
+
localDaemonUrl?: string;
|
|
318
|
+
localDaemonToken?: string;
|
|
319
|
+
privateCliBinDir?: string;
|
|
320
|
+
dryRun: boolean;
|
|
321
|
+
agentUuid: string;
|
|
322
|
+
computerId?: string | null;
|
|
323
|
+
connectionId?: string | null;
|
|
324
|
+
runtimeProvider: string;
|
|
325
|
+
isConnectionRevoked?: () => boolean;
|
|
326
|
+
abortSignal?: AbortSignal;
|
|
327
|
+
}): Promise<void> {
|
|
328
|
+
console.log(`[daemon] processPendingDeliveries agent=${options.agent} daemon=${options.daemonId}`);
|
|
329
|
+
const deliveries = await client.listDeliveries(options.agent, 'pending', 20);
|
|
330
|
+
console.log(`[daemon] processPendingDeliveries found ${deliveries.length} pending deliveries`);
|
|
331
|
+
for (const delivery of deliveries) {
|
|
332
|
+
try {
|
|
333
|
+
console.log(`[daemon] claimDelivery delivery=${delivery.id}`);
|
|
334
|
+
const claimed = await client.claimDelivery(delivery.id, { daemon_id: options.daemonId, connection_id: options.connectionId, computer_id: options.computerId });
|
|
335
|
+
console.log(`[daemon] claimDelivery success delivery=${claimed.id} token=${claimed.claim_token}`);
|
|
336
|
+
await handleDelivery(client, claimed, options);
|
|
337
|
+
} catch (error) {
|
|
338
|
+
console.warn(`[daemon] claimDelivery failed delivery=${delivery.id}: ${error instanceof Error ? error.message : String(error)}`);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
async function buildManagedAgent(input: {
|
|
344
|
+
assignment: Pick<ComputerAgentAssignment, 'agent' | 'runtime' | 'cwd'>;
|
|
345
|
+
state: DaemonState;
|
|
346
|
+
serverUrl: string;
|
|
347
|
+
defaultCwd: string;
|
|
348
|
+
}): Promise<ManagedAgent> {
|
|
349
|
+
const agentUuid = ensureAgentUuid(input.state, input.assignment.agent);
|
|
350
|
+
const runtime = await resolveRuntime(input.assignment.agent, input.serverUrl, agentUuid);
|
|
351
|
+
const agentHome = agentHomePath(agentUuid);
|
|
352
|
+
ensureAgentHome(agentHome, input.assignment.agent, runtime.name);
|
|
353
|
+
return {
|
|
354
|
+
agent: input.assignment.agent,
|
|
355
|
+
runtimeProvider: runtime.name,
|
|
356
|
+
agentUuid,
|
|
357
|
+
agentHome,
|
|
358
|
+
projectCwd: input.assignment.cwd || input.defaultCwd,
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function daemonAgentPayload(managedAgents: Map<string, ManagedAgent>): Array<{ agent: string; cwd: string; capabilities: Record<string, unknown> }> {
|
|
363
|
+
return Array.from(managedAgents.values()).map((managed) => ({
|
|
364
|
+
agent: managed.agent,
|
|
365
|
+
cwd: managed.agentHome,
|
|
366
|
+
capabilities: { runtime: managed.runtimeProvider, agentHome: managed.agentHome, projectCwd: managed.projectCwd },
|
|
367
|
+
}));
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
async function reconcileManagedAgents(input: {
|
|
371
|
+
assignments: ComputerAgentAssignment[];
|
|
372
|
+
explicitAgents: Set<string>;
|
|
373
|
+
managedAgents: Map<string, ManagedAgent>;
|
|
374
|
+
state: DaemonState;
|
|
375
|
+
client: LockClient;
|
|
376
|
+
daemonId: string;
|
|
377
|
+
computerId: string;
|
|
378
|
+
serverUrl: string;
|
|
379
|
+
defaultCwd: string;
|
|
380
|
+
}): Promise<void> {
|
|
381
|
+
const desiredAssignedAgents = new Set(input.assignments.map((assignment) => assignment.agent));
|
|
382
|
+
|
|
383
|
+
for (const assignment of input.assignments) {
|
|
384
|
+
if (!assignment.runtime) {
|
|
385
|
+
console.warn(`[daemon] assigned agent ${assignment.agent} has no runtime configured; skipping`);
|
|
386
|
+
continue;
|
|
387
|
+
}
|
|
388
|
+
const existing = input.managedAgents.get(assignment.agent);
|
|
389
|
+
const projectCwd = assignment.cwd || input.defaultCwd;
|
|
390
|
+
if (existing && existing.runtimeProvider === assignment.runtime && existing.projectCwd === projectCwd) continue;
|
|
391
|
+
const managed = await buildManagedAgent({ assignment, state: input.state, serverUrl: input.serverUrl, defaultCwd: input.defaultCwd });
|
|
392
|
+
input.managedAgents.set(assignment.agent, managed);
|
|
393
|
+
console.log(`[daemon] agent active agent=${managed.agent} runtime=${managed.runtimeProvider} projectCwd=${managed.projectCwd}`);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
for (const agent of Array.from(input.managedAgents.keys())) {
|
|
397
|
+
if (input.explicitAgents.has(agent)) continue;
|
|
398
|
+
if (desiredAssignedAgents.has(agent)) continue;
|
|
399
|
+
input.managedAgents.delete(agent);
|
|
400
|
+
console.log(`[daemon] agent removed agent=${agent}`);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
await input.client.registerDaemon({
|
|
404
|
+
id: input.daemonId,
|
|
405
|
+
name: input.computerId,
|
|
406
|
+
server_url: input.serverUrl,
|
|
407
|
+
agents: daemonAgentPayload(input.managedAgents),
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
async function main(): Promise<void> {
|
|
412
|
+
const rawArgs = parseArgs();
|
|
413
|
+
// When launched as `bun run src/daemon.ts --agent foo`, the first arg is a flag,
|
|
414
|
+
// not a subcommand. Shift it back into flags/values.
|
|
415
|
+
const args: typeof rawArgs = rawArgs.command.startsWith('--')
|
|
416
|
+
? parseArgs(['daemon', rawArgs.command, ...rawArgs.values, ...Object.entries(rawArgs.flags).flatMap(([k, v]) => v === true ? [`--${k}`] : [`--${k}=${v}`])])
|
|
417
|
+
: rawArgs;
|
|
418
|
+
if (args.flags.help) {
|
|
419
|
+
console.log(usage());
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
const explicitAgent = flag(args.flags, 'agent') ?? process.env.PAL_AGENT;
|
|
424
|
+
const serverUrl = flag(args.flags, 'server-url') ?? flag(args.flags, 'server') ?? defaultServerUrl();
|
|
425
|
+
const cwd = flag(args.flags, 'cwd') ?? process.cwd();
|
|
426
|
+
const interval = numberFlag(args.flags, 'interval', 1500)!;
|
|
427
|
+
const localHost = flag(args.flags, 'local-host') ?? DEFAULT_HOST;
|
|
428
|
+
const localPort = numberFlag(args.flags, 'local-port', DEFAULT_DAEMON_PORT)!;
|
|
429
|
+
const argvLocalToken = flag(args.flags, 'local-token');
|
|
430
|
+
if (argvLocalToken && process.env.NODE_ENV !== 'test') {
|
|
431
|
+
throw new Error('--local-token is only allowed when NODE_ENV=test; use LOCK_DAEMON_TOKEN or token file');
|
|
432
|
+
}
|
|
433
|
+
const localToken = argvLocalToken ?? defaultDaemonToken() ?? loadOrCreateDaemonToken();
|
|
434
|
+
const statePath = flag(args.flags, 'state') ?? defaultStatePath(explicitAgent ?? 'computer');
|
|
435
|
+
const once = boolFlag(args.flags, 'once');
|
|
436
|
+
const dryRun = boolFlag(args.flags, 'dry-run');
|
|
437
|
+
const extraArgsRaw = flag(args.flags, 'neeko-args') ?? flag(args.flags, 'agent-args') ?? '';
|
|
438
|
+
const extraArgs = extraArgsRaw ? extraArgsRaw.split(' ').filter(Boolean) : [];
|
|
439
|
+
const privateCliBinDir = ensurePrivatePalCliBin();
|
|
440
|
+
const bootstrapClient = new LockClient(serverUrl);
|
|
441
|
+
const state = readState(statePath);
|
|
442
|
+
|
|
443
|
+
const apiKey = flag(args.flags, 'api-key') ?? process.env.PAL_API_KEY;
|
|
444
|
+
const computerId = flag(args.flags, 'computer-id') ?? process.env.PAL_COMPUTER_ID;
|
|
445
|
+
const computerSecret = flag(args.flags, 'computer-secret') ?? process.env.PAL_COMPUTER_SECRET;
|
|
446
|
+
if (!apiKey?.trim() && !computerId?.trim()) throw new Error('--api-key, --computer-id, or PAL_COMPUTER_ID is required');
|
|
447
|
+
if (!apiKey?.trim() && !computerSecret?.trim()) throw new Error('--api-key, --computer-secret, or PAL_COMPUTER_SECRET is required');
|
|
448
|
+
|
|
449
|
+
const explicitAgents = new Set<string>();
|
|
450
|
+
const managedAgents = new Map<string, ManagedAgent>();
|
|
451
|
+
if (explicitAgent) {
|
|
452
|
+
explicitAgents.add(explicitAgent);
|
|
453
|
+
const managed = await buildManagedAgent({
|
|
454
|
+
assignment: { agent: explicitAgent, runtime: null, cwd },
|
|
455
|
+
state,
|
|
456
|
+
serverUrl,
|
|
457
|
+
defaultCwd: cwd,
|
|
458
|
+
});
|
|
459
|
+
managedAgents.set(explicitAgent, managed);
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
const connected = await bootstrapClient.connectComputer({
|
|
463
|
+
computer_id: computerId,
|
|
464
|
+
secret: computerSecret,
|
|
465
|
+
api_key: apiKey,
|
|
466
|
+
name: computerId,
|
|
467
|
+
server_url: serverUrl,
|
|
468
|
+
agents: daemonAgentPayload(managedAgents),
|
|
469
|
+
});
|
|
470
|
+
state.computerId = connected.computer.id;
|
|
471
|
+
state.connectionId = connected.connection.id;
|
|
472
|
+
state.daemonId = connected.connection.id;
|
|
473
|
+
|
|
474
|
+
const daemonAuth = { computer_id: connected.computer.id, connection_id: connected.connection.id, token: connected.token };
|
|
475
|
+
const client = new LockClient(serverUrl, daemonAuth);
|
|
476
|
+
await reconcileManagedAgents({
|
|
477
|
+
assignments: connected.agents ?? [],
|
|
478
|
+
explicitAgents,
|
|
479
|
+
managedAgents,
|
|
480
|
+
state,
|
|
481
|
+
client,
|
|
482
|
+
daemonId: connected.connection.id,
|
|
483
|
+
computerId: connected.computer.id,
|
|
484
|
+
serverUrl,
|
|
485
|
+
defaultCwd: cwd,
|
|
486
|
+
});
|
|
487
|
+
if (managedAgents.size === 0) {
|
|
488
|
+
console.log(`[daemon] no agents currently assigned to computer ${connected.computer.id}; waiting for assignments`);
|
|
489
|
+
}
|
|
490
|
+
writeState(statePath, state);
|
|
491
|
+
let connectionRevoked = false;
|
|
492
|
+
const heartbeatMs = numberFlag(args.flags, 'heartbeat-interval', 5000)!;
|
|
493
|
+
|
|
494
|
+
const localServer = startLocalApi({ host: localHost, port: localPort, serverUrl, token: localToken, daemonAuth });
|
|
495
|
+
|
|
496
|
+
console.log(`pal daemon computer=${connected.computer.id} connection=${connected.connection.id} agents=${Array.from(managedAgents.values()).map((managed) => `${managed.agent}:${managed.runtimeProvider}`).join(',') || 'none'} server=${serverUrl}`);
|
|
497
|
+
console.log(`local api=http://${localServer.hostname}:${localServer.port} token=${localToken ? 'set' : 'missing'}`);
|
|
498
|
+
console.log(`state=${statePath} lastSeenId=${state.lastSeenId}`);
|
|
499
|
+
console.log(`private cli bin=${privateCliBinDir}`);
|
|
500
|
+
|
|
501
|
+
let running = true;
|
|
502
|
+
const runtimeAbortController = new AbortController();
|
|
503
|
+
process.on('SIGINT', () => { running = false; runtimeAbortController.abort(); });
|
|
504
|
+
process.on('SIGTERM', () => { running = false; runtimeAbortController.abort(); });
|
|
505
|
+
const heartbeatTimer = setInterval(() => {
|
|
506
|
+
void client.heartbeatComputer(connected.computer.id).catch((error) => {
|
|
507
|
+
connectionRevoked = true;
|
|
508
|
+
running = false;
|
|
509
|
+
runtimeAbortController.abort();
|
|
510
|
+
console.warn(`[daemon] heartbeat failed, stopping connection=${connected.connection.id}: ${error instanceof Error ? error.message : String(error)}`);
|
|
511
|
+
});
|
|
512
|
+
}, heartbeatMs);
|
|
513
|
+
|
|
514
|
+
while (running) {
|
|
515
|
+
console.log(`[daemon] tick process deliveries interval=${interval}ms once=${once}`);
|
|
516
|
+
try {
|
|
517
|
+
const heartbeat = await client.heartbeatComputer(connected.computer.id);
|
|
518
|
+
await reconcileManagedAgents({
|
|
519
|
+
assignments: heartbeat.agents ?? [],
|
|
520
|
+
explicitAgents,
|
|
521
|
+
managedAgents,
|
|
522
|
+
state,
|
|
523
|
+
client,
|
|
524
|
+
daemonId: connected.connection.id,
|
|
525
|
+
computerId: connected.computer.id,
|
|
526
|
+
serverUrl,
|
|
527
|
+
defaultCwd: cwd,
|
|
528
|
+
});
|
|
529
|
+
writeState(statePath, state);
|
|
530
|
+
} catch (error) {
|
|
531
|
+
connectionRevoked = true;
|
|
532
|
+
running = false;
|
|
533
|
+
runtimeAbortController.abort();
|
|
534
|
+
console.warn(`[daemon] heartbeat failed, stopping connection=${connected.connection.id}: ${error instanceof Error ? error.message : String(error)}`);
|
|
535
|
+
break;
|
|
536
|
+
}
|
|
537
|
+
if (managedAgents.size === 0) {
|
|
538
|
+
console.log(`[daemon] no assigned agents; idle`);
|
|
539
|
+
}
|
|
540
|
+
for (const managed of managedAgents.values()) {
|
|
541
|
+
if (process.env.PAL_DAEMON_DISCOVER_INBOX === '1') {
|
|
542
|
+
await discoverDeliveries(client, state, managed.agent);
|
|
543
|
+
writeState(statePath, state);
|
|
544
|
+
}
|
|
545
|
+
await processPendingDeliveries(client, {
|
|
546
|
+
agent: managed.agent,
|
|
547
|
+
daemonId: connected.connection.id,
|
|
548
|
+
serverUrl,
|
|
549
|
+
agentHome: managed.agentHome,
|
|
550
|
+
projectCwd: managed.projectCwd,
|
|
551
|
+
extraArgs,
|
|
552
|
+
dryRun,
|
|
553
|
+
agentUuid: managed.agentUuid,
|
|
554
|
+
computerId: connected.computer.id,
|
|
555
|
+
connectionId: connected.connection.id,
|
|
556
|
+
runtimeProvider: managed.runtimeProvider,
|
|
557
|
+
isConnectionRevoked: () => connectionRevoked,
|
|
558
|
+
abortSignal: runtimeAbortController.signal,
|
|
559
|
+
localDaemonUrl: `http://${localServer.hostname}:${localServer.port}`,
|
|
560
|
+
localDaemonToken: localToken,
|
|
561
|
+
privateCliBinDir,
|
|
562
|
+
});
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
if (once) {
|
|
566
|
+
console.log(`[daemon] --once set, exiting loop`);
|
|
567
|
+
break;
|
|
568
|
+
}
|
|
569
|
+
console.log(`[daemon] sleep ${interval}ms`);
|
|
570
|
+
await sleep(interval);
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
clearInterval(heartbeatTimer);
|
|
574
|
+
localServer.stop(true);
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
main().catch((error) => {
|
|
578
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
579
|
+
process.exit(1);
|
|
580
|
+
});
|