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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "replica-bridge",
3
- "version": "0.3.15",
3
+ "version": "0.3.18",
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": {
@@ -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. Not /assist, /mcp, /search, /changes (the bridge's own poll) or anything under /ui.
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
- function changedEvent(items ) {
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 latest.size > CHANGED_ITEMS_MAX ? { t: "board/changed", reset: true } : { t: "board/changed", items: [...latest.values()] };
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.deliver(items, end );
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.deliver(batch, end);
427
+ this.send(batch, end);
367
428
  };
368
429
  try {
369
430
  const decoder = new TextDecoder();
@@ -83,12 +83,19 @@ function startBridge() {
83
83
  return service;
84
84
  }
85
85
 
86
- /** Warns, without stopping, when Codex does not answer the way the bridge will run it (through the login shell). */
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
- function install() {
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.`);
@@ -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", () => { answered = true; this.retry = 0; this.log(`connected to relay ${state.relay} as ${state.hostId}`); this.options.onRelayConnected?.(true); });
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) => this.log(`relay connection error: ${error.message}`));
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
- run(command , args ) {
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. */