@wlv-zedd/dsh-chatgpt-web 1.0.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 (85) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +140 -0
  3. package/assets/demo.gif +0 -0
  4. package/assets/hero-demo.png +0 -0
  5. package/assets/promo-dshmarket-official.png +0 -0
  6. package/cordis.patch.yml +4 -0
  7. package/lib/cli.js +239642 -0
  8. package/lib/plugin.js +195 -0
  9. package/package.json +88 -0
  10. package/screenshots.json +5 -0
  11. package/src/adapters/base.ts +16 -0
  12. package/src/adapters/chatgpt-web/adapter-error.ts +59 -0
  13. package/src/adapters/chatgpt-web/browser-helper-main.ts +513 -0
  14. package/src/adapters/chatgpt-web/browser-helper-prompt-selection.ts +27 -0
  15. package/src/adapters/chatgpt-web/browser-worker.ts +4944 -0
  16. package/src/adapters/chatgpt-web/codex-rollout-environment.ts +628 -0
  17. package/src/adapters/chatgpt-web/compaction-handoff.ts +533 -0
  18. package/src/adapters/chatgpt-web/compaction-transaction.ts +142 -0
  19. package/src/adapters/chatgpt-web/concurrency.ts +6 -0
  20. package/src/adapters/chatgpt-web/conversation-key.ts +58 -0
  21. package/src/adapters/chatgpt-web/environment.ts +669 -0
  22. package/src/adapters/chatgpt-web/index.ts +1544 -0
  23. package/src/adapters/chatgpt-web/input-tokens.ts +74 -0
  24. package/src/adapters/chatgpt-web/launcher-helper-client.ts +695 -0
  25. package/src/adapters/chatgpt-web/markdown.ts +418 -0
  26. package/src/adapters/chatgpt-web/mcp-main.ts +25 -0
  27. package/src/adapters/chatgpt-web/mcp-server.ts +933 -0
  28. package/src/adapters/chatgpt-web/model.ts +70 -0
  29. package/src/adapters/chatgpt-web/native-compaction-control.ts +74 -0
  30. package/src/adapters/chatgpt-web/output-validation.ts +62 -0
  31. package/src/adapters/chatgpt-web/process-line-writer.ts +46 -0
  32. package/src/adapters/chatgpt-web/prompt.ts +702 -0
  33. package/src/adapters/chatgpt-web/retry-policy.ts +73 -0
  34. package/src/adapters/chatgpt-web/rolling-checkpoint.ts +384 -0
  35. package/src/adapters/chatgpt-web/thread-environment.ts +238 -0
  36. package/src/adapters/chatgpt-web/tool-stream-parser.ts +601 -0
  37. package/src/adapters/chatgpt-web/turn-broker.ts +1481 -0
  38. package/src/adapters/chatgpt-web/turn-execution.ts +816 -0
  39. package/src/adapters/chatgpt-web/turn-progress.ts +292 -0
  40. package/src/adapters/chatgpt-web/usage.ts +121 -0
  41. package/src/adapters/image.ts +9 -0
  42. package/src/bridge.ts +1083 -0
  43. package/src/browser-login.ts +521 -0
  44. package/src/chatgpt-session.ts +240 -0
  45. package/src/chatgpt-web-models.ts +400 -0
  46. package/src/cli.ts +568 -0
  47. package/src/codex-integration-document.ts +824 -0
  48. package/src/codex-integration-journal.ts +212 -0
  49. package/src/codex-integration-route.ts +515 -0
  50. package/src/codex-integration-shared.ts +332 -0
  51. package/src/codex-integration.ts +529 -0
  52. package/src/codex-interrupt-hook.ts +158 -0
  53. package/src/config.ts +616 -0
  54. package/src/dev-chat/cli.ts +432 -0
  55. package/src/dev-chat/constants.ts +3 -0
  56. package/src/dev-chat/driver.ts +655 -0
  57. package/src/dev-chat/profile.ts +223 -0
  58. package/src/dev-chat/session.ts +287 -0
  59. package/src/dev-chat/transport.ts +54 -0
  60. package/src/doctor.ts +237 -0
  61. package/src/event-queue.ts +45 -0
  62. package/src/http-body.ts +30 -0
  63. package/src/launcher-browser-host.ts +695 -0
  64. package/src/lib/errors.ts +281 -0
  65. package/src/lib/token-estimate.ts +42 -0
  66. package/src/login-helper.cjs +140 -0
  67. package/src/model-catalog.ts +197 -0
  68. package/src/native-passthrough.ts +261 -0
  69. package/src/plugin.ts +191 -0
  70. package/src/process.ts +45 -0
  71. package/src/responses/compaction.ts +199 -0
  72. package/src/responses/parser.ts +633 -0
  73. package/src/responses/reasoning-envelope.ts +49 -0
  74. package/src/responses/schema.ts +172 -0
  75. package/src/responses/state.ts +230 -0
  76. package/src/server.ts +1111 -0
  77. package/src/service.ts +315 -0
  78. package/src/setup.ts +671 -0
  79. package/src/stall-timeout.ts +23 -0
  80. package/src/tunnel-service.ts +160 -0
  81. package/src/tunnel.ts +417 -0
  82. package/src/turndown-plugin-gfm.d.ts +5 -0
  83. package/src/types.ts +307 -0
  84. package/src/usage/totals.ts +12 -0
  85. package/src/version.ts +1 -0
