@btatum5/codex-bridge 0.1.0 → 1.3.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.
@@ -0,0 +1,62 @@
1
+ // FILE: session-state.js
2
+ // Purpose: Persists the latest active Codex thread so the user can reopen it on the Mac for handoff.
3
+ // Layer: CLI helper
4
+ // Exports: rememberActiveThread, openLastActiveThread, readLastActiveThread
5
+ // Depends on: fs, os, path, child_process
6
+
7
+ const fs = require("fs");
8
+ const os = require("os");
9
+ const path = require("path");
10
+ const { execFileSync } = require("child_process");
11
+
12
+ const STATE_DIR = path.join(os.homedir(), ".codex", "bridge");
13
+ const LEGACY_STATE_DIR = path.join(os.homedir(), ".remodex");
14
+ const STATE_FILE = path.join(STATE_DIR, "last-thread.json");
15
+ const LEGACY_STATE_FILE = path.join(LEGACY_STATE_DIR, "last-thread.json");
16
+ const DEFAULT_BUNDLE_ID = "com.openai.codex";
17
+
18
+ function rememberActiveThread(threadId, source) {
19
+ if (!threadId || typeof threadId !== "string") {
20
+ return false;
21
+ }
22
+
23
+ const payload = {
24
+ threadId,
25
+ source: source || "unknown",
26
+ updatedAt: new Date().toISOString(),
27
+ };
28
+
29
+ fs.mkdirSync(STATE_DIR, { recursive: true });
30
+ fs.writeFileSync(STATE_FILE, JSON.stringify(payload, null, 2));
31
+ return true;
32
+ }
33
+
34
+ function openLastActiveThread({ bundleId = DEFAULT_BUNDLE_ID } = {}) {
35
+ const state = readState();
36
+ const threadId = state?.threadId;
37
+ if (!threadId) {
38
+ throw new Error("No remembered Codex thread found yet.");
39
+ }
40
+
41
+ const targetUrl = `codex://threads/${threadId}`;
42
+ execFileSync("open", ["-b", bundleId, targetUrl], { stdio: "ignore" });
43
+ return state;
44
+ }
45
+
46
+ function readState() {
47
+ const preferredFile = fs.existsSync(STATE_FILE)
48
+ ? STATE_FILE
49
+ : LEGACY_STATE_FILE;
50
+ if (!fs.existsSync(preferredFile)) {
51
+ return null;
52
+ }
53
+
54
+ const raw = fs.readFileSync(preferredFile, "utf8");
55
+ return JSON.parse(raw);
56
+ }
57
+
58
+ module.exports = {
59
+ rememberActiveThread,
60
+ openLastActiveThread,
61
+ readLastActiveThread: readState,
62
+ };
@@ -0,0 +1,80 @@
1
+ // FILE: thread-context-handler.js
2
+ // Purpose: Serves on-demand thread context-window usage reads from local Codex rollout files.
3
+ // Layer: Bridge handler
4
+ // Exports: handleThreadContextRequest
5
+ // Depends on: ./rollout-watch
6
+
7
+ const { readLatestContextWindowUsage } = require("./rollout-watch");
8
+
9
+ function handleThreadContextRequest(rawMessage, sendResponse) {
10
+ let parsed;
11
+ try {
12
+ parsed = JSON.parse(rawMessage);
13
+ } catch {
14
+ return false;
15
+ }
16
+
17
+ const method = typeof parsed?.method === "string" ? parsed.method.trim() : "";
18
+ if (method !== "thread/contextWindow/read") {
19
+ return false;
20
+ }
21
+
22
+ const id = parsed.id;
23
+ const params = parsed.params || {};
24
+
25
+ handleThreadContextRead(params)
26
+ .then((result) => {
27
+ sendResponse(JSON.stringify({ id, result }));
28
+ })
29
+ .catch((err) => {
30
+ const errorCode = err.errorCode || "thread_context_error";
31
+ const message = err.userMessage || err.message || "Unknown thread context error";
32
+ sendResponse(
33
+ JSON.stringify({
34
+ id,
35
+ error: {
36
+ code: -32000,
37
+ message,
38
+ data: { errorCode },
39
+ },
40
+ })
41
+ );
42
+ });
43
+
44
+ return true;
45
+ }
46
+
47
+ // Reads the newest rollout-backed usage snapshot and returns it in the app-facing shape.
48
+ async function handleThreadContextRead(params) {
49
+ const threadId = readString(params.threadId) || readString(params.thread_id);
50
+ if (!threadId) {
51
+ throw threadContextError("missing_thread_id", "thread/contextWindow/read requires a threadId.");
52
+ }
53
+
54
+ const turnId = readString(params.turnId) || readString(params.turn_id);
55
+ const result = readLatestContextWindowUsage({
56
+ threadId,
57
+ turnId,
58
+ });
59
+
60
+ return {
61
+ threadId,
62
+ usage: result?.usage ?? null,
63
+ rolloutPath: result?.rolloutPath ?? null,
64
+ };
65
+ }
66
+
67
+ function readString(value) {
68
+ return typeof value === "string" && value.trim() ? value.trim() : null;
69
+ }
70
+
71
+ function threadContextError(errorCode, userMessage) {
72
+ const error = new Error(userMessage);
73
+ error.errorCode = errorCode;
74
+ error.userMessage = userMessage;
75
+ return error;
76
+ }
77
+
78
+ module.exports = {
79
+ handleThreadContextRequest,
80
+ };
@@ -0,0 +1,464 @@
1
+ // FILE: workspace-handler.js
2
+ // Purpose: Executes workspace-scoped reverse patch previews/applies without touching unrelated repo changes.
3
+ // Layer: Bridge handler
4
+ // Exports: handleWorkspaceRequest
5
+ // Depends on: child_process, fs, os, path, ./git-handler
6
+
7
+ const { execFile } = require("child_process");
8
+ const fs = require("fs");
9
+ const os = require("os");
10
+ const path = require("path");
11
+ const { promisify } = require("util");
12
+ const { gitStatus } = require("./git-handler");
13
+
14
+ const execFileAsync = promisify(execFile);
15
+ const GIT_TIMEOUT_MS = 30_000;
16
+ const repoMutationLocks = new Map();
17
+
18
+ function handleWorkspaceRequest(rawMessage, sendResponse) {
19
+ let parsed;
20
+ try {
21
+ parsed = JSON.parse(rawMessage);
22
+ } catch {
23
+ return false;
24
+ }
25
+
26
+ const method = typeof parsed?.method === "string" ? parsed.method.trim() : "";
27
+ if (!method.startsWith("workspace/")) {
28
+ return false;
29
+ }
30
+
31
+ const id = parsed.id;
32
+ const params = parsed.params || {};
33
+
34
+ handleWorkspaceMethod(method, params)
35
+ .then((result) => {
36
+ sendResponse(JSON.stringify({ id, result }));
37
+ })
38
+ .catch((err) => {
39
+ const errorCode = err.errorCode || "workspace_error";
40
+ const message = err.userMessage || err.message || "Unknown workspace error";
41
+ sendResponse(
42
+ JSON.stringify({
43
+ id,
44
+ error: {
45
+ code: -32000,
46
+ message,
47
+ data: { errorCode },
48
+ },
49
+ })
50
+ );
51
+ });
52
+
53
+ return true;
54
+ }
55
+
56
+ async function handleWorkspaceMethod(method, params) {
57
+ const cwd = await resolveWorkspaceCwd(params);
58
+ const repoRoot = await resolveRepoRoot(cwd);
59
+
60
+ switch (method) {
61
+ case "workspace/revertPatchPreview":
62
+ return workspaceRevertPatchPreview(repoRoot, params);
63
+ case "workspace/revertPatchApply":
64
+ return withRepoMutationLock(repoRoot, () => workspaceRevertPatchApply(repoRoot, params));
65
+ default:
66
+ throw workspaceError("unknown_method", `Unknown workspace method: ${method}`);
67
+ }
68
+ }
69
+
70
+ // Validates the reverse patch against the current tree without writing repo files.
71
+ async function workspaceRevertPatchPreview(repoRoot, params) {
72
+ const forwardPatch = resolveForwardPatch(params);
73
+ const analysis = analyzeUnifiedPatch(forwardPatch);
74
+ const stagedFiles = await findStagedTargetedFiles(repoRoot, analysis.affectedFiles);
75
+
76
+ if (analysis.unsupportedReasons.length || stagedFiles.length) {
77
+ return {
78
+ canRevert: false,
79
+ affectedFiles: analysis.affectedFiles,
80
+ conflicts: [],
81
+ unsupportedReasons: analysis.unsupportedReasons,
82
+ stagedFiles,
83
+ };
84
+ }
85
+
86
+ const applyCheck = await runGitApply(repoRoot, ["apply", "--reverse", "--check"], forwardPatch);
87
+ const conflicts = applyCheck.ok
88
+ ? []
89
+ : parseApplyConflicts(applyCheck.stderr || applyCheck.stdout || "Patch does not apply.");
90
+
91
+ return {
92
+ canRevert: applyCheck.ok && conflicts.length === 0,
93
+ affectedFiles: analysis.affectedFiles,
94
+ conflicts,
95
+ unsupportedReasons: [],
96
+ stagedFiles,
97
+ };
98
+ }
99
+
100
+ // Reverse-applies the patch only after the same safety checks pass in the locked mutation path.
101
+ async function workspaceRevertPatchApply(repoRoot, params) {
102
+ const preview = await workspaceRevertPatchPreview(repoRoot, params);
103
+ if (!preview.canRevert) {
104
+ return {
105
+ success: false,
106
+ revertedFiles: [],
107
+ conflicts: preview.conflicts,
108
+ unsupportedReasons: preview.unsupportedReasons,
109
+ stagedFiles: preview.stagedFiles,
110
+ };
111
+ }
112
+
113
+ const forwardPatch = resolveForwardPatch(params);
114
+ const applyResult = await runGitApply(repoRoot, ["apply", "--reverse"], forwardPatch);
115
+ if (!applyResult.ok) {
116
+ return {
117
+ success: false,
118
+ revertedFiles: [],
119
+ conflicts: parseApplyConflicts(applyResult.stderr || applyResult.stdout || "Patch does not apply."),
120
+ unsupportedReasons: [],
121
+ stagedFiles: [],
122
+ status: await gitStatus(repoRoot).catch(() => null),
123
+ };
124
+ }
125
+
126
+ const status = await gitStatus(repoRoot).catch(() => null);
127
+ return {
128
+ success: true,
129
+ revertedFiles: preview.affectedFiles,
130
+ conflicts: [],
131
+ unsupportedReasons: [],
132
+ stagedFiles: [],
133
+ status,
134
+ };
135
+ }
136
+
137
+ function resolveForwardPatch(params) {
138
+ const forwardPatch =
139
+ typeof params.forwardPatch === "string" ? params.forwardPatch : "";
140
+
141
+ if (!forwardPatch.trim()) {
142
+ throw workspaceError("missing_patch", "The request must include a non-empty forwardPatch.");
143
+ }
144
+
145
+ return forwardPatch.endsWith("\n") ? forwardPatch : `${forwardPatch}\n`;
146
+ }
147
+
148
+ function analyzeUnifiedPatch(rawPatch) {
149
+ const patch = rawPatch.trim();
150
+ if (!patch) {
151
+ return {
152
+ affectedFiles: [],
153
+ unsupportedReasons: ["No exact patch was captured."],
154
+ };
155
+ }
156
+
157
+ const chunks = splitPatchIntoChunks(patch);
158
+ if (!chunks.length) {
159
+ return {
160
+ affectedFiles: [],
161
+ unsupportedReasons: ["No exact patch was captured."],
162
+ };
163
+ }
164
+
165
+ const affectedFiles = [];
166
+ const unsupportedReasons = new Set();
167
+
168
+ for (const chunk of chunks) {
169
+ const analysis = analyzePatchChunk(chunk);
170
+ if (analysis.path) {
171
+ affectedFiles.push(analysis.path);
172
+ }
173
+ for (const reason of analysis.unsupportedReasons) {
174
+ unsupportedReasons.add(reason);
175
+ }
176
+ }
177
+
178
+ if (!affectedFiles.length) {
179
+ unsupportedReasons.add("No exact patch was captured.");
180
+ }
181
+
182
+ return {
183
+ affectedFiles: [...new Set(affectedFiles)].sort(),
184
+ unsupportedReasons: [...unsupportedReasons].sort(),
185
+ };
186
+ }
187
+
188
+ function splitPatchIntoChunks(patch) {
189
+ const lines = patch.split("\n");
190
+ if (!lines.length) {
191
+ return [];
192
+ }
193
+
194
+ const chunks = [];
195
+ let current = [];
196
+
197
+ for (const line of lines) {
198
+ if (line.startsWith("diff --git ") && current.length) {
199
+ chunks.push(current);
200
+ current = [];
201
+ }
202
+ current.push(line);
203
+ }
204
+
205
+ if (current.length) {
206
+ chunks.push(current);
207
+ }
208
+
209
+ return chunks;
210
+ }
211
+
212
+ function analyzePatchChunk(lines) {
213
+ const path = extractPatchPath(lines);
214
+ const isBinary = lines.some((line) => line.startsWith("Binary files ") || line === "GIT binary patch");
215
+ const isRenameOrModeOnly = lines.some((line) =>
216
+ line.startsWith("rename from ")
217
+ || line.startsWith("rename to ")
218
+ || line.startsWith("copy from ")
219
+ || line.startsWith("copy to ")
220
+ || line.startsWith("old mode ")
221
+ || line.startsWith("new mode ")
222
+ || line.startsWith("similarity index ")
223
+ || line.startsWith("new file mode 120")
224
+ || line.startsWith("deleted file mode 120")
225
+ );
226
+
227
+ let additions = 0;
228
+ let deletions = 0;
229
+ for (const line of lines) {
230
+ if (line.startsWith("+") && !line.startsWith("+++")) {
231
+ additions += 1;
232
+ } else if (line.startsWith("-") && !line.startsWith("---")) {
233
+ deletions += 1;
234
+ }
235
+ }
236
+
237
+ const unsupportedReasons = [];
238
+ if (isBinary) {
239
+ unsupportedReasons.push("Binary changes are not auto-revertable in v1.");
240
+ }
241
+ if (isRenameOrModeOnly) {
242
+ unsupportedReasons.push("Rename, mode-only, or symlink changes are not auto-revertable in v1.");
243
+ }
244
+ if (!path || (!additions && !deletions && !lines.includes("--- /dev/null") && !lines.includes("+++ /dev/null"))) {
245
+ if (!isBinary && !isRenameOrModeOnly) {
246
+ unsupportedReasons.push("No exact patch was captured.");
247
+ }
248
+ }
249
+
250
+ return { path, unsupportedReasons };
251
+ }
252
+
253
+ function extractPatchPath(lines) {
254
+ for (const line of lines) {
255
+ if (line.startsWith("+++ ")) {
256
+ const normalized = normalizeDiffPath(line.slice(4).trim());
257
+ if (normalized && normalized !== "/dev/null") {
258
+ return normalized;
259
+ }
260
+ }
261
+ }
262
+
263
+ for (const line of lines) {
264
+ if (line.startsWith("diff --git ")) {
265
+ const components = line.trim().split(/\s+/);
266
+ if (components.length >= 4) {
267
+ return normalizeDiffPath(components[3]);
268
+ }
269
+ }
270
+ }
271
+
272
+ return "";
273
+ }
274
+
275
+ function normalizeDiffPath(rawPath) {
276
+ if (!rawPath) {
277
+ return "";
278
+ }
279
+
280
+ if (rawPath.startsWith("a/") || rawPath.startsWith("b/")) {
281
+ return rawPath.slice(2);
282
+ }
283
+
284
+ return rawPath;
285
+ }
286
+
287
+ async function findStagedTargetedFiles(cwd, affectedFiles) {
288
+ if (!affectedFiles.length) {
289
+ return [];
290
+ }
291
+
292
+ try {
293
+ const output = await git(cwd, "diff", "--name-only", "--cached", "--", ...affectedFiles);
294
+ return output
295
+ .split("\n")
296
+ .map((line) => line.trim())
297
+ .filter(Boolean)
298
+ .sort();
299
+ } catch {
300
+ return [];
301
+ }
302
+ }
303
+
304
+ async function runGitApply(cwd, args, patchText) {
305
+ const tempPatchPath = await writeTempPatchFile(patchText);
306
+
307
+ try {
308
+ const { stdout, stderr } = await execFileAsync("git", [...args, tempPatchPath], {
309
+ cwd,
310
+ timeout: GIT_TIMEOUT_MS,
311
+ });
312
+ return { ok: true, stdout, stderr };
313
+ } catch (err) {
314
+ return {
315
+ ok: false,
316
+ stdout: err.stdout || "",
317
+ stderr: err.stderr || err.message || "",
318
+ };
319
+ } finally {
320
+ try {
321
+ fs.unlinkSync(tempPatchPath);
322
+ } catch {
323
+ // Ignore temp cleanup failures.
324
+ }
325
+ }
326
+ }
327
+
328
+ async function writeTempPatchFile(patchText) {
329
+ const tempPatchPath = path.join(
330
+ os.tmpdir(),
331
+ `remodex-revert-${Date.now()}-${Math.random().toString(16).slice(2)}.patch`
332
+ );
333
+ await fs.promises.writeFile(tempPatchPath, patchText, "utf8");
334
+ return tempPatchPath;
335
+ }
336
+
337
+ function parseApplyConflicts(stderr) {
338
+ const lines = String(stderr || "")
339
+ .split("\n")
340
+ .map((line) => line.trim())
341
+ .filter(Boolean);
342
+
343
+ const conflictsByPath = new Map();
344
+ for (const line of lines) {
345
+ let path = "unknown";
346
+ const patchFailedMatch = line.match(/^error:\s+patch failed:\s+(.+?):\d+$/i);
347
+ const doesNotApplyMatch = line.match(/^error:\s+(.+?):\s+patch does not apply$/i);
348
+
349
+ if (patchFailedMatch) {
350
+ path = patchFailedMatch[1];
351
+ } else if (doesNotApplyMatch) {
352
+ path = doesNotApplyMatch[1];
353
+ }
354
+
355
+ if (!conflictsByPath.has(path)) {
356
+ conflictsByPath.set(path, { path, message: line });
357
+ }
358
+ }
359
+
360
+ if (!conflictsByPath.size && lines.length) {
361
+ return [{ path: "unknown", message: lines.join(" ") }];
362
+ }
363
+
364
+ return [...conflictsByPath.values()];
365
+ }
366
+
367
+ async function withRepoMutationLock(cwd, callback) {
368
+ const previous = repoMutationLocks.get(cwd) || Promise.resolve();
369
+ let releaseCurrent = null;
370
+ const current = new Promise((resolve) => {
371
+ releaseCurrent = resolve;
372
+ });
373
+ const chained = previous.then(() => current);
374
+ repoMutationLocks.set(cwd, chained);
375
+
376
+ await previous;
377
+ try {
378
+ return await callback();
379
+ } finally {
380
+ releaseCurrent();
381
+ if (repoMutationLocks.get(cwd) === chained) {
382
+ repoMutationLocks.delete(cwd);
383
+ }
384
+ }
385
+ }
386
+
387
+ async function resolveWorkspaceCwd(params) {
388
+ const requestedCwd = firstNonEmptyString([params.cwd, params.currentWorkingDirectory]);
389
+
390
+ if (!requestedCwd) {
391
+ throw workspaceError(
392
+ "missing_working_directory",
393
+ "Workspace actions require a bound local working directory."
394
+ );
395
+ }
396
+
397
+ if (!isExistingDirectory(requestedCwd)) {
398
+ throw workspaceError(
399
+ "missing_working_directory",
400
+ "The requested local working directory does not exist on this Mac."
401
+ );
402
+ }
403
+
404
+ return requestedCwd;
405
+ }
406
+
407
+ // Resolves the canonical repo root so revert safety checks stay stable from nested chat folders.
408
+ async function resolveRepoRoot(cwd) {
409
+ try {
410
+ const output = await git(cwd, "rev-parse", "--show-toplevel");
411
+ const repoRoot = output.trim();
412
+ if (repoRoot) {
413
+ return repoRoot;
414
+ }
415
+ } catch {
416
+ // Fall through to the user-facing error below.
417
+ }
418
+
419
+ throw workspaceError(
420
+ "missing_working_directory",
421
+ "The selected local folder is not inside a Git repository."
422
+ );
423
+ }
424
+
425
+ function firstNonEmptyString(candidates) {
426
+ for (const candidate of candidates) {
427
+ if (typeof candidate !== "string") {
428
+ continue;
429
+ }
430
+
431
+ const trimmed = candidate.trim();
432
+ if (trimmed) {
433
+ return trimmed;
434
+ }
435
+ }
436
+
437
+ return null;
438
+ }
439
+
440
+ function isExistingDirectory(candidatePath) {
441
+ try {
442
+ return fs.statSync(candidatePath).isDirectory();
443
+ } catch {
444
+ return false;
445
+ }
446
+ }
447
+
448
+ function workspaceError(errorCode, userMessage) {
449
+ const err = new Error(userMessage);
450
+ err.errorCode = errorCode;
451
+ err.userMessage = userMessage;
452
+ return err;
453
+ }
454
+
455
+ function git(cwd, ...args) {
456
+ return execFileAsync("git", args, { cwd, timeout: GIT_TIMEOUT_MS })
457
+ .then(({ stdout }) => stdout)
458
+ .catch((err) => {
459
+ const msg = (err.stderr || err.message || "").trim();
460
+ throw new Error(msg || "git command failed");
461
+ });
462
+ }
463
+
464
+ module.exports = { handleWorkspaceRequest };