@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.
- package/mcp-servers/official/media-tools/index.js +45 -30
- package/mcp-servers/official/media-tools/lib/render.js +5 -4
- package/package.json +3 -2
- package/src/_vendor/video/recorder/atoms.js +81 -75
- package/src/_vendor/video/recorder/chromium-driver.js +1 -5
- package/src/_vendor/video/recorder/index.js +15 -130
- package/src/_vendor/video/recorder/plan-executor.js +121 -10
- package/src/cli.js +255 -0
- package/src/doctor.js +52 -0
- package/src/index.js +36 -2
- package/src/local-api.js +106 -0
- package/src/local-config.js +116 -0
- package/src/tools/plan-video-segments.js +66 -60
- package/src/tools/record-url-narration.js +1 -1
- package/src/_vendor/video/recorder/display-pool.js +0 -126
- package/src/_vendor/video/recorder/ffmpeg-runner.js +0 -291
|
@@ -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
|
-
|
|
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
|
-
|
|
125
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
44
|
-
|
|
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'); });
|
package/src/local-api.js
ADDED
|
@@ -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
|
+
}
|