qq-codex-bridge 0.1.2 → 0.1.4
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/.env.example +62 -0
- package/README.md +232 -287
- package/bin/chatgpt-desktop.js +2 -0
- package/bin/qq-codex-weixin-gateway.js +14 -0
- package/dist/apps/bridge-daemon/src/bootstrap.js +161 -31
- package/dist/apps/bridge-daemon/src/cli.js +5 -1
- package/dist/apps/bridge-daemon/src/config.js +168 -37
- package/dist/apps/bridge-daemon/src/http-server.js +23 -11
- package/dist/apps/bridge-daemon/src/main.js +163 -29
- package/dist/apps/bridge-daemon/src/thread-command-handler.js +320 -23
- package/dist/apps/chatgpt-desktop-cli/src/cli.js +191 -0
- package/dist/apps/weixin-gateway/src/cli.js +446 -0
- package/dist/apps/weixin-gateway/src/config.js +135 -0
- package/dist/apps/weixin-gateway/src/dev.js +2 -0
- package/dist/apps/weixin-gateway/src/message-store.js +50 -0
- package/dist/apps/weixin-gateway/src/server.js +216 -0
- package/dist/apps/weixin-gateway/src/state.js +163 -0
- package/dist/apps/weixin-gateway/src/weixin-client.js +520 -0
- package/dist/packages/adapters/chatgpt-desktop/src/ax-client.js +472 -0
- package/dist/packages/adapters/chatgpt-desktop/src/bridge-provider.js +82 -0
- package/dist/packages/adapters/chatgpt-desktop/src/driver.js +161 -0
- package/dist/packages/adapters/chatgpt-desktop/src/image-cache.js +155 -0
- package/dist/packages/adapters/chatgpt-desktop/src/session-registry.js +48 -0
- package/dist/packages/adapters/chatgpt-desktop/src/types.js +1 -0
- package/dist/packages/adapters/codex-desktop/src/codex-app-server-driver.js +810 -0
- package/dist/packages/adapters/codex-desktop/src/codex-app-ui-notification-forwarder.js +33 -0
- package/dist/packages/adapters/codex-desktop/src/codex-desktop-driver.js +727 -123
- package/dist/packages/adapters/codex-desktop/src/codex-local-rollout-reader.js +227 -0
- package/dist/packages/adapters/codex-desktop/src/codex-local-submission-reader.js +142 -0
- package/dist/packages/adapters/weixin/src/weixin-channel-adapter.js +15 -0
- package/dist/packages/adapters/weixin/src/weixin-http-client.js +42 -0
- package/dist/packages/adapters/weixin/src/weixin-sender.js +200 -0
- package/dist/packages/adapters/weixin/src/weixin-webhook.js +35 -0
- package/dist/packages/orchestrator/src/bridge-orchestrator.js +72 -25
- package/dist/packages/orchestrator/src/weixin-outbound-format.js +55 -0
- package/dist/packages/ports/src/chat.js +1 -0
- package/dist/packages/store/src/session-repo.js +16 -3
- package/dist/packages/store/src/sqlite.js +3 -0
- package/package.json +8 -2
|
@@ -0,0 +1,810 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import net from "node:net";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { spawn } from "node:child_process";
|
|
6
|
+
import WebSocket from "ws";
|
|
7
|
+
import { DesktopDriverError } from "../../../domain/src/driver.js";
|
|
8
|
+
import { buildMediaArtifactFromReference, parseQqMediaSegments } from "../../qq/src/qq-media-parser.js";
|
|
9
|
+
const APP_THREAD_REF_PREFIX = "codex-app-thread:";
|
|
10
|
+
const LEGACY_THREAD_REF_PREFIX = "codex-thread:";
|
|
11
|
+
export class CodexAppServerDriver {
|
|
12
|
+
connectTimeoutMs;
|
|
13
|
+
replyTimeoutMs;
|
|
14
|
+
requestTimeoutMs;
|
|
15
|
+
staleTurnInterruptMs;
|
|
16
|
+
sleep;
|
|
17
|
+
createWebSocket;
|
|
18
|
+
controlFallback;
|
|
19
|
+
notificationForwarder;
|
|
20
|
+
externalAppServerUrl;
|
|
21
|
+
codexBinaryPath;
|
|
22
|
+
appServerUrl = null;
|
|
23
|
+
child = null;
|
|
24
|
+
socket = null;
|
|
25
|
+
connectPromise = null;
|
|
26
|
+
nextRequestId = 1;
|
|
27
|
+
initialized = false;
|
|
28
|
+
pendingRequests = new Map();
|
|
29
|
+
pendingTurnsBySession = new Map();
|
|
30
|
+
pendingTurnsByKey = new Map();
|
|
31
|
+
notificationForwardTail = Promise.resolve();
|
|
32
|
+
lastNotificationForwardErrorAt = 0;
|
|
33
|
+
constructor(options = {}) {
|
|
34
|
+
this.externalAppServerUrl =
|
|
35
|
+
options.appServerUrl ?? process.env.CODEX_APP_SERVER_URL ?? null;
|
|
36
|
+
this.codexBinaryPath =
|
|
37
|
+
options.codexBinaryPath
|
|
38
|
+
?? process.env.CODEX_BINARY_PATH
|
|
39
|
+
?? resolveDefaultCodexBinaryPath();
|
|
40
|
+
this.connectTimeoutMs = options.connectTimeoutMs ?? 15_000;
|
|
41
|
+
this.replyTimeoutMs = options.replyTimeoutMs ?? 10 * 60_000;
|
|
42
|
+
this.requestTimeoutMs = options.requestTimeoutMs ?? 30_000;
|
|
43
|
+
this.staleTurnInterruptMs = options.staleTurnInterruptMs ?? this.replyTimeoutMs;
|
|
44
|
+
this.sleep =
|
|
45
|
+
options.sleep ??
|
|
46
|
+
((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
47
|
+
this.createWebSocket =
|
|
48
|
+
options.createWebSocket ??
|
|
49
|
+
((url) => new WebSocket(url));
|
|
50
|
+
this.controlFallback = options.controlFallback ?? null;
|
|
51
|
+
this.notificationForwarder = options.notificationForwarder ?? null;
|
|
52
|
+
}
|
|
53
|
+
async ensureAppReady() {
|
|
54
|
+
await this.ensureConnected();
|
|
55
|
+
}
|
|
56
|
+
async getControlState(binding = null) {
|
|
57
|
+
try {
|
|
58
|
+
await this.ensureConnected();
|
|
59
|
+
const [config, controlThread] = await Promise.all([
|
|
60
|
+
this.request("config/read", {
|
|
61
|
+
includeLayers: false
|
|
62
|
+
}).catch(() => null),
|
|
63
|
+
this.getControlThread(binding?.codexThreadRef ?? null).catch(() => null)
|
|
64
|
+
]);
|
|
65
|
+
const quotaSummary = await this.getQuotaSummary().catch(() => null);
|
|
66
|
+
const effectiveConfig = config?.config ?? {};
|
|
67
|
+
const threadSummary = controlThread ? this.threadToSummary(controlThread, 1) : null;
|
|
68
|
+
return {
|
|
69
|
+
threadRef: threadSummary?.threadRef ?? binding?.codexThreadRef ?? null,
|
|
70
|
+
threadTitle: threadSummary?.title ?? null,
|
|
71
|
+
threadProjectName: threadSummary?.projectName ?? null,
|
|
72
|
+
threadRelativeTime: threadSummary?.relativeTime ?? null,
|
|
73
|
+
model: readString(effectiveConfig.model),
|
|
74
|
+
reasoningEffort: readString(effectiveConfig.model_reasoning_effort),
|
|
75
|
+
workspace: controlThread?.cwd ? path.basename(controlThread.cwd) : null,
|
|
76
|
+
branch: controlThread?.gitInfo?.branch ?? null,
|
|
77
|
+
permissionMode: formatPermissionMode(effectiveConfig.approval_policy, effectiveConfig.sandbox_mode),
|
|
78
|
+
quotaSummary
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
return (await this.controlFallback?.getControlState().catch(() => null)) ?? {
|
|
83
|
+
model: null,
|
|
84
|
+
reasoningEffort: null,
|
|
85
|
+
workspace: null,
|
|
86
|
+
branch: null,
|
|
87
|
+
permissionMode: null,
|
|
88
|
+
quotaSummary: null
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
async getQuotaSummary() {
|
|
93
|
+
try {
|
|
94
|
+
await this.ensureConnected();
|
|
95
|
+
const response = await this.request("account/rateLimits/read");
|
|
96
|
+
const snapshot = response.rateLimitsByLimitId?.codex ?? response.rateLimits ?? null;
|
|
97
|
+
return formatRateLimitSnapshot(snapshot);
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
return this.controlFallback?.getQuotaSummary().catch(() => null) ?? null;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
async switchModel(model) {
|
|
104
|
+
if (this.controlFallback) {
|
|
105
|
+
return this.controlFallback.switchModel(model);
|
|
106
|
+
}
|
|
107
|
+
throw new DesktopDriverError("Codex app-server model switching is not enabled in bridge yet", "control_not_found");
|
|
108
|
+
}
|
|
109
|
+
async openOrBindSession(sessionKey, binding) {
|
|
110
|
+
await this.ensureConnected();
|
|
111
|
+
const existingThreadId = await this.resolveThreadId(binding?.codexThreadRef ?? null);
|
|
112
|
+
if (existingThreadId) {
|
|
113
|
+
const thread = await this.findThreadById(existingThreadId);
|
|
114
|
+
return {
|
|
115
|
+
sessionKey,
|
|
116
|
+
codexThreadRef: this.encodeThreadRef(existingThreadId, thread ? this.threadToSummary(thread, 1).title : existingThreadId, thread ? this.threadToSummary(thread, 1).projectName : null)
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
const latestThread = await this.getLatestThread();
|
|
120
|
+
if (latestThread) {
|
|
121
|
+
const summary = this.threadToSummary(latestThread, 1);
|
|
122
|
+
return {
|
|
123
|
+
sessionKey,
|
|
124
|
+
codexThreadRef: summary.threadRef
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
return this.createThread(sessionKey, "");
|
|
128
|
+
}
|
|
129
|
+
async listRecentThreads(limit) {
|
|
130
|
+
await this.ensureConnected();
|
|
131
|
+
const response = await this.request("thread/list", {
|
|
132
|
+
limit,
|
|
133
|
+
sortKey: "updated_at",
|
|
134
|
+
sortDirection: "desc",
|
|
135
|
+
sourceKinds: [],
|
|
136
|
+
archived: false
|
|
137
|
+
});
|
|
138
|
+
return (response.data ?? [])
|
|
139
|
+
.slice(0, limit)
|
|
140
|
+
.map((thread, index) => this.threadToSummary(thread, index + 1));
|
|
141
|
+
}
|
|
142
|
+
async switchToThread(sessionKey, threadRef) {
|
|
143
|
+
await this.ensureConnected();
|
|
144
|
+
const threadId = await this.resolveThreadId(threadRef);
|
|
145
|
+
if (!threadId) {
|
|
146
|
+
throw new DesktopDriverError("Codex app-server thread binding is invalid", "session_not_found");
|
|
147
|
+
}
|
|
148
|
+
const thread = await this.findThreadById(threadId);
|
|
149
|
+
if (!thread) {
|
|
150
|
+
throw new DesktopDriverError("Codex app-server thread not found", "session_not_found");
|
|
151
|
+
}
|
|
152
|
+
const summary = this.threadToSummary(thread, 1);
|
|
153
|
+
return {
|
|
154
|
+
sessionKey,
|
|
155
|
+
codexThreadRef: summary.threadRef
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
async createThread(sessionKey, seedPrompt) {
|
|
159
|
+
await this.ensureConnected();
|
|
160
|
+
const response = await this.request("thread/start", {
|
|
161
|
+
cwd: process.cwd(),
|
|
162
|
+
experimentalRawEvents: false,
|
|
163
|
+
persistExtendedHistory: true
|
|
164
|
+
});
|
|
165
|
+
const thread = response.thread;
|
|
166
|
+
if (!thread?.id) {
|
|
167
|
+
throw new DesktopDriverError("Codex app-server did not return a thread id", "session_not_found");
|
|
168
|
+
}
|
|
169
|
+
const summary = this.threadToSummary(thread, 1);
|
|
170
|
+
const binding = {
|
|
171
|
+
sessionKey,
|
|
172
|
+
codexThreadRef: summary.threadRef
|
|
173
|
+
};
|
|
174
|
+
if (seedPrompt.trim()) {
|
|
175
|
+
await this.sendUserMessage(binding, {
|
|
176
|
+
messageId: `thread-seed:${randomUUID()}`,
|
|
177
|
+
accountKey: "qqbot:default",
|
|
178
|
+
sessionKey,
|
|
179
|
+
peerKey: "qq:c2c:thread-control",
|
|
180
|
+
chatType: "c2c",
|
|
181
|
+
senderId: "thread-control",
|
|
182
|
+
text: seedPrompt,
|
|
183
|
+
receivedAt: new Date().toISOString()
|
|
184
|
+
});
|
|
185
|
+
await this.collectAssistantReply(binding).catch(() => []);
|
|
186
|
+
}
|
|
187
|
+
return binding;
|
|
188
|
+
}
|
|
189
|
+
async sendUserMessage(binding, message) {
|
|
190
|
+
await this.ensureConnected();
|
|
191
|
+
const threadId = await this.resolveThreadId(binding.codexThreadRef);
|
|
192
|
+
if (!threadId) {
|
|
193
|
+
throw new DesktopDriverError("Codex app-server thread binding is missing", "session_not_found");
|
|
194
|
+
}
|
|
195
|
+
await this.request("thread/resume", {
|
|
196
|
+
threadId,
|
|
197
|
+
persistExtendedHistory: true
|
|
198
|
+
}).catch(() => undefined);
|
|
199
|
+
await this.interruptStaleRunningTurn(threadId);
|
|
200
|
+
await this.forwardThreadSnapshotToApp(threadId);
|
|
201
|
+
const response = await this.request("turn/start", {
|
|
202
|
+
threadId,
|
|
203
|
+
input: [
|
|
204
|
+
{
|
|
205
|
+
type: "text",
|
|
206
|
+
text: message.text,
|
|
207
|
+
text_elements: []
|
|
208
|
+
}
|
|
209
|
+
]
|
|
210
|
+
});
|
|
211
|
+
const turnId = response.turn?.id;
|
|
212
|
+
if (!turnId) {
|
|
213
|
+
throw new DesktopDriverError("Codex app-server did not return a turn id", "submit_failed");
|
|
214
|
+
}
|
|
215
|
+
const pending = this.createPendingTurn(binding.sessionKey, threadId, turnId);
|
|
216
|
+
this.pendingTurnsBySession.set(binding.sessionKey, pending);
|
|
217
|
+
this.pendingTurnsByKey.set(buildTurnKey(threadId, turnId), pending);
|
|
218
|
+
}
|
|
219
|
+
async collectAssistantReply(binding, options = {}) {
|
|
220
|
+
const pending = this.pendingTurnsBySession.get(binding.sessionKey);
|
|
221
|
+
if (!pending) {
|
|
222
|
+
throw new DesktopDriverError("Codex app-server has no pending turn for this session", "reply_timeout");
|
|
223
|
+
}
|
|
224
|
+
let result;
|
|
225
|
+
try {
|
|
226
|
+
result = await withTimeout(pending.promise, this.replyTimeoutMs, "Codex app-server reply did not arrive before timeout");
|
|
227
|
+
}
|
|
228
|
+
catch (error) {
|
|
229
|
+
await this.interruptTurn(pending.threadId, pending.turnId).catch(() => undefined);
|
|
230
|
+
throw error;
|
|
231
|
+
}
|
|
232
|
+
finally {
|
|
233
|
+
this.pendingTurnsBySession.delete(binding.sessionKey);
|
|
234
|
+
this.pendingTurnsByKey.delete(buildTurnKey(pending.threadId, pending.turnId));
|
|
235
|
+
}
|
|
236
|
+
const draft = this.buildOutboundDraftFromText(binding.sessionKey, result.finalText, result.mediaReferences, result.turnId);
|
|
237
|
+
if (options.onDraft) {
|
|
238
|
+
await options.onDraft(draft);
|
|
239
|
+
return [];
|
|
240
|
+
}
|
|
241
|
+
return [draft];
|
|
242
|
+
}
|
|
243
|
+
async markSessionBroken(_sessionKey, _reason) {
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
async ensureConnected() {
|
|
247
|
+
if (this.socket?.readyState === WebSocket.OPEN && this.initialized) {
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
if (this.connectPromise) {
|
|
251
|
+
await this.connectPromise;
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
this.connectPromise = this.connect();
|
|
255
|
+
try {
|
|
256
|
+
await this.connectPromise;
|
|
257
|
+
}
|
|
258
|
+
finally {
|
|
259
|
+
this.connectPromise = null;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
async connect() {
|
|
263
|
+
const url = this.externalAppServerUrl ?? await this.startManagedAppServer();
|
|
264
|
+
const startedAt = Date.now();
|
|
265
|
+
let lastError = null;
|
|
266
|
+
while (Date.now() - startedAt < this.connectTimeoutMs) {
|
|
267
|
+
try {
|
|
268
|
+
await this.openSocket(url);
|
|
269
|
+
await this.request("initialize", {
|
|
270
|
+
clientInfo: {
|
|
271
|
+
name: "qq-codex-bridge",
|
|
272
|
+
title: "QQ Codex Bridge",
|
|
273
|
+
version: "0.1.3"
|
|
274
|
+
},
|
|
275
|
+
capabilities: {
|
|
276
|
+
experimentalApi: true
|
|
277
|
+
}
|
|
278
|
+
});
|
|
279
|
+
this.initialized = true;
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
catch (error) {
|
|
283
|
+
lastError = error instanceof Error ? error : new Error(String(error));
|
|
284
|
+
this.socket?.close();
|
|
285
|
+
this.socket = null;
|
|
286
|
+
await this.sleep(250);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
throw new DesktopDriverError(`Codex app-server is not ready: ${lastError?.message ?? "connection timeout"}`, "app_not_ready");
|
|
290
|
+
}
|
|
291
|
+
async startManagedAppServer() {
|
|
292
|
+
if (this.appServerUrl) {
|
|
293
|
+
return this.appServerUrl;
|
|
294
|
+
}
|
|
295
|
+
const port = await getFreePort();
|
|
296
|
+
const url = `ws://127.0.0.1:${port}`;
|
|
297
|
+
this.child = spawn(this.codexBinaryPath, ["app-server", "--listen", url, "-c", "analytics.enabled=false"], {
|
|
298
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
299
|
+
});
|
|
300
|
+
this.child.on("exit", () => {
|
|
301
|
+
this.socket?.close();
|
|
302
|
+
this.socket = null;
|
|
303
|
+
this.initialized = false;
|
|
304
|
+
this.appServerUrl = null;
|
|
305
|
+
});
|
|
306
|
+
this.child.stderr?.on("data", (chunk) => {
|
|
307
|
+
this.logCodexAppServerStderr(String(chunk));
|
|
308
|
+
});
|
|
309
|
+
this.child.stdout?.on("data", (chunk) => {
|
|
310
|
+
const text = String(chunk).trim();
|
|
311
|
+
if (text) {
|
|
312
|
+
console.info("[qq-codex-bridge] codex app-server", { text });
|
|
313
|
+
}
|
|
314
|
+
});
|
|
315
|
+
this.appServerUrl = url;
|
|
316
|
+
console.info("[qq-codex-bridge] codex app-server starting", {
|
|
317
|
+
url,
|
|
318
|
+
binary: this.codexBinaryPath
|
|
319
|
+
});
|
|
320
|
+
return url;
|
|
321
|
+
}
|
|
322
|
+
logCodexAppServerStderr(rawText) {
|
|
323
|
+
const text = stripAnsi(rawText).trim();
|
|
324
|
+
if (!text || isNoisyCodexBackendWebsocketError(text)) {
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
console.warn("[qq-codex-bridge] codex app-server stderr", { text });
|
|
328
|
+
}
|
|
329
|
+
async openSocket(url) {
|
|
330
|
+
const socket = this.createWebSocket(url);
|
|
331
|
+
await new Promise((resolve, reject) => {
|
|
332
|
+
const timeout = setTimeout(() => {
|
|
333
|
+
reject(new Error("websocket open timeout"));
|
|
334
|
+
}, this.connectTimeoutMs);
|
|
335
|
+
socket.on("open", () => {
|
|
336
|
+
clearTimeout(timeout);
|
|
337
|
+
resolve();
|
|
338
|
+
});
|
|
339
|
+
socket.on("error", (error) => {
|
|
340
|
+
clearTimeout(timeout);
|
|
341
|
+
reject(error);
|
|
342
|
+
});
|
|
343
|
+
});
|
|
344
|
+
socket.on("message", (data) => {
|
|
345
|
+
this.handleSocketMessage(String(data));
|
|
346
|
+
});
|
|
347
|
+
socket.on("close", () => {
|
|
348
|
+
this.initialized = false;
|
|
349
|
+
this.socket = null;
|
|
350
|
+
for (const request of this.pendingRequests.values()) {
|
|
351
|
+
clearTimeout(request.timeout);
|
|
352
|
+
request.reject(new Error("Codex app-server websocket closed"));
|
|
353
|
+
}
|
|
354
|
+
this.pendingRequests.clear();
|
|
355
|
+
});
|
|
356
|
+
socket.on("error", (error) => {
|
|
357
|
+
console.warn("[qq-codex-bridge] codex app-server websocket error", {
|
|
358
|
+
error: error.message
|
|
359
|
+
});
|
|
360
|
+
});
|
|
361
|
+
this.socket = socket;
|
|
362
|
+
}
|
|
363
|
+
request(method, params) {
|
|
364
|
+
if (!this.socket || this.socket.readyState !== WebSocket.OPEN) {
|
|
365
|
+
return Promise.reject(new Error("Codex app-server websocket is not connected"));
|
|
366
|
+
}
|
|
367
|
+
const id = this.nextRequestId++;
|
|
368
|
+
const payload = typeof params === "undefined"
|
|
369
|
+
? { jsonrpc: "2.0", id, method }
|
|
370
|
+
: { jsonrpc: "2.0", id, method, params };
|
|
371
|
+
return new Promise((resolve, reject) => {
|
|
372
|
+
const timeout = setTimeout(() => {
|
|
373
|
+
this.pendingRequests.delete(id);
|
|
374
|
+
reject(new Error(`Codex app-server request timed out: ${method}`));
|
|
375
|
+
}, this.requestTimeoutMs);
|
|
376
|
+
this.pendingRequests.set(id, {
|
|
377
|
+
resolve: (value) => resolve(value),
|
|
378
|
+
reject,
|
|
379
|
+
timeout
|
|
380
|
+
});
|
|
381
|
+
this.socket.send(JSON.stringify(payload));
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
handleSocketMessage(raw) {
|
|
385
|
+
let message;
|
|
386
|
+
try {
|
|
387
|
+
message = JSON.parse(raw);
|
|
388
|
+
}
|
|
389
|
+
catch {
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
if (isJsonRpcResponse(message)) {
|
|
393
|
+
const pending = this.pendingRequests.get(message.id);
|
|
394
|
+
if (!pending) {
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
clearTimeout(pending.timeout);
|
|
398
|
+
this.pendingRequests.delete(message.id);
|
|
399
|
+
if (message.error) {
|
|
400
|
+
pending.reject(new Error(message.error.message ?? "Codex app-server request failed"));
|
|
401
|
+
}
|
|
402
|
+
else {
|
|
403
|
+
pending.resolve(message.result);
|
|
404
|
+
}
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
if (!isJsonRpcNotification(message)) {
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
410
|
+
if ("id" in message && typeof message.id === "number") {
|
|
411
|
+
this.socket?.send(JSON.stringify({
|
|
412
|
+
jsonrpc: "2.0",
|
|
413
|
+
id: message.id,
|
|
414
|
+
error: {
|
|
415
|
+
code: -32601,
|
|
416
|
+
message: "qq-codex-bridge does not handle server requests yet"
|
|
417
|
+
}
|
|
418
|
+
}));
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
421
|
+
this.handleNotification(message.method, message.params);
|
|
422
|
+
}
|
|
423
|
+
handleNotification(method, params) {
|
|
424
|
+
this.forwardNotificationToApp(method, params);
|
|
425
|
+
if (method === "item/agentMessage/delta") {
|
|
426
|
+
this.handleAgentDelta(params);
|
|
427
|
+
return;
|
|
428
|
+
}
|
|
429
|
+
if (method === "item/completed") {
|
|
430
|
+
this.handleItemCompleted(params);
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
433
|
+
if (method === "turn/completed") {
|
|
434
|
+
this.handleTurnCompleted(params);
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
handleAgentDelta(params) {
|
|
438
|
+
const pending = this.findPendingTurn(params.threadId, params.turnId);
|
|
439
|
+
if (!pending || !params.itemId || typeof params.delta !== "string") {
|
|
440
|
+
return;
|
|
441
|
+
}
|
|
442
|
+
pending.itemTexts.set(params.itemId, `${pending.itemTexts.get(params.itemId) ?? ""}${params.delta}`);
|
|
443
|
+
}
|
|
444
|
+
handleItemCompleted(params) {
|
|
445
|
+
const pending = this.findPendingTurn(params.threadId, params.turnId);
|
|
446
|
+
const item = params.item;
|
|
447
|
+
if (!pending || !item?.id || item.type !== "agentMessage") {
|
|
448
|
+
return;
|
|
449
|
+
}
|
|
450
|
+
const text = (item.text ?? pending.itemTexts.get(item.id) ?? "").trim();
|
|
451
|
+
if (!text) {
|
|
452
|
+
return;
|
|
453
|
+
}
|
|
454
|
+
pending.finalText = text;
|
|
455
|
+
pending.mediaReferences = extractMediaReferences(text);
|
|
456
|
+
}
|
|
457
|
+
handleTurnCompleted(params) {
|
|
458
|
+
const turnId = params.turn?.id;
|
|
459
|
+
const pending = this.findPendingTurn(params.threadId, turnId);
|
|
460
|
+
if (!pending || !turnId) {
|
|
461
|
+
return;
|
|
462
|
+
}
|
|
463
|
+
if (params.turn?.status && params.turn.status !== "completed") {
|
|
464
|
+
pending.completed = true;
|
|
465
|
+
pending.failedReason = JSON.stringify(params.turn.error ?? params.turn.status);
|
|
466
|
+
pending.reject(new DesktopDriverError(`Codex app-server turn failed: ${pending.failedReason}`, "reply_timeout"));
|
|
467
|
+
return;
|
|
468
|
+
}
|
|
469
|
+
const finalText = pending.finalText || getLastMapValue(pending.itemTexts).trim();
|
|
470
|
+
pending.completed = true;
|
|
471
|
+
pending.resolve({
|
|
472
|
+
turnId,
|
|
473
|
+
finalText,
|
|
474
|
+
mediaReferences: extractMediaReferences(finalText)
|
|
475
|
+
});
|
|
476
|
+
}
|
|
477
|
+
findPendingTurn(threadId, turnId) {
|
|
478
|
+
if (!threadId || !turnId) {
|
|
479
|
+
return null;
|
|
480
|
+
}
|
|
481
|
+
return this.pendingTurnsByKey.get(buildTurnKey(threadId, turnId)) ?? null;
|
|
482
|
+
}
|
|
483
|
+
createPendingTurn(sessionKey, threadId, turnId) {
|
|
484
|
+
let resolve;
|
|
485
|
+
let reject;
|
|
486
|
+
const promise = new Promise((innerResolve, innerReject) => {
|
|
487
|
+
resolve = innerResolve;
|
|
488
|
+
reject = innerReject;
|
|
489
|
+
});
|
|
490
|
+
return {
|
|
491
|
+
sessionKey,
|
|
492
|
+
threadId,
|
|
493
|
+
turnId,
|
|
494
|
+
completed: false,
|
|
495
|
+
failedReason: null,
|
|
496
|
+
finalText: "",
|
|
497
|
+
itemTexts: new Map(),
|
|
498
|
+
mediaReferences: [],
|
|
499
|
+
resolve,
|
|
500
|
+
reject,
|
|
501
|
+
promise
|
|
502
|
+
};
|
|
503
|
+
}
|
|
504
|
+
async forwardThreadSnapshotToApp(threadId) {
|
|
505
|
+
if (!this.notificationForwarder) {
|
|
506
|
+
return;
|
|
507
|
+
}
|
|
508
|
+
const thread = await this.findThreadById(threadId).catch(() => null);
|
|
509
|
+
if (!thread) {
|
|
510
|
+
return;
|
|
511
|
+
}
|
|
512
|
+
await this.forwardNotificationToApp("thread/started", { thread }, { wait: true });
|
|
513
|
+
}
|
|
514
|
+
async forwardNotificationToApp(method, params, options = {}) {
|
|
515
|
+
if (!this.notificationForwarder) {
|
|
516
|
+
return;
|
|
517
|
+
}
|
|
518
|
+
const task = this.notificationForwardTail
|
|
519
|
+
.catch(() => undefined)
|
|
520
|
+
.then(() => this.notificationForwarder.forwardNotification(method, params));
|
|
521
|
+
this.notificationForwardTail = task.catch((error) => {
|
|
522
|
+
this.logNotificationForwardError(error);
|
|
523
|
+
});
|
|
524
|
+
if (options.wait) {
|
|
525
|
+
await this.notificationForwardTail;
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
logNotificationForwardError(error) {
|
|
529
|
+
const now = Date.now();
|
|
530
|
+
if (now - this.lastNotificationForwardErrorAt < 30_000) {
|
|
531
|
+
return;
|
|
532
|
+
}
|
|
533
|
+
this.lastNotificationForwardErrorAt = now;
|
|
534
|
+
console.warn("[qq-codex-bridge] codex app ui notification forward failed", {
|
|
535
|
+
error: error instanceof Error ? error.message : String(error)
|
|
536
|
+
});
|
|
537
|
+
}
|
|
538
|
+
async getLatestThread() {
|
|
539
|
+
const response = await this.request("thread/list", {
|
|
540
|
+
limit: 1,
|
|
541
|
+
sortKey: "updated_at",
|
|
542
|
+
sortDirection: "desc",
|
|
543
|
+
sourceKinds: [],
|
|
544
|
+
archived: false
|
|
545
|
+
});
|
|
546
|
+
return response.data?.[0] ?? null;
|
|
547
|
+
}
|
|
548
|
+
async getControlThread(threadRef) {
|
|
549
|
+
const threadId = await this.resolveThreadId(threadRef);
|
|
550
|
+
if (threadId) {
|
|
551
|
+
return this.findThreadById(threadId);
|
|
552
|
+
}
|
|
553
|
+
return this.getLatestThread();
|
|
554
|
+
}
|
|
555
|
+
async findThreadById(threadId) {
|
|
556
|
+
return this.readThreadById(threadId, false);
|
|
557
|
+
}
|
|
558
|
+
async readThreadById(threadId, includeTurns) {
|
|
559
|
+
const response = await this.request("thread/read", {
|
|
560
|
+
threadId,
|
|
561
|
+
includeTurns
|
|
562
|
+
}).catch(() => null);
|
|
563
|
+
return response?.thread ?? null;
|
|
564
|
+
}
|
|
565
|
+
async interruptStaleRunningTurn(threadId) {
|
|
566
|
+
if (this.staleTurnInterruptMs <= 0) {
|
|
567
|
+
return;
|
|
568
|
+
}
|
|
569
|
+
const thread = await this.readThreadById(threadId, true);
|
|
570
|
+
const staleTurns = (thread?.turns ?? [])
|
|
571
|
+
.filter((turn) => turn.id
|
|
572
|
+
&& turn.status === "inProgress"
|
|
573
|
+
&& isStaleTurn(turn.startedAt, this.staleTurnInterruptMs));
|
|
574
|
+
for (const turn of staleTurns) {
|
|
575
|
+
await this.interruptTurn(threadId, turn.id).catch((error) => {
|
|
576
|
+
console.warn("[qq-codex-bridge] codex stale turn interrupt failed", {
|
|
577
|
+
threadId,
|
|
578
|
+
turnId: turn.id,
|
|
579
|
+
error: error instanceof Error ? error.message : String(error)
|
|
580
|
+
});
|
|
581
|
+
});
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
async interruptTurn(threadId, turnId) {
|
|
585
|
+
await this.request("turn/interrupt", { threadId, turnId });
|
|
586
|
+
}
|
|
587
|
+
async resolveThreadId(threadRef) {
|
|
588
|
+
if (!threadRef) {
|
|
589
|
+
return null;
|
|
590
|
+
}
|
|
591
|
+
if (threadRef.startsWith(APP_THREAD_REF_PREFIX)) {
|
|
592
|
+
const payload = threadRef.slice(APP_THREAD_REF_PREFIX.length);
|
|
593
|
+
const separatorIndex = payload.indexOf(":");
|
|
594
|
+
return separatorIndex >= 0 ? payload.slice(0, separatorIndex) : payload;
|
|
595
|
+
}
|
|
596
|
+
const legacyLocator = decodeLegacyThreadRef(threadRef);
|
|
597
|
+
if (!legacyLocator) {
|
|
598
|
+
return null;
|
|
599
|
+
}
|
|
600
|
+
const threads = await this.listRecentThreads(200);
|
|
601
|
+
const matched = threads.find((thread) => thread.title === legacyLocator.title
|
|
602
|
+
&& (!legacyLocator.projectName
|
|
603
|
+
|| thread.projectName === legacyLocator.projectName));
|
|
604
|
+
return matched ? this.resolveThreadId(matched.threadRef) : null;
|
|
605
|
+
}
|
|
606
|
+
encodeThreadRef(threadId, title, projectName) {
|
|
607
|
+
const encoded = Buffer.from(JSON.stringify({ title, projectName }), "utf8").toString("base64url");
|
|
608
|
+
return `${APP_THREAD_REF_PREFIX}${threadId}:${encoded}`;
|
|
609
|
+
}
|
|
610
|
+
threadToSummary(thread, index) {
|
|
611
|
+
const title = normalizeThreadTitle(thread);
|
|
612
|
+
const projectName = thread.cwd ? path.basename(thread.cwd) : null;
|
|
613
|
+
return {
|
|
614
|
+
index,
|
|
615
|
+
title,
|
|
616
|
+
projectName,
|
|
617
|
+
relativeTime: formatRelativeTime(thread.updatedAt),
|
|
618
|
+
isCurrent: false,
|
|
619
|
+
threadRef: this.encodeThreadRef(thread.id, title, projectName)
|
|
620
|
+
};
|
|
621
|
+
}
|
|
622
|
+
buildOutboundDraftFromText(sessionKey, text, mediaReferences, turnId) {
|
|
623
|
+
return {
|
|
624
|
+
draftId: randomUUID(),
|
|
625
|
+
turnId,
|
|
626
|
+
sessionKey,
|
|
627
|
+
text,
|
|
628
|
+
...(mediaReferences.length > 0
|
|
629
|
+
? {
|
|
630
|
+
mediaArtifacts: mediaReferences.map((reference) => buildMediaArtifactFromReference(reference))
|
|
631
|
+
}
|
|
632
|
+
: {}),
|
|
633
|
+
createdAt: new Date().toISOString()
|
|
634
|
+
};
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
function resolveDefaultCodexBinaryPath() {
|
|
638
|
+
const appBundleBinary = "/Applications/Codex.app/Contents/Resources/codex";
|
|
639
|
+
return fs.existsSync(appBundleBinary) ? appBundleBinary : "codex";
|
|
640
|
+
}
|
|
641
|
+
function stripAnsi(text) {
|
|
642
|
+
return text.replace(/\x1B\[[0-?]*[ -/]*[@-~]/g, "");
|
|
643
|
+
}
|
|
644
|
+
function isNoisyCodexBackendWebsocketError(text) {
|
|
645
|
+
return (text.includes("codex_api::endpoint::responses_websocket")
|
|
646
|
+
&& text.includes("failed to connect to websocket")
|
|
647
|
+
&& text.includes("wss://chatgpt.com/backend-api/codex/responses")
|
|
648
|
+
&& (text.includes("Connection reset by peer")
|
|
649
|
+
|| text.includes("Broken pipe")));
|
|
650
|
+
}
|
|
651
|
+
function isJsonRpcResponse(value) {
|
|
652
|
+
return (typeof value === "object"
|
|
653
|
+
&& value !== null
|
|
654
|
+
&& "id" in value
|
|
655
|
+
&& !("method" in value));
|
|
656
|
+
}
|
|
657
|
+
function isJsonRpcNotification(value) {
|
|
658
|
+
return (typeof value === "object"
|
|
659
|
+
&& value !== null
|
|
660
|
+
&& "method" in value
|
|
661
|
+
&& typeof value.method === "string");
|
|
662
|
+
}
|
|
663
|
+
function buildTurnKey(threadId, turnId) {
|
|
664
|
+
return `${threadId}:${turnId}`;
|
|
665
|
+
}
|
|
666
|
+
function getLastMapValue(map) {
|
|
667
|
+
let value = "";
|
|
668
|
+
for (const next of map.values()) {
|
|
669
|
+
value = next;
|
|
670
|
+
}
|
|
671
|
+
return value;
|
|
672
|
+
}
|
|
673
|
+
async function getFreePort() {
|
|
674
|
+
return new Promise((resolve, reject) => {
|
|
675
|
+
const server = net.createServer();
|
|
676
|
+
server.listen(0, "127.0.0.1", () => {
|
|
677
|
+
const address = server.address();
|
|
678
|
+
if (typeof address === "object" && address?.port) {
|
|
679
|
+
const port = address.port;
|
|
680
|
+
server.close(() => resolve(port));
|
|
681
|
+
}
|
|
682
|
+
else {
|
|
683
|
+
server.close(() => reject(new Error("failed to allocate port")));
|
|
684
|
+
}
|
|
685
|
+
});
|
|
686
|
+
server.on("error", reject);
|
|
687
|
+
});
|
|
688
|
+
}
|
|
689
|
+
async function withTimeout(promise, timeoutMs, message) {
|
|
690
|
+
let timeout = null;
|
|
691
|
+
try {
|
|
692
|
+
return await Promise.race([
|
|
693
|
+
promise,
|
|
694
|
+
new Promise((_, reject) => {
|
|
695
|
+
timeout = setTimeout(() => reject(new DesktopDriverError(message, "reply_timeout")), timeoutMs);
|
|
696
|
+
})
|
|
697
|
+
]);
|
|
698
|
+
}
|
|
699
|
+
finally {
|
|
700
|
+
if (timeout) {
|
|
701
|
+
clearTimeout(timeout);
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
function normalizeThreadTitle(thread) {
|
|
706
|
+
const explicitName = thread.name?.trim();
|
|
707
|
+
if (explicitName) {
|
|
708
|
+
return explicitName;
|
|
709
|
+
}
|
|
710
|
+
const firstLine = thread.preview?.split(/\r?\n/).find((line) => line.trim())?.trim();
|
|
711
|
+
return firstLine || thread.id;
|
|
712
|
+
}
|
|
713
|
+
function formatRelativeTime(updatedAt) {
|
|
714
|
+
if (!updatedAt) {
|
|
715
|
+
return null;
|
|
716
|
+
}
|
|
717
|
+
const elapsedMs = Math.max(0, Date.now() - updatedAt * 1000);
|
|
718
|
+
const minute = 60_000;
|
|
719
|
+
const hour = 60 * minute;
|
|
720
|
+
const day = 24 * hour;
|
|
721
|
+
if (elapsedMs < minute) {
|
|
722
|
+
return "刚刚";
|
|
723
|
+
}
|
|
724
|
+
if (elapsedMs < hour) {
|
|
725
|
+
return `${Math.floor(elapsedMs / minute)} 分钟前`;
|
|
726
|
+
}
|
|
727
|
+
if (elapsedMs < day) {
|
|
728
|
+
return `${Math.floor(elapsedMs / hour)} 小时前`;
|
|
729
|
+
}
|
|
730
|
+
return `${Math.floor(elapsedMs / day)} 天前`;
|
|
731
|
+
}
|
|
732
|
+
function isStaleTurn(startedAt, staleTurnInterruptMs) {
|
|
733
|
+
if (typeof startedAt !== "number" || !Number.isFinite(startedAt)) {
|
|
734
|
+
return false;
|
|
735
|
+
}
|
|
736
|
+
return Date.now() - startedAt * 1000 >= staleTurnInterruptMs;
|
|
737
|
+
}
|
|
738
|
+
function decodeLegacyThreadRef(threadRef) {
|
|
739
|
+
if (!threadRef.startsWith(LEGACY_THREAD_REF_PREFIX)) {
|
|
740
|
+
return null;
|
|
741
|
+
}
|
|
742
|
+
const payload = threadRef.slice(LEGACY_THREAD_REF_PREFIX.length);
|
|
743
|
+
const separatorIndex = payload.indexOf(":");
|
|
744
|
+
if (separatorIndex <= 0) {
|
|
745
|
+
return null;
|
|
746
|
+
}
|
|
747
|
+
try {
|
|
748
|
+
const locator = JSON.parse(Buffer.from(payload.slice(separatorIndex + 1), "base64url").toString("utf8"));
|
|
749
|
+
if (typeof locator.title !== "string" || !locator.title.trim()) {
|
|
750
|
+
return null;
|
|
751
|
+
}
|
|
752
|
+
return {
|
|
753
|
+
title: locator.title,
|
|
754
|
+
projectName: typeof locator.projectName === "string" && locator.projectName.trim()
|
|
755
|
+
? locator.projectName
|
|
756
|
+
: null
|
|
757
|
+
};
|
|
758
|
+
}
|
|
759
|
+
catch {
|
|
760
|
+
return null;
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
function readString(value) {
|
|
764
|
+
return typeof value === "string" && value.trim() ? value : null;
|
|
765
|
+
}
|
|
766
|
+
function formatPermissionMode(approvalPolicy, sandboxMode) {
|
|
767
|
+
const parts = [readString(approvalPolicy), readString(sandboxMode)].filter(Boolean);
|
|
768
|
+
return parts.length > 0 ? parts.join(" / ") : null;
|
|
769
|
+
}
|
|
770
|
+
function formatRateLimitSnapshot(snapshot) {
|
|
771
|
+
if (!snapshot) {
|
|
772
|
+
return null;
|
|
773
|
+
}
|
|
774
|
+
const lines = [
|
|
775
|
+
formatRateLimitWindow("5 小时", snapshot.primary),
|
|
776
|
+
formatRateLimitWindow("1 周", snapshot.secondary)
|
|
777
|
+
].filter(Boolean);
|
|
778
|
+
return lines.length > 0 ? lines.join("\n") : null;
|
|
779
|
+
}
|
|
780
|
+
function formatRateLimitWindow(label, window) {
|
|
781
|
+
if (!window) {
|
|
782
|
+
return null;
|
|
783
|
+
}
|
|
784
|
+
const remainingPercent = typeof window.remainingPercent === "number"
|
|
785
|
+
? window.remainingPercent
|
|
786
|
+
: typeof window.usedPercent === "number"
|
|
787
|
+
? 100 - window.usedPercent
|
|
788
|
+
: null;
|
|
789
|
+
if (remainingPercent === null) {
|
|
790
|
+
return null;
|
|
791
|
+
}
|
|
792
|
+
const normalizedPercent = Math.max(0, Math.min(100, remainingPercent));
|
|
793
|
+
const reset = typeof window.resetsAt === "number"
|
|
794
|
+
? new Date(window.resetsAt * 1000).toLocaleString("zh-CN", {
|
|
795
|
+
month: "numeric",
|
|
796
|
+
day: label === "1 周" ? "numeric" : undefined,
|
|
797
|
+
hour: "2-digit",
|
|
798
|
+
minute: "2-digit",
|
|
799
|
+
hour12: false
|
|
800
|
+
})
|
|
801
|
+
: null;
|
|
802
|
+
return reset
|
|
803
|
+
? `${label} ${Math.round(normalizedPercent)}%(${reset} 重置)`
|
|
804
|
+
: `${label} ${Math.round(normalizedPercent)}%`;
|
|
805
|
+
}
|
|
806
|
+
function extractMediaReferences(text) {
|
|
807
|
+
return parseQqMediaSegments(text)
|
|
808
|
+
.filter((segment) => segment.type === "media")
|
|
809
|
+
.map((segment) => segment.reference);
|
|
810
|
+
}
|