@makerbi/remodex 1.3.9 → 1.4.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/bin/remodex.js +1 -0
- package/package.json +3 -3
- package/src/bridge.js +87 -13
- package/src/codex-desktop-refresher.js +1 -0
- package/src/desktop-ipc-action-follower.js +648 -0
- package/src/git-handler.js +313 -6
- package/src/package-version-status.js +1 -1
- package/src/workspace-handler.js +23 -3
package/bin/remodex.js
CHANGED
package/package.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@makerbi/remodex",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.0",
|
|
4
4
|
"description": "Local bridge between Codex and the Remodex mobile app. Run `remodex up` to start.",
|
|
5
5
|
"main": "src/index.js",
|
|
6
6
|
"bin": {
|
|
7
|
-
"remodex": "
|
|
7
|
+
"remodex": "bin/remodex.js"
|
|
8
8
|
},
|
|
9
9
|
"files": [
|
|
10
10
|
"bin/",
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
"cli"
|
|
25
25
|
],
|
|
26
26
|
"author": "Emanuele Di Pietro",
|
|
27
|
-
"license": "
|
|
27
|
+
"license": "Apache-2.0",
|
|
28
28
|
"type": "commonjs",
|
|
29
29
|
"dependencies": {
|
|
30
30
|
"qrcode-terminal": "^0.12.0",
|
package/src/bridge.js
CHANGED
|
@@ -40,6 +40,10 @@ const {
|
|
|
40
40
|
} = require("./secure-device-state");
|
|
41
41
|
const { createBridgeSecureTransport } = require("./secure-transport");
|
|
42
42
|
const { createRolloutLiveMirrorController } = require("./rollout-live-mirror");
|
|
43
|
+
const {
|
|
44
|
+
createDesktopIpcActionFollower,
|
|
45
|
+
seedConversationStateFromThreadRead,
|
|
46
|
+
} = require("./desktop-ipc-action-follower");
|
|
43
47
|
const { version: bridgePackageVersion = "" } = require("../package.json");
|
|
44
48
|
const {
|
|
45
49
|
MINIMUM_SUPPORTED_IOS_APP_VERSION,
|
|
@@ -55,6 +59,9 @@ const RELAY_WATCHDOG_PING_INTERVAL_MS = 10_000;
|
|
|
55
59
|
const RELAY_WATCHDOG_STALE_AFTER_MS = 70_000;
|
|
56
60
|
const BRIDGE_STATUS_HEARTBEAT_INTERVAL_MS = 5_000;
|
|
57
61
|
const STALE_RELAY_STATUS_MESSAGE = "Relay heartbeat stalled; reconnect pending.";
|
|
62
|
+
const CLOSE_CODE_INVALID_RELAY_REQUEST = 4000;
|
|
63
|
+
const CLOSE_CODE_REPLACED_BY_NEW_MAC = 4001;
|
|
64
|
+
const CLOSE_CODE_MAC_UNAUTHORIZED = 4005;
|
|
58
65
|
const RELAY_HISTORY_IMAGE_REFERENCE_URL = "remodex://history-image-elided";
|
|
59
66
|
const RELAY_THREAD_PAYLOAD_SOFT_LIMIT_BYTES = 4 * 1024 * 1024;
|
|
60
67
|
const RELAY_HISTORY_TEXT_TAIL_LIMIT_CHARS = 24_000;
|
|
@@ -146,6 +153,7 @@ function startBridge({
|
|
|
146
153
|
let lastRelayActivityAt = 0;
|
|
147
154
|
let lastPublishedBridgeStatus = null;
|
|
148
155
|
let lastConnectionStatus = null;
|
|
156
|
+
let lastConnectionError = "";
|
|
149
157
|
let codexLaunchState = config.codexEndpoint ? "connected" : "starting";
|
|
150
158
|
let codexHandshakeState = config.codexEndpoint ? "warm" : "cold";
|
|
151
159
|
const forwardedInitializeRequestIds = new Set();
|
|
@@ -194,6 +202,13 @@ function startBridge({
|
|
|
194
202
|
sendApplicationResponse,
|
|
195
203
|
})
|
|
196
204
|
: null;
|
|
205
|
+
const desktopIpcActionFollower = !config.codexEndpoint
|
|
206
|
+
? createDesktopIpcActionFollower({
|
|
207
|
+
sendApplicationResponse,
|
|
208
|
+
readConversationState: readDesktopConversationState,
|
|
209
|
+
socketPath: config.desktopIpcSocketPath || undefined,
|
|
210
|
+
})
|
|
211
|
+
: null;
|
|
197
212
|
let contextUsageWatcher = null;
|
|
198
213
|
let watchedContextUsageKey = null;
|
|
199
214
|
|
|
@@ -322,35 +337,42 @@ function startBridge({
|
|
|
322
337
|
}
|
|
323
338
|
|
|
324
339
|
// Keeps npm start output compact by emitting only high-signal connection states.
|
|
325
|
-
function logConnectionStatus(status) {
|
|
326
|
-
if (lastConnectionStatus === status) {
|
|
340
|
+
function logConnectionStatus(status, lastError = "") {
|
|
341
|
+
if (lastConnectionStatus === status && lastConnectionError === lastError) {
|
|
327
342
|
return;
|
|
328
343
|
}
|
|
329
344
|
|
|
330
345
|
lastConnectionStatus = status;
|
|
346
|
+
lastConnectionError = lastError;
|
|
331
347
|
publishBridgeStatus({
|
|
332
348
|
state: "running",
|
|
333
349
|
connectionStatus: status,
|
|
334
350
|
pid: process.pid,
|
|
335
|
-
lastError
|
|
351
|
+
lastError,
|
|
336
352
|
});
|
|
337
353
|
console.log(`[remodex] ${status}`);
|
|
354
|
+
if (lastError) {
|
|
355
|
+
console.error(`[remodex] ${lastError}`);
|
|
356
|
+
}
|
|
338
357
|
}
|
|
339
358
|
|
|
340
359
|
// Retries the relay socket while preserving the active Codex process and session id.
|
|
341
|
-
function scheduleRelayReconnect(closeCode) {
|
|
360
|
+
function scheduleRelayReconnect(closeCode, closeReason = "") {
|
|
342
361
|
if (isShuttingDown) {
|
|
343
362
|
return;
|
|
344
363
|
}
|
|
345
364
|
|
|
346
|
-
if (closeCode
|
|
347
|
-
|
|
365
|
+
if (isTerminalRelayCloseCode(closeCode)) {
|
|
366
|
+
const lastError = buildRelayCloseStatusError(closeCode, closeReason);
|
|
367
|
+
logConnectionStatus("disconnected", lastError);
|
|
348
368
|
shutdown(codex, () => socket, () => {
|
|
349
369
|
isShuttingDown = true;
|
|
350
370
|
bridgeWakeAssertion.stop();
|
|
351
371
|
clearReconnectTimer();
|
|
352
372
|
clearRelayWatchdog();
|
|
353
373
|
clearBridgeStatusHeartbeat();
|
|
374
|
+
}, {
|
|
375
|
+
exitCode: closeCode === CLOSE_CODE_MAC_UNAUTHORIZED ? 1 : 0,
|
|
354
376
|
});
|
|
355
377
|
return;
|
|
356
378
|
}
|
|
@@ -421,18 +443,20 @@ function startBridge({
|
|
|
421
443
|
markRelayActivity();
|
|
422
444
|
});
|
|
423
445
|
|
|
424
|
-
nextSocket.on("close", (code) => {
|
|
446
|
+
nextSocket.on("close", (code, reason) => {
|
|
447
|
+
const closeReason = normalizeWebSocketCloseReason(reason);
|
|
425
448
|
if (socket === nextSocket) {
|
|
426
449
|
clearRelayWatchdog();
|
|
427
450
|
}
|
|
428
|
-
logConnectionStatus("disconnected");
|
|
451
|
+
logConnectionStatus("disconnected", buildRelayCloseStatusError(code, closeReason));
|
|
429
452
|
if (socket === nextSocket) {
|
|
430
453
|
socket = null;
|
|
431
454
|
}
|
|
432
455
|
stopContextUsageWatcher();
|
|
433
456
|
rolloutLiveMirror?.stopAll();
|
|
457
|
+
desktopIpcActionFollower?.stopAll();
|
|
434
458
|
desktopRefresher.handleTransportReset();
|
|
435
|
-
scheduleRelayReconnect(code);
|
|
459
|
+
scheduleRelayReconnect(code, closeReason);
|
|
436
460
|
});
|
|
437
461
|
|
|
438
462
|
nextSocket.on("error", () => {
|
|
@@ -473,18 +497,20 @@ function startBridge({
|
|
|
473
497
|
codex.onClose(() => {
|
|
474
498
|
clearRelayWatchdog();
|
|
475
499
|
clearBridgeStatusHeartbeat();
|
|
476
|
-
|
|
500
|
+
const lastError = lastConnectionError || "";
|
|
501
|
+
logConnectionStatus("disconnected", lastError);
|
|
477
502
|
publishBridgeStatus({
|
|
478
503
|
state: "stopped",
|
|
479
504
|
connectionStatus: "disconnected",
|
|
480
505
|
pid: process.pid,
|
|
481
|
-
lastError
|
|
506
|
+
lastError,
|
|
482
507
|
});
|
|
483
508
|
isShuttingDown = true;
|
|
484
509
|
bridgeWakeAssertion.stop();
|
|
485
510
|
clearReconnectTimer();
|
|
486
511
|
stopContextUsageWatcher();
|
|
487
512
|
rolloutLiveMirror?.stopAll();
|
|
513
|
+
desktopIpcActionFollower?.stopAll();
|
|
488
514
|
desktopRefresher.handleTransportReset();
|
|
489
515
|
failBridgeManagedCodexRequests(new Error("Codex transport closed before the bridge request completed."));
|
|
490
516
|
forwardedRequestMethodsById.clear();
|
|
@@ -547,6 +573,9 @@ function startBridge({
|
|
|
547
573
|
}
|
|
548
574
|
desktopRefresher.handleInbound(rawMessage);
|
|
549
575
|
rolloutLiveMirror?.observeInbound(rawMessage);
|
|
576
|
+
if (desktopIpcActionFollower?.observeInbound(rawMessage)) {
|
|
577
|
+
return;
|
|
578
|
+
}
|
|
550
579
|
rememberForwardedRequestMethod(rawMessage);
|
|
551
580
|
rememberThreadFromMessage("phone", rawMessage);
|
|
552
581
|
codex.send(rawMessage);
|
|
@@ -579,6 +608,15 @@ function startBridge({
|
|
|
579
608
|
}));
|
|
580
609
|
}
|
|
581
610
|
|
|
611
|
+
// Seeds the desktop IPC follower when it receives patches before a full snapshot.
|
|
612
|
+
async function readDesktopConversationState(threadId) {
|
|
613
|
+
const result = await sendCodexRequest("thread/read", {
|
|
614
|
+
threadId,
|
|
615
|
+
includeTurns: true,
|
|
616
|
+
});
|
|
617
|
+
return seedConversationStateFromThreadRead(result);
|
|
618
|
+
}
|
|
619
|
+
|
|
582
620
|
// ─── Bridge-owned auth snapshot ─────────────────────────────
|
|
583
621
|
|
|
584
622
|
// Handles the bridge-owned auth status wrappers without exposing tokens to the phone.
|
|
@@ -1281,7 +1319,7 @@ function buildMacRegistration(deviceState, pairingSession) {
|
|
|
1281
1319
|
};
|
|
1282
1320
|
}
|
|
1283
1321
|
|
|
1284
|
-
function shutdown(codex, getSocket, beforeExit = () => {}) {
|
|
1322
|
+
function shutdown(codex, getSocket, beforeExit = () => {}, { exitCode = 0 } = {}) {
|
|
1285
1323
|
beforeExit();
|
|
1286
1324
|
|
|
1287
1325
|
const socket = getSocket();
|
|
@@ -1291,7 +1329,41 @@ function shutdown(codex, getSocket, beforeExit = () => {}) {
|
|
|
1291
1329
|
|
|
1292
1330
|
codex.shutdown();
|
|
1293
1331
|
|
|
1294
|
-
setTimeout(() => process.exit(
|
|
1332
|
+
setTimeout(() => process.exit(exitCode), 100);
|
|
1333
|
+
}
|
|
1334
|
+
|
|
1335
|
+
function isTerminalRelayCloseCode(closeCode) {
|
|
1336
|
+
return closeCode === CLOSE_CODE_INVALID_RELAY_REQUEST
|
|
1337
|
+
|| closeCode === CLOSE_CODE_REPLACED_BY_NEW_MAC
|
|
1338
|
+
|| closeCode === CLOSE_CODE_MAC_UNAUTHORIZED;
|
|
1339
|
+
}
|
|
1340
|
+
|
|
1341
|
+
function normalizeWebSocketCloseReason(reason) {
|
|
1342
|
+
if (typeof reason === "string") {
|
|
1343
|
+
return reason.trim();
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1346
|
+
if (Buffer.isBuffer(reason)) {
|
|
1347
|
+
return reason.toString("utf8").trim();
|
|
1348
|
+
}
|
|
1349
|
+
|
|
1350
|
+
return "";
|
|
1351
|
+
}
|
|
1352
|
+
|
|
1353
|
+
function buildRelayCloseStatusError(closeCode, closeReason = "") {
|
|
1354
|
+
if (!Number.isInteger(closeCode) || closeCode === 1000 || closeCode === 1005) {
|
|
1355
|
+
return "";
|
|
1356
|
+
}
|
|
1357
|
+
|
|
1358
|
+
const normalizedReason = normalizeNonEmptyString(closeReason);
|
|
1359
|
+
if (closeCode === CLOSE_CODE_MAC_UNAUTHORIZED) {
|
|
1360
|
+
return normalizedReason
|
|
1361
|
+
|| "Relay authorization failed. Set REMODEX_RELAY_ACCESS_TOKEN or use a relay that does not require a Mac access token.";
|
|
1362
|
+
}
|
|
1363
|
+
|
|
1364
|
+
return normalizedReason
|
|
1365
|
+
? `Relay closed the connection (${closeCode}): ${normalizedReason}`
|
|
1366
|
+
: `Relay closed the connection (${closeCode}).`;
|
|
1295
1367
|
}
|
|
1296
1368
|
|
|
1297
1369
|
function extractBridgeMessageContext(rawMessage) {
|
|
@@ -2103,10 +2175,12 @@ function persistBridgePreferences(
|
|
|
2103
2175
|
|
|
2104
2176
|
module.exports = {
|
|
2105
2177
|
buildHeartbeatBridgeStatus,
|
|
2178
|
+
buildRelayCloseStatusError,
|
|
2106
2179
|
buildRelayAccessTokenHeaders,
|
|
2107
2180
|
buildRelayUserAgentHeader,
|
|
2108
2181
|
createMacOSBridgeWakeAssertion,
|
|
2109
2182
|
hasRelayConnectionGoneStale,
|
|
2183
|
+
isTerminalRelayCloseCode,
|
|
2110
2184
|
persistBridgePreferences,
|
|
2111
2185
|
sanitizeLiveGeneratedImageMessageForRelay,
|
|
2112
2186
|
sanitizeThreadHistoryImagesForRelay,
|
|
@@ -587,6 +587,7 @@ function readBridgeConfig({
|
|
|
587
587
|
? (persistedKeepMacAwakeEnabled == null ? false : persistedKeepMacAwakeEnabled)
|
|
588
588
|
: explicitKeepMacAwakeEnabled,
|
|
589
589
|
codexEndpoint,
|
|
590
|
+
desktopIpcSocketPath: readFirstDefinedEnv(["REMODEX_DESKTOP_IPC_SOCKET"], "", env),
|
|
590
591
|
refreshCommand,
|
|
591
592
|
codexBundleId: readFirstDefinedEnv(["REMODEX_CODEX_BUNDLE_ID"], DEFAULT_BUNDLE_ID, env),
|
|
592
593
|
codexAppPath: DEFAULT_APP_PATH,
|
|
@@ -0,0 +1,648 @@
|
|
|
1
|
+
// FILE: desktop-ipc-action-follower.js
|
|
2
|
+
// Purpose: Mirrors live Codex Desktop IPC pending actions to the phone and routes replies back to the desktop runtime.
|
|
3
|
+
// Layer: CLI helper
|
|
4
|
+
// Exports: createDesktopIpcActionFollower, projectPendingDesktopActions
|
|
5
|
+
// Depends on: net, os, path
|
|
6
|
+
|
|
7
|
+
const net = require("net");
|
|
8
|
+
const os = require("os");
|
|
9
|
+
const path = require("path");
|
|
10
|
+
|
|
11
|
+
const FRAME_HEADER_BYTES = 4;
|
|
12
|
+
const MAX_FRAME_BYTES = 256 * 1024 * 1024;
|
|
13
|
+
const REQUEST_TIMEOUT_MS = 10_000;
|
|
14
|
+
const DESKTOP_RESUME_METHODS = new Set(["thread/read", "thread/resume"]);
|
|
15
|
+
const ACTION_METHODS = new Set([
|
|
16
|
+
"item/commandExecution/requestApproval",
|
|
17
|
+
"item/fileChange/requestApproval",
|
|
18
|
+
"item/fileRead/requestApproval",
|
|
19
|
+
"item/tool/requestUserInput",
|
|
20
|
+
]);
|
|
21
|
+
const REPLY_METHOD_BY_ACTION_METHOD = new Map([
|
|
22
|
+
["item/commandExecution/requestApproval", "thread-follower-command-approval-decision"],
|
|
23
|
+
["item/fileChange/requestApproval", "thread-follower-file-approval-decision"],
|
|
24
|
+
["item/fileRead/requestApproval", "thread-follower-file-approval-decision"],
|
|
25
|
+
["item/tool/requestUserInput", "thread-follower-submit-user-input"],
|
|
26
|
+
]);
|
|
27
|
+
const METHOD_VERSION_BY_NAME = new Map([
|
|
28
|
+
["initialize", 1],
|
|
29
|
+
["thread-follower-command-approval-decision", 1],
|
|
30
|
+
["thread-follower-file-approval-decision", 1],
|
|
31
|
+
["thread-follower-submit-user-input", 1],
|
|
32
|
+
]);
|
|
33
|
+
const APPROVAL_DECISIONS = new Set(["accept", "acceptForSession", "decline", "cancel"]);
|
|
34
|
+
|
|
35
|
+
// Opens the Desktop IPC bus on demand and exposes Mac-owned pending actions as normal app-server requests.
|
|
36
|
+
function createDesktopIpcActionFollower({
|
|
37
|
+
sendApplicationResponse,
|
|
38
|
+
readConversationState = null,
|
|
39
|
+
logPrefix = "[remodex]",
|
|
40
|
+
socketPath = resolveDefaultIpcSocketPath(),
|
|
41
|
+
netModule = net,
|
|
42
|
+
now = () => Date.now(),
|
|
43
|
+
requestTimeoutMs = REQUEST_TIMEOUT_MS,
|
|
44
|
+
} = {}) {
|
|
45
|
+
const ipc = createDesktopIpcClient({
|
|
46
|
+
socketPath,
|
|
47
|
+
netModule,
|
|
48
|
+
now,
|
|
49
|
+
requestTimeoutMs,
|
|
50
|
+
logPrefix,
|
|
51
|
+
onEnvelope,
|
|
52
|
+
onDisconnect,
|
|
53
|
+
});
|
|
54
|
+
const rawStatesByThreadId = new Map();
|
|
55
|
+
const pendingRoutesByRequestId = new Map();
|
|
56
|
+
const activeThreadIds = new Set();
|
|
57
|
+
const recoveringThreadIds = new Set();
|
|
58
|
+
const queuedChangesByThreadId = new Map();
|
|
59
|
+
|
|
60
|
+
function observeInbound(rawMessage) {
|
|
61
|
+
const message = safeParseJSON(rawMessage);
|
|
62
|
+
const responseRoute = desktopRouteForResponse(message);
|
|
63
|
+
if (responseRoute) {
|
|
64
|
+
submitDesktopActionResponse(responseRoute, message);
|
|
65
|
+
return true;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const method = readString(message?.method);
|
|
69
|
+
if (!DESKTOP_RESUME_METHODS.has(method)) {
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const threadId = readThreadId(message?.params);
|
|
74
|
+
if (!threadId) {
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
activeThreadIds.add(threadId);
|
|
79
|
+
ipc.ensureConnected();
|
|
80
|
+
recoverThreadBaseline(threadId);
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function stopAll() {
|
|
85
|
+
rawStatesByThreadId.clear();
|
|
86
|
+
pendingRoutesByRequestId.clear();
|
|
87
|
+
activeThreadIds.clear();
|
|
88
|
+
recoveringThreadIds.clear();
|
|
89
|
+
queuedChangesByThreadId.clear();
|
|
90
|
+
ipc.close();
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Desktop broadcasts carry the live conversation state Litter projects from.
|
|
94
|
+
function onEnvelope(envelope) {
|
|
95
|
+
if (envelope?.type !== "broadcast" || envelope.method !== "thread-stream-state-changed") {
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const params = envelope.params || {};
|
|
100
|
+
const threadId = readString(params.conversationId) || readString(params.conversation_id);
|
|
101
|
+
if (!threadId || !activeThreadIds.has(threadId)) {
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (recoveringThreadIds.has(threadId)) {
|
|
106
|
+
queueThreadChange(threadId, params.change);
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const previousState = rawStatesByThreadId.get(threadId) || null;
|
|
111
|
+
const nextState = applyConversationStateChange(previousState, params.change);
|
|
112
|
+
if (!nextState) {
|
|
113
|
+
if (isPatchChange(params.change)) {
|
|
114
|
+
queueThreadChange(threadId, params.change);
|
|
115
|
+
recoverThreadBaseline(threadId);
|
|
116
|
+
}
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
rawStatesByThreadId.set(threadId, nextState);
|
|
121
|
+
syncProjectedActions(threadId, projectPendingDesktopActions(threadId, nextState));
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function onDisconnect() {
|
|
125
|
+
rawStatesByThreadId.clear();
|
|
126
|
+
pendingRoutesByRequestId.clear();
|
|
127
|
+
recoveringThreadIds.clear();
|
|
128
|
+
queuedChangesByThreadId.clear();
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function syncProjectedActions(threadId, actions) {
|
|
132
|
+
const nextRequestIds = new Set(actions.map((action) => action.id));
|
|
133
|
+
for (const [requestId, route] of Array.from(pendingRoutesByRequestId.entries())) {
|
|
134
|
+
if (route.threadId !== threadId || nextRequestIds.has(requestId)) {
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
pendingRoutesByRequestId.delete(requestId);
|
|
139
|
+
sendApplicationResponse(JSON.stringify({
|
|
140
|
+
method: "serverRequest/resolved",
|
|
141
|
+
params: {
|
|
142
|
+
threadId,
|
|
143
|
+
requestId,
|
|
144
|
+
},
|
|
145
|
+
}));
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
for (const action of actions) {
|
|
149
|
+
if (pendingRoutesByRequestId.has(action.id)) {
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
pendingRoutesByRequestId.set(action.id, {
|
|
154
|
+
requestId: action.id,
|
|
155
|
+
method: action.method,
|
|
156
|
+
threadId,
|
|
157
|
+
});
|
|
158
|
+
sendApplicationResponse(JSON.stringify({
|
|
159
|
+
id: action.id,
|
|
160
|
+
method: action.method,
|
|
161
|
+
params: action.params,
|
|
162
|
+
}));
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function desktopRouteForResponse(message) {
|
|
167
|
+
if (!message || typeof message !== "object" || message.method) {
|
|
168
|
+
return null;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const requestId = requestIdKey(message.id);
|
|
172
|
+
return requestId ? pendingRoutesByRequestId.get(requestId) || null : null;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function submitDesktopActionResponse(route, responseMessage) {
|
|
176
|
+
const payload = desktopFollowerPayloadForResponse(route, responseMessage);
|
|
177
|
+
if (!payload) {
|
|
178
|
+
sendApplicationResponse(JSON.stringify({
|
|
179
|
+
id: responseMessage?.id ?? route.requestId,
|
|
180
|
+
error: {
|
|
181
|
+
code: -32602,
|
|
182
|
+
message: "Invalid desktop action response.",
|
|
183
|
+
},
|
|
184
|
+
}));
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
ipc.sendRequest(payload.method, payload.params)
|
|
189
|
+
.then(() => {
|
|
190
|
+
pendingRoutesByRequestId.delete(route.requestId);
|
|
191
|
+
sendApplicationResponse(JSON.stringify({
|
|
192
|
+
method: "serverRequest/resolved",
|
|
193
|
+
params: {
|
|
194
|
+
threadId: route.threadId,
|
|
195
|
+
requestId: route.requestId,
|
|
196
|
+
},
|
|
197
|
+
}));
|
|
198
|
+
})
|
|
199
|
+
.catch((error) => {
|
|
200
|
+
console.warn(`${logPrefix} desktop action reply failed for ${route.threadId}: ${error.message}`);
|
|
201
|
+
sendApplicationResponse(JSON.stringify({
|
|
202
|
+
id: responseMessage.id,
|
|
203
|
+
error: {
|
|
204
|
+
code: -32000,
|
|
205
|
+
message: "Could not send this action to Codex on the Mac.",
|
|
206
|
+
},
|
|
207
|
+
}));
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function queueThreadChange(threadId, change) {
|
|
212
|
+
if (!change || typeof change !== "object") {
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const queuedChanges = queuedChangesByThreadId.get(threadId) || [];
|
|
217
|
+
queuedChanges.push(change);
|
|
218
|
+
queuedChangesByThreadId.set(threadId, queuedChanges);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function recoverThreadBaseline(threadId) {
|
|
222
|
+
if (typeof readConversationState !== "function"
|
|
223
|
+
|| recoveringThreadIds.has(threadId)
|
|
224
|
+
|| rawStatesByThreadId.has(threadId)) {
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
recoveringThreadIds.add(threadId);
|
|
229
|
+
Promise.resolve()
|
|
230
|
+
.then(() => readConversationState(threadId))
|
|
231
|
+
.then((baselineState) => {
|
|
232
|
+
if (!baselineState || typeof baselineState !== "object") {
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
let nextState = cloneJSON(baselineState);
|
|
237
|
+
const queuedChanges = queuedChangesByThreadId.get(threadId) || [];
|
|
238
|
+
queuedChangesByThreadId.delete(threadId);
|
|
239
|
+
for (const change of queuedChanges) {
|
|
240
|
+
nextState = applyConversationStateChange(nextState, change) || nextState;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
rawStatesByThreadId.set(threadId, nextState);
|
|
244
|
+
syncProjectedActions(threadId, projectPendingDesktopActions(threadId, nextState));
|
|
245
|
+
})
|
|
246
|
+
.catch((error) => {
|
|
247
|
+
console.warn(`${logPrefix} desktop IPC baseline recovery failed for ${threadId}: ${error.message}`);
|
|
248
|
+
})
|
|
249
|
+
.finally(() => {
|
|
250
|
+
recoveringThreadIds.delete(threadId);
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
return {
|
|
255
|
+
observeInbound,
|
|
256
|
+
stopAll,
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Minimal IPC client for Litter's length-prefixed Codex desktop bus.
|
|
261
|
+
function createDesktopIpcClient({
|
|
262
|
+
socketPath,
|
|
263
|
+
netModule,
|
|
264
|
+
now,
|
|
265
|
+
requestTimeoutMs,
|
|
266
|
+
logPrefix,
|
|
267
|
+
onEnvelope,
|
|
268
|
+
onDisconnect,
|
|
269
|
+
}) {
|
|
270
|
+
let socket = null;
|
|
271
|
+
let clientId = "";
|
|
272
|
+
let isConnecting = false;
|
|
273
|
+
let readBuffer = Buffer.alloc(0);
|
|
274
|
+
const pendingRequests = new Map();
|
|
275
|
+
|
|
276
|
+
function ensureConnected() {
|
|
277
|
+
if (socket || isConnecting) {
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
isConnecting = true;
|
|
282
|
+
const nextSocket = netModule.createConnection(socketPath);
|
|
283
|
+
socket = nextSocket;
|
|
284
|
+
|
|
285
|
+
nextSocket.on("connect", () => {
|
|
286
|
+
isConnecting = false;
|
|
287
|
+
sendRequest("initialize", { clientType: "remodex-bridge" })
|
|
288
|
+
.then((result) => {
|
|
289
|
+
clientId = readString(result?.clientId) || clientId;
|
|
290
|
+
})
|
|
291
|
+
.catch((error) => {
|
|
292
|
+
console.warn(`${logPrefix} desktop IPC initialize failed: ${error.message}`);
|
|
293
|
+
close();
|
|
294
|
+
});
|
|
295
|
+
});
|
|
296
|
+
nextSocket.on("data", handleData);
|
|
297
|
+
nextSocket.on("close", handleClose);
|
|
298
|
+
nextSocket.on("error", (error) => {
|
|
299
|
+
if (error?.code !== "ENOENT" && error?.code !== "ECONNREFUSED") {
|
|
300
|
+
console.warn(`${logPrefix} desktop IPC connection failed: ${error.message}`);
|
|
301
|
+
}
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function sendRequest(method, params) {
|
|
306
|
+
ensureConnected();
|
|
307
|
+
if (!socket || socket.destroyed) {
|
|
308
|
+
return Promise.reject(new Error("Desktop IPC is not connected."));
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const requestId = `remodex-${now().toString(36)}-${Math.random().toString(16).slice(2)}`;
|
|
312
|
+
const envelope = {
|
|
313
|
+
type: "request",
|
|
314
|
+
requestId,
|
|
315
|
+
sourceClientId: method === "initialize" ? "initializing-client" : clientId || "remodex-bridge",
|
|
316
|
+
version: METHOD_VERSION_BY_NAME.get(method) || 1,
|
|
317
|
+
method,
|
|
318
|
+
params: params || {},
|
|
319
|
+
};
|
|
320
|
+
|
|
321
|
+
return new Promise((resolve, reject) => {
|
|
322
|
+
const timeout = setTimeout(() => {
|
|
323
|
+
pendingRequests.delete(requestId);
|
|
324
|
+
reject(new Error(`Desktop IPC request timed out: ${method}`));
|
|
325
|
+
}, requestTimeoutMs);
|
|
326
|
+
timeout.unref?.();
|
|
327
|
+
|
|
328
|
+
pendingRequests.set(requestId, {
|
|
329
|
+
method,
|
|
330
|
+
resolve,
|
|
331
|
+
reject,
|
|
332
|
+
timeout,
|
|
333
|
+
});
|
|
334
|
+
writeFrame(socket, JSON.stringify(envelope), (error) => {
|
|
335
|
+
if (!error) {
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
clearTimeout(timeout);
|
|
340
|
+
pendingRequests.delete(requestId);
|
|
341
|
+
reject(error);
|
|
342
|
+
});
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function handleData(chunk) {
|
|
347
|
+
readBuffer = Buffer.concat([readBuffer, chunk]);
|
|
348
|
+
while (readBuffer.length >= FRAME_HEADER_BYTES) {
|
|
349
|
+
const frameLength = readBuffer.readUInt32LE(0);
|
|
350
|
+
if (frameLength > MAX_FRAME_BYTES) {
|
|
351
|
+
close();
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
if (readBuffer.length < FRAME_HEADER_BYTES + frameLength) {
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
const payload = readBuffer.slice(FRAME_HEADER_BYTES, FRAME_HEADER_BYTES + frameLength).toString("utf8");
|
|
359
|
+
readBuffer = readBuffer.slice(FRAME_HEADER_BYTES + frameLength);
|
|
360
|
+
const envelope = safeParseJSON(payload);
|
|
361
|
+
if (envelope) {
|
|
362
|
+
dispatchEnvelope(envelope);
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function dispatchEnvelope(envelope) {
|
|
368
|
+
if (envelope.type === "client-discovery-request") {
|
|
369
|
+
writeEnvelope({
|
|
370
|
+
type: "client-discovery-response",
|
|
371
|
+
requestId: envelope.requestId,
|
|
372
|
+
response: {
|
|
373
|
+
canHandle: false,
|
|
374
|
+
},
|
|
375
|
+
});
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
if (envelope.type === "response") {
|
|
380
|
+
const requestId = requestIdKey(envelope.requestId);
|
|
381
|
+
const waiter = requestId ? pendingRequests.get(requestId) : null;
|
|
382
|
+
if (!waiter) {
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
pendingRequests.delete(requestId);
|
|
387
|
+
clearTimeout(waiter.timeout);
|
|
388
|
+
if (envelope.resultType === "error") {
|
|
389
|
+
waiter.reject(new Error(envelope.error || `Desktop IPC request failed: ${waiter.method}`));
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
waiter.resolve(envelope.result ?? null);
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
onEnvelope(envelope);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
function handleClose() {
|
|
401
|
+
socket = null;
|
|
402
|
+
clientId = "";
|
|
403
|
+
isConnecting = false;
|
|
404
|
+
readBuffer = Buffer.alloc(0);
|
|
405
|
+
for (const waiter of pendingRequests.values()) {
|
|
406
|
+
clearTimeout(waiter.timeout);
|
|
407
|
+
waiter.reject(new Error("Desktop IPC connection closed."));
|
|
408
|
+
}
|
|
409
|
+
pendingRequests.clear();
|
|
410
|
+
onDisconnect();
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
function close() {
|
|
414
|
+
if (!socket) {
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
const nextSocket = socket;
|
|
419
|
+
socket = null;
|
|
420
|
+
nextSocket.destroy();
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
function writeEnvelope(envelope, callback = () => {}) {
|
|
424
|
+
if (!socket || socket.destroyed) {
|
|
425
|
+
callback(new Error("Desktop IPC is not connected."));
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
writeFrame(socket, JSON.stringify(envelope), callback);
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
return {
|
|
433
|
+
ensureConnected,
|
|
434
|
+
sendRequest,
|
|
435
|
+
close,
|
|
436
|
+
};
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
function desktopFollowerPayloadForResponse(route, responseMessage) {
|
|
440
|
+
const method = REPLY_METHOD_BY_ACTION_METHOD.get(route.method);
|
|
441
|
+
if (!method || responseMessage?.error) {
|
|
442
|
+
return null;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
if (route.method === "item/tool/requestUserInput") {
|
|
446
|
+
const answers = responseMessage?.result?.answers;
|
|
447
|
+
if (!answers || typeof answers !== "object" || Array.isArray(answers)) {
|
|
448
|
+
return null;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
return {
|
|
452
|
+
method,
|
|
453
|
+
params: {
|
|
454
|
+
conversationId: route.threadId,
|
|
455
|
+
requestId: route.requestId,
|
|
456
|
+
response: {
|
|
457
|
+
answers,
|
|
458
|
+
},
|
|
459
|
+
},
|
|
460
|
+
};
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
const decision = readString(responseMessage?.result?.decision);
|
|
464
|
+
if (!APPROVAL_DECISIONS.has(decision)) {
|
|
465
|
+
return null;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
return {
|
|
469
|
+
method,
|
|
470
|
+
params: {
|
|
471
|
+
conversationId: route.threadId,
|
|
472
|
+
requestId: route.requestId,
|
|
473
|
+
decision,
|
|
474
|
+
},
|
|
475
|
+
};
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
function projectPendingDesktopActions(threadId, conversationState) {
|
|
479
|
+
const requests = Array.isArray(conversationState?.requests) ? conversationState.requests : [];
|
|
480
|
+
return requests
|
|
481
|
+
.filter((request) => request && request.completed !== true)
|
|
482
|
+
.filter((request) => ACTION_METHODS.has(readString(request.method)))
|
|
483
|
+
.map((request) => projectPendingDesktopAction(threadId, request))
|
|
484
|
+
.filter(Boolean);
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
function projectPendingDesktopAction(threadId, request) {
|
|
488
|
+
const requestId = requestIdKey(request.id);
|
|
489
|
+
const method = readString(request.method);
|
|
490
|
+
const params = request.params && typeof request.params === "object" && !Array.isArray(request.params)
|
|
491
|
+
? request.params
|
|
492
|
+
: {};
|
|
493
|
+
if (!requestId || !method) {
|
|
494
|
+
return null;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
if (method === "item/tool/requestUserInput") {
|
|
498
|
+
const questions = Array.isArray(params.questions) ? params.questions : [];
|
|
499
|
+
if (questions.length === 0) {
|
|
500
|
+
return null;
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
return {
|
|
505
|
+
id: requestId,
|
|
506
|
+
method,
|
|
507
|
+
params: {
|
|
508
|
+
...params,
|
|
509
|
+
threadId: readString(params.threadId) || readString(params.thread_id) || threadId,
|
|
510
|
+
},
|
|
511
|
+
};
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
function applyConversationStateChange(previousState, change) {
|
|
515
|
+
if (!change || typeof change !== "object") {
|
|
516
|
+
return null;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
if (change.type === "snapshot" || change.type === "Snapshot") {
|
|
520
|
+
return cloneJSON(change.conversationState || change.conversation_state || {});
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
if (change.type !== "patches" && change.type !== "Patches") {
|
|
524
|
+
return previousState || null;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
const patches = Array.isArray(change.patches) ? change.patches : [];
|
|
528
|
+
if (!previousState || patches.length === 0) {
|
|
529
|
+
return previousState || null;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
const nextState = cloneJSON(previousState);
|
|
533
|
+
for (const patch of patches) {
|
|
534
|
+
applyImmerPatch(nextState, patch);
|
|
535
|
+
}
|
|
536
|
+
return nextState;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
function isPatchChange(change) {
|
|
540
|
+
return change?.type === "patches" || change?.type === "Patches";
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
function seedConversationStateFromThreadRead(response) {
|
|
544
|
+
const conversationState = response?.conversationState || response?.conversation_state;
|
|
545
|
+
if (conversationState && typeof conversationState === "object" && !Array.isArray(conversationState)) {
|
|
546
|
+
return cloneJSON(conversationState);
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
const thread = response?.thread && typeof response.thread === "object" && !Array.isArray(response.thread)
|
|
550
|
+
? response.thread
|
|
551
|
+
: {};
|
|
552
|
+
return {
|
|
553
|
+
turns: Array.isArray(thread.turns) ? cloneJSON(thread.turns) : [],
|
|
554
|
+
requests: Array.isArray(thread.requests) ? cloneJSON(thread.requests) : [],
|
|
555
|
+
};
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
function applyImmerPatch(target, patch) {
|
|
559
|
+
const patchPath = Array.isArray(patch?.path) ? patch.path : [];
|
|
560
|
+
const op = readString(patch?.op).toLowerCase();
|
|
561
|
+
if (!op || patchPath.length === 0) {
|
|
562
|
+
return;
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
let parent = target;
|
|
566
|
+
for (let index = 0; index < patchPath.length - 1; index += 1) {
|
|
567
|
+
parent = parent?.[patchPath[index]];
|
|
568
|
+
if (parent == null) {
|
|
569
|
+
return;
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
const key = patchPath[patchPath.length - 1];
|
|
574
|
+
if (op === "remove") {
|
|
575
|
+
if (Array.isArray(parent) && Number.isInteger(key)) {
|
|
576
|
+
parent.splice(key, 1);
|
|
577
|
+
} else if (parent && typeof parent === "object") {
|
|
578
|
+
delete parent[key];
|
|
579
|
+
}
|
|
580
|
+
return;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
if (op === "add" || op === "replace") {
|
|
584
|
+
if (Array.isArray(parent) && Number.isInteger(key)) {
|
|
585
|
+
if (op === "add") {
|
|
586
|
+
parent.splice(key, 0, patch.value);
|
|
587
|
+
} else {
|
|
588
|
+
parent[key] = patch.value;
|
|
589
|
+
}
|
|
590
|
+
} else if (parent && typeof parent === "object") {
|
|
591
|
+
parent[key] = patch.value;
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
function writeFrame(socket, payload, callback) {
|
|
597
|
+
const body = Buffer.from(payload, "utf8");
|
|
598
|
+
const header = Buffer.alloc(FRAME_HEADER_BYTES);
|
|
599
|
+
header.writeUInt32LE(body.length, 0);
|
|
600
|
+
socket.write(Buffer.concat([header, body]), callback);
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
function resolveDefaultIpcSocketPath() {
|
|
604
|
+
const uid = typeof process.getuid === "function" ? process.getuid() : 0;
|
|
605
|
+
return path.join(os.tmpdir(), "codex-ipc", `ipc-${uid}.sock`);
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
function readThreadId(params) {
|
|
609
|
+
return readString(params?.threadId)
|
|
610
|
+
|| readString(params?.thread_id)
|
|
611
|
+
|| readString(params?.conversationId)
|
|
612
|
+
|| readString(params?.conversation_id);
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
function requestIdKey(value) {
|
|
616
|
+
if (typeof value === "string" && value) {
|
|
617
|
+
return value;
|
|
618
|
+
}
|
|
619
|
+
if (typeof value === "number" && Number.isFinite(value)) {
|
|
620
|
+
return String(value);
|
|
621
|
+
}
|
|
622
|
+
return "";
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
function readString(value) {
|
|
626
|
+
return typeof value === "string" && value.trim() ? value.trim() : "";
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
function cloneJSON(value) {
|
|
630
|
+
return JSON.parse(JSON.stringify(value));
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
function safeParseJSON(value) {
|
|
634
|
+
try {
|
|
635
|
+
return JSON.parse(value);
|
|
636
|
+
} catch {
|
|
637
|
+
return null;
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
module.exports = {
|
|
642
|
+
applyConversationStateChange,
|
|
643
|
+
createDesktopIpcActionFollower,
|
|
644
|
+
desktopFollowerPayloadForResponse,
|
|
645
|
+
projectPendingDesktopActions,
|
|
646
|
+
resolveDefaultIpcSocketPath,
|
|
647
|
+
seedConversationStateFromThreadRead,
|
|
648
|
+
};
|
package/src/git-handler.js
CHANGED
|
@@ -14,11 +14,13 @@ const { promisify } = require("util");
|
|
|
14
14
|
const execFileAsync = promisify(execFile);
|
|
15
15
|
const GIT_TIMEOUT_MS = 30_000;
|
|
16
16
|
const GIT_DRAFT_TIMEOUT_MS = 120_000;
|
|
17
|
+
const GITHUB_CLI_TIMEOUT_MS = 120_000;
|
|
17
18
|
const GIT_DRAFT_PATCH_MAX_BYTES = 80_000;
|
|
18
19
|
const EMPTY_TREE_HASH = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
|
|
19
20
|
const DEFAULT_GIT_WRITER_MODEL = "gpt-5.4-mini";
|
|
20
21
|
|
|
21
22
|
let runStructuredCodexJsonImpl = runStructuredCodexJson;
|
|
23
|
+
let runGitHubCliImpl = runGitHubCli;
|
|
22
24
|
|
|
23
25
|
function resolveGitWriterModel(rawModel) {
|
|
24
26
|
const trimmed = typeof rawModel === "string" ? rawModel.trim() : "";
|
|
@@ -123,6 +125,10 @@ async function handleGitMethod(method, params, options = {}) {
|
|
|
123
125
|
return gitRemoteUrl(cwd);
|
|
124
126
|
case "git/generatePullRequestDraft":
|
|
125
127
|
return gitGeneratePullRequestDraft(cwd, params, options);
|
|
128
|
+
case "git/createPullRequest":
|
|
129
|
+
return gitCreatePullRequest(cwd, params, options);
|
|
130
|
+
case "git/runStackedAction":
|
|
131
|
+
return gitRunStackedAction(cwd, params, options);
|
|
126
132
|
case "git/branchesWithStatus":
|
|
127
133
|
return gitBranchesWithStatus(cwd);
|
|
128
134
|
default:
|
|
@@ -400,7 +406,7 @@ async function gitPush(cwd) {
|
|
|
400
406
|
pushErr.message?.includes("no upstream") ||
|
|
401
407
|
pushErr.message?.includes("has no upstream branch")
|
|
402
408
|
) {
|
|
403
|
-
await git(cwd, "push", "--set-upstream",
|
|
409
|
+
await git(cwd, "push", "--set-upstream", remote, branch);
|
|
404
410
|
} else {
|
|
405
411
|
throw pushErr;
|
|
406
412
|
}
|
|
@@ -963,6 +969,282 @@ async function gitRemoteUrl(cwd) {
|
|
|
963
969
|
return { url: raw, ownerRepo };
|
|
964
970
|
}
|
|
965
971
|
|
|
972
|
+
// ─── Git Stacked Actions / Pull Requests ─────────────────────
|
|
973
|
+
|
|
974
|
+
async function gitRunStackedAction(cwd, params, options = {}) {
|
|
975
|
+
const action = normalizeStackedGitAction(params.action);
|
|
976
|
+
const initialStatus = await gitStatus(cwd);
|
|
977
|
+
const wantsCommit = action === "commit" || action === "commit_push" || action === "commit_push_pr";
|
|
978
|
+
const wantsPr = action === "create_pr" || action === "commit_push_pr";
|
|
979
|
+
|
|
980
|
+
if (params.featureBranch === true) {
|
|
981
|
+
await gitCreateFeatureBranch(cwd, params);
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
const branch = await currentBranchName(cwd);
|
|
985
|
+
const result = {
|
|
986
|
+
action,
|
|
987
|
+
branch: {
|
|
988
|
+
status: params.featureBranch === true ? "created" : "skipped_not_requested",
|
|
989
|
+
name: params.featureBranch === true ? branch : undefined,
|
|
990
|
+
},
|
|
991
|
+
commit: { status: "skipped_not_requested" },
|
|
992
|
+
push: { status: "skipped_not_requested" },
|
|
993
|
+
pr: { status: "skipped_not_requested" },
|
|
994
|
+
status: initialStatus,
|
|
995
|
+
};
|
|
996
|
+
|
|
997
|
+
if (action === "push" && initialStatus.dirty) {
|
|
998
|
+
throw gitError("dirty_worktree", "Commit or stash local changes before pushing.");
|
|
999
|
+
}
|
|
1000
|
+
if (action === "create_pr" && initialStatus.dirty) {
|
|
1001
|
+
throw gitError("dirty_worktree", "Commit local changes before creating a PR.");
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
if (wantsCommit) {
|
|
1005
|
+
const statusBeforeCommit = await gitStatus(cwd);
|
|
1006
|
+
if (statusBeforeCommit.dirty) {
|
|
1007
|
+
const commitResult = await gitCommit(cwd, {
|
|
1008
|
+
message: params.commitMessage || params.message,
|
|
1009
|
+
});
|
|
1010
|
+
result.commit = {
|
|
1011
|
+
status: "created",
|
|
1012
|
+
...commitResult,
|
|
1013
|
+
commitSha: commitResult.hash,
|
|
1014
|
+
subject: firstCommitMessageLine(params.commitMessage || params.message),
|
|
1015
|
+
};
|
|
1016
|
+
} else if (action === "commit") {
|
|
1017
|
+
throw gitError("nothing_to_commit", "Nothing to commit.");
|
|
1018
|
+
} else {
|
|
1019
|
+
result.commit = { status: "skipped_clean" };
|
|
1020
|
+
}
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
const statusBeforePush = await gitStatus(cwd);
|
|
1024
|
+
const shouldPush =
|
|
1025
|
+
action === "push" ||
|
|
1026
|
+
action === "commit_push" ||
|
|
1027
|
+
action === "commit_push_pr" ||
|
|
1028
|
+
(action === "create_pr" && (!statusBeforePush.tracking || statusBeforePush.ahead > 0));
|
|
1029
|
+
|
|
1030
|
+
if (shouldPush) {
|
|
1031
|
+
if (action === "push" && !statusBeforePush.canPush) {
|
|
1032
|
+
throw gitError("nothing_to_push", "Nothing to push.");
|
|
1033
|
+
}
|
|
1034
|
+
if (action === "commit_push" && result.commit.status === "skipped_clean" && !statusBeforePush.canPush) {
|
|
1035
|
+
throw gitError("nothing_to_commit", "Nothing to commit or push.");
|
|
1036
|
+
}
|
|
1037
|
+
if (statusBeforePush.dirty) {
|
|
1038
|
+
throw gitError("dirty_worktree", "Commit or stash local changes before pushing.");
|
|
1039
|
+
}
|
|
1040
|
+
result.push = {
|
|
1041
|
+
state: "pushed",
|
|
1042
|
+
...(await gitPush(cwd)),
|
|
1043
|
+
};
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
if (wantsPr) {
|
|
1047
|
+
result.pr = await gitCreatePullRequest(cwd, {
|
|
1048
|
+
...params,
|
|
1049
|
+
pushBeforeCreate: false,
|
|
1050
|
+
}, options);
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
result.status = await gitStatus(cwd);
|
|
1054
|
+
return result;
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
async function gitCreatePullRequest(cwd, params, options = {}) {
|
|
1058
|
+
const status = await gitStatus(cwd);
|
|
1059
|
+
if (status.dirty) {
|
|
1060
|
+
throw gitError("dirty_worktree", "Commit local changes before creating a PR.");
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
const branch = status.branch || await currentBranchName(cwd);
|
|
1064
|
+
if (!branch || branch === "HEAD") {
|
|
1065
|
+
throw gitError("no_branch", "No current branch found.");
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
if (params.pushBeforeCreate !== false && (!status.tracking || status.ahead > 0)) {
|
|
1069
|
+
await gitPush(cwd);
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
const branchResult = await gitBranches(cwd);
|
|
1073
|
+
const baseBranch = resolveBaseBranchName(params.baseBranch, branchResult.default || branchResult.defaultBranch);
|
|
1074
|
+
if (!baseBranch) {
|
|
1075
|
+
throw gitError("no_default_branch", "Could not determine the repository default branch.");
|
|
1076
|
+
}
|
|
1077
|
+
if (baseBranch === branch) {
|
|
1078
|
+
throw gitError(
|
|
1079
|
+
"pull_request_same_branch",
|
|
1080
|
+
`Cannot create a pull request from '${branch}' into itself. Create or switch to a feature branch first.`
|
|
1081
|
+
);
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
await ensureGitHubCliReady(cwd);
|
|
1085
|
+
const existing = await findOpenPullRequest(cwd, branch);
|
|
1086
|
+
if (existing) {
|
|
1087
|
+
return pullRequestResult("opened_existing", existing, baseBranch, branch);
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
const draft = await generatePullRequestDraftOrFallback(cwd, params, options, baseBranch, branch);
|
|
1091
|
+
const bodyFile = path.join(os.tmpdir(), `remodex-pr-body-${process.pid}-${Date.now()}-${randomBytes(4).toString("hex")}.md`);
|
|
1092
|
+
fs.writeFileSync(bodyFile, draft.body, "utf8");
|
|
1093
|
+
|
|
1094
|
+
let createOutput = null;
|
|
1095
|
+
try {
|
|
1096
|
+
createOutput = await gitHubCli(cwd, [
|
|
1097
|
+
"pr",
|
|
1098
|
+
"create",
|
|
1099
|
+
"--base",
|
|
1100
|
+
baseBranch,
|
|
1101
|
+
"--head",
|
|
1102
|
+
branch,
|
|
1103
|
+
"--title",
|
|
1104
|
+
draft.title,
|
|
1105
|
+
"--body-file",
|
|
1106
|
+
bodyFile,
|
|
1107
|
+
]);
|
|
1108
|
+
} catch (error) {
|
|
1109
|
+
const existingFromError = await findOpenPullRequest(cwd, branch).catch(() => null);
|
|
1110
|
+
if (existingFromError || isPullRequestAlreadyExistsMessage(error.message)) {
|
|
1111
|
+
return pullRequestResult("opened_existing", existingFromError, baseBranch, branch, draft.title);
|
|
1112
|
+
}
|
|
1113
|
+
throw error;
|
|
1114
|
+
} finally {
|
|
1115
|
+
fs.rmSync(bodyFile, { force: true });
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
const created = await findOpenPullRequest(cwd, branch).catch(() => null);
|
|
1119
|
+
const createdUrl = parsePullRequestUrlFromText(`${createOutput?.stdout || ""}\n${createOutput?.stderr || ""}`);
|
|
1120
|
+
return pullRequestResult("created", created, baseBranch, branch, draft.title, createdUrl);
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
function normalizeStackedGitAction(rawAction) {
|
|
1124
|
+
const action = typeof rawAction === "string" ? rawAction.trim() : "";
|
|
1125
|
+
if (["commit", "push", "create_pr", "commit_push", "commit_push_pr"].includes(action)) {
|
|
1126
|
+
return action;
|
|
1127
|
+
}
|
|
1128
|
+
throw gitError("invalid_git_action", "Unknown git action.");
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
async function gitCreateFeatureBranch(cwd, params) {
|
|
1132
|
+
const requestedName = normalizeNonEmptyLine(params.featureBranchName || params.branchName);
|
|
1133
|
+
const branchName = requestedName || await defaultFeatureBranchName(cwd, params);
|
|
1134
|
+
await assertValidCreatedBranchName(cwd, branchName);
|
|
1135
|
+
if (await branchExists(cwd, branchName)) {
|
|
1136
|
+
throw gitError("branch_exists", `Branch '${branchName}' already exists.`);
|
|
1137
|
+
}
|
|
1138
|
+
await git(cwd, "checkout", "-b", branchName);
|
|
1139
|
+
return branchName;
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
async function defaultFeatureBranchName(cwd, params) {
|
|
1143
|
+
const prefix = normalizeNonEmptyLine(params.featureBranchPrefix) || "remodex/mobile-pr";
|
|
1144
|
+
const timestamp = new Date().toISOString().replace(/[-:T.Z]/g, "").slice(0, 14);
|
|
1145
|
+
const base = `${prefix}-${timestamp}`;
|
|
1146
|
+
let candidate = base;
|
|
1147
|
+
for (let index = 2; await branchExists(cwd, candidate); index += 1) {
|
|
1148
|
+
candidate = `${base}-${index}`;
|
|
1149
|
+
}
|
|
1150
|
+
return candidate;
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
async function branchExists(cwd, branchName) {
|
|
1154
|
+
try {
|
|
1155
|
+
await git(cwd, "show-ref", "--verify", "--quiet", `refs/heads/${branchName}`);
|
|
1156
|
+
return true;
|
|
1157
|
+
} catch {
|
|
1158
|
+
return false;
|
|
1159
|
+
}
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
async function currentBranchName(cwd) {
|
|
1163
|
+
return (await git(cwd, "rev-parse", "--abbrev-ref", "HEAD")).trim();
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
async function generatePullRequestDraftOrFallback(cwd, params, options, baseBranch, branch) {
|
|
1167
|
+
try {
|
|
1168
|
+
return await gitGeneratePullRequestDraft(cwd, { ...params, baseBranch }, options);
|
|
1169
|
+
} catch (error) {
|
|
1170
|
+
if (error?.errorCode && error.errorCode !== "pull_request_draft_generation_failed") {
|
|
1171
|
+
throw error;
|
|
1172
|
+
}
|
|
1173
|
+
return {
|
|
1174
|
+
title: `Update ${branch}`,
|
|
1175
|
+
body: [
|
|
1176
|
+
"## Summary",
|
|
1177
|
+
`- Prepare changes from \`${branch}\` for review.`,
|
|
1178
|
+
"",
|
|
1179
|
+
"## Testing",
|
|
1180
|
+
"- Not run from Remodex.",
|
|
1181
|
+
"",
|
|
1182
|
+
"## Notes",
|
|
1183
|
+
`- Base branch: \`${baseBranch}\`.`,
|
|
1184
|
+
].join("\n"),
|
|
1185
|
+
};
|
|
1186
|
+
}
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
function firstCommitMessageLine(message) {
|
|
1190
|
+
return normalizeNonEmptyLine(message) || "Changes from Remodex";
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
async function ensureGitHubCliReady(cwd) {
|
|
1194
|
+
try {
|
|
1195
|
+
await gitHubCli(cwd, ["auth", "status"]);
|
|
1196
|
+
} catch (error) {
|
|
1197
|
+
if (error?.errorCode) {
|
|
1198
|
+
throw error;
|
|
1199
|
+
}
|
|
1200
|
+
throw gitError("github_cli_unavailable", error.message || "GitHub CLI is unavailable.");
|
|
1201
|
+
}
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
async function findOpenPullRequest(cwd, branch) {
|
|
1205
|
+
const output = await gitHubCli(cwd, [
|
|
1206
|
+
"pr",
|
|
1207
|
+
"list",
|
|
1208
|
+
"--head",
|
|
1209
|
+
branch,
|
|
1210
|
+
"--state",
|
|
1211
|
+
"open",
|
|
1212
|
+
"--limit",
|
|
1213
|
+
"1",
|
|
1214
|
+
"--json",
|
|
1215
|
+
"number,title,url,baseRefName,headRefName,state",
|
|
1216
|
+
]);
|
|
1217
|
+
const pullRequests = JSON.parse(output.stdout.trim() || "[]");
|
|
1218
|
+
return Array.isArray(pullRequests) ? pullRequests[0] || null : null;
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
function pullRequestResult(status, pullRequest, baseBranch, branch, fallbackTitle = "", fallbackUrl = null) {
|
|
1222
|
+
return {
|
|
1223
|
+
status,
|
|
1224
|
+
url: pullRequest?.url || fallbackUrl || null,
|
|
1225
|
+
number: pullRequest?.number || null,
|
|
1226
|
+
baseBranch: pullRequest?.baseRefName || baseBranch,
|
|
1227
|
+
headBranch: pullRequest?.headRefName || branch,
|
|
1228
|
+
title: pullRequest?.title || fallbackTitle || "",
|
|
1229
|
+
};
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1232
|
+
function isPullRequestAlreadyExistsMessage(message) {
|
|
1233
|
+
return typeof message === "string" && /pull request .*already exists|already exists.*pull request/i.test(message);
|
|
1234
|
+
}
|
|
1235
|
+
|
|
1236
|
+
function parsePullRequestUrlFromText(text) {
|
|
1237
|
+
if (typeof text !== "string") {
|
|
1238
|
+
return null;
|
|
1239
|
+
}
|
|
1240
|
+
const match = text.match(/https:\/\/github\.com\/[^\s"'<>]+\/pull\/\d+/);
|
|
1241
|
+
return match ? match[0] : null;
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
async function gitHubCli(cwd, args) {
|
|
1245
|
+
return runGitHubCliImpl(cwd, args);
|
|
1246
|
+
}
|
|
1247
|
+
|
|
966
1248
|
async function buildCommitDraftContext(cwd) {
|
|
967
1249
|
const [statusResult, repoRoot] = await Promise.all([
|
|
968
1250
|
gitStatus(cwd),
|
|
@@ -1010,7 +1292,7 @@ async function buildPullRequestDraftContext(cwd, params) {
|
|
|
1010
1292
|
throw gitError("no_default_branch", "Could not determine the repository default branch.");
|
|
1011
1293
|
}
|
|
1012
1294
|
|
|
1013
|
-
const baseRef = await
|
|
1295
|
+
const baseRef = await resolvePullRequestBaseRef(cwd, baseBranch);
|
|
1014
1296
|
const mergeBase = (await git(cwd, "merge-base", "HEAD", baseRef)).trim();
|
|
1015
1297
|
const patch = truncateDraftPatch(
|
|
1016
1298
|
(await git(cwd, "diff", "--binary", "--find-renames", `${mergeBase}..HEAD`)).trim()
|
|
@@ -1041,16 +1323,17 @@ async function buildPullRequestDraftContext(cwd, params) {
|
|
|
1041
1323
|
};
|
|
1042
1324
|
}
|
|
1043
1325
|
|
|
1044
|
-
|
|
1326
|
+
// PRs compare against the remote base when possible, matching GitHub's base branch.
|
|
1327
|
+
async function resolvePullRequestBaseRef(cwd, branchName) {
|
|
1045
1328
|
const localRef = `refs/heads/${branchName}`;
|
|
1046
1329
|
const remoteRef = `refs/remotes/origin/${branchName}`;
|
|
1047
1330
|
|
|
1048
|
-
if (await refExists(cwd, localRef)) {
|
|
1049
|
-
return localRef;
|
|
1050
|
-
}
|
|
1051
1331
|
if (await refExists(cwd, remoteRef)) {
|
|
1052
1332
|
return remoteRef;
|
|
1053
1333
|
}
|
|
1334
|
+
if (await refExists(cwd, localRef)) {
|
|
1335
|
+
return localRef;
|
|
1336
|
+
}
|
|
1054
1337
|
|
|
1055
1338
|
return branchName;
|
|
1056
1339
|
}
|
|
@@ -2154,6 +2437,22 @@ function git(cwd, ...args) {
|
|
|
2154
2437
|
});
|
|
2155
2438
|
}
|
|
2156
2439
|
|
|
2440
|
+
// Runs GitHub CLI in the same working tree so PR creation respects local auth and remotes.
|
|
2441
|
+
function runGitHubCli(cwd, args) {
|
|
2442
|
+
return execFileAsync("gh", args, { cwd, timeout: GITHUB_CLI_TIMEOUT_MS })
|
|
2443
|
+
.then(({ stdout, stderr }) => ({ stdout, stderr }))
|
|
2444
|
+
.catch((err) => {
|
|
2445
|
+
const detail = (err.stderr || err.message || "").trim();
|
|
2446
|
+
if (err.code === "ENOENT") {
|
|
2447
|
+
throw gitError("github_cli_unavailable", "GitHub CLI (`gh`) is required but is not available on PATH.");
|
|
2448
|
+
}
|
|
2449
|
+
if (/not logged into|not authenticated|gh auth login/i.test(detail)) {
|
|
2450
|
+
throw gitError("github_cli_unauthenticated", "GitHub CLI is not authenticated. Run `gh auth login` on this Mac and retry.");
|
|
2451
|
+
}
|
|
2452
|
+
throw gitError("github_cli_failed", detail || "GitHub CLI command failed.");
|
|
2453
|
+
});
|
|
2454
|
+
}
|
|
2455
|
+
|
|
2157
2456
|
async function revListCounts(cwd) {
|
|
2158
2457
|
const output = await git(cwd, "rev-list", "--left-right", "--count", "HEAD...@{u}");
|
|
2159
2458
|
const parts = output.trim().split(/\s+/);
|
|
@@ -2339,6 +2638,8 @@ module.exports = {
|
|
|
2339
2638
|
__test: {
|
|
2340
2639
|
gitGenerateCommitMessage,
|
|
2341
2640
|
gitGeneratePullRequestDraft,
|
|
2641
|
+
gitCreatePullRequest,
|
|
2642
|
+
gitRunStackedAction,
|
|
2342
2643
|
threadGenerateTitle,
|
|
2343
2644
|
threadNameSet,
|
|
2344
2645
|
gitBranches,
|
|
@@ -2367,5 +2668,11 @@ module.exports = {
|
|
|
2367
2668
|
resetRunStructuredCodexJsonImplementation() {
|
|
2368
2669
|
runStructuredCodexJsonImpl = runStructuredCodexJson;
|
|
2369
2670
|
},
|
|
2671
|
+
setRunGitHubCliImplementation(fn) {
|
|
2672
|
+
runGitHubCliImpl = typeof fn === "function" ? fn : runGitHubCli;
|
|
2673
|
+
},
|
|
2674
|
+
resetRunGitHubCliImplementation() {
|
|
2675
|
+
runGitHubCliImpl = runGitHubCli;
|
|
2676
|
+
},
|
|
2370
2677
|
},
|
|
2371
2678
|
};
|
|
@@ -10,7 +10,7 @@ const { version: installedVersion = "" } = require("../package.json");
|
|
|
10
10
|
const DEFAULT_CACHE_TTL_MS = 30 * 60 * 1000;
|
|
11
11
|
const DEFAULT_EMPTY_CACHE_RETRY_MS = 60 * 1000;
|
|
12
12
|
const DEFAULT_INITIAL_FETCH_WAIT_MS = 250;
|
|
13
|
-
const REMODEX_REGISTRY_URL = "https://registry.npmjs.org/
|
|
13
|
+
const REMODEX_REGISTRY_URL = "https://registry.npmjs.org/@makerbi%2fremodex/latest";
|
|
14
14
|
|
|
15
15
|
function createBridgePackageVersionStatusReader({
|
|
16
16
|
cacheTtlMs = DEFAULT_CACHE_TTL_MS,
|
package/src/workspace-handler.js
CHANGED
|
@@ -132,12 +132,12 @@ async function workspaceReadImage(params) {
|
|
|
132
132
|
throw workspaceError("image_not_found", "The image file no longer exists on this Mac.");
|
|
133
133
|
}
|
|
134
134
|
|
|
135
|
-
const [
|
|
136
|
-
cwd ?
|
|
135
|
+
const [realWorkspaceRoot, realTempRoots] = await Promise.all([
|
|
136
|
+
cwd ? resolveImageWorkspaceRoot(cwd) : null,
|
|
137
137
|
realTemporaryImageRoots(),
|
|
138
138
|
]);
|
|
139
139
|
const isAllowed =
|
|
140
|
-
(
|
|
140
|
+
(realWorkspaceRoot && isPathInside(realImagePath, realWorkspaceRoot))
|
|
141
141
|
|| (realGeneratedImagesRoot && isPathInside(realImagePath, realGeneratedImagesRoot))
|
|
142
142
|
|| realTempRoots.some((tempRoot) => isPathInside(realImagePath, tempRoot));
|
|
143
143
|
if (!isAllowed) {
|
|
@@ -212,6 +212,26 @@ async function realTemporaryImageRoots() {
|
|
|
212
212
|
return Array.from(new Set(roots.filter(Boolean)));
|
|
213
213
|
}
|
|
214
214
|
|
|
215
|
+
// Image previews are read-only, so non-git Codex scratch workspaces can be scoped to their cwd.
|
|
216
|
+
async function resolveImageWorkspaceRoot(cwd) {
|
|
217
|
+
const realRepoRoot = await resolveRepoRoot(cwd).then(realpathOrNull).catch(() => null);
|
|
218
|
+
if (realRepoRoot) {
|
|
219
|
+
return realRepoRoot;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const realCwd = await realpathOrNull(cwd);
|
|
223
|
+
if (!realCwd || isBroadWorkspaceRoot(realCwd)) {
|
|
224
|
+
return null;
|
|
225
|
+
}
|
|
226
|
+
return realCwd;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function isBroadWorkspaceRoot(candidatePath) {
|
|
230
|
+
const normalized = path.resolve(candidatePath);
|
|
231
|
+
return normalized === path.parse(normalized).root
|
|
232
|
+
|| normalized === path.resolve(os.homedir());
|
|
233
|
+
}
|
|
234
|
+
|
|
215
235
|
async function readPreviewImageData(imagePath, maxPixelDimension, originalByteLength) {
|
|
216
236
|
if (!usesSipsImagePreview()) {
|
|
217
237
|
if (originalByteLength <= MAX_IMAGE_PREVIEW_READ_BYTES) {
|