replica-bridge 0.3.18 → 0.3.20
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 +7 -3
- package/src/bridge/install.js +1 -1
- package/src/bridge/service.js +6 -0
- package/src/bridge/work.js +46 -0
- package/src/protocol.js +9 -2
- package/src/relay/config.js +25 -1
package/package.json
CHANGED
package/src/bridge/board.js
CHANGED
|
@@ -39,13 +39,17 @@ const REC = "[A-Za-z0-9_.:-]+";
|
|
|
39
39
|
/**
|
|
40
40
|
* GETs (#367). /session also writes TASK.md in the task's working folder, which is what 开工 asks for; /share and /me
|
|
41
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
|
-
*
|
|
42
|
+
* From 0.3.20 also the change log, which unlike the task reads marks nothing seen: the phone counts new tasks from it
|
|
43
|
+
* for the sidebar while 看板 is closed; and the read state every client shares (agens-backend 0.15.0), which the phone
|
|
44
|
+
* reads and writes so what was looked at on the desktop is not new again on the phone.
|
|
45
|
+
* Not /assist, /mcp, /search, /context, /changes/stream (the bridge's own) or anything under /ui.
|
|
43
46
|
*/
|
|
44
47
|
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}$`)
|
|
48
|
+
new RegExp(`^/v1/recommendations/${REC}/(share|session)$`), /^\/v1\/me$/, new RegExp(`^/v1/items/${REC}$`), /^\/v1\/changes$/,
|
|
49
|
+
/^\/v1\/me\/read-state$/];
|
|
46
50
|
|
|
47
51
|
/** 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$/];
|
|
52
|
+
const WRITES = [new RegExp(`^/v1/recommendations/${REC}/actions$`), new RegExp(`^/v1/recommendations/${REC}/prep/cancel$`), /^\/v1\/me\/muted$/, /^\/v1\/me\/read-state$/];
|
|
49
53
|
/** agens-backend src/recs.mjs ACTIONS. */
|
|
50
54
|
export const BOARD_ACTIONS = ["accept", "snooze", "delegate", "dismiss", "done", "reopen"] ;
|
|
51
55
|
|
package/src/bridge/install.js
CHANGED
|
@@ -216,7 +216,7 @@ WantedBy=default.target
|
|
|
216
216
|
function serviceEnv() {
|
|
217
217
|
const env = {};
|
|
218
218
|
for (const [key, value] of Object.entries(process.env)) {
|
|
219
|
-
if (value !== undefined && (key === "PATH" || key === "Path" || /^(CODEX_|REPLICA_|RELAY_ENV$|AGENS_BACKEND_PORT$)/.test(key)) && key !== "REPLICA_BRIDGE_LOG") env[key] = value;
|
|
219
|
+
if (value !== undefined && (key === "PATH" || key === "Path" || /^(CODEX_|REPLICA_|RELAY_ENV$|AGENS_BACKEND_PORT$|AGENS_WORKSPACE_ROOT$)/.test(key)) && key !== "REPLICA_BRIDGE_LOG") env[key] = value;
|
|
220
220
|
}
|
|
221
221
|
for (const key of ["PATH", "Path"]) if (env[key]) env[key] = servicePATH(env[key]);
|
|
222
222
|
env.REPLICA_BRIDGE_SERVICE = "1";
|
package/src/bridge/service.js
CHANGED
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
|
|
16
16
|
import { relaySocketURL } from "../http.js";
|
|
17
17
|
import { BoardReader } from "./board.js";
|
|
18
|
+
import { readWork, workDirectories } from "./work.js";
|
|
18
19
|
import { killCommand, spawnCommand } from "./command.js";
|
|
19
20
|
import { hostKeyPair, loadState } from "./state.js";
|
|
20
21
|
import { annotateWriterError } from "./writer.js";
|
|
@@ -57,6 +58,8 @@ import { annotateWriterError } from "./writer.js";
|
|
|
57
58
|
|
|
58
59
|
|
|
59
60
|
|
|
61
|
+
|
|
62
|
+
|
|
60
63
|
|
|
61
64
|
|
|
62
65
|
|
|
@@ -316,6 +319,9 @@ export class BridgeService {
|
|
|
316
319
|
void this.board.post(message.path, message.body).then((answer) => this.sendInner(conn, { t: "board/res", id: message.id, ...answer }));
|
|
317
320
|
} else if (message.t === "board/sub") {
|
|
318
321
|
conn.boardUnsubscribe ??= this.board.subscribe((event) => this.sendInner(conn, event), message.after);
|
|
322
|
+
} else if (message.t === "work/get") {
|
|
323
|
+
// Read when asked, so a new main conversation started on the desktop is what the next question hears.
|
|
324
|
+
this.sendInner(conn, { t: "work/res", id: message.id, ...readWork(this.options.workDirectories ?? workDirectories()) });
|
|
319
325
|
} else if (message.t === "board/unsub") {
|
|
320
326
|
conn.boardUnsubscribe?.();
|
|
321
327
|
conn.boardUnsubscribe = null;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// Agens Work's main conversation (PROTOCOL.md "Work"), for agens-mobile's Work row. The Agens desktop keeps it in
|
|
2
|
+
// agens-work.json in the Work workspace (AgensWorkDev agens/extensions/work-chat/main.cjs): AGENS_WORKSPACE_ROOT, else
|
|
3
|
+
// ~/.agens-work/workspaces/assistant, and the first of those with a record is the one in use. The bridge only reads
|
|
4
|
+
// that file when a phone asks, so it needs no hook into the desktop, and a computer without Agens Work simply says so.
|
|
5
|
+
// Only whether Work is on and the conversation's id leave this computer, not the paths or anything else in the record.
|
|
6
|
+
import fs from "node:fs";
|
|
7
|
+
import os from "node:os";
|
|
8
|
+
import path from "node:path";
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
export const WORK_RECORD = "agens-work.json";
|
|
12
|
+
/** A Codex thread id (a UUID); anything else in the record is not passed on. */
|
|
13
|
+
const THREAD_ID = /^[A-Za-z0-9-]{1,64}$/;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* What the phone hears (WorkAnswer): on with the main conversation, or off and why. `none`: Agens Work was never agreed
|
|
17
|
+
* to here; `off`: switched off in its settings; `preparing`: agreed, but the main conversation is not made yet.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/** Where the desktop looks for the record, in its order (main.cjs chooseWorkspace). */
|
|
21
|
+
export function workDirectories(env = process.env, home = os.homedir()) {
|
|
22
|
+
const legacy = path.join(home, ".agens-work", "workspaces", "assistant");
|
|
23
|
+
return [...new Set([env.AGENS_WORKSPACE_ROOT || legacy, legacy])];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function readRecord(directory ) {
|
|
27
|
+
try {
|
|
28
|
+
const record = JSON.parse(fs.readFileSync(path.join(directory, WORK_RECORD), "utf8"));
|
|
29
|
+
return record && typeof record === "object" && record.schema === 1 ? record : null;
|
|
30
|
+
} catch {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Reads the record now, as the desktop would see it. */
|
|
36
|
+
export function readWork(directories = workDirectories()) {
|
|
37
|
+
for (const directory of directories) {
|
|
38
|
+
const record = readRecord(directory);
|
|
39
|
+
if (!record) continue;
|
|
40
|
+
// A record without `enabled` predates the desktop's switch, and means on (main.cjs enabled()).
|
|
41
|
+
if (record.enabled === false) return { on: false, reason: "off" };
|
|
42
|
+
const threadId = record.mainThreadId;
|
|
43
|
+
return typeof threadId === "string" && THREAD_ID.test(threadId) ? { on: true, threadId } : { on: false, reason: "preparing" };
|
|
44
|
+
}
|
|
45
|
+
return { on: false, reason: "none" };
|
|
46
|
+
}
|
package/src/protocol.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Shared protocol for replica-relay, replica-bridge and the test phone (
|
|
1
|
+
// Shared protocol for replica-relay, replica-bridge and the test phone (PROTOCOL.md).
|
|
2
2
|
// Everything here is plain Node: X25519, HKDF-SHA256, ChaCha20-Poly1305 and HMAC-SHA256 match CryptoKit's
|
|
3
3
|
// Curve25519.KeyAgreement, HKDF<SHA256>, ChaChaPoly and HMAC<SHA256>, so the Swift side can follow the same steps.
|
|
4
4
|
import crypto from "node:crypto";
|
|
@@ -243,7 +243,12 @@ export class FrameCipher {
|
|
|
243
243
|
|
|
244
244
|
|
|
245
245
|
|
|
246
|
-
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
/** Agens Work's main conversation on this computer, or why there is none (PROTOCOL.md "Work", 0.3.19). */
|
|
251
|
+
|
|
247
252
|
|
|
248
253
|
/** A read or write of the optional board: the backend's status and body, or why there is none (PROTOCOL.md "看板"). */
|
|
249
254
|
|
|
@@ -337,6 +342,8 @@ export const CloseCode = {
|
|
|
337
342
|
replaced: 4409,
|
|
338
343
|
protocolError: 4400,
|
|
339
344
|
streamGone: 4410,
|
|
345
|
+
/** A desktop's computer used up its daily traffic; it may connect again after midnight UTC. */
|
|
346
|
+
quota: 4429,
|
|
340
347
|
} ;
|
|
341
348
|
|
|
342
349
|
/**
|
package/src/relay/config.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Reads ~/.config/replica-relay/relay.env (
|
|
1
|
+
// Reads ~/.config/replica-relay/relay.env (deploy/relay.env.example). Real environment variables win over
|
|
2
2
|
// the file, so a systemd Environment= line or a one-off `RELAY_PORT=… node …` still works.
|
|
3
3
|
import fs from "node:fs";
|
|
4
4
|
import os from "node:os";
|
|
@@ -33,6 +33,13 @@ export function readEnvFile(file ) {
|
|
|
33
33
|
|
|
34
34
|
|
|
35
35
|
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
|
|
36
43
|
|
|
37
44
|
|
|
38
45
|
|
|
@@ -77,6 +84,18 @@ export function allowedIdsReader(config
|
|
|
77
84
|
};
|
|
78
85
|
}
|
|
79
86
|
|
|
87
|
+
function desktopPairingMode(value ) {
|
|
88
|
+
if (value === "open" || value === "owned" || value === "off") return value;
|
|
89
|
+
throw new Error(`RELAY_DESKTOP_PAIRING takes open, owned or off, not "${value}"`);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function positiveNumber(name , value ) {
|
|
93
|
+
if (value === null) return null;
|
|
94
|
+
const number = Number(value);
|
|
95
|
+
if (!(number > 0)) throw new Error(`${name} takes a positive number, not "${value}"`);
|
|
96
|
+
return number;
|
|
97
|
+
}
|
|
98
|
+
|
|
80
99
|
const expandHome = (value ) => (value.startsWith("~/") ? path.join(os.homedir(), value.slice(2)) : value);
|
|
81
100
|
|
|
82
101
|
export function loadRelayConfig(envPath = defaultEnvPath()) {
|
|
@@ -87,11 +106,16 @@ export function loadRelayConfig(envPath = defaultEnvPath()) {
|
|
|
87
106
|
};
|
|
88
107
|
const origin = get("RELAY_PUBLIC_ORIGIN");
|
|
89
108
|
if (origin && !/^https?:\/\/[^/]+\/?$/.test(origin)) throw new Error(`RELAY_PUBLIC_ORIGIN must look like https://host[:port], got ${origin}`);
|
|
109
|
+
const dailyMiB = positiveNumber("RELAY_DESKTOP_DAILY_MIB", get("RELAY_DESKTOP_DAILY_MIB"));
|
|
90
110
|
return {
|
|
91
111
|
listen: get("RELAY_LISTEN") ?? "127.0.0.1",
|
|
92
112
|
port: Number(get("RELAY_PORT") ?? 8790),
|
|
93
113
|
publicOrigin: origin?.replace(/\/$/, "") ?? null,
|
|
94
114
|
trustProxy: get("RELAY_TRUST_PROXY") === "1",
|
|
115
|
+
clientIPHeader: get("RELAY_CLIENT_IP_HEADER"),
|
|
116
|
+
desktopPairing: desktopPairingMode(get("RELAY_DESKTOP_PAIRING") ?? "open"),
|
|
117
|
+
anonymousIdleDays: positiveNumber("RELAY_ANONYMOUS_IDLE_DAYS", get("RELAY_ANONYMOUS_IDLE_DAYS")),
|
|
118
|
+
desktopBytesPerHostPerDay: dailyMiB === null ? null : dailyMiB * 1024 * 1024,
|
|
95
119
|
dataDir: expandHome(get("RELAY_DATA_DIR") ?? "~/.local/share/replica-relay"),
|
|
96
120
|
devLogin: get("RELAY_DEV_LOGIN") === "1",
|
|
97
121
|
githubClientId: get("GITHUB_CLIENT_ID"),
|