remodex-windows-fix 1.0.2
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/README.md +183 -0
- package/bin/phodex.js +8 -0
- package/bin/remodex-relay.js +19 -0
- package/bin/remodex.js +44 -0
- package/cloudflare/README.md +16 -0
- package/cloudflare/worker.mjs +221 -0
- package/package.json +48 -0
- package/relay/README.md +39 -0
- package/relay/relay.js +199 -0
- package/render.yaml +10 -0
- package/src/bridge.js +330 -0
- package/src/codex-desktop-refresher.js +338 -0
- package/src/codex-transport.js +277 -0
- package/src/git-handler.js +617 -0
- package/src/index.js +11 -0
- package/src/qr.js +21 -0
- package/src/relay-server.js +61 -0
- package/src/rollout-watch.js +176 -0
- package/src/scripts/codex-refresh.applescript +51 -0
- package/src/session-state.js +57 -0
- package/src/workspace-paths.js +181 -0
- package/wrangler.toml +13 -0
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
// FILE: codex-desktop-refresher.js
|
|
2
|
+
// Purpose: Debounced Mac desktop refresh controller for Codex.app after phone-authored conversation changes.
|
|
3
|
+
// Layer: CLI helper
|
|
4
|
+
// Exports: CodexDesktopRefresher, readBridgeConfig
|
|
5
|
+
// Depends on: child_process, path
|
|
6
|
+
|
|
7
|
+
const { execFile } = require("child_process");
|
|
8
|
+
const path = require("path");
|
|
9
|
+
|
|
10
|
+
const DEFAULT_BUNDLE_ID = "com.openai.codex";
|
|
11
|
+
const DEFAULT_APP_PATH = "/Applications/Codex.app";
|
|
12
|
+
const DEFAULT_DEBOUNCE_MS = 1200;
|
|
13
|
+
const REFRESH_SCRIPT_PATH = path.join(__dirname, "scripts", "codex-refresh.applescript");
|
|
14
|
+
const NEW_THREAD_DEEP_LINK = "codex://threads/new";
|
|
15
|
+
|
|
16
|
+
class CodexDesktopRefresher {
|
|
17
|
+
constructor({
|
|
18
|
+
enabled = true,
|
|
19
|
+
debounceMs = DEFAULT_DEBOUNCE_MS,
|
|
20
|
+
refreshCommand = "",
|
|
21
|
+
bundleId = DEFAULT_BUNDLE_ID,
|
|
22
|
+
appPath = DEFAULT_APP_PATH,
|
|
23
|
+
logPrefix = "[remodex]",
|
|
24
|
+
} = {}) {
|
|
25
|
+
this.enabled = enabled;
|
|
26
|
+
this.debounceMs = debounceMs;
|
|
27
|
+
this.refreshCommand = refreshCommand;
|
|
28
|
+
this.bundleId = bundleId;
|
|
29
|
+
this.appPath = appPath;
|
|
30
|
+
this.logPrefix = logPrefix;
|
|
31
|
+
|
|
32
|
+
this.pendingUserRefresh = false;
|
|
33
|
+
this.pendingCompletionRefresh = false;
|
|
34
|
+
this.pendingCompletionTurnId = null;
|
|
35
|
+
this.pendingTargetUrl = "";
|
|
36
|
+
this.pendingTargetThreadId = null;
|
|
37
|
+
this.lastRefreshAt = 0;
|
|
38
|
+
this.lastTurnIdRefreshed = null;
|
|
39
|
+
this.refreshTimer = null;
|
|
40
|
+
this.refreshRunning = false;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
handleInbound(rawMessage) {
|
|
44
|
+
const parsed = safeParseJSON(rawMessage);
|
|
45
|
+
if (!parsed) {
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const method = parsed.method;
|
|
50
|
+
if (method !== "thread/start" && method !== "turn/start") {
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
this.noteRefreshTarget(resolveInboundTarget(method, parsed));
|
|
55
|
+
this.pendingUserRefresh = true;
|
|
56
|
+
this.scheduleRefresh(`phone ${method}`);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
handleOutbound(rawMessage) {
|
|
60
|
+
const parsed = safeParseJSON(rawMessage);
|
|
61
|
+
if (!parsed) {
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const method = parsed.method;
|
|
66
|
+
if (method === "turn/completed") {
|
|
67
|
+
const turnId = extractTurnId(parsed);
|
|
68
|
+
if (turnId && turnId === this.lastTurnIdRefreshed) {
|
|
69
|
+
this.log(`refresh skipped (debounced): completion already refreshed for ${turnId}`);
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
this.noteRefreshTarget(resolveOutboundTarget(method, parsed));
|
|
74
|
+
this.pendingCompletionRefresh = true;
|
|
75
|
+
this.pendingCompletionTurnId = turnId;
|
|
76
|
+
this.scheduleRefresh(`codex ${method}`);
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (method === "thread/started") {
|
|
81
|
+
this.noteRefreshTarget(resolveOutboundTarget(method, parsed));
|
|
82
|
+
this.pendingUserRefresh = true;
|
|
83
|
+
this.scheduleRefresh(`codex ${method}`);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
noteRefreshTarget(target) {
|
|
88
|
+
if (!target?.url) {
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
this.pendingTargetUrl = target.url;
|
|
93
|
+
if (target.threadId) {
|
|
94
|
+
this.pendingTargetThreadId = target.threadId;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
scheduleRefresh(reason) {
|
|
99
|
+
if (!this.enabled) {
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (this.refreshTimer) {
|
|
104
|
+
this.log(`refresh already pending: ${reason}`);
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const elapsedSinceLastRefresh = Date.now() - this.lastRefreshAt;
|
|
109
|
+
const waitMs = Math.max(0, this.debounceMs - elapsedSinceLastRefresh);
|
|
110
|
+
this.log(`refresh scheduled: ${reason}`);
|
|
111
|
+
this.refreshTimer = setTimeout(() => {
|
|
112
|
+
this.refreshTimer = null;
|
|
113
|
+
void this.runPendingRefresh();
|
|
114
|
+
}, waitMs);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async runPendingRefresh() {
|
|
118
|
+
if (!this.enabled) {
|
|
119
|
+
this.clearPendingState();
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (!this.pendingUserRefresh && !this.pendingCompletionRefresh) {
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (this.refreshRunning) {
|
|
128
|
+
this.log("refresh skipped (debounced): another refresh is already running");
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const completionTurnId = this.pendingCompletionTurnId;
|
|
133
|
+
const targetUrl = this.pendingTargetUrl;
|
|
134
|
+
const targetThreadId = this.pendingTargetThreadId;
|
|
135
|
+
const labelParts = [];
|
|
136
|
+
if (this.pendingUserRefresh) {
|
|
137
|
+
labelParts.push("user");
|
|
138
|
+
}
|
|
139
|
+
if (this.pendingCompletionRefresh) {
|
|
140
|
+
labelParts.push("completion");
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
this.pendingUserRefresh = false;
|
|
144
|
+
this.pendingCompletionRefresh = false;
|
|
145
|
+
this.pendingCompletionTurnId = null;
|
|
146
|
+
this.pendingTargetUrl = "";
|
|
147
|
+
this.pendingTargetThreadId = null;
|
|
148
|
+
this.refreshRunning = true;
|
|
149
|
+
this.log(
|
|
150
|
+
`refresh running: ${labelParts.join("+")}${targetThreadId ? ` thread=${targetThreadId}` : ""}`
|
|
151
|
+
);
|
|
152
|
+
|
|
153
|
+
try {
|
|
154
|
+
await this.executeRefresh(targetUrl);
|
|
155
|
+
this.lastRefreshAt = Date.now();
|
|
156
|
+
if (completionTurnId) {
|
|
157
|
+
this.lastTurnIdRefreshed = completionTurnId;
|
|
158
|
+
}
|
|
159
|
+
} catch (error) {
|
|
160
|
+
this.logRefreshFailure(error);
|
|
161
|
+
} finally {
|
|
162
|
+
this.refreshRunning = false;
|
|
163
|
+
if (this.pendingUserRefresh || this.pendingCompletionRefresh) {
|
|
164
|
+
this.scheduleRefresh("pending follow-up refresh");
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
executeRefresh(targetUrl) {
|
|
170
|
+
if (this.refreshCommand) {
|
|
171
|
+
return execFilePromise("/bin/sh", ["-lc", this.refreshCommand]);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return execFilePromise("osascript", [
|
|
175
|
+
REFRESH_SCRIPT_PATH,
|
|
176
|
+
this.bundleId,
|
|
177
|
+
this.appPath,
|
|
178
|
+
targetUrl || "",
|
|
179
|
+
]);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
clearPendingState() {
|
|
183
|
+
this.pendingUserRefresh = false;
|
|
184
|
+
this.pendingCompletionRefresh = false;
|
|
185
|
+
this.pendingCompletionTurnId = null;
|
|
186
|
+
this.pendingTargetUrl = "";
|
|
187
|
+
this.pendingTargetThreadId = null;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
log(message) {
|
|
191
|
+
console.log(`${this.logPrefix} ${message}`);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
logRefreshFailure(error) {
|
|
195
|
+
const message = error?.stderr?.toString("utf8")
|
|
196
|
+
|| error?.stdout?.toString("utf8")
|
|
197
|
+
|| error?.message
|
|
198
|
+
|| "unknown refresh error";
|
|
199
|
+
|
|
200
|
+
console.error(`${this.logPrefix} refresh failed: ${message.trim()}`);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function readBridgeConfig() {
|
|
205
|
+
return {
|
|
206
|
+
relayUrl: readFirstDefinedEnv(["REMODEX_RELAY", "PHODEX_RELAY"], "wss://api.phodex.app/relay"),
|
|
207
|
+
refreshEnabled: parseBooleanEnv(readFirstDefinedEnv(["REMODEX_REFRESH_ENABLED"], "false")),
|
|
208
|
+
refreshDebounceMs: parseIntegerEnv(
|
|
209
|
+
readFirstDefinedEnv(["REMODEX_REFRESH_DEBOUNCE_MS"], String(DEFAULT_DEBOUNCE_MS)),
|
|
210
|
+
DEFAULT_DEBOUNCE_MS
|
|
211
|
+
),
|
|
212
|
+
codexEndpoint: readFirstDefinedEnv(["REMODEX_CODEX_ENDPOINT", "PHODEX_CODEX_ENDPOINT"], ""),
|
|
213
|
+
refreshCommand: readFirstDefinedEnv(
|
|
214
|
+
["REMODEX_REFRESH_COMMAND", "PHODEX_ON_PHONE_MESSAGE"],
|
|
215
|
+
""
|
|
216
|
+
),
|
|
217
|
+
codexBundleId: readFirstDefinedEnv(["REMODEX_CODEX_BUNDLE_ID"], DEFAULT_BUNDLE_ID),
|
|
218
|
+
codexAppPath: DEFAULT_APP_PATH,
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function execFilePromise(command, args) {
|
|
223
|
+
return new Promise((resolve, reject) => {
|
|
224
|
+
execFile(command, args, (error, stdout, stderr) => {
|
|
225
|
+
if (error) {
|
|
226
|
+
error.stdout = stdout;
|
|
227
|
+
error.stderr = stderr;
|
|
228
|
+
reject(error);
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
resolve({ stdout, stderr });
|
|
232
|
+
});
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function safeParseJSON(value) {
|
|
237
|
+
try {
|
|
238
|
+
return JSON.parse(value);
|
|
239
|
+
} catch {
|
|
240
|
+
return null;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function extractTurnId(message) {
|
|
245
|
+
const params = message?.params;
|
|
246
|
+
if (!params || typeof params !== "object") {
|
|
247
|
+
return null;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
if (typeof params.turnId === "string" && params.turnId) {
|
|
251
|
+
return params.turnId;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
if (params.turn && typeof params.turn === "object" && typeof params.turn.id === "string") {
|
|
255
|
+
return params.turn.id;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
return null;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function extractThreadId(message) {
|
|
262
|
+
const params = message?.params;
|
|
263
|
+
if (!params || typeof params !== "object") {
|
|
264
|
+
return null;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const candidates = [
|
|
268
|
+
params.threadId,
|
|
269
|
+
params.conversationId,
|
|
270
|
+
params.thread?.id,
|
|
271
|
+
params.thread?.threadId,
|
|
272
|
+
params.turn?.threadId,
|
|
273
|
+
params.turn?.conversationId,
|
|
274
|
+
];
|
|
275
|
+
|
|
276
|
+
for (const candidate of candidates) {
|
|
277
|
+
if (typeof candidate === "string" && candidate) {
|
|
278
|
+
return candidate;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
return null;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function resolveInboundTarget(method, message) {
|
|
286
|
+
const threadId = extractThreadId(message);
|
|
287
|
+
if (threadId) {
|
|
288
|
+
return { threadId, url: buildThreadDeepLink(threadId) };
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
if (method === "thread/start" || method === "turn/start") {
|
|
292
|
+
return { threadId: null, url: NEW_THREAD_DEEP_LINK };
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
return null;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function resolveOutboundTarget(method, message) {
|
|
299
|
+
const threadId = extractThreadId(message);
|
|
300
|
+
if (threadId) {
|
|
301
|
+
return { threadId, url: buildThreadDeepLink(threadId) };
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
if (method === "thread/started") {
|
|
305
|
+
return { threadId: null, url: NEW_THREAD_DEEP_LINK };
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
return null;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function buildThreadDeepLink(threadId) {
|
|
312
|
+
return `codex://threads/${threadId}`;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function readFirstDefinedEnv(keys, fallback) {
|
|
316
|
+
for (const key of keys) {
|
|
317
|
+
const value = process.env[key];
|
|
318
|
+
if (typeof value === "string" && value.trim() !== "") {
|
|
319
|
+
return value.trim();
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
return fallback;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function parseBooleanEnv(value) {
|
|
326
|
+
const normalized = String(value).trim().toLowerCase();
|
|
327
|
+
return normalized !== "false" && normalized !== "0" && normalized !== "no";
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function parseIntegerEnv(value, fallback) {
|
|
331
|
+
const parsed = Number.parseInt(String(value), 10);
|
|
332
|
+
return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
module.exports = {
|
|
336
|
+
CodexDesktopRefresher,
|
|
337
|
+
readBridgeConfig,
|
|
338
|
+
};
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
// FILE: codex-transport.js
|
|
2
|
+
// Purpose: Abstracts the Codex-side transport so the bridge can talk to either a spawned app-server or an existing WebSocket endpoint.
|
|
3
|
+
// Layer: CLI helper
|
|
4
|
+
// Exports: createCodexTransport
|
|
5
|
+
// Depends on: child_process, ws
|
|
6
|
+
|
|
7
|
+
const fs = require("fs");
|
|
8
|
+
const path = require("path");
|
|
9
|
+
const { spawn } = require("child_process");
|
|
10
|
+
const WebSocket = require("ws");
|
|
11
|
+
|
|
12
|
+
function createCodexTransport({ endpoint = "", env = process.env } = {}) {
|
|
13
|
+
if (endpoint) {
|
|
14
|
+
return createWebSocketTransport({ endpoint });
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
return createSpawnTransport({ env });
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function createSpawnTransport({ env }) {
|
|
21
|
+
const listeners = createListenerBag();
|
|
22
|
+
const spawnConfig = resolveCodexSpawnConfig(env);
|
|
23
|
+
|
|
24
|
+
if (spawnConfig.error) {
|
|
25
|
+
process.nextTick(() => listeners.emitError(spawnConfig.error));
|
|
26
|
+
return createInactiveSpawnTransport(listeners, spawnConfig);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
let codex = null;
|
|
30
|
+
try {
|
|
31
|
+
codex = spawn(spawnConfig.command, spawnConfig.args, {
|
|
32
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
33
|
+
env: { ...env },
|
|
34
|
+
shell: spawnConfig.shell,
|
|
35
|
+
windowsHide: true,
|
|
36
|
+
});
|
|
37
|
+
} catch (error) {
|
|
38
|
+
process.nextTick(() => listeners.emitError(normalizeSpawnError(error, spawnConfig)));
|
|
39
|
+
return createInactiveSpawnTransport(listeners, spawnConfig);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
let stdoutBuffer = "";
|
|
43
|
+
|
|
44
|
+
codex.on("error", (error) => listeners.emitError(normalizeSpawnError(error, spawnConfig)));
|
|
45
|
+
codex.on("close", (code, signal) => listeners.emitClose(code, signal));
|
|
46
|
+
// The bridge keeps stdout focused on connection state, so raw app-server logs stay muted here.
|
|
47
|
+
codex.stderr.on("data", () => {});
|
|
48
|
+
|
|
49
|
+
codex.stdout.on("data", (chunk) => {
|
|
50
|
+
stdoutBuffer += chunk.toString("utf8");
|
|
51
|
+
const lines = stdoutBuffer.split("\n");
|
|
52
|
+
stdoutBuffer = lines.pop() || "";
|
|
53
|
+
|
|
54
|
+
for (const line of lines) {
|
|
55
|
+
const trimmedLine = line.trim();
|
|
56
|
+
if (trimmedLine) {
|
|
57
|
+
listeners.emitMessage(trimmedLine);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
mode: "spawn",
|
|
64
|
+
describe() {
|
|
65
|
+
return spawnConfig.description;
|
|
66
|
+
},
|
|
67
|
+
send(message) {
|
|
68
|
+
if (!codex.stdin.writable) {
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
codex.stdin.write(message.endsWith("\n") ? message : `${message}\n`);
|
|
73
|
+
},
|
|
74
|
+
onMessage(handler) {
|
|
75
|
+
listeners.onMessage = handler;
|
|
76
|
+
},
|
|
77
|
+
onClose(handler) {
|
|
78
|
+
listeners.onClose = handler;
|
|
79
|
+
},
|
|
80
|
+
onError(handler) {
|
|
81
|
+
listeners.onError = handler;
|
|
82
|
+
},
|
|
83
|
+
shutdown() {
|
|
84
|
+
if (!codex.killed) {
|
|
85
|
+
codex.kill("SIGTERM");
|
|
86
|
+
}
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function createInactiveSpawnTransport(listeners, spawnConfig) {
|
|
92
|
+
return {
|
|
93
|
+
mode: "spawn",
|
|
94
|
+
describe() {
|
|
95
|
+
return spawnConfig.description;
|
|
96
|
+
},
|
|
97
|
+
send() {},
|
|
98
|
+
onMessage(handler) {
|
|
99
|
+
listeners.onMessage = handler;
|
|
100
|
+
},
|
|
101
|
+
onClose(handler) {
|
|
102
|
+
listeners.onClose = handler;
|
|
103
|
+
},
|
|
104
|
+
onError(handler) {
|
|
105
|
+
listeners.onError = handler;
|
|
106
|
+
},
|
|
107
|
+
shutdown() {},
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function resolveCodexSpawnConfig(env) {
|
|
112
|
+
const commandName = "codex";
|
|
113
|
+
const args = ["app-server"];
|
|
114
|
+
const description = "`codex app-server`";
|
|
115
|
+
|
|
116
|
+
if (process.platform !== "win32") {
|
|
117
|
+
return {
|
|
118
|
+
command: commandName,
|
|
119
|
+
args,
|
|
120
|
+
shell: false,
|
|
121
|
+
description,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const override = readFirstDefinedEnv(env, ["REMODEX_CODEX_BIN", "PHODEX_CODEX_BIN"], "");
|
|
126
|
+
if (override) {
|
|
127
|
+
return createWindowsSpawnConfig(override, args);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const codexCmd = findFirstOnPath(env, ["codex.cmd", "codex.bat"]);
|
|
131
|
+
if (codexCmd) {
|
|
132
|
+
return createWindowsSpawnConfig(codexCmd, args);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const codexExe = findFirstOnPath(env, ["codex.exe"]);
|
|
136
|
+
if (codexExe) {
|
|
137
|
+
return createWindowsSpawnConfig(codexExe, args);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const error = new Error("spawn codex ENOENT");
|
|
141
|
+
error.code = "ENOENT";
|
|
142
|
+
|
|
143
|
+
return {
|
|
144
|
+
command: commandName,
|
|
145
|
+
args,
|
|
146
|
+
shell: false,
|
|
147
|
+
description,
|
|
148
|
+
error,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function createWindowsSpawnConfig(command, args) {
|
|
153
|
+
const normalized = String(command).trim();
|
|
154
|
+
const lower = normalized.toLowerCase();
|
|
155
|
+
const needsShell = lower.endsWith(".cmd") || lower.endsWith(".bat");
|
|
156
|
+
|
|
157
|
+
return {
|
|
158
|
+
command: normalized,
|
|
159
|
+
args,
|
|
160
|
+
shell: needsShell,
|
|
161
|
+
description: `\`${normalized} ${args.join(" ")}\``,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function normalizeSpawnError(error, spawnConfig) {
|
|
166
|
+
if (!error) {
|
|
167
|
+
return new Error(`Failed to start ${spawnConfig.description}.`);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (!error.message.includes(spawnConfig.description)) {
|
|
171
|
+
error.message = `${error.message} (${spawnConfig.description})`;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return error;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function findFirstOnPath(env, names) {
|
|
178
|
+
const pathValue = readFirstDefinedEnv(env, ["PATH", "Path"], "");
|
|
179
|
+
if (!pathValue) {
|
|
180
|
+
return "";
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const directories = pathValue
|
|
184
|
+
.split(path.delimiter)
|
|
185
|
+
.map((segment) => segment.trim().replace(/^"+|"+$/g, ""))
|
|
186
|
+
.filter(Boolean);
|
|
187
|
+
|
|
188
|
+
for (const directory of directories) {
|
|
189
|
+
for (const name of names) {
|
|
190
|
+
const candidate = path.join(directory, name);
|
|
191
|
+
try {
|
|
192
|
+
if (fs.statSync(candidate).isFile()) {
|
|
193
|
+
return candidate;
|
|
194
|
+
}
|
|
195
|
+
} catch {}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return "";
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function readFirstDefinedEnv(env, keys, fallback) {
|
|
203
|
+
for (const key of keys) {
|
|
204
|
+
const value = env[key];
|
|
205
|
+
if (typeof value === "string" && value.trim() !== "") {
|
|
206
|
+
return value.trim();
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
return fallback;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function createWebSocketTransport({ endpoint }) {
|
|
214
|
+
const socket = new WebSocket(endpoint);
|
|
215
|
+
const listeners = createListenerBag();
|
|
216
|
+
|
|
217
|
+
socket.on("message", (chunk) => {
|
|
218
|
+
const message = typeof chunk === "string" ? chunk : chunk.toString("utf8");
|
|
219
|
+
if (message.trim()) {
|
|
220
|
+
listeners.emitMessage(message);
|
|
221
|
+
}
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
socket.on("close", (code, reason) => {
|
|
225
|
+
const safeReason = reason ? reason.toString("utf8") : "no reason";
|
|
226
|
+
listeners.emitClose(code, safeReason);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
socket.on("error", (error) => listeners.emitError(error));
|
|
230
|
+
|
|
231
|
+
return {
|
|
232
|
+
mode: "websocket",
|
|
233
|
+
describe() {
|
|
234
|
+
return endpoint;
|
|
235
|
+
},
|
|
236
|
+
send(message) {
|
|
237
|
+
if (socket.readyState !== WebSocket.OPEN) {
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
socket.send(message);
|
|
242
|
+
},
|
|
243
|
+
onMessage(handler) {
|
|
244
|
+
listeners.onMessage = handler;
|
|
245
|
+
},
|
|
246
|
+
onClose(handler) {
|
|
247
|
+
listeners.onClose = handler;
|
|
248
|
+
},
|
|
249
|
+
onError(handler) {
|
|
250
|
+
listeners.onError = handler;
|
|
251
|
+
},
|
|
252
|
+
shutdown() {
|
|
253
|
+
if (socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING) {
|
|
254
|
+
socket.close();
|
|
255
|
+
}
|
|
256
|
+
},
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function createListenerBag() {
|
|
261
|
+
return {
|
|
262
|
+
onMessage: null,
|
|
263
|
+
onClose: null,
|
|
264
|
+
onError: null,
|
|
265
|
+
emitMessage(message) {
|
|
266
|
+
this.onMessage?.(message);
|
|
267
|
+
},
|
|
268
|
+
emitClose(...args) {
|
|
269
|
+
this.onClose?.(...args);
|
|
270
|
+
},
|
|
271
|
+
emitError(error) {
|
|
272
|
+
this.onError?.(error);
|
|
273
|
+
},
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
module.exports = { createCodexTransport };
|