qq-codex-bridge 0.1.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.
Files changed (45) hide show
  1. package/.env.example +58 -0
  2. package/LICENSE +21 -0
  3. package/README.md +453 -0
  4. package/bin/qq-codex-bridge.js +11 -0
  5. package/dist/apps/bridge-daemon/src/bootstrap.js +100 -0
  6. package/dist/apps/bridge-daemon/src/cli.js +141 -0
  7. package/dist/apps/bridge-daemon/src/config.js +109 -0
  8. package/dist/apps/bridge-daemon/src/debug-codex-workers.js +309 -0
  9. package/dist/apps/bridge-daemon/src/dev-launch.js +73 -0
  10. package/dist/apps/bridge-daemon/src/dev.js +28 -0
  11. package/dist/apps/bridge-daemon/src/http-server.js +36 -0
  12. package/dist/apps/bridge-daemon/src/main.js +57 -0
  13. package/dist/apps/bridge-daemon/src/thread-command-handler.js +197 -0
  14. package/dist/packages/adapters/codex-desktop/src/cdp-session.js +189 -0
  15. package/dist/packages/adapters/codex-desktop/src/codex-desktop-driver.js +1259 -0
  16. package/dist/packages/adapters/codex-desktop/src/composer-heuristics.js +11 -0
  17. package/dist/packages/adapters/codex-desktop/src/health.js +7 -0
  18. package/dist/packages/adapters/codex-desktop/src/reply-parser.js +10 -0
  19. package/dist/packages/adapters/qq/src/qq-api-client.js +232 -0
  20. package/dist/packages/adapters/qq/src/qq-channel-adapter.js +22 -0
  21. package/dist/packages/adapters/qq/src/qq-gateway-client.js +295 -0
  22. package/dist/packages/adapters/qq/src/qq-gateway-session-store.js +64 -0
  23. package/dist/packages/adapters/qq/src/qq-gateway.js +62 -0
  24. package/dist/packages/adapters/qq/src/qq-media-downloader.js +246 -0
  25. package/dist/packages/adapters/qq/src/qq-media-parser.js +144 -0
  26. package/dist/packages/adapters/qq/src/qq-normalizer.js +35 -0
  27. package/dist/packages/adapters/qq/src/qq-sender.js +241 -0
  28. package/dist/packages/adapters/qq/src/qq-stt.js +189 -0
  29. package/dist/packages/domain/src/driver.js +7 -0
  30. package/dist/packages/domain/src/message.js +7 -0
  31. package/dist/packages/domain/src/session.js +7 -0
  32. package/dist/packages/orchestrator/src/bridge-orchestrator.js +143 -0
  33. package/dist/packages/orchestrator/src/job-runner.js +5 -0
  34. package/dist/packages/orchestrator/src/media-context.js +90 -0
  35. package/dist/packages/orchestrator/src/qq-outbound-draft.js +38 -0
  36. package/dist/packages/orchestrator/src/qq-outbound-format.js +51 -0
  37. package/dist/packages/orchestrator/src/qqbot-skill-context.js +13 -0
  38. package/dist/packages/orchestrator/src/session-key.js +6 -0
  39. package/dist/packages/ports/src/conversation.js +1 -0
  40. package/dist/packages/ports/src/qq.js +1 -0
  41. package/dist/packages/ports/src/store.js +1 -0
  42. package/dist/packages/store/src/message-repo.js +53 -0
  43. package/dist/packages/store/src/session-repo.js +80 -0
  44. package/dist/packages/store/src/sqlite.js +64 -0
  45. package/package.json +60 -0
