@livedesk/hub 0.1.41 → 0.1.43

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.
@@ -1,34 +1,34 @@
1
- import crypto from 'node:crypto';
2
- import { existsSync } from 'node:fs';
3
- import { access, link, mkdir, stat, unlink } from 'node:fs/promises';
1
+ import crypto from 'node:crypto';
2
+ import { existsSync } from 'node:fs';
3
+ import { access, link, mkdir, stat, unlink } from 'node:fs/promises';
4
4
  import os from 'node:os';
5
5
  import path from 'node:path';
6
6
  import { spawn } from 'node:child_process';
7
- import { createRequire } from 'node:module';
8
- import { AgentRuntimeError } from './agent-runtime-error.js';
9
- import { AGENT_TOOL_NAMES } from './agent-tool-registry.js';
7
+ import { createRequire } from 'node:module';
8
+ import { AgentRuntimeError } from './agent-runtime-error.js';
9
+ import { AGENT_TOOL_NAMES } from './agent-tool-registry.js';
10
10
  import { createAgentPermissionPolicy, hashAgentPermissionPolicy } from './agent-permissions.js';
11
11
 
12
12
  const require = createRequire(import.meta.url);
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 = [
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 = [
32
32
  'Path',
33
33
  'PATH',
34
34
  'SystemRoot',
@@ -71,23 +71,23 @@ function isAbortError(error) {
71
71
  || /aborted|abort/i.test(String(error?.message || ''));
72
72
  }
73
73
 
74
- function normalizeCodexError(error) {
75
- const message = safeText(error?.message, 800) || 'Codex run failed.';
76
- if (error instanceof AgentRuntimeError && error.code !== 'codex-run-failed') return error;
77
- if (/out of credits|usage limit|rate limit|too many requests/i.test(message)) return new AgentRuntimeError('codex-usage-limit-reached', 'The Codex workspace has no remaining usage.', { status: 402, retryable: true });
78
- if (/refresh[_ -]?token[_ -]?(?:already[_ -]?)?used|token refresh|refresh credential/i.test(message)) return new AgentRuntimeError('codex-auth-stale', 'The saved Codex sign-in is stale. LiveDesk refreshed its Codex authentication bridge and will retry.', { status: 401, retryable: true });
79
- if (/not authenticated|not logged in|authentication|unauthorized|login required/i.test(message)) return new AgentRuntimeError('codex-auth-required', 'Sign in with the Codex CLI first.', { status: 401 });
80
- if (/user cancelled MCP tool call/i.test(message)) return new AgentRuntimeError('codex-mcp-tool-rejected', 'Codex rejected the LiveDesk MCP tool before it reached the Hub.', { status: 502 });
81
- return new AgentRuntimeError('codex-run-failed', message, { status: 502, retryable: true });
82
- }
83
-
84
- function codexThreadOptions(workspace) {
85
- return {
86
- sandboxMode: 'read-only',
87
- approvalPolicy: 'never',
88
- workingDirectory: workspace,
89
- skipGitRepoCheck: true,
90
- networkAccessEnabled: false,
74
+ function normalizeCodexError(error) {
75
+ const message = safeText(error?.message, 800) || 'Codex run failed.';
76
+ if (error instanceof AgentRuntimeError && error.code !== 'codex-run-failed') return error;
77
+ if (/out of credits|usage limit|rate limit|too many requests/i.test(message)) return new AgentRuntimeError('codex-usage-limit-reached', 'The Codex workspace has no remaining usage.', { status: 402, retryable: true });
78
+ if (/refresh[_ -]?token[_ -]?(?:already[_ -]?)?used|token refresh|refresh credential/i.test(message)) return new AgentRuntimeError('codex-auth-stale', 'The saved Codex sign-in is stale. LiveDesk refreshed its Codex authentication bridge and will retry.', { status: 401, retryable: true });
79
+ if (/not authenticated|not logged in|authentication|unauthorized|login required/i.test(message)) return new AgentRuntimeError('codex-auth-required', 'Sign in with the Codex CLI first.', { status: 401 });
80
+ if (/user cancelled MCP tool call/i.test(message)) return new AgentRuntimeError('codex-mcp-tool-rejected', 'Codex rejected the LiveDesk MCP tool before it reached the Hub.', { status: 502 });
81
+ return new AgentRuntimeError('codex-run-failed', message, { status: 502, retryable: true });
82
+ }
83
+
84
+ function codexThreadOptions(workspace) {
85
+ return {
86
+ sandboxMode: 'read-only',
87
+ approvalPolicy: 'never',
88
+ workingDirectory: workspace,
89
+ skipGitRepoCheck: true,
90
+ networkAccessEnabled: false,
91
91
  webSearchMode: 'disabled',
92
92
  webSearchEnabled: false,
93
93
  additionalDirectories: []
@@ -161,342 +161,342 @@ function safeCodexEnv(codexHome) {
161
161
  return env;
162
162
  }
163
163
 
164
- function sameFile(left, right) {
165
- return left.dev === right.dev && left.ino === right.ino;
166
- }
167
-
168
- async function replaceAuthHardLink(sourceAuthPath, targetAuthPath) {
169
- await unlink(targetAuthPath).catch(error => {
170
- if (error?.code !== 'ENOENT') throw error;
171
- });
172
- try {
173
- await link(sourceAuthPath, targetAuthPath);
174
- } catch (error) {
175
- if (error?.code !== 'EEXIST') throw error;
176
- }
177
- }
178
-
179
- async function ensureCodexHome(codexHome, globalCodexHome, { preferGlobalAuth = false } = {}) {
180
- const resolvedHome = path.resolve(codexHome);
181
- const globalHome = path.resolve(globalCodexHome);
182
- if (!resolvedHome || resolvedHome === globalHome) {
183
- throw new AgentRuntimeError('codex-isolation-unavailable', 'Codex requires a dedicated LiveDesk home.', { status: 503 });
184
- }
164
+ function sameFile(left, right) {
165
+ return left.dev === right.dev && left.ino === right.ino;
166
+ }
167
+
168
+ async function replaceAuthHardLink(sourceAuthPath, targetAuthPath) {
169
+ await unlink(targetAuthPath).catch(error => {
170
+ if (error?.code !== 'ENOENT') throw error;
171
+ });
172
+ try {
173
+ await link(sourceAuthPath, targetAuthPath);
174
+ } catch (error) {
175
+ if (error?.code !== 'EEXIST') throw error;
176
+ }
177
+ }
178
+
179
+ async function ensureCodexHome(codexHome, globalCodexHome, { preferGlobalAuth = false } = {}) {
180
+ const resolvedHome = path.resolve(codexHome);
181
+ const globalHome = path.resolve(globalCodexHome);
182
+ if (!resolvedHome || resolvedHome === globalHome) {
183
+ throw new AgentRuntimeError('codex-isolation-unavailable', 'Codex requires a dedicated LiveDesk home.', { status: 503 });
184
+ }
185
185
  try {
186
186
  await mkdir(resolvedHome, { recursive: true, mode: 0o700 });
187
187
  if (await access(path.join(resolvedHome, 'config.toml')).then(() => true, () => false)) {
188
- throw new AgentRuntimeError('codex-environment-isolation-failed', 'The dedicated Codex home contains an unmanaged config.toml.', { status: 503 });
188
+ throw new AgentRuntimeError('codex-environment-isolation-failed', 'The dedicated Codex home contains an unmanaged config.toml.', { status: 503 });
189
189
  }
190
190
  } catch (error) {
191
- if (error instanceof AgentRuntimeError) throw error;
192
- const wrapped = new AgentRuntimeError('codex-environment-isolation-failed', 'The dedicated Codex home could not be prepared.', { status: 503 });
191
+ if (error instanceof AgentRuntimeError) throw error;
192
+ const wrapped = new AgentRuntimeError('codex-environment-isolation-failed', 'The dedicated Codex home could not be prepared.', { status: 503 });
193
193
  wrapped.cause = error;
194
194
  throw wrapped;
195
- }
196
- const targetAuthPath = path.join(resolvedHome, 'auth.json');
197
- const sourceAuthPath = path.join(globalHome, 'auth.json');
198
- if (!(await access(sourceAuthPath).then(() => true, () => false))) return;
199
- try {
200
- const sourceStatus = await stat(sourceAuthPath);
201
- const targetStatus = await stat(targetAuthPath).catch(error => {
202
- if (error?.code === 'ENOENT') return null;
203
- throw error;
204
- });
205
- if (!targetStatus) {
206
- await link(sourceAuthPath, targetAuthPath);
207
- return;
208
- }
209
- if (sameFile(sourceStatus, targetStatus)) return;
210
-
211
- // Codex may atomically replace auth.json while refreshing a token. That
212
- // breaks a hard link even though both paths still exist. Follow the newer
213
- // credential owner, or explicitly recover from a stale-refresh failure,
214
- // without ever reading or copying credential contents.
215
- if (preferGlobalAuth || sourceStatus.mtimeMs > targetStatus.mtimeMs || targetStatus.size === 0) {
216
- await replaceAuthHardLink(sourceAuthPath, targetAuthPath);
217
- }
218
- } catch (error) {
219
- const wrapped = new AgentRuntimeError('codex-environment-isolation-failed', 'The Codex authentication bridge could not be prepared.', { status: 503 });
220
- wrapped.cause = error;
195
+ }
196
+ const targetAuthPath = path.join(resolvedHome, 'auth.json');
197
+ const sourceAuthPath = path.join(globalHome, 'auth.json');
198
+ if (!(await access(sourceAuthPath).then(() => true, () => false))) return;
199
+ try {
200
+ const sourceStatus = await stat(sourceAuthPath);
201
+ const targetStatus = await stat(targetAuthPath).catch(error => {
202
+ if (error?.code === 'ENOENT') return null;
203
+ throw error;
204
+ });
205
+ if (!targetStatus) {
206
+ await link(sourceAuthPath, targetAuthPath);
207
+ return;
208
+ }
209
+ if (sameFile(sourceStatus, targetStatus)) return;
210
+
211
+ // Codex may atomically replace auth.json while refreshing a token. That
212
+ // breaks a hard link even though both paths still exist. Follow the newer
213
+ // credential owner, or explicitly recover from a stale-refresh failure,
214
+ // without ever reading or copying credential contents.
215
+ if (preferGlobalAuth || sourceStatus.mtimeMs > targetStatus.mtimeMs || targetStatus.size === 0) {
216
+ await replaceAuthHardLink(sourceAuthPath, targetAuthPath);
217
+ }
218
+ } catch (error) {
219
+ const wrapped = new AgentRuntimeError('codex-environment-isolation-failed', 'The Codex authentication bridge could not be prepared.', { status: 503 });
220
+ wrapped.cause = error;
221
221
  throw wrapped;
222
222
  }
223
223
  }
224
224
 
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
- }
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
+ }
500
500
 
501
501
  function isTerminalStatus(status) {
502
502
  return ['completed', 'failed', 'cancelled'].includes(status);
@@ -507,7 +507,7 @@ function runAgeMs(run) {
507
507
  }
508
508
 
509
509
  function codexAbortError(code, message) {
510
- return new AgentRuntimeError(code, message, { status: code === 'codex-run-timeout' ? 504 : 409, retryable: code === 'codex-run-timeout' });
510
+ return new AgentRuntimeError(code, message, { status: code === 'codex-run-timeout' ? 504 : 409, retryable: code === 'codex-run-timeout' });
511
511
  }
512
512
 
513
513
  function safeAgentDeviceIds(value) {
@@ -528,41 +528,41 @@ export function createCodexAgentRuntime({
528
528
  settingsStore,
529
529
  dataDir = path.join(os.homedir(), '.livedesk'),
530
530
  mcpServerPath,
531
- createMcpSession,
532
- destroyMcpSession,
533
- createCodexClient: injectedCodexClient,
534
- getCodexStatus: injectedCodexStatus,
535
- onProgress,
536
- globalCodexHome = path.join(os.homedir(), '.codex')
537
- } = {}) {
531
+ createMcpSession,
532
+ destroyMcpSession,
533
+ createCodexClient: injectedCodexClient,
534
+ getCodexStatus: injectedCodexStatus,
535
+ onProgress,
536
+ globalCodexHome = path.join(os.homedir(), '.codex')
537
+ } = {}) {
538
538
  const runs = new Map();
539
539
  let activeCount = 0;
540
540
  let codexModulePromise;
541
541
  let securityVerificationState = 'configured';
542
- const workspace = path.resolve(dataDir, 'agent-workspace');
543
- const codexHome = path.resolve(dataDir, 'codex-home');
544
- const resolvedGlobalCodexHome = path.resolve(globalCodexHome);
545
- let codexHomePreparation = Promise.resolve();
546
-
547
- function reportProgress(run, stage, message, extra = {}) {
548
- run.stage = safeText(stage, 80);
549
- run.progressMessage = safeText(message, 500);
550
- if (typeof onProgress !== 'function') return;
551
- try {
552
- onProgress({
553
- runId: run.runId,
554
- deviceIds: [...run.deviceIds],
555
- stage: safeText(stage, 80),
556
- message: safeText(message, 500),
557
- status: run.status,
558
- ...extra
559
- });
560
- } catch {
561
- // Client-side developer progress is best effort and must never affect a
562
- // Codex run or its enforced permission boundary.
563
- }
564
- }
565
-
542
+ const workspace = path.resolve(dataDir, 'agent-workspace');
543
+ const codexHome = path.resolve(dataDir, 'codex-home');
544
+ const resolvedGlobalCodexHome = path.resolve(globalCodexHome);
545
+ let codexHomePreparation = Promise.resolve();
546
+
547
+ function reportProgress(run, stage, message, extra = {}) {
548
+ run.stage = safeText(stage, 80);
549
+ run.progressMessage = safeText(message, 500);
550
+ if (typeof onProgress !== 'function') return;
551
+ try {
552
+ onProgress({
553
+ runId: run.runId,
554
+ deviceIds: [...run.deviceIds],
555
+ stage: safeText(stage, 80),
556
+ message: safeText(message, 500),
557
+ status: run.status,
558
+ ...extra
559
+ });
560
+ } catch {
561
+ // Client-side developer progress is best effort and must never affect a
562
+ // Codex run or its enforced permission boundary.
563
+ }
564
+ }
565
+
566
566
  function securityStatus() {
567
567
  const configured = codexHome !== path.resolve(os.homedir(), '.codex')
568
568
  && typeof createMcpSession === 'function'
@@ -578,24 +578,24 @@ export function createCodexAgentRuntime({
578
578
  };
579
579
  }
580
580
 
581
- async function prepareCodexHome(options = {}) {
582
- const operation = codexHomePreparation.then(async () => {
583
- try {
584
- await ensureCodexHome(codexHome, resolvedGlobalCodexHome, options);
585
- securityVerificationState = 'verified';
586
- } catch (error) {
587
- securityVerificationState = 'unavailable';
588
- throw error;
589
- }
590
- });
591
- codexHomePreparation = operation.catch(() => undefined);
592
- return operation;
581
+ async function prepareCodexHome(options = {}) {
582
+ const operation = codexHomePreparation.then(async () => {
583
+ try {
584
+ await ensureCodexHome(codexHome, resolvedGlobalCodexHome, options);
585
+ securityVerificationState = 'verified';
586
+ } catch (error) {
587
+ securityVerificationState = 'unavailable';
588
+ throw error;
589
+ }
590
+ });
591
+ codexHomePreparation = operation.catch(() => undefined);
592
+ return operation;
593
593
  }
594
594
 
595
595
  function assertSecurityConfiguration() {
596
596
  const status = securityStatus();
597
597
  if (!status.isolatedCodexHome || !status.restrictedEnvironment || !status.livedeskMcpOnly || !status.selectedDeviceScopeEnforced) {
598
- throw new AgentRuntimeError('codex-unsupported-security-config', 'Codex security isolation is not available.', { status: 503 });
598
+ throw new AgentRuntimeError('codex-unsupported-security-config', 'Codex security isolation is not available.', { status: 503 });
599
599
  }
600
600
  }
601
601
 
@@ -628,16 +628,16 @@ export function createCodexAgentRuntime({
628
628
 
629
629
  async function loadCodex() {
630
630
  if (!codexModulePromise) codexModulePromise = import('@openai/codex-sdk').catch(error => {
631
- const wrapped = new AgentRuntimeError('codex-sdk-not-installed', 'Codex SDK is not installed in this LiveDesk package.', { status: 503 });
631
+ const wrapped = new AgentRuntimeError('codex-sdk-not-installed', 'Codex SDK is not installed in this LiveDesk package.', { status: 503 });
632
632
  wrapped.cause = error;
633
633
  throw wrapped;
634
634
  });
635
635
  return codexModulePromise;
636
636
  }
637
637
 
638
- function cliPath() {
639
- return resolveBundledCodexCliPath();
640
- }
638
+ function cliPath() {
639
+ return resolveBundledCodexCliPath();
640
+ }
641
641
 
642
642
  async function getStatus() {
643
643
  if (typeof injectedCodexStatus === 'function') return injectedCodexStatus();
@@ -655,7 +655,7 @@ export function createCodexAgentRuntime({
655
655
  ? 'signed-in'
656
656
  : 'not-signed-in';
657
657
  } catch (error) {
658
- if (error instanceof AgentRuntimeError) throw error;
658
+ if (error instanceof AgentRuntimeError) throw error;
659
659
  detail = safeText(error?.message, 300);
660
660
  }
661
661
  return { installed: true, authenticated, status: authenticated === 'signed-in' ? 'ready' : 'auth-required', codexPath: pathToCli, detail };
@@ -665,8 +665,8 @@ export function createCodexAgentRuntime({
665
665
  assertSecurityConfiguration();
666
666
  const startedAt = Date.now();
667
667
  const status = await getStatus();
668
- if (!status.installed) throw new AgentRuntimeError('codex-sdk-not-installed', 'Codex SDK is not installed.', { status: 503 });
669
- if (status.authenticated !== 'signed-in') throw new AgentRuntimeError('codex-auth-required', 'Sign in with the Codex CLI first.', { status: 401 });
668
+ if (!status.installed) throw new AgentRuntimeError('codex-sdk-not-installed', 'Codex SDK is not installed.', { status: 503 });
669
+ if (status.authenticated !== 'signed-in') throw new AgentRuntimeError('codex-auth-required', 'Sign in with the Codex CLI first.', { status: 401 });
670
670
  const settings = await settingsStore.get();
671
671
  await mkdir(workspace, { recursive: true });
672
672
  await prepareCodexHome();
@@ -674,10 +674,10 @@ export function createCodexAgentRuntime({
674
674
  const controller = new AbortController();
675
675
  const timer = setTimeout(() => controller.abort(), Math.min(30000, settings.codexTaskTimeoutMs));
676
676
  try {
677
- const thread = codex.startThread(codexThreadOptions(workspace));
678
- const turn = await thread.run('Reply with the single word READY. Do not use tools.', { signal: controller.signal });
679
- if (!String(turn.finalResponse || '').trim()) throw new AgentRuntimeError('codex-empty-response', 'Codex returned an empty response.', { status: 502 });
680
- return { ok: true, latencyMs: Date.now() - startedAt, threadId: thread.id || '' };
677
+ const thread = codex.startThread(codexThreadOptions(workspace));
678
+ const turn = await thread.run('Reply with the single word READY. Do not use tools.', { signal: controller.signal });
679
+ if (!String(turn.finalResponse || '').trim()) throw new AgentRuntimeError('codex-empty-response', 'Codex returned an empty response.', { status: 502 });
680
+ return { ok: true, latencyMs: Date.now() - startedAt, threadId: thread.id || '' };
681
681
  } catch (error) {
682
682
  throw normalizeCodexError(error);
683
683
  } finally {
@@ -686,18 +686,18 @@ export function createCodexAgentRuntime({
686
686
  }
687
687
 
688
688
  function publicRun(run) {
689
- return {
690
- runId: run.runId,
691
- status: run.status,
692
- instruction: run.instruction,
689
+ return {
690
+ runId: run.runId,
691
+ status: run.status,
692
+ instruction: run.instruction,
693
693
  threadId: run.threadId,
694
- finalResponse: run.finalResponse,
695
- error: run.error,
696
- errorMessage: run.errorMessage,
697
- errorStage: run.errorStage,
698
- stage: run.stage,
699
- progressMessage: run.progressMessage,
700
- createdAt: run.createdAt,
694
+ finalResponse: run.finalResponse,
695
+ error: run.error,
696
+ errorMessage: run.errorMessage,
697
+ errorStage: run.errorStage,
698
+ stage: run.stage,
699
+ progressMessage: run.progressMessage,
700
+ createdAt: run.createdAt,
701
701
  updatedAt: run.updatedAt,
702
702
  toolCallCount: run.toolCallCount,
703
703
  permissionMode: run.permissionPolicy?.mode || 'ask',
@@ -728,20 +728,20 @@ export function createCodexAgentRuntime({
728
728
  'Never use arbitrary MCP servers, change permissions, forge approvals, request credentials, or invent a tool result.',
729
729
  `The Hub has fixed this run to permission mode ${permissionPolicy?.mode || 'ask'} and enforces the policy independently of your instructions.`,
730
730
  'Use only the selected connected device IDs below. If the Hub asks for user approval, wait for that approval result and do not work around it.',
731
- 'You may perform multiple safe read-only checks when the request requires a sequence. For example, find Clients missing a named process, then check a related service only on those Clients.',
731
+ 'You may perform multiple safe read-only checks when the request requires a sequence. For example, find Clients missing a named process, then check a related service only on those Clients.',
732
732
  `Selected device IDs: ${JSON.stringify(deviceIds)}`,
733
733
  'Return a concise Korean or English summary grounded only in tool results. Do not invent results.',
734
734
  `User request: ${safeText(instruction, 4000)}`
735
735
  ].join('\n');
736
736
  }
737
737
 
738
- async function execute(run, input) {
739
- activeCount += 1;
740
- let session;
741
- let timeoutTimer;
742
- try {
743
- reportProgress(run, 'checking-codex', 'Checking Codex SDK installation and sign-in.');
744
- assertSecurityConfiguration();
738
+ async function execute(run, input) {
739
+ activeCount += 1;
740
+ let session;
741
+ let timeoutTimer;
742
+ try {
743
+ reportProgress(run, 'checking-codex', 'Checking Codex SDK installation and sign-in.');
744
+ assertSecurityConfiguration();
745
745
  const settings = await settingsStore.get();
746
746
  const timeoutMs = Math.max(1000, Number(settings.codexTaskTimeoutMs) || 600000);
747
747
  timeoutTimer = setTimeout(() => {
@@ -752,16 +752,16 @@ export function createCodexAgentRuntime({
752
752
  timeoutTimer.unref?.();
753
753
  await mkdir(workspace, { recursive: true });
754
754
  if (activeCount > settings.maxConcurrentRequests) {
755
- throw new AgentRuntimeError('agent-concurrency-limit', 'The LiveDesk Agent concurrency limit is reached.', { status: 429, retryable: true });
755
+ throw new AgentRuntimeError('agent-concurrency-limit', 'The LiveDesk Agent concurrency limit is reached.', { status: 429, retryable: true });
756
756
  }
757
757
  if (run.abortController.signal.aborted) {
758
758
  if (run.abortReason === 'timeout') throw codexAbortError('codex-run-timeout', 'The Codex task exceeded its timeout.');
759
759
  throw Object.assign(new Error('The Codex task was cancelled.'), { name: 'AbortError' });
760
760
  }
761
- const status = await getStatus();
762
- if (!status.installed) throw new AgentRuntimeError('codex-sdk-not-installed', 'Codex SDK is not installed.', { status: 503 });
763
- if (status.authenticated !== 'signed-in') throw new AgentRuntimeError('codex-auth-required', 'Sign in with the Codex CLI first.', { status: 401 });
764
- reportProgress(run, 'preparing-tools', 'Codex is ready. Preparing the LiveDesk tools for this Client.');
761
+ const status = await getStatus();
762
+ if (!status.installed) throw new AgentRuntimeError('codex-sdk-not-installed', 'Codex SDK is not installed.', { status: 503 });
763
+ if (status.authenticated !== 'signed-in') throw new AgentRuntimeError('codex-auth-required', 'Sign in with the Codex CLI first.', { status: 401 });
764
+ reportProgress(run, 'preparing-tools', 'Codex is ready. Preparing the LiveDesk tools for this Client.');
765
765
  session = createMcpSession({
766
766
  runId: run.runId,
767
767
  signal: run.abortController.signal,
@@ -787,120 +787,120 @@ export function createCodexAgentRuntime({
787
787
  if (run.abortReason === 'timeout') throw codexAbortError('codex-run-timeout', 'The Codex task exceeded its timeout.');
788
788
  throw Object.assign(new Error('The Codex task was cancelled.'), { name: 'AbortError' });
789
789
  }
790
- const threadOptions = codexThreadOptions(workspace);
791
- run.status = 'running';
792
- run.updatedAt = new Date().toISOString();
793
- for (let attempt = 0; attempt < 2; attempt += 1) {
794
- let turnCompleted = false;
795
- try {
796
- await prepareCodexHome({ preferGlobalAuth: attempt > 0 && run.authRecoveryRequested === true });
797
- reportProgress(run, 'thinking', attempt === 0 ? 'Codex is analyzing the request.' : 'Codex is retrying the request with a refreshed runtime.');
798
- const codex = await createCodexClient({ env: safeCodexEnv(codexHome), config: codexConfig({ mcpServerPath, mcpUrl: session.url, token: session.token, workspace }) });
799
- const thread = attempt === 0 && input.resumeThreadId && settings.codexResumeSessions
800
- ? codex.resumeThread(String(input.resumeThreadId), threadOptions)
801
- : codex.startThread(threadOptions);
802
- const streamed = await thread.runStreamed(promptFor({ instruction: run.instruction, deviceIds: input.deviceIds, permissionPolicy: run.permissionPolicy }), { signal: run.abortController.signal });
803
- for await (const event of streamed.events) {
804
- emit(run, event);
805
- if (event.type === 'item.started' && event.item?.type === 'mcp_tool_call') {
806
- reportProgress(run, 'tool-running', `Running ${safeText(event.item.tool, 120) || 'a LiveDesk tool'}.`, {
807
- toolName: safeText(event.item.tool, 120)
808
- });
809
- }
810
- if (event.type === 'turn.started') {
811
- run.turnCount += 1;
812
- if (run.turnCount > settings.codexMaxTurns) {
813
- run.abortReason = 'turn-limit';
814
- run.abortController.abort();
815
- session.cancel();
816
- throw new AgentRuntimeError('codex-turn-limit-reached', 'Codex exceeded the LiveDesk turn limit.', { status: 409 });
817
- }
818
- }
819
- if (run.toolCallCount > run.permissionPolicy.maxToolCalls) {
820
- run.abortReason = 'tool-limit';
821
- run.abortController.abort();
822
- session.cancel();
823
- throw new AgentRuntimeError('codex-tool-limit-reached', 'Codex exceeded the LiveDesk tool-call limit.', { status: 409 });
824
- }
825
- if (event.type === 'item.completed' && event.item?.type === 'mcp_tool_call' && event.item?.status === 'failed') {
826
- throw new Error(safeText(event.item?.error?.message, 800) || `LiveDesk tool ${safeText(event.item?.tool, 120) || 'call'} failed.`);
827
- }
828
- if (event.type === 'item.completed' && event.item?.type === 'agent_message') run.finalResponse = safeText(event.item.text, 4000);
829
- if (event.type === 'turn.completed') turnCompleted = true;
830
- if (event.type === 'turn.failed') throw new Error(safeText(event.error?.message, 800) || 'Codex run failed.');
831
- if (event.type === 'error') throw new Error(safeText(event.message, 800) || 'Codex run failed.');
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';
850
- break;
851
- } catch (error) {
852
- const normalized = normalizeCodexError(error);
853
- const retryBeforeTools = attempt === 0
854
- && run.toolCallCount === 0
855
- && run.abortController.signal.aborted !== true
856
- && normalized.retryable === true;
857
- if (!retryBeforeTools) throw normalized;
858
- run.authRecoveryRequested = ['codex-auth-stale', 'codex-auth-required'].includes(normalized.code);
859
- run.finalResponse = '';
860
- run.status = 'running';
861
- reportProgress(
862
- run,
863
- 'recovering',
864
- `Codex did not start cleanly (${normalized.code}). Refreshing the runtime and retrying once.`,
865
- { errorCode: normalized.code, errorMessage: safeText(normalized.message, 500), attempt: 2 }
866
- );
867
- await new Promise(resolve => setTimeout(resolve, 250));
868
- }
869
- }
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
- }
790
+ const threadOptions = codexThreadOptions(workspace);
791
+ run.status = 'running';
792
+ run.updatedAt = new Date().toISOString();
793
+ for (let attempt = 0; attempt < 2; attempt += 1) {
794
+ let turnCompleted = false;
795
+ try {
796
+ await prepareCodexHome({ preferGlobalAuth: attempt > 0 && run.authRecoveryRequested === true });
797
+ reportProgress(run, 'thinking', attempt === 0 ? 'Codex is analyzing the request.' : 'Codex is retrying the request with a refreshed runtime.');
798
+ const codex = await createCodexClient({ env: safeCodexEnv(codexHome), config: codexConfig({ mcpServerPath, mcpUrl: session.url, token: session.token, workspace }) });
799
+ const thread = attempt === 0 && input.resumeThreadId && settings.codexResumeSessions
800
+ ? codex.resumeThread(String(input.resumeThreadId), threadOptions)
801
+ : codex.startThread(threadOptions);
802
+ const streamed = await thread.runStreamed(promptFor({ instruction: run.instruction, deviceIds: input.deviceIds, permissionPolicy: run.permissionPolicy }), { signal: run.abortController.signal });
803
+ for await (const event of streamed.events) {
804
+ emit(run, event);
805
+ if (event.type === 'item.started' && event.item?.type === 'mcp_tool_call') {
806
+ reportProgress(run, 'tool-running', `Running ${safeText(event.item.tool, 120) || 'a LiveDesk tool'}.`, {
807
+ toolName: safeText(event.item.tool, 120)
808
+ });
809
+ }
810
+ if (event.type === 'turn.started') {
811
+ run.turnCount += 1;
812
+ if (run.turnCount > settings.codexMaxTurns) {
813
+ run.abortReason = 'turn-limit';
814
+ run.abortController.abort();
815
+ session.cancel();
816
+ throw new AgentRuntimeError('codex-turn-limit-reached', 'Codex exceeded the LiveDesk turn limit.', { status: 409 });
817
+ }
818
+ }
819
+ if (run.toolCallCount > run.permissionPolicy.maxToolCalls) {
820
+ run.abortReason = 'tool-limit';
821
+ run.abortController.abort();
822
+ session.cancel();
823
+ throw new AgentRuntimeError('codex-tool-limit-reached', 'Codex exceeded the LiveDesk tool-call limit.', { status: 409 });
824
+ }
825
+ if (event.type === 'item.completed' && event.item?.type === 'mcp_tool_call' && event.item?.status === 'failed') {
826
+ throw new Error(safeText(event.item?.error?.message, 800) || `LiveDesk tool ${safeText(event.item?.tool, 120) || 'call'} failed.`);
827
+ }
828
+ if (event.type === 'item.completed' && event.item?.type === 'agent_message') run.finalResponse = safeText(event.item.text, 4000);
829
+ if (event.type === 'turn.completed') turnCompleted = true;
830
+ if (event.type === 'turn.failed') throw new Error(safeText(event.error?.message, 800) || 'Codex run failed.');
831
+ if (event.type === 'error') throw new Error(safeText(event.message, 800) || 'Codex run failed.');
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';
850
+ break;
851
+ } catch (error) {
852
+ const normalized = normalizeCodexError(error);
853
+ const retryBeforeTools = attempt === 0
854
+ && run.toolCallCount === 0
855
+ && run.abortController.signal.aborted !== true
856
+ && normalized.retryable === true;
857
+ if (!retryBeforeTools) throw normalized;
858
+ run.authRecoveryRequested = ['codex-auth-stale', 'codex-auth-required'].includes(normalized.code);
859
+ run.finalResponse = '';
860
+ run.status = 'running';
861
+ reportProgress(
862
+ run,
863
+ 'recovering',
864
+ `Codex did not start cleanly (${normalized.code}). Refreshing the runtime and retrying once.`,
865
+ { errorCode: normalized.code, errorMessage: safeText(normalized.message, 500), attempt: 2 }
866
+ );
867
+ await new Promise(resolve => setTimeout(resolve, 250));
868
+ }
869
+ }
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
+ }
877
877
  } catch (error) {
878
- if (run.abortReason === 'timeout') {
879
- run.status = 'failed';
880
- run.error = 'codex-run-timeout';
881
- run.errorMessage = 'The Codex task exceeded its timeout.';
882
- run.errorStage = run.stage || 'unknown';
883
- } else if (run.abortReason === 'tool-limit') {
884
- run.status = 'failed';
885
- run.error = 'codex-tool-limit-reached';
886
- run.errorMessage = 'Codex exceeded the LiveDesk tool-call limit.';
887
- run.errorStage = run.stage || 'unknown';
888
- } else if (run.abortReason === 'turn-limit') {
889
- run.status = 'failed';
890
- run.error = 'codex-turn-limit-reached';
891
- run.errorMessage = 'Codex exceeded the LiveDesk turn limit.';
892
- run.errorStage = run.stage || 'unknown';
893
- } else if (isAbortError(error) || run.abortController.signal.aborted) {
894
- run.status = 'cancelled';
895
- run.error = 'cancelled-by-user';
896
- run.errorMessage = 'The Codex request was cancelled.';
897
- run.errorStage = run.stage || 'unknown';
878
+ if (run.abortReason === 'timeout') {
879
+ run.status = 'failed';
880
+ run.error = 'codex-run-timeout';
881
+ run.errorMessage = 'The Codex task exceeded its timeout.';
882
+ run.errorStage = run.stage || 'unknown';
883
+ } else if (run.abortReason === 'tool-limit') {
884
+ run.status = 'failed';
885
+ run.error = 'codex-tool-limit-reached';
886
+ run.errorMessage = 'Codex exceeded the LiveDesk tool-call limit.';
887
+ run.errorStage = run.stage || 'unknown';
888
+ } else if (run.abortReason === 'turn-limit') {
889
+ run.status = 'failed';
890
+ run.error = 'codex-turn-limit-reached';
891
+ run.errorMessage = 'Codex exceeded the LiveDesk turn limit.';
892
+ run.errorStage = run.stage || 'unknown';
893
+ } else if (isAbortError(error) || run.abortController.signal.aborted) {
894
+ run.status = 'cancelled';
895
+ run.error = 'cancelled-by-user';
896
+ run.errorMessage = 'The Codex request was cancelled.';
897
+ run.errorStage = run.stage || 'unknown';
898
898
  } else {
899
- const normalized = normalizeCodexError(error);
900
- run.status = 'failed';
901
- run.error = safeText(normalized.code, 240) || 'codex-run-failed';
902
- run.errorMessage = safeText(normalized.message, 800) || 'Codex run failed.';
903
- run.errorStage = run.stage || 'unknown';
899
+ const normalized = normalizeCodexError(error);
900
+ run.status = 'failed';
901
+ run.error = safeText(normalized.code, 240) || 'codex-run-failed';
902
+ run.errorMessage = safeText(normalized.message, 800) || 'Codex run failed.';
903
+ run.errorStage = run.stage || 'unknown';
904
904
  }
905
905
  } finally {
906
906
  activeCount = Math.max(0, activeCount - 1);
@@ -908,44 +908,44 @@ export function createCodexAgentRuntime({
908
908
  try { session.cancel(); } catch { /* best effort */ }
909
909
  destroyMcpSession(session.token);
910
910
  }
911
- clearTimeout(timeoutTimer);
912
- run.updatedAt = new Date().toISOString();
913
- reportProgress(
914
- run,
915
- run.status,
916
- run.status === 'completed'
917
- ? 'Codex completed the request.'
918
- : run.status === 'cancelled'
919
- ? 'The Codex request was cancelled.'
920
- : `Codex stopped (${run.error || 'codex-run-failed'}): ${run.errorMessage || 'Codex run failed.'}`
921
- );
922
- if (isTerminalStatus(run.status)) scheduleRunCleanup(run);
911
+ clearTimeout(timeoutTimer);
912
+ run.updatedAt = new Date().toISOString();
913
+ reportProgress(
914
+ run,
915
+ run.status,
916
+ run.status === 'completed'
917
+ ? 'Codex completed the request.'
918
+ : run.status === 'cancelled'
919
+ ? 'The Codex request was cancelled.'
920
+ : `Codex stopped (${run.error || 'codex-run-failed'}): ${run.errorMessage || 'Codex run failed.'}`
921
+ );
922
+ if (isTerminalStatus(run.status)) scheduleRunCleanup(run);
923
923
  }
924
924
  }
925
925
 
926
926
  async function start(input = {}) {
927
927
  const instruction = safeText(input.instruction, 4000);
928
- if (!instruction) throw new AgentRuntimeError('agent-invalid-instruction', 'Instruction is required.', { status: 400 });
928
+ if (!instruction) throw new AgentRuntimeError('agent-invalid-instruction', 'Instruction is required.', { status: 400 });
929
929
  const deviceIds = safeAgentDeviceIds(input.deviceIds);
930
- if (deviceIds.length === 0) throw new AgentRuntimeError('agent-no-target-devices', 'Select at least one connected device.', { status: 400 });
930
+ if (deviceIds.length === 0) throw new AgentRuntimeError('agent-no-target-devices', 'Select at least one connected device.', { status: 400 });
931
931
  const settings = await settingsStore.get();
932
- if (!settings.enabled) throw new AgentRuntimeError('agent-disabled', 'Codex Agent is disabled in Settings.', { status: 409 });
932
+ if (!settings.enabled) throw new AgentRuntimeError('agent-disabled', 'Codex Agent is disabled in Settings.', { status: 409 });
933
933
  assertSecurityConfiguration();
934
934
  pruneRuns();
935
- if (runs.size >= MAX_RUNS) throw new AgentRuntimeError('agent-run-limit', 'The LiveDesk Agent run history is full.', { status: 429, retryable: true });
935
+ if (runs.size >= MAX_RUNS) throw new AgentRuntimeError('agent-run-limit', 'The LiveDesk Agent run history is full.', { status: 429, retryable: true });
936
936
  const permissionPolicy = freezeAgentPermissionPolicy(input.permissionPolicy && typeof input.permissionPolicy === 'object'
937
937
  ? input.permissionPolicy
938
938
  : createAgentPermissionPolicy({ mode: input.permissionMode || 'safe-auto', deviceIds, maxToolCalls: settings.codexMaxToolCalls }));
939
- const run = {
940
- runId: crypto.randomUUID(),
941
- status: 'queued',
942
- instruction,
943
- deviceIds,
944
- threadId: '',
945
- finalResponse: '',
946
- error: '',
947
- errorMessage: '',
948
- errorStage: '',
939
+ const run = {
940
+ runId: crypto.randomUUID(),
941
+ status: 'queued',
942
+ instruction,
943
+ deviceIds,
944
+ threadId: '',
945
+ finalResponse: '',
946
+ error: '',
947
+ errorMessage: '',
948
+ errorStage: '',
949
949
  createdAt: new Date().toISOString(),
950
950
  updatedAt: new Date().toISOString(),
951
951
  toolCallCount: 0,
@@ -955,15 +955,15 @@ export function createCodexAgentRuntime({
955
955
  batchIds: new Set(),
956
956
  events: [],
957
957
  abortController: new AbortController(),
958
- abortReason: '',
959
- authRecoveryRequested: false,
960
- stage: 'queued',
961
- progressMessage: '',
962
- mcpToken: ''
963
- };
964
- runs.set(run.runId, run);
965
- reportProgress(run, 'queued', 'Codex request queued on the Hub.');
966
- void execute(run, { deviceIds, resumeThreadId: input.resumeThreadId });
958
+ abortReason: '',
959
+ authRecoveryRequested: false,
960
+ stage: 'queued',
961
+ progressMessage: '',
962
+ mcpToken: ''
963
+ };
964
+ runs.set(run.runId, run);
965
+ reportProgress(run, 'queued', 'Codex request queued on the Hub.');
966
+ void execute(run, { deviceIds, resumeThreadId: input.resumeThreadId });
967
967
  return publicRun(run);
968
968
  }
969
969