@livedesk/hub 0.1.27 → 0.1.28

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@livedesk/hub",
3
- "version": "0.1.27",
3
+ "version": "0.1.28",
4
4
  "description": "LiveDesk local Hub API and browser frame bridge",
5
5
  "type": "module",
6
6
  "main": "src/server.js",
@@ -15,7 +15,7 @@
15
15
  },
16
16
  "dependencies": {
17
17
  "@ffmpeg-installer/ffmpeg": "^1.1.0",
18
- "@livedesk/runtime-core": "0.1.0",
18
+ "@livedesk/runtime-core": "0.1.1",
19
19
  "@openai/codex-sdk": "0.145.0",
20
20
  "cors": "^2.8.5",
21
21
  "express": "^4.21.2",
@@ -30,23 +30,37 @@ export function createAgentManager({
30
30
  const settings = settingsStore || new AgentSettingsStore({ dataDir });
31
31
  const runtime = codexRuntime;
32
32
  let statusCache = { expiresAt: 0, value: null };
33
+ let statusRefreshPromise = null;
34
+ let statusRefreshGeneration = 0;
33
35
 
34
36
  async function getCodexStatus() {
35
37
  if (!runtime) return { installed: false, authenticated: 'unknown', status: 'unavailable', codexPath: '' };
36
38
  if (statusCache.value && statusCache.expiresAt > Date.now()) return statusCache.value;
37
- let value;
39
+ if (statusRefreshPromise) return statusRefreshPromise;
40
+ const generation = statusRefreshGeneration;
41
+ const refresh = (async () => {
42
+ let value;
43
+ try {
44
+ value = publicCodexStatus(await runtime.getStatus());
45
+ } catch (error) {
46
+ value = publicCodexStatus({
47
+ installed: true,
48
+ authenticated: 'unknown',
49
+ status: error?.code || 'security-unavailable',
50
+ detail: error?.message || 'Codex security isolation is unavailable.'
51
+ });
52
+ }
53
+ if (generation === statusRefreshGeneration) {
54
+ statusCache = { value, expiresAt: Date.now() + 10000 };
55
+ }
56
+ return value;
57
+ })();
58
+ statusRefreshPromise = refresh;
38
59
  try {
39
- value = publicCodexStatus(await runtime.getStatus());
40
- } catch (error) {
41
- value = publicCodexStatus({
42
- installed: true,
43
- authenticated: 'unknown',
44
- status: error?.code || 'security-unavailable',
45
- detail: error?.message || 'Codex security isolation is unavailable.'
46
- });
60
+ return await refresh;
61
+ } finally {
62
+ if (statusRefreshPromise === refresh) statusRefreshPromise = null;
47
63
  }
48
- statusCache = { value, expiresAt: Date.now() + 10000 };
49
- return value;
50
64
  }
51
65
 
52
66
  async function publicSettings() {
@@ -65,6 +79,8 @@ export function createAgentManager({
65
79
  async testConnection() {
66
80
  if (!runtime) throw new AgentRuntimeError('codex-sdk-not-installed', 'Codex SDK is not installed.', { status: 503 });
67
81
  statusCache = { expiresAt: 0, value: null };
82
+ statusRefreshGeneration += 1;
83
+ statusRefreshPromise = null;
68
84
  return publicCodexConnection(await runtime.testConnection());
69
85
  },
70
86
  async createSummary(input) {
@@ -1,4 +1,5 @@
1
- import crypto from 'node:crypto';
1
+ import crypto from 'node:crypto';
2
+ import { existsSync } from 'node:fs';
2
3
  import { access, link, mkdir, stat, unlink } from 'node:fs/promises';
3
4
  import os from 'node:os';
4
5
  import path from 'node:path';
@@ -9,10 +10,25 @@ import { AGENT_TOOL_NAMES } from './agent-tool-registry.js';
9
10
  import { createAgentPermissionPolicy, hashAgentPermissionPolicy } from './agent-permissions.js';
10
11
 
11
12
  const require = createRequire(import.meta.url);
12
- const MAX_EVENTS = 240;
13
- const MAX_RUNS = 500;
14
- const RUN_TTL_MS = 60 * 60 * 1000;
15
- const SAFE_ENV_KEYS = [
13
+ const MAX_EVENTS = 240;
14
+ const MAX_RUNS = 500;
15
+ const RUN_TTL_MS = 60 * 60 * 1000;
16
+ const MAX_CODEX_CLI_OUTPUT_BYTES = 64 * 1024;
17
+ const MAX_CODEX_CLI_OUTPUT_CHUNKS = 256;
18
+ const CODEX_CLI_TERMINATE_GRACE_MS = 200;
19
+ const CODEX_CLI_FORCE_CLOSE_MS = 1000;
20
+ const activeCliProcessOwners = new Map();
21
+ let nextCliProcessOwnerId = 0;
22
+ let bundledCodexCliPathCache;
23
+ const CODEX_NATIVE_TARGETS = Object.freeze({
24
+ 'linux:x64': ['@openai/codex-linux-x64', 'x86_64-unknown-linux-musl', 'codex'],
25
+ 'linux:arm64': ['@openai/codex-linux-arm64', 'aarch64-unknown-linux-musl', 'codex'],
26
+ 'darwin:x64': ['@openai/codex-darwin-x64', 'x86_64-apple-darwin', 'codex'],
27
+ 'darwin:arm64': ['@openai/codex-darwin-arm64', 'aarch64-apple-darwin', 'codex'],
28
+ 'win32:x64': ['@openai/codex-win32-x64', 'x86_64-pc-windows-msvc', 'codex.exe'],
29
+ 'win32:arm64': ['@openai/codex-win32-arm64', 'aarch64-pc-windows-msvc', 'codex.exe']
30
+ });
31
+ const SAFE_ENV_KEYS = [
16
32
  'Path',
17
33
  'PATH',
18
34
  'SystemRoot',
@@ -206,33 +222,281 @@ async function ensureCodexHome(codexHome, globalCodexHome, { preferGlobalAuth =
206
222
  }
207
223
  }
208
224
 
209
- function runCli(cliPath, args, timeoutMs = 5000, env = process.env) {
210
- return new Promise((resolve, reject) => {
211
- const child = spawn(process.execPath, [cliPath, ...args], {
212
- env,
213
- windowsHide: true,
214
- stdio: ['ignore', 'pipe', 'pipe']
215
- });
216
- let stdout = '';
217
- let stderr = '';
218
- const timer = setTimeout(() => {
219
- child.kill();
220
- reject(Object.assign(new Error('Codex CLI status check timed out.'), { code: 'codex-status-timeout' }));
221
- }, timeoutMs);
222
- child.stdout.setEncoding('utf8');
223
- child.stderr.setEncoding('utf8');
224
- child.stdout.on('data', chunk => { stdout += chunk; });
225
- child.stderr.on('data', chunk => { stderr += chunk; });
226
- child.once('error', error => {
227
- clearTimeout(timer);
228
- reject(error);
229
- });
230
- child.once('close', code => {
231
- clearTimeout(timer);
232
- resolve({ code, stdout: stdout.trim(), stderr: stderr.trim() });
233
- });
234
- });
235
- }
225
+ function boundedCliOption(value, fallback, minimum = 1) {
226
+ const number = Number(value);
227
+ if (!Number.isFinite(number)) return fallback;
228
+ return Math.min(fallback, Math.max(minimum, Math.floor(number)));
229
+ }
230
+
231
+ export function resolveBundledCodexCliPath() {
232
+ if (bundledCodexCliPathCache !== undefined) return bundledCodexCliPathCache;
233
+ const target = CODEX_NATIVE_TARGETS[`${process.platform}:${process.arch}`];
234
+ if (!target) {
235
+ bundledCodexCliPathCache = '';
236
+ return bundledCodexCliPathCache;
237
+ }
238
+ const [platformPackage, targetTriple, executableName] = target;
239
+ try {
240
+ const packageJsonPath = require.resolve(`${platformPackage}/package.json`);
241
+ const executablePath = path.join(
242
+ path.dirname(packageJsonPath),
243
+ 'vendor',
244
+ targetTriple,
245
+ 'bin',
246
+ executableName
247
+ );
248
+ if (existsSync(executablePath)) {
249
+ bundledCodexCliPathCache = executablePath;
250
+ return bundledCodexCliPathCache;
251
+ }
252
+ } catch {
253
+ // Older Codex packages may keep the platform payload under the portable
254
+ // package. That fallback remains a native executable, never the JS wrapper.
255
+ }
256
+ try {
257
+ const portableCliPath = require.resolve('@openai/codex/bin/codex.js');
258
+ const executablePath = path.join(
259
+ path.dirname(portableCliPath),
260
+ '..',
261
+ 'vendor',
262
+ targetTriple,
263
+ 'bin',
264
+ executableName
265
+ );
266
+ bundledCodexCliPathCache = existsSync(executablePath) ? executablePath : '';
267
+ return bundledCodexCliPathCache;
268
+ } catch {
269
+ bundledCodexCliPathCache = '';
270
+ return bundledCodexCliPathCache;
271
+ }
272
+ }
273
+
274
+ export function getCodexCliProcessOwnerSnapshot() {
275
+ const owners = [...activeCliProcessOwners.values()];
276
+ return {
277
+ activeOwners: owners.length,
278
+ activeProcesses: owners.filter(owner => owner.child !== null).length,
279
+ activeStdoutStreams: owners.filter(owner => owner.stdoutStream !== null).length,
280
+ activeStderrStreams: owners.filter(owner => owner.stderrStream !== null).length,
281
+ activeListeners: owners.reduce((total, owner) => total + owner.listenerCount, 0),
282
+ activeTimers: owners.reduce((total, owner) => total
283
+ + Number(owner.timeoutTimer !== null)
284
+ + Number(owner.forceKillTimer !== null)
285
+ + Number(owner.forceCloseTimer !== null), 0),
286
+ retainedOutputBytes: owners.reduce((total, owner) => total + owner.retainedOutputBytes, 0)
287
+ };
288
+ }
289
+
290
+ export function runCli(
291
+ cliPath,
292
+ args,
293
+ timeoutMs = 5000,
294
+ env = process.env,
295
+ lifecycleOptions = {}
296
+ ) {
297
+ const maxOutputBytes = boundedCliOption(
298
+ lifecycleOptions.maxOutputBytes,
299
+ MAX_CODEX_CLI_OUTPUT_BYTES
300
+ );
301
+ const terminateGraceMs = boundedCliOption(
302
+ lifecycleOptions.terminateGraceMs,
303
+ CODEX_CLI_TERMINATE_GRACE_MS
304
+ );
305
+ const forceCloseMs = boundedCliOption(
306
+ lifecycleOptions.forceCloseMs,
307
+ CODEX_CLI_FORCE_CLOSE_MS
308
+ );
309
+ const boundedTimeoutMs = Math.max(1, Math.floor(Number(timeoutMs) || 5000));
310
+ return new Promise((resolve, reject) => {
311
+ let child;
312
+ try {
313
+ const usesNodeLauncher = /\.(?:c|m)?js$/i.test(cliPath);
314
+ child = spawn(usesNodeLauncher ? process.execPath : cliPath, [
315
+ ...(usesNodeLauncher ? [cliPath] : []),
316
+ ...args
317
+ ], {
318
+ env,
319
+ windowsHide: true,
320
+ stdio: ['ignore', 'pipe', 'pipe']
321
+ });
322
+ } catch (error) {
323
+ reject(error);
324
+ return;
325
+ }
326
+
327
+ const owner = {
328
+ id: ++nextCliProcessOwnerId,
329
+ child,
330
+ stdoutStream: child.stdout,
331
+ stderrStream: child.stderr,
332
+ stdoutChunks: [],
333
+ stderrChunks: [],
334
+ stdoutBytes: 0,
335
+ stderrBytes: 0,
336
+ retainedOutputBytes: 0,
337
+ listenerCount: 6,
338
+ acceptingOutput: true,
339
+ failure: null,
340
+ settled: false,
341
+ timeoutTimer: null,
342
+ forceKillTimer: null,
343
+ forceCloseTimer: null
344
+ };
345
+ activeCliProcessOwners.set(owner.id, owner);
346
+
347
+ const clearOwnerTimer = key => {
348
+ if (owner[key] === null) return;
349
+ clearTimeout(owner[key]);
350
+ owner[key] = null;
351
+ };
352
+
353
+ const removeOutputDataListeners = () => {
354
+ if (!owner.acceptingOutput) return;
355
+ owner.acceptingOutput = false;
356
+ owner.stdoutStream?.removeListener('data', onStdoutData);
357
+ owner.stderrStream?.removeListener('data', onStderrData);
358
+ owner.listenerCount = Math.max(0, owner.listenerCount - 2);
359
+ owner.stdoutStream?.resume();
360
+ owner.stderrStream?.resume();
361
+ };
362
+
363
+ const cleanupOwner = () => {
364
+ clearOwnerTimer('timeoutTimer');
365
+ clearOwnerTimer('forceKillTimer');
366
+ clearOwnerTimer('forceCloseTimer');
367
+ owner.child?.removeListener('error', onChildError);
368
+ owner.child?.removeListener('close', onChildClose);
369
+ owner.stdoutStream?.removeListener('data', onStdoutData);
370
+ owner.stdoutStream?.removeListener('error', onStdoutError);
371
+ owner.stderrStream?.removeListener('data', onStderrData);
372
+ owner.stderrStream?.removeListener('error', onStderrError);
373
+ owner.stdoutStream?.destroy();
374
+ owner.stderrStream?.destroy();
375
+ owner.child?.unref?.();
376
+ owner.child = null;
377
+ owner.stdoutStream = null;
378
+ owner.stderrStream = null;
379
+ owner.stdoutChunks.length = 0;
380
+ owner.stderrChunks.length = 0;
381
+ owner.stdoutBytes = 0;
382
+ owner.stderrBytes = 0;
383
+ owner.retainedOutputBytes = 0;
384
+ owner.listenerCount = 0;
385
+ activeCliProcessOwners.delete(owner.id);
386
+ };
387
+
388
+ const finish = code => {
389
+ if (owner.settled) return;
390
+ owner.settled = true;
391
+ const failure = owner.failure;
392
+ const result = failure ? null : {
393
+ code,
394
+ stdout: Buffer.concat(owner.stdoutChunks, owner.stdoutBytes).toString('utf8').trim(),
395
+ stderr: Buffer.concat(owner.stderrChunks, owner.stderrBytes).toString('utf8').trim()
396
+ };
397
+ cleanupOwner();
398
+ if (failure) reject(failure);
399
+ else resolve(result);
400
+ };
401
+
402
+ const killExactChild = signal => {
403
+ if (owner.child === null || owner.child.exitCode !== null || owner.child.signalCode !== null) return;
404
+ try {
405
+ owner.child.kill(signal);
406
+ } catch {
407
+ // The bounded forced-close owner below still releases every local
408
+ // handle if the OS reports that this exact child already disappeared.
409
+ }
410
+ };
411
+
412
+ const failAfterExactChildStops = error => {
413
+ if (owner.failure || owner.settled) return;
414
+ owner.failure = error;
415
+ clearOwnerTimer('timeoutTimer');
416
+ removeOutputDataListeners();
417
+ killExactChild('SIGTERM');
418
+ owner.forceKillTimer = setTimeout(() => {
419
+ owner.forceKillTimer = null;
420
+ killExactChild('SIGKILL');
421
+ owner.forceCloseTimer = setTimeout(() => {
422
+ owner.forceCloseTimer = null;
423
+ error.cleanupTimedOut = true;
424
+ killExactChild('SIGKILL');
425
+ finish(null);
426
+ }, forceCloseMs);
427
+ owner.forceCloseTimer.unref?.();
428
+ }, terminateGraceMs);
429
+ owner.forceKillTimer.unref?.();
430
+ };
431
+
432
+ const retainOutput = (channel, chunk) => {
433
+ if (!owner.acceptingOutput || owner.settled) return;
434
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
435
+ const nextBytes = owner.retainedOutputBytes + buffer.length;
436
+ const nextChunks = owner.stdoutChunks.length + owner.stderrChunks.length + 1;
437
+ if (nextBytes > maxOutputBytes || nextChunks > MAX_CODEX_CLI_OUTPUT_CHUNKS) {
438
+ failAfterExactChildStops(Object.assign(
439
+ new Error(`Codex CLI status output exceeded its bounded retention budget.`),
440
+ {
441
+ code: 'codex-status-output-limit',
442
+ maxOutputBytes,
443
+ observedOutputBytes: nextBytes,
444
+ maxOutputChunks: MAX_CODEX_CLI_OUTPUT_CHUNKS,
445
+ observedOutputChunks: nextChunks
446
+ }
447
+ ));
448
+ return;
449
+ }
450
+ owner.retainedOutputBytes = nextBytes;
451
+ if (channel === 'stdout') {
452
+ owner.stdoutChunks.push(buffer);
453
+ owner.stdoutBytes += buffer.length;
454
+ } else {
455
+ owner.stderrChunks.push(buffer);
456
+ owner.stderrBytes += buffer.length;
457
+ }
458
+ };
459
+
460
+ function onStdoutData(chunk) {
461
+ retainOutput('stdout', chunk);
462
+ }
463
+
464
+ function onStderrData(chunk) {
465
+ retainOutput('stderr', chunk);
466
+ }
467
+
468
+ function onStdoutError(error) {
469
+ failAfterExactChildStops(error);
470
+ }
471
+
472
+ function onStderrError(error) {
473
+ failAfterExactChildStops(error);
474
+ }
475
+
476
+ function onChildError(error) {
477
+ failAfterExactChildStops(error);
478
+ }
479
+
480
+ function onChildClose(code) {
481
+ finish(code);
482
+ }
483
+
484
+ child.stdout.on('data', onStdoutData);
485
+ child.stdout.once('error', onStdoutError);
486
+ child.stderr.on('data', onStderrData);
487
+ child.stderr.once('error', onStderrError);
488
+ child.once('error', onChildError);
489
+ child.once('close', onChildClose);
490
+ owner.timeoutTimer = setTimeout(() => {
491
+ owner.timeoutTimer = null;
492
+ failAfterExactChildStops(Object.assign(
493
+ new Error('Codex CLI status check timed out.'),
494
+ { code: 'codex-status-timeout' }
495
+ ));
496
+ }, boundedTimeoutMs);
497
+ owner.timeoutTimer.unref?.();
498
+ });
499
+ }
236
500
 
237
501
  function isTerminalStatus(status) {
238
502
  return ['completed', 'failed', 'cancelled'].includes(status);
@@ -371,13 +635,9 @@ export function createCodexAgentRuntime({
371
635
  return codexModulePromise;
372
636
  }
373
637
 
374
- function cliPath() {
375
- try {
376
- return require.resolve('@openai/codex/bin/codex.js');
377
- } catch {
378
- return '';
379
- }
380
- }
638
+ function cliPath() {
639
+ return resolveBundledCodexCliPath();
640
+ }
381
641
 
382
642
  async function getStatus() {
383
643
  if (typeof injectedCodexStatus === 'function') return injectedCodexStatus();
@@ -531,6 +791,7 @@ export function createCodexAgentRuntime({
531
791
  run.status = 'running';
532
792
  run.updatedAt = new Date().toISOString();
533
793
  for (let attempt = 0; attempt < 2; attempt += 1) {
794
+ let turnCompleted = false;
534
795
  try {
535
796
  await prepareCodexHome({ preferGlobalAuth: attempt > 0 && run.authRecoveryRequested === true });
536
797
  reportProgress(run, 'thinking', attempt === 0 ? 'Codex is analyzing the request.' : 'Codex is retrying the request with a refreshed runtime.');
@@ -565,10 +826,27 @@ export function createCodexAgentRuntime({
565
826
  throw new Error(safeText(event.item?.error?.message, 800) || `LiveDesk tool ${safeText(event.item?.tool, 120) || 'call'} failed.`);
566
827
  }
567
828
  if (event.type === 'item.completed' && event.item?.type === 'agent_message') run.finalResponse = safeText(event.item.text, 4000);
568
- if (event.type === 'turn.completed') run.status = 'completed';
829
+ if (event.type === 'turn.completed') turnCompleted = true;
569
830
  if (event.type === 'turn.failed') throw new Error(safeText(event.error?.message, 800) || 'Codex run failed.');
570
831
  if (event.type === 'error') throw new Error(safeText(event.message, 800) || 'Codex run failed.');
571
832
  }
833
+ if (!turnCompleted) {
834
+ throw new AgentRuntimeError(
835
+ 'codex-incomplete-turn',
836
+ 'Codex ended without a completed turn.',
837
+ { status: 502 }
838
+ );
839
+ }
840
+ if (!safeText(run.finalResponse, 4000)) {
841
+ throw new AgentRuntimeError(
842
+ 'codex-empty-response',
843
+ 'Codex completed without a final response.',
844
+ { status: 502 }
845
+ );
846
+ }
847
+ // Terminal state is externally observable. Publish it only after the
848
+ // complete event stream has drained and its final response is owned.
849
+ run.status = 'completed';
572
850
  break;
573
851
  } catch (error) {
574
852
  const normalized = normalizeCodexError(error);
@@ -589,7 +867,13 @@ export function createCodexAgentRuntime({
589
867
  await new Promise(resolve => setTimeout(resolve, 250));
590
868
  }
591
869
  }
592
- if (run.status === 'running') run.status = 'completed';
870
+ if (run.status !== 'completed') {
871
+ throw new AgentRuntimeError(
872
+ 'codex-incomplete-turn',
873
+ 'Codex did not reach a completed terminal state.',
874
+ { status: 502 }
875
+ );
876
+ }
593
877
  } catch (error) {
594
878
  if (run.abortReason === 'timeout') {
595
879
  run.status = 'failed';