@love-moon/conductor-cli 0.2.19 → 0.2.21

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/daemon.js CHANGED
@@ -11,15 +11,32 @@ import yaml from "js-yaml";
11
11
  import { ConductorWebSocketClient, ConductorConfig, loadConfig, ConfigFileNotFound } from "@love-moon/conductor-sdk";
12
12
  import { DaemonLogCollector } from "./log-collector.js";
13
13
  import { filterRuntimeSupportedAllowCliList, normalizeRuntimeBackendName } from "./runtime-backends.js";
14
+ import {
15
+ PACKAGE_NAME,
16
+ fetchLatestVersion,
17
+ isNewerVersion,
18
+ detectPackageManager,
19
+ parseUpdateWindow,
20
+ isInUpdateWindow,
21
+ isManagedInstallPath,
22
+ } from "./version-check.js";
14
23
 
15
24
  dotenv.config();
16
25
 
17
26
  const __filename = fileURLToPath(import.meta.url);
18
27
  const __dirname = path.dirname(__filename);
28
+ const PACKAGE_ROOT = path.join(__dirname, "..");
19
29
  const moduleRequire = createRequire(import.meta.url);
20
- const CLI_PATH = path.resolve(__dirname, "..", "bin", "conductor-fire.js");
30
+ const CLI_PATH = path.resolve(PACKAGE_ROOT, "bin", "conductor-fire.js");
21
31
  const DAEMON_LOG_DIR = path.join(os.homedir(), ".conductor", "logs");
22
32
  const DAEMON_LOG_PATH = path.join(DAEMON_LOG_DIR, "conductor-daemon.log");
