@love-moon/conductor-cli 0.14.0 → 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 CHANGED
@@ -1,5 +1,47 @@
1
1
  # @love-moon/conductor-cli
2
2
 
3
+ ## 0.15.0
4
+
5
+ ### Minor Changes
6
+
7
+ - b61c881: Global AI backend tasks (RFC 0041) that work directly in another daemon's
8
+ project directory (`launch_config.remoteWorkspace`) now get the same
9
+ `conductor remote mcp` tools as remote-worktree tasks, bound to that
10
+ repository and starting in the project directory.
11
+
12
+ The daemon advertises `global_backend_v1` so the web app only offers a daemon
13
+ as a global AI backend when its CLI can drive another daemon.
14
+
15
+ - cc1e290: New Terminal and Resume Session previews in the create-task dialog.
16
+
17
+ - `@love-moon/ai-sdk`: `listSessions` (claude, codex) returns `preview` with
18
+ the first user message, the assistant reply to it, and the session's last
19
+ message.
20
+ - The daemon passes the preview to `list_backend_sessions` as
21
+ `first_user_message`, `first_reply`, `last_message` and `last_message_role`.
22
+ - A PTY task with no project path now starts its shell in `$HOME`. The
23
+ terminal log stays in the dated workspace directory.
24
+
25
+ - 55b65db: `conductor remote mcp`: structured tools for a task whose worktree lives on
26
+ another daemon (RFC 0040).
27
+
28
+ - New `conductor remote mcp --host <daemon> --root <dir> [--cwd <dir>]`, a
29
+ stdio MCP server with `remote_read`, `remote_edit`, `remote_write`,
30
+ `remote_grep`, `remote_glob` and `remote_bash`, bound to that directory.
31
+ - For a `launch_config.remoteWorktree` task the daemon passes the binding to
32
+ fire (`CONDUCTOR_REMOTE_WORKTREE`), and fire attaches the server to Claude
33
+ (`mcpServers`) and Codex (`-c mcp_servers.*`). The agent token is forwarded
34
+ by env only, never on argv.
35
+ - `@love-moon/ai-sdk`: the Codex app-server session accepts `configOverrides`,
36
+ passed to the app-server as `-c key=value`.
37
+
38
+ ### Patch Changes
39
+
40
+ - Updated dependencies [cc1e290]
41
+ - Updated dependencies [55b65db]
42
+ - @love-moon/ai-sdk@0.15.0
43
+ - @love-moon/conductor-sdk@0.15.0
44
+
3
45
  ## 0.14.0
4
46
 
5
47
  ### Minor Changes
@@ -26,6 +26,11 @@ import {
26
26
  resolveSessionRunDirectory as resolveCliSessionRunDirectory,
27
27
  resumeProviderForBackend as resumeProviderForCliBackend,
28
28
  } from "../src/fire/resume.js";
