replica-bridge 0.1.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/README.md CHANGED
@@ -5,4 +5,10 @@ Lets the ChatReplica iPhone app use Codex on this computer. Needs Codex installe
5
5
  npx replica-bridge
6
6
 
7
7
  The first run sets this computer up with the relay, shows a QR code to scan in the app (远程 › 添加连接 › 配对新设备),
8
- and keeps running: paired phones can use Codex here while it does. `npx replica-bridge --help` lists the other commands.
8
+ then offers to keep running in the background and start at login (macOS launchd, Linux systemd, Windows Task
9
+ Scheduler), so the terminal can be closed. Run it again for the status.
10
+
11
+ npx replica-bridge pair pair another phone
12
+ npx replica-bridge uninstall stop and remove the background service
13
+
14
+ `npx replica-bridge --help` lists the other commands.
package/package.json CHANGED
@@ -1,13 +1,13 @@
1
1
  {
2
2
  "name": "replica-bridge",
3
- "version": "0.1.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": {
7
7
  "replica-bridge": "src/bridge/main.js"
8
8
  },
9
9
  "engines": {
10
- "node": ">=20"
10
+ "node": ">=20.11"
11
11
  },
12
12
  "dependencies": {
13
13
  "qrcode": "1.5.4",
@@ -0,0 +1,28 @@
1
+ // Running Codex, npm and the service managers the same way on macOS, Linux and Windows.
2
+ import { spawn, spawnSync } from "node:child_process";
3
+
4
+
5
+ /** One argument as cmd.exe reads it. */
6
+ export function quoteForCmd(arg ) {
7
+ return /^[\w\-.:\\/=@]+$/.test(arg) ? arg : `"${arg.replace(/"/g, '""')}"`;
8
+ }
9
+
10
+ /**
11
+ * On Windows npm installs commands such as codex as `codex.cmd`, which Node runs only through cmd.exe
12
+ * (spawn without a shell fails with EINVAL or ENOENT), so the command line goes through the shell there.
13
+ */
14
+ export function spawnCommand(command , args , options = {}) {
15
+ if (process.platform !== "win32") return spawn(command, args, options);
16
+ return spawn([command, ...args].map(quoteForCmd).join(" "), [], { ...options, shell: true, windowsHide: true });
17
+ }
18
+
19
+ export function spawnCommandSync(command , args , options = {}) {
20
+ if (process.platform !== "win32") return spawnSync(command, args, options);
21
+ return spawnSync([command, ...args].map(quoteForCmd).join(" "), [], { ...options, shell: true, windowsHide: true });
22
+ }
23
+
24
+ /** Stops a command from spawnCommand; on Windows that is cmd.exe, so the whole tree goes (taskkill /T). */
25
+ export function killCommand(child ) {
26
+ if (process.platform === "win32" && child.pid && child.exitCode === null) spawnSync("taskkill", ["/pid", String(child.pid), "/T", "/F"], { stdio: "ignore", windowsHide: true });
27
+ else child.kill();
28
+ }
@@ -0,0 +1,253 @@
1
+ // Keeping the bridge running after its terminal closes, the way Codex keeps its app-server: a per-user background
2
+ // service that starts at login and restarts when it fails. macOS: a launchd agent. Linux: a systemd user service.
3
+ // Windows: a Task Scheduler task at logon that runs a hidden script, which restarts the bridge when it exits.
4
+ // The service runs a copy installed under ~/.config/replica-bridge/app, not the npx cache, which npm may clear.
5
+ import fs from "node:fs";
6
+ import os from "node:os";
7
+ import path from "node:path";
8
+ import { spawnCommandSync } from "./command.js";
9
+ import { bridgeHome } from "./state.js";
10
+
11
+ export const PACKAGE_VERSION = JSON.parse(fs.readFileSync(path.resolve(import.meta.dirname, "../../package.json"), "utf8")).version;
12
+
13
+
14
+
15
+
16
+
17
+
18
+
19
+
20
+
21
+
22
+
23
+
24
+
25
+
26
+
27
+
28
+
29
+
30
+
31
+
32
+
33
+
34
+
35
+
36
+ export const serviceLabel = () => process.env.REPLICA_BRIDGE_LABEL ?? "replica-bridge";
37
+ export const logPath = (home = bridgeHome()) => path.join(home, "bridge.log");
38
+ const infoPath = () => path.join(bridgeHome(), "service.json");
39
+ const pidPath = () => path.join(bridgeHome(), "bridge.pid");
40
+
41
+ const xml = (text ) => text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
42
+ /** systemd unit-file quoting: specifiers (%) doubled, backslashes and quotes escaped. */
43
+ const unitQuote = (text ) => `"${text.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/%/g, "%%")}"`;
44
+ /** A VBScript string literal. */
45
+ const vbs = (text ) => `"${text.replace(/"/g, '""')}"`;
46
+
47
+ /** The service files and the commands that load and unload them; pure, so each platform can be checked anywhere. */
48
+ export function servicePlan(spec ) {
49
+ const log = logPath(spec.bridgeHome);
50
+ switch (spec.platform) {
51
+ case "darwin": {
52
+ const label = `com.chatreplica.${spec.label}`;
53
+ const plist = path.join(spec.home, "Library", "LaunchAgents", `${label}.plist`);
54
+ const target = `gui/${spec.uid}`;
55
+ const env = Object.entries(spec.env).map(([k, v]) => ` <key>${xml(k)}</key><string>${xml(v)}</string>`).join("\n");
56
+ const content = `<?xml version="1.0" encoding="UTF-8"?>
57
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
58
+ <plist version="1.0">
59
+ <dict>
60
+ <key>Label</key><string>${xml(label)}</string>
61
+ <key>ProgramArguments</key>
62
+ <array>
63
+ ${spec.program.map((a) => ` <string>${xml(a)}</string>`).join("\n")}
64
+ </array>
65
+ <key>EnvironmentVariables</key>
66
+ <dict>
67
+ ${env}
68
+ </dict>
69
+ <key>RunAtLoad</key><true/>
70
+ <key>KeepAlive</key><dict><key>SuccessfulExit</key><false/></dict>
71
+ <key>ThrottleInterval</key><integer>10</integer>
72
+ <key>ProcessType</key><string>Background</string>
73
+ <key>StandardOutPath</key><string>${xml(log)}</string>
74
+ <key>StandardErrorPath</key><string>${xml(log)}</string>
75
+ </dict>
76
+ </plist>
77
+ `;
78
+ return {
79
+ files: [{ path: plist, content }],
80
+ install: [
81
+ { command: "launchctl", args: ["bootout", `${target}/${label}`], optional: true },
82
+ { command: "launchctl", args: ["bootstrap", target, plist] },
83
+ ],
84
+ uninstall: [{ command: "launchctl", args: ["bootout", `${target}/${label}`], optional: true }],
85
+ where: `launchd agent ${label} (${plist})`,
86
+ };
87
+ }
88
+ case "linux": {
89
+ const unit = `${spec.label}.service`;
90
+ const file = path.join(spec.home, ".config", "systemd", "user", unit);
91
+ const content = `[Unit]
92
+ Description=replica-bridge: ChatReplica phones use Codex on this computer
93
+ After=network-online.target
94
+ Wants=network-online.target
95
+
96
+ [Service]
97
+ ExecStart=${spec.program.map(unitQuote).join(" ")}
98
+ Restart=on-failure
99
+ RestartSec=10
100
+ ${Object.entries(spec.env).map(([k, v]) => `Environment=${unitQuote(`${k}=${v}`)}`).join("\n")}
101
+ StandardOutput=append:${log}
102
+ StandardError=append:${log}
103
+
104
+ [Install]
105
+ WantedBy=default.target
106
+ `;
107
+ return {
108
+ files: [{ path: file, content }],
109
+ install: [
110
+ { command: "systemctl", args: ["--user", "daemon-reload"] },
111
+ { command: "systemctl", args: ["--user", "enable", unit] },
112
+ { command: "systemctl", args: ["--user", "restart", unit] },
113
+ ],
114
+ uninstall: [
115
+ { command: "systemctl", args: ["--user", "disable", "--now", unit], optional: true },
116
+ ],
117
+ where: `systemd user service ${unit} (${file})`,
118
+ };
119
+ }
120
+ case "win32": {
121
+ const script = path.join(spec.bridgeHome, "run-bridge.vbs");
122
+ // wscript shows no window; the loop brings the bridge back ten seconds after it fails. Exit code 0 means another
123
+ // bridge took this computer over (or it was told to stop), so the loop ends.
124
+ const content = [
125
+ `' replica-bridge background service (Task Scheduler task "${spec.label}"). Made by "replica-bridge install".`,
126
+ `Set shell = CreateObject("WScript.Shell")`,
127
+ ...Object.entries({ ...spec.env, REPLICA_BRIDGE_LOG: log }).map(([k, v]) => `shell.Environment("Process")(${vbs(k)}) = ${vbs(v)}`),
128
+ `Do`,
129
+ ` code = shell.Run(${vbs(spec.program.map((a) => `"${a}"`).join(" "))}, 0, True)`,
130
+ ` If code = 0 Then Exit Do`,
131
+ ` WScript.Sleep 10000`,
132
+ `Loop`,
133
+ "",
134
+ ].join("\r\n");
135
+ return {
136
+ files: [{ path: script, content }],
137
+ install: [
138
+ { command: "schtasks", args: ["/End", "/TN", spec.label], optional: true },
139
+ { command: "schtasks", args: ["/Create", "/F", "/TN", spec.label, "/SC", "ONLOGON", "/RL", "LIMITED", "/TR", `wscript.exe "${script}"`] },
140
+ { command: "schtasks", args: ["/Run", "/TN", spec.label] },
141
+ ],
142
+ uninstall: [
143
+ { command: "schtasks", args: ["/End", "/TN", spec.label], optional: true },
144
+ { command: "schtasks", args: ["/Delete", "/F", "/TN", spec.label], optional: true },
145
+ ],
146
+ where: `Task Scheduler task "${spec.label}" (${script})`,
147
+ };
148
+ }
149
+ default:
150
+ throw new Error(`no background service for ${spec.platform}: keep "replica-bridge start" running yourself`);
151
+ }
152
+ }
153
+
154
+ /** What the service passes on: PATH (so it finds codex and node) and the bridge's and Codex's own settings. */
155
+ function serviceEnv() {
156
+ const env = {};
157
+ for (const [key, value] of Object.entries(process.env)) {
158
+ if (value !== undefined && (key === "PATH" || key === "Path" || /^(CODEX_|REPLICA_|RELAY_ENV$)/.test(key)) && key !== "REPLICA_BRIDGE_LOG") env[key] = value;
159
+ }
160
+ env.REPLICA_BRIDGE_SERVICE = "1";
161
+ return env;
162
+ }
163
+
164
+ /**
165
+ * The script the service runs. From npx (inside node_modules) a copy of this version is installed under
166
+ * ~/.config/replica-bridge/app, since npm may clear its npx cache; from a working copy, the working copy itself.
167
+ */
168
+ function installedScript(say ) {
169
+ const current = process.argv[1] ? fs.realpathSync(process.argv[1]) : "";
170
+ if (!current.split(path.sep).includes("node_modules")) return current;
171
+ const app = path.join(bridgeHome(), "app");
172
+ const spec = process.env.REPLICA_BRIDGE_PACKAGE ?? `replica-bridge@${PACKAGE_VERSION}`;
173
+ say(`Installing ${spec} in ${app}…`);
174
+ fs.mkdirSync(app, { recursive: true });
175
+ // npx's own npm_* settings (a temporary prefix, for one) must not leak into this install.
176
+ const env = Object.fromEntries(Object.entries(process.env).filter(([key]) => !/^npm_/i.test(key)));
177
+ const result = spawnCommandSync("npm", ["install", "--prefix", app, "--no-audit", "--no-fund", "--omit=dev", "--loglevel=error", spec], { stdio: "inherit", env });
178
+ if (result.status !== 0) throw new Error(`npm install ${spec} failed`);
179
+ return path.join(app, "node_modules", "replica-bridge", "src", "bridge", "main.js");
180
+ }
181
+
182
+ function run(steps ) {
183
+ for (const step of steps) {
184
+ const result = spawnCommandSync(step.command, step.args, { encoding: "utf8" });
185
+ if (result.status !== 0 && !step.optional) {
186
+ throw new Error(`${step.command} ${step.args.join(" ")} failed: ${(result.stderr || result.stdout || result.error?.message || "").toString().trim()}`);
187
+ }
188
+ }
189
+ }
190
+
191
+ export function readServiceInfo() {
192
+ try { return JSON.parse(fs.readFileSync(infoPath(), "utf8")) ; } catch { return null; }
193
+ }
194
+
195
+ function spec(program ) {
196
+ return { platform: process.platform, label: serviceLabel(), home: os.homedir(), bridgeHome: bridgeHome(), program, env: serviceEnv(), uid: os.userInfo().uid };
197
+ }
198
+
199
+ /** Installs (or replaces) the background service and starts it. `startArgs` are the options `start` keeps. */
200
+ export function installService(startArgs , say ) {
201
+ servicePlan(spec([])); // fails early on a platform without a service manager
202
+ stopRunning();
203
+ const program = [process.execPath, installedScript(say), "start", ...startArgs];
204
+ const plan = servicePlan(spec(program));
205
+ fs.mkdirSync(bridgeHome(), { recursive: true, mode: 0o700 });
206
+ for (const file of plan.files) {
207
+ fs.mkdirSync(path.dirname(file.path), { recursive: true });
208
+ fs.writeFileSync(file.path, file.content);
209
+ }
210
+ run(plan.install);
211
+ const info = { platform: process.platform, label: serviceLabel(), version: PACKAGE_VERSION, program, installedAt: new Date().toISOString() };
212
+ fs.writeFileSync(infoPath(), JSON.stringify(info, null, 2) + "\n");
213
+ return plan;
214
+ }
215
+
216
+ export function uninstallService() {
217
+ const info = readServiceInfo();
218
+ if (!info) return null;
219
+ const plan = servicePlan({ ...spec(info.program), label: info.label });
220
+ run(plan.uninstall);
221
+ stopRunning();
222
+ for (const file of plan.files) fs.rmSync(file.path, { force: true });
223
+ if (process.platform === "linux") run([{ command: "systemctl", args: ["--user", "daemon-reload"], optional: true }]);
224
+ fs.rmSync(infoPath(), { force: true });
225
+ return plan;
226
+ }
227
+
228
+ // MARK: The running service's pid, so `status` can tell whether it runs and a second bridge does not start beside it.
229
+
230
+ export function writePid() {
231
+ fs.mkdirSync(bridgeHome(), { recursive: true, mode: 0o700 });
232
+ fs.writeFileSync(pidPath(), String(process.pid));
233
+ }
234
+
235
+ export function clearPid() {
236
+ try { if (fs.readFileSync(pidPath(), "utf8").trim() === String(process.pid)) fs.rmSync(pidPath()); } catch { /* gone */ }
237
+ }
238
+
239
+ /** The background bridge's pid while it runs. */
240
+ export function runningPid() {
241
+ let pid ;
242
+ try { pid = Number(fs.readFileSync(pidPath(), "utf8").trim()); } catch { return null; }
243
+ if (!pid || pid === process.pid) return null;
244
+ try { process.kill(pid, 0); return pid; } catch (error) { return (error ).code === "EPERM" ? pid : null; }
245
+ }
246
+
247
+ /** The service manager stops its own process; on Windows the loop's bridge is left, so it goes by pid. */
248
+ function stopRunning() {
249
+ const pid = runningPid();
250
+ if (!pid) return;
251
+ try { process.kill(pid); } catch { /* already gone */ }
252
+ fs.rmSync(pidPath(), { force: true });
253
+ }
@@ -1,19 +1,23 @@
1
1
  #!/usr/bin/env node
2
2
  // replica-bridge: runs on the computer that has Codex. See docs/RELAY.md.
3
- // The first pair or start sets the computer up by itself (anonymous enrollment); init does it explicitly.
4
- // (no command) [--relay <url>] [--name <name>] [--pair] [--codex <path>]
5
- // what `npx replica-bridge` runs: set up if needed, keep the bridge running, and show a QR code while no
6
- // phone is paired yet (or with --pair)
3
+ // (no command) what `npx replica-bridge` runs. The first time: set this computer up with the relay, show a QR
4
+ // code, and once a phone is paired offer to keep the bridge running in the background (install).
5
+ // Later: show the status, updating the background service when this is a newer version.
6
+ // [--relay <url>] [--name <name>] [--pair] [--install | --no-install] [--codex <path>]
7
+ // pair [--relay <url>] [--confirm] [--yes] [--png <file>] [--timeout <seconds>]
8
+ // install | uninstall | status
9
+ // start [--codex <path>] [--stream-ttl <seconds>] [--no-login-shell] the bridge in this terminal
7
10
  // init [--relay <url>] [--public-url <url>] [--name <name>]
8
11
  // config [--public-url <url>]
9
- // pair [--relay <url>] [--confirm] [--yes] [--png <file>] [--timeout <seconds>]
10
- // start [--codex <path>] [--stream-ttl <seconds>] [--no-login-shell]
11
12
  // devices
12
13
  // revoke <deviceId>
13
- import { spawnSync } from "node:child_process";
14
- import { parseArgs } from "node:util";
14
+ import fs from "node:fs";
15
+ import readline from "node:readline/promises";
16
+ import { parseArgs, format } from "node:util";
15
17
  import { relayRequest } from "../http.js";
18
+ import { spawnCommandSync } from "./command.js";
16
19
  import { defaultRelayURL, enroll, ensureEnrolled } from "./enroll.js";
20
+ import { PACKAGE_VERSION, clearPid, installService, logPath, readServiceInfo, runningPid, servicePlan, uninstallService, writePid } from "./install.js";
17
21
  import { pair, publicRelayURL } from "./pair.js";
18
22
  import { BridgeService } from "./service.js";
19
23
  import { loadState, saveState } from "./state.js";
@@ -33,62 +37,152 @@ const { values, positionals } = parseArgs({
33
37
  png: { type: "string" },
34
38
  timeout: { type: "string", default: "600" },
35
39
  pair: { type: "boolean", default: false },
40
+ install: { type: "boolean", default: false },
41
+ "no-install": { type: "boolean", default: false },
36
42
  help: { type: "boolean", short: "h", default: false },
43
+ version: { type: "boolean", short: "v", default: false },
37
44
  codex: { type: "string" },
38
45
  "stream-ttl": { type: "string", default: "1800" },
39
46
  "no-login-shell": { type: "boolean", default: false },
40
47
  },
41
48
  });
42
49
 
43
- const usage = `usage: replica-bridge [command] (docs/RELAY.md)
44
- (none) set up, keep running, and show a QR code until a phone is paired (--pair shows one anyway)
45
- pair show a QR code for another phone
46
- start keep running without showing a QR code
47
- init set up only (--relay <url>, --name <name>)
48
- config show the relay, this computer and its owner
49
- devices list paired phones
50
- revoke <id> remove a phone
50
+ const usage = `replica-bridge ${PACKAGE_VERSION}: lets the ChatReplica app use Codex on this computer (docs/RELAY.md)
51
+ npx replica-bridge the only command most people need: sets up, pairs a phone, keeps running
52
+ in the background; run again for the status
53
+ npx replica-bridge pair pair another phone
54
+ npx replica-bridge uninstall stop and remove the background service
55
+ Others: status, install, start (in this terminal), init, config, devices, revoke <id>.
51
56
  The relay is ${defaultRelayURL()} unless --relay or REPLICA_RELAY says otherwise.`;
52
57
 
53
- function startService() {
54
- const service = new BridgeService({ codexPath: values.codex, loginShell: !values["no-login-shell"], streamTTLSeconds: Number(values["stream-ttl"]) });
58
+ // The Windows service has no terminal to write to: its output goes to the log file it names.
59
+ if (process.env.REPLICA_BRIDGE_LOG) {
60
+ const log = fs.createWriteStream(process.env.REPLICA_BRIDGE_LOG, { flags: "a" });
61
+ console.log = console.warn = console.error = (...args ) => { log.write(format(...args) + "\n"); };
62
+ }
63
+
64
+ /** The options `start` keeps when the background service runs it. */
65
+ function startArgs() {
66
+ const args = [];
67
+ if (values.codex) args.push("--codex", values.codex);
68
+ if (values["no-login-shell"]) args.push("--no-login-shell");
69
+ if (values["stream-ttl"] !== "1800") args.push("--stream-ttl", values["stream-ttl"] );
70
+ return args;
71
+ }
72
+
73
+ function startBridge() {
74
+ // Exit 0: the background services restart the bridge only when it fails, so the newer bridge keeps the computer.
75
+ const service = new BridgeService({ codexPath: values.codex, loginShell: !values["no-login-shell"], streamTTLSeconds: Number(values["stream-ttl"]), onReplaced: () => process.exit(0) });
55
76
  service.start();
56
- for (const signal of ["SIGINT", "SIGTERM"] ) process.on(signal, () => { service.stop(); process.exit(0); });
77
+ return service;
57
78
  }
58
79
 
59
80
  /** Warns, without stopping, when Codex does not answer the way the bridge will run it (through the login shell). */
60
81
  function checkCodex() {
61
82
  const codex = values.codex ?? "codex";
62
- const loginShell = !values["no-login-shell"] && process.env.SHELL;
83
+ const loginShell = process.platform !== "win32" && !values["no-login-shell"] && process.env.SHELL;
63
84
  const result = loginShell
64
- ? spawnSync(process.env.SHELL , ["-lic", `"$0" --version`, codex], { encoding: "utf8", timeout: 15_000 })
65
- : spawnSync(codex, ["--version"], { encoding: "utf8", timeout: 15_000 });
85
+ ? spawnCommandSync(process.env.SHELL , ["-lic", `"$0" --version`, codex], { encoding: "utf8", timeout: 15_000 })
86
+ : spawnCommandSync(codex, ["--version"], { encoding: "utf8", timeout: 15_000 });
66
87
  if (result.status === 0) return;
67
88
  console.warn(`Codex did not answer "${codex} --version". Install it (npm install -g @openai/codex) and sign in (codex login); the phone cannot use this computer until then.`);
68
89
  }
69
90
 
91
+ function serviceSupported() {
92
+ try { servicePlan({ platform: process.platform, label: "x", home: "/", bridgeHome: "/", program: [], env: {}, uid: 0 }); return true; } catch { return false; }
93
+ }
94
+
95
+ async function ask(question ) {
96
+ const prompt = readline.createInterface({ input: process.stdin, output: process.stdout });
97
+ try { return !/^n(o)?$/i.test((await prompt.question(question)).trim()); } finally { prompt.close(); }
98
+ }
99
+
100
+ /** --install, --no-install, or ask in a terminal; never without one. */
101
+ async function wantBackground() {
102
+ if (values["no-install"] || !serviceSupported()) return false;
103
+ if (values.install) return true;
104
+ if (!process.stdin.isTTY) return false;
105
+ return ask("Keep it running in the background and start it at login, so you can close this terminal? [Y/n] ");
106
+ }
107
+
108
+ function install() {
109
+ const plan = installService(startArgs(), console.log);
110
+ console.log(`Running in the background: ${plan.where}. It starts at login and comes back if it stops, so this terminal can be closed.`);
111
+ console.log(`Log: ${logPath()}. Remove it with "npx replica-bridge uninstall".`);
112
+ if (process.platform === "linux") console.log(`To keep it running while you are logged out: loginctl enable-linger ${process.env.USER ?? "$USER"}`);
113
+ }
114
+
115
+ async function pairOrExplain() {
116
+ try {
117
+ await pair({ confirm: values.confirm, yes: values.yes, timeoutSeconds: Number(values.timeout), pngPath: values.png });
118
+ } catch (error) {
119
+ console.error(`replica-bridge: ${(error ).message}. Pair later with "npx replica-bridge pair".`);
120
+ }
121
+ }
122
+
123
+ function status() {
124
+ let state;
125
+ try { state = loadState(); } catch { console.log(`Not set up yet: run "npx replica-bridge".`); return; }
126
+ const info = readServiceInfo();
127
+ const pid = runningPid();
128
+ console.log(`${state.hostName} (${state.hostId}), relay ${state.relay}`);
129
+ console.log(`Owner: ${state.owner ? `${state.owner.login} (${state.owner.provider})` : "none yet"}; paired phones: ${state.devices.map((d) => d.name).join(", ") || "none"}`);
130
+ if (info) console.log(`Background service ${info.version}: ${pid ? `running (pid ${pid})` : "installed, not running right now"}. Log: ${logPath()}`);
131
+ else console.log(pid ? `Running (pid ${pid}).` : `Not running: "npx replica-bridge" sets up the background service.`);
132
+ }
133
+
70
134
  async function main() {
135
+ if (values.version) { console.log(PACKAGE_VERSION); return; }
71
136
  if (values.help || command === "help") { console.log(usage); return; }
72
137
  switch (command) {
73
138
  case undefined:
74
139
  case "up": {
140
+ const installed = readServiceInfo();
141
+ if (installed && !values.pair && !values.install) {
142
+ if (installed.version !== PACKAGE_VERSION) {
143
+ console.log(`Updating the background service from ${installed.version} to ${PACKAGE_VERSION}.`);
144
+ install();
145
+ }
146
+ status();
147
+ break;
148
+ }
75
149
  checkCodex();
76
150
  const state = await ensureEnrolled(values.relay, console.log, values.name);
77
- startService();
78
- if (values.pair || !state.devices.length) {
151
+ if (installed && !values.install) { await pairOrExplain(); break; } // the background service serves the phone
152
+ if (runningPid()) throw new Error(`a bridge already runs for this computer (pid ${runningPid()}); stop it first`);
153
+ let bridge = startBridge();
154
+ if (values.pair || !state.devices.length) await pairOrExplain();
155
+ if (loadState().devices.length && await wantBackground()) {
156
+ // One bridge per computer: the relay keeps only the newest connection for it.
157
+ bridge.stop();
79
158
  try {
80
- await pair({ confirm: values.confirm, yes: values.yes, timeoutSeconds: Number(values.timeout), pngPath: values.png });
159
+ install();
160
+ process.exit(0);
81
161
  } catch (error) {
82
- console.error(`replica-bridge: ${(error ).message}. Pair later with "replica-bridge pair".`);
162
+ console.error(`replica-bridge: could not set up the background service: ${(error ).message}`);
163
+ bridge = startBridge();
83
164
  }
84
165
  }
85
- console.log(`Running: paired phones can use Codex on ${state.hostName} while this keeps running (Ctrl-C stops it).`);
166
+ for (const signal of ["SIGINT", "SIGTERM"] ) process.on(signal, () => { bridge.stop(); process.exit(0); });
167
+ console.log(`Running in this terminal: paired phones can use Codex on ${state.hostName} until it closes (Ctrl-C stops it).`);
86
168
  break;
87
169
  }
170
+ case "install":
171
+ await ensureEnrolled(values.relay, console.log, values.name);
172
+ install();
173
+ break;
174
+ case "uninstall": {
175
+ const plan = uninstallService();
176
+ console.log(plan ? `Removed the background service (${plan.where}). This computer stays paired; "npx replica-bridge" sets it up again.` : "No background service is installed.");
177
+ break;
178
+ }
179
+ case "status":
180
+ status();
181
+ break;
88
182
  case "init": {
89
183
  const relay = values.relay ?? defaultRelayURL();
90
184
  const state = await enroll(relay, { name: values.name, publicRelay: values["public-url"] });
91
- console.log(`Set up ${state.hostName} as ${state.hostId}. Next: "replica-bridge start" (keep it running), then "replica-bridge pair".`);
185
+ console.log(`Set up ${state.hostName} as ${state.hostId}. Next: "npx replica-bridge".`);
92
186
  console.log("Nobody owns it yet: the first phone that scans its QR code and is approved here claims it.");
93
187
  break;
94
188
  }
@@ -102,11 +196,17 @@ async function main() {
102
196
  case "pair":
103
197
  await ensureEnrolled(values.relay, console.log, values.name);
104
198
  await pair({ confirm: values.confirm, yes: values.yes, timeoutSeconds: Number(values.timeout), pngPath: values.png });
199
+ if (!runningPid()) console.log(`Nothing serves this computer right now: run "npx replica-bridge" so the phone can connect.`);
105
200
  break;
106
- case "start":
201
+ case "start": {
107
202
  await ensureEnrolled(values.relay, console.log, values.name);
108
- startService();
203
+ const asService = process.env.REPLICA_BRIDGE_SERVICE === "1";
204
+ if (runningPid()) throw new Error(`a bridge already runs for this computer (pid ${runningPid()})${asService ? "" : '; "npx replica-bridge uninstall" stops the background one'}`);
205
+ const bridge = startBridge();
206
+ if (asService) { writePid(); process.on("exit", clearPid); }
207
+ for (const signal of ["SIGINT", "SIGTERM"] ) process.on(signal, () => { bridge.stop(); process.exit(0); });
109
208
  break;
209
+ }
110
210
  case "devices": {
111
211
  const state = loadState();
112
212
  const { owner, devices } = await relayRequest (state.relay, "GET", "/v1/host/devices", { token: state.hostToken });
@@ -3,7 +3,6 @@
3
3
  // The bridge does not run or configure Codex: when no app-server answers it asks Codex to start its own managed
4
4
  // one (`codex app-server daemon start`). A stream outlives the phone's connection for `streamTTL`, buffering what
5
5
  // the app-server says, so a phone that drops off the network resumes where it left off.
6
- import { spawn } from "node:child_process";
7
6
 
8
7
  import crypto from "node:crypto";
9
8
  import os from "node:os";
@@ -15,7 +14,10 @@ import {
15
14
  } from "../protocol.js";
16
15
 
17
16
  import { relaySocketURL } from "../http.js";
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
 
@@ -28,6 +30,13 @@ import { hostKeyPair, loadState } from "./state.js";
28
30
 
29
31
 
30
32
 
33
+
34
+
35
+
36
+
37
+
38
+
39
+
31
40
 
32
41
 
33
42
 
@@ -39,6 +48,8 @@ import { hostKeyPair, loadState } from "./state.js";
39
48
 
40
49
 
41
50
 
51
+
52
+
42
53
 
43
54
 
44
55
 
@@ -92,10 +103,16 @@ export class BridgeService {
92
103
  ws.on("unexpected-response", (_req, res) => this.log(`relay refused the host connection: HTTP ${res.statusCode}`));
93
104
  ws.on("error", (error) => this.log(`relay connection error: ${error.message}`));
94
105
  ws.on("close", (code) => {
106
+ if (ws !== this.relay) return; // a socket this bridge already replaced itself
95
107
  for (const conn of this.conns.values()) this.detach(conn);
96
108
  this.conns.clear();
97
109
  if (this.stopped) return;
98
- if (code === CloseCode.replaced) this.log("another bridge with the same identity connected; reconnecting anyway");
110
+ if (code === CloseCode.replaced) {
111
+ this.log("another bridge for this computer connected to the relay, which keeps only the newest; stopping this one");
112
+ this.stop();
113
+ this.options.onReplaced?.();
114
+ return;
115
+ }
99
116
  const delay = Math.min(30_000, 1000 * 2 ** this.retry++);
100
117
  setTimeout(() => this.connectRelay(), delay);
101
118
  });
@@ -176,6 +193,10 @@ export class BridgeService {
176
193
  this.scheduleAck(stream);
177
194
  } else if (message.t === "ack") {
178
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");
179
200
  }
180
201
  }
181
202
 
@@ -202,12 +223,17 @@ export class BridgeService {
202
223
 
203
224
  async openStream(deviceId ) {
204
225
  const appServer = await this.connectAppServer();
205
- 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() };
206
227
  this.streams.set(stream.id, stream);
207
228
  appServer.on("message", (data ) => {
208
- let item;
209
- try { item = stream.out.push(data.toString()); } catch { return this.endStream(stream, "the phone stayed away too long and the buffer filled up"); }
210
- 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
+ });
211
237
  });
212
238
  appServer.on("close", () => this.endStream(stream, "codex app-server closed the connection"));
213
239
  appServer.on("error", (error) => this.log(`app-server stream ${stream.id}: ${error.message}`));
@@ -251,19 +277,19 @@ export class BridgeService {
251
277
  */
252
278
  openProxy() {
253
279
  return new Promise((resolve, reject) => {
254
- const child = spawn(this.options.codexPath ?? "codex", ["app-server", "proxy"], { stdio: ["pipe", "pipe", "pipe"] });
280
+ const child = spawnCommand(this.options.codexPath ?? "codex", ["app-server", "proxy"], { stdio: ["pipe", "pipe", "pipe"] });
255
281
  let stderr = "";
256
282
  child.stderr .on("data", (chunk) => { stderr = (stderr + chunk).slice(-2000); });
257
283
  const pipe = Duplex.from({ readable: child.stdout , writable: child.stdin }) ;
258
284
  // The HTTP client that carries the WebSocket handshake tunes its socket; a pipe has nothing to tune.
259
285
  for (const name of ["setNoDelay", "setKeepAlive", "setTimeout", "ref", "unref"]) pipe[name] = () => pipe;
260
286
  const ws = new WebSocket("ws://localhost/", { createConnection: () => pipe , perMessageDeflate: false, maxPayload: MAX_FRAME_BYTES });
261
- const fail = (why ) => { ws.removeAllListeners("open"); child.kill(); reject(new Error(why)); };
287
+ const fail = (why ) => { ws.removeAllListeners("open"); killCommand(child); reject(new Error(why)); };
262
288
  child.once("error", (error) => fail(`could not run codex: ${error.message}`));
263
289
  child.once("exit", (code) => { if (ws.readyState !== WebSocket.OPEN) fail(`codex app-server proxy exited (${code})${stderr ? `: ${stderr.trim()}` : ""}`); });
264
290
  ws.once("error", (error) => { if (ws.readyState !== WebSocket.OPEN) fail(error.message); });
265
291
  ws.once("open", () => {
266
- ws.on("close", () => child.kill());
292
+ ws.on("close", () => killCommand(child));
267
293
  resolve(ws);
268
294
  });
269
295
  });
@@ -272,8 +298,8 @@ export class BridgeService {
272
298
  /** Runs a command to completion; true when it exits 0 within 30 s. Output is dropped (an rc file may print). */
273
299
  run(command , args ) {
274
300
  return new Promise((resolve, reject) => {
275
- const child = spawn(command, args, { stdio: "ignore" });
276
- const timer = setTimeout(() => { child.kill(); resolve(false); }, 30_000);
301
+ const child = spawnCommand(command, args, { stdio: "ignore" });
302
+ const timer = setTimeout(() => { killCommand(child); resolve(false); }, 30_000);
277
303
  child.once("error", (error) => { clearTimeout(timer); reject(error); });
278
304
  child.once("exit", (code) => { clearTimeout(timer); resolve(code === 0); });
279
305
  });
@@ -289,8 +315,9 @@ export class BridgeService {
289
315
  // people configure Codex (config.toml, auth.json, cc-switch, keys exported in their shell rc), the daemon should
290
316
  // see what their terminal sees, so ask through their login shell; fall back to a direct start.
291
317
  const codex = this.options.codexPath ?? "codex";
292
- // launchd services may have no SHELL; the account's login shell is the same answer.
293
- const shell = process.env.SHELL || os.userInfo().shell;
318
+ // launchd services may have no SHELL; the account's login shell is the same answer. Windows has no login shell
319
+ // (a SHELL from Git Bash is a POSIX path Node cannot run), so Codex is asked directly there.
320
+ const shell = process.platform === "win32" ? null : process.env.SHELL || os.userInfo().shell;
294
321
  const started = this.options.loginShell !== false && shell
295
322
  ? await this.run(shell, ["-lic", 'exec "$0" app-server daemon start', codex]).catch(() => false)
296
323
  : false;
@@ -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
+ }