Skip to content

Commit 229cfe7

Browse files
committed
feat: Add LifecycleEventManager, RpcHandlerDispatcher, and SessionRequestBuilder
- Implement LifecycleEventManager for managing lifecycle event subscriptions and dispatching. - Create RpcHandlerDispatcher to handle incoming JSON-RPC method calls for session events, tool calls, permission requests, user input requests, hooks invocations, and lifecycle events. - Introduce SessionRequestBuilder to construct JSON-RPC request objects from session configurations for session creation and resumption.
1 parent a2104f2 commit 229cfe7

5 files changed

Lines changed: 844 additions & 583 deletions

File tree

Lines changed: 237 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,237 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* Copyright (c) Microsoft Corporation. All rights reserved.
3+
*--------------------------------------------------------------------------------------------*/
4+
5+
package com.github.copilot.sdk;
6+
7+
import java.io.BufferedReader;
8+
import java.io.File;
9+
import java.io.IOException;
10+
import java.io.InputStreamReader;
11+
import java.net.Socket;
12+
import java.net.URI;
13+
import java.nio.charset.StandardCharsets;
14+
import java.util.ArrayList;
15+
import java.util.Arrays;
16+
import java.util.List;
17+
import java.util.logging.Level;
18+
import java.util.logging.Logger;
19+
import java.util.regex.Matcher;
20+
import java.util.regex.Pattern;
21+
22+
import com.github.copilot.sdk.json.CopilotClientOptions;
23+
24+
/**
25+
* Manages the lifecycle of the Copilot CLI server process.
26+
* <p>
27+
* This class handles spawning the CLI server process, building command lines,
28+
* detecting the listening port, and establishing connections.
29+
*/
30+
final class CliServerManager {
31+
32+
private static final Logger LOG = Logger.getLogger(CliServerManager.class.getName());
33+
34+
private final CopilotClientOptions options;
35+
36+
CliServerManager(CopilotClientOptions options) {
37+
this.options = options;
38+
}
39+
40+
/**
41+
* Starts the CLI server process.
42+
*
43+
* @return information about the started process including detected port
44+
* @throws IOException
45+
* if the process cannot be started
46+
* @throws InterruptedException
47+
* if interrupted while waiting for port detection
48+
*/
49+
ProcessInfo startCliServer() throws IOException, InterruptedException {
50+
String cliPath = options.getCliPath() != null ? options.getCliPath() : "copilot";
51+
var args = new ArrayList<String>();
52+
53+
if (options.getCliArgs() != null) {
54+
args.addAll(Arrays.asList(options.getCliArgs()));
55+
}
56+
57+
args.add("--server");
58+
args.add("--log-level");
59+
args.add(options.getLogLevel());
60+
61+
if (options.isUseStdio()) {
62+
args.add("--stdio");
63+
} else if (options.getPort() > 0) {
64+
args.add("--port");
65+
args.add(String.valueOf(options.getPort()));
66+
}
67+
68+
// Add auth-related flags
69+
if (options.getGithubToken() != null && !options.getGithubToken().isEmpty()) {
70+
args.add("--auth-token-env");
71+
args.add("COPILOT_SDK_AUTH_TOKEN");
72+
}
73+
74+
// Default UseLoggedInUser to false when GithubToken is provided
75+
boolean useLoggedInUser = options.getUseLoggedInUser() != null
76+
? options.getUseLoggedInUser()
77+
: (options.getGithubToken() == null || options.getGithubToken().isEmpty());
78+
if (!useLoggedInUser) {
79+
args.add("--no-auto-login");
80+
}
81+
82+
List<String> command = resolveCliCommand(cliPath, args);
83+
84+
var pb = new ProcessBuilder(command);
85+
pb.redirectErrorStream(false);
86+
87+
if (options.getCwd() != null) {
88+
pb.directory(new File(options.getCwd()));
89+
}
90+
91+
if (options.getEnvironment() != null) {
92+
pb.environment().clear();
93+
pb.environment().putAll(options.getEnvironment());
94+
}
95+
pb.environment().remove("NODE_DEBUG");
96+
97+
// Set auth token in environment if provided
98+
if (options.getGithubToken() != null && !options.getGithubToken().isEmpty()) {
99+
pb.environment().put("COPILOT_SDK_AUTH_TOKEN", options.getGithubToken());
100+
}
101+
102+
Process process = pb.start();
103+
104+
// Forward stderr to logger in background
105+
startStderrReader(process);
106+
107+
Integer detectedPort = null;
108+
if (!options.isUseStdio()) {
109+
detectedPort = waitForPortAnnouncement(process);
110+
}
111+
112+
return new ProcessInfo(process, detectedPort);
113+
}
114+
115+
/**
116+
* Connects to a running Copilot server.
117+
*
118+
* @param process
119+
* the CLI process (null if connecting to external server)
120+
* @param tcpHost
121+
* the host to connect to (null for stdio mode)
122+
* @param tcpPort
123+
* the port to connect to (null for stdio mode)
124+
* @return the JSON-RPC client connected to the server
125+
* @throws IOException
126+
* if connection fails
127+
*/
128+
JsonRpcClient connectToServer(Process process, String tcpHost, Integer tcpPort) throws IOException {
129+
if (options.isUseStdio()) {
130+
if (process == null) {
131+
throw new IllegalStateException("CLI process not started");
132+
}
133+
return JsonRpcClient.fromProcess(process);
134+
} else {
135+
if (tcpHost == null || tcpPort == null) {
136+
throw new IllegalStateException("Cannot connect because TCP host or port are not available");
137+
}
138+
Socket socket = new Socket(tcpHost, tcpPort);
139+
return JsonRpcClient.fromSocket(socket);
140+
}
141+
}
142+
143+
private void startStderrReader(Process process) {
144+
var stderrThread = new Thread(() -> {
145+
try (BufferedReader reader = new BufferedReader(
146+
new InputStreamReader(process.getErrorStream(), StandardCharsets.UTF_8))) {
147+
String line;
148+
while ((line = reader.readLine()) != null) {
149+
LOG.fine("[CLI] " + line);
150+
}
151+
} catch (IOException e) {
152+
LOG.log(Level.FINE, "Error reading stderr", e);
153+
}
154+
}, "cli-stderr-reader");
155+
stderrThread.setDaemon(true);
156+
stderrThread.start();
157+
}
158+
159+
private Integer waitForPortAnnouncement(Process process) throws IOException {
160+
try (BufferedReader reader = new BufferedReader(
161+
new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8))) {
162+
Pattern portPattern = Pattern.compile("listening on port (\\d+)", Pattern.CASE_INSENSITIVE);
163+
long deadline = System.currentTimeMillis() + 30000;
164+
165+
while (System.currentTimeMillis() < deadline) {
166+
String line = reader.readLine();
167+
if (line == null) {
168+
throw new IOException("CLI process exited unexpectedly");
169+
}
170+
171+
Matcher matcher = portPattern.matcher(line);
172+
if (matcher.find()) {
173+
return Integer.parseInt(matcher.group(1));
174+
}
175+
}
176+
177+
process.destroyForcibly();
178+
throw new IOException("Timeout waiting for CLI to announce port");
179+
}
180+
}
181+
182+
private List<String> resolveCliCommand(String cliPath, List<String> args) {
183+
boolean isJsFile = cliPath.toLowerCase().endsWith(".js");
184+
185+
if (isJsFile) {
186+
var result = new ArrayList<String>();
187+
result.add("node");
188+
result.add(cliPath);
189+
result.addAll(args);
190+
return result;
191+
}
192+
193+
// On Windows, use cmd /c to resolve the executable
194+
String os = System.getProperty("os.name").toLowerCase();
195+
if (os.contains("win") && !new File(cliPath).isAbsolute()) {
196+
var result = new ArrayList<String>();
197+
result.add("cmd");
198+
result.add("/c");
199+
result.add(cliPath);
200+
result.addAll(args);
201+
return result;
202+
}
203+
204+
var result = new ArrayList<String>();
205+
result.add(cliPath);
206+
result.addAll(args);
207+
return result;
208+
}
209+
210+
static URI parseCliUrl(String url) {
211+
// If it's just a port number, treat as localhost
212+
try {
213+
int port = Integer.parseInt(url);
214+
return URI.create("http://localhost:" + port);
215+
} catch (NumberFormatException e) {
216+
// Not a port number, continue
217+
}
218+
219+
// Add scheme if missing
220+
if (!url.toLowerCase().startsWith("http://") && !url.toLowerCase().startsWith("https://")) {
221+
url = "https://" + url;
222+
}
223+
224+
return URI.create(url);
225+
}
226+
227+
/**
228+
* Information about a started CLI server process.
229+
*
230+
* @param process
231+
* the CLI process
232+
* @param port
233+
* the detected TCP port (null for stdio mode)
234+
*/
235+
record ProcessInfo(Process process, Integer port) {
236+
}
237+
}

0 commit comments

Comments
 (0)