@love-moon/conductor-cli 0.5.0 → 0.6.0

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/CHANGELOG.md CHANGED
@@ -1,5 +1,40 @@
1
1
  # @love-moon/conductor-cli
2
2
 
3
+ ## 0.6.0
4
+
5
+ ### Minor Changes
6
+
7
+ - bcc80b5: Initialize Git submodules automatically when preparing task worktrees.
8
+
9
+ ### Patch Changes
10
+
11
+ - @love-moon/conductor-sdk@0.6.0
12
+ - @love-moon/ai-sdk@0.6.0
13
+
14
+ ## 0.5.1
15
+
16
+ ### Patch Changes
17
+
18
+ - 39a49fc: fix: reclaim orphaned chat-web browser and cap chat-web task lifetime
19
+
20
+ chat-web persists one Chromium profile per provider, guarded by a per-profile
21
+ SingletonLock. A task whose browser was not cleaned up (e.g. the ai-sdk worker
22
+ was SIGKILLed) left an orphaned Chromium holding that lock, so the next task for
23
+ the same provider failed to launch with `Opening in existing browser session`.
24
+
25
+ - chat-web now reclaims stale/orphaned profile locks before launching (kills an
26
+ orphan whose owner process is gone, clears dead locks) and refuses with a
27
+ clear `ProfileLockedError` when a genuine live chat still holds the profile.
28
+ - The ai-sdk worker now closes its session (and browser) on SIGTERM/SIGINT and
29
+ bounds the close so it can't hang, preventing browser leaks on shutdown.
30
+ - conductor fire caps a chat-web task's active lifetime (default 24h,
31
+ `CONDUCTOR_CHATWEB_MAX_ACTIVE_MS`) and auto-stops it as
32
+ `KILLED / max_active_duration`; chat history is preserved.
33
+
34
+ - Updated dependencies [39a49fc]
35
+ - @love-moon/ai-sdk@0.5.1
36
+ - @love-moon/conductor-sdk@0.5.1
37
+
3
38
  ## 0.5.0
4
39
 
5
40
  ### Patch Changes