33
+ const CLI_VERSION = (() => {
34
+ try {
35
+ return JSON.parse(fs.readFileSync(path.join(PACKAGE_ROOT, "package.json"), "utf-8")).version;
36
+ } catch {
37
+ return "unknown";
38
+ }
39
+ })();
23
40
  const PLAN_LIMIT_MESSAGES = {
24
41
  manual_fire_active_task: "Free plan limit reached: only 1 active fire task is allowed.",
25
42
  app_active_task: "Free plan limit reached: only 1 active app task is allowed.",
@@ -28,6 +45,7 @@ const PLAN_LIMIT_MESSAGES = {
28
45
  const DEFAULT_TERMINAL_COLS = 120;
29
46
  const DEFAULT_TERMINAL_ROWS = 40;
30
47
  const DEFAULT_TERMINAL_RING_BUFFER_MAX_BYTES = 2 * 1024 * 1024;
48
+ const DEFAULT_RTC_MODULE_CANDIDATES = ["@roamhq/wrtc", "wrtc"];
31
49
  let nodePtySpawnPromise = null;
32
50
 
33
51
  function appendDaemonLog(line) {
@@ -199,6 +217,16 @@ function normalizeOptionalString(value) {
199
217
  return normalized || null;
200
218
  }
201
219
 
220
+ function normalizeStringArray(value) {
221
+ if (!Array.isArray(value)) {
222
+ return [];
223
+ }
224
+ return value
225
+ .filter((entry) => typeof entry === "string")
226
+ .map((entry) => entry.trim())
227
+ .filter(Boolean);
228
+ }
229
+
202
230
  function normalizePositiveInt(value, fallback) {
203
231
  const parsed = Number.parseInt(String(value ?? ""), 10);
204
232
  if (Number.isFinite(parsed) && parsed > 0) {
@@ -207,6 +235,25 @@ function normalizePositiveInt(value, fallback) {
207
235
  return fallback;
208
236
  }
209
237
 
238
+ function normalizeNonNegativeInt(value, fallback = null) {
239
+ const parsed = Number.parseInt(String(value ?? ""), 10);
240
+ if (Number.isFinite(parsed) && parsed >= 0) {
241
+ return parsed;
242
+ }
243
+ return fallback;
244
+ }
245
+
246
+ function normalizeIsoTimestamp(value) {
247
+ if (typeof value !== "string") {
248
+ return null;
249
+ }
250
+ const normalized = value.trim();
251
+ if (!normalized) {
252
+ return null;
253
+ }
254
+ return Number.isNaN(Date.parse(normalized)) ? null : normalized;
255
+ }
256
+
210
257
  function normalizeLaunchConfig(value) {
211
258
  if (!value || typeof value !== "object" || Array.isArray(value)) {
212
259
  return {};
@@ -309,6 +356,43 @@ export function startDaemon(config = {}, deps = {}) {
309
356
  // Get allow_cli_list from config
310
357
  const ALLOW_CLI_LIST = getAllowCliList(userConfig);
311
358
  const SUPPORTED_BACKENDS = Object.keys(ALLOW_CLI_LIST);
359
+ const fetchLatestVersionFn = deps.fetchLatestVersion || fetchLatestVersion;
360
+ const isNewerVersionFn = deps.isNewerVersion || isNewerVersion;
361
+ const detectPackageManagerFn = deps.detectPackageManager || detectPackageManager;
362
+ const parseUpdateWindowFn = deps.parseUpdateWindow || parseUpdateWindow;
363
+ const isInUpdateWindowFn = deps.isInUpdateWindow || isInUpdateWindow;
364
+ const isManagedInstallPathFn = deps.isManagedInstallPath || isManagedInstallPath;
365
+ const installedPackageRoot = deps.packageRoot || PACKAGE_ROOT;
366
+ const cliVersion = deps.cliVersion || CLI_VERSION;
367
+ const isBackgroundProcess = deps.isBackgroundProcess ?? !process.stdout.isTTY;
368
+ const autoUpdateForceLocal = parseBooleanEnv(process.env.CONDUCTOR_AUTO_UPDATE_FORCE_LOCAL);
369
+ const autoUpdateSupportedInstall =
370
+ autoUpdateForceLocal || isManagedInstallPathFn(installedPackageRoot);
371
+ const lockHandoffToken =
372
+ normalizeOptionalString(config.LOCK_HANDOFF_TOKEN) ||
373
+ normalizeOptionalString(process.env.CONDUCTOR_LOCK_HANDOFF_TOKEN);
374
+ const lockHandoffFromPid = normalizePositiveInt(
375
+ config.LOCK_HANDOFF_FROM_PID || process.env.CONDUCTOR_LOCK_HANDOFF_FROM_PID,
376
+ null,
377
+ );
378
+ const restartLauncherScript = normalizeOptionalString(config.RESTART_LAUNCHER_SCRIPT);
379
+ const restartLauncherArgs = normalizeStringArray(config.RESTART_LAUNCHER_ARGS);
380
+ const normalizedVersionCheckArgs = normalizeStringArray(config.VERSION_CHECK_ARGS);
381
+ const versionCheckScript =
382
+ normalizeOptionalString(config.VERSION_CHECK_SCRIPT) || restartLauncherScript;
383
+ const versionCheckArgs =
384
+ normalizedVersionCheckArgs.length > 0
385
+ ? normalizedVersionCheckArgs
386
+ : ["--version"];
387
+
388
+ // Auto-update configuration
389
+ const AUTO_UPDATE_ENABLED =
390
+ autoUpdateSupportedInstall &&
391
+ (process.env.CONDUCTOR_AUTO_UPDATE !== "false") &&
392
+ (userConfig.auto_update !== false);
393
+ const UPDATE_WINDOW = parseUpdateWindowFn(
394
+ process.env.CONDUCTOR_UPDATE_WINDOW || userConfig.update_window || "02:00-04:00"
395
+ );
312
396
 
313
397
  const spawnFn = deps.spawn || spawn;
314
398
  const mkdirSyncFn = deps.mkdirSync || fs.mkdirSync;
@@ -319,10 +403,14 @@ export function startDaemon(config = {}, deps = {}) {
319
403
  const renameSyncFn = deps.renameSync || fs.renameSync;
320
404
  const createWriteStreamFn = deps.createWriteStream || fs.createWriteStream;
321
405
  const fetchFn = deps.fetch || fetch;
406
+ const createRtcPeerConnection = deps.createRtcPeerConnection || null;
407
+ const importOptionalModule = deps.importOptionalModule || ((moduleName) => import(moduleName));
322
408
  const createWebSocketClient =
323
409
  deps.createWebSocketClient ||
324
410
  ((clientConfig, options) => new ConductorWebSocketClient(clientConfig, options));
325
411
  const createLogCollector = deps.createLogCollector || ((backendUrl) => new DaemonLogCollector(backendUrl));
412
+ const RTC_MODULE_CANDIDATES = resolveRtcModuleCandidates(process.env.CONDUCTOR_PTY_RTC_MODULES);
413
+ const RTC_DIRECT_DISABLED = parseBooleanEnv(process.env.CONDUCTOR_DISABLE_PTY_DIRECT_RTC);
326
414
  const PROJECT_PATH_LOOKUP_TIMEOUT_MS = parsePositiveInt(
327
415
  process.env.CONDUCTOR_PROJECT_PATH_LOOKUP_TIMEOUT_MS,
328
416
  1500,
@@ -368,6 +456,53 @@ export function startDaemon(config = {}, deps = {}) {
368
456
  DEFAULT_TERMINAL_RING_BUFFER_MAX_BYTES,
369
457
  );
370
458
 
459
+ const readLockState = () => {
460
+ const raw = String(readFileSyncFn(LOCK_FILE, "utf-8") || "").trim();
461
+ if (!raw) {
462
+ return null;
463
+ }
464
+
465
+ const pid = Number.parseInt(raw, 10);
466
+ if (!Number.isNaN(pid) && pid > 0) {
467
+ return {
468
+ pid,
469
+ handoffFromPid: null,
470
+ handoffToken: null,
471
+ handoffExpiresAt: null,
472
+ };
473
+ }
474
+
475
+ try {
476
+ const parsed = JSON.parse(raw);
477
+ const parsedPid = normalizePositiveInt(parsed?.pid, null);
478
+ const parsedHandoffFromPid = normalizePositiveInt(parsed?.handoff_from_pid, null);
479
+ return {
480
+ pid: parsedPid ?? parsedHandoffFromPid,
481
+ handoffFromPid: parsedHandoffFromPid,
482
+ handoffToken: normalizeOptionalString(parsed?.handoff_token),
483
+ handoffExpiresAt: normalizePositiveInt(parsed?.handoff_expires_at, null),
484
+ };
485
+ } catch {
486
+ return null;
487
+ }
488
+ };
489
+
490
+ const hasMatchingLockHandoff = (lockState) => {
491
+ if (!lockState || !lockHandoffToken || !lockHandoffFromPid) {
492
+ return false;
493
+ }
494
+ if (lockState.handoffToken !== lockHandoffToken) {
495
+ return false;
496
+ }
497
+ if (lockState.handoffFromPid !== lockHandoffFromPid) {
498
+ return false;
499
+ }
500
+ if (lockState.handoffExpiresAt && lockState.handoffExpiresAt < Date.now()) {
501
+ return false;
502
+ }
503
+ return true;
504
+ };
505
+
371
506
  try {
372
507
  mkdirSyncFn(WORKSPACE_ROOT, { recursive: true });
373
508
  } catch (err) {
@@ -378,44 +513,57 @@ export function startDaemon(config = {}, deps = {}) {
378
513
  const LOCK_FILE = path.join(WORKSPACE_ROOT, "daemon.pid");
379
514
  try {
380
515
  if (existsSyncFn(LOCK_FILE)) {
381
- const pid = parseInt(readFileSyncFn(LOCK_FILE, "utf-8"), 10);
382
- if (!Number.isNaN(pid)) {
516
+ const lockState = readLockState();
517
+ const pid = lockState?.pid;
518
+ if (pid) {
519
+ const handoffMatched = hasMatchingLockHandoff(lockState);
383
520
  try {
384
- const alive = isProcessAlive(pid);
385
- if (alive) {
386
- if (config.FORCE) {
387
- log(`Force enabled: stopping existing daemon PID ${pid}`);
388
- try {
389
- killFn(pid, "SIGTERM");
390
- } catch (killErr) {
391
- if (!killErr || killErr.code !== "ESRCH") {
392
- logError(`Failed to stop existing daemon PID ${pid}: ${killErr.message}`);
393
- return exitAndReturn(1);
521
+ if (handoffMatched) {
522
+ log(`Taking over daemon lock from PID ${pid} via handoff`);
523
+ } else {
524
+ const alive = isProcessAlive(pid);
525
+ if (alive) {
526
+ if (config.FORCE) {
527
+ log(`Force enabled: stopping existing daemon PID ${pid}`);
528
+ try {
529
+ killFn(pid, "SIGTERM");
530
+ } catch (killErr) {
531
+ if (!killErr || killErr.code !== "ESRCH") {
532
+ logError(`Failed to stop existing daemon PID ${pid}: ${killErr.message}`);
533
+ return exitAndReturn(1);
534
+ }
394
535
  }
395
- }
396
- try {
397
- if (isProcessAlive(pid)) {
398
- logError(`Existing daemon PID ${pid} is still running; please stop it manually.`);
536
+ try {
537
+ if (isProcessAlive(pid)) {
538
+ logError(`Existing daemon PID ${pid} is still running; please stop it manually.`);
539
+ return exitAndReturn(1);
540
+ }
541
+ } catch (checkErr) {
542
+ logError(`Failed to verify daemon PID ${pid}: ${checkErr.message}`);
399
543
  return exitAndReturn(1);
400
544
  }
401
- } catch (checkErr) {
402
- logError(`Failed to verify daemon PID ${pid}: ${checkErr.message}`);
545
+ log("Removing lock file after force stop");
546
+ unlinkSyncFn(LOCK_FILE);
547
+ } else {
548
+ logError(`Daemon already running with PID ${pid}`);
403
549
  return exitAndReturn(1);
404
550
  }
405
- log("Removing lock file after force stop");
406
- unlinkSyncFn(LOCK_FILE);
407
551
  } else {
408
- logError(`Daemon already running with PID ${pid}`);
409
- return exitAndReturn(1);
552
+ log("Removing stale lock file");
553
+ unlinkSyncFn(LOCK_FILE);
410
554
  }
411
- } else {
412
- log("Removing stale lock file");
413
- unlinkSyncFn(LOCK_FILE);
414
555
  }
415
556
  } catch (e) {
416
- logError(`Daemon already running with PID ${pid} (access denied)`);
417
- return exitAndReturn(1);
557
+ if (handoffMatched) {
558
+ log(`Taking over daemon lock from PID ${pid} via handoff`);
559
+ } else {
560
+ logError(`Daemon already running with PID ${pid} (access denied)`);
561
+ return exitAndReturn(1);
562
+ }
418
563
  }
564
+ } else {
565
+ log("Removing malformed lock file");
566
+ unlinkSyncFn(LOCK_FILE);
419
567
  }
420
568
  }
421
569
  writeFileSyncFn(LOCK_FILE, process.pid.toString());
@@ -427,8 +575,8 @@ export function startDaemon(config = {}, deps = {}) {
427
575
  const cleanupLock = () => {
428
576
  try {
429
577
  if (existsSyncFn(LOCK_FILE)) {
430
- const pid = parseInt(readFileSyncFn(LOCK_FILE, "utf-8"), 10);
431
- if (pid === process.pid) {
578
+ const lockState = readLockState();
579
+ if (lockState?.pid === process.pid) {
432
580
  unlinkSyncFn(LOCK_FILE);
433
581
  }
434
582
  }
@@ -437,6 +585,18 @@ export function startDaemon(config = {}, deps = {}) {
437
585
  }
438
586
  };
439
587
 
588
+ const writeLockHandoff = ({ handoffToken, handoffFromPid, handoffExpiresAt }) => {
589
+ writeFileSyncFn(
590
+ LOCK_FILE,
591
+ JSON.stringify({
592
+ pid: handoffFromPid,
593
+ handoff_from_pid: handoffFromPid,
594
+ handoff_token: handoffToken,
595
+ handoff_expires_at: handoffExpiresAt,
596
+ }),
597
+ );
598
+ };
599
+
440
600
  process.on("exit", cleanupLock);
441
601
  const signalExitCode = (signal) => (signal === "SIGINT" ? 130 : 143);
442
602
  const handleSignal = (signal) => {
@@ -503,6 +663,7 @@ export function startDaemon(config = {}, deps = {}) {
503
663
  let daemonShuttingDown = false;
504
664
  const activeTaskProcesses = new Map();
505
665
  const activePtySessions = new Map();
666
+ const activePtyRtcTransports = new Map();
506
667
  const suppressedExitStatusReports = new Set();
507
668
  const seenCommandRequestIds = new Set();
508
669
  let lastConnectedAt = null;
@@ -519,6 +680,16 @@ export function startDaemon(config = {}, deps = {}) {
519
680
  let watchdogLastPresenceMismatchAt = 0;
520
681
  let watchdogAwaitingHealthySignalAt = null;
521
682
  let watchdogTimer = null;
683
+
684
+ // --- Auto-update state ---
685
+ const VERSION_CHECK_INTERVAL_MS = 6 * 60 * 60 * 1000; // 6 hours
686
+ let lastVersionCheckAt = 0;
687
+ let latestKnownVersion = null;
688
+ let updateAvailable = false;
689
+ let autoUpdateInProgress = false;
690
+
691
+ let rtcImplementationPromise = null;
692
+ let rtcAvailabilityLogKey = null;
522
693
  const logCollector = createLogCollector(BACKEND_HTTP);
523
694
  const createPtyFn = deps.createPty || defaultCreatePty;
524
695
  const client = createWebSocketClient(sdkConfig, {
@@ -526,6 +697,7 @@ export function startDaemon(config = {}, deps = {}) {
526
697
  "x-conductor-host": AGENT_NAME,
527
698
  "x-conductor-backends": SUPPORTED_BACKENDS.join(","),
528
699
  "x-conductor-capabilities": "pty_task",
700
+ "x-conductor-version": cliVersion,
529
701
  },
530
702
  onConnected: ({ isReconnect, connectedAt } = { isReconnect: false, connectedAt: Date.now() }) => {
531
703
  wsConnected = true;
@@ -583,8 +755,15 @@ export function startDaemon(config = {}, deps = {}) {
583
755
  logError(`Failed to connect: ${err}`);
584
756
  });
585
757
 
758
+ if (!AUTO_UPDATE_ENABLED && autoUpdateSupportedInstall === false) {
759
+ log("[auto-update] Disabled for local/dev install; set CONDUCTOR_AUTO_UPDATE_FORCE_LOCAL=true to override");
760
+ }
761
+
586
762
  watchdogTimer = setInterval(() => {
587
763
  void runDaemonWatchdog();
764
+ // Auto-update checks (internally throttled)
765
+ void checkForUpdate().catch(() => {});
766
+ void tryAutoUpdate().catch(() => {});
588
767
  }, DAEMON_WATCHDOG_INTERVAL_MS);
589
768
  if (typeof watchdogTimer?.unref === "function") {
590
769
  watchdogTimer.unref();
@@ -742,6 +921,260 @@ export function startDaemon(config = {}, deps = {}) {
742
921
  }
743
922
  }
744
923
 
924
+ // ---------------------------------------------------------------------------
925
+ // Auto-update: periodic version check (P1) + safe-window update (P2)
926
+ // ---------------------------------------------------------------------------
927
+
928
+ const SEMVER_RE = /^\d+\.\d+\.\d+(-[\w.]+)?$/;
929
+
930
+ async function checkForUpdate() {
931
+ if (!AUTO_UPDATE_ENABLED || cliVersion === "unknown") return;
932
+ const now = Date.now();
933
+ if (now - lastVersionCheckAt < VERSION_CHECK_INTERVAL_MS) return;
934
+ lastVersionCheckAt = now;
935
+ try {
936
+ const latest = await fetchLatestVersionFn();
937
+ if (!latest || !SEMVER_RE.test(latest)) return; // reject malformed versions
938
+ if (isNewerVersionFn(latest, cliVersion)) {
939
+ if (latestKnownVersion !== latest) {
940
+ log(`[auto-update] New version available: ${cliVersion} -> ${latest}`);
941
+ }
942
+ latestKnownVersion = latest;
943
+ updateAvailable = true;
944
+ } else {
945
+ updateAvailable = false;
946
+ latestKnownVersion = latest;
947
+ }
948
+ } catch {
949
+ /* silent — non-critical */
950
+ }
951
+ }
952
+
953
+ const AUTO_UPDATE_COOLDOWN_MS = 60 * 60 * 1000; // 1 hour cooldown after failure
954
+ let lastAutoUpdateFailAt = 0;
955
+
956
+ function hasActiveTasks() {
957
+ return activeTaskProcesses.size > 0 || activePtySessions.size > 0;
958
+ }
959
+
960
+ async function tryAutoUpdate() {
961
+ if (!AUTO_UPDATE_ENABLED || !updateAvailable || !latestKnownVersion) return;
962
+ if (autoUpdateInProgress || daemonShuttingDown) return;
963
+ if (hasActiveTasks()) return;
964
+ if (!isInUpdateWindowFn(UPDATE_WINDOW)) return;
965
+ if (Date.now() - lastAutoUpdateFailAt < AUTO_UPDATE_COOLDOWN_MS) return;
966
+
967
+ autoUpdateInProgress = true;
968
+ log(`[auto-update] Starting: ${cliVersion} -> ${latestKnownVersion}`);
969
+ try {
970
+ await performAutoUpdate(latestKnownVersion);
971
+ } catch (err) {
972
+ logError(`[auto-update] Failed: ${err?.message || err}`);
973
+ lastAutoUpdateFailAt = Date.now();
974
+ } finally {
975
+ autoUpdateInProgress = false;
976
+ }
977
+ }
978
+
979
+ function runInstallCommand(pm, pkgSpec) {
980
+ return new Promise((resolve) => {
981
+ let cmd, args;
982
+ switch (pm) {
983
+ case "pnpm":
984
+ cmd = "pnpm";
985
+ args = ["add", "-g", pkgSpec];
986
+ break;
987
+ case "yarn":
988
+ cmd = "yarn";
989
+ args = ["global", "add", pkgSpec];
990
+ break;
991
+ default:
992
+ cmd = "npm";
993
+ args = ["install", "-g", pkgSpec];
994
+ break;
995
+ }
996
+ let stdout = "";
997
+ let stderr = "";
998
+ const child = spawnFn(cmd, args, {
999
+ stdio: ["ignore", "pipe", "pipe"],
1000
+ });
1001
+ const timer = setTimeout(() => {
1002
+ try {
1003
+ child.kill("SIGTERM");
1004
+ } catch {
1005
+ /* ignore */
1006
+ }
1007
+ }, 120_000);
1008
+ child.stdout?.on("data", (d) => {
1009
+ if (stdout.length < 4000) stdout += d.toString().slice(0, 2000);
1010
+ });
1011
+ child.stderr?.on("data", (d) => {
1012
+ if (stderr.length < 4000) stderr += d.toString().slice(0, 2000);
1013
+ });
1014
+ child.on("close", (code) => {
1015
+ clearTimeout(timer);
1016
+ resolve({ success: code === 0, code, stdout, stderr });
1017
+ });
1018
+ child.on("error", (err) => {
1019
+ clearTimeout(timer);
1020
+ resolve({ success: false, code: -1, stdout, stderr: err.message });
1021
+ });
1022
+ });
1023
+ }
1024
+
1025
+ function runCommand(command, args, timeoutMs = 120_000) {
1026
+ return new Promise((resolve) => {
1027
+ let stdout = "";
1028
+ let stderr = "";
1029
+ const child = spawnFn(command, args, {
1030
+ stdio: ["ignore", "pipe", "pipe"],
1031
+ env: { ...process.env },
1032
+ });
1033
+ const timer = setTimeout(() => {
1034
+ try {
1035
+ child.kill("SIGTERM");
1036
+ } catch {
1037
+ /* ignore */
1038
+ }
1039
+ }, timeoutMs);
1040
+ child.stdout?.on("data", (chunk) => {
1041
+ if (stdout.length < 4000) stdout += chunk.toString().slice(0, 2000);
1042
+ });
1043
+ child.stderr?.on("data", (chunk) => {
1044
+ if (stderr.length < 4000) stderr += chunk.toString().slice(0, 2000);
1045
+ });
1046
+ child.on("close", (code) => {
1047
+ clearTimeout(timer);
1048
+ resolve({ success: code === 0, code, stdout, stderr });
1049
+ });
1050
+ child.on("error", (err) => {
1051
+ clearTimeout(timer);
1052
+ resolve({ success: false, code: -1, stdout, stderr: err.message || String(err) });
1053
+ });
1054
+ });
1055
+ }
1056
+
1057
+ async function readInstalledCliVersion() {
1058
+ const commandAttempts = versionCheckScript
1059
+ ? [{
1060
+ command: process.execPath,
1061
+ args: [versionCheckScript, ...versionCheckArgs],
1062
+ }]
1063
+ : [{
1064
+ command: "conductor",
1065
+ args: ["--version"],
1066
+ }];
1067
+
1068
+ for (const attempt of commandAttempts) {
1069
+ const commandResult = await runCommand(attempt.command, attempt.args, 15_000);
1070
+ const combinedOutput = `${commandResult.stdout}\n${commandResult.stderr}`.trim();
1071
+ const match = combinedOutput.match(/conductor version ([^\s]+)/);
1072
+ if (match?.[1]) {
1073
+ return match[1];
1074
+ }
1075
+ }
1076
+
1077
+ let pkgPath;
1078
+ try {
1079
+ pkgPath = moduleRequire.resolve(`${PACKAGE_NAME}/package.json`);
1080
+ } catch {
1081
+ pkgPath = path.join(installedPackageRoot, "package.json");
1082
+ }
1083
+ const newPkg = JSON.parse(readFileSyncFn(pkgPath, "utf-8"));
1084
+ return newPkg.version || null;
1085
+ }
1086
+
1087
+ async function performAutoUpdate(targetVersion) {
1088
+ const pm = detectPackageManagerFn({
1089
+ launcherPath: restartLauncherScript || versionCheckScript,
1090
+ packageRoot: installedPackageRoot,
1091
+ });
1092
+ const pkgSpec = `${PACKAGE_NAME}@${targetVersion}`;
1093
+ log(`[auto-update] Installing ${pkgSpec} via ${pm}...`);
1094
+
1095
+ // Step 1: install
1096
+ const result = await runInstallCommand(pm, pkgSpec);
1097
+ if (!result.success) {
1098
+ throw new Error(
1099
+ `Install failed (exit ${result.code}): ${(result.stderr || result.stdout).slice(0, 200)}`
1100
+ );
1101
+ }
1102
+
1103
+ // Step 2: re-check active tasks — a task may have arrived during the install
1104
+ if (hasActiveTasks()) {
1105
+ log("[auto-update] Active tasks appeared during install; aborting restart (will retry later)");
1106
+ return;
1107
+ }
1108
+
1109
+ // Step 3: verify installed version using the globally resolved CLI entry point.
1110
+ try {
1111
+ const installedVersion = await readInstalledCliVersion();
1112
+ if (installedVersion !== targetVersion) {
1113
+ throw new Error(
1114
+ `Version mismatch after install: expected ${targetVersion}, got ${installedVersion}`
1115
+ );
1116
+ }
1117
+ } catch (verifyErr) {
1118
+ throw new Error(`Version verification failed: ${verifyErr?.message || verifyErr}`);
1119
+ }
1120
+
1121
+ log(`[auto-update] Verified ${targetVersion}. Restarting daemon...`);
1122
+
1123
+ let logFd = null;
1124
+ if (isBackgroundProcess) {
1125
+ if (!restartLauncherScript) {
1126
+ throw new Error("Missing daemon restart launcher script");
1127
+ }
1128
+ try {
1129
+ mkdirSyncFn(DAEMON_LOG_DIR, { recursive: true });
1130
+ } catch {
1131
+ /* ignore */
1132
+ }
1133
+ logFd = fs.openSync(DAEMON_LOG_PATH, "a");
1134
+ }
1135
+
1136
+ // Step 4: graceful shutdown
1137
+ await shutdownDaemon("auto-update");
1138
+
1139
+ // Step 5: re-spawn (only in background/nohup mode)
1140
+ if (isBackgroundProcess) {
1141
+ const handoffToken = `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
1142
+ const handoffExpiresAt = Date.now() + 15_000;
1143
+ try {
1144
+ writeLockHandoff({
1145
+ handoffToken,
1146
+ handoffFromPid: process.pid,
1147
+ handoffExpiresAt,
1148
+ });
1149
+ const child = spawnFn(process.execPath, [restartLauncherScript, ...restartLauncherArgs], {
1150
+ detached: true,
1151
+ stdio: ["ignore", logFd, logFd],
1152
+ env: {
1153
+ ...process.env,
1154
+ CONDUCTOR_LOCK_HANDOFF_TOKEN: handoffToken,
1155
+ CONDUCTOR_LOCK_HANDOFF_FROM_PID: String(process.pid),
1156
+ CONDUCTOR_LOCK_HANDOFF_EXPIRES_AT: String(handoffExpiresAt),
1157
+ },
1158
+ });
1159
+ child.unref();
1160
+ log(`[auto-update] New daemon spawned (PID ${child.pid})`);
1161
+ } catch (error) {
1162
+ cleanupLock();
1163
+ exitFn(1);
1164
+ throw new Error(`Restart failed after shutdown: ${error?.message || error}`);
1165
+ } finally {
1166
+ if (typeof logFd === "number") {
1167
+ fs.closeSync(logFd);
1168
+ }
1169
+ }
1170
+ } else {
1171
+ log(
1172
+ `[auto-update] Updated to ${targetVersion}. Foreground mode — please restart the daemon.`
1173
+ );
1174
+ }
1175
+ exitFn(0);
1176
+ }
1177
+
745
1178
  const getActiveTaskIds = () => [
746
1179
  ...new Set([...activeTaskProcesses.keys(), ...activePtySessions.keys()]),
747
1180
  ];
@@ -908,6 +1341,285 @@ export function startDaemon(config = {}, deps = {}) {
908
1341
  });
909
1342
  }
910
1343
 
1344
+ function sendPtyTransportStatus(payload) {
1345
+ return client.sendJson({
1346
+ type: "pty_transport_status",
1347
+ payload,
1348
+ });
1349
+ }
1350
+
1351
+ function rejectCreateTaskDuringShutdown(payload, { sendAck = true } = {}) {
1352
+ const taskId = payload?.task_id ? String(payload.task_id) : "";
1353
+ const projectId = payload?.project_id ? String(payload.project_id) : "";
1354
+ const requestId = payload?.request_id ? String(payload.request_id) : "";
1355
+ log(`Rejecting create_task for ${taskId || "unknown"}: daemon is shutting down`);
1356
+ if (sendAck) {
1357
+ sendAgentCommandAck({
1358
+ requestId,
1359
+ taskId,
1360
+ eventType: "create_task",
1361
+ accepted: false,
1362
+ }).catch(() => {});
1363
+ }
1364
+ if (!taskId || !projectId) {
1365
+ return;
1366
+ }
1367
+ client
1368
+ .sendJson({
1369
+ type: "task_status_update",
1370
+ payload: {
1371
+ task_id: taskId,
1372
+ project_id: projectId,
1373
+ status: "KILLED",
1374
+ summary: "daemon shutting down",
1375
+ },
1376
+ })
1377
+ .catch((err) => {
1378
+ logError(`Failed to report shutdown rejection for ${taskId}: ${err?.message || err}`);
1379
+ });
1380
+ }
1381
+
1382
+ function rejectCreatePtyTaskDuringShutdown(payload, { sendAck = true } = {}) {
1383
+ const taskId = payload?.task_id ? String(payload.task_id) : "";
1384
+ const projectId = payload?.project_id ? String(payload.project_id) : "";
1385
+ const ptySessionId = payload?.pty_session_id ? String(payload.pty_session_id) : null;
1386
+ const requestId = payload?.request_id ? String(payload.request_id) : "";
1387
+ log(`Rejecting create_pty_task for ${taskId || "unknown"}: daemon is shutting down`);
1388
+ if (sendAck) {
1389
+ sendAgentCommandAck({
1390
+ requestId,
1391
+ taskId,
1392
+ eventType: "create_pty_task",
1393
+ accepted: false,
1394
+ }).catch(() => {});
1395
+ }
1396
+ sendTerminalEvent("terminal_error", {
1397
+ task_id: taskId || undefined,
1398
+ project_id: projectId || undefined,
1399
+ pty_session_id: ptySessionId,
1400
+ message: "daemon shutting down",
1401
+ }).catch((err) => {
1402
+ logError(`Failed to report PTY shutdown rejection for ${taskId || "unknown"}: ${err?.message || err}`);
1403
+ });
1404
+ }
1405
+
1406
+ function sendPtyTransportSignal(payload) {
1407
+ return client.sendJson({
1408
+ type: "pty_transport_signal",
1409
+ payload,
1410
+ });
1411
+ }
1412
+
1413
+ function logRtcAvailabilityOnce(key, message) {
1414
+ if (rtcAvailabilityLogKey === key) {
1415
+ return;
1416
+ }
1417
+ rtcAvailabilityLogKey = key;
1418
+ log(message);
1419
+ }
1420
+
1421
+ async function resolveRtcImplementation() {
1422
+ if (RTC_DIRECT_DISABLED) {
1423
+ logRtcAvailabilityOnce(
1424
+ "disabled",
1425
+ "PTY direct RTC runtime disabled by CONDUCTOR_DISABLE_PTY_DIRECT_RTC=1; relay fallback only",
1426
+ );
1427
+ return null;
1428
+ }
1429
+
1430
+ if (createRtcPeerConnection) {
1431
+ logRtcAvailabilityOnce("ready:deps", "PTY direct RTC runtime ready via injected peer connection");
1432
+ return {
1433
+ source: "deps.createRtcPeerConnection",
1434
+ createPeerConnection: (...args) => createRtcPeerConnection(...args),
1435
+ };
1436
+ }
1437
+
1438
+ if (typeof globalThis.RTCPeerConnection === "function") {
1439
+ logRtcAvailabilityOnce("ready:global", "PTY direct RTC runtime ready via globalThis.RTCPeerConnection");
1440
+ return {
1441
+ source: "globalThis.RTCPeerConnection",
1442
+ createPeerConnection: (...args) => new globalThis.RTCPeerConnection(...args),
1443
+ };
1444
+ }
1445
+
1446
+ if (!rtcImplementationPromise) {
1447
+ rtcImplementationPromise = (async () => {
1448
+ for (const moduleName of RTC_MODULE_CANDIDATES) {
1449
+ try {
1450
+ const mod = await importOptionalModule(moduleName);
1451
+ const PeerConnectionCtor =
1452
+ mod?.RTCPeerConnection ||
1453
+ mod?.default?.RTCPeerConnection ||
1454
+ mod?.default;
1455
+ if (typeof PeerConnectionCtor === "function") {
1456
+ return {
1457
+ source: moduleName,
1458
+ createPeerConnection: (...args) => new PeerConnectionCtor(...args),
1459
+ };
1460
+ }
1461
+ } catch {
1462
+ // Try next implementation.
1463
+ }
1464
+ }
1465
+ return null;
1466
+ })();
1467
+ }
1468
+
1469
+ const rtc = await rtcImplementationPromise;
1470
+ if (rtc) {
1471
+ logRtcAvailabilityOnce(`ready:${rtc.source}`, `PTY direct RTC runtime ready via ${rtc.source}`);
1472
+ return rtc;
1473
+ }
1474
+
1475
+ logRtcAvailabilityOnce(
1476
+ "unavailable",
1477
+ `PTY direct RTC runtime unavailable; install optional dependency ${DEFAULT_RTC_MODULE_CANDIDATES[0]} or keep relay fallback`,
1478
+ );
1479
+ return null;
1480
+ }
1481
+
1482
+ function cleanupPtyRtcTransport(taskId, expectedSessionId = null) {
1483
+ const current = activePtyRtcTransports.get(taskId);
1484
+ if (!current) {
1485
+ return;
1486
+ }
1487
+ if (expectedSessionId && current.sessionId !== expectedSessionId) {
1488
+ return;
1489
+ }
1490
+ try {
1491
+ current.channel?.close?.();
1492
+ } catch {}
1493
+ try {
1494
+ current.peer?.close?.();
1495
+ } catch {}
1496
+ activePtyRtcTransports.delete(taskId);
1497
+ }
1498
+
1499
+ async function startPtyRtcNegotiation(taskId, sessionId, connectionId, offerDescription) {
1500
+ const record = activePtySessions.get(taskId);
1501
+ if (!record) {
1502
+ return { ok: false, reason: "terminal_session_not_found" };
1503
+ }
1504
+
1505
+ const rtc = await resolveRtcImplementation();
1506
+ if (!rtc) {
1507
+ return { ok: false, reason: "direct_transport_not_supported" };
1508
+ }
1509
+
1510
+ cleanupPtyRtcTransport(taskId);
1511
+
1512
+ try {
1513
+ const peer = rtc.createPeerConnection();
1514
+ const transport = {
1515
+ taskId,
1516
+ sessionId,
1517
+ connectionId,
1518
+ peer,
1519
+ channel: null,
1520
+ };
1521
+ activePtyRtcTransports.set(taskId, transport);
1522
+
1523
+ peer.ondatachannel = (event) => {
1524
+ transport.channel = event?.channel || null;
1525
+ if (transport.channel) {
1526
+ transport.channel.onmessage = (messageEvent) => {
1527
+ try {
1528
+ const raw =
1529
+ typeof messageEvent?.data === "string"
1530
+ ? messageEvent.data
1531
+ : Buffer.isBuffer(messageEvent?.data)
1532
+ ? messageEvent.data.toString("utf8")
1533
+ : String(messageEvent?.data ?? "");
1534
+ const parsed = JSON.parse(raw);
1535
+ handleDirectTransportPayload(taskId, sessionId, connectionId, parsed);
1536
+ } catch (error) {
1537
+ logError(`Failed to handle PTY direct channel message for ${taskId}: ${error?.message || error}`);
1538
+ }
1539
+ };
1540
+ transport.channel.onopen = () => {
1541
+ sendPtyTransportStatus({
1542
+ task_id: taskId,
1543
+ session_id: sessionId,
1544
+ connection_id: connectionId,
1545
+ transport_state: "direct",
1546
+ transport_policy: "direct_preferred",
1547
+ writer_connection_id: connectionId,
1548
+ direct_candidate: true,
1549
+ }).catch((err) => {
1550
+ logError(`Failed to report direct PTY transport status for ${taskId}: ${err?.message || err}`);
1551
+ });
1552
+ };
1553
+ transport.channel.onclose = () => {
1554
+ sendPtyTransportStatus({
1555
+ task_id: taskId,
1556
+ session_id: sessionId,
1557
+ connection_id: connectionId,
1558
+ transport_state: "fallback_relay",
1559
+ transport_policy: "direct_preferred",
1560
+ writer_connection_id: connectionId,
1561
+ direct_candidate: false,
1562
+ reason: "direct_channel_closed",
1563
+ }).catch((err) => {
1564
+ logError(`Failed to report PTY transport fallback for ${taskId}: ${err?.message || err}`);
1565
+ });
1566
+ cleanupPtyRtcTransport(taskId, sessionId);
1567
+ };
1568
+ }
1569
+ };
1570
+
1571
+ peer.onicecandidate = (event) => {
1572
+ if (!event?.candidate) {
1573
+ return;
1574
+ }
1575
+ sendPtyTransportSignal({
1576
+ task_id: taskId,
1577
+ session_id: sessionId,
1578
+ connection_id: connectionId,
1579
+ signal_type: "ice_candidate",
1580
+ candidate: typeof event.candidate.toJSON === "function" ? event.candidate.toJSON() : event.candidate,
1581
+ }).catch((err) => {
1582
+ logError(`Failed to report PTY ICE candidate for ${taskId}: ${err?.message || err}`);
1583
+ });
1584
+ };
1585
+
1586
+ await peer.setRemoteDescription({
1587
+ type: "offer",
1588
+ sdp: offerDescription.sdp,
1589
+ });
1590
+ const answer = await peer.createAnswer();
1591
+ await peer.setLocalDescription(answer);
1592
+
1593
+ await sendPtyTransportSignal({
1594
+ task_id: taskId,
1595
+ session_id: sessionId,
1596
+ connection_id: connectionId,
1597
+ signal_type: "answer",
1598
+ description: {
1599
+ type: answer.type,
1600
+ sdp: answer.sdp,
1601
+ },
1602
+ });
1603
+ await sendPtyTransportStatus({
1604
+ task_id: taskId,
1605
+ session_id: sessionId,
1606
+ connection_id: connectionId,
1607
+ transport_state: "negotiating",
1608
+ transport_policy: "direct_preferred",
1609
+ writer_connection_id: connectionId,
1610
+ direct_candidate: true,
1611
+ });
1612
+
1613
+ return { ok: true };
1614
+ } catch (error) {
1615
+ cleanupPtyRtcTransport(taskId, sessionId);
1616
+ return {
1617
+ ok: false,
1618
+ reason: error?.message || "rtc_negotiation_failed",
1619
+ };
1620
+ }
1621
+ }
1622
+
911
1623
  function resolvePtyLaunchSpec(launchConfig, fallbackCwd) {
912
1624
  const normalizedLaunchConfig = normalizeLaunchConfig(launchConfig);
913
1625
  const entrypointType =
@@ -1025,6 +1737,54 @@ export function startDaemon(config = {}, deps = {}) {
1025
1737
  return record.outputSeq;
1026
1738
  }
1027
1739
 
1740
+ function sendDirectPtyPayload(taskId, payload) {
1741
+ const transport = activePtyRtcTransports.get(taskId);
1742
+ const channel = transport?.channel;
1743
+ if (!channel || channel.readyState !== "open" || typeof channel.send !== "function") {
1744
+ return false;
1745
+ }
1746
+ try {
1747
+ channel.send(JSON.stringify(payload));
1748
+ return true;
1749
+ } catch (error) {
1750
+ logError(`Failed to send PTY direct payload for ${taskId}: ${error?.message || error}`);
1751
+ if (transport) {
1752
+ sendPtyTransportStatus({
1753
+ task_id: taskId,
1754
+ session_id: transport.sessionId,
1755
+ connection_id: transport.connectionId,
1756
+ transport_state: "fallback_relay",
1757
+ transport_policy: "direct_preferred",
1758
+ writer_connection_id: transport.connectionId,
1759
+ direct_candidate: false,
1760
+ reason: "direct_channel_send_failed",
1761
+ }).catch((err) => {
1762
+ logError(`Failed to report PTY direct send fallback for ${taskId}: ${err?.message || err}`);
1763
+ });
1764
+ }
1765
+ cleanupPtyRtcTransport(taskId);
1766
+ return false;
1767
+ }
1768
+ }
1769
+
1770
+ function handleDirectTransportPayload(taskId, sessionId, connectionId, payload) {
1771
+ const transport = activePtyRtcTransports.get(taskId);
1772
+ if (
1773
+ !transport ||
1774
+ transport.sessionId !== sessionId ||
1775
+ transport.connectionId !== connectionId
1776
+ ) {
1777
+ return;
1778
+ }
1779
+ if (payload?.type === "terminal_input" && payload.payload) {
1780
+ handleTerminalInput(payload.payload);
1781
+ return;
1782
+ }
1783
+ if (payload?.type === "terminal_resize" && payload.payload) {
1784
+ handleTerminalResize(payload.payload);
1785
+ }
1786
+ }
1787
+
1028
1788
  function attachPtyStreamHandlers(taskId, record) {
1029
1789
  const writeLogChunk = (chunk) => {
1030
1790
  if (record.logStream) {
@@ -1035,13 +1795,30 @@ export function startDaemon(config = {}, deps = {}) {
1035
1795
  record.pty.onData((data) => {
1036
1796
  writeLogChunk(data);
1037
1797
  const seq = bufferTerminalOutput(record, data);
1038
- sendTerminalEvent("terminal_output", {
1798
+ const latencySample = record.pendingLatencySample
1799
+ ? {
1800
+ client_input_seq: record.pendingLatencySample.clientInputSeq ?? undefined,
1801
+ client_sent_at: record.pendingLatencySample.clientSentAt ?? undefined,
1802
+ server_received_at: record.pendingLatencySample.serverReceivedAt ?? undefined,
1803
+ daemon_received_at: record.pendingLatencySample.daemonReceivedAt,
1804
+ first_output_at: new Date().toISOString(),
1805
+ daemon_input_to_first_output_ms: Math.max(0, Date.now() - record.pendingLatencySample.daemonReceivedAtMs),
1806
+ }
1807
+ : undefined;
1808
+ record.pendingLatencySample = null;
1809
+ const outputPayload = {
1039
1810
  task_id: taskId,
1040
1811
  project_id: record.projectId,
1041
1812
  pty_session_id: record.ptySessionId,
1042
1813
  seq,
1043
1814
  data,
1044
- }).catch((err) => {
1815
+ ...(latencySample ? { latency_sample: latencySample } : {}),
1816
+ };
1817
+ sendDirectPtyPayload(taskId, {
1818
+ type: "terminal_output",
1819
+ payload: outputPayload,
1820
+ });
1821
+ sendTerminalEvent("terminal_output", outputPayload).catch((err) => {
1045
1822
  logError(`Failed to report terminal_output for ${taskId}: ${err?.message || err}`);
1046
1823
  });
1047
1824
  });
@@ -1050,6 +1827,7 @@ export function startDaemon(config = {}, deps = {}) {
1050
1827
  if (record.stopForceKillTimer) {
1051
1828
  clearTimeout(record.stopForceKillTimer);
1052
1829
  }
1830
+ cleanupPtyRtcTransport(taskId);
1053
1831
  activePtySessions.delete(taskId);
1054
1832
  if (record.logStream) {
1055
1833
  const ts = new Date().toLocaleString("sv-SE", { timeZone: "Asia/Shanghai" }).replace(" ", "T");
@@ -1113,6 +1891,11 @@ export function startDaemon(config = {}, deps = {}) {
1113
1891
  return;
1114
1892
  }
1115
1893
 
1894
+ if (daemonShuttingDown) {
1895
+ rejectCreatePtyTaskDuringShutdown(payload);
1896
+ return;
1897
+ }
1898
+
1116
1899
  if (activeTaskProcesses.has(taskId) || activePtySessions.has(taskId)) {
1117
1900
  log(`Duplicate create_pty_task ignored for ${taskId}: task already active`);
1118
1901
  sendAgentCommandAck({
@@ -1125,6 +1908,10 @@ export function startDaemon(config = {}, deps = {}) {
1125
1908
  }
1126
1909
 
1127
1910
  let boundPath = await getProjectLocalPath(projectId);
1911
+ if (daemonShuttingDown) {
1912
+ rejectCreatePtyTaskDuringShutdown(payload);
1913
+ return;
1914
+ }
1128
1915
  let taskDir = normalizeOptionalString(launchConfig.cwd) || boundPath;
1129
1916
  if (!taskDir) {
1130
1917
  const now = new Date();
@@ -1217,6 +2004,20 @@ export function startDaemon(config = {}, deps = {}) {
1217
2004
  cwd: launchSpec.cwd,
1218
2005
  env,
1219
2006
  });
2007
+ if (daemonShuttingDown) {
2008
+ try {
2009
+ if (typeof pty?.kill === "function") {
2010
+ pty.kill("SIGTERM");
2011
+ }
2012
+ } catch (killError) {
2013
+ logError(`Failed to stop PTY task ${taskId} during shutdown: ${killError?.message || killError}`);
2014
+ }
2015
+ if (logStream) {
2016
+ logStream.end();
2017
+ }
2018
+ rejectCreatePtyTaskDuringShutdown(payload, { sendAck: false });
2019
+ return;
2020
+ }
1220
2021
  const resolvedLogPath = path.join(taskDir, "conductor-terminal.log");
1221
2022
 
1222
2023
  const startedAt = new Date().toISOString();
@@ -1235,6 +2036,7 @@ export function startDaemon(config = {}, deps = {}) {
1235
2036
  outputSeq: 0,
1236
2037
  ringBuffer: [],
1237
2038
  ringBufferByteLength: 0,
2039
+ pendingLatencySample: null,
1238
2040
  stopForceKillTimer: null,
1239
2041
  };
1240
2042
  activePtySessions.set(taskId, record);
@@ -1322,6 +2124,13 @@ export function startDaemon(config = {}, deps = {}) {
1322
2124
  if (!record || typeof record.pty.write !== "function") {
1323
2125
  return;
1324
2126
  }
2127
+ record.pendingLatencySample = {
2128
+ clientInputSeq: normalizeNonNegativeInt(payload?.client_input_seq ?? payload?.clientInputSeq, null),
2129
+ clientSentAt: normalizeIsoTimestamp(payload?.client_sent_at ?? payload?.clientSentAt),
2130
+ serverReceivedAt: normalizeIsoTimestamp(payload?.server_received_at ?? payload?.serverReceivedAt),
2131
+ daemonReceivedAt: new Date().toISOString(),
2132
+ daemonReceivedAtMs: Date.now(),
2133
+ };
1325
2134
  record.pty.write(data);
1326
2135
  }
1327
2136
 
@@ -1337,6 +2146,124 @@ export function startDaemon(config = {}, deps = {}) {
1337
2146
  // PTY sessions stay alive without viewers. Detach is currently a no-op.
1338
2147
  }
1339
2148
 
2149
+ async function handlePtyTransportSignal(payload) {
2150
+ const taskId = payload?.task_id ? String(payload.task_id) : "";
2151
+ const sessionId = payload?.session_id ? String(payload.session_id) : "";
2152
+ const connectionId = payload?.connection_id ? String(payload.connection_id) : "";
2153
+ const signalType = payload?.signal_type ? String(payload.signal_type) : "";
2154
+ if (!taskId || !connectionId || !signalType) {
2155
+ return;
2156
+ }
2157
+
2158
+ const record = activePtySessions.get(taskId);
2159
+ const description =
2160
+ payload?.description && typeof payload.description === "object" && !Array.isArray(payload.description)
2161
+ ? payload.description
2162
+ : null;
2163
+ const candidate =
2164
+ payload?.candidate && typeof payload.candidate === "object" && !Array.isArray(payload.candidate)
2165
+ ? payload.candidate
2166
+ : null;
2167
+
2168
+ if (signalType === "ice_candidate") {
2169
+ if (!sessionId) {
2170
+ return;
2171
+ }
2172
+ const transport = activePtyRtcTransports.get(taskId);
2173
+ if (
2174
+ transport &&
2175
+ transport.sessionId === sessionId &&
2176
+ transport.connectionId === connectionId &&
2177
+ typeof transport.peer?.addIceCandidate === "function" &&
2178
+ candidate
2179
+ ) {
2180
+ try {
2181
+ await transport.peer.addIceCandidate(candidate);
2182
+ } catch (err) {
2183
+ logError(`Failed to apply PTY ICE candidate for ${taskId}: ${err?.message || err}`);
2184
+ }
2185
+ }
2186
+ return;
2187
+ }
2188
+
2189
+ if (signalType === "revoke") {
2190
+ const transport = activePtyRtcTransports.get(taskId);
2191
+ if (transport && transport.connectionId === connectionId) {
2192
+ cleanupPtyRtcTransport(taskId);
2193
+ }
2194
+ return;
2195
+ }
2196
+
2197
+ if (signalType === "offer" && description?.type === "offer" && typeof description.sdp === "string") {
2198
+ if (!sessionId) {
2199
+ return;
2200
+ }
2201
+ const negotiation = await startPtyRtcNegotiation(taskId, sessionId, connectionId, description);
2202
+ if (negotiation.ok) {
2203
+ return;
2204
+ }
2205
+ const reason = negotiation.reason || (record ? "direct_transport_not_supported" : "terminal_session_not_found");
2206
+ sendPtyTransportSignal({
2207
+ task_id: taskId,
2208
+ session_id: sessionId,
2209
+ connection_id: connectionId,
2210
+ signal_type: "answer_placeholder",
2211
+ description: {
2212
+ type: "answer",
2213
+ mode: "placeholder",
2214
+ reason,
2215
+ },
2216
+ }).catch((err) => {
2217
+ logError(`Failed to report pty_transport_signal for ${taskId}: ${err?.message || err}`);
2218
+ });
2219
+ sendPtyTransportStatus({
2220
+ task_id: taskId,
2221
+ session_id: sessionId,
2222
+ connection_id: connectionId,
2223
+ transport_state: "fallback_relay",
2224
+ transport_policy: "relay_only",
2225
+ writer_connection_id: connectionId,
2226
+ direct_candidate: false,
2227
+ reason,
2228
+ }).catch((err) => {
2229
+ logError(`Failed to report pty_transport_status for ${taskId}: ${err?.message || err}`);
2230
+ });
2231
+ return;
2232
+ }
2233
+
2234
+ const reason = record ? "direct_transport_not_supported" : "terminal_session_not_found";
2235
+ if (signalType === "direct_request") {
2236
+ if (!sessionId) {
2237
+ return;
2238
+ }
2239
+ sendPtyTransportSignal({
2240
+ task_id: taskId,
2241
+ session_id: sessionId,
2242
+ connection_id: connectionId,
2243
+ signal_type: "answer_placeholder",
2244
+ description: {
2245
+ type: "answer",
2246
+ mode: "placeholder",
2247
+ reason,
2248
+ },
2249
+ }).catch((err) => {
2250
+ logError(`Failed to report pty_transport_signal for ${taskId}: ${err?.message || err}`);
2251
+ });
2252
+ sendPtyTransportStatus({
2253
+ task_id: taskId,
2254
+ session_id: sessionId,
2255
+ connection_id: connectionId,
2256
+ transport_state: "fallback_relay",
2257
+ transport_policy: "relay_only",
2258
+ writer_connection_id: connectionId,
2259
+ direct_candidate: false,
2260
+ reason,
2261
+ }).catch((err) => {
2262
+ logError(`Failed to report pty_transport_status for ${taskId}: ${err?.message || err}`);
2263
+ });
2264
+ }
2265
+ }
2266
+
1340
2267
  function handleEvent(event) {
1341
2268
  const receivedAt = Date.now();
1342
2269
  lastInboundAt = receivedAt;
@@ -1357,6 +2284,17 @@ export function startDaemon(config = {}, deps = {}) {
1357
2284
  return;
1358
2285
  }
1359
2286
 
2287
+ if (daemonShuttingDown) {
2288
+ if (event.type === "create_task") {
2289
+ rejectCreateTaskDuringShutdown(event.payload);
2290
+ return;
2291
+ }
2292
+ if (event.type === "create_pty_task") {
2293
+ rejectCreatePtyTaskDuringShutdown(event.payload);
2294
+ return;
2295
+ }
2296
+ }
2297
+
1360
2298
  if (event.type === "create_task") {
1361
2299
  handleCreateTask(event.payload);
1362
2300
  return;
@@ -1385,6 +2323,10 @@ export function startDaemon(config = {}, deps = {}) {
1385
2323
  handleTerminalDetach(event.payload);
1386
2324
  return;
1387
2325
  }
2326
+ if (event.type === "pty_transport_signal") {
2327
+ void handlePtyTransportSignal(event.payload);
2328
+ return;
2329
+ }
1388
2330
  if (event.type === "collect_logs") {
1389
2331
  void handleCollectLogs(event.payload);
1390
2332
  }
@@ -1516,6 +2458,9 @@ export function startDaemon(config = {}, deps = {}) {
1516
2458
  clearTimeout(activeRecord.stopForceKillTimer);
1517
2459
  activeRecord.stopForceKillTimer = null;
1518
2460
  }
2461
+ if (ptyRecord) {
2462
+ cleanupPtyRtcTransport(taskId);
2463
+ }
1519
2464
 
1520
2465
  if (processRecord?.child) {
1521
2466
  try {
@@ -1651,6 +2596,11 @@ export function startDaemon(config = {}, deps = {}) {
1651
2596
  return;
1652
2597
  }
1653
2598
 
2599
+ if (daemonShuttingDown) {
2600
+ rejectCreateTaskDuringShutdown(payload);
2601
+ return;
2602
+ }
2603
+
1654
2604
  const existingTaskRecord = activeTaskProcesses.get(taskId);
1655
2605
  if (existingTaskRecord?.child) {
1656
2606
  log(
@@ -1718,6 +2668,10 @@ export function startDaemon(config = {}, deps = {}) {
1718
2668
 
1719
2669
  // Check if project has a bound local path for this daemon
1720
2670
  const boundPath = await getProjectLocalPath(projectId);
2671
+ if (daemonShuttingDown) {
2672
+ rejectCreateTaskDuringShutdown(payload, { sendAck: false });
2673
+ return;
2674
+ }
1721
2675
  let taskDir;
1722
2676
  let logPath;
1723
2677
  let runTimestampPart = null;
@@ -1961,6 +2915,7 @@ export function startDaemon(config = {}, deps = {}) {
1961
2915
  if (record?.stopForceKillTimer) {
1962
2916
  clearTimeout(record.stopForceKillTimer);
1963
2917
  }
2918
+ cleanupPtyRtcTransport(taskId);
1964
2919
  try {
1965
2920
  if (typeof record.pty?.kill === "function") {
1966
2921
  record.pty.kill("SIGTERM");
@@ -2044,6 +2999,25 @@ function parsePositiveInt(value, fallback) {
2044
2999
  return fallback;
2045
3000
  }
2046
3001
 
3002
+ function parseBooleanEnv(value) {
3003
+ if (typeof value !== "string") {
3004
+ return false;
3005
+ }
3006
+ const normalized = value.trim().toLowerCase();
3007
+ return normalized === "1" || normalized === "true" || normalized === "yes" || normalized === "on";
3008
+ }
3009
+
3010
+ function resolveRtcModuleCandidates(value) {
3011
+ if (typeof value !== "string" || !value.trim()) {
3012
+ return [...DEFAULT_RTC_MODULE_CANDIDATES];
3013
+ }
3014
+ const candidates = value
3015
+ .split(",")
3016
+ .map((entry) => entry.trim())
3017
+ .filter(Boolean);
3018
+ return candidates.length > 0 ? [...new Set(candidates)] : [...DEFAULT_RTC_MODULE_CANDIDATES];
3019
+ }
3020
+
2047
3021
  function formatDisconnectDiagnostics(event) {
2048
3022
  const parts = [];
2049
3023
  const reason = typeof event?.reason === "string" && event.reason.trim()