replica-bridge 0.3.19 → 0.3.22

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.19",
3
+ "version": "0.3.22",
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": {
@@ -22,8 +22,11 @@ const CHANGES_PAGE = 100;
22
22
  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
- /** What this bridge can do beyond reading, for board/state (PROTOCOL.md "看板"). */
26
- export const BOARD_CAPS = ["post", "delta", "items", "tasks"] ;
25
+ /**
26
+ * What this bridge can do beyond reading, for board/state (PROTOCOL.md "看板"). `ring`: this connection takes the
27
+ * ring/* frames (PROTOCOL.md "设备环:电脑一侧"), since a board connection has no resume to say it in.
28
+ */
29
+ export const BOARD_CAPS = ["post", "delta", "items", "tasks", "ring"] ;
27
30
  /** Up to this many changed tasks in one event come with their contents (`task`); past it the phone reads them itself. */
28
31
  export const CARRIED_TASKS_MAX = 50;
29
32
  /** A task bigger than this is left for the phone to read. */
@@ -39,13 +42,17 @@ const REC = "[A-Za-z0-9_.:-]+";
39
42
  /**
40
43
  * GETs (#367). /session also writes TASK.md in the task's working folder, which is what 开工 asks for; /share and /me
41
44
  * 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.
45
+ * From 0.3.20 also the change log, which unlike the task reads marks nothing seen: the phone counts new tasks from it
46
+ * for the sidebar while 看板 is closed; and the read state every client shares (agens-backend 0.15.0), which the phone
47
+ * reads and writes so what was looked at on the desktop is not new again on the phone.
48
+ * Not /assist, /mcp, /search, /context, /changes/stream (the bridge's own) or anything under /ui.
43
49
  */
44
50
  const READS = [/^\/v1\/status$/, /^\/v1\/today$/, /^\/v1\/recommendations$/, new RegExp(`^/v1/recommendations/${REC}$`),
45
- new RegExp(`^/v1/recommendations/${REC}/(share|session)$`), /^\/v1\/me$/, new RegExp(`^/v1/items/${REC}$`)];
51
+ new RegExp(`^/v1/recommendations/${REC}/(share|session)$`), /^\/v1\/me$/, new RegExp(`^/v1/items/${REC}$`), /^\/v1\/changes$/,
52
+ /^\/v1\/me\/read-state$/];
46
53
 
47
54
  /** POSTs (#367): a task's follow-up actions, cancelling its preparation note, and muting a conversation. Nothing else. */
48
- const WRITES = [new RegExp(`^/v1/recommendations/${REC}/actions$`), new RegExp(`^/v1/recommendations/${REC}/prep/cancel$`), /^\/v1\/me\/muted$/];
55
+ const WRITES = [new RegExp(`^/v1/recommendations/${REC}/actions$`), new RegExp(`^/v1/recommendations/${REC}/prep/cancel$`), /^\/v1\/me\/muted$/, /^\/v1\/me\/read-state$/];
49
56
  /** agens-backend src/recs.mjs ACTIONS. */
50
57
  export const BOARD_ACTIONS = ["accept", "snooze", "delegate", "dismiss", "done", "reopen"] ;
51
58
 
@@ -11,6 +11,9 @@
11
11
  // config [--public-url <url>]
12
12
  // devices
13
13
  // revoke <deviceId>
14
+ // ring [status] the device ring this computer is anchored to, and anchors waiting
15
+ // ring accept|reject [<n|fingerprint>] confirm or refuse a waiting anchor (lists them without an argument)
16
+ // ring reset [--yes] leave the ring, so a different one can be anchored
14
17
  import fs from "node:fs";
15
18
  import os from "node:os";
16
19
  import readline from "node:readline/promises";
@@ -20,8 +23,10 @@ import { spawnCommandSync } from "./command.js";
20
23
  import { defaultRelayURL, enroll, ensureEnrolled, ensureRelay } from "./enroll.js";
21
24
  import { PACKAGE_VERSION, clearPid, installService, logPath, pauseService, readServiceInfo, runningPid, servicePaused, servicePlan, uninstallService, writePid } from "./install.js";
22
25
  import { pair, publicRelayURL } from "./pair.js";
