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 +7 -1
- package/package.json +2 -2
- package/src/bridge/command.js +28 -0
- package/src/bridge/install.js +253 -0
- package/src/bridge/main.js +130 -30
- package/src/bridge/service.js +40 -13
- package/src/bridge/writer.js +67 -0
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
|
-
|
|
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
|
|
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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
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
|
+
}
|
package/src/bridge/main.js
CHANGED
|
@@ -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
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
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
|
|
14
|
-
import
|
|
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 = `
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
|
|
54
|
-
|
|
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
|
-
|
|
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
|
-
?
|
|
65
|
-
:
|
|
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
|
-
|
|
78
|
-
if (
|
|
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
|
-
|
|
159
|
+
install();
|
|
160
|
+
process.exit(0);
|
|
81
161
|
} catch (error) {
|
|
82
|
-
console.error(`replica-bridge: ${(error ).message}
|
|
162
|
+
console.error(`replica-bridge: could not set up the background service: ${(error ).message}`);
|
|
163
|
+
bridge = startBridge();
|
|
83
164
|
}
|
|
84
165
|
}
|
|
85
|
-
|
|
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: "
|
|
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
|
-
|
|
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 });
|
package/src/bridge/service.js
CHANGED
|
@@ -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)
|
|
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
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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 =
|
|
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
|
|
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
|
|
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 =
|
|
276
|
-
const timer = setTimeout(() => { child
|
|
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
|
-
|
|
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
|
+
}
|