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.
Files changed (39) hide show
  1. package/.env.example +62 -0
  2. package/README.md +232 -287
  3. package/bin/chatgpt-desktop.js +2 -0
  4. package/bin/qq-codex-weixin-gateway.js +14 -0
  5. package/dist/apps/bridge-daemon/src/bootstrap.js +161 -31
  6. package/dist/apps/bridge-daemon/src/cli.js +5 -1
  7. package/dist/apps/bridge-daemon/src/config.js +168 -37
  8. package/dist/apps/bridge-daemon/src/http-server.js +23 -11
  9. package/dist/apps/bridge-daemon/src/main.js +163 -29
  10. package/dist/apps/bridge-daemon/src/thread-command-handler.js +320 -23
  11. package/dist/apps/chatgpt-desktop-cli/src/cli.js +191 -0
  12. package/dist/apps/weixin-gateway/src/cli.js +446 -0
  13. package/dist/apps/weixin-gateway/src/config.js +135 -0
  14. package/dist/apps/weixin-gateway/src/dev.js +2 -0
  15. package/dist/apps/weixin-gateway/src/message-store.js +50 -0
  16. package/dist/apps/weixin-gateway/src/server.js +216 -0
  17. package/dist/apps/weixin-gateway/src/state.js +163 -0
  18. package/dist/apps/weixin-gateway/src/weixin-client.js +520 -0
  19. package/dist/packages/adapters/chatgpt-desktop/src/ax-client.js +472 -0
  20. package/dist/packages/adapters/chatgpt-desktop/src/bridge-provider.js +82 -0
  21. package/dist/packages/adapters/chatgpt-desktop/src/driver.js +161 -0
  22. package/dist/packages/adapters/chatgpt-desktop/src/image-cache.js +155 -0
  23. package/dist/packages/adapters/chatgpt-desktop/src/session-registry.js +48 -0
  24. package/dist/packages/adapters/chatgpt-desktop/src/types.js +1 -0
  25. package/dist/packages/adapters/codex-desktop/src/codex-app-server-driver.js +810 -0
  26. package/dist/packages/adapters/codex-desktop/src/codex-app-ui-notification-forwarder.js +33 -0
  27. package/dist/packages/adapters/codex-desktop/src/codex-desktop-driver.js +727 -123
  28. package/dist/packages/adapters/codex-desktop/src/codex-local-rollout-reader.js +227 -0
  29. package/dist/packages/adapters/codex-desktop/src/codex-local-submission-reader.js +142 -0
  30. package/dist/packages/adapters/weixin/src/weixin-channel-adapter.js +15 -0
  31. package/dist/packages/adapters/weixin/src/weixin-http-client.js +42 -0
  32. package/dist/packages/adapters/weixin/src/weixin-sender.js +200 -0
  33. package/dist/packages/adapters/weixin/src/weixin-webhook.js +35 -0
  34. package/dist/packages/orchestrator/src/bridge-orchestrator.js +72 -25
  35. package/dist/packages/orchestrator/src/weixin-outbound-format.js +55 -0
  36. package/dist/packages/ports/src/chat.js +1 -0
  37. package/dist/packages/store/src/session-repo.js +16 -3
  38. package/dist/packages/store/src/sqlite.js +3 -0
  39. 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
+ }