@agentbean/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/dist/config.js ADDED
@@ -0,0 +1,138 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { load as parseYaml } from 'js-yaml';
3
+ const KINDS = ['codex', 'claude-code', 'openclaw', 'hermes', 'standalone'];
4
+ const ENV_PATTERN = /\$\{([A-Z0-9_]+)\}/g;
5
+ function interpolate(value) {
6
+ return value.replace(ENV_PATTERN, (_match, name) => {
7
+ const v = process.env[name];
8
+ if (v === undefined)
9
+ throw new Error(`config references missing env var: ${name}`);
10
+ return v;
11
+ });
12
+ }
13
+ function deepInterpolate(node) {
14
+ if (typeof node === 'string')
15
+ return interpolate(node);
16
+ if (Array.isArray(node))
17
+ return node.map(deepInterpolate);
18
+ if (node && typeof node === 'object') {
19
+ const out = {};
20
+ for (const [k, v] of Object.entries(node))
21
+ out[k] = deepInterpolate(v);
22
+ return out;
23
+ }
24
+ return node;
25
+ }
26
+ export function loadConfig(path) {
27
+ const raw = parseYaml(readFileSync(path, 'utf8'));
28
+ if (!raw || typeof raw !== 'object')
29
+ throw new Error('config: top-level must be a mapping');
30
+ const interp = deepInterpolate(raw);
31
+ const need = ['id', 'name', 'role'];
32
+ for (const k of need) {
33
+ if (typeof interp[k] !== 'string' || interp[k].length === 0) {
34
+ throw new Error(`config: ${k} is required (non-empty string)`);
35
+ }
36
+ }
37
+ const a = interp.adapter ?? {};
38
+ if (!KINDS.includes(a.kind)) {
39
+ throw new Error(`config: adapter.kind must be one of ${KINDS.join(', ')}`);
40
+ }
41
+ if (typeof a.command !== 'string') {
42
+ throw new Error('config: adapter.command is required');
43
+ }
44
+ const s = interp.server ?? {};
45
+ if (typeof s.url !== 'string' || typeof s.token !== 'string') {
46
+ throw new Error('config: server.url and server.token are required');
47
+ }
48
+ const inferredCategory = a.kind === 'codex' || a.kind === 'claude-code' ? 'executor-hosted' :
49
+ a.kind === 'openclaw' || a.kind === 'hermes' ? 'agentos-hosted' :
50
+ 'standalone-cli';
51
+ const category = typeof interp.category === 'string' && ['executor-hosted', 'agentos-hosted', 'standalone-cli'].includes(interp.category)
52
+ ? interp.category
53
+ : inferredCategory;
54
+ return {
55
+ id: interp.id,
56
+ name: interp.name,
57
+ role: interp.role,
58
+ category,
59
+ adapter: {
60
+ kind: a.kind,
61
+ command: a.command,
62
+ args: Array.isArray(a.args) ? a.args.map(String) : [],
63
+ cwd: typeof a.cwd === 'string' ? a.cwd : undefined,
64
+ workspace: typeof a.workspace === 'string' ? a.workspace : undefined,
65
+ systemPrompt: typeof a.systemPrompt === 'string' ? a.systemPrompt : undefined,
66
+ },
67
+ server: { url: s.url, token: s.token },
68
+ heartbeatIntervalMs: isValidHeartbeat(interp.heartbeatIntervalMs) ? interp.heartbeatIntervalMs : 10_000,
69
+ };
70
+ }
71
+ function isValidHeartbeat(v) {
72
+ return typeof v === 'number' && v > 0 && Number.isFinite(v);
73
+ }
74
+ export function loadDeviceConfig(path) {
75
+ const raw = parseYaml(readFileSync(path, 'utf8'));
76
+ if (!raw || typeof raw !== 'object')
77
+ throw new Error('config: top-level must be a mapping');
78
+ const interp = deepInterpolate(raw);
79
+ if (typeof interp.deviceId !== 'string' || interp.deviceId.length === 0) {
80
+ throw new Error('config: deviceId is required (non-empty string)');
81
+ }
82
+ if (typeof interp.networkId !== 'string' || interp.networkId.length === 0) {
83
+ throw new Error('config: networkId is required (non-empty string)');
84
+ }
85
+ const s = interp.server ?? {};
86
+ if (typeof s.url !== 'string' || typeof s.token !== 'string') {
87
+ throw new Error('config: server.url and server.token are required');
88
+ }
89
+ const agents = interp.agents ?? [];
90
+ if (!Array.isArray(agents) || agents.length === 0) {
91
+ throw new Error('config: agents array is required (at least one agent)');
92
+ }
93
+ const parsedAgents = [];
94
+ for (const a of agents) {
95
+ if (typeof a.id !== 'string' || a.id.length === 0) {
96
+ throw new Error('config: each agent must have an id (non-empty string)');
97
+ }
98
+ if (typeof a.name !== 'string' || a.name.length === 0) {
99
+ throw new Error('config: each agent must have a name (non-empty string)');
100
+ }
101
+ const ad = a.adapter ?? {};
102
+ if (!KINDS.includes(ad.kind)) {
103
+ throw new Error(`config: adapter.kind must be one of ${KINDS.join(', ')}`);
104
+ }
105
+ if (typeof ad.command !== 'string') {
106
+ throw new Error('config: adapter.command is required');
107
+ }
108
+ const inferredCategory = ad.kind === 'codex' || ad.kind === 'claude-code' ? 'executor-hosted' :
109
+ ad.kind === 'openclaw' || ad.kind === 'hermes' ? 'agentos-hosted' :
110
+ 'standalone-cli';
111
+ const category = typeof a.category === 'string' && ['executor-hosted', 'agentos-hosted', 'standalone-cli'].includes(a.category)
112
+ ? a.category
113
+ : inferredCategory;
114
+ parsedAgents.push({
115
+ id: a.id,
116
+ name: a.name,
117
+ role: typeof a.role === 'string' ? a.role : '',
118
+ category,
119
+ adapter: {
120
+ kind: ad.kind,
121
+ command: ad.command,
122
+ args: Array.isArray(ad.args) ? ad.args.map(String) : [],
123
+ cwd: typeof ad.cwd === 'string' ? ad.cwd : undefined,
124
+ workspace: typeof ad.workspace === 'string' ? ad.workspace : undefined,
125
+ systemPrompt: typeof ad.systemPrompt === 'string' ? ad.systemPrompt : undefined,
126
+ },
127
+ visibility: a.visibility === 'public' ? 'public' : 'private',
128
+ sandboxed: a.sandboxed === true,
129
+ });
130
+ }
131
+ return {
132
+ deviceId: interp.deviceId,
133
+ networkId: interp.networkId,
134
+ server: { url: s.url, token: s.token },
135
+ heartbeatIntervalMs: isValidHeartbeat(interp.heartbeatIntervalMs) ? interp.heartbeatIntervalMs : 10_000,
136
+ agents: parsedAgents,
137
+ };
138
+ }
@@ -0,0 +1,113 @@
1
+ import { io } from 'socket.io-client';
2
+ import { logger } from './log.js';
3
+ import { uploadArtifact } from './uploader.js';
4
+ import { postProcess } from './post-process.js';
5
+ export function createConnection(cfg, adapter) {
6
+ let socket = null;
7
+ let heartbeatTimer = null;
8
+ let queue = Promise.resolve();
9
+ return {
10
+ async start() {
11
+ const agentUrl = cfg.server.url.endsWith('/agent') ? cfg.server.url : cfg.server.url + '/agent';
12
+ socket = io(agentUrl, {
13
+ auth: {
14
+ token: cfg.server.token,
15
+ agentId: cfg.id,
16
+ name: cfg.name,
17
+ role: cfg.role,
18
+ adapterKind: cfg.adapter.kind,
19
+ },
20
+ reconnection: true,
21
+ reconnectionDelay: 1_000,
22
+ });
23
+ socket.on('connect', () => {
24
+ logger.info({ id: cfg.id }, 'connected to server');
25
+ socket.emit('register', {
26
+ id: cfg.id, name: cfg.name, role: cfg.role,
27
+ adapterKind: cfg.adapter.kind,
28
+ });
29
+ if (heartbeatTimer)
30
+ clearInterval(heartbeatTimer);
31
+ heartbeatTimer = setInterval(() => {
32
+ socket?.emit('heartbeat', { at: Date.now() });
33
+ }, cfg.heartbeatIntervalMs);
34
+ });
35
+ socket.on('connect_error', (err) => {
36
+ logger.error({ err: err.message }, 'connect_error');
37
+ });
38
+ socket.on('dispatch', (req) => {
39
+ const currentSocket = socket;
40
+ if (!currentSocket) {
41
+ logger.warn({ requestId: req.requestId }, 'dispatch received but socket is null');
42
+ return;
43
+ }
44
+ queue = queue.then(async () => {
45
+ const ctl = new AbortController();
46
+ const dispatchStart = Date.now();
47
+ try {
48
+ const rawBody = await adapter.ask({
49
+ prompt: req.prompt,
50
+ history: req.history ?? [],
51
+ systemPrompt: cfg.adapter.systemPrompt,
52
+ workspace: cfg.adapter.workspace,
53
+ }, ctl.signal);
54
+ const processed = await postProcess(rawBody, cfg.adapter.workspace, cfg.adapter.kind, dispatchStart);
55
+ const artifactIds = [];
56
+ if (processed.outputFiles.length > 0) {
57
+ const httpBase = cfg.server.url.replace(/\/agent$/, '');
58
+ for (const filePath of processed.outputFiles) {
59
+ try {
60
+ const result = await uploadArtifact({
61
+ serverUrl: httpBase,
62
+ token: cfg.server.token,
63
+ networkId: 'default',
64
+ filePath,
65
+ channelId: req.channelId,
66
+ uploaderId: cfg.id,
67
+ });
68
+ if (result)
69
+ artifactIds.push(result.id);
70
+ }
71
+ catch (err) {
72
+ logger.warn({ err: err.message, filePath }, 'artifact upload failed');
73
+ }
74
+ }
75
+ }
76
+ currentSocket.emit('reply', {
77
+ channelId: req.channelId,
78
+ body: processed.replyText,
79
+ requestId: req.requestId,
80
+ artifactIds: artifactIds.length > 0 ? artifactIds : undefined,
81
+ });
82
+ }
83
+ catch (err) {
84
+ logger.error({ err: err.message, requestId: req.requestId }, 'dispatch failed');
85
+ currentSocket.emit('error_event', {
86
+ at: Date.now(),
87
+ message: err.message ?? 'unknown',
88
+ scope: 'reply',
89
+ requestId: req.requestId,
90
+ });
91
+ }
92
+ }).catch((err) => {
93
+ logger.error({ err: err?.message, requestId: req.requestId }, 'dispatch queue error');
94
+ });
95
+ });
96
+ socket.on('disconnect', (reason) => {
97
+ logger.warn({ reason }, 'disconnected');
98
+ if (heartbeatTimer) {
99
+ clearInterval(heartbeatTimer);
100
+ heartbeatTimer = null;
101
+ }
102
+ });
103
+ },
104
+ async stop() {
105
+ if (heartbeatTimer) {
106
+ clearInterval(heartbeatTimer);
107
+ heartbeatTimer = null;
108
+ }
109
+ socket?.close();
110
+ socket = null;
111
+ },
112
+ };
113
+ }
@@ -0,0 +1,212 @@
1
+ import { io } from 'socket.io-client';
2
+ import { existsSync, readFileSync, mkdirSync, writeFileSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { homedir } from 'node:os';
5
+ import { logger } from './log.js';
6
+ import { scanRuntimes, scanAgentOSAgents, scanLocalAgents, collectSystemInfo } from './scanner.js';
7
+ const CACHE_DIR = join(homedir(), '.agentbean');
8
+ const CACHE_FILE = join(CACHE_DIR, 'scanned-agents.json');
9
+ function loadCache() {
10
+ try {
11
+ if (!existsSync(CACHE_FILE))
12
+ return null;
13
+ return JSON.parse(readFileSync(CACHE_FILE, 'utf-8'));
14
+ }
15
+ catch {
16
+ return null;
17
+ }
18
+ }
19
+ function saveCache(agents) {
20
+ try {
21
+ if (!existsSync(CACHE_DIR))
22
+ mkdirSync(CACHE_DIR, { recursive: true });
23
+ writeFileSync(CACHE_FILE, JSON.stringify(agents, null, 2));
24
+ }
25
+ catch (err) {
26
+ logger.warn({ err: err?.message }, 'failed to save scan cache');
27
+ }
28
+ }
29
+ async function scanAll() {
30
+ const [runtimes, agentos, local] = await Promise.all([
31
+ scanRuntimes(),
32
+ scanAgentOSAgents(),
33
+ scanLocalAgents(),
34
+ ]);
35
+ const results = [];
36
+ // Runtimes (executor-hosted) — only installed ones
37
+ for (const rt of runtimes) {
38
+ if (rt.installed) {
39
+ results.push({
40
+ name: rt.name,
41
+ category: 'executor-hosted',
42
+ adapterKind: rt.adapterKind,
43
+ command: rt.command,
44
+ args: [],
45
+ source: 'scanned',
46
+ });
47
+ }
48
+ }
49
+ // AgentOS + standalone (from gateway and filesystem scans)
50
+ const seen = new Set();
51
+ for (const ag of agentos) {
52
+ if (!seen.has(ag.command)) {
53
+ seen.add(ag.command);
54
+ results.push({ ...ag, source: 'scanned' });
55
+ }
56
+ }
57
+ for (const ag of local) {
58
+ if (!seen.has(ag.command)) {
59
+ seen.add(ag.command);
60
+ results.push({ ...ag, source: 'scanned' });
61
+ }
62
+ }
63
+ return results;
64
+ }
65
+ export function createDeviceDaemon(cfg, agents) {
66
+ let socket = null;
67
+ let heartbeatTimer = null;
68
+ const queues = new Map();
69
+ const httpBase = cfg.server.url.replace(/\/agent$/, '');
70
+ let firstConnect = true;
71
+ const systemInfo = collectSystemInfo();
72
+ const publicAgents = Array.from(agents.values())
73
+ .filter((a) => a.visibility === 'public')
74
+ .map((a) => a.publicMeta);
75
+ function emitRegister(sock, scanned) {
76
+ if (scanned.length === 0)
77
+ return;
78
+ sock.emit('device:register-agents', { agents: scanned }, (ack) => {
79
+ if (ack?.ok) {
80
+ logger.info({ count: ack.agents?.length }, 'scanned agents registered');
81
+ }
82
+ else {
83
+ logger.warn({ error: ack?.error }, 'failed to register scanned agents');
84
+ }
85
+ });
86
+ }
87
+ async function scanAndRegister(sock, useCache) {
88
+ if (useCache) {
89
+ const cached = loadCache();
90
+ if (cached) {
91
+ logger.info({ count: cached.length }, 'using cached scan results');
92
+ emitRegister(sock, cached);
93
+ // Background refresh — only emit if results differ
94
+ scanAll().then((fresh) => {
95
+ saveCache(fresh);
96
+ const cachedKey = JSON.stringify(cached.map((a) => a.command).sort());
97
+ const freshKey = JSON.stringify(fresh.map((a) => a.command).sort());
98
+ if (cachedKey !== freshKey) {
99
+ logger.info({ count: fresh.length }, 'scan results changed, updating');
100
+ emitRegister(sock, fresh);
101
+ }
102
+ }).catch((err) => {
103
+ logger.warn({ err: err?.message }, 'background scan failed');
104
+ });
105
+ return;
106
+ }
107
+ }
108
+ // Full scan (no cache or cache miss)
109
+ try {
110
+ const scanned = await scanAll();
111
+ saveCache(scanned);
112
+ emitRegister(sock, scanned);
113
+ }
114
+ catch (err) {
115
+ logger.error({ err: err?.message }, 'scan failed');
116
+ }
117
+ }
118
+ return {
119
+ async start() {
120
+ const agentUrl = cfg.server.url.endsWith('/agent') ? cfg.server.url : cfg.server.url + '/agent';
121
+ socket = io(agentUrl, {
122
+ auth: {
123
+ token: cfg.server.token,
124
+ deviceId: cfg.deviceId,
125
+ networkId: cfg.networkId,
126
+ agents: publicAgents,
127
+ systemInfo,
128
+ },
129
+ transports: ['websocket'],
130
+ reconnection: true,
131
+ reconnectionDelay: 1_000,
132
+ });
133
+ socket.on('connect', () => {
134
+ const reconnecting = !firstConnect;
135
+ firstConnect = false;
136
+ logger.info({ deviceId: cfg.deviceId, sid: socket.id, reconnecting }, 'device daemon connected');
137
+ socket.emit('register');
138
+ // Reconnect: skip scan entirely (server already has our agents)
139
+ // First connect: use cache if available, otherwise full scan
140
+ if (!reconnecting) {
141
+ scanAndRegister(socket, true);
142
+ }
143
+ if (heartbeatTimer)
144
+ clearInterval(heartbeatTimer);
145
+ heartbeatTimer = setInterval(() => {
146
+ socket?.emit('heartbeat');
147
+ }, cfg.heartbeatIntervalMs);
148
+ });
149
+ socket.on('connect_error', (err) => {
150
+ logger.error({ err: err.message }, 'connect_error');
151
+ });
152
+ socket.on('dispatch', (req) => {
153
+ const agent = agents.get(req.agentId);
154
+ if (!agent) {
155
+ logger.warn({ agentId: req.agentId, requestId: req.requestId }, 'dispatch for unknown agent');
156
+ socket?.emit('error_event', {
157
+ agentId: req.agentId,
158
+ at: Date.now(),
159
+ message: `agent ${req.agentId} not found on this device`,
160
+ scope: 'dispatch',
161
+ requestId: req.requestId,
162
+ });
163
+ return;
164
+ }
165
+ // Serialize dispatches per agent to avoid concurrent adapter usage
166
+ const currentSocket = socket;
167
+ if (!currentSocket) {
168
+ logger.warn({ agentId: req.agentId, requestId: req.requestId }, 'dispatch received but socket is null');
169
+ return;
170
+ }
171
+ const prev = queues.get(req.agentId) ?? Promise.resolve();
172
+ const next = prev.then(async () => {
173
+ await agent.handleDispatch({
174
+ socket: currentSocket,
175
+ req,
176
+ serverUrl: httpBase,
177
+ token: cfg.server.token,
178
+ networkId: cfg.networkId,
179
+ });
180
+ }).catch((err) => {
181
+ logger.error({ err: err?.message, agentId: req.agentId }, 'dispatch queue error');
182
+ currentSocket.emit('error_event', {
183
+ agentId: req.agentId,
184
+ at: Date.now(),
185
+ message: err?.message ?? 'unknown',
186
+ scope: 'reply',
187
+ requestId: req.requestId,
188
+ });
189
+ });
190
+ queues.set(req.agentId, next);
191
+ });
192
+ socket.on('agents:discover', async () => {
193
+ await scanAndRegister(socket, false);
194
+ });
195
+ socket.on('disconnect', (reason) => {
196
+ logger.warn({ reason }, 'device daemon disconnected');
197
+ if (heartbeatTimer) {
198
+ clearInterval(heartbeatTimer);
199
+ heartbeatTimer = null;
200
+ }
201
+ });
202
+ },
203
+ async stop() {
204
+ if (heartbeatTimer) {
205
+ clearInterval(heartbeatTimer);
206
+ heartbeatTimer = null;
207
+ }
208
+ socket?.close();
209
+ socket = null;
210
+ },
211
+ };
212
+ }