@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 +42 -0
- package/bin/conductor-fire.js +19 -0
- package/bin/conductor-remote.js +5 -1
- package/bin/conductor.js +1 -1
- package/package.json +5 -5
- package/src/daemon.js +22 -6
- package/src/remote/cp.js +2 -2
- package/src/remote/mcp-launch.js +167 -0
- package/src/remote/mcp.js +849 -0
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
|
package/bin/conductor-fire.js
CHANGED
|
@@ -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 },
|
package/bin/conductor-remote.js
CHANGED
|
@@ -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.
|
|
4
|
-
"gitCommitId": "
|
|
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.
|
|
28
|
-
"@love-moon/conductor-sdk": "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.
|
|
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,
|
|
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(
|
|
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
|
-
|
|
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:
|
|
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.
|
|
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
|
+
}
|