@lightcone-ai/daemon 0.23.6 → 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/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
+ }
@@ -0,0 +1,116 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, chmodSync, statSync } from 'node:fs';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+
6
+ const DEFAULT_CONFIG = Object.freeze({
7
+ serverUrl: '',
8
+ machineApiKey: '',
9
+ localApiPort: 19876,
10
+ localApiToken: '',
11
+ });
12
+
13
+ function normalizeText(value) {
14
+ return typeof value === 'string' ? value.trim() : '';
15
+ }
16
+
17
+ export function resolveLightconeHome(env = process.env) {
18
+ return path.resolve(normalizeText(env.LIGHTCONE_HOME) || path.join(os.homedir(), '.lightcone'));
19
+ }
20
+
21
+ export function resolveConfigPath(env = process.env) {
22
+ return path.join(resolveLightconeHome(env), 'config.json');
23
+ }
24
+
25
+ export function resolveLogsDir(env = process.env) {
26
+ return path.join(resolveLightconeHome(env), 'logs');
27
+ }
28
+
29
+ export function resolveDaemonLogPath(env = process.env) {
30
+ return path.join(resolveLogsDir(env), 'daemon.log');
31
+ }
32
+
33
+ export function resolveDaemonPidPath(env = process.env) {
34
+ return path.join(resolveLightconeHome(env), 'daemon.pid');
35
+ }
36
+
37
+ export function ensureLightconeDirs(env = process.env) {
38
+ const home = resolveLightconeHome(env);
39
+ mkdirSync(home, { recursive: true });
40
+ mkdirSync(resolveLogsDir(env), { recursive: true });
41
+ mkdirSync(path.join(home, 'bin'), { recursive: true });
42
+ mkdirSync(path.join(home, 'chrome-profiles'), { recursive: true });
43
+ return home;
44
+ }
45
+
46
+ export function readLocalConfig(env = process.env) {
47
+ const configPath = resolveConfigPath(env);
48
+ if (!existsSync(configPath)) return { ...DEFAULT_CONFIG };
49
+ try {
50
+ const parsed = JSON.parse(readFileSync(configPath, 'utf8'));
51
+ return {
52
+ ...DEFAULT_CONFIG,
53
+ ...(parsed && typeof parsed === 'object' ? parsed : {}),
54
+ serverUrl: normalizeText(parsed?.serverUrl ?? parsed?.server_url),
55
+ machineApiKey: normalizeText(parsed?.machineApiKey ?? parsed?.machine_api_key),
56
+ localApiPort: Number.parseInt(String(parsed?.localApiPort ?? parsed?.local_api_port ?? DEFAULT_CONFIG.localApiPort), 10) || DEFAULT_CONFIG.localApiPort,
57
+ localApiToken: normalizeText(parsed?.localApiToken ?? parsed?.local_api_token),
58
+ };
59
+ } catch (error) {
60
+ const wrapped = new Error(`local_config_invalid:${error.message}`);
61
+ wrapped.code = 'LOCAL_CONFIG_INVALID';
62
+ wrapped.configPath = configPath;
63
+ throw wrapped;
64
+ }
65
+ }
66
+
67
+ export function writeLocalConfig(config, env = process.env) {
68
+ ensureLightconeDirs(env);
69
+ const configPath = resolveConfigPath(env);
70
+ const current = readLocalConfig(env);
71
+ const next = {
72
+ ...current,
73
+ ...config,
74
+ localApiToken: normalizeText(config?.localApiToken ?? current.localApiToken) || `lc_local_${randomBytes(24).toString('hex')}`,
75
+ updatedAt: new Date().toISOString(),
76
+ };
77
+ writeFileSync(configPath, `${JSON.stringify(next, null, 2)}\n`, 'utf8');
78
+ try { chmodSync(configPath, 0o600); } catch {}
79
+ return next;
80
+ }
81
+
82
+ export function readDaemonPid(env = process.env) {
83
+ const pidPath = resolveDaemonPidPath(env);
84
+ if (!existsSync(pidPath)) return null;
85
+ const raw = normalizeText(readFileSync(pidPath, 'utf8'));
86
+ const pid = Number.parseInt(raw, 10);
87
+ return Number.isFinite(pid) && pid > 0 ? pid : null;
88
+ }
89
+
90
+ export function isProcessRunning(pid) {
91
+ if (!Number.isFinite(Number(pid)) || Number(pid) <= 0) return false;
92
+ try {
93
+ process.kill(Number(pid), 0);
94
+ return true;
95
+ } catch {
96
+ return false;
97
+ }
98
+ }
99
+
100
+ export function getDaemonStatus(env = process.env) {
101
+ const pid = readDaemonPid(env);
102
+ const logPath = resolveDaemonLogPath(env);
103
+ let logSizeBytes = 0;
104
+ try {
105
+ if (existsSync(logPath)) logSizeBytes = statSync(logPath).size;
106
+ } catch {}
107
+ return {
108
+ running: pid ? isProcessRunning(pid) : false,
109
+ pid,
110
+ pidPath: resolveDaemonPidPath(env),
111
+ logPath,
112
+ logSizeBytes,
113
+ configPath: resolveConfigPath(env),
114
+ home: resolveLightconeHome(env),
115
+ };
116
+ }
@@ -142,6 +142,33 @@ function validateReadingFlow(operations, segmentIndex) {
142
142
  }
