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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "replica-bridge",
3
- "version": "0.3.18",
3
+ "version": "0.3.20",
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": {
@@ -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
- * Not /assist, /mcp, /search, /context, /changes (the bridge's own poll) or anything under /ui.
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
 
@@ -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";
@@ -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 (Relay/PROTOCOL.md).
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
  /**
@@ -1,4 +1,4 @@
1
- // Reads ~/.config/replica-relay/relay.env (Relay/deploy/relay.env.example). Real environment variables win over
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"),