@@ -0,0 +1,261 @@
1
+ import { readJsonRequestBody } from "./http-body";
2
+ import {
3
+ BRIDGE_COMPACTION_PREFIX,
4
+ SUMMARY_PREFIX,
5
+ decodeCompactionSummary,
6
+ } from "./responses/compaction";
7
+ import { BRIDGE_REASONING_PREFIX } from "./responses/reasoning-envelope";
8
+
9
+ const CODEX_BACKEND = "https://chatgpt.com/backend-api/codex";
10
+ const FIRST_PARTY_CODEX_ORIGINATORS = new Set([
11
+ "codex_cli_rs",
12
+ "codex-tui",
13
+ "codex_vscode",
14
+ "codex_atlas",
15
+ "codex_chatgpt_desktop",
16
+ ]);
17
+ const HOP_BY_HOP_HEADERS = new Set([
18
+ "connection",
19
+ "keep-alive",
20
+ "proxy-authenticate",
21
+ "proxy-authorization",
22
+ "te",
23
+ "trailer",
24
+ "transfer-encoding",
25
+ "upgrade",
26
+ "host",
27
+ ]);
28
+
29
+ export type NativeFetch = (request: Request) => Promise<Response>;
30
+ export type NativeCodexEndpoint = "models" | "responses" | "responses/compact" | "alpha/search";
31
+
32
+ type JsonObject = Record<string, unknown>;
33
+ type BridgeCompactionItem = JsonObject & { type: "compaction"; encrypted_content: string };
34
+
35
+ function firstPartyCodexOriginator(value: string): boolean {
36
+ return FIRST_PARTY_CODEX_ORIGINATORS.has(value)
37
+ || /^Codex [A-Za-z0-9][A-Za-z0-9._ -]{0,63}$/.test(value);
38
+ }
39
+
40
+ /**
41
+ * Current Codex clients identify themselves as `<originator>/<cargo semver> (...)`. The models
42
+ * backend requires the release-only `major.minor.patch` value even when the client is an alpha.
43
+ * Derive it only from the documented first-party Codex prefix; an arbitrary browser or proxy
44
+ * User-Agent is not evidence of a Codex version and leaves the original request untouched.
45
+ */
46
+ export function codexClientVersionFromUserAgent(userAgent: string | null): string | undefined {
47
+ if (!userAgent) return undefined;
48
+ const separator = userAgent.indexOf("/");
49
+ if (separator < 1) return undefined;
50
+ const originator = userAgent.slice(0, separator);
51
+ if (!firstPartyCodexOriginator(originator)) return undefined;
52
+ const version = /^(\d{1,6})\.(\d{1,6})\.(\d{1,6})(?:[-+][0-9A-Za-z.-]+)?(?:\s|$)/
53
+ .exec(userAgent.slice(separator + 1));
54
+ return version ? `${version[1]}.${version[2]}.${version[3]}` : undefined;
55
+ }
56
+
57
+ function isObject(value: unknown): value is JsonObject {
58
+ return value !== null && typeof value === "object" && !Array.isArray(value);
59
+ }
60
+
61
+ function isBridgeReasoningItem(value: unknown): value is JsonObject {
62
+ if (!isObject(value) || value.type !== "reasoning") return false;
63
+ const encrypted = value.encrypted_content;
64
+ if (typeof encrypted === "string" && encrypted.startsWith(BRIDGE_REASONING_PREFIX)) return true;
65
+ return typeof value.id === "string"
66
+ && /^rs_[0-9a-f]{32}$/i.test(value.id)
67
+ && (encrypted === undefined || encrypted === null)
68
+ && (Array.isArray(value.summary) || Array.isArray(value.content));
69
+ }
70
+
71
+ function isBridgeCompactionItem(value: unknown): value is BridgeCompactionItem {
72
+ return isObject(value)
73
+ && value.type === "compaction"
74
+ && typeof value.encrypted_content === "string"
75
+ && value.encrypted_content.startsWith(BRIDGE_COMPACTION_PREFIX);
76
+ }
77
+
78
+ /**
79
+ * Response item ids are scoped to the backend that created them. A ChatGPT Web response is
80
+ * generated locally, so replaying its `rs_*` id after switching back to native Codex makes the
81
+ * official backend try to load an item it has never stored. The same boundary applies to local
82
+ * `ocx1:` compaction checkpoints: preserve their decoded summary as a normal input message rather
83
+ * than asking the official backend to decrypt a bridge-owned envelope. Once either artifact proves
84
+ * that the history crossed providers, send the complete item content without provider-local ids.
85
+ */
86
+ export function scrubBridgeArtifactsForNative(value: unknown): { value: unknown; changed: boolean } {
87
+ if (!isObject(value)
88
+ || !Array.isArray(value.input)
89
+ || !value.input.some(item => isBridgeReasoningItem(item) || isBridgeCompactionItem(item))) {
90
+ return { value, changed: false };
91
+ }
92
+
93
+ const input = value.input.flatMap(item => {
94
+ if (!isObject(item)) return [item];
95
+ const clean = { ...item };
96
+ delete clean.id;
97
+ if (isBridgeCompactionItem(clean)) {
98
+ const summary = decodeCompactionSummary(clean.encrypted_content);
99
+ if (summary === null) throw new Error("Invalid ChatGPT Web compaction checkpoint");
100
+ return [{
101
+ type: "message",
102
+ role: "user",
103
+ content: [{ type: "input_text", text: `${SUMMARY_PREFIX}\n\n${summary}` }],
104
+ }];
105
+ }
106
+ if (clean.type !== "reasoning") return [clean];
107
+
108
+ if (typeof clean.encrypted_content === "string"
109
+ && clean.encrypted_content.startsWith(BRIDGE_REASONING_PREFIX)) {
110
+ delete clean.encrypted_content;
111
+ } else if (clean.encrypted_content === null) {
112
+ delete clean.encrypted_content;
113
+ }
114
+
115
+ const hasSummary = Array.isArray(clean.summary) && clean.summary.length > 0;
116
+ const hasContent = Array.isArray(clean.content) && clean.content.length > 0;
117
+ const hasNativeEncryptedContent = typeof clean.encrypted_content === "string";
118
+ return hasSummary || hasContent || hasNativeEncryptedContent ? [clean] : [];
119
+ });
120
+ const clean: JsonObject = { ...value, input };
121
+ delete clean.previous_response_id;
122
+ return { value: clean, changed: true };
123
+ }
124
+
125
+ function endToEndHeaders(source: Headers): Headers {
126
+ const headers = new Headers();
127
+ for (const [name, value] of source) {
128
+ if (!HOP_BY_HOP_HEADERS.has(name.toLowerCase())) headers.append(name, value);
129
+ }
130
+ headers.delete("content-length");
131
+ return headers;
132
+ }
133
+
134
+ /** Terminator every Responses SSE stream ends with; nothing after it carries meaning. */
135
+ const SSE_TERMINATOR = "data: [DONE]";
136
+
137
+ /**
138
+ * ChatGPT's backend routinely resets the native Codex connection instead of closing it cleanly,
139
+ * which Bun surfaces as ECONNRESET while reading the body. Passed through untouched that reaches
140
+ * Codex as a truncated HTTP body and the opaque "error decoding response body".
141
+ *
142
+ * A reset that arrives after the stream already delivered `data: [DONE]` is an unclean TCP close on
143
+ * a turn that finished: every byte the protocol defines has been forwarded, so the stream is closed
144
+ * normally rather than failed. A reset before that genuinely truncated the turn and is still raised,
145
+ * because inventing a terminal event there would tell Codex a turn ended when it did not.
146
+ */
147
+ function withUncleanCloseTolerance(
148
+ body: ReadableStream<Uint8Array>,
149
+ isEventStream: boolean,
150
+ onUncleanClose?: (bytes: number) => void,
151
+ ): ReadableStream<Uint8Array> {
152
+ if (!isEventStream) return body;
153
+ const reader = body.getReader();
154
+ const decoder = new TextDecoder();
155
+ let lineBuffer = "";
156
+ let completed = false;
157
+ let bytes = 0;
158
+ const inspectLines = (text: string): void => {
159
+ lineBuffer += text;
160
+ let newline = lineBuffer.indexOf("\n");
161
+ while (newline >= 0) {
162
+ const line = lineBuffer.slice(0, newline).replace(/\r$/, "");
163
+ lineBuffer = lineBuffer.slice(newline + 1);
164
+ if (line === SSE_TERMINATOR) completed = true;
165
+ newline = lineBuffer.indexOf("\n");
166
+ }
167
+ };
168
+ const inspectTrailingLine = (): void => {
169
+ // A reset can arrive before the final line separator. Treat only an exact unterminated
170
+ // terminator line as complete; text embedded in a JSON data payload must not qualify.
171
+ if (lineBuffer.replace(/\r$/, "") === SSE_TERMINATOR) completed = true;
172
+ };
173
+ return new ReadableStream<Uint8Array>({
174
+ async pull(controller) {
175
+ try {
176
+ const chunk = await reader.read();
177
+ if (chunk.done) {
178
+ inspectLines(decoder.decode());
179
+ inspectTrailingLine();
180
+ controller.close();
181
+ return;
182
+ }
183
+ bytes += chunk.value.byteLength;
184
+ inspectLines(decoder.decode(chunk.value, { stream: true }));
185
+ controller.enqueue(chunk.value);
186
+ } catch (error) {
187
+ inspectTrailingLine();
188
+ if (!completed) {
189
+ controller.error(error);
190
+ return;
191
+ }
192
+ onUncleanClose?.(bytes);
193
+ controller.close();
194
+ }
195
+ },
196
+ cancel(reason) {
197
+ return reader.cancel(reason);
198
+ },
199
+ });
200
+ }
201
+
202
+ export async function forwardNativeCodexRequest(
203
+ request: Request,
204
+ endpoint: NativeCodexEndpoint,
205
+ fetchUpstream: NativeFetch = fetch,
206
+ decodedBody?: unknown,
207
+ ): Promise<Response> {
208
+ const authorization = request.headers.get("authorization") ?? "";
209
+ if (!authorization.startsWith("Bearer ") || authorization.length <= "Bearer ".length) {
210
+ throw new Error("Native Codex passthrough requires the incoming Bearer authorization");
211
+ }
212
+
213
+ const incomingUrl = new URL(request.url);
214
+ if (endpoint === "models" && !incomingUrl.searchParams.has("client_version")) {
215
+ const clientVersion = codexClientVersionFromUserAgent(request.headers.get("user-agent"));
216
+ if (clientVersion) incomingUrl.searchParams.set("client_version", clientVersion);
217
+ }
218
+ const headers = endToEndHeaders(request.headers);
219
+ if (endpoint === "models") headers.delete("if-none-match");
220
+ const method = endpoint === "models" ? "GET" : "POST";
221
+ let body: BodyInit | undefined;
222
+ if (method === "POST") {
223
+ const parseRequest = decodedBody === undefined ? request.clone() : undefined;
224
+ const originalBody = await request.arrayBuffer();
225
+ const scrubbed = scrubBridgeArtifactsForNative(
226
+ decodedBody === undefined ? await readJsonRequestBody(parseRequest!) : decodedBody,
227
+ );
228
+ if (scrubbed.changed) {
229
+ headers.delete("content-encoding");
230
+ body = JSON.stringify(scrubbed.value);
231
+ } else {
232
+ body = originalBody;
233
+ }
234
+ }
235
+ const upstreamRequest = new Request(`${CODEX_BACKEND}/${endpoint}${incomingUrl.search}`, {
236
+ method,
237
+ headers,
238
+ ...(body ? { body } : {}),
239
+ signal: request.signal,
240
+ });
241
+ const upstream = await fetchUpstream(upstreamRequest);
242
+ const responseHeaders = endToEndHeaders(upstream.headers);
243
+ const isEventStream = (upstream.headers.get("content-type") ?? "")
244
+ .toLowerCase()
245
+ .includes("text/event-stream");
246
+ return new Response(
247
+ upstream.body
248
+ ? withUncleanCloseTolerance(upstream.body, isEventStream, bytes => {
249
+ console.warn(
250
+ `[codex-chatgpt-web] native_upstream_unclean_close endpoint=${endpoint} bytes=${bytes}`
251
+ + " (turn had already completed; closing the client stream normally)",
252
+ );
253
+ })
254
+ : upstream.body,
255
+ {
256
+ status: upstream.status,
257
+ statusText: upstream.statusText,
258
+ headers: responseHeaders,
259
+ },
260
+ );
261
+ }
package/src/plugin.ts ADDED
@@ -0,0 +1,191 @@
1
+ import { spawn, type ChildProcess } from "node:child_process";
2
+ import { existsSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { dirname, join, resolve } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+
7
+ export interface CordisContext {
8
+ effect?: (cb: () => void | Promise<void> | (() => void) | (() => Promise<void>)) => void;
9
+ logger?: (name: string) => {
10
+ info(msg: string): void;
11
+ warn(msg: string): void;
12
+ error(msg: string): void;
13
+ debug(msg: string): void;
14
+ };
15
+ }
16
+
17
+ export const name = "dsh-chatgpt-web";
18
+ export const inject = [];
19
+
20
+ export interface ChatGPTWebPluginConfig {
21
+ /** Host to bind or check for health (default: 127.0.0.1). */
22
+ host?: string;
23
+ /** Port the sidecar listens on (default: 17841). */
24
+ port?: number;
25
+ /** Automatically start the sidecar daemon if not running (default: true). */
26
+ autoStart?: boolean;
27
+ /** Maximum milliseconds to wait for the sidecar to report ready (default: 30000). */
28
+ readyTimeoutMs?: number;
29
+ /** Explicit path to the bun executable (optional). */
30
+ bunPath?: string;
31
+ }
32
+
33
+ import { pathToFileURL } from "node:url";
34
+
35
+ const ROOT_DIR = resolve(dirname(fileURLToPath(import.meta.url)), "..");
36
+
37
+ function resolveLauncher(customBunPath?: string): { cmd: string; args: string[] } {
38
+ const libCliPath = resolve(ROOT_DIR, "lib", "cli.js");
39
+ if (existsSync(libCliPath)) {
40
+ return {
41
+ cmd: process.execPath,
42
+ args: [libCliPath, "serve"],
43
+ };
44
+ }
45
+
46
+ // Development fallback: check for bun or tsx
47
+ if (customBunPath && existsSync(customBunPath)) {
48
+ return { cmd: customBunPath, args: ["run", "src/cli.ts", "serve"] };
49
+ }
50
+
51
+ const winBun = join(homedir(), ".bun", "bin", "bun.exe");
52
+ if (existsSync(winBun)) {
53
+ return { cmd: winBun, args: ["run", "src/cli.ts", "serve"] };
54
+ }
55
+
56
+ const tsxPath = resolve(ROOT_DIR, "../deepseek-harness/node_modules/tsx/dist/esm/index.mjs");
57
+ if (existsSync(tsxPath)) {
58
+ return {
59
+ cmd: process.execPath,
60
+ args: ["--import", pathToFileURL(tsxPath).href, "src/cli.ts", "serve"],
61
+ };
62
+ }
63
+
64
+ return { cmd: process.execPath, args: ["src/cli.ts", "serve"] };
65
+ }
66
+
67
+ async function isSidecarHealthy(host: string, port: number): Promise<boolean> {
68
+ try {
69
+ const controller = new AbortController();
70
+ const timer = setTimeout(() => controller.abort(), 1500);
71
+ const res = await fetch(`http://${host}:${port}/healthz`, {
72
+ signal: controller.signal,
73
+ });
74
+ clearTimeout(timer);
75
+ if (!res.ok) return false;
76
+ const body = (await res.json()) as { status?: string };
77
+ return body.status === "ok";
78
+ } catch {
79
+ return false;
80
+ }
81
+ }
82
+
83
+ export function apply(ctx: CordisContext, config: ChatGPTWebPluginConfig = {}): void {
84
+ const host = config.host || "127.0.0.1";
85
+ const port = config.port || 17841;
86
+ const autoStart = config.autoStart !== false;
87
+ const readyTimeoutMs = config.readyTimeoutMs || 30_000;
88
+ const logger = typeof ctx.logger === "function" ? ctx.logger("chatgpt-web") : console;
89
+
90
+ let spawnedProcess: ChildProcess | undefined;
91
+
92
+ const startDaemon = async () => {
93
+ const alreadyHealthy = await isSidecarHealthy(host, port);
94
+ if (alreadyHealthy) {
95
+ logger.info(`[dsh-chatgpt-web] Sidecar already running and healthy at http://${host}:${port}/v1`);
96
+ return;
97
+ }
98
+
99
+ if (!autoStart) {
100
+ logger.warn(`[dsh-chatgpt-web] Sidecar is offline and autoStart is false. Start it manually with 'bun run src/cli.ts serve'`);
101
+ return;
102
+ }
103
+
104
+ const launcher = resolveLauncher(config.bunPath);
105
+ logger.info(`[dsh-chatgpt-web] Starting dsh-chatgpt-web daemon via ${launcher.cmd} at http://${host}:${port}/v1...`);
106
+
107
+ const child = spawn(launcher.cmd, launcher.args, {
108
+ cwd: ROOT_DIR,
109
+ stdio: ["ignore", "pipe", "pipe"],
110
+ windowsHide: true,
111
+ env: {
112
+ ...process.env,
113
+ },
114
+ });
115
+
116
+ spawnedProcess = child;
117
+
118
+ child.stdout?.on("data", (chunk: Buffer) => {
119
+ const text = chunk.toString().trim();
120
+ if (text && typeof logger.debug === "function") logger.debug(`[sidecar] ${text}`);
121
+ });
122
+
123
+ child.stderr?.on("data", (chunk: Buffer) => {
124
+ const text = chunk.toString().trim();
125
+ if (text && typeof logger.debug === "function") logger.debug(`[sidecar:err] ${text}`);
126
+ });
127
+
128
+ child.on("error", (err) => {
129
+ logger.error(`[dsh-chatgpt-web] Failed to launch sidecar process: ${err.message}`);
130
+ });
131
+
132
+ child.on("exit", (code, signal) => {
133
+ if (code !== 0 && code !== null) {
134
+ logger.warn(`[dsh-chatgpt-web] Sidecar process exited with code ${code} (signal: ${signal})`);
135
+ }
136
+ spawnedProcess = undefined;
137
+ });
138
+
139
+ // Wait for healthcheck
140
+ const deadline = Date.now() + readyTimeoutMs;
141
+ while (Date.now() < deadline) {
142
+ if (await isSidecarHealthy(host, port)) {
143
+ logger.info(`[dsh-chatgpt-web] Sidecar ready and accepting turns at http://${host}:${port}/v1`);
144
+ return;
145
+ }
146
+ await new Promise((r) => setTimeout(r, 500));
147
+ }
148
+
149
+ logger.error(`[dsh-chatgpt-web] Sidecar did not become healthy within ${readyTimeoutMs}ms`);
150
+ };
151
+
152
+ const stopDaemon = async () => {
153
+ if (!spawnedProcess) return;
154
+
155
+ logger.info("[dsh-chatgpt-web] Stopping sidecar daemon...");
156
+ try {
157
+ const controller = new AbortController();
158
+ const timer = setTimeout(() => controller.abort(), 2000);
159
+ await fetch(`http://${host}:${port}/admin/shutdown`, {
160
+ method: "POST",
161
+ signal: controller.signal,
162
+ }).catch(() => {});
163
+ clearTimeout(timer);
164
+ } catch {
165
+ // ignore
166
+ }
167
+
168
+ if (spawnedProcess && !spawnedProcess.killed) {
169
+ spawnedProcess.kill("SIGTERM");
170
+ }
171
+ spawnedProcess = undefined;
172
+ };
173
+
174
+ if (typeof ctx.effect === "function") {
175
+ ctx.effect(() => {
176
+ void startDaemon();
177
+ return () => {
178
+ void stopDaemon();
179
+ };
180
+ });
181
+ } else {
182
+ void startDaemon();
183
+ process.once("beforeExit", () => void stopDaemon());
184
+ }
185
+ }
186
+
187
+ export default {
188
+ name,
189
+ inject,
190
+ apply,
191
+ };
package/src/process.ts ADDED
@@ -0,0 +1,45 @@
1
+ import { spawnSync, type SpawnSyncOptions } from "node:child_process";
2
+
3
+ export interface CommandResult {
4
+ status: number;
5
+ stdout: string;
6
+ stderr: string;
7
+ }
8
+
9
+ export function processRunning(
10
+ pid: unknown,
11
+ probe: (pid: number, signal: 0) => void = process.kill,
12
+ ): boolean {
13
+ if (!Number.isInteger(pid) || (pid as number) < 1) return false;
14
+ try {
15
+ probe(pid as number, 0);
16
+ return true;
17
+ } catch (error) {
18
+ // Windows and hardened Unix environments can deny signalling an existing process. EPERM is
19
+ // existence evidence, not proof that the launcher/browser/tunnel owner disappeared.
20
+ return (error as NodeJS.ErrnoException)?.code === "EPERM";
21
+ }
22
+ }
23
+
24
+ export function runCommand(command: string, args: string[], options: SpawnSyncOptions = {}): CommandResult {
25
+ const result = spawnSync(command, args, {
26
+ encoding: "utf8",
27
+ stdio: "pipe",
28
+ ...options,
29
+ });
30
+ if (result.error) throw result.error;
31
+ return {
32
+ status: result.status ?? 1,
33
+ stdout: typeof result.stdout === "string" ? result.stdout : result.stdout?.toString("utf8") ?? "",
34
+ stderr: typeof result.stderr === "string" ? result.stderr : result.stderr?.toString("utf8") ?? "",
35
+ };
36
+ }
37
+
38
+ export function runChecked(command: string, args: string[], options: SpawnSyncOptions = {}): CommandResult {
39
+ const result = runCommand(command, args, options);
40
+ if (result.status !== 0) {
41
+ const detail = result.stderr.trim() || result.stdout.trim() || `exit ${result.status}`;
42
+ throw new Error(`${command} ${args.join(" ")} failed: ${detail}`);
43
+ }
44
+ return result;
45
+ }
@@ -0,0 +1,199 @@
1
+ /**
2
+ * Remote compaction v2 support for ROUTED providers.
3
+ *
4
+ * Codex decides "this provider supports remote compaction" by provider name (built-in `OpenAI`),
5
+ * and Design B points that provider at this proxy — so Codex sends remote compaction v2 requests
6
+ * for EVERY routed model. The request is a normal /responses call whose input ends with
7
+ * `{"type":"compaction_trigger"}`; codex-rs `collect_compaction_output` then requires the stream
8
+ * to carry EXACTLY ONE `{"type":"compaction","encrypted_content":...}` output item
9
+ * (compact_remote_v2.rs) or it fatals with "expected exactly one compaction output item".
10
+ *
11
+ * Routed models cannot produce OpenAI's encrypted blob, so the proxy runs the model as a plain
12
+ * summarizer and wraps the summary text in a transparent envelope: `ocx1:` + base64(utf8 summary).
13
+ * Codex stores the item and replays it in later input; the parser decodes our envelope back into
14
+ * plain text for routed models. Real OpenAI-encrypted blobs (no `ocx1:` prefix) are opaque —
15
+ * routed models get a short "history was compacted" note instead.
16
+ */
17
+
18
+ export const BRIDGE_COMPACTION_PREFIX = "ocx1:";
19
+
20
+ /** Mirrors codex-rs core/templates/compact/prompt.md (the local-compaction instruction). */
21
+ export const COMPACT_PROMPT = `You are performing a CONTEXT CHECKPOINT COMPACTION. Create a handoff summary for another LLM that will resume the task.
22
+
23
+ Include:
24
+ - Current progress and key decisions made
25
+ - Important context, constraints, or user preferences
26
+ - What remains to be done (clear next steps)
27
+ - Any critical data, examples, or references needed to continue
28
+
29
+ Be concise, structured, and focused on helping the next LLM seamlessly continue the work.`;
30
+
31
+ /** Mirrors codex-rs core/templates/compact/summary_prefix.md (framing for a replayed summary). */
32
+ export const SUMMARY_PREFIX = "Another language model started to solve this problem and produced a summary of its thinking process. You also have access to the state of the tools that were used by that language model. Use this to build on the work that has already been done and avoid duplicating work. Here is the summary produced by the other language model, use the information in this summary to assist with your own analysis:";
33
+
34
+ export const OPAQUE_COMPACTION_NOTE = "[earlier conversation was compacted; the summary is stored in a format this model cannot read]";
35
+
36
+ /** Codex v1 uses one newline after the prefix; the transparent v2 replay uses two. */
37
+ export function isReadableCompactionSummaryText(value: unknown): value is string {
38
+ return typeof value === "string" && value.startsWith(`${SUMMARY_PREFIX}\n`);
39
+ }
40
+
41
+ export function encodeCompactionSummary(summary: string): string {
42
+ return BRIDGE_COMPACTION_PREFIX + Buffer.from(summary, "utf-8").toString("base64");
43
+ }
44
+
45
+ /** Decode an `ocx1:` envelope; returns null for real (OpenAI-encrypted) blobs or garbage. */
46
+ export function decodeCompactionSummary(encryptedContent: string): string | null {
47
+ if (!encryptedContent.startsWith(BRIDGE_COMPACTION_PREFIX)) return null;
48
+ try {
49
+ return Buffer.from(encryptedContent.slice(BRIDGE_COMPACTION_PREFIX.length), "base64").toString("utf-8");
50
+ } catch {
51
+ return null;
52
+ }
53
+ }
54
+
55
+ /** Render a replayed compaction item as plain user-visible text for a routed model. */
56
+ export function compactionItemToText(encryptedContent: string | undefined): string {
57
+ const decoded = typeof encryptedContent === "string" ? decodeCompactionSummary(encryptedContent) : null;
58
+ return decoded ? `${SUMMARY_PREFIX}\n\n${decoded}` : OPAQUE_COMPACTION_NOTE;
59
+ }
60
+
61
+ /**
62
+ * Remote compaction v1 (`POST /responses/compact`, unary) — codex-rs installs the returned
63
+ * `{"output":[ResponseItem...]}` as the REPLACEMENT history (compact_remote.rs
64
+ * process_compacted_history). Mirror codex-rs local `build_compacted_history`: recent real user
65
+ * messages within a token budget, then one user message `SUMMARY_PREFIX\n<summary>`. Plain user
66
+ * message items parse as real user messages on the codex side (event_mapping parse_user_message);
67
+ * contextual wrappers are filtered there, and v2-style `compaction` items are NOT expected here.
68
+ */
69
+
70
+ /** codex-rs compact.rs COMPACT_USER_MESSAGE_MAX_TOKENS = 20k tokens (~4 chars/token). */
71
+ const COMPACT_V1_RETAINED_CHAR_BUDGET = 20_000 * 4;
72
+
73
+ type CompactMessageItem = Record<string, unknown>;
74
+
75
+ interface CompactContentBlock extends Record<string, unknown> {
76
+ type?: string;
77
+ text?: string;
78
+ image_url?: string;
79
+ }
80
+
81
+ /**
82
+ * Codex can persist unavailable historical images as a one-pixel PNG. Replaying that sentinel as
83
+ * a real attachment produces an opaque black tile in ChatGPT and consumes one attachment slot,
84
+ * but carries no visual information. Treat every 1x1 PNG data URL as non-semantic transport state.
85
+ */
86
+ export function isOnePixelPngDataUrl(value: unknown): value is string {
87
+ if (typeof value !== "string" || !value.startsWith("data:image/png;base64,")) return false;
88
+ try {
89
+ const png = Buffer.from(value.slice("data:image/png;base64,".length), "base64");
90
+ return png.length >= 24
91
+ && png.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]))
92
+ && png.readUInt32BE(16) === 1
93
+ && png.readUInt32BE(20) === 1;
94
+ } catch {
95
+ return false;
96
+ }
97
+ }
98
+
99
+ /**
100
+ * Extract original user message items from a Responses `input` array.
101
+ *
102
+ * Keeping the original item metadata matters: Codex uses it after `/responses/compact` to
103
+ * distinguish real user turns from contextual user-role wrappers. Images remain structured
104
+ * `input_image` blocks so the browser adapter can upload them as attachments; their data URL is
105
+ * never copied into the textual ChatGPT transport envelope.
106
+ */
107
+ export function extractCompactUserMessages(input: unknown): CompactMessageItem[] {
108
+ if (!Array.isArray(input)) return [];
109
+ const out: CompactMessageItem[] = [];
110
+ for (const item of input) {
111
+ if (!item || typeof item !== "object" || Array.isArray(item)) continue;
112
+ const rec = item as CompactMessageItem & { type?: string; role?: string; content?: unknown };
113
+ if (rec.type !== undefined && rec.type !== "message") continue;
114
+ if (rec.role !== "user") continue;
115
+ if (isReadableCompactionSummaryText(
116
+ compactContentBlocks(rec).filter(textBlock).map(block => block.text).join(""),
117
+ )) continue;
118
+ out.push(structuredClone(rec));
119
+ }
120
+ return out;
121
+ }
122
+
123
+ function compactUserMessageItem(text: string): CompactMessageItem {
124
+ return { type: "message", role: "user", content: [{ type: "input_text", text }] };
125
+ }
126
+
127
+ function compactContentBlocks(item: CompactMessageItem): CompactContentBlock[] {
128
+ if (typeof item.content === "string") {
129
+ return [{ type: "input_text", text: item.content }];
130
+ }
131
+ if (!Array.isArray(item.content)) return [];
132
+ return item.content
133
+ .filter((block): block is CompactContentBlock => Boolean(block && typeof block === "object" && !Array.isArray(block)))
134
+ .map(block => structuredClone(block));
135
+ }
136
+
137
+ function textBlock(block: CompactContentBlock): boolean {
138
+ return (block.type === "input_text" || block.type === "text") && typeof block.text === "string";
139
+ }
140
+
141
+ function imageBlock(block: CompactContentBlock): boolean {
142
+ return block.type === "input_image"
143
+ && typeof block.image_url === "string"
144
+ && !isOnePixelPngDataUrl(block.image_url);
145
+ }
146
+
147
+ /**
148
+ * Build the v1 compact replacement history.
149
+ *
150
+ * Text follows Codex's 20k-token retained-user-message budget. Image history is independently
151
+ * bounded to ChatGPT's ten-attachment limit, newest first. This prevents an old image corpus from
152
+ * immediately refilling Codex's context window after a successful compact while still preserving
153
+ * the visual context the browser model can actually receive.
154
+ */
155
+ export function buildCompactV1Output(
156
+ userMessages: CompactMessageItem[],
157
+ summary: string,
158
+ maxImages = 10,
159
+ ): CompactMessageItem[] {
160
+ const selected: CompactMessageItem[] = [];
161
+ let remaining = COMPACT_V1_RETAINED_CHAR_BUDGET;
162
+ let retainedImages = 0;
163
+ for (let i = userMessages.length - 1; i >= 0 && (remaining > 0 || retainedImages < maxImages); i--) {
164
+ const message = structuredClone(userMessages[i]!);
165
+ const blocks = compactContentBlocks(message);
166
+ const retainedReversed: CompactContentBlock[] = [];
167
+ for (let blockIndex = blocks.length - 1; blockIndex >= 0; blockIndex -= 1) {
168
+ const block = blocks[blockIndex]!;
169
+ if (imageBlock(block)) {
170
+ if (retainedImages < maxImages) {
171
+ retainedImages += 1;
172
+ retainedReversed.push(block);
173
+ }
174
+ continue;
175
+ }
176
+ if (!textBlock(block) || remaining === 0) continue;
177
+ const text = block.text!;
178
+ if (text.length <= remaining) {
179
+ remaining -= text.length;
180
+ retainedReversed.push({ ...block, type: "input_text", text });
181
+ } else {
182
+ retainedReversed.push({ ...block, type: "input_text", text: text.slice(text.length - remaining) });
183
+ remaining = 0;
184
+ }
185
+ }
186
+ const content = retainedReversed.reverse();
187
+ if (content.length > 0) {
188
+ message.type = "message";
189
+ message.role = "user";
190
+ message.content = content;
191
+ selected.push(message);
192
+ }
193
+ }
194
+ selected.reverse();
195
+ // codex-rs compact.rs uses "{SUMMARY_PREFIX}\n{summary}" (single newline) and detects stored
196
+ // summaries by that exact prefix — keep the same shape.
197
+ const summaryText = summary.trim().length > 0 ? `${SUMMARY_PREFIX}\n${summary}` : "(no summary available)";
198
+ return [...selected, compactUserMessageItem(summaryText)];
199
+ }