@lightcone-ai/daemon 0.23.5 → 0.23.7

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.
@@ -14,8 +14,9 @@
14
14
  // or unsafe coordinates throw — no silent skipping.
15
15
 
16
16
  import { ATOMS, ATOM_NAMES } from './atoms.js';
17
- import { findOverlappingUnsafeRegion } from '../understanding/schema.js';
17
+ import { findBlockById, findOverlappingUnsafeRegion } from '../understanding/schema.js';
18
18
  import { getCdpSession } from '../cdp-touch.js';
19
+ import { setSpotlight, clearSpotlight } from './spotlight.js';
19
20
 
20
21
  const V5_FIELDS_ON_SECTION = Object.freeze([
21
22
  'action',
@@ -93,7 +94,70 @@ function assertYNotInUnsafeRegion(y, { unsafeRegions, atomName, sectionId, opera
93
94
  }
94
95
  }
95
96
 
96
- function validateOperation(op, { sectionId, operationIndex, fullHeightPx, unsafeRegions }) {
97
+ // Resolve a scroll_to operation's target scrollTop. Two mutually-exclusive forms:
98
+ //
99
+ // { block: 'b3' } — frame a content block: the block's middle is placed at
100
+ // the viewport center, then the section holds STILL on it. The recorder
101
+ // owns this geometry so the agent never writes pixels. A block taller than
102
+ // the viewport just shows its centered slice held still — there is no pan,
103
+ // partial visibility is accepted (decided 2026-05-16: 定住,接受看不全).
104
+ //
105
+ // { y: <number> } — raw scrollTop, for content-agnostic moves such as the
106
+ // opening lead-in drift (画面跟内容无关的漫滑).
107
+ //
108
+ // Result is clamped to [0, full_height_px - viewport_height].
109
+ export function resolveScrollTargetY(op, {
110
+ blocks = [],
111
+ viewportHeight = 1920,
112
+ fullHeightPx = null,
113
+ sectionId = '?',
114
+ operationIndex = 0,
115
+ } = {}) {
116
+ const hasBlock = typeof op?.block === 'string' && op.block.trim();
117
+ const hasY = Number.isFinite(Number(op?.y));
118
+
119
+ if (hasBlock && hasY) {
120
+ const error = new Error(
121
+ `operations_invalid: section "${sectionId}" operations[${operationIndex}] sets both `
122
+ + '`block` and `y` on a scroll_to — specify exactly one (block to frame a content block, '
123
+ + 'y for a raw content-agnostic move).',
124
+ );
125
+ error.code = 'OPERATIONS_INVALID';
126
+ throw error;
127
+ }
128
+
129
+ if (hasBlock) {
130
+ const blockId = op.block.trim();
131
+ const block = findBlockById(blocks, blockId);
132
+ if (!block) {
133
+ const known = blocks.map(b => b?.id).filter(Boolean).join(', ') || '(none)';
134
+ const error = new Error(
135
+ `block_not_found: section "${sectionId}" operations[${operationIndex}].block="${blockId}" `
136
+ + `is not a block id in page_understanding.blocks. Known ids: ${known}.`,
137
+ );
138
+ error.code = 'BLOCK_NOT_FOUND';
139
+ throw error;
140
+ }
141
+ // Center the block. A block taller than the viewport shows its centered
142
+ // slice — the section then holds still on it (no pan).
143
+ const target = (block.y_top + block.y_bottom) / 2 - viewportHeight / 2;
144
+ const maxScroll = Number.isFinite(fullHeightPx) && fullHeightPx > 0
145
+ ? Math.max(0, fullHeightPx - viewportHeight)
146
+ : Number.POSITIVE_INFINITY;
147
+ return Math.round(Math.min(Math.max(target, 0), maxScroll));
148
+ }
149
+
150
+ if (hasY) return Math.round(Number(op.y));
151
+
152
+ const error = new Error(
153
+ `operations_invalid: section "${sectionId}" operations[${operationIndex}] is a scroll_to with `
154
+ + 'neither `block` nor `y`. Set `block` (centers/frames a content block) or `y` (raw scrollTop).',
155
+ );
156
+ error.code = 'OPERATIONS_INVALID';
157
+ throw error;
158
+ }
159
+
160
+ function validateOperation(op, { sectionId, operationIndex, fullHeightPx, unsafeRegions, blocks, viewportHeight }) {
97
161
  if (!op || typeof op !== 'object' || Array.isArray(op)) {
98
162
  const error = new Error(
99
163
  `operations_invalid: section "${sectionId}" operations[${operationIndex}] is not an object.`,
@@ -120,9 +184,15 @@ function validateOperation(op, { sectionId, operationIndex, fullHeightPx, unsafe
120
184
  throw error;
121
185
  }
122
186
 
187
+ let scrollTargetY = null;
123
188
  if (atomName === 'scroll_to') {
124
- assertYWithinBounds(Number(op.y), { fullHeightPx, atomName, sectionId, operationIndex });
125
- assertYNotInUnsafeRegion(Number(op.y), { unsafeRegions, atomName, sectionId, operationIndex });
189
+ // scroll_to.y is no longer agent-authored pixels — resolveScrollTargetY
190
+ // turns { block, align } into a clamped scrollTop. Raw { y } is still
191
+ // accepted for content-agnostic moves. The resolved value is what gets
192
+ // bounds/unsafe-checked and handed to the atom.
193
+ scrollTargetY = resolveScrollTargetY(op, { blocks, viewportHeight, fullHeightPx, sectionId, operationIndex });
194
+ assertYWithinBounds(scrollTargetY, { fullHeightPx, atomName, sectionId, operationIndex });
195
+ assertYNotInUnsafeRegion(scrollTargetY, { unsafeRegions, atomName, sectionId, operationIndex });
126
196
  } else if (atomName === 'cursor_focus') {
127
197
  assertYWithinBounds(Number(op.y), { fullHeightPx, atomName, sectionId, operationIndex });
128
198
  assertYNotInUnsafeRegion(Number(op.y), { unsafeRegions, atomName, sectionId, operationIndex });
@@ -134,16 +204,19 @@ function validateOperation(op, { sectionId, operationIndex, fullHeightPx, unsafe
134
204
  throw error;
135
205
  }
136
206
  }
137
- return atomName;
207
+ return { atomName, scrollTargetY };
138
208
  }
139
209
 
140
- function operationToAtomParams(op, atomName, anchorY) {
210
+ function operationToAtomParams(op, atomName, anchorY, scrollTargetY) {
141
211
  if (atomName === 'scroll_to') {
142
212
  return {
143
- target_y: Number(op.y),
213
+ // scrollTargetY is resolved by resolveScrollTargetY (block-framed or raw y).
214
+ target_y: scrollTargetY,
144
215
  duration_ms: Number(op.duration_ms),
145
216
  curve: op.curve || 'easeInOutQuad',
146
- jitter_px: Number.isFinite(Number(op.jitter_px)) ? Number(op.jitter_px) : 2,
217
+ // Default 0 — no pixel jitter (user requirement, repeatedly stated).
218
+ // Only consulted by touch mode; auto/programmatic ignore it.
219
+ jitter_px: Number.isFinite(Number(op.jitter_px)) ? Number(op.jitter_px) : 0,
147
220
  from_y: anchorY,
148
221
  mode: op.mode || 'auto',
149
222
  };
@@ -198,22 +271,45 @@ export function normalizePlanSections(plan = {}) {
198
271
  async function runOperations(page, ctx, operations, {
199
272
  fullHeightPx,
200
273
  unsafeRegions,
274
+ blocks,
275
+ viewportHeight,
276
+ viewportWidth,
201
277
  sectionId,
202
278
  fallbackAnchorY,
203
279
  }) {
204
280
  let anchorY = fallbackAnchorY;
281
+ // Spotlight off during the transition scroll — it appears once the section's
282
+ // block has landed centered (set right after the scroll_to below).
283
+ await clearSpotlight(page);
205
284
  for (let i = 0; i < operations.length; i += 1) {
206
285
  const op = operations[i];
207
- const atomName = validateOperation(op, {
286
+ const { atomName, scrollTargetY } = validateOperation(op, {
208
287
  sectionId,
209
288
  operationIndex: i,
210
289
  fullHeightPx,
211
290
  unsafeRegions,
291
+ blocks,
292
+ viewportHeight,
212
293
  });
213
- const params = operationToAtomParams(op, atomName, anchorY);
294
+ const params = operationToAtomParams(op, atomName, anchorY, scrollTargetY);
214
295
  const atomFn = ATOMS[atomName];
215
296
  const result = await atomFn(page, ctx, params);
216
297
  if (result?.anchorY != null) anchorY = result.anchorY;
298
+ // Once a scroll_to has landed on a content block, frame it: bordered box
299
+ // around the block + the rest of the page dimmed. The box then stays for
300
+ // the section's hold.
301
+ if (atomName === 'scroll_to' && typeof op.block === 'string' && op.block.trim()) {
302
+ const blk = findBlockById(blocks, op.block.trim());
303
+ if (blk) {
304
+ await setSpotlight(page, {
305
+ yTop: blk.y_top,
306
+ yBottom: blk.y_bottom,
307
+ viewportTop: scrollTargetY,
308
+ viewportHeight,
309
+ viewportWidth,
310
+ });
311
+ }
312
+ }
217
313
  }
218
314
  return { anchorY };
219
315
  }
@@ -230,6 +326,11 @@ function createEvent({ tMs, action, sectionId, detail = {} }) {
230
326
 
231
327
  export async function executePlanPhases(page, plan, {
232
328
  pageUnderstanding = null,
329
+ // The actual record-browser viewport height — used to frame block-referenced
330
+ // scroll_to operations. Falls back to page_understanding.viewport.height,
331
+ // then 1920. Pass the real recorder viewport so centering is pixel-correct.
332
+ viewportHeight = null,
333
+ viewportWidth = null,
233
334
  getNowMs = () => Date.now(),
234
335
  onEvent = null,
235
336
  } = {}) {
@@ -238,6 +339,13 @@ export async function executePlanPhases(page, plan, {
238
339
  const unsafeRegions = Array.isArray(pageUnderstanding?.unsafe_regions)
239
340
  ? pageUnderstanding.unsafe_regions
240
341
  : [];
342
+ const blocks = Array.isArray(pageUnderstanding?.blocks) ? pageUnderstanding.blocks : [];
343
+ const resolvedViewportHeight = Number(viewportHeight)
344
+ || Number(pageUnderstanding?.viewport?.height)
345
+ || 1920;
346
+ const resolvedViewportWidth = Number(viewportWidth)
347
+ || Number(pageUnderstanding?.viewport?.width)
348
+ || 1080;
241
349
 
242
350
  const startedAt = nowMs(getNowMs);
243
351
  const eventsLog = [];
@@ -263,6 +371,9 @@ export async function executePlanPhases(page, plan, {
263
371
  const result = await runOperations(page, ctx, section.operations, {
264
372
  fullHeightPx,
265
373
  unsafeRegions,
374
+ blocks,
375
+ viewportHeight: resolvedViewportHeight,
376
+ viewportWidth: resolvedViewportWidth,
266
377
  sectionId,
267
378
  fallbackAnchorY: lastAnchorY,
268
379
  });
package/src/cli.js ADDED
@@ -0,0 +1,255 @@
1
+ #!/usr/bin/env node
2
+ import { spawn } from 'node:child_process';
3
+ import { createWriteStream, existsSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
4
+ import { fileURLToPath } from 'node:url';
5
+ import os from 'node:os';
6
+ import path from 'node:path';
7
+
8
+ import {
9
+ ensureLightconeDirs,
10
+ getDaemonStatus,
11
+ isProcessRunning,
12
+ readLocalConfig,
13
+ resolveDaemonLogPath,
14
+ resolveDaemonPidPath,
15
+ writeLocalConfig,
16
+ } from './local-config.js';
17
+ import { runDoctor } from './doctor.js';
18
+
19
+ function parseArgs(raw) {
20
+ const opts = { _: [] };
21
+ for (let i = 0; i < raw.length; i += 1) {
22
+ const arg = raw[i];
23
+ if (arg.startsWith('--')) {
24
+ const next = raw[i + 1];
25
+ if (next && !next.startsWith('--')) {
26
+ opts[arg] = next;
27
+ i += 1;
28
+ } else {
29
+ opts[arg] = true;
30
+ }
31
+ } else {
32
+ opts._.push(arg);
33
+ }
34
+ }
35
+ return opts;
36
+ }
37
+
38
+ function printUsage() {
39
+ console.log(`Usage:
40
+ lightcone pair --server-url <url> --code <6-digit>
41
+ lightcone pair --server-url <url> --api-key <sk_machine_...>
42
+ lightcone daemon start
43
+ lightcone daemon stop
44
+ lightcone daemon restart
45
+ lightcone daemon status
46
+ lightcone status
47
+ lightcone doctor [--json]
48
+ lightcone logs [--lines 100]
49
+
50
+ Notes:
51
+ Use --code for normal onboarding, or --api-key for advanced/server installs.`);
52
+ }
53
+
54
+ function printJson(value) {
55
+ console.log(JSON.stringify(value, null, 2));
56
+ }
57
+
58
+ function redactConfig(config) {
59
+ const token = String(config?.machineApiKey || '');
60
+ return {
61
+ ...config,
62
+ machineApiKey: token ? `${token.slice(0, 10)}...` : '',
63
+ localApiToken: config?.localApiToken ? `${String(config.localApiToken).slice(0, 12)}...` : '',
64
+ };
65
+ }
66
+
67
+ function requirePairedConfig() {
68
+ const config = readLocalConfig();
69
+ if (!config.serverUrl || !config.machineApiKey) {
70
+ throw new Error('not_paired: run `lightcone pair --server-url <url> --code <6-digit>` first');
71
+ }
72
+ return config;
73
+ }
74
+
75
+ async function exchangePairingCode({ serverUrl, code, name }) {
76
+ const endpoint = new URL('/api/servers/machine-pairing/exchange', serverUrl);
77
+ const response = await fetch(endpoint, {
78
+ method: 'POST',
79
+ headers: { 'content-type': 'application/json' },
80
+ body: JSON.stringify({
81
+ code,
82
+ name,
83
+ hostname: os.hostname(),
84
+ os: `${os.platform()} ${os.arch()} ${os.release()}`,
85
+ }),
86
+ });
87
+ let payload = null;
88
+ try { payload = await response.json(); } catch {}
89
+ if (!response.ok) {
90
+ throw new Error(`pair_code_exchange_failed:${response.status}:${payload?.error ?? response.statusText}`);
91
+ }
92
+ const apiKey = String(payload?.apiKey ?? '').trim();
93
+ if (!apiKey) throw new Error('pair_code_exchange_missing_api_key');
94
+ return payload;
95
+ }
96
+
97
+ function daemonEntryPath() {
98
+ return fileURLToPath(new URL('./index.js', import.meta.url));
99
+ }
100
+
101
+ function startDaemon({ foreground = false } = {}) {
102
+ const config = requirePairedConfig();
103
+ ensureLightconeDirs();
104
+
105
+ const current = getDaemonStatus();
106
+ if (current.running) {
107
+ console.log(`Daemon already running pid=${current.pid}`);
108
+ return current;
109
+ }
110
+
111
+ const args = [
112
+ daemonEntryPath(),
113
+ '--server-url', config.serverUrl,
114
+ '--api-key', config.machineApiKey,
115
+ ];
116
+
117
+ if (foreground) {
118
+ const child = spawn(process.execPath, args, { stdio: 'inherit' });
119
+ child.on('exit', (code, signal) => process.exit(code ?? (signal ? 1 : 0)));
120
+ return { running: true, foreground: true };
121
+ }
122
+
123
+ const logPath = resolveDaemonLogPath();
124
+ const out = createWriteStream(logPath, { flags: 'a' });
125
+ const child = spawn(process.execPath, args, {
126
+ detached: true,
127
+ stdio: ['ignore', out, out],
128
+ env: {
129
+ ...process.env,
130
+ SERVER_URL: config.serverUrl,
131
+ MACHINE_API_KEY: config.machineApiKey,
132
+ },
133
+ });
134
+ child.unref();
135
+ writeFileSync(resolveDaemonPidPath(), `${child.pid}\n`, 'utf8');
136
+ console.log(`Daemon started pid=${child.pid}`);
137
+ console.log(`Logs: ${logPath}`);
138
+ return getDaemonStatus();
139
+ }
140
+
141
+ function stopDaemon() {
142
+ const status = getDaemonStatus();
143
+ if (!status.pid) {
144
+ console.log('Daemon is not running.');
145
+ return status;
146
+ }
147
+ if (!isProcessRunning(status.pid)) {
148
+ try { unlinkSync(status.pidPath); } catch {}
149
+ console.log('Removed stale daemon pid file.');
150
+ return getDaemonStatus();
151
+ }
152
+ process.kill(status.pid, 'SIGTERM');
153
+ console.log(`Daemon stop requested pid=${status.pid}`);
154
+ return status;
155
+ }
156
+
157
+ function tailLogs(lines = 100) {
158
+ const logPath = resolveDaemonLogPath();
159
+ if (!existsSync(logPath)) {
160
+ console.log(`No daemon log found at ${logPath}`);
161
+ return;
162
+ }
163
+ const raw = readFileSync(logPath, 'utf8');
164
+ const count = Number.parseInt(String(lines), 10);
165
+ console.log(raw.split(/\r?\n/).slice(-(Number.isFinite(count) && count > 0 ? count : 100)).join('\n'));
166
+ }
167
+
168
+ async function main() {
169
+ const opts = parseArgs(process.argv.slice(2));
170
+ const [command, subcommand] = opts._;
171
+
172
+ if (!command || opts['--help'] || opts['-h']) {
173
+ printUsage();
174
+ return;
175
+ }
176
+
177
+ if (command === 'pair') {
178
+ const serverUrl = String(opts['--server-url'] || '').trim();
179
+ const code = String(opts['--code'] || '').trim();
180
+ let machineApiKey = String(opts['--api-key'] || '').trim();
181
+ if (!serverUrl) {
182
+ throw new Error('pair_requires_server_url');
183
+ }
184
+ let exchangePayload = null;
185
+ if (!machineApiKey && code) {
186
+ exchangePayload = await exchangePairingCode({
187
+ serverUrl,
188
+ code,
189
+ name: opts['--name'],
190
+ });
191
+ machineApiKey = exchangePayload.apiKey;
192
+ }
193
+ if (!machineApiKey) {
194
+ throw new Error('pair_requires_api_key_or_code');
195
+ }
196
+ const config = writeLocalConfig({ serverUrl, machineApiKey });
197
+ console.log(`Paired with ${config.serverUrl}`);
198
+ if (exchangePayload?.machine?.id) console.log(`Machine: ${exchangePayload.machine.id}`);
199
+ console.log('Config saved.');
200
+ return;
201
+ }
202
+
203
+ if (command === 'status' || (command === 'daemon' && subcommand === 'status')) {
204
+ const status = getDaemonStatus();
205
+ if (opts['--json']) return printJson({ ...status, config: redactConfig(readLocalConfig()) });
206
+ console.log(status.running ? `Daemon running pid=${status.pid}` : 'Daemon stopped');
207
+ console.log(`Home: ${status.home}`);
208
+ console.log(`Config: ${status.configPath}`);
209
+ console.log(`Logs: ${status.logPath}`);
210
+ return;
211
+ }
212
+
213
+ if (command === 'doctor') {
214
+ const result = runDoctor();
215
+ if (opts['--json']) return printJson(result);
216
+ for (const [name, item] of Object.entries(result.required)) {
217
+ console.log(`${item.ok ? 'ok' : 'missing'} required ${name}: ${item.version || item.error}`);
218
+ }
219
+ for (const [name, item] of Object.entries(result.recommended)) {
220
+ console.log(`${item.ok ? 'ok' : 'missing'} recommended ${name}: ${item.version || item.error}`);
221
+ }
222
+ for (const [name, item] of Object.entries(result.runtimes)) {
223
+ console.log(`${item.ok ? 'ok' : 'missing'} runtime ${name}: ${item.version || item.error}`);
224
+ }
225
+ return;
226
+ }
227
+
228
+ if (command === 'logs') {
229
+ tailLogs(opts['--lines']);
230
+ return;
231
+ }
232
+
233
+ if (command === 'daemon') {
234
+ if (subcommand === 'start') {
235
+ startDaemon({ foreground: !!opts['--foreground'] });
236
+ return;
237
+ }
238
+ if (subcommand === 'stop') {
239
+ stopDaemon();
240
+ return;
241
+ }
242
+ if (subcommand === 'restart') {
243
+ stopDaemon();
244
+ setTimeout(() => startDaemon(), 1000);
245
+ return;
246
+ }
247
+ }
248
+
249
+ throw new Error(`unknown_command:${[command, subcommand].filter(Boolean).join(' ')}`);
250
+ }
251
+
252
+ main().catch((error) => {
253
+ console.error(`Error: ${error.message}`);
254
+ process.exitCode = 1;
255
+ });
package/src/doctor.js ADDED
@@ -0,0 +1,52 @@
1
+ import { spawnSync } from 'node:child_process';
2
+
3
+ function commandExists(command, args = ['--version']) {
4
+ const result = spawnSync(command, args, {
5
+ encoding: 'utf8',
6
+ timeout: 5000,
7
+ });
8
+ return {
9
+ ok: result.status === 0,
10
+ command,
11
+ version: String(result.stdout || result.stderr || '').split('\n')[0].trim(),
12
+ error: result.error?.message || (result.status === 0 ? '' : String(result.stderr || '').trim()),
13
+ };
14
+ }
15
+
16
+ function firstAvailable(candidates) {
17
+ for (const candidate of candidates) {
18
+ const result = commandExists(candidate.command, candidate.args);
19
+ if (result.ok) return result;
20
+ }
21
+ return {
22
+ ok: false,
23
+ command: candidates.map(candidate => candidate.command).join('|'),
24
+ version: '',
25
+ error: 'not_found',
26
+ };
27
+ }
28
+
29
+ export function runDoctor() {
30
+ const node = commandExists('node', ['--version']);
31
+ const npm = commandExists('npm', ['--version']);
32
+ const chrome = firstAvailable([
33
+ { command: 'google-chrome', args: ['--version'] },
34
+ { command: 'google-chrome-stable', args: ['--version'] },
35
+ { command: 'chromium-browser', args: ['--version'] },
36
+ { command: 'chromium', args: ['--version'] },
37
+ ]);
38
+ const ffmpeg = commandExists('ffmpeg', ['-version']);
39
+
40
+ const runtimes = {
41
+ claude: commandExists('claude', ['--version']),
42
+ codex: commandExists('codex', ['--version']),
43
+ kimi: commandExists('kimi', ['--version']),
44
+ };
45
+
46
+ return {
47
+ ok: node.ok && npm.ok,
48
+ required: { node, npm },
49
+ recommended: { chrome, ffmpeg },
50
+ runtimes,
51
+ };
52
+ }
package/src/index.js CHANGED
@@ -5,6 +5,8 @@ import { DaemonConnection } from './connection.js';
5
5
  import { AgentManager } from './agent-manager.js';
6
6
  import { releaseProfileLocksForProcess } from './profile-lock.js';
7
7
  import { resolveLightconeServerUrl } from './runtime-config.js';
8
+ import { readLocalConfig } from './local-config.js';
9
+ import { startLocalApi } from './local-api.js';
8
10
 
9
11
  const { version } = createRequire(import.meta.url)('../package.json');
10
12
 
@@ -30,6 +32,10 @@ function parseArgs(raw) {
30
32
  function printUsage() {
31
33
  console.log('Usage:');
32
34
  console.log(' lightcone-daemon --server-url <url> --api-key <key>');
35
+ console.log('');
36
+ console.log('Or configure once and start via:');
37
+ console.log(' lightcone pair --server-url <url> --code <6-digit>');
38
+ console.log(' lightcone daemon start');
33
39
  }
34
40
 
35
41
  // ── CLI args ──────────────────────────────────────────────────────────────────
@@ -40,8 +46,26 @@ if (opts['--help'] || opts['-h']) {
40
46
  process.exit(0);
41
47
  }
42
48
 
43
- const SERVER_URL = String(opts['--server-url'] || resolveLightconeServerUrl()).trim();
44
- const MACHINE_API_KEY = String(opts['--api-key'] || process.env.MACHINE_API_KEY || '').trim();
49
+ let localConfig = {};
50
+ try {
51
+ localConfig = readLocalConfig();
52
+ } catch (error) {
53
+ console.error(`Error: ${error.message}`);
54
+ process.exit(1);
55
+ }
56
+
57
+ const SERVER_URL = String(
58
+ opts['--server-url']
59
+ || process.env.SERVER_URL
60
+ || localConfig.serverUrl
61
+ || resolveLightconeServerUrl()
62
+ ).trim();
63
+ const MACHINE_API_KEY = String(
64
+ opts['--api-key']
65
+ || process.env.MACHINE_API_KEY
66
+ || localConfig.machineApiKey
67
+ || ''
68
+ ).trim();
45
69
 
46
70
  if (!MACHINE_API_KEY) {
47
71
  console.error('Error: API key is required.');
@@ -61,6 +85,7 @@ const connection = new DaemonConnection({
61
85
  getAgentInventory: () => agentManager.getAgentInventory(),
62
86
  });
63
87
 
88
+ let localApi = null;
64
89
  connection.connect();
65
90
 
66
91
  let shuttingDown = false;
@@ -68,12 +93,21 @@ async function shutdown(signal) {
68
93
  if (shuttingDown) return;
69
94
  shuttingDown = true;
70
95
  console.log(`[Daemon] Shutting down (${signal})`);
96
+ try { localApi?.close(); } catch {}
71
97
  connection.stop();
72
98
  try { await agentManager.stopAll(); } catch (err) { console.error('[Daemon] Shutdown error:', err.message); }
73
99
  releaseProfileLocksForProcess();
74
100
  process.exit(0);
75
101
  }
76
102
 
103
+ localApi = startLocalApi({
104
+ serverUrl: SERVER_URL,
105
+ version,
106
+ connection,
107
+ agentManager,
108
+ onStop: shutdown,
109
+ });
110
+
77
111
  process.on('SIGINT', () => { shutdown('SIGINT'); });
78
112
  process.on('SIGTERM', () => { shutdown('SIGTERM'); });
79
113
  process.on('SIGHUP', () => { shutdown('SIGHUP'); });
@@ -0,0 +1,106 @@
1
+ import http from 'node:http';
2
+ import { readFileSync } from 'node:fs';
3
+
4
+ import { getDaemonStatus, readLocalConfig, resolveDaemonLogPath } from './local-config.js';
5
+ import { runDoctor } from './doctor.js';
6
+
7
+ function sendJson(res, statusCode, payload) {
8
+ const body = `${JSON.stringify(payload, null, 2)}\n`;
9
+ res.writeHead(statusCode, {
10
+ 'content-type': 'application/json; charset=utf-8',
11
+ 'content-length': Buffer.byteLength(body),
12
+ });
13
+ res.end(body);
14
+ }
15
+
16
+ function bearerToken(req) {
17
+ const auth = String(req.headers.authorization || '').trim();
18
+ if (auth.toLowerCase().startsWith('bearer ')) return auth.slice(7).trim();
19
+ return String(req.headers['x-lightcone-local-token'] || '').trim();
20
+ }
21
+
22
+ function isAuthorized(req, config) {
23
+ const expected = String(config.localApiToken || '').trim();
24
+ return !!expected && bearerToken(req) === expected;
25
+ }
26
+
27
+ function safeConnectionState(connection) {
28
+ const ws = connection?.ws;
29
+ return {
30
+ stopped: !!connection?.stopped,
31
+ readyState: ws?.readyState ?? null,
32
+ connected: ws?.readyState === 1,
33
+ };
34
+ }
35
+
36
+ export function startLocalApi({
37
+ serverUrl,
38
+ version,
39
+ connection,
40
+ agentManager,
41
+ onStop,
42
+ env = process.env,
43
+ } = {}) {
44
+ const config = readLocalConfig(env);
45
+ const port = Number.parseInt(String(env.LIGHTCONE_LOCAL_API_PORT || config.localApiPort || 19876), 10) || 19876;
46
+
47
+ const server = http.createServer((req, res) => {
48
+ const url = new URL(req.url || '/', `http://${req.headers.host || '127.0.0.1'}`);
49
+
50
+ if (req.method === 'GET' && url.pathname === '/health') {
51
+ return sendJson(res, 200, { ok: true, service: 'lightcone-daemon', version });
52
+ }
53
+
54
+ if (!isAuthorized(req, config)) {
55
+ return sendJson(res, 401, { error: 'unauthorized' });
56
+ }
57
+
58
+ if (req.method === 'GET' && url.pathname === '/status') {
59
+ return sendJson(res, 200, {
60
+ ok: true,
61
+ version,
62
+ serverUrl,
63
+ daemon: getDaemonStatus(env),
64
+ connection: safeConnectionState(connection),
65
+ inventory: typeof agentManager?.getAgentInventory === 'function'
66
+ ? agentManager.getAgentInventory()
67
+ : [],
68
+ });
69
+ }
70
+
71
+ if (req.method === 'GET' && url.pathname === '/doctor') {
72
+ return sendJson(res, 200, runDoctor());
73
+ }
74
+
75
+ if (req.method === 'GET' && url.pathname === '/logs') {
76
+ const lines = Number.parseInt(url.searchParams.get('lines') || '200', 10);
77
+ let text = '';
78
+ try {
79
+ text = readFileSync(resolveDaemonLogPath(env), 'utf8')
80
+ .split(/\r?\n/)
81
+ .slice(-(Number.isFinite(lines) && lines > 0 ? lines : 200))
82
+ .join('\n');
83
+ } catch {}
84
+ res.writeHead(200, { 'content-type': 'text/plain; charset=utf-8' });
85
+ res.end(text);
86
+ return;
87
+ }
88
+
89
+ if (req.method === 'POST' && url.pathname === '/stop') {
90
+ sendJson(res, 202, { ok: true });
91
+ setTimeout(() => onStop?.('LOCAL_API'), 20);
92
+ return;
93
+ }
94
+
95
+ return sendJson(res, 404, { error: 'not_found' });
96
+ });
97
+
98
+ server.listen(port, '127.0.0.1', () => {
99
+ console.log(`[LocalAPI] Listening on http://127.0.0.1:${port}`);
100
+ });
101
+ server.on('error', (error) => {
102
+ console.error(`[LocalAPI] Failed: ${error.message}`);
103
+ });
104
+
105
+ return server;
106
+ }