replica-bridge 0.2.0 → 0.3.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "replica-bridge",
3
- "version": "0.2.0",
3
+ "version": "0.3.1",
4
4
  "description": "Lets the ChatReplica iPhone app use Codex on this computer through a relay, end-to-end encrypted.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -16,6 +16,8 @@ import {
16
16
  import { relaySocketURL } from "../http.js";
17
17
  import { killCommand, spawnCommand } from "./command.js";
18
18
  import { hostKeyPair, loadState } from "./state.js";
19
+ import { annotateWriterError } from "./writer.js";
20
+
19
21
 
20
22
 
21
23
 
@@ -33,6 +35,8 @@ import { hostKeyPair, loadState } from "./state.js";
33
35
 
34
36
 
35
37
 
38
+
39
+
36
40
 
37
41
 
38
42
 
@@ -44,6 +48,8 @@ import { hostKeyPair, loadState } from "./state.js";
44
48
 
45
49
 
46
50
 
51
+
52
+
47
53
 
48
54
 
49
55
 
@@ -187,6 +193,10 @@ export class BridgeService {
187
193
  this.scheduleAck(stream);
188
194
  } else if (message.t === "ack") {
189
195
  conn.stream?.out.ack(message.seq);
196
+ } else if (message.t === "closed") {
197
+ // The phone is done with this stream: stop its proxy now, so the app-server unloads the threads it followed and
198
+ // the computer's other Codex apps can open them, rather than holding them for streamTTL.
199
+ if (conn.stream) this.endStream(conn.stream, "the phone closed the stream");
190
200
  }
191
201
  }
192
202
 
@@ -213,12 +223,17 @@ export class BridgeService {
213
223
 
214
224
  async openStream(deviceId ) {
215
225
  const appServer = await this.connectAppServer();
216
- const stream = { id: crypto.randomBytes(12).toString("base64url"), deviceId, appServer, out: new StreamBuffer(), in: new StreamBuffer(), conn: null, expiry: null, ackTimer: null };
226
+ const stream = { id: crypto.randomBytes(12).toString("base64url"), deviceId, appServer, out: new StreamBuffer(), in: new StreamBuffer(), conn: null, expiry: null, ackTimer: null, relayed: Promise.resolve() };
217
227
  this.streams.set(stream.id, stream);
218
228
  appServer.on("message", (data ) => {
219
- let item;
220
- try { item = stream.out.push(data.toString()); } catch { return this.endStream(stream, "the phone stayed away too long and the buffer filled up"); }
221
- if (stream.conn) this.sendInner(stream.conn, { t: "msg", seq: item.seq, line: item.line });
229
+ const annotated = annotateWriterError(data.toString(), this.options.findWriter);
230
+ stream.relayed = stream.relayed.then(async () => {
231
+ const line = await annotated;
232
+ if (!this.streams.has(stream.id)) return;
233
+ let item;
234
+ try { item = stream.out.push(line); } catch { return this.endStream(stream, "the phone stayed away too long and the buffer filled up"); }
235
+ if (stream.conn) this.sendInner(stream.conn, { t: "msg", seq: item.seq, line: item.line });
236
+ });
222
237
  });
223
238
  appServer.on("close", () => this.endStream(stream, "codex app-server closed the connection"));
224
239
  appServer.on("error", (error) => this.log(`app-server stream ${stream.id}: ${error.message}`));
@@ -0,0 +1,67 @@
1
+ // Which app holds a thread when the app-server answers "thread <id> already has an active writer". Codex lets one
2
+ // process write a thread at a time through a lock file, `$CODEX_HOME/thread-writer-locks/<id>.lock`, and the error
3
+ // does not say who has it. On the computer the bridge can: whoever has the lock file open. The answer goes back to
4
+ // the phone in the error's `data.replicaWriter`, so the app can say "the ChatGPT desktop app has this task" rather
5
+ // than "another Codex session". Only macOS and Linux (lsof); elsewhere, or when lsof finds nothing, the error passes
6
+ // through unchanged.
7
+ import { execFile } from "node:child_process";
8
+ import os from "node:os";
9
+ import path from "node:path";
10
+
11
+ /** The app holding a thread's lock, as the phone reads it. */
12
+
13
+
14
+
15
+
16
+
17
+
18
+
19
+ const ACTIVE_WRITER = /^thread ([0-9A-Za-z-]+) already has an active writer/;
20
+
21
+ /**
22
+ * The app behind a process's command line. The ChatGPT desktop app runs its own app-server from inside its bundle
23
+ * (`/Applications/ChatGPT.app/Contents/Resources/codex-cli/CodexCLI.app/…/codex app-server`, observed with
24
+ * ChatGPT 155.0.8059.80); `codex exec` runs one for the length of the command.
25
+ */
26
+ export function classifyWriter(command ) {
27
+ if (/\/(ChatGPT|Codex)\.app\//.test(command)) return "chatgpt-desktop";
28
+ if (/(^|[\s/])codex(\.exe)?\s+(.*\s)?exec(\s|$)/.test(command)) return "codex-exec";
29
+ if (/(^|[\s/])codex(\.exe)?(\s|$)/.test(command)) return "codex-cli";
30
+ return "other";
31
+ }
32
+
33
+ function run(command , args ) {
34
+ return new Promise((resolve) => {
35
+ execFile(command, args, { timeout: 3000 }, (_error, stdout) => resolve(String(stdout ?? "")));
36
+ });
37
+ }
38
+
39
+ /** Who has the thread's lock file open; null when nobody does or it cannot be told on this platform. */
40
+ export async function findWriter(threadId , codexHome = process.env.CODEX_HOME || path.join(os.homedir(), ".codex")) {
41
+ if (process.platform === "win32") return null;
42
+ const lock = path.join(codexHome, "thread-writer-locks", `${threadId}.lock`);
43
+ const pids = (await run("lsof", ["-t", "--", lock])).split(/\s+/).map(Number).filter((pid) => pid > 0 && pid !== process.pid);
44
+ for (const pid of pids) {
45
+ const command = (await run("ps", ["-o", "command=", "-p", String(pid)])).trim();
46
+ if (command) return { app: classifyWriter(command), pid };
47
+ }
48
+ return null;
49
+ }
50
+
51
+ /**
52
+ * An app-server line with the writer added to an "already has an active writer" error, or the line itself. Lines
53
+ * that cannot be such an error are not parsed.
54
+ */
55
+ export async function annotateWriterError(line , find = findWriter) {
56
+ if (!line.includes("already has an active writer")) return line;
57
+ let message;
58
+ try { message = JSON.parse(line); } catch { return line; }
59
+ const error = message?.error;
60
+ const threadId = typeof error?.message === "string" ? ACTIVE_WRITER.exec(error.message)?.[1] : undefined;
61
+ if (!threadId) return line;
62
+ const writer = await find(threadId).catch(() => null);
63
+ if (!writer) return line;
64
+ const data = error.data && typeof error.data === "object" && !Array.isArray(error.data) ? error.data : {};
65
+ message.error = { ...error, data: { ...data, replicaWriter: writer } };
66
+ return JSON.stringify(message);
67
+ }