143
143
  }
144
144
 
145
+ // 1 section ↔ 1 block — the V6 audio/visual sync contract. A section's
146
+ // scroll_to operations may reference at most one distinct block id: the
147
+ // section narrates that block while it sits framed. A section that scrolls
148
+ // across two blocks has no single "what is narrated" → "what is framed"
149
+ // mapping, which is the whole basis of staying in sync. Sections with no
150
+ // block reference (e.g. the opening lead-in drift on raw `y`) are unaffected.
151
+ // A tall block panned over a top-aligned + bottom-aligned scroll_to still
152
+ // references one block id, so it passes.
153
+ function validateSingleBlock(operations, segmentIndex) {
154
+ const ids = new Set();
155
+ for (const op of operations) {
156
+ if (op?.atom === 'scroll_to' && typeof op.block === 'string' && op.block.trim()) {
157
+ ids.add(op.block.trim());
158
+ }
159
+ }
160
+ if (ids.size > 1) {
161
+ const err = new Error(
162
+ `section_spans_multiple_blocks: segments[${segmentIndex}] references ${ids.size} different `
163
+ + `blocks (${[...ids].join(', ')}) in one section. V6 contract: one narration segment ↔ one `
164
+ + 'block — the segment narrates that block while it stays framed. Split this into one segment '
165
+ + 'per block.',
166
+ );
167
+ err.code = 'SECTION_SPANS_MULTIPLE_BLOCKS';
168
+ throw err;
169
+ }
170
+ }
171
+
145
172
  // Process operations[]: expand "fill" on the last hold, validate atom shape.
146
173
  function processOperations(operations, audioDurationMs, segmentIndex) {
147
174
  if (!Array.isArray(operations) || operations.length === 0) {
@@ -214,6 +241,7 @@ function processOperations(operations, audioDurationMs, segmentIndex) {
214
241
  sum += n;
215
242
  }
216
243
  validateReadingFlow(expanded, segmentIndex);
244
+ validateSingleBlock(expanded, segmentIndex);
217
245
  return { operations: expanded, durationSumMs: Math.round(sum) };
218
246
  }
219
247
 
@@ -285,8 +313,13 @@ export async function runPlanVideoSegmentsTool({ segments } = {}) {
285
313
  const perCard = Math.max(2, Math.ceil((totalDuration / numCards) * 2) / 2);
286
314
  presentation = { per_card_duration: perCard };
287
315
  } else {
288
- const duration = planDurationSec(audioDurationMs, kind === 'scroll' ? 1.0 : 0.5);
289
- presentation = { duration, ...(kind === 'scroll' ? { style: 'scroll' } : {}) };
316
+ // Scroll-style images (a tall image ffmpeg pans over) need a longer
317
+ // buffer the eye follows motion slower than it reads a static frame.
318
+ // The scroll style lives on presentation.style, NOT visual_kind
319
+ // (visual_kind is only image / video / gif / carousel).
320
+ const isScroll = String(seg.presentation?.style ?? '') === 'scroll';
321
+ const duration = planDurationSec(audioDurationMs, isScroll ? 1.0 : 0.5);
322
+ presentation = { duration, ...(isScroll ? { style: 'scroll' } : {}) };
290
323
  }
291
324
 
292
325
  planned.push({
@@ -1,6 +1,6 @@
1
1
  // V6 record_url_narration daemon tool wrapper.
2
2
  //
3
- // Drives Chromium on Xvfb + Playwright recordVideo to capture a silent mp4
3
+ // Drives headless Chromium + Playwright recordVideo to capture a silent mp4
4
4
  // per section, then ffmpeg-transcodes + slices. The resulting silent mp4s
5
5
  // feed compose_video_v2 as video-kind segments alongside narration audio.
6
6
  //