replica-bridge 0.3.15 → 0.3.18
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 +1 -1
- package/src/bridge/board.js +71 -10
- package/src/bridge/main.js +66 -8
- package/src/bridge/service.js +59 -5
- package/src/protocol.js +3 -1
package/package.json
CHANGED
package/src/bridge/board.js
CHANGED
|
@@ -23,7 +23,14 @@ const CATCH_UP_PAGES = 1000;
|
|
|
23
23
|
/** More changed tasks than this in one event, and the phone reads the whole board instead. */
|
|
24
24
|
export const CHANGED_ITEMS_MAX = 200;
|
|
25
25
|
/** What this bridge can do beyond reading, for board/state (PROTOCOL.md "看板"). */
|
|
26
|
-
export const BOARD_CAPS = ["post", "delta"] ;
|
|
26
|
+
export const BOARD_CAPS = ["post", "delta", "items", "tasks"] ;
|
|
27
|
+
/** Up to this many changed tasks in one event come with their contents (`task`); past it the phone reads them itself. */
|
|
28
|
+
export const CARRIED_TASKS_MAX = 50;
|
|
29
|
+
/** A task bigger than this is left for the phone to read. */
|
|
30
|
+
const CARRIED_TASK_MAX_BYTES = 256 * 1024;
|
|
31
|
+
|
|
32
|
+
/** What the backend says a changed task now is: its view, or gone (404). Missing when the read failed. */
|
|
33
|
+
|
|
27
34
|
|
|
28
35
|
/** The backend's own cap on a request body (agens-backend src/http.mjs BODY_MAX). */
|
|
29
36
|
export const POST_BODY_MAX_BYTES = 16 * 1024;
|
|
@@ -31,10 +38,11 @@ const REC = "[A-Za-z0-9_.:-]+";
|
|
|
31
38
|
|
|
32
39
|
/**
|
|
33
40
|
* GETs (#367). /session also writes TASK.md in the task's working folder, which is what 开工 asks for; /share and /me
|
|
34
|
-
* only read.
|
|
41
|
+
* only read. From 0.3.16 also one item, a task's source (`?offset=` pages its text), as the backend's own page opens it.
|
|
42
|
+
* Not /assist, /mcp, /search, /context, /changes (the bridge's own poll) or anything under /ui.
|
|
35
43
|
*/
|
|
36
44
|
const READS = [/^\/v1\/status$/, /^\/v1\/today$/, /^\/v1\/recommendations$/, new RegExp(`^/v1/recommendations/${REC}$`),
|
|
37
|
-
new RegExp(`^/v1/recommendations/${REC}/(share|session)$`), /^\/v1\/me
|
|
45
|
+
new RegExp(`^/v1/recommendations/${REC}/(share|session)$`), /^\/v1\/me$/, new RegExp(`^/v1/items/${REC}$`)];
|
|
38
46
|
|
|
39
47
|
/** POSTs (#367): a task's follow-up actions, cancelling its preparation note, and muting a conversation. Nothing else. */
|
|
40
48
|
const WRITES = [new RegExp(`^/v1/recommendations/${REC}/actions$`), new RegExp(`^/v1/recommendations/${REC}/prep/cancel$`), /^\/v1\/me\/muted$/];
|
|
@@ -110,10 +118,17 @@ const DEFAULT_STREAM_RETRY_SECONDS = 30;
|
|
|
110
118
|
|
|
111
119
|
|
|
112
120
|
/** Changes as a phone gets them: one per task, the latest, or a reset when there are too many. */
|
|
113
|
-
|
|
121
|
+
/** The latest change of each task, in the order they last changed. */
|
|
122
|
+
function latestOf(items ) {
|
|
114
123
|
const latest = new Map ();
|
|
115
124
|
for (const item of items) { latest.delete(item.recId); latest.set(item.recId, item); }
|
|
116
|
-
return
|
|
125
|
+
return [...latest.values()];
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function changedEvent(items , carried = new Map ()) {
|
|
129
|
+
const latest = latestOf(items);
|
|
130
|
+
if (latest.length > CHANGED_ITEMS_MAX) return { t: "board/changed", reset: true };
|
|
131
|
+
return { t: "board/changed", items: latest.map((item) => ({ ...item, ...carried.get(item.recId) })) };
|
|
117
132
|
}
|
|
118
133
|
|
|
119
134
|
function changeOf(value ) {
|
|
@@ -135,6 +150,8 @@ export class BoardReader {
|
|
|
135
150
|
round = 0;
|
|
136
151
|
/** The backend's push stream: open, unknown (try it), or not there (an older backend answers 404). */
|
|
137
152
|
stream = null;
|
|
153
|
+
/** Changes go out one batch at a time and in order, each after its tasks were read. */
|
|
154
|
+
delivery = Promise.resolve();
|
|
138
155
|
streamSupported = null;
|
|
139
156
|
/** Not before this time (ms) after the backend refused a stream for a passing reason. */
|
|
140
157
|
streamRetryAt = 0;
|
|
@@ -215,6 +232,7 @@ export class BoardReader {
|
|
|
215
232
|
this.end = null;
|
|
216
233
|
this.up = null;
|
|
217
234
|
this.polling = false;
|
|
235
|
+
this.delivery = Promise.resolve();
|
|
218
236
|
this.round++;
|
|
219
237
|
}
|
|
220
238
|
|
|
@@ -259,20 +277,63 @@ export class BoardReader {
|
|
|
259
277
|
return;
|
|
260
278
|
}
|
|
261
279
|
if (round !== this.round || !this.subs.has(sub)) return;
|
|
280
|
+
// What changed comes with the tasks as they are now; reading them takes a moment, in which more may change, so
|
|
281
|
+
// read the log again up to where the shared reader got before this subscriber starts hearing from it.
|
|
282
|
+
let carried = new Map ();
|
|
283
|
+
if (!reset && missed.length) {
|
|
284
|
+
carried = await this.carry(missed);
|
|
285
|
+
try {
|
|
286
|
+
for (let page = 0; cursor !== null && cursor < this.end && page < CATCH_UP_PAGES; page++) {
|
|
287
|
+
const { items, next } = await this.changes(cursor);
|
|
288
|
+
missed.push(...items);
|
|
289
|
+
if (next === cursor && items.length === 0) break;
|
|
290
|
+
cursor = next;
|
|
291
|
+
}
|
|
292
|
+
} catch { /* the late ones go without contents; the phone reads them */ }
|
|
293
|
+
if (round !== this.round || !this.subs.has(sub)) return;
|
|
294
|
+
}
|
|
262
295
|
sub.admitting = false;
|
|
263
296
|
sub.cursor = Math.max(cursor ?? this.end , this.end );
|
|
264
297
|
sub.listener(this.state(true, sub.cursor));
|
|
265
298
|
if (reset) sub.listener({ t: "board/changed", reset: true });
|
|
266
|
-
else if (missed.length) sub.listener(changedEvent(missed));
|
|
299
|
+
else if (missed.length) sub.listener(changedEvent(missed, carried));
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Reads each changed task (its latest change only) from the backend, so the phone needs no read of its own. Only while
|
|
304
|
+
* a phone is subscribed, which is while its 任务 page is on screen, so marking them seen is what the phone's own read
|
|
305
|
+
* did before. Too many at once, and the phone reads them (or the whole board) itself.
|
|
306
|
+
*/
|
|
307
|
+
async carry(items ) {
|
|
308
|
+
const carried = new Map ();
|
|
309
|
+
const latest = latestOf(items);
|
|
310
|
+
if (latest.length > CARRIED_TASKS_MAX) return carried;
|
|
311
|
+
await Promise.all(latest.map(async ({ recId }) => {
|
|
312
|
+
const answer = await this.answer(`/v1/recommendations/${encodeURIComponent(recId)}`);
|
|
313
|
+
if ("error" in answer) return;
|
|
314
|
+
if (answer.status === 404) { carried.set(recId, { gone: true }); return; }
|
|
315
|
+
if (answer.status !== 200 || Buffer.byteLength(answer.body, "utf8") > CARRIED_TASK_MAX_BYTES) return;
|
|
316
|
+
try { carried.set(recId, { task: JSON.parse(answer.body) }); } catch { /* the phone reads it */ }
|
|
317
|
+
}));
|
|
318
|
+
return carried;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/** Reads the changed tasks, then tells each subscriber; batches stay in order. */
|
|
322
|
+
send(items , end ) {
|
|
323
|
+
const round = this.round;
|
|
324
|
+
this.delivery = this.delivery.then(async () => {
|
|
325
|
+
const carried = await this.carry(items);
|
|
326
|
+
if (round === this.round) this.deliver(items, end, carried);
|
|
327
|
+
});
|
|
267
328
|
}
|
|
268
329
|
|
|
269
330
|
/** New changes up to `end`: each subscriber gets those past its own place. */
|
|
270
|
-
deliver(items , end ) {
|
|
331
|
+
deliver(items , end , carried = new Map ()) {
|
|
271
332
|
for (const sub of this.subs) {
|
|
272
333
|
if (sub.cursor === null) continue;
|
|
273
334
|
const fresh = items.filter((item) => item.seq > sub.cursor );
|
|
274
335
|
sub.cursor = Math.max(sub.cursor, end);
|
|
275
|
-
if (fresh.length) sub.listener(changedEvent(fresh));
|
|
336
|
+
if (fresh.length) sub.listener(changedEvent(fresh, carried));
|
|
276
337
|
}
|
|
277
338
|
}
|
|
278
339
|
|
|
@@ -312,7 +373,7 @@ export class BoardReader {
|
|
|
312
373
|
if (up) this.end = end;
|
|
313
374
|
this.setUp(up);
|
|
314
375
|
if (!up) return;
|
|
315
|
-
if (items.length) this.
|
|
376
|
+
if (items.length) this.send(items, end );
|
|
316
377
|
for (const sub of this.subs) if (sub.cursor === null) void this.admit(sub);
|
|
317
378
|
if (this.streamSupported !== false && Date.now() >= this.streamRetryAt) void this.openStream(round);
|
|
318
379
|
}
|
|
@@ -363,7 +424,7 @@ export class BoardReader {
|
|
|
363
424
|
pending = [];
|
|
364
425
|
const end = Math.max(this.end ?? 0, ...batch.map((item) => item.seq));
|
|
365
426
|
this.end = end;
|
|
366
|
-
this.
|
|
427
|
+
this.send(batch, end);
|
|
367
428
|
};
|
|
368
429
|
try {
|
|
369
430
|
const decoder = new TextDecoder();
|
package/src/bridge/main.js
CHANGED
|
@@ -83,12 +83,19 @@ function startBridge() {
|
|
|
83
83
|
return service;
|
|
84
84
|
}
|
|
85
85
|
|
|
86
|
-
/**
|
|
86
|
+
/**
|
|
87
|
+
* Warns, without stopping, when Codex does not answer the way the bridge will run it (through the login shell).
|
|
88
|
+
* The shell is interactive (-i) so it reads the rc file where people put Codex on PATH, and an interactive shell that
|
|
89
|
+
* shares this terminal takes job control of it: zsh, and some bash set-ups, left the terminal to their own process
|
|
90
|
+
* group, so the next read of the terminal (the [Y/n] question, or "Allow?") stopped this process ("[1]+ Stopped
|
|
91
|
+
* npx replica-bridge", 2026-11-08) and with it the relay connection. `detached` starts it in its own session, with no
|
|
92
|
+
* terminal to take; it still reads the rc file.
|
|
93
|
+
*/
|
|
87
94
|
function checkCodex() {
|
|
88
95
|
const codex = values.codex ?? "codex";
|
|
89
96
|
const loginShell = process.platform !== "win32" && !values["no-login-shell"] && process.env.SHELL;
|
|
90
97
|
const result = loginShell
|
|
91
|
-
? spawnCommandSync(process.env.SHELL , ["-lic", `"$0" --version`, codex], { encoding: "utf8", timeout: 15_000 })
|
|
98
|
+
? spawnCommandSync(process.env.SHELL , ["-lic", `"$0" --version`, codex], { encoding: "utf8", timeout: 15_000, detached: true })
|
|
92
99
|
: spawnCommandSync(codex, ["--version"], { encoding: "utf8", timeout: 15_000 });
|
|
93
100
|
if (result.status === 0) return;
|
|
94
101
|
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.`);
|
|
@@ -111,7 +118,55 @@ async function wantBackground() {
|
|
|
111
118
|
return ask("Keep it running in the background and start it at login, so you can close this terminal? [Y/n] ");
|
|
112
119
|
}
|
|
113
120
|
|
|
114
|
-
|
|
121
|
+
/** How long to wait for the relay socket before saying it has not connected; a working network takes well under a second. */
|
|
122
|
+
const RELAY_WAIT_MS = Number(process.env.REPLICA_BRIDGE_RELAY_WAIT_MS ?? 10_000);
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Said when the bridge has not reached the relay. Enrolling and pairing are plain HTTPS requests, so they work on a
|
|
126
|
+
* network that blocks the WebSocket; then the phone pairs and shows the computer offline with no hint why
|
|
127
|
+
* (2026-11-08). The bridge keeps retrying, so this is a warning, not a stop.
|
|
128
|
+
*/
|
|
129
|
+
function relayWarning(relay , problem ) {
|
|
130
|
+
const socket = relay.replace(/^http/, "ws");
|
|
131
|
+
return [
|
|
132
|
+
`This computer has not connected to the relay yet${problem ? `: ${problem}` : ` (no answer within ${RELAY_WAIT_MS / 1000} s)`}.`,
|
|
133
|
+
"Until it does, the phone shows this computer offline. Pairing still works, since it uses plain HTTPS, and the bridge keeps trying.",
|
|
134
|
+
`A proxy or firewall that lets HTTPS through but blocks WebSocket connections to ${socket} causes exactly this.`,
|
|
135
|
+
].join("\n");
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Waits for this process's bridge to reach the relay and warns when it does not. */
|
|
139
|
+
async function checkRelay(bridge , relay ) {
|
|
140
|
+
if (await bridge.waitForRelay(RELAY_WAIT_MS)) return true;
|
|
141
|
+
console.warn(relayWarning(relay, bridge.relayProblem));
|
|
142
|
+
return false;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** The background service's log past `offset` bytes, or "" before it exists. */
|
|
146
|
+
function logSince(offset ) {
|
|
147
|
+
try { return fs.readFileSync(logPath()).subarray(offset).toString("utf8"); } catch { return ""; }
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function logSize() {
|
|
151
|
+
try { return fs.statSync(logPath()).size; } catch { return 0; }
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** The background service is another process: its log is the only place that says whether it reached the relay. */
|
|
155
|
+
async function checkServiceRelay(offset , relay ) {
|
|
156
|
+
for (let waited = 0; waited < RELAY_WAIT_MS; waited += 250) {
|
|
157
|
+
if (/ connected to relay /.test(logSince(offset))) return;
|
|
158
|
+
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
159
|
+
}
|
|
160
|
+
const text = logSince(offset);
|
|
161
|
+
if (/ connected to relay /.test(text)) return;
|
|
162
|
+
const problems = [...text.matchAll(/relay connection error: (.*)|relay refused the host connection: (HTTP \d+)/g)];
|
|
163
|
+
const last = problems.at(-1);
|
|
164
|
+
console.warn(relayWarning(relay, last ? (last[1] ?? `${last[2]} instead of a WebSocket`) : null));
|
|
165
|
+
console.warn(`The background service keeps trying; its log is ${logPath()}.`);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async function install() {
|
|
169
|
+
const offset = logSize();
|
|
115
170
|
const plan = installService(startArgs(), console.log);
|
|
116
171
|
// The service writes its pid once it has started; `status` right after the install said "not running" before that.
|
|
117
172
|
for (let waited = 0; !runningPid() && waited < 10_000; waited += 200) Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 200);
|
|
@@ -122,6 +177,7 @@ function install() {
|
|
|
122
177
|
if (process.platform === "linux" && !fs.existsSync(`/var/lib/systemd/linger/${user}`)) {
|
|
123
178
|
console.log(`To start it when the computer boots, before anyone logs in: sudo loginctl enable-linger ${user}`);
|
|
124
179
|
}
|
|
180
|
+
await checkServiceRelay(offset, loadState().relay);
|
|
125
181
|
}
|
|
126
182
|
|
|
127
183
|
async function pairOrExplain() {
|
|
@@ -156,19 +212,20 @@ async function main() {
|
|
|
156
212
|
// The background service serves the phone: restart it onto a new set-up or a newer version, then pair.
|
|
157
213
|
if (moved) console.log("Restarting the background service with the new set-up.");
|
|
158
214
|
else if (installed.version !== PACKAGE_VERSION) console.log(`Updating the background service from ${installed.version} to ${PACKAGE_VERSION}.`);
|
|
159
|
-
if (moved || values.install || installed.version !== PACKAGE_VERSION) install();
|
|
215
|
+
if (moved || values.install || installed.version !== PACKAGE_VERSION) await install();
|
|
160
216
|
if (moved || values.pair || !state.devices.length) await pairOrExplain();
|
|
161
217
|
else status();
|
|
162
218
|
break;
|
|
163
219
|
}
|
|
164
220
|
if (runningPid()) throw new Error(`a bridge already runs for this computer (pid ${runningPid()}); stop it first`);
|
|
165
221
|
let bridge = startBridge();
|
|
222
|
+
await checkRelay(bridge, state.relay);
|
|
166
223
|
if (values.pair || !state.devices.length) await pairOrExplain();
|
|
167
224
|
if (loadState().devices.length && await wantBackground()) {
|
|
168
225
|
// One bridge per computer: the relay keeps only the newest connection for it.
|
|
169
226
|
bridge.stop();
|
|
170
227
|
try {
|
|
171
|
-
install();
|
|
228
|
+
await install();
|
|
172
229
|
process.exit(0);
|
|
173
230
|
} catch (error) {
|
|
174
231
|
console.error(`replica-bridge: could not set up the background service: ${(error ).message}`);
|
|
@@ -177,11 +234,12 @@ async function main() {
|
|
|
177
234
|
}
|
|
178
235
|
for (const signal of ["SIGINT", "SIGTERM"] ) process.on(signal, () => { bridge.stop(); process.exit(0); });
|
|
179
236
|
console.log(`Running in this terminal: paired phones can use Codex on ${state.hostName} until it closes (Ctrl-C stops it).`);
|
|
237
|
+
if (!(await bridge.waitForRelay(RELAY_WAIT_MS))) console.warn(`Still not connected to the relay${bridge.relayProblem ? ` (${bridge.relayProblem})` : ""}: the phone shows ${state.hostName} offline until it is.`);
|
|
180
238
|
break;
|
|
181
239
|
}
|
|
182
240
|
case "install":
|
|
183
241
|
await ensureRelay(values.relay, console.log, values.name);
|
|
184
|
-
install();
|
|
242
|
+
await install();
|
|
185
243
|
break;
|
|
186
244
|
case "uninstall": {
|
|
187
245
|
const plan = uninstallService();
|
|
@@ -196,7 +254,7 @@ async function main() {
|
|
|
196
254
|
const state = await enroll(relay, { name: values.name, publicRelay: values["public-url"] });
|
|
197
255
|
console.log(`Set up ${state.hostName} as ${state.hostId}. Next: "npx replica-bridge".`);
|
|
198
256
|
console.log("Nobody owns it yet: the first phone that scans its QR code and is approved here claims it.");
|
|
199
|
-
if (readServiceInfo()) { console.log("Restarting the background service with the new set-up."); install(); }
|
|
257
|
+
if (readServiceInfo()) { console.log("Restarting the background service with the new set-up."); await install(); }
|
|
200
258
|
break;
|
|
201
259
|
}
|
|
202
260
|
case "config": {
|
|
@@ -209,7 +267,7 @@ async function main() {
|
|
|
209
267
|
case "pair":
|
|
210
268
|
if ((await ensureRelay(values.relay, console.log, values.name)).moved && readServiceInfo()) {
|
|
211
269
|
console.log("Restarting the background service with the new set-up.");
|
|
212
|
-
install();
|
|
270
|
+
await install();
|
|
213
271
|
}
|
|
214
272
|
await pair({ confirm: values.confirm, yes: values.yes, timeoutSeconds: Number(values.timeout), pngPath: values.png });
|
|
215
273
|
if (!runningPid()) console.log(`Nothing serves this computer right now: run "npx replica-bridge" so the phone can connect.`);
|
package/src/bridge/service.js
CHANGED
|
@@ -121,12 +121,42 @@ export class BridgeService {
|
|
|
121
121
|
|
|
122
122
|
get streamCount() { return this.streams.size; }
|
|
123
123
|
|
|
124
|
+
/** The relay socket is open: phones see this computer online. */
|
|
125
|
+
get relayConnected() { return this.relay?.readyState === WebSocket.OPEN; }
|
|
126
|
+
|
|
127
|
+
/** Why the last attempt to reach the relay failed, while it has not connected since. */
|
|
128
|
+
get relayProblem() { return this.lastRelayProblem; }
|
|
129
|
+
|
|
130
|
+
lastRelayProblem = null;
|
|
131
|
+
relayWaiters = new Set ();
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Waits up to `ms` for the relay socket to open. Pairing goes over plain HTTPS, so a network that lets HTTPS through
|
|
135
|
+
* but not the WebSocket (2026-11-08: two computers paired, never once came online, and the phone showed both
|
|
136
|
+
* offline) pairs fine and leaves nothing for the phone to reach; the caller says so instead of looking finished.
|
|
137
|
+
*/
|
|
138
|
+
async waitForRelay(ms ) {
|
|
139
|
+
if (this.relayConnected) return true;
|
|
140
|
+
await new Promise ((resolve) => {
|
|
141
|
+
const done = () => { clearTimeout(timer); this.relayWaiters.delete(done); resolve(); };
|
|
142
|
+
const timer = setTimeout(done, ms);
|
|
143
|
+
this.relayWaiters.add(done);
|
|
144
|
+
});
|
|
145
|
+
return this.relayConnected;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Only an open socket ends the wait early: a first attempt that fails often succeeds on the retry a second later. */
|
|
149
|
+
relayChanged() {
|
|
150
|
+
for (const waiter of [...this.relayWaiters]) waiter();
|
|
151
|
+
}
|
|
152
|
+
|
|
124
153
|
// MARK: - Relay connection
|
|
125
154
|
|
|
126
155
|
connectRelay() {
|
|
127
156
|
const state = loadState();
|
|
128
157
|
const ws = new WebSocket(relaySocketURL(state.relay, "/v1/host"), { headers: { authorization: `Bearer ${state.hostToken}` }, maxPayload: MAX_FRAME_BYTES + 4096, perMessageDeflate: false });
|
|
129
158
|
this.relay = ws;
|
|
159
|
+
let refused = false;
|
|
130
160
|
// A laptop that sleeps or changes networks keeps a socket the relay dropped long ago, and nothing here would
|
|
131
161
|
// notice until the bridge sent something: the computer stayed offline for the phones. A missed pong ends it.
|
|
132
162
|
let answered = true;
|
|
@@ -138,7 +168,14 @@ export class BridgeService {
|
|
|
138
168
|
}, (this.options.relayPingSeconds ?? 30) * 1000);
|
|
139
169
|
ws.on("pong", () => { answered = true; });
|
|
140
170
|
ws.on("ping", () => { answered = true; });
|
|
141
|
-
ws.on("open", () => {
|
|
171
|
+
ws.on("open", () => {
|
|
172
|
+
answered = true;
|
|
173
|
+
this.retry = 0;
|
|
174
|
+
this.lastRelayProblem = null;
|
|
175
|
+
this.log(`connected to relay ${state.relay} as ${state.hostId}`);
|
|
176
|
+
this.options.onRelayConnected?.(true);
|
|
177
|
+
this.relayChanged();
|
|
178
|
+
});
|
|
142
179
|
ws.on("message", (data ) => {
|
|
143
180
|
let envelope ;
|
|
144
181
|
try { envelope = JSON.parse(data.toString()); } catch { return; }
|
|
@@ -149,10 +186,16 @@ export class BridgeService {
|
|
|
149
186
|
// process ran out of work and exited 0, which the background services read as "replaced" and left stopped.
|
|
150
187
|
// Ending the socket goes through close and the usual backoff instead.
|
|
151
188
|
ws.on("unexpected-response", (_req, res) => {
|
|
189
|
+
refused = true;
|
|
190
|
+
this.lastRelayProblem = `HTTP ${res.statusCode} instead of a WebSocket`;
|
|
152
191
|
this.log(`relay refused the host connection: HTTP ${res.statusCode}; retrying`);
|
|
153
192
|
ws.terminate();
|
|
154
193
|
});
|
|
155
|
-
ws.on("error", (error) =>
|
|
194
|
+
ws.on("error", (error) => {
|
|
195
|
+
// Ending a refused socket also reports "closed before the connection was established"; the status says more.
|
|
196
|
+
if (!refused) this.lastRelayProblem = describeSocketError(error);
|
|
197
|
+
this.log(`relay connection error: ${this.lastRelayProblem}`);
|
|
198
|
+
});
|
|
156
199
|
ws.on("close", (code) => {
|
|
157
200
|
clearInterval(ping);
|
|
158
201
|
if (ws !== this.relay) return; // a socket this bridge already replaced itself
|
|
@@ -420,9 +463,10 @@ export class BridgeService {
|
|
|
420
463
|
}
|
|
421
464
|
|
|
422
465
|
/** Runs a command to completion; true when it exits 0 within 30 s. Output is dropped (an rc file may print). */
|
|
423
|
-
|
|
466
|
+
/** `detached` for an interactive login shell, which would otherwise take the terminal of a bridge run in one (main.ts checkCodex). */
|
|
467
|
+
run(command , args , detached = false) {
|
|
424
468
|
return new Promise((resolve, reject) => {
|
|
425
|
-
const child = spawnCommand(command, args, { stdio: "ignore" });
|
|
469
|
+
const child = spawnCommand(command, args, { stdio: "ignore", detached });
|
|
426
470
|
const timer = setTimeout(() => { killCommand(child); resolve(false); }, 30_000);
|
|
427
471
|
child.once("error", (error) => { clearTimeout(timer); reject(error); });
|
|
428
472
|
child.once("exit", (code) => { clearTimeout(timer); resolve(code === 0); });
|
|
@@ -457,7 +501,7 @@ export class BridgeService {
|
|
|
457
501
|
// (a SHELL from Git Bash is a POSIX path Node cannot run), so Codex is asked directly there.
|
|
458
502
|
const shell = process.platform === "win32" ? null : process.env.SHELL || os.userInfo().shell;
|
|
459
503
|
const started = this.options.loginShell !== false && shell
|
|
460
|
-
? await this.run(shell, ["-lic", 'exec "$0" app-server daemon start', codex]).catch(() => false)
|
|
504
|
+
? await this.run(shell, ["-lic", 'exec "$0" app-server daemon start', codex], true).catch(() => false)
|
|
461
505
|
: false;
|
|
462
506
|
if (!started && !(await this.run(codex, ["app-server", "daemon", "start"]).catch(() => false))) {
|
|
463
507
|
throw new Error("codex app-server daemon start failed");
|
|
@@ -510,3 +554,13 @@ export function fitLine(line , limit = RELAY_REPLY_LIMIT)
|
|
|
510
554
|
if (id === undefined || id === null) return null;
|
|
511
555
|
return JSON.stringify({ id, error: { code: -32010, message: `the reply is ${bytes} bytes, more than the relay carries (${limit})`, data: { replicaTooLarge: { bytes, limit } } } });
|
|
512
556
|
}
|
|
557
|
+
|
|
558
|
+
/**
|
|
559
|
+
* An error's message, or its code when Node leaves the message empty: a refused connection tried on both IPv4 and
|
|
560
|
+
* IPv6 is an AggregateError with message "" and the reasons in `errors`.
|
|
561
|
+
*/
|
|
562
|
+
export function describeSocketError(error ) {
|
|
563
|
+
const parts = error instanceof AggregateError ? error.errors : [error];
|
|
564
|
+
const text = parts.map((e ) => e?.message || e?.code || String(e)).filter(Boolean);
|
|
565
|
+
return [...new Set(text)].join("; ") || error.name;
|
|
566
|
+
}
|
package/src/protocol.js
CHANGED
|
@@ -249,13 +249,15 @@ export class FrameCipher {
|
|
|
249
249
|
|
|
250
250
|
/** One task that changed, from agens-backend's change log (0.3.14). */
|
|
251
251
|
|
|
252
|
+
/** As a phone gets it from 0.3.17 on: the task as the backend now has it (`GET /v1/recommendations/:id`), or gone. */
|
|
253
|
+
|
|
252
254
|
/**
|
|
253
255
|
* What a subscriber hears. From 0.3.14 a board/state that says up also gives `seq` (where in the change log the phone
|
|
254
256
|
* starts), `caps` and the bridge's version, and board/changed names the tasks, or says `reset` when there are too many.
|
|
255
257
|
*/
|
|
256
258
|
|
|
257
259
|
|
|
258
|
-
|
|
260
|
+
|
|
259
261
|
|
|
260
262
|
|
|
261
263
|
/** One segment of a long line: `data` is base64 of bytes [segment * chunk size, …) of the payload, `size` its length. */
|