29
+ import {
30
+ REMOTE_WORKTREE_ENV,
31
+ buildRemoteWorktreeSessionOptions,
32
+ readRemoteWorktreeBinding,
33
+ } from "../src/remote/mcp-launch.js";
29
34
  import {
30
35
  filterRuntimeSupportedAllowCliList,
31
36
  inferBuiltInRuntimeBackendFromCommand,
@@ -616,6 +621,13 @@ async function main() {
616
621
  delete process.env.CONDUCTOR_BACKEND_WS_URL;
617
622
  process.env.CONDUCTOR_CONFIG = cliArgs.configFile;
618
623
  }
624
+ // RFC 0040: the daemon's binding for a remote-worktree task. Consumed here so
625
+ // it does not leak into the AI's shell (and from there into a nested fire).
626
+ const remoteWorktreeBinding = readRemoteWorktreeBinding(process.env);
627
+ delete process.env[REMOTE_WORKTREE_ENV];
628
+ if (remoteWorktreeBinding) {
629
+ log(`Remote worktree tools bound to ${remoteWorktreeBinding.host}:${remoteWorktreeBinding.cwd}`);
630
+ }
619
631
  let runtimeProjectPath = process.cwd();
620
632
  let backendSession = null;
621
633
 
@@ -1031,6 +1043,13 @@ async function main() {
1031
1043
  resumeSessionId,
1032
1044
  configFile: cliArgs.configFile,
1033
1045
  ...(cliArgs.sessionOptions || {}),
1046
+ ...buildRemoteWorktreeSessionOptions({
1047
+ backend: cliArgs.sessionBackend || cliArgs.backend,
1048
+ binding: remoteWorktreeBinding,
1049
+ sessionOptions: cliArgs.sessionOptions || {},
1050
+ commandLine: sessionCommandLine,
1051
+ configFile: cliArgs.configFile,
1052
+ }),
1034
1053
  ...(sessionCommandLine ? { commandLine: sessionCommandLine } : {}),
1035
1054
  ...(enableGoalsForBackend ? { goalMode: true } : {}),
1036
1055
  logger: { log },
@@ -20,6 +20,7 @@ import { EXIT } from "../src/remote/client.js";
20
20
  import { runRemoteExec, showHelp as showExecHelp } from "../src/remote/exec.js";
21
21
  import { runRemoteCp, showHelp as showCpHelp } from "../src/remote/cp.js";
22
22
  import { runRemoteWait, showHelp as showWaitHelp } from "../src/remote/wait.js";
23
+ import { runRemoteMcp, showHelp as showMcpHelp } from "../src/remote/mcp.js";
23
24
 
24
25
  export { EXIT };
25
26
 
@@ -27,6 +28,7 @@ const VERBS = new Map([
27
28
  ["exec", runRemoteExec],
28
29
  ["cp", runRemoteCp],
29
30
  ["wait", runRemoteWait],
31
+ ["mcp", runRemoteMcp],
30
32
  ]);
31
33
 
32
34
  const isMainModule = (() => {
@@ -45,6 +47,7 @@ Verbs:
45
47
  exec Run a command on another daemon's host
46
48
  cp Copy a file to or from another daemon's host
47
49
  wait Wait for a command that \`exec\` left running (by run id)
50
+ mcp Serve MCP tools bound to a directory on another daemon's host
48
51
 
49
52
  Options:
50
53
  -h, --help Show this help
@@ -59,6 +62,7 @@ For verb-specific help:
59
62
  conductor remote exec --help
60
63
  conductor remote cp --help
61
64
  conductor remote wait --help
65
+ conductor remote mcp --help
62
66
  `);
63
67
  }
64
68
 
@@ -89,7 +93,7 @@ export async function runRemote(argv, deps = {}) {
89
93
  return handler(argv.slice(1), deps);
90
94
  }
91
95
 
92
- export { showExecHelp, showCpHelp, showWaitHelp };
96
+ export { showExecHelp, showCpHelp, showWaitHelp, showMcpHelp };
93
97
 
94
98
  if (isMainModule) {
95
99
  // `process.exitCode` rather than `process.exit()`: writes to a pipe are async,
package/bin/conductor.js CHANGED
@@ -137,7 +137,7 @@ Subcommands:
137
137
  project Manage Conductor projects (list/show/create/...)
138
138
  issue Manage issues (list/show/create/update/start/done)
139
139
  task Manage tasks (create/list/show/send/messages/schedule)
140
- remote Run commands and copy files on another daemon's host (exec/cp/wait)
140
+ remote Run commands and copy files on another daemon's host (exec/cp/wait/mcp)
141
141
 
142
142
  Options:
143
143
  -h, --help Show this help message
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@love-moon/conductor-cli",
3
- "version": "0.14.0",
4
- "gitCommitId": "6def94c",
3
+ "version": "0.15.0",
4
+ "gitCommitId": "a097cd6",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "git+https://github.com/lovemoon-ai/conductor.git"
@@ -24,8 +24,8 @@
24
24
  "test": "node --test test/*.test.js"
25
25
  },
26
26
  "dependencies": {
27
- "@love-moon/ai-sdk": "0.14.0",
28
- "@love-moon/conductor-sdk": "0.14.0",
27
+ "@love-moon/ai-sdk": "0.15.0",
28
+ "@love-moon/conductor-sdk": "0.15.0",
29
29
  "@github/copilot-sdk": "^0.3.0",
30
30
  "chrome-launcher": "^1.2.1",
31
31
  "chrome-remote-interface": "^0.33.0",
@@ -38,7 +38,7 @@
38
38
  },
39
39
  "optionalDependencies": {
40
40
  "@roamhq/wrtc": "^0.10.0",
41
- "@love-moon/chat-web": "0.14.0"
41
+ "@love-moon/chat-web": "0.15.0"
42
42
  },
43
43
  "pnpm": {
44
44
  "onlyBuiltDependencies": [
package/src/daemon.js CHANGED
@@ -44,6 +44,7 @@ import {
44
44
  createRemoteExecHandlers,
45
45
  handleRemoteExecRequest,
46
46
  } from "./remote-exec-handlers.js";
47
+ import { REMOTE_WORKTREE_ENV, remoteWorktreeFireEnv } from "./remote/mcp-launch.js";
47
48
  import {
48
49
  REMOTE_FILE_CAPABILITY,
49
50
  createRemoteFileHandlers,
@@ -792,6 +793,10 @@ export async function collectBackendSessions({
792
793
  cwd: normalizeOptionalString(entry?.cwd),
793
794
  title: normalizeOptionalString(entry?.title),
794
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),
795
800
  });
796
801
  }
797
802
  } catch (error) {
@@ -892,6 +897,7 @@ const PTY_TASK_SCOPED_ENV_KEYS = [
892
897
  "CONDUCTOR_PTY_SESSION_ID",
893
898
  "CONDUCTOR_LAUNCHED_BY_DAEMON",
894
899
  "CONDUCTOR_RESUME_CWD",
900
+ REMOTE_WORKTREE_ENV,
895
901
  ];
896
902
 
897
903
  // Task-scoped env vars a Fire must NEVER inherit from anywhere but this
@@ -3889,6 +3895,10 @@ export function startDaemon(config = {}, deps = {}) {
3889
3895
  "restart_first_message",
3890
3896
  "persistent_round_v1",
3891
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",
3892
3902
  "backend_session_list",
3893
3903
  CUSTOM_COMMANDS_CAPABILITY,
3894
3904
  UPDATE_DAEMON_CAPABILITY,
@@ -5784,6 +5794,9 @@ export function startDaemon(config = {}, deps = {}) {
5784
5794
  let taskDir =
5785
5795
  normalizeOptionalString(launchConfig.cwd) ||
5786
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;
5787
5800
  if (!taskDir) {
5788
5801
  const now = new Date();
5789
5802
  const dayDir = path.join(WORKSPACE_ROOT, formatWorkspaceDate(now));
@@ -5809,7 +5822,7 @@ export function startDaemon(config = {}, deps = {}) {
5809
5822
 
5810
5823
  let launchSpec;
5811
5824
  try {
5812
- launchSpec = resolvePtyLaunchSpec(launchConfig, taskDir);
5825
+ launchSpec = resolvePtyLaunchSpec(launchConfig, shellCwd);
5813
5826
  } catch (error) {
5814
5827
  logError(`Failed to resolve PTY launch config for ${taskId}: ${error?.message || error}`);
5815
5828
  sendAgentCommandAck({
@@ -5838,7 +5851,7 @@ export function startDaemon(config = {}, deps = {}) {
5838
5851
 
5839
5852
  const env = buildPtyTaskEnv(process.env, launchSpec.env);
5840
5853
 
5841
- const logPath = path.join(launchSpec.cwd, "conductor-terminal.log");
5854
+ const logPath = path.join(taskDir, "conductor-terminal.log");
5842
5855
  let logStream;
5843
5856
  try {
5844
5857
  logStream = createWriteStreamFn(logPath, { flags: "a" });
@@ -5874,7 +5887,6 @@ export function startDaemon(config = {}, deps = {}) {
5874
5887
  rejectCreatePtyTaskDuringShutdown(payload, { sendAck: false });
5875
5888
  return;
5876
5889
  }
5877
- const resolvedLogPath = path.join(taskDir, "conductor-terminal.log");
5878
5890
 
5879
5891
  const startedAt = new Date().toISOString();
5880
5892
  const record = {
@@ -5883,7 +5895,8 @@ export function startDaemon(config = {}, deps = {}) {
5883
5895
  ptySessionId,
5884
5896
  projectId,
5885
5897
  taskDir,
5886
- logPath: resolvedLogPath,
5898
+ cwd: launchSpec.cwd,
5899
+ logPath,
5887
5900
  logStream,
5888
5901
  cols: launchSpec.cols,
5889
5902
  rows: launchSpec.rows,
@@ -5904,7 +5917,7 @@ export function startDaemon(config = {}, deps = {}) {
5904
5917
  project_id: projectId,
5905
5918
  pty_session_id: ptySessionId,
5906
5919
  pid: Number.isInteger(pty?.pid) ? pty.pid : null,
5907
- cwd: taskDir,
5920
+ cwd: launchSpec.cwd,
5908
5921
  shell: launchSpec.shell,
5909
5922
  cols: launchSpec.cols,
5910
5923
  rows: launchSpec.rows,
@@ -6111,7 +6124,7 @@ export function startDaemon(config = {}, deps = {}) {
6111
6124
  project_id: record.projectId,
6112
6125
  pty_session_id: record.ptySessionId,
6113
6126
  pid: Number.isInteger(record.pty?.pid) ? record.pty.pid : null,
6114
- cwd: record.taskDir,
6127
+ cwd: record.cwd,
6115
6128
  shell: record.shell,
6116
6129
  cols: record.cols,
6117
6130
  rows: record.rows,
@@ -7812,6 +7825,8 @@ export function startDaemon(config = {}, deps = {}) {
7812
7825
  // backend (dsh/copilot) would see e.g. `claude --model opus` here and
7813
7826
  // mis-apply its flags. Fire treats "" as unset.
7814
7827
  CONDUCTOR_CLI_COMMAND: cliCommand,
7828
+ // RFC 0040: lets fire give the AI tools bound to the remote worktree.
7829
+ ...remoteWorktreeFireEnv(launchConfig),
7815
7830
  };
7816
7831
  if (AGENT_TOKEN) {
7817
7832
  env.CONDUCTOR_AGENT_TOKEN = AGENT_TOKEN;
@@ -8571,6 +8586,7 @@ export function startDaemon(config = {}, deps = {}) {
8571
8586
  // gets an explicit `-e CONDUCTOR_CLI_COMMAND=` that overrides any stale
8572
8587
  // value in the tmux SERVER's global environment (see create_task).
8573
8588
  CONDUCTOR_CLI_COMMAND: cliCommand,
8589
+ ...remoteWorktreeFireEnv(targetLaunchConfig),
8574
8590
  };
8575
8591
  env.CONDUCTOR_RESUME_CWD = resolvedResumeCwd;
8576
8592
  if (AGENT_TOKEN) {
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
 
@@ -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
+ }
@@ -0,0 +1,849 @@
1
+ /**
2
+ * `conductor remote mcp` — a stdio MCP server whose tools act on one directory
3
+ * on another daemon's host (RFC 0040).
4
+ *
5
+ * The AI runs on this daemon, the task's worktree lives on `--host`. Without
6
+ * this server the AI reaches it only through `conductor remote exec -- <shell>`:
7
+ * hand-numbered `sed -n` reads, heredoc/`sed -i` edits that are easy to get
8
+ * wrong, `rg` output cut to the last 64 000 characters, and nothing stopping a
9
+ * forgotten `-t/-w` from editing the local checkout instead. Here the target is
10
+ * fixed at startup and the tools have the shape of the native ones.
11
+ *
12
+ * Nothing new runs on the target: reads, searches and shell commands go through
13
+ * `remote exec`, and edits/writes move whole files with `remote cp`'s transfer
14
+ * (exact bytes, no shell quoting, atomic rename on the target).
15
+ */
16
+
17
+ import fsp from "node:fs/promises";
18
+ import os from "node:os";
19
+ import path from "node:path";
20
+ import process from "node:process";
21
+ import readline from "node:readline";
22
+ import { createRequire } from "node:module";
23
+
24
+ import { EXIT, UsageError, loadCliConfig } from "./client.js";
25
+ import { execRemote, waitForRun } from "./exec.js";
26
+ import { downloadFile, uploadFile } from "./cp.js";
27
+ import { MCP_SERVER_NAME } from "./mcp-launch.js";
28
+
29
+ const require = createRequire(import.meta.url);
30
+ const { version: CLI_VERSION } = require("../../package.json");
31
+
32
+ const PROTOCOL_VERSIONS = ["2025-06-18", "2025-03-26", "2024-11-05"];
33
+
34
+ /** `remote exec` keeps the last 64 000 characters; stay well inside that. */
35
+ const OUTPUT_BUDGET_BYTES = 60_000;
36
+ const READ_DEFAULT_LIMIT = 2000;
37
+ const READ_MAX_LINE_CHARS = 2000;
38
+ const SEARCH_DEFAULT_LIMIT = 100;
39
+ const SEARCH_MAX_LIMIT = 1000;
40
+ const FILE_OP_TIMEOUT_MS = 120_000;
41
+ const BASH_DEFAULT_TIMEOUT_MS = 120_000;
42
+ const BASH_MAX_TIMEOUT_MS = 600_000;
43
+ const DEFAULT_FILE_MODE = 0o644;
44
+
45
+ /** A failure the AI should see as a tool error, not a protocol error. */
46
+ export class ToolError extends Error {}
47
+
48
+ // ---------------------------------------------------------------------------
49
+ // Remote scripts. Arguments are always passed as positional parameters
50
+ // (`bash -c <script> conductor-mcp <args...>`), never interpolated, so no
51
+ // path or pattern is ever re-parsed by a shell.
52
+ // ---------------------------------------------------------------------------
53
+
54
+ const READ_SCRIPT = `f=$1; start=$2; end=$3; budget=$4
55
+ if [ -d "$f" ]; then echo "is a directory: $f" >&2; exit 21; fi
56
+ if [ ! -e "$f" ]; then echo "no such file: $f" >&2; exit 2; fi
57
+ if [ ! -r "$f" ]; then echo "permission denied: $f" >&2; exit 13; fi
58
+ awk 'END { print NR }' "$f" || exit $?
59
+ sed -n "\${start},\${end}p" "$f" | head -c "$budget"`;
60
+
61
+ /**
62
+ * A symlink is refused rather than followed: the daemon's upload renames over
63
+ * the link itself, which would silently turn it into a regular file.
64
+ */
65
+ const SYMLINK_CHECK = `if [ -L "$1" ]; then echo "$1 is a symlink to $(readlink "$1"); edit the target instead" >&2; exit 40; fi`;
66
+
67
+ /** Hash via stdin: given a file name, GNU sha256sum escapes odd names with a leading "\\". */
68
+ const HASH_SCRIPT = `${SYMLINK_CHECK}
69
+ if command -v sha256sum >/dev/null 2>&1; then sha256sum <"$1"; else shasum -a 256 <"$1"; fi`;
70
+
71
+ /** Makes the parent directory and reports the current mode, if the file exists. */
72
+ const PREPARE_WRITE_SCRIPT = `${SYMLINK_CHECK}
73
+ f=$1
74
+ if [ -d "$f" ]; then echo "is a directory: $f" >&2; exit 21; fi
75
+ mkdir -p "$(dirname "$f")" || exit $?
76
+ if [ -e "$f" ]; then stat -c %a "$f" 2>/dev/null || stat -f %Lp "$f"; fi`;
77
+
78
+ /**
79
+ * Runs "$@" on `target` into a temp file, prints "<d|f> <line count>", then one
80
+ * page of it. The search runs from inside the target directory (or a file's
81
+ * parent) on a relative path, because that is the only way ripgrep anchors a
82
+ * glob like `src/**\/*.ts`; the caller turns the relative paths back into
83
+ * absolute ones. Every page re-runs the command, so paging relies on each
84
+ * caller asking for a deterministic order (`--sort`/`--sortr`).
85
+ */
86
+ const PAGE_SCRIPT = `off=$1; lim=$2; budget=$3; nozero=$4; target=$5; shift 5
87
+ if [ -d "$target" ]; then cd "$target" || exit 2; rel=.; kind=d
88
+ elif [ -e "$target" ]; then cd "$(dirname "$target")" || exit 2; rel=$(basename "$target"); kind=f
89
+ else echo "no such file or directory: $target" >&2; exit 2; fi
90
+ tmp=$(mktemp "\${TMPDIR:-/tmp}/conductor-mcp.XXXXXX") || exit 125
91
+ trap 'rm -f "$tmp" "$tmp.f"' EXIT
92
+ "$@" "$rel" >"$tmp"; code=$?
93
+ if [ "$code" -gt 1 ] && [ ! -s "$tmp" ]; then exit "$code"; fi
94
+ if [ "$nozero" = 1 ]; then grep -v ':0$' "$tmp" >"$tmp.f"; mv "$tmp.f" "$tmp"; fi
95
+ echo "$kind $(wc -l <"$tmp" | tr -d ' ')"
96
+ tail -n "+$((off + 1))" "$tmp" | head -n "$lim" | head -c "$budget"`;
97
+
98
+ /** Fallback for `remote_glob` on a host without ripgrep: `$1` pattern, `$2` dir. */
99
+ const GLOB_FALLBACK_SCRIPT = `cd "$2" || exit 2
100
+ shopt -s globstar nullglob dotglob 2>/dev/null
101
+ IFS=$'\\n'
102
+ for f in $1; do [ -f "$f" ] && printf '%s/%s\\n' "$PWD" "$f"; done
103
+ exit 0`;
104
+
105
+ const TOOLS_SCRIPT = `command -v rg >/dev/null 2>&1 && echo rg || echo grep`;
106
+
107
+ // ---------------------------------------------------------------------------
108
+ // Helpers
109
+ // ---------------------------------------------------------------------------
110
+
111
+ const isWindowsStylePath = (value) => /^[A-Za-z]:[\\/]/.test(value) || value.includes("\\");
112
+
113
+ function countOccurrences(text, needle) {
114
+ let count = 0;
115
+ let index = text.indexOf(needle);
116
+ while (index !== -1) {
117
+ count += 1;
118
+ index = text.indexOf(needle, index + needle.length);
119
+ }
120
+ return count;
121
+ }
122
+
123
+ /** Split a page of output into whole lines, dropping a line cut by the byte budget. */
124
+ function splitPage(text, budget) {
125
+ const hitBudget = Buffer.byteLength(text) >= budget - 3;
126
+ const lines = text.split("\n");
127
+ if (lines[lines.length - 1] === "") {
128
+ lines.pop();
129
+ } else if (hitBudget && lines.length > 1) {
130
+ lines.pop();
131
+ }
132
+ return lines;
133
+ }
134
+
135
+ function splitHeader(stdout) {
136
+ const newline = stdout.indexOf("\n");
137
+ if (newline === -1) return { header: stdout.trim(), body: "" };
138
+ return { header: stdout.slice(0, newline).trim(), body: stdout.slice(newline + 1) };
139
+ }
140
+
141
+ function formatNumbered(lines, firstLine) {
142
+ return lines
143
+ .map((line, index) => {
144
+ const shown = line.length > READ_MAX_LINE_CHARS
145
+ ? `${line.slice(0, READ_MAX_LINE_CHARS)}… [line truncated]`
146
+ : line;
147
+ return `${String(firstLine + index).padStart(6)}\t${shown}`;
148
+ })
149
+ .join("\n");
150
+ }
151
+
152
+ function toInteger(value, name, { min, max, fallback }) {
153
+ if (value === undefined || value === null) return fallback;
154
+ const number = Number(value);
155
+ if (!Number.isInteger(number) || number < min || (max !== undefined && number > max)) {
156
+ throw new ToolError(`${name} must be an integer${max !== undefined ? ` between ${min} and ${max}` : ` >= ${min}`}`);
157
+ }
158
+ return number;
159
+ }
160
+
161
+ function requireString(value, name, { allowEmpty = false } = {}) {
162
+ if (typeof value !== "string" || (!allowEmpty && value === "")) {
163
+ throw new ToolError(`${name} is required`);
164
+ }
165
+ return value;
166
+ }
167
+
168
+ function runFailure(run, what) {
169
+ const detail = (run?.stderrTail || run?.error || run?.stdoutTail || "").trim();
170
+ const status = run?.status === "running"
171
+ ? "timed out"
172
+ : `exited ${run?.exitCode ?? run?.status ?? "?"}`;
173
+ return new ToolError(detail ? `${what} failed (${status}): ${detail}` : `${what} failed (${status})`);
174
+ }
175
+
176
+ // ---------------------------------------------------------------------------
177
+ // The workspace: every tool's behaviour, independent of MCP framing.
178
+ // ---------------------------------------------------------------------------
179
+
180
+ /**
181
+ * @param {object} opts
182
+ * @param {string} opts.host daemon the workspace lives on
183
+ * @param {string} opts.root no tool may touch a path outside this directory
184
+ * @param {string} [opts.cwd] relative paths resolve here (default: root)
185
+ * @param {(req: {command: string, args: string[], workspace?: string, timeoutMs: number}) => Promise<object>} opts.exec
186
+ * run one command on the host; resolves with the run record
187
+ * @param {(runId: string, timeoutMs: number) => Promise<object>} opts.wait
188
+ * keep waiting for a run `exec` returned while still running
189
+ * @param {(remotePath: string, localPath: string) => Promise<{sha256: string, mode?: number}>} opts.download
190
+ * @param {(localPath: string, remotePath: string) => Promise<unknown>} opts.upload
191
+ * replaces the remote file atomically, with the local file's mode
192
+ */
193
+ export function createRemoteWorkspace(opts) {
194
+ const { host, exec, wait, download, upload } = opts;
195
+ const pathApi = isWindowsStylePath(opts.root) ? path.win32 : path.posix;
196
+ const root = pathApi.normalize(opts.root);
197
+ const cwd = pathApi.normalize(opts.cwd || opts.root);
198
+ const tmpRoot = opts.tmpDir || os.tmpdir();
199
+ let searchTool = null;
200
+
201
+ function resolvePath(value, name = "file_path") {
202
+ const raw = requireString(value, name).trim();
203
+ const absolute = pathApi.isAbsolute(raw) ? pathApi.normalize(raw) : pathApi.join(cwd, raw);
204
+ const relative = pathApi.relative(root, absolute);
205
+ if (relative === ".." || relative.startsWith(`..${pathApi.sep}`) || pathApi.isAbsolute(relative)) {
206
+ throw new ToolError(`${raw} is outside the remote workspace ${root} on ${host}`);
207
+ }
208
+ return absolute;
209
+ }
210
+
211
+ async function runScript(script, args, { what, timeoutMs = FILE_OP_TIMEOUT_MS, okCodes = [0] } = {}) {
212
+ const run = await exec({ command: "bash", args: ["-c", script, "conductor-mcp", ...args.map(String)], timeoutMs });
213
+ if (run?.status !== "completed" || !okCodes.includes(run.exitCode)) {
214
+ throw runFailure(run, what);
215
+ }
216
+ return run;
217
+ }
218
+
219
+ async function withTempDir(fn) {
220
+ const dir = await fsp.mkdtemp(path.join(tmpRoot, "conductor-mcp-"));
221
+ try {
222
+ return await fn(dir);
223
+ } finally {
224
+ await fsp.rm(dir, { recursive: true, force: true }).catch(() => {});
225
+ }
226
+ }
227
+
228
+ async function remoteSha256(file) {
229
+ const run = await runScript(HASH_SCRIPT, [file], { what: `hashing ${file}` });
230
+ return String(run.stdoutTail || "").trim().split(/\s+/)[0] || "";
231
+ }
232
+
233
+ async function read({ file_path: filePath, offset, limit } = {}) {
234
+ const file = resolvePath(filePath);
235
+ const start = toInteger(offset, "offset", { min: 1, fallback: 1 });
236
+ const count = toInteger(limit, "limit", { min: 1, fallback: READ_DEFAULT_LIMIT });
237
+ const run = await runScript(READ_SCRIPT, [file, start, start + count - 1, OUTPUT_BUDGET_BYTES], {
238
+ what: `reading ${file}`,
239
+ });
240
+ const { header, body } = splitHeader(String(run.stdoutTail || ""));
241
+ const total = Number.parseInt(header, 10) || 0;
242
+ if (total === 0) return `(${file} is empty)`;
243
+ if (start > total) {
244
+ throw new ToolError(`offset ${start} is past the end of ${file} (${total} lines)`);
245
+ }
246
+ if (body.includes("\u0000")) {
247
+ throw new ToolError(`${file} looks like a binary file; use remote_bash to inspect it`);
248
+ }
249
+ const lines = splitPage(body, OUTPUT_BUDGET_BYTES);
250
+ const last = start + lines.length - 1;
251
+ let text = formatNumbered(lines, start);
252
+ if (last < total) {
253
+ text += `\n\n[Showing lines ${start}-${last} of ${total}. Continue with offset=${last + 1}.]`;
254
+ }
255
+ return text;
256
+ }
257
+
258
+ async function edit({ file_path: filePath, old_string: oldRaw, new_string: newRaw, replace_all: replaceAll } = {}) {
259
+ const file = resolvePath(filePath);
260
+ let oldString = requireString(oldRaw, "old_string", { allowEmpty: true });
261
+ let newString = requireString(newRaw, "new_string", { allowEmpty: true });
262
+ if (oldString === "") {
263
+ throw new ToolError("old_string must not be empty; use remote_write to create or overwrite a file");
264
+ }
265
+ if (oldString === newString) {
266
+ throw new ToolError("old_string and new_string are identical; nothing to change");
267
+ }
268
+
269
+ return withTempDir(async (dir) => {
270
+ const local = path.join(dir, "file");
271
+ const original = await download(file, local);
272
+ const bytes = await fsp.readFile(local);
273
+ if (bytes.includes(0)) {
274
+ throw new ToolError(`${file} looks like a binary file; refusing to edit it`);
275
+ }
276
+ const text = bytes.toString("utf8");
277
+ if (!Buffer.from(text, "utf8").equals(bytes)) {
278
+ throw new ToolError(`${file} is not valid UTF-8; refusing to edit it`);
279
+ }
280
+
281
+ let matches = countOccurrences(text, oldString);
282
+ // A CRLF file edited with LF snippets: match the file's own line endings.
283
+ if (matches === 0 && text.includes("\r\n") && oldString.includes("\n") && !oldString.includes("\r\n")) {
284
+ const crlf = (value) => value.replace(/\r?\n/g, "\r\n");
285
+ oldString = crlf(oldString);
286
+ newString = crlf(newString);
287
+ matches = countOccurrences(text, oldString);
288
+ }
289
+ if (matches === 0) {
290
+ throw new ToolError(`old_string was not found in ${file}`);
291
+ }
292
+ if (matches > 1 && replaceAll !== true) {
293
+ throw new ToolError(
294
+ `old_string matches ${matches} places in ${file}; add surrounding context to make it unique, or set replace_all`,
295
+ );
296
+ }
297
+
298
+ const firstIndex = text.indexOf(oldString);
299
+ const updated = replaceAll === true
300
+ ? text.split(oldString).join(newString)
301
+ : text.slice(0, firstIndex) + newString + text.slice(firstIndex + oldString.length);
302
+ await fsp.writeFile(local, updated);
303
+
304
+ // Optimistic concurrency: refuse to overwrite a change made since we read.
305
+ const current = await remoteSha256(file);
306
+ if (current !== original.sha256) {
307
+ throw new ToolError(`${file} changed on ${host} while it was being edited; read it again and retry`);
308
+ }
309
+ await fsp.chmod(local, Number.isInteger(original.mode) ? original.mode : DEFAULT_FILE_MODE);
310
+ await upload(local, file);
311
+
312
+ const replaced = replaceAll === true ? matches : 1;
313
+ const firstLine = text.slice(0, firstIndex).split("\n").length;
314
+ const updatedLines = updated.split("\n");
315
+ if (updated.endsWith("\n")) updatedLines.pop();
316
+ const from = Math.max(1, firstLine - 3);
317
+ const to = Math.min(updatedLines.length, firstLine + newString.split("\n").length + 2);
318
+ return (
319
+ `Edited ${file} on ${host}: replaced ${replaced} occurrence${replaced === 1 ? "" : "s"}.\n` +
320
+ formatNumbered(updatedLines.slice(from - 1, to), from)
321
+ );
322
+ });
323
+ }
324
+
325
+ async function write({ file_path: filePath, content } = {}) {
326
+ const file = resolvePath(filePath);
327
+ const body = requireString(content, "content", { allowEmpty: true });
328
+ const prepared = await runScript(PREPARE_WRITE_SCRIPT, [file], { what: `preparing ${file}` });
329
+ const modeText = String(prepared.stdoutTail || "").trim();
330
+ const existed = /^[0-7]{3,4}$/.test(modeText);
331
+ const mode = existed ? Number.parseInt(modeText, 8) & 0o777 : DEFAULT_FILE_MODE;
332
+
333
+ return withTempDir(async (dir) => {
334
+ const local = path.join(dir, "file");
335
+ await fsp.writeFile(local, body);
336
+ await fsp.chmod(local, mode);
337
+ await upload(local, file);
338
+ return `${existed ? "Updated" : "Created"} ${file} on ${host} (${Buffer.byteLength(body)} bytes).`;
339
+ });
340
+ }
341
+
342
+ async function resolveSearchTool() {
343
+ if (!searchTool) {
344
+ searchTool = runScript(TOOLS_SCRIPT, [], { what: "probing for ripgrep" })
345
+ .then((run) => (String(run.stdoutTail || "").trim() === "rg" ? "rg" : "grep"))
346
+ .catch((error) => {
347
+ searchTool = null;
348
+ throw error;
349
+ });
350
+ }
351
+ return searchTool;
352
+ }
353
+
354
+ /** Run a search command on `target` and return one page, with absolute paths. */
355
+ async function page(command, { target, offset, limit, nozero = false }) {
356
+ const run = await runScript(
357
+ PAGE_SCRIPT,
358
+ [offset, limit, OUTPUT_BUDGET_BYTES, nozero ? 1 : 0, target, ...command],
359
+ { what: `searching ${target}` },
360
+ );
361
+ const { header, body } = splitHeader(String(run.stdoutTail || ""));
362
+ const [kind, count] = header.split(" ");
363
+ const total = Number.parseInt(count, 10) || 0;
364
+ const dir = kind === "f" ? pathApi.dirname(target) : target;
365
+ const prefix = dir.endsWith(pathApi.sep) ? dir : `${dir}${pathApi.sep}`;
366
+ // `--` separates context groups; everything else starts with a path that
367
+ // is relative to `dir` (`./x` from GNU tools and rg, bare `x` from BSD grep).
368
+ const lines = (total > offset ? splitPage(body, OUTPUT_BUDGET_BYTES) : [])
369
+ .map((line) => (line === "--" || pathApi.isAbsolute(line) ? line : prefix + line.replace(/^\.\//, "")));
370
+ return { total, lines };
371
+ }
372
+
373
+ function pageFooter(offset, shown, total) {
374
+ if (offset + shown >= total) return "";
375
+ return `\n\n[Showing results ${offset + 1}-${offset + shown} of ${total}. Continue with offset=${offset + shown}.]`;
376
+ }
377
+
378
+ async function grep(input = {}) {
379
+ const pattern = requireString(input.pattern, "pattern");
380
+ const searchPath = input.path === undefined ? cwd : resolvePath(input.path, "path");
381
+ const mode = input.output_mode ?? "files_with_matches";
382
+ if (!["files_with_matches", "content", "count"].includes(mode)) {
383
+ throw new ToolError("output_mode must be one of files_with_matches, content, count");
384
+ }
385
+ const offset = toInteger(input.offset, "offset", { min: 0, fallback: 0 });
386
+ const limit = toInteger(input.head_limit, "head_limit", { min: 1, max: SEARCH_MAX_LIMIT, fallback: SEARCH_DEFAULT_LIMIT });
387
+ const context = toInteger(input.context, "context", { min: 0, max: 20, fallback: 0 });
388
+ const tool = await resolveSearchTool();
389
+
390
+ let command;
391
+ const notes = [];
392
+ if (tool === "rg") {
393
+ command = ["rg", "--color=never", "--no-messages", "--hidden", "--glob", "!.git"];
394
+ if (mode === "files_with_matches") command.push("--files-with-matches", "--sortr=modified");
395
+ if (mode === "count") command.push("--count", "--with-filename", "--sort=path");
396
+ if (mode === "content") {
397
+ command.push("--line-number", "--with-filename", "--no-heading", "--max-columns=500", "--max-columns-preview", "--sort=path");
398
+ if (context > 0) command.push("--context", String(context));
399
+ }
400
+ if (input.case_insensitive === true) command.push("--ignore-case");
401
+ if (input.glob) command.push("--glob", requireString(input.glob, "glob"));
402
+ if (input.type) command.push("--type", requireString(input.type, "type"));
403
+ command.push("-e", pattern, "--");
404
+ } else {
405
+ notes.push(`[ripgrep is not installed on ${host}; used grep -E, which ignores .gitignore]`);
406
+ command = ["grep", "-r", "-I", "-E", "--exclude-dir=.git"];
407
+ if (mode === "files_with_matches") command.push("-l");
408
+ if (mode === "count") command.push("-c", "-H");
409
+ if (mode === "content") {
410
+ command.push("-n", "-H");
411
+ if (context > 0) command.push("-C", String(context));
412
+ }
413
+ if (input.case_insensitive === true) command.push("-i");
414
+ if (input.glob) command.push(`--include=${requireString(input.glob, "glob")}`);
415
+ if (input.type) notes.push("[type is not supported without ripgrep and was ignored]");
416
+ command.push("-e", pattern, "--");
417
+ }
418
+
419
+ const { total, lines } = await page(command, {
420
+ target: searchPath, offset, limit, nozero: tool === "grep" && mode === "count",
421
+ });
422
+ const prefix = notes.length > 0 ? `${notes.join("\n")}\n` : "";
423
+ if (total === 0) return `${prefix}No matches found.`;
424
+ if (lines.length === 0) return `${prefix}offset ${offset} is past the last result (${total} results).`;
425
+ const heading = mode === "files_with_matches" ? `Found ${total} file${total === 1 ? "" : "s"}\n` : "";
426
+ return `${prefix}${heading}${lines.join("\n")}${pageFooter(offset, lines.length, total)}`;
427
+ }
428
+
429
+ async function glob(input = {}) {
430
+ const searchPath = input.path === undefined ? cwd : resolvePath(input.path, "path");
431
+ let pattern = requireString(input.pattern, "pattern");
432
+ // Globs match relative to the search path, so an absolute one never would.
433
+ if (pathApi.isAbsolute(pattern)) {
434
+ const relative = pathApi.relative(searchPath, pattern);
435
+ if (!relative || relative.startsWith("..") || pathApi.isAbsolute(relative)) {
436
+ throw new ToolError(`pattern ${pattern} is not under ${searchPath}`);
437
+ }
438
+ pattern = relative;
439
+ }
440
+ const offset = toInteger(input.offset, "offset", { min: 0, fallback: 0 });
441
+ const limit = toInteger(input.head_limit, "head_limit", { min: 1, max: SEARCH_MAX_LIMIT, fallback: SEARCH_DEFAULT_LIMIT });
442
+ const tool = await resolveSearchTool();
443
+ const command = tool === "rg"
444
+ ? ["rg", "--files", "--hidden", "--glob", "!.git", "--glob", pattern, "--sortr=modified", "--color=never", "--"]
445
+ : ["bash", "-c", GLOB_FALLBACK_SCRIPT, "conductor-mcp-glob", pattern];
446
+ const { total, lines } = await page(command, { target: searchPath, offset, limit });
447
+ if (total === 0) return "No files found.";
448
+ if (lines.length === 0) return `offset ${offset} is past the last result (${total} files).`;
449
+ return `${lines.join("\n")}${pageFooter(offset, lines.length, total)}`;
450
+ }
451
+
452
+ async function bash(input = {}) {
453
+ const timeoutMs = toInteger(input.timeout_ms, "timeout_ms", {
454
+ min: 1000, max: BASH_MAX_TIMEOUT_MS, fallback: BASH_DEFAULT_TIMEOUT_MS,
455
+ });
456
+ let run;
457
+ if (input.run_id !== undefined) {
458
+ run = await wait(requireString(input.run_id, "run_id"), timeoutMs);
459
+ } else {
460
+ const command = requireString(input.command, "command");
461
+ try {
462
+ run = await exec({ command: "bash", args: ["-lc", command], workspace: cwd, timeoutMs });
463
+ } catch (error) {
464
+ throw new ToolError(
465
+ `${error.message} (remote_bash runs in ${cwd} on ${host}; if the task worktree has not been created yet, create it first)`,
466
+ );
467
+ }
468
+ }
469
+
470
+ const parts = [];
471
+ if (run?.stdoutTail) parts.push(run.stdoutTail.replace(/\n$/, ""));
472
+ if (run?.stderrTail) parts.push(`[stderr]\n${run.stderrTail.replace(/\n$/, "")}`);
473
+ if (run?.truncated) parts.push("[output truncated; showing the tail only]");
474
+ if (run?.error) parts.push(`[error] ${run.error}`);
475
+ if (run?.status === "running") {
476
+ parts.push(
477
+ `[still running on ${host} after ${Math.round(timeoutMs / 1000)}s; ` +
478
+ `call remote_bash with run_id="${run.runId}" to keep waiting]`,
479
+ );
480
+ return { text: parts.join("\n"), isError: false };
481
+ }
482
+ const exitCode = typeof run?.exitCode === "number" ? run.exitCode : null;
483
+ parts.push(`[exit code ${exitCode ?? run?.status ?? "unknown"}]`);
484
+ return { text: parts.join("\n"), isError: exitCode !== 0 };
485
+ }
486
+
487
+ return { host, root, cwd, resolvePath, read, edit, write, grep, glob, bash };
488
+ }
489
+
490
+ // ---------------------------------------------------------------------------
491
+ // Tool catalogue
492
+ // ---------------------------------------------------------------------------
493
+
494
+ export function buildRemoteWorkspaceTools(workspace) {
495
+ const where = `on daemon "${workspace.host}"`;
496
+ const pathNote = `Relative paths resolve against ${workspace.cwd}; paths outside ${workspace.root} are refused.`;
497
+ return [
498
+ {
499
+ name: "remote_read",
500
+ description: `Read a file in the remote workspace ${where}, with line numbers (cat -n format). ${pathNote}`,
501
+ inputSchema: {
502
+ type: "object",
503
+ properties: {
504
+ file_path: { type: "string", description: "File to read" },
505
+ offset: { type: "integer", minimum: 1, description: "Line number to start at (default 1)" },
506
+ limit: { type: "integer", minimum: 1, description: `Number of lines to read (default ${READ_DEFAULT_LIMIT})` },
507
+ },
508
+ required: ["file_path"],
509
+ additionalProperties: false,
510
+ },
511
+ handler: workspace.read,
512
+ },
513
+ {
514
+ name: "remote_edit",
515
+ description:
516
+ `Replace an exact string in a file in the remote workspace ${where}. Fails without writing if ` +
517
+ `old_string is missing or matches more than once (unless replace_all). ${pathNote}`,
518
+ inputSchema: {
519
+ type: "object",
520
+ properties: {
521
+ file_path: { type: "string", description: "File to edit" },
522
+ old_string: { type: "string", description: "Exact text to replace" },
523
+ new_string: { type: "string", description: "Replacement text" },
524
+ replace_all: { type: "boolean", description: "Replace every occurrence (default false)" },
525
+ },
526
+ required: ["file_path", "old_string", "new_string"],
527
+ additionalProperties: false,
528
+ },
529
+ handler: workspace.edit,
530
+ },
531
+ {
532
+ name: "remote_write",
533
+ description: `Create or overwrite a file in the remote workspace ${where}; parent directories are created. ${pathNote}`,
534
+ inputSchema: {
535
+ type: "object",
536
+ properties: {
537
+ file_path: { type: "string", description: "File to write" },
538
+ content: { type: "string", description: "Full file content" },
539
+ },
540
+ required: ["file_path", "content"],
541
+ additionalProperties: false,
542
+ },
543
+ handler: workspace.write,
544
+ },
545
+ {
546
+ name: "remote_grep",
547
+ description:
548
+ `Search file contents in the remote workspace ${where} with ripgrep regex syntax. ` +
549
+ `Results are paged: pass offset to continue. ${pathNote}`,
550
+ inputSchema: {
551
+ type: "object",
552
+ properties: {
553
+ pattern: { type: "string", description: "Regular expression" },
554
+ path: { type: "string", description: "File or directory to search (default: the work dir)" },
555
+ glob: { type: "string", description: 'Only files matching this glob, e.g. "*.ts"' },
556
+ type: { type: "string", description: 'ripgrep file type, e.g. "js", "py"' },
557
+ output_mode: {
558
+ type: "string",
559
+ enum: ["files_with_matches", "content", "count"],
560
+ description: "files_with_matches (default), content (matching lines), or count",
561
+ },
562
+ case_insensitive: { type: "boolean" },
563
+ context: { type: "integer", minimum: 0, maximum: 20, description: "Lines of context in content mode" },
564
+ offset: { type: "integer", minimum: 0, description: "Results to skip (default 0)" },
565
+ head_limit: { type: "integer", minimum: 1, maximum: SEARCH_MAX_LIMIT, description: `Results per page (default ${SEARCH_DEFAULT_LIMIT})` },
566
+ },
567
+ required: ["pattern"],
568
+ additionalProperties: false,
569
+ },
570
+ handler: workspace.grep,
571
+ },
572
+ {
573
+ name: "remote_glob",
574
+ description: `Find files by glob pattern (e.g. "src/**/*.ts") in the remote workspace ${where}, newest first. ${pathNote}`,
575
+ inputSchema: {
576
+ type: "object",
577
+ properties: {
578
+ pattern: { type: "string", description: "Glob pattern" },
579
+ path: { type: "string", description: "Directory to search (default: the work dir)" },
580
+ offset: { type: "integer", minimum: 0, description: "Results to skip (default 0)" },
581
+ head_limit: { type: "integer", minimum: 1, maximum: SEARCH_MAX_LIMIT, description: `Results per page (default ${SEARCH_DEFAULT_LIMIT})` },
582
+ },
583
+ required: ["pattern"],
584
+ additionalProperties: false,
585
+ },
586
+ handler: workspace.glob,
587
+ },
588
+ {
589
+ name: "remote_bash",
590
+ description:
591
+ `Run a bash command in ${workspace.cwd} ${where} (git, builds, tests). Output keeps the last 64 000 ` +
592
+ `characters. A command still running at timeout_ms keeps going; call again with the returned run_id to wait more.`,
593
+ inputSchema: {
594
+ type: "object",
595
+ properties: {
596
+ command: { type: "string", description: "Command to run with bash -lc" },
597
+ timeout_ms: {
598
+ type: "integer",
599
+ minimum: 1000,
600
+ maximum: BASH_MAX_TIMEOUT_MS,
601
+ description: `How long to wait (default ${BASH_DEFAULT_TIMEOUT_MS})`,
602
+ },
603
+ run_id: { type: "string", description: "Keep waiting for a command that was still running" },
604
+ },
605
+ additionalProperties: false,
606
+ },
607
+ handler: workspace.bash,
608
+ },
609
+ ];
610
+ }
611
+
612
+ export function buildServerInstructions(workspace) {
613
+ return (
614
+ `The task workspace lives on daemon "${workspace.host}" at ${workspace.cwd}, not on this machine. ` +
615
+ "Use these remote_* tools to read, edit, write and search files there, and remote_bash for git, builds and tests. " +
616
+ "Local file tools and local shell commands do not see that workspace."
617
+ );
618
+ }
619
+
620
+ // ---------------------------------------------------------------------------
621
+ // MCP framing (JSON-RPC 2.0 over newline-delimited stdio)
622
+ // ---------------------------------------------------------------------------
623
+
624
+ export function createMcpServer({ tools, instructions, name = MCP_SERVER_NAME, version = CLI_VERSION }) {
625
+ const byName = new Map(tools.map((tool) => [tool.name, tool]));
626
+
627
+ async function callTool(params) {
628
+ const tool = byName.get(params?.name);
629
+ if (!tool) {
630
+ return { content: [{ type: "text", text: `unknown tool: ${params?.name}` }], isError: true };
631
+ }
632
+ try {
633
+ const output = await tool.handler(params?.arguments ?? {});
634
+ const { text, isError = false } = typeof output === "string" ? { text: output } : output;
635
+ return { content: [{ type: "text", text }], isError };
636
+ } catch (error) {
637
+ return { content: [{ type: "text", text: `Error: ${error?.message || error}` }], isError: true };
638
+ }
639
+ }
640
+
641
+ async function dispatch(method, params) {
642
+ switch (method) {
643
+ case "initialize": {
644
+ const requested = params?.protocolVersion;
645
+ return {
646
+ protocolVersion: PROTOCOL_VERSIONS.includes(requested) ? requested : PROTOCOL_VERSIONS[0],
647
+ capabilities: { tools: { listChanged: false } },
648
+ serverInfo: { name, version },
649
+ ...(instructions ? { instructions } : {}),
650
+ };
651
+ }
652
+ case "ping":
653
+ return {};
654
+ case "tools/list":
655
+ return {
656
+ tools: tools.map(({ name: toolName, description, inputSchema }) => ({ name: toolName, description, inputSchema })),
657
+ };
658
+ case "tools/call":
659
+ return callTool(params);
660
+ default: {
661
+ const error = new Error(`method not found: ${method}`);
662
+ error.code = -32601;
663
+ throw error;
664
+ }
665
+ }
666
+ }
667
+
668
+ /** Returns the response to write, or null for a notification. */
669
+ async function handleMessage(message) {
670
+ if (!message || typeof message !== "object" || message.jsonrpc !== "2.0" || typeof message.method !== "string") {
671
+ if (message && typeof message === "object" && ("result" in message || "error" in message)) {
672
+ return null; // a response to a request we never send
673
+ }
674
+ return { jsonrpc: "2.0", id: message?.id ?? null, error: { code: -32600, message: "invalid request" } };
675
+ }
676
+ const isNotification = message.id === undefined || message.id === null;
677
+ if (isNotification) return null;
678
+ try {
679
+ return { jsonrpc: "2.0", id: message.id, result: await dispatch(message.method, message.params) };
680
+ } catch (error) {
681
+ return {
682
+ jsonrpc: "2.0",
683
+ id: message.id,
684
+ error: { code: typeof error?.code === "number" ? error.code : -32603, message: error?.message || String(error) },
685
+ };
686
+ }
687
+ }
688
+
689
+ return { handleMessage };
690
+ }
691
+
692
+ /** Serve until `input` ends. Requests run concurrently; responses go out as they finish. */
693
+ export async function serveStdio(server, { input = process.stdin, output = process.stdout } = {}) {
694
+ const lines = readline.createInterface({ input, crlfDelay: Infinity });
695
+ const inFlight = new Set();
696
+ const send = (message) => {
697
+ if (message) output.write(`${JSON.stringify(message)}\n`);
698
+ };
699
+
700
+ for await (const line of lines) {
701
+ if (!line.trim()) continue;
702
+ let message;
703
+ try {
704
+ message = JSON.parse(line);
705
+ } catch {
706
+ send({ jsonrpc: "2.0", id: null, error: { code: -32700, message: "parse error" } });
707
+ continue;
708
+ }
709
+ const pending = server.handleMessage(message).then(send, (error) => {
710
+ send({ jsonrpc: "2.0", id: message?.id ?? null, error: { code: -32603, message: String(error?.message || error) } });
711
+ });
712
+ inFlight.add(pending);
713
+ pending.finally(() => inFlight.delete(pending));
714
+ }
715
+ await Promise.allSettled([...inFlight]);
716
+ }
717
+
718
+ // ---------------------------------------------------------------------------
719
+ // CLI
720
+ // ---------------------------------------------------------------------------
721
+
722
+ const VALUE_FLAGS = new Map([
723
+ ["--host", "host"],
724
+ ["--target", "host"],
725
+ ["-t", "host"],
726
+ ["--root", "root"],
727
+ ["--cwd", "cwd"],
728
+ ["--config-file", "configFile"],
729
+ ]);
730
+
731
+ export function parseMcpArgs(argv) {
732
+ const options = { help: false };
733
+ for (let index = 0; index < argv.length; index += 1) {
734
+ const token = argv[index];
735
+ if (token === "--help" || token === "-h") {
736
+ options.help = true;
737
+ continue;
738
+ }
739
+ const splitAt = token.startsWith("--") ? token.indexOf("=") : -1;
740
+ const name = splitAt > 0 ? token.slice(0, splitAt) : token;
741
+ if (!VALUE_FLAGS.has(name)) {
742
+ throw new UsageError(`unknown option: ${token}`);
743
+ }
744
+ const value = splitAt > 0 ? token.slice(splitAt + 1) : argv[index + 1];
745
+ if (value === undefined || value === "") {
746
+ throw new UsageError(`${name} requires a value`);
747
+ }
748
+ options[VALUE_FLAGS.get(name)] = value;
749
+ if (splitAt <= 0) index += 1;
750
+ }
751
+ return options;
752
+ }
753
+
754
+ export function showHelp(consoleImpl = console) {
755
+ consoleImpl.log(`conductor remote mcp - serve MCP tools bound to a directory on another daemon
756
+
757
+ Usage:
758
+ conductor remote mcp --host <daemon> --root <dir> [--cwd <dir>] [--config-file <path>]
759
+
760
+ Options:
761
+ -t, --host <daemon> Daemon the workspace lives on (required)
762
+ --root <dir> Absolute directory on that daemon; no tool may leave it (required)
763
+ --cwd <dir> Where relative paths and remote_bash run (default: --root)
764
+ --config-file <p> Conductor config file to authenticate with
765
+ -h, --help Show this help
766
+
767
+ Speaks MCP over stdin/stdout. Tools: remote_read, remote_edit, remote_write,
768
+ remote_grep, remote_glob, remote_bash. Fire starts it automatically for tasks
769
+ whose worktree lives on another daemon.
770
+ `);
771
+ }
772
+
773
+ export async function runRemoteMcp(argv, deps = {}) {
774
+ const consoleImpl = deps.console || console;
775
+ const env = deps.env || process.env;
776
+ const fetchImpl = deps.fetch || globalThis.fetch;
777
+
778
+ let options;
779
+ try {
780
+ options = parseMcpArgs(argv);
781
+ } catch (error) {
782
+ consoleImpl.error(`Error: ${error.message}`);
783
+ return EXIT.CLI_ERROR;
784
+ }
785
+ if (options.help) {
786
+ // stdout is the protocol channel only when serving; help is fine there.
787
+ showHelp(consoleImpl);
788
+ return EXIT.OK;
789
+ }
790
+
791
+ const host = typeof options.host === "string" ? options.host.trim() : "";
792
+ const root = typeof options.root === "string" ? options.root.trim() : "";
793
+ if (!host || !root) {
794
+ consoleImpl.error("Error: --host <daemon> and --root <dir> are required");
795
+ return EXIT.CLI_ERROR;
796
+ }
797
+ const pathApi = isWindowsStylePath(root) ? path.win32 : path.posix;
798
+ if (!pathApi.isAbsolute(root) || (options.cwd && !pathApi.isAbsolute(options.cwd))) {
799
+ consoleImpl.error("Error: --root and --cwd must be absolute paths on the target");
800
+ return EXIT.CLI_ERROR;
801
+ }
802
+ if (options.cwd) {
803
+ const relative = pathApi.relative(pathApi.normalize(root), pathApi.normalize(options.cwd));
804
+ if (relative === ".." || relative.startsWith(`..${pathApi.sep}`) || pathApi.isAbsolute(relative)) {
805
+ consoleImpl.error("Error: --cwd must be inside --root");
806
+ return EXIT.CLI_ERROR;
807
+ }
808
+ }
809
+
810
+ let config;
811
+ try {
812
+ config = deps.config || loadCliConfig(options.configFile, env);
813
+ } catch (error) {
814
+ consoleImpl.error(`Error: ${error.message}`);
815
+ return EXIT.CLI_ERROR;
816
+ }
817
+
818
+ // Everything below must stay off stdout: it is the MCP channel.
819
+ const quietConsole = { log: () => {}, error: () => {} };
820
+ const basePath = `/api/agents/${encodeURIComponent(host)}/exec`;
821
+ const workspace = createRemoteWorkspace({
822
+ host,
823
+ root,
824
+ cwd: options.cwd,
825
+ exec: async ({ command, args, workspace: dir, timeoutMs }) =>
826
+ (await execRemote(config, host, command, { args, workspace: dir, timeoutMs, fetchImpl })).run,
827
+ wait: async (runId, timeoutMs) =>
828
+ (await waitForRun(config, basePath, { runId, status: "running" }, {
829
+ deadline: Date.now() + timeoutMs, fetchImpl,
830
+ })).run,
831
+ download: (remotePath, localPath) =>
832
+ downloadFile({
833
+ config, target: host, remotePath, localPath, fetchImpl, consoleImpl: quietConsole, quiet: true,
834
+ deadline: Date.now() + FILE_OP_TIMEOUT_MS, onProgress: () => {},
835
+ }),
836
+ upload: (localPath, remotePath) =>
837
+ uploadFile({
838
+ config, target: host, localPath, remotePath, fetchImpl, consoleImpl: quietConsole, quiet: true,
839
+ env, deadline: Date.now() + FILE_OP_TIMEOUT_MS, onProgress: () => {},
840
+ }),
841
+ });
842
+
843
+ const server = createMcpServer({
844
+ tools: buildRemoteWorkspaceTools(workspace),
845
+ instructions: buildServerInstructions(workspace),
846
+ });
847
+ await serveStdio(server, { input: deps.stdin || process.stdin, output: deps.stdout || process.stdout });
848
+ return EXIT.OK;
849
+ }