@@ -246,6 +246,18 @@ const DEFAULT_POLL_INTERVAL_MS = parseInt(
246
246
  const DEFAULT_ERROR_LOOP_WINDOW_MS = 2 * 60 * 1000;
247
247
  const DEFAULT_ERROR_LOOP_BACKOFF_MS = 3 * 60 * 1000;
248
248
  const DEFAULT_ERROR_LOOP_THRESHOLD = 3;
249
+ // Runtime backend tokens (first word of the resolved command line) that mean
250
+ // "this task drives a chat-web Chromium browser". Mirrors ai-sdk's chat-web
251
+ // aliases; user-facing aliases like `web-chatgpt` resolve to one of these.
252
+ const CHAT_WEB_RUNTIME_BACKEND_TOKENS = new Set(["chat-web", "chatweb", "chat_web", "web-chat"]);
253
+ // Max active lifetime for a chat-web task before it is auto-stopped (default
254
+ // 24h). Bounded to [1min, 7d]; override via CONDUCTOR_CHATWEB_MAX_ACTIVE_MS.
255
+ const CHAT_WEB_MAX_ACTIVE_MS = getBoundedEnvInt(
256
+ "CONDUCTOR_CHATWEB_MAX_ACTIVE_MS",
257
+ 24 * 60 * 60 * 1000,
258
+ 60_000,
259
+ 7 * 24 * 60 * 60 * 1000,
260
+ );
249
261
  const SESSION_BOOTSTRAP_LOCK_TIMEOUT_MS = 15_000;
250
262
  const SESSION_BOOTSTRAP_LOCK_RETRY_MS = 50;
251
263
  const FIRE_WATCHDOG_INTERVAL_MS = getBoundedEnvInt(
@@ -866,6 +878,18 @@ async function main() {
866
878
  env: process.env,
867
879
  });
868
880
 
881
+ // chat-web tasks drive a real Chromium browser that holds a per-profile
882
+ // singleton lock for as long as the task lives. A task that never ends
883
+ // pins that browser indefinitely (and blocks other chats for the same
884
+ // provider), so we cap its active lifetime and auto-stop it when exceeded.
885
+ // The conversation itself is preserved (it lives in the provider account
886
+ // and the persisted profile; closing the browser does not delete it).
887
+ const runtimeBackendToken = String(sessionCommandLine || "")
888
+ .trim()
889
+ .split(/\s+/)[0]
890
+ ?.toLowerCase() || "";
891
+ const isChatWebTask = CHAT_WEB_RUNTIME_BACKEND_TOKENS.has(runtimeBackendToken);
892
+
869
893
  log(`Using backend: ${cliArgs.backend}`);
870
894
 
871
895
  try {
@@ -880,6 +904,7 @@ async function main() {
880
904
 
881
905
  const signals = new AbortController();
882
906
  let shutdownSignal = null;
907
+ let autoStopReason = null;
883
908
  let backendShutdownRequested = false;
884
909
  const requestBackendShutdown = (source) => {
885
910
  if (backendShutdownRequested) {
@@ -911,6 +936,27 @@ async function main() {
911
936
  process.on("SIGINT", onSigint);
912
937
  process.on("SIGTERM", onSigterm);
913
938
 
939
+ // Cap the active lifetime of chat-web tasks (default 24h). On expiry we
940
+ // stop the task gracefully — abort the runner and close the backend
941
+ // session so the Chromium browser is released — and mark it KILLED with a
942
+ // max_active_duration reason. Chat history is retained on the provider side.
943
+ let maxActiveTimer = null;
944
+ if (isChatWebTask) {
945
+ maxActiveTimer = setTimeout(() => {
946
+ log(
947
+ `chat-web task exceeded max active duration (${CHAT_WEB_MAX_ACTIVE_MS}ms); ` +
948
+ `auto-stopping task (chat history is preserved).`,
949
+ );
950
+ autoStopReason = "max_active_duration";
951
+ fireShuttingDown = true;
952
+ signals.abort();
953
+ requestBackendShutdown("max_active_duration");
954
+ }, CHAT_WEB_MAX_ACTIVE_MS);
955
+ if (typeof maxActiveTimer.unref === "function") {
956
+ maxActiveTimer.unref();
957
+ }
958
+ }
959
+
914
960
  if (shouldFireReportTaskStatus({ launchedByDaemon, phase: "running" })) {
915
961
  try {
916
962
  await conductor.sendTaskStatus(taskContext.taskId, {
@@ -1030,7 +1076,12 @@ async function main() {
1030
1076
  // SDK durable outbox would retry forever, preventing the process from
1031
1077
  // exiting.
1032
1078
  const taskDeletedByUser = remoteStopReason === "deleted_by_user";
1033
- const finalStatus = shutdownSignal
1079
+ const finalStatus = autoStopReason
1080
+ ? {
1081
+ status: "KILLED",
1082
+ summary: `${autoStopReason}: chat-web task exceeded its max active duration`,
1083
+ }
1084
+ : shutdownSignal
1034
1085
  ? {
1035
1086
  status: "KILLED",
1036
1087
  summary: `terminated by ${shutdownSignal}`,
@@ -1079,6 +1130,9 @@ async function main() {
1079
1130
  } finally {
1080
1131
  process.off("SIGINT", onSigint);
1081
1132
  process.off("SIGTERM", onSigterm);
1133
+ if (maxActiveTimer) {
1134
+ clearTimeout(maxActiveTimer);
1135
+ }
1082
1136
  if (shutdownSignal === "SIGINT") {
1083
1137
  process.exitCode = 130;
1084
1138
  } else if (shutdownSignal === "SIGTERM") {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@love-moon/conductor-cli",
3
- "version": "0.5.0",
4
- "gitCommitId": "7f50aa7",
3
+ "version": "0.6.0",
4
+ "gitCommitId": "2f914a7",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "git+https://github.com/lovemoon-ai/conductor.git"
@@ -23,8 +23,8 @@
23
23
  "test": "node --test test/*.test.js"
24
24
  },
25
25
  "dependencies": {
26
- "@love-moon/ai-sdk": "0.5.0",
27
- "@love-moon/conductor-sdk": "0.5.0",
26
+ "@love-moon/ai-sdk": "0.6.0",
27
+ "@love-moon/conductor-sdk": "0.6.0",
28
28
  "@github/copilot-sdk": "^0.3.0",
29
29
  "chrome-launcher": "^1.2.1",
30
30
  "chrome-remote-interface": "^0.33.0",
@@ -37,7 +37,7 @@
37
37
  },
38
38
  "optionalDependencies": {
39
39
  "@roamhq/wrtc": "^0.10.0",
40
- "@love-moon/chat-web": "0.5.0"
40
+ "@love-moon/chat-web": "0.6.0"
41
41
  },
42
42
  "pnpm": {
43
43
  "onlyBuiltDependencies": [
package/src/daemon.js CHANGED
@@ -28,6 +28,7 @@ import {
28
28
  listAdvertisedBackends,
29
29
  resolveConfiguredRuntimeBackend,
30
30
  isBuiltInRuntimeBackend,
31
+ isCommandOptionalBuiltInRuntimeBackend,
31
32
  isRuntimeSupportedBackend,
32
33
  normalizeRuntimeBackendAlias,
33
34
  normalizeRuntimeBackendName,
@@ -1255,6 +1256,8 @@ export function startDaemon(config = {}, deps = {}) {
1255
1256
  "",
1256
1257
  "worktree:",
1257
1258
  " sync_branch: false",
1259
+ " sync_submodules: true",
1260
+ " # When .gitmodules exists, initialize/update submodules in task worktrees.",
1258
1261
  " symlink: []",
1259
1262
  " # Example: symlink paths from the parent workspace into each worktree",
1260
1263
  " # symlink:",
@@ -1263,6 +1266,72 @@ export function startDaemon(config = {}, deps = {}) {
1263
1266
  "",
1264
1267
  ].join("\n");
1265
1268
 
1269
+ const MAX_PROJECT_ICON_IMAGE_BYTES = 128 * 1024;
1270
+ const PROJECT_ICON_MIME_BY_EXTENSION = {
1271
+ ".svg": "image/svg+xml",
1272
+ ".png": "image/png",
1273
+ ".jpg": "image/jpeg",
1274
+ ".jpeg": "image/jpeg",
1275
+ ".gif": "image/gif",
1276
+ ".webp": "image/webp",
1277
+ ".ico": "image/x-icon",
1278
+ ".avif": "image/avif",
1279
+ };
1280
+
1281
+ function getProjectSettingsCandidates(projectWorkspacePath) {
1282
+ return [
1283
+ path.join(projectWorkspacePath, ".conductor", "settings.yaml"),
1284
+ path.join(projectWorkspacePath, ".conductor", "settings.yml"),
1285
+ path.join(projectWorkspacePath, ".conductor", "setttings.yaml"),
1286
+ path.join(projectWorkspacePath, ".conductor", "setttings.yml"),
1287
+ ];
1288
+ }
1289
+
1290
+ function extractProjectIconFromSettings(parsed) {
1291
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
1292
+ return null;
1293
+ }
1294
+ const direct = normalizeOptionalString(parsed.icon);
1295
+ if (direct) return direct;
1296
+ const projectSettings = parsed.project;
1297
+ if (
1298
+ projectSettings &&
1299
+ typeof projectSettings === "object" &&
1300
+ !Array.isArray(projectSettings)
1301
+ ) {
1302
+ return normalizeOptionalString(projectSettings.icon);
1303
+ }
1304
+ return null;
1305
+ }
1306
+
1307
+ function projectIconLooksLikeFilesystemPath(iconValue) {
1308
+ if (/^(https?:\/\/|data:)/i.test(iconValue)) return false;
1309
+ if (iconValue.startsWith("/") || iconValue.startsWith("./") || iconValue.startsWith("../")) {
1310
+ return true;
1311
+ }
1312
+ if (!iconValue.includes("/")) return false;
1313
+ const ext = path.extname(iconValue).toLowerCase();
1314
+ return Object.prototype.hasOwnProperty.call(PROJECT_ICON_MIME_BY_EXTENSION, ext);
1315
+ }
1316
+
1317
+ function readProjectIconAsDataUri(settingsDir, iconValue) {
1318
+ const resolvedPath = path.isAbsolute(iconValue)
1319
+ ? iconValue
1320
+ : path.resolve(settingsDir, iconValue);
1321
+ const ext = path.extname(resolvedPath).toLowerCase();
1322
+ const mime = PROJECT_ICON_MIME_BY_EXTENSION[ext];
1323
+ if (!mime) return null;
1324
+ try {
1325
+ const stat = statSyncFn(resolvedPath);
1326
+ if (!stat.isFile() || stat.size === 0 || stat.size > MAX_PROJECT_ICON_IMAGE_BYTES) {
1327
+ return null;
1328
+ }
1329
+ return `data:${mime};base64,${readFileSyncFn(resolvedPath).toString("base64")}`;
1330
+ } catch {
1331
+ return null;
1332
+ }
1333
+ }
1334
+
1266
1335
  function ensureProjectSettingsTemplate(projectWorkspacePath) {
1267
1336
  const settingsPath = path.join(projectWorkspacePath, ".conductor", "settings.yaml");
1268
1337
  if (existsSyncFn(settingsPath)) {
@@ -1276,15 +1345,28 @@ export function startDaemon(config = {}, deps = {}) {
1276
1345
  }
1277
1346
  }
1278
1347
 
1279
- function readProjectWorktreeSettings(projectWorkspacePath) {
1280
- const settingsCandidates = [
1281
- path.join(projectWorkspacePath, ".conductor", "settings.yaml"),
1282
- path.join(projectWorkspacePath, ".conductor", "settings.yml"),
1283
- path.join(projectWorkspacePath, ".conductor", "setttings.yaml"),
1284
- path.join(projectWorkspacePath, ".conductor", "setttings.yml"),
1285
- ];
1348
+ function readProjectIconSetting(projectWorkspacePath) {
1349
+ for (const settingsPath of getProjectSettingsCandidates(projectWorkspacePath)) {
1350
+ if (!existsSyncFn(settingsPath)) {
1351
+ continue;
1352
+ }
1353
+ let rawIcon = null;
1354
+ try {
1355
+ rawIcon = extractProjectIconFromSettings(yaml.load(readFileSyncFn(settingsPath, "utf8")));
1356
+ } catch {
1357
+ return null;
1358
+ }
1359
+ if (!rawIcon) return null;
1360
+ if (!projectIconLooksLikeFilesystemPath(rawIcon)) {
1361
+ return rawIcon;
1362
+ }
1363
+ return readProjectIconAsDataUri(path.dirname(settingsPath), rawIcon);
1364
+ }
1365
+ return null;
1366
+ }
1286
1367
 
1287
- for (const settingsPath of settingsCandidates) {
1368
+ function readProjectWorktreeSettings(projectWorkspacePath) {
1369
+ for (const settingsPath of getProjectSettingsCandidates(projectWorkspacePath)) {
1288
1370
  if (!existsSyncFn(settingsPath)) {
1289
1371
  continue;
1290
1372
  }
@@ -1298,6 +1380,7 @@ export function startDaemon(config = {}, deps = {}) {
1298
1380
  return {
1299
1381
  symlinkPaths: normalizeConfiguredPathList(worktreeSettings.symlink, projectWorkspacePath),
1300
1382
  syncBranch: worktreeSettings.sync_branch === true || worktreeSettings.syncBranch === true,
1383
+ syncSubmodules: worktreeSettings.sync_submodules !== false && worktreeSettings.syncSubmodules !== false,
1301
1384
  settingsPath,
1302
1385
  };
1303
1386
  } catch (error) {
@@ -1308,6 +1391,7 @@ export function startDaemon(config = {}, deps = {}) {
1308
1391
  return {
1309
1392
  symlinkPaths: [],
1310
1393
  syncBranch: false,
1394
+ syncSubmodules: true,
1311
1395
  settingsPath: null,
1312
1396
  };
1313
1397
  }
@@ -1377,6 +1461,28 @@ export function startDaemon(config = {}, deps = {}) {
1377
1461
  }
1378
1462
  }
1379
1463
 
1464
+ async function ensureTaskWorktreeSubmodules({ taskId, projectWorkspacePath, worktreeRoot }) {
1465
+ const { syncSubmodules } = readProjectWorktreeSettings(projectWorkspacePath);
1466
+ if (!syncSubmodules || !existsSyncFn(path.join(worktreeRoot, ".gitmodules"))) {
1467
+ return;
1468
+ }
1469
+
1470
+ try {
1471
+ await runSpawnProcess(
1472
+ "git",
1473
+ ["-C", worktreeRoot, "submodule", "sync", "--recursive"],
1474
+ { cwd: worktreeRoot, timeoutMs: WORKTREE_SUBMODULE_SYNC_TIMEOUT_MS },
1475
+ );
1476
+ await runSpawnProcess(
1477
+ "git",
1478
+ ["-C", worktreeRoot, "submodule", "update", "--init", "--recursive"],
1479
+ { cwd: worktreeRoot, timeoutMs: WORKTREE_SUBMODULE_SYNC_TIMEOUT_MS },
1480
+ );
1481
+ } catch (error) {
1482
+ throw new Error(`Failed to sync git submodules for ${taskId}: ${error?.message || error}`);
1483
+ }
1484
+ }
1485
+
1380
1486
  async function runSpawnProcess(command, args, options = {}) {
1381
1487
  let child;
1382
1488
  const { timeoutMs, ...spawnOptions } = options || {};
@@ -1565,6 +1671,11 @@ export function startDaemon(config = {}, deps = {}) {
1565
1671
  }
1566
1672
  }
1567
1673
 
1674
+ await ensureTaskWorktreeSubmodules({
1675
+ taskId,
1676
+ projectWorkspacePath: worktreeConfig.projectWorkspacePath,
1677
+ worktreeRoot,
1678
+ });
1568
1679
  mkdirSyncFn(finalCwd, { recursive: true });
1569
1680
  await ensureTaskWorktreeSymlinks({
1570
1681
  projectRepoRoot: worktreeConfig.projectRepoRoot,
@@ -1600,6 +1711,10 @@ export function startDaemon(config = {}, deps = {}) {
1600
1711
  process.env.CONDUCTOR_WORKTREE_SYNC_TIMEOUT_MS,
1601
1712
  5_000,
1602
1713
  );
1714
+ const WORKTREE_SUBMODULE_SYNC_TIMEOUT_MS = parsePositiveInt(
1715
+ process.env.CONDUCTOR_WORKTREE_SUBMODULE_SYNC_TIMEOUT_MS,
1716
+ 120_000,
1717
+ );
1603
1718
  const SHUTDOWN_STATUS_REPORT_TIMEOUT_MS = parsePositiveInt(
1604
1719
  process.env.CONDUCTOR_SHUTDOWN_STATUS_REPORT_TIMEOUT_MS,
1605
1720
  1000,
@@ -4337,6 +4452,7 @@ export function startDaemon(config = {}, deps = {}) {
4337
4452
  lastCommit: null,
4338
4453
  lastCommitAt: null,
4339
4454
  fileCount: null,
4455
+ icon: null,
4340
4456
  error: null,
4341
4457
  errorCode: null,
4342
4458
  validatedAt,
@@ -4390,6 +4506,7 @@ export function startDaemon(config = {}, deps = {}) {
4390
4506
  typeof snapshot?.fileCount === "number" && Number.isInteger(snapshot.fileCount)
4391
4507
  ? snapshot.fileCount
4392
4508
  : null,
4509
+ icon: readProjectIconSetting(effectiveWorkspace),
4393
4510
  };
4394
4511
  }
4395
4512
  } catch (error) {
@@ -4413,6 +4530,7 @@ export function startDaemon(config = {}, deps = {}) {
4413
4530
  last_commit_at: result.lastCommitAt,
4414
4531
  git_remote_url: result.gitRemoteUrl,
4415
4532
  file_count: result.fileCount,
4533
+ icon: result.icon,
4416
4534
  error: result.error,
4417
4535
  error_code: result.errorCode,
4418
4536
  validated_at: result.validatedAt,
@@ -5055,7 +5173,8 @@ export function startDaemon(config = {}, deps = {}) {
5055
5173
  const isAllowedExternalBackend =
5056
5174
  !isBuiltInRuntimeBackend(effectiveBackend) &&
5057
5175
  await isRuntimeSupportedBackend(effectiveBackend, { configFilePath: config.CONFIG_FILE });
5058
- if (!isAdvertisedBackend || (!hasConfiguredEntry && !isAllowedExternalBackend)) {
5176
+ const isCommandOptionalBuiltIn = isCommandOptionalBuiltInRuntimeBackend(effectiveBackend);
5177
+ if (!isAdvertisedBackend || (!hasConfiguredEntry && !isAllowedExternalBackend && !isCommandOptionalBuiltIn)) {
5059
5178
  logError(`Unsupported backend: ${selectedBackend}. Supported: ${SUPPORTED_BACKENDS.join(", ")}`);
5060
5179
  sendAgentCommandAck({
5061
5180
  requestId,
@@ -5526,7 +5645,8 @@ export function startDaemon(config = {}, deps = {}) {
5526
5645
  const isAllowedExternalBackend =
5527
5646
  !isBuiltInRuntimeBackend(effectiveBackend) &&
5528
5647
  await isRuntimeSupportedBackend(effectiveBackend, { configFilePath: config.CONFIG_FILE });
5529
- if (!isAdvertisedBackend || (!hasConfiguredEntry && !isAllowedExternalBackend)) {
5648
+ const isCommandOptionalBuiltIn = isCommandOptionalBuiltInRuntimeBackend(effectiveBackend);
5649
+ if (!isAdvertisedBackend || (!hasConfiguredEntry && !isAllowedExternalBackend && !isCommandOptionalBuiltIn)) {
5530
5650
  reportRestartFailure({
5531
5651
  taskId: normalizedTargetTaskId,
5532
5652
  projectId: normalizedProjectId,