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 +1 -1
- package/src/bridge/board.js +12 -5
- package/src/bridge/main.js +126 -5
- package/src/bridge/pair.js +2 -0
- package/src/bridge/ring.js +427 -0
- package/src/bridge/service.js +402 -25
- package/src/bridge/state.js +53 -1
- package/src/http.js +5 -2
- package/src/protocol.js +704 -3
- package/src/relay/config.js +8 -0
package/package.json
CHANGED
package/src/bridge/board.js
CHANGED
|
@@ -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
|
-
/**
|
|
26
|
-
|
|
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
|
-
*
|
|
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
|
|
package/src/bridge/main.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
302
|
-
|
|
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;
|
package/src/bridge/pair.js
CHANGED
|
@@ -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 {
|