26
+ import { acceptAnchor, anchorPrompt, describeAnchor, findPending, rejectAnchor, removeDevice, resetRing, ringSummary } from "./ring.js";
27
+
23
28
  import { BridgeService } from "./service.js";
24
- import { loadState, saveState } from "./state.js";
29
+ import { hostKeyPair, loadState, saveState, updateState } from "./state.js";
25
30
 
26
31
  // A leading option means no command: `replica-bridge --pair` is the default command with --pair.
27
32
  const argv = process.argv.slice(2);
@@ -53,7 +58,8 @@ const usage = `replica-bridge ${PACKAGE_VERSION}: lets the ChatReplica app use C
53
58
  in the background; run again for the status
54
59
  npx replica-bridge pair pair another phone
55
60
  npx replica-bridge uninstall stop and remove the background service
56
- Others: status, install, start (in this terminal), init, config, devices, revoke <id>.
61
+ npx replica-bridge ring accept confirm a device ring asking to include this computer (ring reject, ring reset)
62
+ Others: status, install, start (in this terminal), init, config, devices, revoke <id>, ring.
57
63
  The relay is ${defaultRelayURL()} unless --relay or REPLICA_RELAY says otherwise.`;
58
64
 
59
65
  // The Windows service has no terminal to write to: its output goes to the log file it names.
@@ -78,11 +84,115 @@ function startBridge() {
78
84
  if (process.env.REPLICA_BRIDGE_SERVICE === "1" && process.platform === "win32") pauseService();
79
85
  process.exit(0);
80
86
  };
81
- const service = new BridgeService({ codexPath: values.codex, loginShell: !values["no-login-shell"], streamTTLSeconds: Number(values["stream-ttl"]), onReplaced });
87
+ const service = new BridgeService({
88
+ codexPath: values.codex, loginShell: !values["no-login-shell"], streamTTLSeconds: Number(values["stream-ttl"]), onReplaced,
89
+ onAnchorRequest: (prompt) => askAnchor(service, prompt),
90
+ });
82
91
  service.start();
83
92
  return service;
84
93
  }
85
94
 