@@ -0,0 +1,141 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import process from "node:process";
4
+ import { fileURLToPath } from "node:url";
5
+ import { ZodError } from "zod";
6
+ import { loadConfigFromEnv } from "./config.js";
7
+ import { ensureCodexDesktopForDev } from "./dev-launch.js";
8
+ import { runBridgeDaemon } from "./main.js";
9
+ const REQUIRED_ENV_MAP = {
10
+ "qqBot.appId": "QQBOT_APP_ID",
11
+ "qqBot.clientSecret": "QQBOT_CLIENT_SECRET",
12
+ "codexDesktop.appName": "CODEX_APP_NAME",
13
+ "codexDesktop.remoteDebuggingPort": "CODEX_REMOTE_DEBUGGING_PORT"
14
+ };
15
+ export async function runCli(rawArgs, deps = {}) {
16
+ const args = normalizeArgs(rawArgs);
17
+ const cwd = deps.cwd ?? process.cwd();
18
+ const env = deps.env ?? process.env;
19
+ const packageRoot = deps.packageRoot ?? findPackageRoot(path.dirname(fileURLToPath(import.meta.url)));
20
+ const writeStdout = deps.writeStdout ?? ((line) => console.log(line));
21
+ const writeStderr = deps.writeStderr ?? ((line) => console.error(line));
22
+ const ensureDesktop = deps.ensureCodexDesktop ?? ensureCodexDesktopForDev;
23
+ const startBridge = deps.runBridgeDaemon ?? runBridgeDaemon;
24
+ if (args[0] === "init") {
25
+ return initEnvTemplate({
26
+ cwd,
27
+ packageRoot,
28
+ writeStdout,
29
+ writeStderr
30
+ });
31
+ }
32
+ if (args[0] === "help" || args[0] === "-h" || args[0] === "--help") {
33
+ printHelp(writeStdout);
34
+ return 0;
35
+ }
36
+ if (args.length > 0) {
37
+ writeStderr(`[qq-codex-bridge] 未知命令:${args.join(" ")}`);
38
+ printHelp(writeStdout);
39
+ return 1;
40
+ }
41
+ const envFilePath = path.join(cwd, ".env");
42
+ if (fs.existsSync(envFilePath)) {
43
+ const loadEnvFile = deps.loadEnvFile ?? process.loadEnvFile.bind(process);
44
+ loadEnvFile(envFilePath);
45
+ }
46
+ try {
47
+ const config = loadConfigFromEnv(env);
48
+ const result = await ensureDesktop({
49
+ appName: config.codexDesktop.appName,
50
+ remoteDebuggingPort: config.codexDesktop.remoteDebuggingPort,
51
+ startupTimeoutMs: Number(env.CODEX_CDP_STARTUP_TIMEOUT_MS ?? "15000"),
52
+ startupPollIntervalMs: Number(env.CODEX_CDP_POLL_INTERVAL_MS ?? "500")
53
+ });
54
+ writeStdout(`[qq-codex-bridge] codex desktop ready { launched: ${String(result.launched)}, remoteDebuggingPort: ${config.codexDesktop.remoteDebuggingPort} }`);
55
+ await startBridge();
56
+ return 0;
57
+ }
58
+ catch (error) {
59
+ if (error instanceof ZodError) {
60
+ writeStderr(formatConfigError(error, cwd));
61
+ return 1;
62
+ }
63
+ const cause = error instanceof Error ? error.cause : undefined;
64
+ writeStderr(`[qq-codex-bridge] fatal: ${error instanceof Error ? error.message : String(error)}`);
65
+ if (cause !== undefined) {
66
+ writeStderr(` caused by: ${String(cause)}`);
67
+ }
68
+ if (error instanceof Error && error.stack) {
69
+ writeStderr(` stack: ${error.stack}`);
70
+ }
71
+ return 1;
72
+ }
73
+ }
74
+ export async function runCliFromProcess() {
75
+ process.exitCode = await runCli(process.argv.slice(2));
76
+ }
77
+ function initEnvTemplate(options) {
78
+ const targetPath = path.join(options.cwd, ".env");
79
+ if (fs.existsSync(targetPath)) {
80
+ options.writeStderr(`[qq-codex-bridge] .env 已存在:${targetPath}`);
81
+ options.writeStderr("[qq-codex-bridge] 如需重新生成,请先手动备份或删除现有文件。");
82
+ return 1;
83
+ }
84
+ const templatePath = path.join(options.packageRoot, ".env.example");
85
+ const template = fs.readFileSync(templatePath, "utf8");
86
+ fs.writeFileSync(targetPath, template, "utf8");
87
+ options.writeStdout(`[qq-codex-bridge] 已生成配置模板:${targetPath}`);
88
+ options.writeStdout("[qq-codex-bridge] 请先填写 QQBOT_APP_ID 和 QQBOT_CLIENT_SECRET,再执行 `qq-codex-bridge`。");
89
+ return 0;
90
+ }
91
+ function printHelp(writeStdout) {
92
+ writeStdout("qq-codex-bridge");
93
+ writeStdout("");
94
+ writeStdout("用法:");
95
+ writeStdout(" qq-codex-bridge 启动桥接守护进程");
96
+ writeStdout(" qq-codex-bridge init 在当前目录生成 .env");
97
+ writeStdout(" qq-codex-bridge help 查看帮助");
98
+ }
99
+ function formatConfigError(error, cwd) {
100
+ const missingVars = Array.from(new Set(error.issues
101
+ .map((issue) => REQUIRED_ENV_MAP[issue.path.join(".")])
102
+ .filter((value) => Boolean(value))));
103
+ const lines = [`[qq-codex-bridge] 配置不完整,无法启动。`];
104
+ if (missingVars.length > 0) {
105
+ lines.push(`[qq-codex-bridge] 缺少或无效的关键变量:${missingVars.join(", ")}`);
106
+ }
107
+ lines.push(`[qq-codex-bridge] 请在当前目录准备 .env:${path.join(cwd, ".env")}`);
108
+ lines.push("[qq-codex-bridge] 如果还没有配置文件,可先执行:qq-codex-bridge init");
109
+ return lines.join("\n");
110
+ }
111
+ function normalizeArgs(args) {
112
+ return args.filter((arg) => arg.length > 0);
113
+ }
114
+ function findPackageRoot(startDir) {
115
+ let currentDir = startDir;
116
+ while (true) {
117
+ if (fs.existsSync(path.join(currentDir, "package.json"))) {
118
+ return currentDir;
119
+ }
120
+ const parentDir = path.dirname(currentDir);
121
+ if (parentDir === currentDir) {
122
+ throw new Error(`Unable to locate package root from ${startDir}`);
123
+ }
124
+ currentDir = parentDir;
125
+ }
126
+ }
127
+ const isEntrypoint = (() => {
128
+ const entrypoint = process.argv[1];
129
+ if (!entrypoint) {
130
+ return false;
131
+ }
132
+ try {
133
+ return fileURLToPath(import.meta.url) === path.resolve(entrypoint);
134
+ }
135
+ catch {
136
+ return false;
137
+ }
138
+ })();
139
+ if (isEntrypoint) {
140
+ void runCliFromProcess();
141
+ }
@@ -0,0 +1,109 @@
1
+ import { z } from "zod";
2
+ export const appConfigSchema = z.object({
3
+ databasePath: z.string().min(1),
4
+ runtime: z.object({
5
+ listenHost: z.string().min(1),
6
+ listenPort: z.number().int().positive(),
7
+ webhookPath: z.string().startsWith("/")
8
+ }),
9
+ qqBot: z.object({
10
+ appId: z.string().min(1),
11
+ clientSecret: z.string().min(1),
12
+ markdownSupport: z.boolean(),
13
+ stt: z
14
+ .union([
15
+ z.object({
16
+ provider: z.literal("local-whisper-cpp"),
17
+ binaryPath: z.string().min(1),
18
+ modelPath: z.string().min(1),
19
+ language: z.string().min(1).optional()
20
+ }),
21
+ z.object({
22
+ provider: z.literal("openai-compatible"),
23
+ baseUrl: z.string().url(),
24
+ apiKey: z.string().min(1),
25
+ model: z.string().min(1)
26
+ }),
27
+ z.object({
28
+ provider: z.literal("volcengine-flash"),
29
+ endpoint: z.string().url(),
30
+ appId: z.string().min(1),
31
+ accessKey: z.string().min(1),
32
+ resourceId: z.string().min(1),
33
+ model: z.string().min(1)
34
+ })
35
+ ])
36
+ .nullable()
37
+ }),
38
+ codexDesktop: z.object({
39
+ appName: z.string().min(1),
40
+ remoteDebuggingPort: z.number().int().positive()
41
+ })
42
+ });
43
+ export function loadConfigFromEnv(env) {
44
+ return appConfigSchema.parse({
45
+ databasePath: env.QQ_CODEX_DATABASE_PATH ?? "runtime/qq-codex-bridge.sqlite",
46
+ runtime: {
47
+ listenHost: env.QQ_CODEX_LISTEN_HOST ?? "127.0.0.1",
48
+ listenPort: Number(env.QQ_CODEX_LISTEN_PORT ?? "3100"),
49
+ webhookPath: env.QQ_CODEX_WEBHOOK_PATH ?? "/webhooks/qq"
50
+ },
51
+ qqBot: {
52
+ appId: env.QQBOT_APP_ID,
53
+ clientSecret: env.QQBOT_CLIENT_SECRET,
54
+ markdownSupport: env.QQBOT_MARKDOWN_SUPPORT === "true",
55
+ stt: resolveSttConfig(env)
56
+ },
57
+ codexDesktop: {
58
+ appName: env.CODEX_APP_NAME ?? "Codex",
59
+ remoteDebuggingPort: Number(env.CODEX_REMOTE_DEBUGGING_PORT ?? "9229")
60
+ }
61
+ });
62
+ }
63
+ function resolveSttConfig(env) {
64
+ if (env.QQBOT_STT_ENABLED === "false") {
65
+ return null;
66
+ }
67
+ if (env.QQBOT_STT_PROVIDER === "local-whisper-cpp") {
68
+ const binaryPath = env.QQBOT_STT_BINARY_PATH;
69
+ const modelPath = env.QQBOT_STT_MODEL_PATH;
70
+ if (!binaryPath || !modelPath) {
71
+ return null;
72
+ }
73
+ return {
74
+ provider: "local-whisper-cpp",
75
+ binaryPath,
76
+ modelPath,
77
+ ...(env.QQBOT_STT_LANGUAGE ? { language: env.QQBOT_STT_LANGUAGE } : {})
78
+ };
79
+ }
80
+ if (env.QQBOT_STT_PROVIDER === "volcengine-flash") {
81
+ const appId = env.QQBOT_STT_APP_ID;
82
+ const accessKey = env.QQBOT_STT_ACCESS_KEY;
83
+ const resourceId = env.QQBOT_STT_RESOURCE_ID;
84
+ if (!appId || !accessKey || !resourceId) {
85
+ return null;
86
+ }
87
+ return {
88
+ provider: "volcengine-flash",
89
+ endpoint: env.QQBOT_STT_ENDPOINT ??
90
+ "https://openspeech.bytedance.com/api/v3/auc/bigmodel/recognize/flash",
91
+ appId,
92
+ accessKey,
93
+ resourceId,
94
+ model: env.QQBOT_STT_MODEL ?? "bigmodel"
95
+ };
96
+ }
97
+ const apiKey = env.QQBOT_STT_API_KEY ?? env.OPENAI_API_KEY;
98
+ if (!apiKey) {
99
+ return null;
100
+ }
101
+ const baseUrl = env.QQBOT_STT_BASE_URL ?? env.OPENAI_BASE_URL ?? "https://api.openai.com/v1";
102
+ const model = env.QQBOT_STT_MODEL ?? "whisper-1";
103
+ return {
104
+ provider: "openai-compatible",
105
+ baseUrl,
106
+ apiKey,
107
+ model
108
+ };
109
+ }
@@ -0,0 +1,309 @@
1
+ import WebSocket from "ws";
2
+ class RawCdpClient {
3
+ socket = null;
4
+ nextId = 1;
5
+ pending = new Map();
6
+ listeners = new Map();
7
+ async connect(browserWebSocketUrl) {
8
+ const socket = new WebSocket(browserWebSocketUrl);
9
+ await new Promise((resolve, reject) => {
10
+ socket.once("open", () => resolve());
11
+ socket.once("error", (error) => reject(error));
12
+ });
13
+ socket.on("message", (raw) => {
14
+ const payload = JSON.parse(String(raw));
15
+ if (typeof payload.id === "number") {
16
+ const pending = this.pending.get(payload.id);
17
+ if (!pending) {
18
+ return;
19
+ }
20
+ this.pending.delete(payload.id);
21
+ if (payload.error) {
22
+ pending.reject(new Error(JSON.stringify(payload.error)));
23
+ }
24
+ else {
25
+ pending.resolve(payload.result);
26
+ }
27
+ return;
28
+ }
29
+ if (typeof payload.method === "string") {
30
+ const handlers = this.listeners.get(payload.method) ?? [];
31
+ for (const handler of handlers) {
32
+ handler(payload.params, payload.sessionId);
33
+ }
34
+ }
35
+ });
36
+ this.socket = socket;
37
+ }
38
+ on(method, handler) {
39
+ const handlers = this.listeners.get(method) ?? [];
40
+ handlers.push(handler);
41
+ this.listeners.set(method, handlers);
42
+ }
43
+ async send(method, params = {}, sessionId, timeoutMs = 5_000) {
44
+ const socket = this.socket;
45
+ if (!socket) {
46
+ throw new Error("CDP browser socket not connected");
47
+ }
48
+ const id = this.nextId++;
49
+ return new Promise((resolve, reject) => {
50
+ const timeout = setTimeout(() => {
51
+ this.pending.delete(id);
52
+ reject(new Error(`CDP command timed out: ${method}`));
53
+ }, timeoutMs);
54
+ this.pending.set(id, { resolve, reject });
55
+ socket.send(JSON.stringify({ id, method, params, ...(sessionId ? { sessionId } : {}) }), (error) => {
56
+ if (!error) {
57
+ return;
58
+ }
59
+ clearTimeout(timeout);
60
+ this.pending.delete(id);
61
+ reject(error);
62
+ });
63
+ const originalResolve = resolve;
64
+ const originalReject = reject;
65
+ this.pending.set(id, {
66
+ resolve: (value) => {
67
+ clearTimeout(timeout);
68
+ originalResolve(value);
69
+ },
70
+ reject: (error) => {
71
+ clearTimeout(timeout);
72
+ originalReject(error);
73
+ }
74
+ });
75
+ });
76
+ }
77
+ }
78
+ async function main() {
79
+ const options = parseArgs(process.argv.slice(2), process.env);
80
+ const version = (await fetchJson(`http://127.0.0.1:${options.remoteDebuggingPort}/json/version`));
81
+ const browserWebSocketUrl = version.webSocketDebuggerUrl;
82
+ if (!browserWebSocketUrl) {
83
+ throw new Error("CDP version response missing webSocketDebuggerUrl");
84
+ }
85
+ const targets = (await fetchJson(`http://127.0.0.1:${options.remoteDebuggingPort}/json/list`));
86
+ const page = targets.find((target) => target.type === "page");
87
+ if (!page) {
88
+ throw new Error("No page target found in Codex Desktop");
89
+ }
90
+ const client = new RawCdpClient();
91
+ await client.connect(browserWebSocketUrl);
92
+ const targetsToAttach = options.pageOnly ? [page] : targets;
93
+ const attachedTargets = [];
94
+ console.log(JSON.stringify({
95
+ discoveredTargets: targetsToAttach.map((target) => ({
96
+ id: target.id,
97
+ type: target.type,
98
+ title: target.title,
99
+ url: target.url
100
+ })),
101
+ monitorDurationMs: options.durationMs,
102
+ promptRequested: Boolean(options.prompt)
103
+ }, null, 2));
104
+ for (const target of targetsToAttach) {
105
+ console.log(`[debug-codex-workers] attaching ${target.type}:${target.id} ...`);
106
+ let attachResult = null;
107
+ try {
108
+ attachResult = (await client.send("Target.attachToTarget", {
109
+ targetId: target.id,
110
+ flatten: true
111
+ }, undefined, 1_500));
112
+ }
113
+ catch (error) {
114
+ console.warn(`[debug-codex-workers] attach failed for ${target.type}:${target.id}`, error);
115
+ continue;
116
+ }
117
+ if (!attachResult?.sessionId) {
118
+ console.warn(`[debug-codex-workers] attach returned no session for ${target.type}:${target.id}`);
119
+ continue;
120
+ }
121
+ attachedTargets.push({
122
+ targetId: target.id,
123
+ targetType: target.type,
124
+ sessionId: attachResult.sessionId
125
+ });
126
+ await client.send("Network.enable", {}, attachResult.sessionId, 800).catch((error) => {
127
+ console.warn(`[debug-codex-workers] Network.enable failed for ${target.type}:${target.id}`, error);
128
+ });
129
+ await client.send("Runtime.enable", {}, attachResult.sessionId, 800).catch((error) => {
130
+ console.warn(`[debug-codex-workers] Runtime.enable failed for ${target.type}:${target.id}`, error);
131
+ });
132
+ }
133
+ const startedAt = Date.now();
134
+ const captured = [];
135
+ const lookupTarget = (sessionId) => attachedTargets.find((target) => target.sessionId === sessionId);
136
+ client.on("Network.requestWillBeSent", (params, sessionId) => {
137
+ const meta = lookupTarget(sessionId);
138
+ captured.push({
139
+ kind: "request",
140
+ t: Date.now() - startedAt,
141
+ targetType: meta?.targetType,
142
+ targetId: meta?.targetId,
143
+ requestId: params.requestId,
144
+ method: params.request?.method,
145
+ url: params.request?.url,
146
+ postData: typeof params.request?.postData === "string"
147
+ ? params.request.postData.slice(0, 1200)
148
+ : null
149
+ });
150
+ });
151
+ client.on("Network.responseReceived", (params, sessionId) => {
152
+ const meta = lookupTarget(sessionId);
153
+ captured.push({
154
+ kind: "response",
155
+ t: Date.now() - startedAt,
156
+ targetType: meta?.targetType,
157
+ targetId: meta?.targetId,
158
+ requestId: params.requestId,
159
+ status: params.response?.status,
160
+ mimeType: params.response?.mimeType,
161
+ url: params.response?.url
162
+ });
163
+ });
164
+ client.on("Network.webSocketFrameReceived", (params, sessionId) => {
165
+ const meta = lookupTarget(sessionId);
166
+ captured.push({
167
+ kind: "ws_in",
168
+ t: Date.now() - startedAt,
169
+ targetType: meta?.targetType,
170
+ targetId: meta?.targetId,
171
+ requestId: params.requestId,
172
+ payload: String(params.response?.payloadData ?? "").slice(0, 1200)
173
+ });
174
+ });
175
+ client.on("Network.webSocketFrameSent", (params, sessionId) => {
176
+ const meta = lookupTarget(sessionId);
177
+ captured.push({
178
+ kind: "ws_out",
179
+ t: Date.now() - startedAt,
180
+ targetType: meta?.targetType,
181
+ targetId: meta?.targetId,
182
+ requestId: params.requestId,
183
+ payload: String(params.response?.payloadData ?? "").slice(0, 1200)
184
+ });
185
+ });
186
+ console.log(JSON.stringify({
187
+ attachedTargets,
188
+ monitorDurationMs: options.durationMs,
189
+ promptInjected: Boolean(options.prompt)
190
+ }, null, 2));
191
+ if (options.prompt) {
192
+ const pageSession = attachedTargets.find((target) => target.targetId === page.id)?.sessionId;
193
+ if (!pageSession) {
194
+ throw new Error("Page target was not attached");
195
+ }
196
+ console.log("[debug-codex-workers] injecting prompt...");
197
+ await sendPromptThroughPage(client, pageSession, options.prompt);
198
+ console.log("[debug-codex-workers] prompt injected");
199
+ }
200
+ await sleep(options.durationMs);
201
+ const filtered = captured.filter((event) => {
202
+ const haystack = `${event.url ?? ""}\n${event.payload ?? ""}\n${event.postData ?? ""}`;
203
+ return /api|backend|chat|conversation|response|message|thread|openai|codex|graphql|assistant|turn/i.test(haystack);
204
+ });
205
+ console.log(JSON.stringify(filtered, null, 2));
206
+ }
207
+ async function sendPromptThroughPage(client, sessionId, prompt) {
208
+ await client.send("Runtime.evaluate", {
209
+ expression: `(() => {
210
+ const input = document.querySelector('[data-codex-composer="true"], textarea, input[type="text"], [contenteditable="true"], [role="textbox"]');
211
+ if (!(input instanceof HTMLElement)) {
212
+ return { ok: false, reason: 'input_not_found' };
213
+ }
214
+ input.focus();
215
+ return { ok: true };
216
+ })()`,
217
+ returnByValue: true,
218
+ awaitPromise: true
219
+ }, sessionId, 5_000);
220
+ await client.send("Input.dispatchKeyEvent", { type: "keyDown", commands: ["selectAll"] }, sessionId, 5_000);
221
+ await client.send("Input.dispatchKeyEvent", {
222
+ type: "keyDown",
223
+ key: "Backspace",
224
+ code: "Backspace",
225
+ windowsVirtualKeyCode: 8,
226
+ nativeVirtualKeyCode: 8
227
+ }, sessionId, 5_000);
228
+ await client.send("Input.dispatchKeyEvent", {
229
+ type: "keyUp",
230
+ key: "Backspace",
231
+ code: "Backspace",
232
+ windowsVirtualKeyCode: 8,
233
+ nativeVirtualKeyCode: 8
234
+ }, sessionId, 5_000);
235
+ await client.send("Input.insertText", { text: prompt }, sessionId, 5_000);
236
+ await client.send("Runtime.evaluate", {
237
+ expression: `(() => {
238
+ const sendButton = Array.from(document.querySelectorAll('button, [role="button"]')).find((candidate) => {
239
+ if (!(candidate instanceof HTMLElement)) {
240
+ return false;
241
+ }
242
+ const rect = candidate.getBoundingClientRect();
243
+ const label = [
244
+ candidate.textContent || '',
245
+ candidate.getAttribute('aria-label') || '',
246
+ candidate.getAttribute('title') || '',
247
+ candidate.className || ''
248
+ ].join(' ');
249
+ return rect.y >= window.innerHeight - 140
250
+ && rect.x >= window.innerWidth - 120
251
+ && /size-token-button-composer|send|发送|submit|开始构建|继续|run|resume/i.test(label);
252
+ });
253
+ if (!(sendButton instanceof HTMLElement)) {
254
+ return { ok: false, reason: 'send_button_not_found' };
255
+ }
256
+ sendButton.click();
257
+ return { ok: true };
258
+ })()`,
259
+ returnByValue: true,
260
+ awaitPromise: true
261
+ }, sessionId, 5_000);
262
+ }
263
+ async function fetchJson(url) {
264
+ const response = await fetch(url);
265
+ if (!response.ok) {
266
+ throw new Error(`Failed to fetch ${url}: ${response.status}`);
267
+ }
268
+ return response.json();
269
+ }
270
+ function parseArgs(argv, env) {
271
+ let durationMs = 10_000;
272
+ let prompt = null;
273
+ let remoteDebuggingPort = Number(env.CODEX_REMOTE_DEBUGGING_PORT ?? 9229);
274
+ let pageOnly = false;
275
+ for (let index = 0; index < argv.length; index += 1) {
276
+ const current = argv[index];
277
+ if (current === "--duration-ms") {
278
+ durationMs = Number(argv[index + 1] ?? durationMs);
279
+ index += 1;
280
+ continue;
281
+ }
282
+ if (current === "--prompt") {
283
+ prompt = argv[index + 1] ?? null;
284
+ index += 1;
285
+ continue;
286
+ }
287
+ if (current === "--remote-debugging-port") {
288
+ remoteDebuggingPort = Number(argv[index + 1] ?? remoteDebuggingPort);
289
+ index += 1;
290
+ continue;
291
+ }
292
+ if (current === "--page-only") {
293
+ pageOnly = true;
294
+ }
295
+ }
296
+ return {
297
+ remoteDebuggingPort,
298
+ durationMs: Number.isFinite(durationMs) ? durationMs : 10_000,
299
+ prompt,
300
+ pageOnly
301
+ };
302
+ }
303
+ function sleep(ms) {
304
+ return new Promise((resolve) => setTimeout(resolve, ms));
305
+ }
306
+ void main().catch((error) => {
307
+ console.error("[qq-codex-bridge] debug-codex-workers failed", error);
308
+ process.exitCode = 1;
309
+ });
@@ -0,0 +1,73 @@
1
+ import { spawn } from "node:child_process";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ export async function ensureCodexDesktopForDev(config, deps = {}) {
5
+ const fetchFn = deps.fetchFn ?? fetch;
6
+ const launchApp = deps.launchApp ?? ((appName, port) => launchCodexDesktop(appName, port));
7
+ const sleep = deps.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
8
+ if (await isCdpReachable(config.remoteDebuggingPort, fetchFn)) {
9
+ return { launched: false };
10
+ }
11
+ await launchApp(config.appName, config.remoteDebuggingPort);
12
+ const deadline = Date.now() + config.startupTimeoutMs;
13
+ while (Date.now() <= deadline) {
14
+ if (await isCdpReachable(config.remoteDebuggingPort, fetchFn)) {
15
+ return { launched: true };
16
+ }
17
+ await sleep(config.startupPollIntervalMs);
18
+ }
19
+ throw new Error(`Timed out waiting for Codex desktop CDP endpoint on port ${config.remoteDebuggingPort}`);
20
+ }
21
+ export function launchCodexDesktop(appName, port, deps = {}) {
22
+ const platform = deps.platform ?? process.platform;
23
+ const spawnFn = deps.spawnFn ?? spawn;
24
+ const portArg = `--remote-debugging-port=${port}`;
25
+ if (platform === "darwin") {
26
+ const executablePath = deps.appExecutablePath ?? resolveDarwinAppExecutablePath(appName);
27
+ const child = spawnFn(executablePath, [portArg], {
28
+ detached: true,
29
+ stdio: "ignore"
30
+ });
31
+ child.unref();
32
+ return;
33
+ }
34
+ if (platform === "linux") {
35
+ const child = spawnFn(appName, [portArg], {
36
+ detached: true,
37
+ stdio: "ignore"
38
+ });
39
+ child.unref();
40
+ return;
41
+ }
42
+ throw new Error(`Unsupported platform for automatic Codex launch: ${platform}`);
43
+ }
44
+ async function isCdpReachable(port, fetchFn) {
45
+ try {
46
+ const response = await fetchFn(`http://127.0.0.1:${port}/json/version`);
47
+ if (!response.ok) {
48
+ return false;
49
+ }
50
+ const payload = (await response.json());
51
+ return typeof payload.webSocketDebuggerUrl === "string" && payload.webSocketDebuggerUrl.length > 0;
52
+ }
53
+ catch {
54
+ return false;
55
+ }
56
+ }
57
+ export function resolveDarwinAppExecutablePath(appName, deps = {}) {
58
+ const searchRoots = deps.searchRoots ?? [
59
+ "/Applications",
60
+ path.join(process.env.HOME ?? "", "Applications")
61
+ ];
62
+ const existsSyncFn = deps.existsSyncFn ?? fs.existsSync;
63
+ const appNames = Array.from(new Set([appName, "Codex"]));
64
+ for (const searchRoot of searchRoots) {
65
+ for (const candidateName of appNames) {
66
+ const candidate = path.join(searchRoot, `${candidateName}.app`, "Contents", "MacOS", candidateName);
67
+ if (candidate && existsSyncFn(candidate)) {
68
+ return candidate;
69
+ }
70
+ }
71
+ }
72
+ return path.join("/Applications", `${appName}.app`, "Contents", "MacOS", appName);
73
+ }
@@ -0,0 +1,28 @@
1
+ import { loadConfigFromEnv } from "./config.js";
2
+ import { ensureCodexDesktopForDev } from "./dev-launch.js";
3
+ import { runBridgeDaemon } from "./main.js";
4
+ async function runDev() {
5
+ const config = loadConfigFromEnv(process.env);
6
+ const result = await ensureCodexDesktopForDev({
7
+ appName: config.codexDesktop.appName,
8
+ remoteDebuggingPort: config.codexDesktop.remoteDebuggingPort,
9
+ startupTimeoutMs: Number(process.env.CODEX_CDP_STARTUP_TIMEOUT_MS ?? "15000"),
10
+ startupPollIntervalMs: Number(process.env.CODEX_CDP_POLL_INTERVAL_MS ?? "500")
11
+ });
12
+ console.log("[qq-codex-bridge] codex desktop ready", {
13
+ launched: result.launched,
14
+ remoteDebuggingPort: config.codexDesktop.remoteDebuggingPort
15
+ });
16
+ await runBridgeDaemon();
17
+ }
18
+ runDev().catch((error) => {
19
+ const cause = error instanceof Error ? error.cause : undefined;
20
+ console.error("[qq-codex-bridge] fatal:", error instanceof Error ? error.message : String(error));
21
+ if (cause !== undefined) {
22
+ console.error(" caused by:", cause);
23
+ }
24
+ if (error instanceof Error && error.stack) {
25
+ console.error(" stack:", error.stack);
26
+ }
27
+ process.exitCode = 1;
28
+ });