replica-bridge 0.3.12 → 0.3.14

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.12",
3
+ "version": "0.3.14",
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": {
@@ -1,13 +1,18 @@
1
1
  // The optional 任务 board (PROTOCOL.md "看板"): reads AgensWork's agens-backend, which only listens on this computer, for
2
2
  // a phone that asked. The bridge does not start the backend or care how it started: it knows a port, and a computer
3
- // where nothing answers there simply has no board. Reads are a short allowlist, and the only background traffic is one
4
- // poll of /v1/changes while a phone is subscribed, because reading the lists counts as the user having seen them.
5
-
3
+ // where nothing answers there simply has no board. Reads and writes are short allowlists, and the only background traffic
4
+ // is the change log (/v1/changes, or its push stream /v1/changes/stream where the backend has one) while a phone is
5
+ // subscribed, because reading the lists counts as the user having seen them. Each subscriber has its own place in the
6
+ // log, so a phone that comes back says where it was and gets only what changed since (0.3.14).
7
+ // The bridge stays thin: it checks the shape of a write and leaves what it means to the backend, whose status and body
8
+ // (REC_NOT_FOUND, REC_STATE_CONFLICT, …) go back to the phone unchanged.
9
+
10
+ import { PACKAGE_VERSION } from "./install.js";
6
11
 
7
12
  /** agens-backend's default port, which its own AGENS_BACKEND_PORT changes. */
8
13
  export const DEFAULT_BOARD_PORT = 4753;
9
- /** agens-backend's own page polls every 10 s. */
10
- const DEFAULT_POLL_SECONDS = 10;
14
+ /** How often the log is read when the backend cannot push it; one indexed query on this computer. */
15
+ const DEFAULT_POLL_SECONDS = 1;
11
16
  /** Past this the backend is treated as gone, so a hung one cannot hold a phone waiting. */
12
17
  const READ_TIMEOUT_MS = 5000;
13
18
  /** Well under RELAY_REPLY_LIMIT, since the bridge sends board answers in one frame. */
@@ -15,19 +20,64 @@ const BODY_MAX_BYTES = 8 * 1024 * 1024;
15
20
  const CHANGES_PAGE = 100;
16
21
  /** Stops a catch-up on a change log that never ends; the backend keeps 30 days of it. */
17
22
  const CATCH_UP_PAGES = 1000;
23
+ /** More changed tasks than this in one event, and the phone reads the whole board instead. */
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"] ;
18
27
 
19
- /** Read-only, and none of them does work on the backend. Not /session, /share, /actions, /assist, /mcp or /search. */
20
- const READS = [/^\/v1\/status$/, /^\/v1\/today$/, /^\/v1\/recommendations$/, /^\/v1\/recommendations\/[A-Za-z0-9_.:-]+$/];
28
+ /** The backend's own cap on a request body (agens-backend src/http.mjs BODY_MAX). */
29
+ export const POST_BODY_MAX_BYTES = 16 * 1024;
30
+ const REC = "[A-Za-z0-9_.:-]+";
21
31
 
