@love-moon/conductor-cli 0.13.3 → 0.15.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 +114 -0
- package/bin/conductor-fire.js +209 -60
- package/bin/conductor-remote.js +5 -1
- package/bin/conductor.js +1 -1
- package/package.json +5 -5
- package/src/daemon.js +38 -11
- package/src/remote/client.js +1 -1
- package/src/remote/cp.js +2 -2
- package/src/remote/exec.js +36 -6
- package/src/remote/mcp-launch.js +167 -0
- package/src/remote/mcp.js +849 -0
- package/src/remote-exec-handlers.js +15 -3
package/src/daemon.js
CHANGED
|
@@ -40,9 +40,11 @@ import {
|
|
|
40
40
|
} from "./custom-command-handlers.js";
|
|
41
41
|
import {
|
|
42
42
|
REMOTE_EXEC_CAPABILITY,
|
|
43
|
+
REMOTE_EXEC_RUN_ID_CAPABILITY,
|
|
43
44
|
createRemoteExecHandlers,
|
|
44
45
|
handleRemoteExecRequest,
|
|
45
46
|
} from "./remote-exec-handlers.js";
|
|
47
|
+
import { REMOTE_WORKTREE_ENV, remoteWorktreeFireEnv } from "./remote/mcp-launch.js";
|
|
46
48
|
import {
|
|
47
49
|
REMOTE_FILE_CAPABILITY,
|
|
48
50
|
createRemoteFileHandlers,
|
|
@@ -703,7 +705,9 @@ export function buildFireSpawnArgs({ selectedBackend, initialContent, launchConf
|
|
|
703
705
|
const prefill = typeof initialContent === "string" ? initialContent : "";
|
|
704
706
|
const effectivePrefill = prefill || goalObjective;
|
|
705
707
|
if (effectivePrefill) {
|
|
706
|
-
|
|
708
|
+
// `=` form: as a separate argv entry, yargs reads content that starts with
|
|
709
|
+
// "-" (a markdown list, "--model ...") as a new flag and drops it.
|
|
710
|
+
args.push(`--prefill=${effectivePrefill}`);
|
|
707
711
|
}
|
|
708
712
|
const resumeSessionId =
|
|
709
713
|
normalizeOptionalString(launchConfig?.resumeSessionId) ||
|
|
@@ -789,6 +793,10 @@ export async function collectBackendSessions({
|
|
|
789
793
|
cwd: normalizeOptionalString(entry?.cwd),
|
|
790
794
|
title: normalizeOptionalString(entry?.title),
|
|
791
795
|
updated_at: Number.isFinite(entry?.updatedAt) ? new Date(entry.updatedAt).toISOString() : null,
|
|
796
|
+
first_user_message: normalizeOptionalString(entry?.preview?.firstUserMessage),
|
|
797
|
+
first_reply: normalizeOptionalString(entry?.preview?.firstReply),
|
|
798
|
+
last_message: normalizeOptionalString(entry?.preview?.lastMessage?.text),
|
|
799
|
+
last_message_role: normalizeOptionalString(entry?.preview?.lastMessage?.role),
|
|
792
800
|
});
|
|
793
801
|
}
|
|
794
802
|
} catch (error) {
|
|
@@ -889,6 +897,7 @@ const PTY_TASK_SCOPED_ENV_KEYS = [
|
|
|
889
897
|
"CONDUCTOR_PTY_SESSION_ID",
|
|
890
898
|
"CONDUCTOR_LAUNCHED_BY_DAEMON",
|
|
891
899
|
"CONDUCTOR_RESUME_CWD",
|
|
900
|
+
REMOTE_WORKTREE_ENV,
|
|
892
901
|
];
|
|
893
902
|
|
|
894
903
|
// Task-scoped env vars a Fire must NEVER inherit from anywhere but this
|
|
@@ -3883,14 +3892,19 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
3883
3892
|
"project_agents_registry",
|
|
3884
3893
|
"restart_daemon",
|
|
3885
3894
|
"refresh_session_inplace",
|
|
3895
|
+
"restart_first_message",
|
|
3886
3896
|
"persistent_round_v1",
|
|
3887
3897
|
"task_attachments_v1",
|
|
3898
|
+
// RFC 0041: this CLI can run a global-backend task's AI here — it drives
|
|
3899
|
+
// another daemon over `conductor remote` and binds the remote_* MCP tools
|
|
3900
|
+
// to a remoteWorkspace as well as a remoteWorktree.
|
|
3901
|
+
"global_backend_v1",
|
|
3888
3902
|
"backend_session_list",
|
|
3889
3903
|
CUSTOM_COMMANDS_CAPABILITY,
|
|
3890
3904
|
UPDATE_DAEMON_CAPABILITY,
|
|
3891
3905
|
];
|
|
3892
3906
|
if (remoteExecEnabled) {
|
|
3893
|
-
advertisedCapabilities.push(REMOTE_EXEC_CAPABILITY);
|
|
3907
|
+
advertisedCapabilities.push(REMOTE_EXEC_CAPABILITY, REMOTE_EXEC_RUN_ID_CAPABILITY);
|
|
3894
3908
|
} else {
|
|
3895
3909
|
log("[remote-exec] Disabled by config (remote_exec: false); capability not advertised");
|
|
3896
3910
|
}
|
|
@@ -5780,6 +5794,9 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
5780
5794
|
let taskDir =
|
|
5781
5795
|
normalizeOptionalString(launchConfig.cwd) ||
|
|
5782
5796
|
boundPath;
|
|
5797
|
+
// No project path: the shell starts in HOME; the scratch dir below only
|
|
5798
|
+
// holds the terminal log.
|
|
5799
|
+
const shellCwd = taskDir || homeDir;
|
|
5783
5800
|
if (!taskDir) {
|
|
5784
5801
|
const now = new Date();
|
|
5785
5802
|
const dayDir = path.join(WORKSPACE_ROOT, formatWorkspaceDate(now));
|
|
@@ -5805,7 +5822,7 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
5805
5822
|
|
|
5806
5823
|
let launchSpec;
|
|
5807
5824
|
try {
|
|
5808
|
-
launchSpec = resolvePtyLaunchSpec(launchConfig,
|
|
5825
|
+
launchSpec = resolvePtyLaunchSpec(launchConfig, shellCwd);
|
|
5809
5826
|
} catch (error) {
|
|
5810
5827
|
logError(`Failed to resolve PTY launch config for ${taskId}: ${error?.message || error}`);
|
|
5811
5828
|
sendAgentCommandAck({
|
|
@@ -5834,7 +5851,7 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
5834
5851
|
|
|
5835
5852
|
const env = buildPtyTaskEnv(process.env, launchSpec.env);
|
|
5836
5853
|
|
|
5837
|
-
const logPath = path.join(
|
|
5854
|
+
const logPath = path.join(taskDir, "conductor-terminal.log");
|
|
5838
5855
|
let logStream;
|
|
5839
5856
|
try {
|
|
5840
5857
|
logStream = createWriteStreamFn(logPath, { flags: "a" });
|
|
@@ -5870,7 +5887,6 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
5870
5887
|
rejectCreatePtyTaskDuringShutdown(payload, { sendAck: false });
|
|
5871
5888
|
return;
|
|
5872
5889
|
}
|
|
5873
|
-
const resolvedLogPath = path.join(taskDir, "conductor-terminal.log");
|
|
5874
5890
|
|
|
5875
5891
|
const startedAt = new Date().toISOString();
|
|
5876
5892
|
const record = {
|
|
@@ -5879,7 +5895,8 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
5879
5895
|
ptySessionId,
|
|
5880
5896
|
projectId,
|
|
5881
5897
|
taskDir,
|
|
5882
|
-
|
|
5898
|
+
cwd: launchSpec.cwd,
|
|
5899
|
+
logPath,
|
|
5883
5900
|
logStream,
|
|
5884
5901
|
cols: launchSpec.cols,
|
|
5885
5902
|
rows: launchSpec.rows,
|
|
@@ -5900,7 +5917,7 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
5900
5917
|
project_id: projectId,
|
|
5901
5918
|
pty_session_id: ptySessionId,
|
|
5902
5919
|
pid: Number.isInteger(pty?.pid) ? pty.pid : null,
|
|
5903
|
-
cwd:
|
|
5920
|
+
cwd: launchSpec.cwd,
|
|
5904
5921
|
shell: launchSpec.shell,
|
|
5905
5922
|
cols: launchSpec.cols,
|
|
5906
5923
|
rows: launchSpec.rows,
|
|
@@ -6107,7 +6124,7 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
6107
6124
|
project_id: record.projectId,
|
|
6108
6125
|
pty_session_id: record.ptySessionId,
|
|
6109
6126
|
pid: Number.isInteger(record.pty?.pid) ? record.pty.pid : null,
|
|
6110
|
-
cwd: record.
|
|
6127
|
+
cwd: record.cwd,
|
|
6111
6128
|
shell: record.shell,
|
|
6112
6129
|
cols: record.cols,
|
|
6113
6130
|
rows: record.rows,
|
|
@@ -7808,6 +7825,8 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
7808
7825
|
// backend (dsh/copilot) would see e.g. `claude --model opus` here and
|
|
7809
7826
|
// mis-apply its flags. Fire treats "" as unset.
|
|
7810
7827
|
CONDUCTOR_CLI_COMMAND: cliCommand,
|
|
7828
|
+
// RFC 0040: lets fire give the AI tools bound to the remote worktree.
|
|
7829
|
+
...remoteWorktreeFireEnv(launchConfig),
|
|
7811
7830
|
};
|
|
7812
7831
|
if (AGENT_TOKEN) {
|
|
7813
7832
|
env.CONDUCTOR_AGENT_TOKEN = AGENT_TOKEN;
|
|
@@ -8101,6 +8120,7 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
8101
8120
|
source_session_file_path: sourceSessionFilePath,
|
|
8102
8121
|
target_backend_type: targetBackendType,
|
|
8103
8122
|
resume_context_url: resumeContextUrlRaw,
|
|
8123
|
+
initial_content: initialContentRaw,
|
|
8104
8124
|
request_id: requestIdRaw,
|
|
8105
8125
|
} = payload || {};
|
|
8106
8126
|
|
|
@@ -8538,9 +8558,15 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
8538
8558
|
if (isForkMode) {
|
|
8539
8559
|
// Fork mode starts a brand-new session on the target backend; no --resume.
|
|
8540
8560
|
// The prior conversation is delivered as a plain-text URL via the prompt
|
|
8541
|
-
// so the target backend fetches it on its own
|
|
8542
|
-
|
|
8543
|
-
|
|
8561
|
+
// so the target backend fetches it on its own — unless the user set their
|
|
8562
|
+
// own first message, which replaces it. That one goes through --prefill
|
|
8563
|
+
// (string-typed) so fire does not coerce a numeric message like "0.10".
|
|
8564
|
+
const customFirstMessage = normalizeOptionalString(initialContentRaw);
|
|
8565
|
+
if (customFirstMessage) {
|
|
8566
|
+
args.push(`--prefill=${customFirstMessage}`, "--");
|
|
8567
|
+
} else {
|
|
8568
|
+
args.push("--", resumeHandoffPrompt);
|
|
8569
|
+
}
|
|
8544
8570
|
} else {
|
|
8545
8571
|
if (shouldResumeSession) {
|
|
8546
8572
|
args.push("--resume", resolvedResumeSessionId);
|
|
@@ -8560,6 +8586,7 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
8560
8586
|
// gets an explicit `-e CONDUCTOR_CLI_COMMAND=` that overrides any stale
|
|
8561
8587
|
// value in the tmux SERVER's global environment (see create_task).
|
|
8562
8588
|
CONDUCTOR_CLI_COMMAND: cliCommand,
|
|
8589
|
+
...remoteWorktreeFireEnv(targetLaunchConfig),
|
|
8563
8590
|
};
|
|
8564
8591
|
env.CONDUCTOR_RESUME_CWD = resolvedResumeCwd;
|
|
8565
8592
|
if (AGENT_TOKEN) {
|
package/src/remote/client.js
CHANGED
|
@@ -112,7 +112,7 @@ export async function withRetry(fn, {
|
|
|
112
112
|
} catch (error) {
|
|
113
113
|
lastError = error;
|
|
114
114
|
// An aborted transfer is the caller's own deadline firing, not a blip.
|
|
115
|
-
if (error?.name === "AbortError" || !retryable(error)) throw error;
|
|
115
|
+
if (error?.name === "AbortError" || !(await retryable(error))) throw error;
|
|
116
116
|
if (attempt === attempts - 1) break;
|
|
117
117
|
await sleep(baseDelayMs * 2 ** attempt);
|
|
118
118
|
}
|
package/src/remote/cp.js
CHANGED
|
@@ -385,7 +385,7 @@ async function downloadChunks({
|
|
|
385
385
|
}, { sleep });
|
|
386
386
|
}
|
|
387
387
|
|
|
388
|
-
async function uploadFile(ctx) {
|
|
388
|
+
export async function uploadFile(ctx) {
|
|
389
389
|
const { config, target, localPath, remotePath, fetchImpl, consoleImpl, signal, quiet } = ctx;
|
|
390
390
|
const resolvedLocal = path.resolve(expandHome(localPath));
|
|
391
391
|
|
|
@@ -466,7 +466,7 @@ async function uploadFile(ctx) {
|
|
|
466
466
|
return { ...delivered, transferId, direction: "up", sizeBytes: stat.size, sha256 };
|
|
467
467
|
}
|
|
468
468
|
|
|
469
|
-
async function downloadFile(ctx) {
|
|
469
|
+
export async function downloadFile(ctx) {
|
|
470
470
|
const { config, target, remotePath, localPath, fetchImpl, consoleImpl, signal, quiet } = ctx;
|
|
471
471
|
const basePath = `/api/agents/${encodeURIComponent(target)}/files`;
|
|
472
472
|
|
package/src/remote/exec.js
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* or globs are actually wanted.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
+
import { randomUUID } from "node:crypto";
|
|
9
10
|
import process from "node:process";
|
|
10
11
|
|
|
11
12
|
import {
|
|
@@ -13,6 +14,7 @@ import {
|
|
|
13
14
|
UsageError,
|
|
14
15
|
callApi,
|
|
15
16
|
delay,
|
|
17
|
+
isRetryable,
|
|
16
18
|
loadCliConfig,
|
|
17
19
|
parseTimeoutMs as parseSharedTimeoutMs,
|
|
18
20
|
withRetry,
|
|
@@ -25,12 +27,10 @@ const POLL_INTERVAL_MS = 1_000;
|
|
|
25
27
|
/** How long a single request may block server-side before we switch to polling. */
|
|
26
28
|
const POST_WAIT_MS = 10_000;
|
|
27
29
|
/**
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
* one saturating POST can block (POST_WAIT_MS). Nothing else is retried here:
|
|
31
|
-
* a 5xx may have started the command already, and a second POST would run it twice.
|
|
30
|
+
* Six attempts at 500ms doubling wait out ~15s, longer than one saturating POST
|
|
31
|
+
* can block (POST_WAIT_MS). What is safe to retry is decided in `execRemote`.
|
|
32
32
|
*/
|
|
33
|
-
const
|
|
33
|
+
const POST_ATTEMPTS = 6;
|
|
34
34
|
const INTERRUPT_SIGNALS = ["SIGINT", "SIGTERM"];
|
|
35
35
|
|
|
36
36
|
const VALUE_FLAGS = new Map([
|
|
@@ -233,16 +233,31 @@ export async function execRemote(config, target, command, options = {}) {
|
|
|
233
233
|
const basePath = `/api/agents/${encodeURIComponent(target)}/exec`;
|
|
234
234
|
const deadline = now() + timeoutMs;
|
|
235
235
|
|
|
236
|
+
// Sent on every attempt: a daemon that already started this run returns it
|
|
237
|
+
// instead of running the command again.
|
|
238
|
+
const runId = randomUUID();
|
|
239
|
+
let dedupes;
|
|
240
|
+
const retryable = async (error) => {
|
|
241
|
+
// The per-user in-flight cap (8) is full: nothing was started.
|
|
242
|
+
if (error?.status === 429) return true;
|
|
243
|
+
// A 502 is the daemon's own answer (bad workspace, …); a retry gets the same one.
|
|
244
|
+
if (!isRetryable(error) || error.status === 502 || now() >= deadline) return false;
|
|
245
|
+
// A network error or other 5xx may have started the command already.
|
|
246
|
+
dedupes ??= await dedupesRunId(config, basePath, fetchImpl, sleep);
|
|
247
|
+
return dedupes;
|
|
248
|
+
};
|
|
249
|
+
|
|
236
250
|
const started = await withRetry(() => callApi(config, "POST", basePath, {
|
|
237
251
|
command,
|
|
238
252
|
args,
|
|
253
|
+
runId,
|
|
239
254
|
...(workspace ? { workspace } : {}),
|
|
240
255
|
...(env && Object.keys(env).length > 0 ? { env } : {}),
|
|
241
256
|
// Deliberately short, and independent of the overall deadline: that is
|
|
242
257
|
// owned by the poll loop below. Handing the daemon the full budget would
|
|
243
258
|
// make one HTTP request block for it and leave the loop unreachable.
|
|
244
259
|
timeoutMs: Math.min(timeoutMs, POST_WAIT_MS),
|
|
245
|
-
}, fetchImpl), {
|
|
260
|
+
}, fetchImpl), { attempts: POST_ATTEMPTS, retryable, sleep });
|
|
246
261
|
|
|
247
262
|
const waited = await waitForRun(config, basePath, started, {
|
|
248
263
|
deadline, fetchImpl, sleep, now, killOnTimeout, signal,
|
|
@@ -250,6 +265,21 @@ export async function execRemote(config, target, command, options = {}) {
|
|
|
250
265
|
return { ...waited, basePath };
|
|
251
266
|
}
|
|
252
267
|
|
|
268
|
+
/**
|
|
269
|
+
* Whether the target dedupes exec on `runId`, which is what makes re-sending a
|
|
270
|
+
* POST that failed mid-flight safe. Asked only after such a failure, so the happy
|
|
271
|
+
* path pays nothing. Anything short of a clear yes — an old server answers 405,
|
|
272
|
+
* an old daemon `false` — keeps the POST to 429-only retries.
|
|
273
|
+
*/
|
|
274
|
+
async function dedupesRunId(config, basePath, fetchImpl, sleep) {
|
|
275
|
+
try {
|
|
276
|
+
const answer = await withRetry(() => callApi(config, "GET", basePath, null, fetchImpl), { sleep });
|
|
277
|
+
return answer?.dedupesRunId === true;
|
|
278
|
+
} catch {
|
|
279
|
+
return false;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
253
283
|
/**
|
|
254
284
|
* `execRemote` for callers that only care whether it worked. Throws with the
|
|
255
285
|
* remote's own stderr, which is what the user needs to see.
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wiring `conductor remote mcp` into a remote-worktree task (RFC 0040).
|
|
3
|
+
*
|
|
4
|
+
* The daemon knows the task's `launch_config.remoteWorktree`; fire knows which
|
|
5
|
+
* AI backend it is about to start. The daemon resolves the binding (which
|
|
6
|
+
* daemon, which directory) and hands it to fire in one env var; fire turns it
|
|
7
|
+
* into that backend's MCP configuration. Kept free of heavy imports: both the
|
|
8
|
+
* daemon and fire load it on every task.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
import process from "node:process";
|
|
13
|
+
import { fileURLToPath } from "node:url";
|
|
14
|
+
|
|
15
|
+
export const MCP_SERVER_NAME = "conductor_remote";
|
|
16
|
+
export const REMOTE_WORKTREE_ENV = "CONDUCTOR_REMOTE_WORKTREE";
|
|
17
|
+
|
|
18
|
+
/** The server's credentials travel by env, never argv (argv is world-readable in `ps`). */
|
|
19
|
+
const FORWARDED_ENV = [
|
|
20
|
+
"CONDUCTOR_AGENT_TOKEN",
|
|
21
|
+
"CONDUCTOR_BACKEND_URL",
|
|
22
|
+
"CONDUCTOR_CONFIG",
|
|
23
|
+
"CONDUCTOR_HOME",
|
|
24
|
+
"CONDUCTOR_REMOTE_CHUNK_BYTES",
|
|
25
|
+
// Codex hands MCP servers only a minimal env; a corporate CA must still reach fetch.
|
|
26
|
+
"NODE_EXTRA_CA_CERTS",
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
/** remote_bash may wait up to 10 minutes; Codex's default MCP tool timeout is 60s. */
|
|
30
|
+
const CODEX_TOOL_TIMEOUT_SEC = 900;
|
|
31
|
+
|
|
32
|
+
const REMOTE_SCRIPT = fileURLToPath(new URL("../../bin/conductor-remote.js", import.meta.url));
|
|
33
|
+
|
|
34
|
+
const str = (value) => (typeof value === "string" && value.trim() ? value.trim() : "");
|
|
35
|
+
|
|
36
|
+
const isWindowsStylePath = (value) => /^[A-Za-z]:[\\/]/.test(value) || value.includes("\\");
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Where the remote worktree lives, from `launch_config.remoteWorktree`. Same
|
|
40
|
+
* math as web's `resolveRemoteWorktreePaths` and the daemon's
|
|
41
|
+
* `buildTaskWorktreeRoot`: `<workspacePath>/.conductor/worktrees/<branch>`, and
|
|
42
|
+
* the work dir is the project's offset inside the repository under that.
|
|
43
|
+
*/
|
|
44
|
+
export function resolveRemoteWorktreeBinding(launchConfig) {
|
|
45
|
+
const raw = launchConfig?.remoteWorktree ?? launchConfig?.remote_worktree;
|
|
46
|
+
if (!raw || typeof raw !== "object") return resolveRemoteWorkspaceBinding(launchConfig);
|
|
47
|
+
const host = str(raw.host);
|
|
48
|
+
const repoRoot = str(raw.repoRoot) || str(raw.repo_root);
|
|
49
|
+
const workspacePath = str(raw.workspacePath) || str(raw.workspace_path);
|
|
50
|
+
const branch = str(raw.branch);
|
|
51
|
+
if (!host || !repoRoot || !workspacePath || !branch) return null;
|
|
52
|
+
|
|
53
|
+
const pathApi = isWindowsStylePath(repoRoot) || isWindowsStylePath(workspacePath) ? path.win32 : path.posix;
|
|
54
|
+
const folder = branch.replace(/[/\\]/g, "_").replace(/\.\./g, "_");
|
|
55
|
+
const root = pathApi.join(workspacePath, ".conductor", "worktrees", folder);
|
|
56
|
+
const relative = pathApi.relative(repoRoot, workspacePath);
|
|
57
|
+
if (relative.startsWith("..") || pathApi.isAbsolute(relative)) return null;
|
|
58
|
+
const cwd = relative && relative !== "." ? pathApi.join(root, relative) : root;
|
|
59
|
+
return { host, root, cwd };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* RFC 0041 direct mode: `launch_config.remoteWorkspace` points at the project
|
|
64
|
+
* directory itself (no worktree). The tools stay inside the repository and
|
|
65
|
+
* start in the project's directory.
|
|
66
|
+
*/
|
|
67
|
+
function resolveRemoteWorkspaceBinding(launchConfig) {
|
|
68
|
+
const raw = launchConfig?.remoteWorkspace ?? launchConfig?.remote_workspace;
|
|
69
|
+
if (!raw || typeof raw !== "object") return null;
|
|
70
|
+
const host = str(raw.host);
|
|
71
|
+
const repoRoot = str(raw.repoRoot) || str(raw.repo_root);
|
|
72
|
+
const workspacePath = str(raw.workspacePath) || str(raw.workspace_path);
|
|
73
|
+
if (!host || !repoRoot || !workspacePath) return null;
|
|
74
|
+
const pathApi = isWindowsStylePath(repoRoot) || isWindowsStylePath(workspacePath) ? path.win32 : path.posix;
|
|
75
|
+
const relative = pathApi.relative(repoRoot, workspacePath);
|
|
76
|
+
if (relative.startsWith("..") || pathApi.isAbsolute(relative)) return null;
|
|
77
|
+
return { host, root: repoRoot, cwd: workspacePath };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Env the daemon adds when it spawns fire. Empty for an ordinary task. */
|
|
81
|
+
export function remoteWorktreeFireEnv(launchConfig) {
|
|
82
|
+
const binding = resolveRemoteWorktreeBinding(launchConfig);
|
|
83
|
+
return binding ? { [REMOTE_WORKTREE_ENV]: JSON.stringify(binding) } : {};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function readRemoteWorktreeBinding(env = process.env) {
|
|
87
|
+
const raw = str(env?.[REMOTE_WORKTREE_ENV]);
|
|
88
|
+
if (!raw) return null;
|
|
89
|
+
try {
|
|
90
|
+
const parsed = JSON.parse(raw);
|
|
91
|
+
const host = str(parsed?.host);
|
|
92
|
+
const root = str(parsed?.root);
|
|
93
|
+
const cwd = str(parsed?.cwd) || root;
|
|
94
|
+
return host && root ? { host, root, cwd } : null;
|
|
95
|
+
} catch {
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function serverArgs(binding, configFile, remoteScript) {
|
|
101
|
+
return [
|
|
102
|
+
remoteScript,
|
|
103
|
+
"mcp",
|
|
104
|
+
"--host", binding.host,
|
|
105
|
+
"--root", binding.root,
|
|
106
|
+
"--cwd", binding.cwd,
|
|
107
|
+
// Absolute: the backend starts the server in the session's cwd, not ours.
|
|
108
|
+
...(configFile ? ["--config-file", path.resolve(configFile)] : []),
|
|
109
|
+
];
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Session options that give the backend the remote workspace tools, merged
|
|
114
|
+
* over what the user configured. Only Claude and Codex are wired; any other
|
|
115
|
+
* backend gets nothing and keeps using `conductor remote exec` as before.
|
|
116
|
+
*/
|
|
117
|
+
export function buildRemoteWorktreeSessionOptions({
|
|
118
|
+
backend,
|
|
119
|
+
binding,
|
|
120
|
+
sessionOptions = {},
|
|
121
|
+
commandLine = "",
|
|
122
|
+
configFile = "",
|
|
123
|
+
env = process.env,
|
|
124
|
+
execPath = process.execPath,
|
|
125
|
+
remoteScript = REMOTE_SCRIPT,
|
|
126
|
+
} = {}) {
|
|
127
|
+
if (!binding) return {};
|
|
128
|
+
const args = serverArgs(binding, configFile, remoteScript);
|
|
129
|
+
const normalizedBackend = String(backend || "").trim().toLowerCase();
|
|
130
|
+
|
|
131
|
+
if (normalizedBackend === "claude") {
|
|
132
|
+
const allowed = Array.isArray(sessionOptions.allowedTools) ? sessionOptions.allowedTools : [];
|
|
133
|
+
// Same precedence as the Claude session: explicit option, then the
|
|
134
|
+
// configured command line's `--permission-mode`.
|
|
135
|
+
const mode = sessionOptions.permissionMode
|
|
136
|
+
?? String(commandLine || "").match(/--permission-mode[=\s]+([^\s"']+)/)?.[1];
|
|
137
|
+
return {
|
|
138
|
+
mcpServers: {
|
|
139
|
+
...(sessionOptions.mcpServers || {}),
|
|
140
|
+
[MCP_SERVER_NAME]: { type: "stdio", command: execPath, args },
|
|
141
|
+
},
|
|
142
|
+
// Under the default bypassPermissions a root install is downgraded to
|
|
143
|
+
// acceptEdits, which would deny every remote_* call headless. A mode the
|
|
144
|
+
// user chose on purpose is left alone rather than widened.
|
|
145
|
+
...(mode === undefined || mode === "bypassPermissions"
|
|
146
|
+
? { allowedTools: [...new Set([...allowed, `mcp__${MCP_SERVER_NAME}`])] }
|
|
147
|
+
: {}),
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (normalizedBackend === "codex") {
|
|
152
|
+
// Values are TOML; JSON strings and string arrays are valid TOML as-is.
|
|
153
|
+
const prefix = `mcp_servers.${MCP_SERVER_NAME}`;
|
|
154
|
+
const forwarded = FORWARDED_ENV.filter((name) => str(env?.[name]));
|
|
155
|
+
return {
|
|
156
|
+
configOverrides: [
|
|
157
|
+
...(Array.isArray(sessionOptions.configOverrides) ? sessionOptions.configOverrides : []),
|
|
158
|
+
`${prefix}.command=${JSON.stringify(execPath)}`,
|
|
159
|
+
`${prefix}.args=${JSON.stringify(args)}`,
|
|
160
|
+
...(forwarded.length > 0 ? [`${prefix}.env_vars=${JSON.stringify(forwarded)}`] : []),
|
|
161
|
+
`${prefix}.tool_timeout_sec=${CODEX_TOOL_TIMEOUT_SEC}`,
|
|
162
|
+
],
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return {};
|
|
167
|
+
}
|