95
+ /** True only for y or yes: anchoring is the one question here whose default is no. */
96
+ async function confirm(question ) {
97
+ const prompt = readline.createInterface({ input: process.stdin, output: process.stdout });
98
+ try { return /^y(es)?$/i.test((await prompt.question(question)).trim()); } finally { prompt.close(); }
99
+ }
100
+
101
+ /** Anchor questions in this terminal, one at a time, once nothing else here is asking (askAnchorsHere). */
102
+ let anchorQuestions = Promise.resolve();
103
+ let askAnchorsHere = false;
104
+
105
+ function askAnchor(bridge , prompt ) {
106
+ if (!askAnchorsHere) {
107
+ console.log(`A device asks to anchor this computer to its device ring (fingerprint ${prompt.fingerprint}): run "npx replica-bridge ring accept" to confirm.`);
108
+ return;
109
+ }
110
+ anchorQuestions = anchorQuestions.then(async () => {
111
+ // Answered meanwhile with `ring accept` or `ring reject` in another terminal.
112
+ if (!bridge.pendingAnchors().some((p) => p.ring === prompt.ring)) return;
113
+ console.log(`\n${describeAnchor(prompt)}`);
114
+ const yes = await confirm("Anchor this computer to this ring? [y/N] ");
115
+ try {
116
+ const { removed } = bridge.answerAnchor(prompt.ring, yes);
117
+ console.log(yes ? `Anchored. The ring's signers can use this computer now.${removed.length ? ` Removed ${removed.join(", ")}, revoked in the ring.` : ""}` : "Rejected. The device can ask again.");
118
+ } catch (error) {
119
+ console.error(`replica-bridge: ${(error ).message}`);
120
+ }
121
+ });
122
+ }
123
+
124
+ /** From here on this terminal asks about anchor requests, starting with any already waiting. */
125
+ function askAnchorsInTerminal(bridge ) {
126
+ if (!process.stdin.isTTY || process.env.REPLICA_BRIDGE_SERVICE === "1") return;
127
+ askAnchorsHere = true;
128
+ for (const prompt of bridge.pendingAnchors()) askAnchor(bridge, prompt);
129
+ }
130
+
131
+ /** With the background service serving the computer: ask here about the anchors it recorded (it notices the answer). */
132
+ async function answerPendingHere() {
133
+ if (!process.stdin.isTTY) return;
134
+ for (const pending of loadState().ringPending ?? []) {
135
+ console.log(`\n${describeAnchor(anchorPrompt(pending))}`);
136
+ const yes = await confirm("Anchor this computer to this ring? [y/N] ");
137
+ if (yes) acceptAnchor(pending.ring, hostKeyPair(loadState()).publicKey);
138
+ else rejectAnchor(pending.ring);
139
+ console.log(yes ? "Anchored." : "Rejected.");
140
+ if (yes) break;
141
+ }
142
+ }
143
+
144
+ async function ringCommand() {
145
+ const sub = positionals[0] ?? "status";
146
+ const state = loadState();
147
+ if (sub === "status") {
148
+ for (const line of ringSummary(state)) console.log(line);
149
+ if (state.ringPending?.length) console.log('Confirm with "npx replica-bridge ring accept", refuse with "npx replica-bridge ring reject".');
150
+ return;
151
+ }
152
+ if (sub === "accept" || sub === "reject") {
153
+ const pending = state.ringPending ?? [];
154
+ if (!pending.length) { console.log("No device ring is waiting for confirmation."); return; }
155
+ let which = positionals[1];
156
+ if (!which) {
157
+ pending.forEach((p, i) => console.log(`\n${i + 1}. ${describeAnchor(anchorPrompt(p))}`));
158
+ if (!process.stdin.isTTY) throw new Error(`say which: replica-bridge ring ${sub} <number or fingerprint>`);
159
+ if (pending.length === 1) {
160
+ if (!(await confirm(`\n${sub === "accept" ? "Anchor this computer to this ring" : "Reject this request"}? [y/N] `))) return;
161
+ which = "1";
162
+ } else {
163
+ const prompt = readline.createInterface({ input: process.stdin, output: process.stdout });
164
+ try { which = (await prompt.question(`\nWhich one (1-${pending.length}, empty to stop)? `)).trim(); } finally { prompt.close(); }
165
+ if (!which) return;
166
+ }
167
+ } else {
168
+ console.log(describeAnchor(anchorPrompt(findPending(state, which))));
169
+ }
170
+ // The ring id of what was shown, so a list that changed meanwhile cannot put another ring under the same number.
171
+ const shown = findPending(state, which).ring;
172
+ if (sub === "reject") {
173
+ rejectAnchor(shown);
174
+ console.log("Rejected. Nothing changed; the device can ask again.");
175
+ return;
176
+ }
177
+ const { ring, removed } = acceptAnchor(shown, hostKeyPair(state).publicKey);
178
+ console.log(`Anchored to ring ${ring.id}. Its signers can use this computer now; "npx replica-bridge ring reset" leaves it.`);
179
+ if (removed.length) console.log(`Removed ${removed.join(", ")} from the paired devices: the ring revoked them.`);
180
+ return;
181
+ }
182
+ if (sub === "reset") {
183
+ if (!state.ring && !state.ringPending?.length) { console.log("Not anchored to a device ring."); return; }
184
+ for (const line of ringSummary(state)) console.log(line);
185
+ if (!values.yes) {
186
+ if (!process.stdin.isTTY) throw new Error("add --yes to leave the ring without a terminal to ask in");
187
+ if (!(await confirm("Leave this ring? Its signers can no longer use this computer unless paired here. [y/N] "))) return;
188
+ }
189
+ resetRing();
190
+ console.log("Left the device ring. Phones paired here by QR code still connect; a device can ask to anchor again.");
191
+ return;
192
+ }
193
+ throw new Error("usage: replica-bridge ring [status | accept [<n>] | reject [<n>] | reset [--yes]]");
194
+ }
195
+
86
196
  /**
87
197
  * Warns, without stopping, when Codex does not answer the way the bridge will run it (through the login shell).
88
198
  * The shell is interactive (-i) so it reads the rc file where people put Codex on PATH, and an interactive shell that
@@ -197,6 +307,7 @@ function status() {
197
307
  console.log(`Owner: ${state.owner ? `${state.owner.login} (${state.owner.provider})` : "none yet"}; paired phones: ${state.devices.map((d) => d.name).join(", ") || "none"}`);
198
308
  if (info) console.log(`Background service ${info.version}: ${pid ? `running (pid ${pid})` : "installed, not running right now"}. Log: ${logPath()}`);
199
309
  else console.log(pid ? `Running (pid ${pid}).` : `Not running: "npx replica-bridge" sets up the background service.`);
310
+ if (state.ring || state.ringPending?.length) for (const line of ringSummary(state)) console.log(line);
200
311
  }
201
312
 
202
313
  async function main() {
@@ -215,6 +326,7 @@ async function main() {
215
326
  if (moved || values.install || installed.version !== PACKAGE_VERSION) await install();
216
327
  if (moved || values.pair || !state.devices.length) await pairOrExplain();
217
328
  else status();
329
+ await answerPendingHere();
218
330
  break;
219
331
  }
220
332
  if (runningPid()) throw new Error(`a bridge already runs for this computer (pid ${runningPid()}); stop it first`);
@@ -234,6 +346,7 @@ async function main() {
234
346
  }
235
347
  for (const signal of ["SIGINT", "SIGTERM"] ) process.on(signal, () => { bridge.stop(); process.exit(0); });
236
348
  console.log(`Running in this terminal: paired phones can use Codex on ${state.hostName} until it closes (Ctrl-C stops it).`);
349
+ askAnchorsInTerminal(bridge);
237
350
  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.`);
238
351
  break;
239
352
  }
@@ -282,6 +395,7 @@ async function main() {
282
395
  if (runningPid()) throw new Error(`a bridge already runs for this computer (pid ${runningPid()})${asService ? "" : '; "npx replica-bridge uninstall" stops the background one'}`);
283
396
  const bridge = startBridge();
284
397
  if (asService) { writePid(); process.on("exit", clearPid); }
398
+ askAnchorsInTerminal(bridge);
285
399
  for (const signal of ["SIGINT", "SIGTERM"] ) process.on(signal, () => { bridge.stop(); process.exit(0); });
286
400
  break;
287
401
  }
@@ -298,11 +412,18 @@ async function main() {
298
412
  if (!deviceId) throw new Error("usage: replica-bridge revoke <deviceId>");
299
413
  const state = loadState();
300
414
  await relayRequest(state.relay, "DELETE", `/v1/host/devices/${encodeURIComponent(deviceId)}`, { token: state.hostToken });
301
- state.devices = state.devices.filter((d) => d.deviceId !== deviceId);
302
- saveState(state);
415
+ // Read again after the request: the running bridge may have stored ring entries meanwhile.
416
+ let name = null;
417
+ const after = updateState((latest) => { name = removeDevice(latest, deviceId); });
303
418
  console.log(`Removed ${deviceId} from this computer.`);
419
+ if (name && after.ring) {
420
+ console.log(`It stays refused here even while this computer's device ring lists it as a signer. To remove "${name}" from your other computers too, remove it from the ring in the app.`);
421
+ }
304
422
  break;
305
423
  }
424
+ case "ring":
425
+ await ringCommand();
426
+ break;
306
427
  default:
307
428
  console.log(usage);
308
429
  process.exitCode = 1;
@@ -136,6 +136,8 @@ export async function pair(options )
136
136
  ...s,
137
137
  owner: s.owner ?? { login: info.login, provider: info.provider },
138
138
  devices: [...s.devices.filter((d) => d.deviceId !== claim .deviceId), { deviceId: claim .deviceId , publicKey: b64url(devicePublicKey), name: info.deviceName, login: info.login, pairedAt: new Date().toISOString() }],
139
+ // Scanning it again here is a person saying yes to this key: it is no longer refused (ring.ts removeDevice).
140
+ ...(s.denied ? { denied: s.denied.filter((d) => d.key !== b64url(devicePublicKey)) } : {}),
139
141
  });
140
142
  saveState(withDevice(loadState()));
141
143
  try {