22
- /** The path and query to read, or null when it is not on the allowlist. */
23
- export function allowedBoardPath(path ) {
32
+ /**
33
+ * 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.
35
+ */
36
+ const READS = [/^\/v1\/status$/, /^\/v1\/today$/, /^\/v1\/recommendations$/, new RegExp(`^/v1/recommendations/${REC}$`),
37
+ new RegExp(`^/v1/recommendations/${REC}/(share|session)$`), /^\/v1\/me$/];
38
+
39
+ /** POSTs (#367): a task's follow-up actions, cancelling its preparation note, and muting a conversation. Nothing else. */
40
+ const WRITES = [new RegExp(`^/v1/recommendations/${REC}/actions$`), new RegExp(`^/v1/recommendations/${REC}/prep/cancel$`), /^\/v1\/me\/muted$/];
41
+ /** agens-backend src/recs.mjs ACTIONS. */
42
+ export const BOARD_ACTIONS = ["accept", "snooze", "delegate", "dismiss", "done", "reopen"] ;
43
+
44
+ function localTarget(path ) {
24
45
  if (typeof path !== "string" || !path.startsWith("/") || path.startsWith("//") || path.includes("\\")) return null;
25
46
  let target ;
26
47
  try { target = new URL(path, "http://board.invalid"); } catch { return null; }
27
- if (target.origin !== "http://board.invalid" || !READS.some((read) => read.test(target.pathname))) return null;
48
+ return target.origin === "http://board.invalid" ? target : null;
49
+ }
50
+
51
+ /** The path and query to read, or null when it is not on the allowlist. */
52
+ export function allowedBoardPath(path ) {
53
+ const target = localTarget(path);
54
+ if (!target || !READS.some((read) => read.test(target.pathname))) return null;
28
55
  return target.pathname + target.search;
29
56
  }
30
57
 
58
+ /**
59
+ * The path and body to POST, or why not. The path is one of WRITES with no query; the body is JSON text of an object
60
+ * within the backend's size cap (absent or empty means `{}`, for prep/cancel), and on /actions names a known action.
61
+ */
62
+ export function allowedBoardPost(path , body ) {
63
+ const target = localTarget(path);
64
+ if (!target || target.search || !WRITES.some((write) => write.test(target.pathname))) return { error: "not_allowed" };
65
+ if (body === undefined || body === "") body = "{}";
66
+ if (typeof body !== "string") return { error: "bad_body" };
67
+ if (Buffer.byteLength(body, "utf8") > POST_BODY_MAX_BYTES) return { error: "too_large" };
68
+ let value ;
69
+ try { value = JSON.parse(body); } catch { return { error: "bad_body" }; }
70
+ if (!value || typeof value !== "object" || Array.isArray(value)) return { error: "bad_body" };
71
+ if (target.pathname.endsWith("/actions")) {
72
+ const action = (value ).action;
73
+ if (typeof action !== "string" || !(BOARD_ACTIONS ).includes(action)) return { error: "not_allowed" };
74
+ }
75
+ const sent = JSON.stringify(value);
76
+ // Rewriting can lengthen it (1e5 becomes 100000), and the backend counts what it receives.
77
+ if (Buffer.byteLength(sent, "utf8") > POST_BODY_MAX_BYTES) return { error: "too_large" };
78
+ return { path: target.pathname, body: sent };
79
+ }
80
+
31
81
  /** The port from AGENS_BACKEND_PORT, the backend's own setting, when it names a real one. */
32
82
  export function boardPortFrom(env = process.env) {
33
83
  const port = Number(env.AGENS_BACKEND_PORT);
@@ -39,33 +89,79 @@ export function boardPortFrom(env = process.env) {
39
89
 
40
90
 
41
91
 
92
+
93
+
94
+ /** One subscriber and how far through the change log it has been told. */
95
+
96
+
97
+
98
+
99
+
100
+
101
+
102
+
103
+
104
+ /** Changes as a phone gets them: one per task, the latest, or a reset when there are too many. */
105
+ function changedEvent(items ) {
106
+ const latest = new Map ();
107
+ for (const item of items) { latest.delete(item.recId); latest.set(item.recId, item); }
108
+ return latest.size > CHANGED_ITEMS_MAX ? { t: "board/changed", reset: true } : { t: "board/changed", items: [...latest.values()] };
109
+ }
110
+
111
+ function changeOf(value ) {
112
+ const item = value ;
113
+ if (!item || !Number.isSafeInteger(item.seq) || typeof item.recId !== "string" || typeof item.kind !== "string") return null;
114
+ return { seq: item.seq , recId: item.recId, kind: item.kind };
115
+ }
116
+
42
117
  export class BoardReader {
43
118
  port ;
44
119
  pollMs ;
45
- listeners = new Set ();
120
+ subs = new Set ();
46
121
  timer = null;
47
122
  polling = false;
48
- /** Where the change log was read to; null until a poll has found its end. */
49
- after = null;
123
+ /** Where the change log was read to; null until a read has found its end. Kept while the backend is away. */
124
+ end = null;
50
125
  up = null;
51
- /** Bumped when polling stops, so a poll still on its way does not speak for the next round. */
126
+ /** Bumped when polling stops, so a read still on its way does not speak for the next round. */
52
127
  round = 0;
128
+ /** The backend's push stream: open, unknown (try it), or not there (an older backend answers 404). */
129
+ stream = null;
130
+ streamSupported = null;
53
131
 
54
132
  constructor(options = {}) {
55
133
  this.port = options.port ?? boardPortFrom();
56
134
  this.pollMs = (options.pollSeconds ?? DEFAULT_POLL_SECONDS) * 1000;
57
135
  }
58
136
 
137
+ /** How many phones are subscribed. */
138
+ get listeners() { return this.subs; }
139
+ /** Whether the backend is pushing changes now, rather than being polled. */
140
+ get streaming() { return this.stream !== null; }
141
+
59
142
  /** The fetch adds the Host header the backend insists on (127.0.0.1:<port>) and no Origin, as a local caller. */
60
- fetch(pathAndQuery ) {
61
- return fetch(`http://127.0.0.1:${this.port}${pathAndQuery}`, { redirect: "error", signal: AbortSignal.timeout(READ_TIMEOUT_MS) });
143
+ fetch(pathAndQuery , post , signal = AbortSignal.timeout(READ_TIMEOUT_MS)) {
144
+ return fetch(`http://127.0.0.1:${this.port}${pathAndQuery}`, {
145
+ redirect: "error", signal,
146
+ ...(post === undefined ? {} : { method: "POST", headers: { "content-type": "application/json" }, body: post }),
147
+ });
62
148
  }
63
149
 
64
- async read(path ) {
150
+ read(path ) {
65
151
  const allowed = allowedBoardPath(path);
66
- if (!allowed) return { error: "not_allowed" };
152
+ return allowed ? this.answer(allowed) : Promise.resolve({ error: "not_allowed" });
153
+ }
154
+
155
+ /** Sent once and never retried: a write that timed out may still have happened, so the phone reads the task again. */
156
+ post(path , body ) {
157
+ const allowed = allowedBoardPost(path, body);
158
+ return "error" in allowed ? Promise.resolve(allowed) : this.answer(allowed.path, allowed.body);
159
+ }
160
+
161
+ /** The backend's status and body as they are, errors included. */
162
+ async answer(pathAndQuery , post ) {
67
163
  try {
68
- const response = await this.fetch(allowed);
164
+ const response = await this.fetch(pathAndQuery, post);
69
165
  const body = await response.text();
70
166
  if (Buffer.byteLength(body, "utf8") > BODY_MAX_BYTES) return { error: "too_large" };
71
167
  return { status: response.status, body };
@@ -74,72 +170,188 @@ export class BoardReader {
74
170
  }
75
171
  }
76
172
 
77
- /** Tells `listener` about the board until the returned function is called. Polls only while someone listens. */
78
- subscribe(listener ) {
79
- this.listeners.add(listener);
80
- if (this.up !== null) listener({ t: "board/state", up: this.up });
173
+ /**
174
+ * Tells `listener` about the board until the returned function is called. Its first board/state carries `seq`, the
175
+ * point in the change log it starts from; with `after` (where the phone was) it then gets what changed since, else it
176
+ * reads the whole board. The log is read only while someone listens.
177
+ */
178
+ subscribe(listener , after ) {
179
+ const sub = { listener, wanted: Number.isSafeInteger(after) && after >= 0 ? after : null, cursor: null, admitting: false };
180
+ this.subs.add(sub);
181
+ if (this.up === false) listener(this.state(false, null));
81
182
  if (!this.timer) {
82
183
  this.timer = setInterval(() => void this.poll(), this.pollMs);
83
184
  this.timer.unref();
84
185
  void this.poll();
85
- }
186
+ } else if (this.up && this.end !== null) void this.admit(sub);
86
187
  return () => {
87
- this.listeners.delete(listener);
88
- if (!this.listeners.size) this.stop();
188
+ this.subs.delete(sub);
189
+ if (!this.subs.size) this.stop();
89
190
  };
90
191
  }
91
192
 
92
193
  stop() {
93
- this.listeners.clear();
194
+ this.subs.clear();
94
195
  if (this.timer) clearInterval(this.timer);
95
196
  this.timer = null;
96
- this.after = null;
197
+ this.stream?.abort();
198
+ this.stream = null;
199
+ this.streamSupported = null;
200
+ this.end = null;
97
201
  this.up = null;
98
202
  this.polling = false;
99
203
  this.round++;
100
204
  }
101
205
 
102
- emit(event ) {
103
- for (const listener of this.listeners) listener(event);
206
+ state(up , seq ) {
207
+ return up && seq !== null ? { t: "board/state", up, seq, caps: [...BOARD_CAPS], bridge: PACKAGE_VERSION } : { t: "board/state", up };
104
208
  }
105
209
 
106
- async changes(after ) {
210
+ async changes(after ) {
107
211
  const response = await this.fetch(`/v1/changes?after=${after}&limit=${CHANGES_PAGE}`);
108
212
  if (!response.ok) throw new Error(`HTTP ${response.status}`);
109
213
  const page = await response.json() ;
110
214
  if (!Array.isArray(page.items)) throw new Error("not a page of changes");
111
- return { items: page.items, next: typeof page.next === "number" ? page.next : after };
215
+ const items = page.items.map(changeOf).filter((item) => item !== null);
216
+ return { items, next: typeof page.next === "number" ? page.next : after };
217
+ }
218
+
219
+ /** Brings a new subscriber to the end of the log: its board/state, then what it missed, or a reset. */
220
+ async admit(sub ) {
221
+ if (sub.admitting || sub.cursor !== null || this.end === null) return;
222
+ sub.admitting = true;
223
+ const round = this.round;
224
+ const missed = [];
225
+ let reset = false, cursor = sub.wanted;
226
+ try {
227
+ if (cursor !== null && cursor > this.end) { reset = true; cursor = this.end; } // a log that went backwards: another backend, or a new database
228
+ // Read until this subscriber is at least where the shared reader is, so nothing falls between the two.
229
+ for (let page = 0; !reset && cursor !== null && cursor < this.end && page < CATCH_UP_PAGES; page++) {
230
+ const { items, next } = await this.changes(cursor);
231
+ if (round !== this.round || !this.subs.has(sub)) return;
232
+ // The log keeps 30 days: a first change past the next number means some were deleted unseen.
233
+ if (page === 0 && items.length && items[0].seq > cursor + 1) reset = true;
234
+ missed.push(...items);
235
+ if (missed.length > CHANGED_ITEMS_MAX * 4) reset = true;
236
+ if (next === cursor && items.length === 0) break;
237
+ cursor = next;
238
+ }
239
+ } catch {
240
+ sub.admitting = false; // the next read of the log tries again
241
+ return;
242
+ }
243
+ if (round !== this.round || !this.subs.has(sub)) return;
244
+ sub.admitting = false;
245
+ sub.cursor = Math.max(cursor ?? this.end , this.end );
246
+ sub.listener(this.state(true, sub.cursor));
247
+ if (reset) sub.listener({ t: "board/changed", reset: true });
248
+ else if (missed.length) sub.listener(changedEvent(missed));
249
+ }
250
+
251
+ /** New changes up to `end`: each subscriber gets those past its own place. */
252
+ deliver(items , end ) {
253
+ for (const sub of this.subs) {
254
+ if (sub.cursor === null) continue;
255
+ const fresh = items.filter((item) => item.seq > sub.cursor );
256
+ sub.cursor = Math.max(sub.cursor, end);
257
+ if (fresh.length) sub.listener(changedEvent(fresh));
258
+ }
259
+ }
260
+
261
+ setUp(up ) {
262
+ if (up === this.up) return;
263
+ this.up = up;
264
+ for (const sub of this.subs) {
265
+ if (!up) sub.listener(this.state(false, null));
266
+ else if (sub.cursor !== null) sub.listener(this.state(true, sub.cursor));
267
+ }
112
268
  }
113
269
 
114
270
  async poll() {
115
- if (this.polling) return;
271
+ if (this.polling || this.stream) return;
116
272
  this.polling = true;
117
273
  const round = this.round;
118
- let changed = false, up = true, after = this.after;
274
+ let items = [], up = true, end = this.end;
119
275
  try {
120
- // The first poll only finds the end of the log: what happened before someone was listening is not news.
121
- if (after === null) {
122
- after = 0;
276
+ // The first read only finds the end of the log: what happened before someone was listening is not news.
277
+ if (end === null) {
278
+ end = 0;
123
279
  for (let page = 0; page < CATCH_UP_PAGES; page++) {
124
- const { items, next } = await this.changes(after);
125
- after = next;
126
- if (items.length < CHANGES_PAGE) break;
280
+ const { items: found, next } = await this.changes(end);
281
+ end = next;
282
+ if (found.length < CHANGES_PAGE) break;
127
283
  }
128
284
  } else {
129
- const { items, next } = await this.changes(after);
130
- after = next;
131
- changed = items.length > 0;
285
+ const page = await this.changes(end);
286
+ items = page.items;
287
+ end = page.next;
132
288
  }
133
289
  } catch {
134
290
  up = false;
135
291
  }
136
292
  if (round !== this.round) return;
137
293
  this.polling = false;
138
- if (up) this.after = after;
139
- if (up !== this.up) {
140
- this.up = up;
141
- this.emit({ t: "board/state", up });
294
+ if (up) this.end = end;
295
+ this.setUp(up);
296
+ if (!up) return;
297
+ if (items.length) this.deliver(items, end );
298
+ for (const sub of this.subs) if (sub.cursor === null) void this.admit(sub);
299
+ if (this.streamSupported !== false) void this.openStream(round);
300
+ }
301
+
302
+ /**
303
+ * Listens to the backend's push stream from where the log was read to. A backend without one answers 404 (or not as
304
+ * an event stream), and the bridge keeps polling; a stream that ends hands back to polling, which reopens it.
305
+ */
306
+ async openStream(round ) {
307
+ if (this.stream || this.end === null) return;
308
+ const controller = new AbortController();
309
+ this.stream = controller;
310
+ let response ;
311
+ try {
312
+ response = await this.fetch(`/v1/changes/stream?after=${this.end}`, undefined, controller.signal);
313
+ } catch {
314
+ if (this.stream === controller) this.stream = null;
315
+ return;
142
316
  }
143
- if (changed) this.emit({ t: "board/changed" });
317
+ if (round !== this.round || this.stream !== controller) { controller.abort(); return; }
318
+ if (!response.ok || !(response.headers.get("content-type") ?? "").startsWith("text/event-stream") || !response.body) {
319
+ this.streamSupported = false;
320
+ this.stream = null;
321
+ controller.abort();
322
+ return;
323
+ }
324
+ this.streamSupported = true;
325
+ let buffer = "", pending = [], flush = null;
326
+ const send = () => {
327
+ flush = null;
328
+ if (round !== this.round || this.stream !== controller) return;
329
+ const batch = pending;
330
+ pending = [];
331
+ const end = Math.max(this.end ?? 0, ...batch.map((item) => item.seq));
332
+ this.end = end;
333
+ this.deliver(batch, end);
334
+ };
335
+ try {
336
+ const decoder = new TextDecoder();
337
+ for await (const chunk of response.body ) {
338
+ buffer += decoder.decode(chunk, { stream: true });
339
+ let split ;
340
+ while ((split = buffer.search(/\r?\n\r?\n/)) >= 0) {
341
+ const event = buffer.slice(0, split);
342
+ buffer = buffer.slice(split).replace(/^\r?\n\r?\n/, "");
343
+ const data = event.split(/\r?\n/).filter((line) => line.startsWith("data:")).map((line) => line.slice(5).trimStart()).join("\n");
344
+ if (!data) continue; // a heartbeat
345
+ let item = null;
346
+ try { item = changeOf(JSON.parse(data)); } catch { /* not a change */ }
347
+ if (!item || item.seq <= (this.end ?? 0)) continue;
348
+ pending.push(item);
349
+ // Changes that land together (a maintenance round) go to the phone as one event.
350
+ flush ??= setTimeout(send, 20);
351
+ }
352
+ }
353
+ } catch { /* aborted, or the backend went away */ }
354
+ if (flush) { clearTimeout(flush); send(); }
355
+ if (this.stream === controller) this.stream = null;
144
356
  }
145
357
  }
@@ -269,8 +269,10 @@ export class BridgeService {
269
269
  } else if (message.t === "board/get") {
270
270
  // Not awaited: a slow backend should not hold up the frames behind this one.
271
271
  void this.board.read(message.path).then((answer) => this.sendInner(conn, { t: "board/res", id: message.id, ...answer }));
272
+ } else if (message.t === "board/post") {
273
+ void this.board.post(message.path, message.body).then((answer) => this.sendInner(conn, { t: "board/res", id: message.id, ...answer }));
272
274
  } else if (message.t === "board/sub") {
273
- conn.boardUnsubscribe ??= this.board.subscribe((event) => this.sendInner(conn, event));
275
+ conn.boardUnsubscribe ??= this.board.subscribe((event) => this.sendInner(conn, event), message.after);
274
276
  } else if (message.t === "board/unsub") {
275
277
  conn.boardUnsubscribe?.();
276
278
  conn.boardUnsubscribe = null;
package/src/protocol.js CHANGED
@@ -239,14 +239,23 @@ export class FrameCipher {
239
239
 
240
240
 
241
241
 
242
+
242
243
 
243
-
244
+
244
245
 
245
246
 
246
247
 
247
- /** A read of the optional board: the backend's status and body, or why there is none (PROTOCOL.md "看板"). */
248
-
249
-
248
+ /** A read or write of the optional board: the backend's status and body, or why there is none (PROTOCOL.md "看板"). */
249
+
250
+ /** One task that changed, from agens-backend's change log (0.3.14). */
251
+
252
+ /**
253
+ * 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
+ * starts), `caps` and the bridge's version, and board/changed names the tasks, or says `reset` when there are too many.
255
+ */
256
+
257
+
258
+
250
259
 
251
260
 
252
261
  /** One segment of a long line: `data` is base64 of bytes [segment * chunk size, …) of the payload, `size` its length. */