@love-moon/conductor-cli 0.2.33 → 0.2.35

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.
@@ -82,24 +82,149 @@ export function shouldRunReconnectRecovery({
82
82
  }
83
83
 
84
84
  // Load allow_cli_list from config file (no defaults - must be configured)
85
- async function loadAllowCliList(configFilePath) {
85
+ function loadFireConfigYaml(configFilePath) {
86
86
  const home = os.homedir();
87
87
  const configPath = configFilePath || process.env.CONDUCTOR_CONFIG || path.join(home, ".conductor", "config.yaml");
88
- let parsed = null;
89
88
  try {
90
89
  if (fs.existsSync(configPath)) {
91
90
  const content = fs.readFileSync(configPath, "utf8");
92
- parsed = yaml.load(content);
91
+ const parsed = yaml.load(content);
92
+ if (parsed && typeof parsed === "object") {
93
+ return { configPath, parsed };
94
+ }
93
95
  }
94
96
  } catch (error) {
95
97
  // ignore error
96
98
  }
97
- if (parsed && typeof parsed === "object" && parsed.allow_cli_list) {
99
+ return { configPath, parsed: null };
100
+ }
101
+
102
+ // Load allow_cli_list from config file (no defaults - must be configured)
103
+ async function loadAllowCliList(configFilePath) {
104
+ const { configPath, parsed } = loadFireConfigYaml(configFilePath);
105
+ if (parsed && parsed.allow_cli_list) {
98
106
  return await filterRuntimeSupportedAllowCliList(parsed.allow_cli_list, { configFilePath: configPath });
99
107
  }
100
108
  return {};
101
109
  }
102
110
 
111
+ function loadPrePromptMap(configFilePath) {
112
+ const { parsed } = loadFireConfigYaml(configFilePath);
113
+ if (parsed && parsed.pre_prompt && typeof parsed.pre_prompt === "object") {
114
+ return parsed.pre_prompt;
115
+ }
116
+ return {};
117
+ }
118
+
119
+ export function expandEnvVars(text, env = process.env) {
120
+ if (typeof text !== "string" || !text) {
121
+ return "";
122
+ }
123
+ return text.replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}|\$([A-Za-z_][A-Za-z0-9_]*)/g, (match, braced, bare) => {
124
+ const key = braced || bare;
125
+ return Object.prototype.hasOwnProperty.call(env, key) && env[key] != null ? String(env[key]) : match;
126
+ });
127
+ }
128
+
129
+ export function resolveConfiguredPrePrompt({ configFilePath, backend, sessionBackend, env = process.env } = {}) {
130
+ const prePromptMap = loadPrePromptMap(configFilePath);
131
+ const candidates = [backend, sessionBackend]
132
+ .map((value) => (typeof value === "string" ? value.trim() : ""))
133
+ .filter(Boolean);
134
+ for (const candidate of candidates) {
135
+ if (typeof prePromptMap[candidate] === "string") {
136
+ const resolved = expandEnvVars(prePromptMap[candidate], env);
137
+ return resolved || undefined;
138
+ }
139
+ }
140
+ return undefined;
141
+ }
142
+
143
+ function parseCommandParts(commandLine) {
144
+ const input = String(commandLine || "").trim();
145
+ if (!input) {
146
+ return [];
147
+ }
148
+
149
+ const parts = [];
150
+ let current = "";
151
+ let quote = "";
152
+ let escaping = false;
153
+ let tokenStarted = false;
154
+
155
+ for (const char of input) {
156
+ if (escaping) {
157
+ current += char;
158
+ tokenStarted = true;
159
+ escaping = false;
160
+ continue;
161
+ }
162
+
163
+ if (char === "\\") {
164
+ escaping = true;
165
+ tokenStarted = true;
166
+ continue;
167
+ }
168
+
169
+ if (quote) {
170
+ if (char === quote) {
171
+ quote = "";
172
+ } else {
173
+ current += char;
174
+ }
175
+ tokenStarted = true;
176
+ continue;
177
+ }
178
+
179
+ if (char === "'" || char === "\"") {
180
+ quote = char;
181
+ tokenStarted = true;
182
+ continue;
183
+ }
184
+
185
+ if (/\s/.test(char)) {
186
+ if (tokenStarted) {
187
+ parts.push(current);
188
+ current = "";
189
+ tokenStarted = false;
190
+ }
191
+ continue;
192
+ }
193
+
194
+ current += char;
195
+ tokenStarted = true;
196
+ }
197
+
198
+ if (tokenStarted) {
199
+ parts.push(current);
200
+ }
201
+
202
+ return parts;
203
+ }
204
+
205
+ function extractModelOptionFromCommandLine(commandLine) {
206
+ const parts = parseCommandParts(commandLine);
207
+ for (let index = 0; index < parts.length; index += 1) {
208
+ const token = String(parts[index] || "").trim();
209
+ if (!token) {
210
+ continue;
211
+ }
212
+ if (token === "--model") {
213
+ const next = String(parts[index + 1] || "").trim();
214
+ return next || "";
215
+ }
216
+ if (token.startsWith("--model=")) {
217
+ return token.slice("--model=".length).trim();
218
+ }
219
+ }
220
+ return "";
221
+ }
222
+
223
+ function extractAiSessionOptionsFromCommandLine(commandLine) {
224
+ const model = extractModelOptionFromCommandLine(commandLine);
225
+ return model ? { model } : {};
226
+ }
227
+
103
228
  export function resolveAiSessionCommandLine(backend, allowCliList, env = process.env, sessionBackend = backend) {
104
229
  const normalizedBackend = normalizeRuntimeBackendName(backend);
105
230
  const normalizedSessionBackend = normalizeRuntimeBackendName(sessionBackend);
@@ -142,6 +267,12 @@ export function resolveAiSessionCommandLine(backend, allowCliList, env = process
142
267
  return resolvedCommand;
143
268
  }
144
269
 
270
+ export function resolveAiSessionOptions(backend, allowCliList, env = process.env, sessionBackend = backend) {
271
+ return extractAiSessionOptionsFromCommandLine(
272
+ resolveAiSessionCommandLine(backend, allowCliList, env, sessionBackend),
273
+ );
274
+ }
275
+
145
276
  const DEFAULT_POLL_INTERVAL_MS = parseInt(
146
277
  process.env.CONDUCTOR_CLI_POLL_INTERVAL_MS || process.env.CCODEX_POLL_INTERVAL_MS || "2000",
147
278
  10,
@@ -595,6 +726,7 @@ async function main() {
595
726
  envTaskTitle: process.env.CONDUCTOR_TASK_TITLE,
596
727
  runtimeProjectPath,
597
728
  });
729
+ const resolvedDaemonName = resolveDaemonHost(configuredDaemonName);
598
730
 
599
731
  conductor = await ConductorClient.connect({
600
732
  projectPath: runtimeProjectPath,
@@ -623,7 +755,7 @@ async function main() {
623
755
  providedTaskId: process.env.CONDUCTOR_TASK_ID,
624
756
  requestedTitle: requestedTaskTitle,
625
757
  backend: cliArgs.backend,
626
- daemonName: configuredDaemonName,
758
+ daemonName: resolvedDaemonName,
627
759
  projectPath: runtimeProjectPath,
628
760
  });
629
761
  injectResolvedTaskId(taskContext.taskId);
@@ -647,6 +779,7 @@ async function main() {
647
779
  project_id: process.env.CONDUCTOR_PROJECT_ID,
648
780
  project_path: runtimeProjectPath,
649
781
  backend_type: cliArgs.backend,
782
+ daemon_name: resolvedDaemonName,
650
783
  });
651
784
  } catch {
652
785
  // best effort only
@@ -661,14 +794,24 @@ async function main() {
661
794
  process.env,
662
795
  cliArgs.sessionBackend,
663
796
  );
797
+ const resolvedPrePrompt = resolveConfiguredPrePrompt({
798
+ configFilePath: cliArgs.configFile,
799
+ backend: cliArgs.backend,
800
+ sessionBackend: cliArgs.sessionBackend,
801
+ env: process.env,
802
+ });
664
803
 
665
804
  backendSession = createAiSession(cliArgs.sessionBackend || cliArgs.backend, {
666
805
  initialImages: cliArgs.initialImages,
667
806
  cwd: runtimeProjectPath,
668
807
  resumeSessionId: resolvedResumeSessionId,
669
808
  configFile: cliArgs.configFile,
809
+ ...(cliArgs.sessionOptions || {}),
670
810
  ...(sessionCommandLine ? { commandLine: sessionCommandLine } : {}),
671
811
  logger: { log },
812
+ ...(resolvedPrePrompt ? { prePrompt: resolvedPrePrompt } : {}),
813
+ sessionStoreKey: taskContext.taskId ? `task-${taskContext.taskId}` : undefined,
814
+ resumePersistedSession: Boolean(!resolvedResumeSessionId && taskContext.taskId),
672
815
  });
673
816
 
674
817
  log(`Using backend: ${cliArgs.backend}`);
@@ -694,7 +837,7 @@ async function main() {
694
837
  cliArgs: cliArgs.rawBackendArgs,
695
838
  backendName: cliArgs.backend,
696
839
  resumeSessionId: resolvedResumeSessionId,
697
- daemonName: configuredDaemonName,
840
+ daemonName: resolvedDaemonName,
698
841
  });
699
842
  reconnectRunner = runner;
700
843
  if (pendingRemoteStopEvent) {
@@ -760,7 +903,13 @@ async function main() {
760
903
  process.off("SIGINT", onSigint);
761
904
  process.off("SIGTERM", onSigterm);
762
905
  if (!launchedByDaemon) {
906
+ const remoteStopReason = typeof runner.getRemoteStopReason === "function" ? runner.getRemoteStopReason() : null;
763
907
  const remoteStopSummary = typeof runner.getRemoteStopSummary === "function" ? runner.getRemoteStopSummary() : null;
908
+ // When the task was deleted by the user, the DB record is already gone —
909
+ // attempting to send a final status update would fail with 500 and the
910
+ // SDK durable outbox would retry forever, preventing the process from
911
+ // exiting.
912
+ const taskDeletedByUser = remoteStopReason === "deleted_by_user";
764
913
  const finalStatus = shutdownSignal
765
914
  ? {
766
915
  status: "KILLED",
@@ -780,16 +929,25 @@ async function main() {
780
929
  status: "COMPLETED",
781
930
  summary: "conductor fire exited",
782
931
  };
783
- try {
784
- const statusResult = await conductor.sendTaskStatus(taskContext.taskId, finalStatus);
785
- if (statusResult?.pending && typeof conductor.flushPendingUpstreamEvents === "function") {
786
- await conductor.flushPendingUpstreamEvents({
787
- timeoutMs: 5_000,
788
- retryIntervalMs: 250,
789
- });
932
+ if (!taskDeletedByUser) {
933
+ try {
934
+ const statusResult = await conductor.sendTaskStatus(taskContext.taskId, finalStatus);
935
+ if (statusResult?.pending && typeof conductor.flushPendingUpstreamEvents === "function") {
936
+ await conductor.flushPendingUpstreamEvents({
937
+ timeoutMs: 5_000,
938
+ retryIntervalMs: 250,
939
+ });
940
+ }
941
+ } catch (error) {
942
+ log(`Failed to report task status (${finalStatus.status}): ${error?.message || error}`);
943
+ }
944
+ } else {
945
+ log(`Skipping final status report: task was deleted by user`);
946
+ // Also clear any pending durable outbox retries (e.g. task_stop_ack)
947
+ // that would keep failing against the deleted task.
948
+ if (typeof conductor.clearDurableOutboxTimer === "function") {
949
+ conductor.clearDurableOutboxTimer();
790
950
  }
791
- } catch (error) {
792
- log(`Failed to report task status (${finalStatus.status}): ${error?.message || error}`);
793
951
  }
794
952
  }
795
953
  if (shutdownSignal === "SIGINT") {
@@ -1023,6 +1181,12 @@ Environment:
1023
1181
  const sessionBackend =
1024
1182
  configuredBackend?.runtimeBackend ||
1025
1183
  (backend ? await normalizeRuntimeBackendAlias(backend, { configFilePath: configFileFromArgs }) : "");
1184
+ const sessionOptions = resolveAiSessionOptions(
1185
+ backend,
1186
+ allowCliList,
1187
+ process.env,
1188
+ sessionBackend || backend,
1189
+ );
1026
1190
  const shouldRequireBackend =
1027
1191
  !Boolean(conductorArgs.listBackends) &&
1028
1192
  !listBackendsWithoutSeparator &&
@@ -1071,6 +1235,7 @@ Environment:
1071
1235
  hasExplicitTaskTitle: typeof conductorArgs.title === "string" && Boolean(conductorArgs.title.trim()),
1072
1236
  configFile: conductorArgs.configFile,
1073
1237
  sessionBackend,
1238
+ sessionOptions,
1074
1239
  resumeSessionId,
1075
1240
  showVersion: Boolean(conductorArgs.version) || versionWithoutSeparator,
1076
1241
  listBackends: Boolean(conductorArgs.listBackends) || listBackendsWithoutSeparator,
@@ -1269,43 +1434,11 @@ export async function resolveProjectId(conductor, explicit, opts = {}) {
1269
1434
  log(`Unable to match project by path: ${error.message}`);
1270
1435
  }
1271
1436
 
1272
- try {
1273
- const created = await conductor.createProject({
1274
- name: projectName,
1275
- bindingConfirmed: true,
1276
- daemonHost,
1277
- workspacePath: snapshot.projectRoot,
1278
- repoRoot: snapshot.repoRoot,
1279
- worktreeBranch: snapshot.worktreeBranch,
1280
- lastCommit: snapshot.lastCommit,
1281
- fileCount: snapshot.fileCount,
1282
- });
1283
- if (created?.id) {
1284
- log(`Created bound project ${created.name || created.id} for ${daemonHost}:${snapshot.projectRoot}`);
1285
- return created.id;
1286
- }
1287
- throw new Error("create_project returned no id");
1288
- } catch (error) {
1289
- log(`Unable to create bound project: ${error.message}`);
1290
- }
1291
-
1292
- try {
1293
- const retryMatch = await conductor.matchProjectByPath({
1294
- daemon_host: daemonHost,
1295
- project_path: snapshot.projectRoot,
1296
- });
1297
- if (retryMatch?.project_id) {
1298
- return retryMatch.project_id;
1299
- }
1300
- } catch {
1301
- // ignore retry match failures
1302
- }
1303
-
1304
- log(`Unable to resolve bound project for ${daemonHost}:${snapshot.projectRoot}, falling back to default`);
1437
+ log(`No matching project found for ${daemonHost}:${snapshot.projectRoot}, falling back to default`);
1305
1438
  return resolveDefaultProjectId(conductor);
1306
1439
  }
1307
1440
 
1308
- function resolveDaemonHost(daemonName) {
1441
+ export function resolveDaemonHost(daemonName) {
1309
1442
  if (typeof daemonName === "string" && daemonName.trim()) {
1310
1443
  return daemonName.trim();
1311
1444
  }
@@ -1725,6 +1858,7 @@ export class BridgeRunner {
1725
1858
  session_file_path:
1726
1859
  typeof sessionFilePath === "string" && sessionFilePath.trim() ? sessionFilePath.trim() : undefined,
1727
1860
  backend_type: this.backendName,
1861
+ daemon_name: this.daemonName,
1728
1862
  });
1729
1863
  this.boundSessionId = normalizedSessionId;
1730
1864
  return true;
@@ -1807,6 +1941,10 @@ export class BridgeRunner {
1807
1941
  return this.stopped || Boolean(this.remoteStopInfo);
1808
1942
  }
1809
1943
 
1944
+ getRemoteStopReason() {
1945
+ return this.remoteStopInfo?.reason || null;
1946
+ }
1947
+
1810
1948
  getRemoteStopSummary() {
1811
1949
  if (!this.remoteStopInfo) {
1812
1950
  return null;
@@ -2090,6 +2228,11 @@ export class BridgeRunner {
2090
2228
  session_file_path: payload.session_file_path || runtimeContext?.session_file_path,
2091
2229
  token_usage_percent: runtimeContext?.token_usage_percent,
2092
2230
  context_usage_percent: runtimeContext?.context_usage_percent,
2231
+ tool_name: payload.tool_name ? String(payload.tool_name) : undefined,
2232
+ tool_id: payload.tool_id ? String(payload.tool_id) : undefined,
2233
+ item_id: payload.item_id ? String(payload.item_id) : undefined,
2234
+ turn_started_at: payload.turn_started_at ? String(payload.turn_started_at) : undefined,
2235
+ event_count: typeof payload.event_count === "number" ? payload.event_count : undefined,
2093
2236
  };
2094
2237
  }
2095
2238
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@love-moon/conductor-cli",
3
- "version": "0.2.33",
4
- "gitCommitId": "db7f9bf",
3
+ "version": "0.2.35",
4
+ "gitCommitId": "686ee4d",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "conductor": "bin/conductor.js"
@@ -18,8 +18,8 @@
18
18
  },
19
19
  "dependencies": {
20
20
  "@love-moon/ai-bridge": "0.1.4",
21
- "@love-moon/ai-sdk": "0.2.33",
22
- "@love-moon/conductor-sdk": "0.2.33",
21
+ "@love-moon/ai-sdk": "0.2.35",
22
+ "@love-moon/conductor-sdk": "0.2.35",
23
23
  "chrome-launcher": "^1.2.1",
24
24
  "chrome-remote-interface": "^0.33.0",
25
25
  "dotenv": "^16.4.5",
package/src/daemon.js CHANGED
@@ -354,6 +354,35 @@ export function ensureNodePtySpawnHelperExecutable(deps = {}) {
354
354
  return { helperPath, updated: true };
355
355
  }
356
356
 
357
+ export function isSafeTaskWorktreeRoot(projectWorkspacePath, worktreeRoot) {
358
+ const normalizedWorkspacePath =
359
+ typeof projectWorkspacePath === "string" ? projectWorkspacePath.trim() : "";
360
+ const normalizedWorktreeRoot =
361
+ typeof worktreeRoot === "string" ? worktreeRoot.trim() : "";
362
+ if (!normalizedWorkspacePath || !normalizedWorktreeRoot) {
363
+ return false;
364
+ }
365
+
366
+ const resolvedWorkspacePath = path.resolve(normalizedWorkspacePath);
367
+ const resolvedWorktreeRoot = path.resolve(normalizedWorktreeRoot);
368
+ if (resolvedWorkspacePath === resolvedWorktreeRoot) {
369
+ return false;
370
+ }
371
+
372
+ const expectedParent = path.resolve(resolvedWorkspacePath, ".conductor", "worktrees");
373
+ const relativeToExpectedParent = path.relative(expectedParent, resolvedWorktreeRoot);
374
+ if (
375
+ !relativeToExpectedParent ||
376
+ relativeToExpectedParent === "." ||
377
+ relativeToExpectedParent.startsWith("..") ||
378
+ path.isAbsolute(relativeToExpectedParent)
379
+ ) {
380
+ return false;
381
+ }
382
+
383
+ return true;
384
+ }
385
+
357
386
  function normalizeOptionalString(value) {
358
387
  if (typeof value !== "string") {
359
388
  return null;
@@ -638,6 +667,7 @@ export function startDaemon(config = {}, deps = {}) {
638
667
  const autoUpdateForceLocal = parseBooleanEnv(process.env.CONDUCTOR_AUTO_UPDATE_FORCE_LOCAL);
639
668
  const autoUpdateSupportedInstall =
640
669
  autoUpdateForceLocal || isManagedInstallPathFn(installedPackageRoot);
670
+ const skipPidLockCheck = parseBooleanEnv(process.env.CONDUCTOR_TUI_DEBUG);
641
671
  const lockHandoffToken =
642
672
  normalizeOptionalString(config.LOCK_HANDOFF_TOKEN) ||
643
673
  normalizeOptionalString(process.env.CONDUCTOR_LOCK_HANDOFF_TOKEN);
@@ -738,6 +768,30 @@ export function startDaemon(config = {}, deps = {}) {
738
768
  return resolvedPath;
739
769
  }
740
770
 
771
+ const PROJECT_SETTINGS_TEMPLATE = [
772
+ "worktree:",
773
+ " sync_branch: false",
774
+ " symlink: []",
775
+ " # Example: symlink paths from the parent workspace into each worktree",
776
+ " # symlink:",
777
+ " # - node_modules",
778
+ " # - .env",
779
+ "",
780
+ ].join("\n");
781
+
782
+ function ensureProjectSettingsTemplate(projectWorkspacePath) {
783
+ const settingsPath = path.join(projectWorkspacePath, ".conductor", "settings.yaml");
784
+ if (existsSyncFn(settingsPath)) {
785
+ return;
786
+ }
787
+ try {
788
+ mkdirSyncFn(path.join(projectWorkspacePath, ".conductor"), { recursive: true });
789
+ writeFileSyncFn(settingsPath, PROJECT_SETTINGS_TEMPLATE, "utf8");
790
+ } catch (_error) {
791
+ // best-effort; do not block project validation if template creation fails
792
+ }
793
+ }
794
+
741
795
  function readProjectWorktreeSettings(projectWorkspacePath) {
742
796
  const settingsCandidates = [
743
797
  path.join(projectWorkspacePath, ".conductor", "settings.yaml"),
@@ -759,6 +813,7 @@ export function startDaemon(config = {}, deps = {}) {
759
813
  : {};
760
814
  return {
761
815
  symlinkPaths: normalizeConfiguredPathList(worktreeSettings.symlink, projectWorkspacePath),
816
+ syncBranch: worktreeSettings.sync_branch === true || worktreeSettings.syncBranch === true,
762
817
  settingsPath,
763
818
  };
764
819
  } catch (error) {
@@ -768,6 +823,7 @@ export function startDaemon(config = {}, deps = {}) {
768
823
 
769
824
  return {
770
825
  symlinkPaths: [],
826
+ syncBranch: false,
771
827
  settingsPath: null,
772
828
  };
773
829
  }
@@ -811,9 +867,10 @@ export function startDaemon(config = {}, deps = {}) {
811
867
 
812
868
  async function runSpawnProcess(command, args, options = {}) {
813
869
  let child;
870
+ const { timeoutMs, ...spawnOptions } = options || {};
814
871
  try {
815
872
  child = spawnFn(command, args, {
816
- ...options,
873
+ ...spawnOptions,
817
874
  stdio: ["ignore", "pipe", "pipe"],
818
875
  });
819
876
  } catch (error) {
@@ -824,12 +881,17 @@ export function startDaemon(config = {}, deps = {}) {
824
881
  let stdout = "";
825
882
  let stderr = "";
826
883
  let settled = false;
884
+ let timeoutHandle = null;
827
885
 
828
886
  const finishResolve = () => {
829
887
  if (settled) {
830
888
  return;
831
889
  }
832
890
  settled = true;
891
+ if (timeoutHandle) {
892
+ clearTimeout(timeoutHandle);
893
+ timeoutHandle = null;
894
+ }
833
895
  resolve({ stdout, stderr });
834
896
  };
835
897
 
@@ -838,9 +900,26 @@ export function startDaemon(config = {}, deps = {}) {
838
900
  return;
839
901
  }
840
902
  settled = true;
903
+ if (timeoutHandle) {
904
+ clearTimeout(timeoutHandle);
905
+ timeoutHandle = null;
906
+ }
841
907
  reject(error instanceof Error ? error : new Error(String(error)));
842
908
  };
843
909
 
910
+ if (Number.isFinite(timeoutMs) && timeoutMs > 0) {
911
+ timeoutHandle = setTimeout(() => {
912
+ try {
913
+ if (child && typeof child.kill === "function") {
914
+ child.kill("SIGTERM");
915
+ }
916
+ } catch {
917
+ // ignore process kill failures; the timeout error is the useful signal
918
+ }
919
+ finishReject(new Error(`${command} ${args.join(" ")} timed out after ${timeoutMs}ms`));
920
+ }, timeoutMs);
921
+ }
922
+
844
923
  if (child.stdout && typeof child.stdout.on === "function") {
845
924
  child.stdout.on("data", (chunk) => {
846
925
  stdout += String(chunk ?? "");
@@ -888,6 +967,48 @@ export function startDaemon(config = {}, deps = {}) {
888
967
  const finalCwd = resolveTaskWorktreeCwd(worktreeRoot, worktreeConfig.projectRelativePath);
889
968
  const gitMarkerPath = path.join(worktreeRoot, ".git");
890
969
  if (!existsSyncFn(gitMarkerPath)) {
970
+ const { syncBranch } = readProjectWorktreeSettings(worktreeConfig.projectWorkspacePath);
971
+ if (syncBranch) {
972
+ try {
973
+ const { stdout: remoteStdout } = await runSpawnProcess(
974
+ "git",
975
+ ["-C", worktreeConfig.projectRepoRoot, "remote"],
976
+ { cwd: worktreeConfig.projectRepoRoot, timeoutMs: WORKTREE_SYNC_TIMEOUT_MS },
977
+ );
978
+ const hasRemote = remoteStdout.trim().length > 0;
979
+ if (hasRemote) {
980
+ await runSpawnProcess(
981
+ "git",
982
+ ["-C", worktreeConfig.projectRepoRoot, "fetch"],
983
+ { cwd: worktreeConfig.projectRepoRoot, timeoutMs: WORKTREE_SYNC_TIMEOUT_MS },
984
+ );
985
+ const { stdout: branchStdout } = await runSpawnProcess(
986
+ "git",
987
+ ["-C", worktreeConfig.projectRepoRoot, "rev-parse", "--abbrev-ref", "HEAD"],
988
+ { cwd: worktreeConfig.projectRepoRoot, timeoutMs: WORKTREE_SYNC_TIMEOUT_MS },
989
+ );
990
+ const currentBranch = branchStdout.trim();
991
+ if (currentBranch && currentBranch !== "HEAD") {
992
+ const { stdout: trackingStdout } = await runSpawnProcess(
993
+ "git",
994
+ ["-C", worktreeConfig.projectRepoRoot, "rev-parse", "--abbrev-ref", `${currentBranch}@{upstream}`],
995
+ { cwd: worktreeConfig.projectRepoRoot, timeoutMs: WORKTREE_SYNC_TIMEOUT_MS },
996
+ ).catch(() => ({ stdout: "" }));
997
+ const upstream = trackingStdout.trim();
998
+ if (upstream) {
999
+ await runSpawnProcess(
1000
+ "git",
1001
+ ["-C", worktreeConfig.projectRepoRoot, "merge", "--ff-only", upstream],
1002
+ { cwd: worktreeConfig.projectRepoRoot, timeoutMs: WORKTREE_SYNC_TIMEOUT_MS },
1003
+ ).catch(() => {});
1004
+ }
1005
+ }
1006
+ }
1007
+ } catch (_syncError) {
1008
+ // sync_branch is best-effort; proceed with worktree creation even if sync fails
1009
+ }
1010
+ }
1011
+
891
1012
  mkdirSyncFn(path.dirname(worktreeRoot), { recursive: true });
892
1013
  try {
893
1014
  await runSpawnProcess(
@@ -962,6 +1083,10 @@ export function startDaemon(config = {}, deps = {}) {
962
1083
  process.env.CONDUCTOR_DAEMON_FORCE_KILL_WAIT_MS,
963
1084
  2_000,
964
1085
  );
1086
+ const WORKTREE_SYNC_TIMEOUT_MS = parsePositiveInt(
1087
+ process.env.CONDUCTOR_WORKTREE_SYNC_TIMEOUT_MS,
1088
+ 5_000,
1089
+ );
965
1090
  const SHUTDOWN_STATUS_REPORT_TIMEOUT_MS = parsePositiveInt(
966
1091
  process.env.CONDUCTOR_SHUTDOWN_STATUS_REPORT_TIMEOUT_MS,
967
1092
  1000,
@@ -1073,87 +1198,94 @@ export function startDaemon(config = {}, deps = {}) {
1073
1198
 
1074
1199
  const LOCK_FILE = path.join(WORKSPACE_ROOT, "daemon.pid");
1075
1200
  try {
1076
- if (existsSyncFn(LOCK_FILE)) {
1077
- const lockState = readLockState();
1078
- const pid = lockState?.pid;
1079
- if (pid) {
1080
- const handoffMatched = hasMatchingLockHandoff(lockState);
1081
- try {
1082
- if (handoffMatched) {
1083
- log(`Taking over daemon lock from PID ${pid} via handoff`);
1084
- } else {
1085
- const alive = isProcessAlive(pid);
1086
- if (alive) {
1087
- if (config.FORCE) {
1088
- log(`Force enabled: stopping existing daemon PID ${pid}`);
1089
- let alreadyExited = false;
1090
- try {
1091
- killFn(pid, "SIGTERM");
1092
- } catch (killErr) {
1093
- if (killErr?.code === "ESRCH") {
1094
- alreadyExited = true;
1095
- } else {
1096
- logError(`Failed to stop existing daemon PID ${pid}: ${killErr.message}`);
1097
- return exitAndReturn(1);
1201
+ if (skipPidLockCheck) {
1202
+ log("CONDUCTOR_TUI_DEBUG enabled; skipping daemon PID lock enforcement");
1203
+ } else {
1204
+ if (existsSyncFn(LOCK_FILE)) {
1205
+ const lockState = readLockState();
1206
+ const pid = lockState?.pid;
1207
+ if (pid) {
1208
+ const handoffMatched = hasMatchingLockHandoff(lockState);
1209
+ try {
1210
+ if (handoffMatched) {
1211
+ log(`Taking over daemon lock from PID ${pid} via handoff`);
1212
+ } else {
1213
+ const alive = isProcessAlive(pid);
1214
+ if (alive) {
1215
+ if (config.FORCE) {
1216
+ log(`Force enabled: stopping existing daemon PID ${pid}`);
1217
+ let alreadyExited = false;
1218
+ try {
1219
+ killFn(pid, "SIGTERM");
1220
+ } catch (killErr) {
1221
+ if (killErr?.code === "ESRCH") {
1222
+ alreadyExited = true;
1223
+ } else {
1224
+ logError(`Failed to stop existing daemon PID ${pid}: ${killErr.message}`);
1225
+ return exitAndReturn(1);
1226
+ }
1098
1227
  }
1099
- }
1100
- try {
1101
- let exited = alreadyExited || waitForProcessExitSync(pid, DAEMON_FORCE_STOP_GRACE_MS);
1102
- if (!exited) {
1103
- log(
1104
- `Existing daemon PID ${pid} did not exit within ${DAEMON_FORCE_STOP_GRACE_MS}ms; sending SIGKILL`,
1105
- );
1106
- try {
1107
- killFn(pid, "SIGKILL");
1108
- } catch (killErr) {
1109
- if (killErr?.code !== "ESRCH") {
1110
- logError(`Failed to force kill existing daemon PID ${pid}: ${killErr.message}`);
1111
- return exitAndReturn(1);
1228
+ try {
1229
+ let exited = alreadyExited || waitForProcessExitSync(pid, DAEMON_FORCE_STOP_GRACE_MS);
1230
+ if (!exited) {
1231
+ log(
1232
+ `Existing daemon PID ${pid} did not exit within ${DAEMON_FORCE_STOP_GRACE_MS}ms; sending SIGKILL`,
1233
+ );
1234
+ try {
1235
+ killFn(pid, "SIGKILL");
1236
+ } catch (killErr) {
1237
+ if (killErr?.code !== "ESRCH") {
1238
+ logError(`Failed to force kill existing daemon PID ${pid}: ${killErr.message}`);
1239
+ return exitAndReturn(1);
1240
+ }
1112
1241
  }
1242
+ exited = waitForProcessExitSync(pid, DAEMON_FORCE_KILL_WAIT_MS);
1113
1243
  }
1114
- exited = waitForProcessExitSync(pid, DAEMON_FORCE_KILL_WAIT_MS);
1115
- }
1116
- if (!exited) {
1117
- logError(`Existing daemon PID ${pid} is still running after force restart; please stop it manually.`);
1244
+ if (!exited) {
1245
+ logError(`Existing daemon PID ${pid} is still running after force restart; please stop it manually.`);
1246
+ return exitAndReturn(1);
1247
+ }
1248
+ } catch (checkErr) {
1249
+ logError(`Failed to verify daemon PID ${pid}: ${checkErr.message}`);
1118
1250
  return exitAndReturn(1);
1119
1251
  }
1120
- } catch (checkErr) {
1121
- logError(`Failed to verify daemon PID ${pid}: ${checkErr.message}`);
1252
+ log("Removing lock file after force stop");
1253
+ if (existsSyncFn(LOCK_FILE)) {
1254
+ unlinkSyncFn(LOCK_FILE);
1255
+ }
1256
+ } else {
1257
+ logError(`Daemon already running with PID ${pid}`);
1122
1258
  return exitAndReturn(1);
1123
1259
  }
1124
- log("Removing lock file after force stop");
1125
- if (existsSyncFn(LOCK_FILE)) {
1126
- unlinkSyncFn(LOCK_FILE);
1127
- }
1128
1260
  } else {
1129
- logError(`Daemon already running with PID ${pid}`);
1130
- return exitAndReturn(1);
1261
+ log("Removing stale lock file");
1262
+ unlinkSyncFn(LOCK_FILE);
1131
1263
  }
1264
+ }
1265
+ } catch (e) {
1266
+ if (handoffMatched) {
1267
+ log(`Taking over daemon lock from PID ${pid} via handoff`);
1132
1268
  } else {
1133
- log("Removing stale lock file");
1134
- unlinkSyncFn(LOCK_FILE);
1269
+ logError(`Daemon already running with PID ${pid} (access denied)`);
1270
+ return exitAndReturn(1);
1135
1271
  }
1136
1272
  }
1137
- } catch (e) {
1138
- if (handoffMatched) {
1139
- log(`Taking over daemon lock from PID ${pid} via handoff`);
1140
- } else {
1141
- logError(`Daemon already running with PID ${pid} (access denied)`);
1142
- return exitAndReturn(1);
1143
- }
1273
+ } else {
1274
+ log("Removing malformed lock file");
1275
+ unlinkSyncFn(LOCK_FILE);
1144
1276
  }
1145
- } else {
1146
- log("Removing malformed lock file");
1147
- unlinkSyncFn(LOCK_FILE);
1148
1277
  }
1278
+ writeFileSyncFn(LOCK_FILE, process.pid.toString());
1149
1279
  }
1150
- writeFileSyncFn(LOCK_FILE, process.pid.toString());
1151
1280
  } catch (err) {
1152
1281
  logError("Failed to acquire lock:", err);
1153
1282
  return exitAndReturn(1);
1154
1283
  }
1155
1284
 
1156
1285
  const cleanupLock = () => {
1286
+ if (skipPidLockCheck) {
1287
+ return;
1288
+ }
1157
1289
  try {
1158
1290
  if (existsSyncFn(LOCK_FILE)) {
1159
1291
  const lockState = readLockState();
@@ -1167,6 +1299,9 @@ export function startDaemon(config = {}, deps = {}) {
1167
1299
  };
1168
1300
 
1169
1301
  const writeLockHandoff = ({ handoffToken, handoffFromPid, handoffExpiresAt }) => {
1302
+ if (skipPidLockCheck) {
1303
+ return;
1304
+ }
1170
1305
  writeFileSyncFn(
1171
1306
  LOCK_FILE,
1172
1307
  JSON.stringify({
@@ -2874,6 +3009,30 @@ export function startDaemon(config = {}, deps = {}) {
2874
3009
  logError(`Failed to report agent_command_ack(cleanup_task_worktree) for ${taskId}: ${error?.message || error}`);
2875
3010
  });
2876
3011
 
3012
+ if (activeTaskProcesses.has(taskId) || activePtySessions.has(taskId)) {
3013
+ if (forceCleanup) {
3014
+ const stopStarted = stopActiveTaskProcess(taskId, {
3015
+ reason: "cleanup_task_worktree",
3016
+ suppressExitStatusReport: true,
3017
+ });
3018
+ if (stopStarted) {
3019
+ const stopped = await waitForTaskToStop(taskId);
3020
+ if (!stopped && (activeTaskProcesses.has(taskId) || activePtySessions.has(taskId))) {
3021
+ await reportTaskWorktreeCleanupResult({
3022
+ requestId,
3023
+ taskId,
3024
+ worktreeBranch: worktreeConfig.worktreeBranch,
3025
+ cleaned: false,
3026
+ error: "Task is still active",
3027
+ }).catch((error) => {
3028
+ logError(`Failed to report task_worktree_cleanup_result for ${taskId}: ${error?.message || error}`);
3029
+ });
3030
+ return;
3031
+ }
3032
+ }
3033
+ }
3034
+ }
3035
+
2877
3036
  if (activeTaskProcesses.has(taskId) || activePtySessions.has(taskId)) {
2878
3037
  await reportTaskWorktreeCleanupResult({
2879
3038
  requestId,
@@ -2891,6 +3050,19 @@ export function startDaemon(config = {}, deps = {}) {
2891
3050
  worktreeConfig.projectWorkspacePath,
2892
3051
  worktreeConfig.worktreeId,
2893
3052
  );
3053
+ if (!isSafeTaskWorktreeRoot(worktreeConfig.projectWorkspacePath, worktreeRoot)) {
3054
+ await reportTaskWorktreeCleanupResult({
3055
+ requestId,
3056
+ taskId,
3057
+ worktreeBranch: worktreeConfig.worktreeBranch,
3058
+ removedPath: worktreeRoot,
3059
+ cleaned: false,
3060
+ error: `Refusing to remove unsafe worktree path: ${worktreeRoot}`,
3061
+ }).catch((error) => {
3062
+ logError(`Failed to report task_worktree_cleanup_result for ${taskId}: ${error?.message || error}`);
3063
+ });
3064
+ return;
3065
+ }
2894
3066
  const worktreeCwd = resolveTaskWorktreeCwd(worktreeRoot, worktreeConfig.projectRelativePath);
2895
3067
  const statusCwd = existsSyncFn(worktreeCwd) ? worktreeCwd : worktreeRoot;
2896
3068
 
@@ -3367,12 +3539,14 @@ export function startDaemon(config = {}, deps = {}) {
3367
3539
  };
3368
3540
  } else {
3369
3541
  const snapshot = await Promise.resolve(resolveProjectSnapshotFn(resolvedPath));
3542
+ const effectiveWorkspace =
3543
+ typeof snapshot?.projectRoot === "string" && snapshot.projectRoot.trim()
3544
+ ? snapshot.projectRoot.trim()
3545
+ : resolvedPath;
3546
+ ensureProjectSettingsTemplate(effectiveWorkspace);
3370
3547
  result = {
3371
3548
  ...result,
3372
- workspacePath:
3373
- typeof snapshot?.projectRoot === "string" && snapshot.projectRoot.trim()
3374
- ? snapshot.projectRoot.trim()
3375
- : resolvedPath,
3549
+ workspacePath: effectiveWorkspace,
3376
3550
  repoRoot:
3377
3551
  typeof snapshot?.repoRoot === "string" && snapshot.repoRoot.trim()
3378
3552
  ? snapshot.repoRoot.trim()
@@ -3420,57 +3594,25 @@ export function startDaemon(config = {}, deps = {}) {
3420
3594
  }
3421
3595
  }
3422
3596
 
3423
- function handleStopTask(payload) {
3424
- const taskId = payload?.task_id;
3425
- if (!taskId) return;
3426
- const requestId = payload?.request_id ? String(payload.request_id) : "";
3427
- if (requestId && !markRequestSeen(requestId)) {
3428
- log(`Duplicate stop_task ignored for ${taskId} (request_id=${requestId})`);
3429
- sendAgentCommandAck({
3430
- requestId,
3431
- taskId,
3432
- eventType: "stop_task",
3433
- accepted: true,
3434
- }).catch(() => {});
3435
- return;
3436
- }
3437
-
3438
- const sendStopAck = (accepted) => {
3439
- if (!requestId) return;
3440
- client
3441
- .sendJson({
3442
- type: "task_stop_ack",
3443
- payload: {
3444
- task_id: taskId,
3445
- request_id: requestId,
3446
- accepted: Boolean(accepted),
3447
- },
3448
- })
3449
- .catch((err) => {
3450
- logError(`Failed to report task_stop_ack for ${taskId}: ${err?.message || err}`);
3451
- });
3452
- sendAgentCommandAck({
3453
- requestId,
3454
- taskId,
3455
- eventType: "stop_task",
3456
- accepted,
3457
- }).catch((err) => {
3458
- logError(`Failed to report agent_command_ack(stop_task) for ${taskId}: ${err?.message || err}`);
3459
- });
3460
- };
3461
-
3597
+ function stopActiveTaskProcess(
3598
+ taskId,
3599
+ {
3600
+ reason = "",
3601
+ suppressExitStatusReport = false,
3602
+ } = {},
3603
+ ) {
3462
3604
  const processRecord = activeTaskProcesses.get(taskId);
3463
3605
  const ptyRecord = activePtySessions.get(taskId);
3464
3606
  if ((!processRecord || !processRecord.child) && !ptyRecord) {
3465
- log(`Stop requested for task ${taskId}, but no active process found`);
3466
- sendStopAck(false);
3467
- return;
3607
+ return false;
3468
3608
  }
3469
3609
 
3470
- const reason = payload?.reason ? ` (${payload.reason})` : "";
3471
- log(`Stopping task ${taskId}${reason}`);
3610
+ if (suppressExitStatusReport) {
3611
+ suppressedExitStatusReports.add(taskId);
3612
+ }
3472
3613
 
3473
- sendStopAck(true);
3614
+ const reasonSuffix = reason ? ` (${reason})` : "";
3615
+ log(`Stopping task ${taskId}${reasonSuffix}`);
3474
3616
 
3475
3617
  const activeRecord = processRecord || ptyRecord;
3476
3618
  if (activeRecord?.stopForceKillTimer) {
@@ -3528,6 +3670,75 @@ export function startDaemon(config = {}, deps = {}) {
3528
3670
  if (typeof activeRecord.stopForceKillTimer?.unref === "function") {
3529
3671
  activeRecord.stopForceKillTimer.unref();
3530
3672
  }
3673
+
3674
+ return true;
3675
+ }
3676
+
3677
+ async function waitForTaskToStop(taskId, timeoutMs = DAEMON_FORCE_STOP_GRACE_MS) {
3678
+ const deadline = Date.now() + Math.max(timeoutMs, 0);
3679
+
3680
+ while (activeTaskProcesses.has(taskId) || activePtySessions.has(taskId)) {
3681
+ const remainingMs = deadline - Date.now();
3682
+ if (remainingMs <= 0) {
3683
+ return false;
3684
+ }
3685
+ await new Promise((resolve) =>
3686
+ setTimeout(resolve, Math.min(DAEMON_FORCE_STOP_POLL_INTERVAL_MS, remainingMs)),
3687
+ );
3688
+ }
3689
+
3690
+ return true;
3691
+ }
3692
+
3693
+ function handleStopTask(payload) {
3694
+ const taskId = payload?.task_id;
3695
+ if (!taskId) return;
3696
+ const requestId = payload?.request_id ? String(payload.request_id) : "";
3697
+ if (requestId && !markRequestSeen(requestId)) {
3698
+ log(`Duplicate stop_task ignored for ${taskId} (request_id=${requestId})`);
3699
+ sendAgentCommandAck({
3700
+ requestId,
3701
+ taskId,
3702
+ eventType: "stop_task",
3703
+ accepted: true,
3704
+ }).catch(() => {});
3705
+ return;
3706
+ }
3707
+
3708
+ const sendStopAck = (accepted) => {
3709
+ if (!requestId) return;
3710
+ client
3711
+ .sendJson({
3712
+ type: "task_stop_ack",
3713
+ payload: {
3714
+ task_id: taskId,
3715
+ request_id: requestId,
3716
+ accepted: Boolean(accepted),
3717
+ },
3718
+ })
3719
+ .catch((err) => {
3720
+ logError(`Failed to report task_stop_ack for ${taskId}: ${err?.message || err}`);
3721
+ });
3722
+ sendAgentCommandAck({
3723
+ requestId,
3724
+ taskId,
3725
+ eventType: "stop_task",
3726
+ accepted,
3727
+ }).catch((err) => {
3728
+ logError(`Failed to report agent_command_ack(stop_task) for ${taskId}: ${err?.message || err}`);
3729
+ });
3730
+ };
3731
+
3732
+ const processRecord = activeTaskProcesses.get(taskId);
3733
+ const ptyRecord = activePtySessions.get(taskId);
3734
+ if ((!processRecord || !processRecord.child) && !ptyRecord) {
3735
+ log(`Stop requested for task ${taskId}, but no active process found`);
3736
+ sendStopAck(false);
3737
+ return;
3738
+ }
3739
+
3740
+ sendStopAck(true);
3741
+ stopActiveTaskProcess(taskId, { reason: payload?.reason });
3531
3742
  }
3532
3743
 
3533
3744
  async function getProjectLocalPath(projectId) {
@@ -17,16 +17,29 @@ const LEGACY_RUNTIME_BACKEND_ALIASES = new Set([
17
17
  const externalRuntimeCatalogPromises = new Map();
18
18
  let externalRuntimeImportNonce = 0;
19
19
 
20
- function normalizeProviderPathEnv(value) {
21
- return String(value || "").trim();
20
+ function appendProviderModulePaths(parts, value) {
21
+ if (Array.isArray(value)) {
22
+ for (const entry of value) {
23
+ appendProviderModulePaths(parts, entry);
24
+ }
25
+ return;
26
+ }
27
+ const raw = String(value || "").trim();
28
+ if (!raw) {
29
+ return;
30
+ }
31
+ for (const item of raw.split(process.platform === "win32" ? ";" : ":")) {
32
+ const normalized = item.trim();
33
+ if (normalized) {
34
+ parts.push(normalized);
35
+ }
36
+ }
22
37
  }
23
38
 
24
39
  function listProviderModulePaths(providerPathEnv) {
25
- const raw = normalizeProviderPathEnv(providerPathEnv);
26
- if (!raw) {
27
- return [];
28
- }
29
- return [...new Set(raw.split(process.platform === "win32" ? ";" : ":").map((item) => item.trim()).filter(Boolean))];
40
+ const parts = [];
41
+ appendProviderModulePaths(parts, providerPathEnv);
42
+ return [...new Set(parts)];
30
43
  }
31
44
 
32
45
  function normalizeRuntimeBackendName(backend) {
@@ -221,18 +234,17 @@ function readConfigEnvValue(configFilePath, key) {
221
234
  if (!parsed || typeof parsed !== "object") {
222
235
  return "";
223
236
  }
224
- const value = parsed?.envs?.[key];
225
- return typeof value === "string" ? value.trim() : "";
237
+ return parsed?.envs?.[key];
226
238
  } catch {
227
- return "";
239
+ return undefined;
228
240
  }
229
241
  }
230
242
 
231
- function resolveProviderPathEnv(options = {}) {
232
- return (
233
- normalizeProviderPathEnv(process.env.AISDK_PROVIDER_PATH) ||
234
- normalizeProviderPathEnv(readConfigEnvValue(options.configFilePath, "AISDK_PROVIDER_PATH"))
235
- );
243
+ function resolveProviderModulePaths(options = {}) {
244
+ return [
245
+ ...listProviderModulePaths(process.env.AISDK_PROVIDER_PATH),
246
+ ...listProviderModulePaths(readConfigEnvValue(options.configFilePath, "AISDK_PROVIDER_PATH")),
247
+ ].filter((value, index, array) => array.indexOf(value) === index);
236
248
  }
237
249
 
238
250
  function createEmptyExternalCatalog() {
@@ -342,15 +354,16 @@ async function loadExternalRuntimeCatalog(providerPathEnv) {
342
354
  }
343
355
 
344
356
  async function getExternalRuntimeCatalog(options = {}) {
345
- const providerPathEnv = resolveProviderPathEnv(options);
346
- if (!externalRuntimeCatalogPromises.has(providerPathEnv)) {
347
- const loadPromise = loadExternalRuntimeCatalog(providerPathEnv).catch((error) => {
348
- externalRuntimeCatalogPromises.delete(providerPathEnv);
357
+ const modulePaths = resolveProviderModulePaths(options);
358
+ const cacheKey = modulePaths.join("\0");
359
+ if (!externalRuntimeCatalogPromises.has(cacheKey)) {
360
+ const loadPromise = loadExternalRuntimeCatalog(modulePaths).catch((error) => {
361
+ externalRuntimeCatalogPromises.delete(cacheKey);
349
362
  throw error;
350
363
  });
351
- externalRuntimeCatalogPromises.set(providerPathEnv, loadPromise);
364
+ externalRuntimeCatalogPromises.set(cacheKey, loadPromise);
352
365
  }
353
- return externalRuntimeCatalogPromises.get(providerPathEnv);
366
+ return externalRuntimeCatalogPromises.get(cacheKey);
354
367
  }
355
368
 
356
369
  export async function normalizeRuntimeBackendAlias(backend, options = {}) {