parawatch 0.3.15 → 0.3.17
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/README.md +4 -0
- package/dist/adapters/claudeCode.js +19 -1
- package/dist/adapters/codex.js +18 -0
- package/dist/bin/cli.js +108 -2
- package/dist/core/codexChat.js +6 -2
- package/dist/core/format.js +2 -1
- package/dist/core/github.js +25 -7
- package/dist/core/log.js +69 -0
- package/dist/core/model.js +18 -0
- package/dist/core/relay.js +474 -0
- package/dist/core/remoteChat.js +212 -0
- package/dist/core/store.js +8 -44
- package/dist/core/sync.js +2 -0
- package/dist/relay/server.js +234 -0
- package/package.json +2 -2
- package/skills/parawatch/SKILL.md +1 -0
|
@@ -0,0 +1,474 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Keeping the shared folder in step through a relay server (src/relay/server.ts) instead of a sync
|
|
3
|
+
* tool. The folder stays where it is on every machine; this mirror uploads what changed here and
|
|
4
|
+
* writes what changed elsewhere, file by file.
|
|
5
|
+
*
|
|
6
|
+
* Everything is sealed on the machine with the space key, which the server never sees: a file's
|
|
7
|
+
* path and its bytes go in one AES-256-GCM box, and the server knows it only by a keyed hash of the
|
|
8
|
+
* path. The server sees how many files there are, their sizes and when they change.
|
|
9
|
+
*
|
|
10
|
+
* The join code carries the server, the space, its token and the key. Anyone with it can read and
|
|
11
|
+
* write the space, so it is passed between one's own machines by hand.
|
|
12
|
+
*/
|
|
13
|
+
import crypto from "node:crypto";
|
|
14
|
+
import fs from "node:fs";
|
|
15
|
+
import path from "node:path";
|
|
16
|
+
import { UserError } from "./errors.js";
|
|
17
|
+
import { homeDir, readConfig } from "./store.js";
|
|
18
|
+
import { findConflicts, folderPeers } from "./sync.js";
|
|
19
|
+
const CODE_PREFIX = "pwr1.";
|
|
20
|
+
/** Files bigger than this stay on their machine (a long screen recording, say). */
|
|
21
|
+
export const MIRROR_MAX_FILE = 200 * 1024 * 1024;
|
|
22
|
+
function keysOf(key) {
|
|
23
|
+
const raw = Buffer.from(key, "base64url");
|
|
24
|
+
if (raw.length !== 32)
|
|
25
|
+
throw new UserError("the relay key is not 32 bytes; join again with a fresh code");
|
|
26
|
+
const derive = (info) => Buffer.from(crypto.hkdfSync("sha256", raw, Buffer.alloc(0), info, 32));
|
|
27
|
+
return { enc: derive("parawatch relay seal v1"), name: derive("parawatch relay name v1") };
|
|
28
|
+
}
|
|
29
|
+
/** The name the server knows a path by. */
|
|
30
|
+
export function sealedName(keys, rel) {
|
|
31
|
+
return crypto.createHmac("sha256", keys.name).update(rel).digest("hex").slice(0, 40);
|
|
32
|
+
}
|
|
33
|
+
/** One box: version, nonce, tag, then the path and the bytes, encrypted together. */
|
|
34
|
+
export function seal(keys, rel, data) {
|
|
35
|
+
const iv = crypto.randomBytes(12);
|
|
36
|
+
const c = crypto.createCipheriv("aes-256-gcm", keys.enc, iv);
|
|
37
|
+
const p = Buffer.from(rel, "utf8");
|
|
38
|
+
const len = Buffer.alloc(4);
|
|
39
|
+
len.writeUInt32BE(p.length);
|
|
40
|
+
const body = Buffer.concat([c.update(len), c.update(p), c.update(data), c.final()]);
|
|
41
|
+
return Buffer.concat([Buffer.from([1]), iv, c.getAuthTag(), body]);
|
|
42
|
+
}
|
|
43
|
+
export function open(keys, box) {
|
|
44
|
+
if (box.length < 29 || box[0] !== 1)
|
|
45
|
+
throw new Error("not a sealed parawatch file");
|
|
46
|
+
const d = crypto.createDecipheriv("aes-256-gcm", keys.enc, box.subarray(1, 13));
|
|
47
|
+
d.setAuthTag(box.subarray(13, 29));
|
|
48
|
+
const plain = Buffer.concat([d.update(box.subarray(29)), d.final()]);
|
|
49
|
+
const n = plain.readUInt32BE(0);
|
|
50
|
+
return { rel: plain.subarray(4, 4 + n).toString("utf8"), data: plain.subarray(4 + n) };
|
|
51
|
+
}
|
|
52
|
+
export function newKey() {
|
|
53
|
+
return crypto.randomBytes(32).toString("base64url");
|
|
54
|
+
}
|
|
55
|
+
// ------------------------------------------------------------------ join codes
|
|
56
|
+
export function joinCode(c) {
|
|
57
|
+
return CODE_PREFIX + Buffer.from(JSON.stringify({ u: c.url, s: c.space, t: c.token, k: c.key })).toString("base64url");
|
|
58
|
+
}
|
|
59
|
+
export function readJoinCode(code) {
|
|
60
|
+
const t = code.trim();
|
|
61
|
+
if (!t.startsWith(CODE_PREFIX))
|
|
62
|
+
throw new UserError("that is not a parawatch relay code (it starts with pwr1.)");
|
|
63
|
+
try {
|
|
64
|
+
const o = JSON.parse(Buffer.from(t.slice(CODE_PREFIX.length), "base64url").toString("utf8"));
|
|
65
|
+
if (typeof o.u !== "string" || typeof o.s !== "string" || typeof o.t !== "string" || typeof o.k !== "string")
|
|
66
|
+
throw new Error("missing parts");
|
|
67
|
+
keysOf(o.k);
|
|
68
|
+
return { url: o.u.replace(/\/+$/, ""), space: o.s, token: o.t, key: o.k };
|
|
69
|
+
}
|
|
70
|
+
catch (err) {
|
|
71
|
+
if (err instanceof UserError)
|
|
72
|
+
throw err;
|
|
73
|
+
throw new UserError("that relay code is cut short or changed; copy it again");
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
/** The relay this machine uses, from config.json, when its sync adapter is the relay. */
|
|
77
|
+
export function relayConfig() {
|
|
78
|
+
const s = readConfig().sync;
|
|
79
|
+
if (s?.adapter !== "relay")
|
|
80
|
+
return undefined;
|
|
81
|
+
const r = s.relay;
|
|
82
|
+
return r && r.url && r.space && r.token && r.key ? r : undefined;
|
|
83
|
+
}
|
|
84
|
+
export class RelayClient {
|
|
85
|
+
c;
|
|
86
|
+
f;
|
|
87
|
+
constructor(c, f = fetch) {
|
|
88
|
+
this.c = c;
|
|
89
|
+
this.f = f;
|
|
90
|
+
}
|
|
91
|
+
base() {
|
|
92
|
+
return `${this.c.url}/v1/spaces/${this.c.space}`;
|
|
93
|
+
}
|
|
94
|
+
async call(pathname, init = {}, signal) {
|
|
95
|
+
const res = await this.f(`${this.base()}${pathname}`, { ...init, signal, headers: { ...(init.headers ?? {}), authorization: `Bearer ${this.c.token}` } });
|
|
96
|
+
if (!res.ok) {
|
|
97
|
+
let why = `${res.status}`;
|
|
98
|
+
try {
|
|
99
|
+
why = (await res.json()).error ?? why;
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
// not JSON
|
|
103
|
+
}
|
|
104
|
+
throw new Error(`relay ${init.method ?? "GET"} ${pathname.split("/")[1]}: ${why}`);
|
|
105
|
+
}
|
|
106
|
+
return res;
|
|
107
|
+
}
|
|
108
|
+
async changes(after, wait = 0, signal) {
|
|
109
|
+
return (await (await this.call(`/changes?after=${after}&wait=${wait}`, {}, signal)).json());
|
|
110
|
+
}
|
|
111
|
+
async get(name) {
|
|
112
|
+
return Buffer.from(await (await this.call(`/files/${name}`)).arrayBuffer());
|
|
113
|
+
}
|
|
114
|
+
async put(name, body) {
|
|
115
|
+
return (await (await this.call(`/files/${name}`, { method: "PUT", body: new Uint8Array(body), headers: { "content-type": "application/octet-stream" } })).json()).seq;
|
|
116
|
+
}
|
|
117
|
+
async remove(name) {
|
|
118
|
+
return (await (await this.call(`/files/${name}`, { method: "DELETE" })).json()).seq;
|
|
119
|
+
}
|
|
120
|
+
static async createSpace(url, invite, f = fetch) {
|
|
121
|
+
const res = await f(`${url.replace(/\/+$/, "")}/v1/spaces`, { method: "POST", headers: { authorization: `Bearer ${invite}` } });
|
|
122
|
+
if (!res.ok) {
|
|
123
|
+
let why = `${res.status}`;
|
|
124
|
+
try {
|
|
125
|
+
why = (await res.json()).error ?? why;
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
// not JSON
|
|
129
|
+
}
|
|
130
|
+
throw new UserError(`the relay would not make a space: ${why}`);
|
|
131
|
+
}
|
|
132
|
+
return (await res.json());
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
export function mirrorStatePath() {
|
|
136
|
+
return path.join(homeDir(), "relay-state.json");
|
|
137
|
+
}
|
|
138
|
+
export function readMirrorState(space, file = mirrorStatePath()) {
|
|
139
|
+
try {
|
|
140
|
+
const s = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
141
|
+
if (s.v === 1 && s.space === space && s.files)
|
|
142
|
+
return s;
|
|
143
|
+
}
|
|
144
|
+
catch {
|
|
145
|
+
// none yet
|
|
146
|
+
}
|
|
147
|
+
return { v: 1, space, cursor: 0, files: {} };
|
|
148
|
+
}
|
|
149
|
+
/** Files the mirror leaves alone: hidden and temporary files, and copies a sync tool made. */
|
|
150
|
+
function ignored(rel) {
|
|
151
|
+
return rel.split("/").some((p) => p.startsWith(".") || p.endsWith(".tmp") || /\.sync-conflict-/.test(p) || p === "~syncthing~");
|
|
152
|
+
}
|
|
153
|
+
/** Is `rel` a safe relative path inside the folder? A sealed file names its own path. */
|
|
154
|
+
function safeRel(rel) {
|
|
155
|
+
return !!rel && !rel.startsWith("/") && !/^[a-zA-Z]:/.test(rel) && !rel.split("/").some((p) => p === ".." || p === "" || p === ".") && !rel.includes("\\");
|
|
156
|
+
}
|
|
157
|
+
const hashOf = (b) => crypto.createHash("sha256").update(b).digest("hex");
|
|
158
|
+
export class RelayMirror {
|
|
159
|
+
o;
|
|
160
|
+
keys;
|
|
161
|
+
client;
|
|
162
|
+
state;
|
|
163
|
+
names = new Map();
|
|
164
|
+
stopped = false;
|
|
165
|
+
abort;
|
|
166
|
+
pushing = false;
|
|
167
|
+
constructor(o) {
|
|
168
|
+
this.o = o;
|
|
169
|
+
this.keys = keysOf(o.config.key);
|
|
170
|
+
this.client = new RelayClient(o.config, o.fetch);
|
|
171
|
+
this.state = readMirrorState(o.config.space, o.stateFile);
|
|
172
|
+
for (const rel of Object.keys(this.state.files))
|
|
173
|
+
this.names.set(sealedName(this.keys, rel), rel);
|
|
174
|
+
}
|
|
175
|
+
save() {
|
|
176
|
+
try {
|
|
177
|
+
const file = this.o.stateFile ?? mirrorStatePath();
|
|
178
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
179
|
+
fs.writeFileSync(`${file}.tmp`, JSON.stringify(this.state) + "\n");
|
|
180
|
+
fs.renameSync(`${file}.tmp`, file);
|
|
181
|
+
}
|
|
182
|
+
catch {
|
|
183
|
+
// kept in memory; the next save writes it
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
log(line) {
|
|
187
|
+
this.o.log?.(line);
|
|
188
|
+
}
|
|
189
|
+
/** Every file in the folder, as relative paths with forward slashes. */
|
|
190
|
+
scan() {
|
|
191
|
+
const out = new Map();
|
|
192
|
+
const walk = (dir, rel) => {
|
|
193
|
+
let entries = [];
|
|
194
|
+
try {
|
|
195
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
196
|
+
}
|
|
197
|
+
catch {
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
for (const e of entries) {
|
|
201
|
+
const r = rel ? `${rel}/${e.name}` : e.name;
|
|
202
|
+
if (ignored(r))
|
|
203
|
+
continue;
|
|
204
|
+
const p = path.join(dir, e.name);
|
|
205
|
+
if (e.isDirectory())
|
|
206
|
+
walk(p, r);
|
|
207
|
+
else if (e.isFile()) {
|
|
208
|
+
try {
|
|
209
|
+
const st = fs.statSync(p);
|
|
210
|
+
out.set(r, { size: st.size, mtime: st.mtimeMs });
|
|
211
|
+
}
|
|
212
|
+
catch {
|
|
213
|
+
// gone meanwhile
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
};
|
|
218
|
+
walk(this.o.dir, "");
|
|
219
|
+
return out;
|
|
220
|
+
}
|
|
221
|
+
/** Bring down what changed on the server since the last look; `wait` holds until something does. */
|
|
222
|
+
async pull(wait = 0) {
|
|
223
|
+
this.abort = new AbortController();
|
|
224
|
+
const { head, changes } = await this.client.changes(this.state.cursor, wait, this.abort.signal);
|
|
225
|
+
let n = 0;
|
|
226
|
+
for (const ch of changes) {
|
|
227
|
+
const known = this.names.get(ch.name);
|
|
228
|
+
if (ch.deleted) {
|
|
229
|
+
if (known) {
|
|
230
|
+
const p = path.join(this.o.dir, ...known.split("/"));
|
|
231
|
+
const k = this.state.files[known];
|
|
232
|
+
// only a file this machine has not changed since goes away
|
|
233
|
+
try {
|
|
234
|
+
const buf = fs.readFileSync(p);
|
|
235
|
+
if (k && hashOf(buf) === k.hash)
|
|
236
|
+
fs.rmSync(p, { force: true });
|
|
237
|
+
}
|
|
238
|
+
catch {
|
|
239
|
+
// already gone
|
|
240
|
+
}
|
|
241
|
+
delete this.state.files[known];
|
|
242
|
+
this.names.delete(ch.name);
|
|
243
|
+
n++;
|
|
244
|
+
}
|
|
245
|
+
continue;
|
|
246
|
+
}
|
|
247
|
+
if ((ch.size ?? 0) > MIRROR_MAX_FILE + 1024)
|
|
248
|
+
continue;
|
|
249
|
+
let rel;
|
|
250
|
+
let data;
|
|
251
|
+
try {
|
|
252
|
+
({ rel, data } = open(this.keys, await this.client.get(ch.name)));
|
|
253
|
+
}
|
|
254
|
+
catch (err) {
|
|
255
|
+
this.log(`could not open a file from the relay: ${err.message}`);
|
|
256
|
+
continue;
|
|
257
|
+
}
|
|
258
|
+
if (!safeRel(rel) || ignored(rel) || sealedName(this.keys, rel) !== ch.name) {
|
|
259
|
+
this.log(`left out a file with a path it may not have: ${JSON.stringify(rel)}`);
|
|
260
|
+
continue;
|
|
261
|
+
}
|
|
262
|
+
const p = path.join(this.o.dir, ...rel.split("/"));
|
|
263
|
+
const hash = hashOf(data);
|
|
264
|
+
const k = this.state.files[rel];
|
|
265
|
+
let here;
|
|
266
|
+
try {
|
|
267
|
+
here = fs.readFileSync(p);
|
|
268
|
+
}
|
|
269
|
+
catch {
|
|
270
|
+
// not here yet
|
|
271
|
+
}
|
|
272
|
+
const hereHash = here ? hashOf(here) : undefined;
|
|
273
|
+
// a change made here that has not gone up yet wins; it goes up on the next push
|
|
274
|
+
if (here && k && hereHash !== k.hash && hereHash !== hash)
|
|
275
|
+
continue;
|
|
276
|
+
if (hereHash !== hash) {
|
|
277
|
+
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
278
|
+
const tmp = path.join(path.dirname(p), `.${path.basename(p)}.relay.tmp`);
|
|
279
|
+
fs.writeFileSync(tmp, data);
|
|
280
|
+
fs.renameSync(tmp, p);
|
|
281
|
+
n++;
|
|
282
|
+
}
|
|
283
|
+
const st = fs.statSync(p);
|
|
284
|
+
this.state.files[rel] = { hash, size: st.size, mtime: st.mtimeMs };
|
|
285
|
+
this.names.set(ch.name, rel);
|
|
286
|
+
}
|
|
287
|
+
this.state.cursor = head;
|
|
288
|
+
this.state.lastOk = new Date().toISOString();
|
|
289
|
+
this.state.error = undefined;
|
|
290
|
+
this.save();
|
|
291
|
+
return n;
|
|
292
|
+
}
|
|
293
|
+
/** Send up what changed here since the last look. */
|
|
294
|
+
async push() {
|
|
295
|
+
if (this.pushing)
|
|
296
|
+
return 0;
|
|
297
|
+
this.pushing = true;
|
|
298
|
+
try {
|
|
299
|
+
if (!fs.existsSync(this.o.dir))
|
|
300
|
+
throw new Error(`the shared folder ${this.o.dir} is not there`);
|
|
301
|
+
const seen = this.scan();
|
|
302
|
+
let n = 0;
|
|
303
|
+
let pending = 0;
|
|
304
|
+
const skipped = [];
|
|
305
|
+
for (const [rel, st] of seen) {
|
|
306
|
+
const k = this.state.files[rel];
|
|
307
|
+
if (k && k.size === st.size && k.mtime === st.mtime)
|
|
308
|
+
continue;
|
|
309
|
+
if (st.size > MIRROR_MAX_FILE) {
|
|
310
|
+
skipped.push(rel);
|
|
311
|
+
continue;
|
|
312
|
+
}
|
|
313
|
+
let data;
|
|
314
|
+
try {
|
|
315
|
+
data = fs.readFileSync(path.join(this.o.dir, ...rel.split("/")));
|
|
316
|
+
}
|
|
317
|
+
catch {
|
|
318
|
+
continue;
|
|
319
|
+
}
|
|
320
|
+
const hash = hashOf(data);
|
|
321
|
+
if (k && k.hash === hash) {
|
|
322
|
+
this.state.files[rel] = { ...k, size: st.size, mtime: st.mtime };
|
|
323
|
+
continue;
|
|
324
|
+
}
|
|
325
|
+
pending++;
|
|
326
|
+
const name = sealedName(this.keys, rel);
|
|
327
|
+
await this.client.put(name, seal(this.keys, rel, data));
|
|
328
|
+
this.state.files[rel] = { hash, size: st.size, mtime: st.mtime };
|
|
329
|
+
this.names.set(name, rel);
|
|
330
|
+
pending--;
|
|
331
|
+
n++;
|
|
332
|
+
}
|
|
333
|
+
// a folder that reads as empty while files were here is more likely unmounted than emptied
|
|
334
|
+
const vanished = Object.keys(this.state.files).filter((rel) => !seen.has(rel) && !ignored(rel));
|
|
335
|
+
if (vanished.length && !seen.size)
|
|
336
|
+
throw new Error(`the shared folder ${this.o.dir} reads as empty; nothing was deleted on the relay`);
|
|
337
|
+
for (const rel of vanished) {
|
|
338
|
+
const name = sealedName(this.keys, rel);
|
|
339
|
+
await this.client.remove(name);
|
|
340
|
+
delete this.state.files[rel];
|
|
341
|
+
this.names.delete(name);
|
|
342
|
+
n++;
|
|
343
|
+
}
|
|
344
|
+
this.state.pending = pending;
|
|
345
|
+
this.state.skipped = skipped.length ? skipped.slice(0, 20) : undefined;
|
|
346
|
+
if (n)
|
|
347
|
+
this.save();
|
|
348
|
+
return n;
|
|
349
|
+
}
|
|
350
|
+
finally {
|
|
351
|
+
this.pushing = false;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
/** Pull first so a machine joining late takes what is there instead of sending it back. */
|
|
355
|
+
async once() {
|
|
356
|
+
const pulled = await this.pull(0);
|
|
357
|
+
const pushed = await this.push();
|
|
358
|
+
return { pulled, pushed };
|
|
359
|
+
}
|
|
360
|
+
/** Keep the folder in step until stopped: a held request for changes there, a look here every second. */
|
|
361
|
+
async run() {
|
|
362
|
+
let backoff = 1000;
|
|
363
|
+
const pushLoop = async () => {
|
|
364
|
+
while (!this.stopped) {
|
|
365
|
+
try {
|
|
366
|
+
await this.push();
|
|
367
|
+
}
|
|
368
|
+
catch (err) {
|
|
369
|
+
this.fail(err);
|
|
370
|
+
}
|
|
371
|
+
await sleep(1000);
|
|
372
|
+
}
|
|
373
|
+
};
|
|
374
|
+
try {
|
|
375
|
+
await this.pull(0);
|
|
376
|
+
}
|
|
377
|
+
catch (err) {
|
|
378
|
+
this.fail(err);
|
|
379
|
+
}
|
|
380
|
+
void pushLoop();
|
|
381
|
+
while (!this.stopped) {
|
|
382
|
+
try {
|
|
383
|
+
await this.pull(25);
|
|
384
|
+
backoff = 1000;
|
|
385
|
+
}
|
|
386
|
+
catch (err) {
|
|
387
|
+
if (this.stopped)
|
|
388
|
+
break;
|
|
389
|
+
this.fail(err);
|
|
390
|
+
await sleep(backoff);
|
|
391
|
+
backoff = Math.min(backoff * 2, 60_000);
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
fail(err) {
|
|
396
|
+
const msg = err.message ?? String(err);
|
|
397
|
+
if (this.state.error !== msg)
|
|
398
|
+
this.log(`relay: ${msg}`);
|
|
399
|
+
this.state.error = msg;
|
|
400
|
+
this.save();
|
|
401
|
+
}
|
|
402
|
+
stop() {
|
|
403
|
+
this.stopped = true;
|
|
404
|
+
this.abort?.abort();
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
408
|
+
/** Only one mirror per machine: the app and `parawatch relay run` would otherwise race. */
|
|
409
|
+
export function takeMirrorLock() {
|
|
410
|
+
const file = path.join(homeDir(), "relay.pid");
|
|
411
|
+
try {
|
|
412
|
+
const pid = Number(fs.readFileSync(file, "utf8"));
|
|
413
|
+
if (pid && pid !== process.pid) {
|
|
414
|
+
try {
|
|
415
|
+
process.kill(pid, 0);
|
|
416
|
+
return undefined;
|
|
417
|
+
}
|
|
418
|
+
catch (err) {
|
|
419
|
+
if (err.code === "EPERM")
|
|
420
|
+
return undefined;
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
catch {
|
|
425
|
+
// no lock
|
|
426
|
+
}
|
|
427
|
+
fs.mkdirSync(homeDir(), { recursive: true });
|
|
428
|
+
fs.writeFileSync(file, String(process.pid));
|
|
429
|
+
return () => {
|
|
430
|
+
try {
|
|
431
|
+
if (fs.readFileSync(file, "utf8").trim() === String(process.pid))
|
|
432
|
+
fs.rmSync(file, { force: true });
|
|
433
|
+
}
|
|
434
|
+
catch {
|
|
435
|
+
// gone
|
|
436
|
+
}
|
|
437
|
+
};
|
|
438
|
+
}
|
|
439
|
+
// ------------------------------------------------------------------ status
|
|
440
|
+
/** How the mirror is doing, for `parawatch sync`, `doctor` and the app. */
|
|
441
|
+
export const relayAdapter = {
|
|
442
|
+
name: "relay",
|
|
443
|
+
about: "a parawatch relay server, sealed end to end",
|
|
444
|
+
async status(ctx) {
|
|
445
|
+
const c = relayConfig();
|
|
446
|
+
const peers = folderPeers(ctx.dir, ctx.device);
|
|
447
|
+
if (!c)
|
|
448
|
+
return { adapter: "relay", dir: ctx.dir, device: ctx.device, ok: false, peers, conflicts: [], error: "no relay is set up: run `parawatch relay setup` or `parawatch relay join`" };
|
|
449
|
+
const st = readMirrorState(c.space);
|
|
450
|
+
let reachable = true;
|
|
451
|
+
let error = st.error;
|
|
452
|
+
try {
|
|
453
|
+
const res = await (ctx.fetch ?? fetch)(`${c.url}/healthz`, { signal: AbortSignal.timeout(ctx.timeoutMs ?? 3000) });
|
|
454
|
+
reachable = res.ok;
|
|
455
|
+
}
|
|
456
|
+
catch (err) {
|
|
457
|
+
reachable = false;
|
|
458
|
+
error = `cannot reach ${c.url}: ${err.message}`;
|
|
459
|
+
}
|
|
460
|
+
const fresh = !!st.lastOk && Date.now() - Date.parse(st.lastOk) < 2 * 60_000;
|
|
461
|
+
return {
|
|
462
|
+
adapter: "relay",
|
|
463
|
+
dir: ctx.dir,
|
|
464
|
+
device: ctx.device,
|
|
465
|
+
ok: reachable && fresh && !st.error,
|
|
466
|
+
lastSync: st.lastOk,
|
|
467
|
+
peers,
|
|
468
|
+
conflicts: findConflicts(ctx.dir),
|
|
469
|
+
...(st.pending ? { pending: st.pending } : {}),
|
|
470
|
+
...(error ? { error } : !fresh ? { error: "nothing is keeping the folder in step: open the app, or run `parawatch relay run`" } : {}),
|
|
471
|
+
};
|
|
472
|
+
},
|
|
473
|
+
};
|
|
474
|
+
//# sourceMappingURL=relay.js.map
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Codex conversations of another machine. The log and the codex command stay on the machine the
|
|
3
|
+
* session ran on (the host); the machine showing it (the viewer) asks through the shared folder:
|
|
4
|
+
*
|
|
5
|
+
* chats/<viewer>/watching.json the sessions the viewer has open, rewritten while they are
|
|
6
|
+
* chats/<viewer>/asks.jsonl messages to send and stops, written by the viewer only
|
|
7
|
+
* chats/<host>/<session>.json the conversation as the host reads it, rewritten as it changes
|
|
8
|
+
*
|
|
9
|
+
* Every file has one writer, so the sync tool never has two versions of one to reconcile.
|
|
10
|
+
*/
|
|
11
|
+
import crypto from "node:crypto";
|
|
12
|
+
import fs from "node:fs";
|
|
13
|
+
import path from "node:path";
|
|
14
|
+
import { DEVICE_NAME, deviceName, sharedDir } from "./store.js";
|
|
15
|
+
/** How often a viewer says it still has its chats open, and when a host stops serving one. */
|
|
16
|
+
export const WATCH_EVERY_MS = 20_000;
|
|
17
|
+
export const WATCH_GONE_MS = 75_000;
|
|
18
|
+
/** How often a host rewrites a watched chat that did not change, and when the viewer gives up on it. */
|
|
19
|
+
export const HOST_BEAT_MS = 30_000;
|
|
20
|
+
export const HOST_QUIET_MS = 100_000;
|
|
21
|
+
/** A message that took longer than this to reach the host is not sent: the person has moved on. */
|
|
22
|
+
export const ASK_LATE_MS = 2 * 60_000;
|
|
23
|
+
/** How long asks and their results are kept. */
|
|
24
|
+
export const ASK_KEEP_MS = 10 * 60_000;
|
|
25
|
+
/** The most conversation items a host shares; the rest count as earlier. */
|
|
26
|
+
export const SHARED_ITEMS = 150;
|
|
27
|
+
/** The first version whose app answers for its Codex sessions. */
|
|
28
|
+
export const REMOTE_CHAT_SINCE = "0.3.16";
|
|
29
|
+
const SESSION_ID = /^[A-Za-z0-9_-]{1,80}$/;
|
|
30
|
+
export function validSession(id) {
|
|
31
|
+
return SESSION_ID.test(id);
|
|
32
|
+
}
|
|
33
|
+
function chatsDir(shared = sharedDir()) {
|
|
34
|
+
return shared ? path.join(shared, "chats") : undefined;
|
|
35
|
+
}
|
|
36
|
+
function writeAtomic(file, body) {
|
|
37
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
38
|
+
const tmp = path.join(path.dirname(file), `.${path.basename(file)}.${process.pid}.tmp`);
|
|
39
|
+
fs.writeFileSync(tmp, body);
|
|
40
|
+
fs.renameSync(tmp, file);
|
|
41
|
+
}
|
|
42
|
+
function readJson(file) {
|
|
43
|
+
try {
|
|
44
|
+
return JSON.parse(fs.readFileSync(file, "utf8"));
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
return undefined;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
/** The machines with a folder under `chats/`, other than this one. */
|
|
51
|
+
function otherDevices(dir, me) {
|
|
52
|
+
try {
|
|
53
|
+
return fs.readdirSync(dir).filter((n) => DEVICE_NAME.test(n) && n !== me);
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
return [];
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
// ------------------------------------------------------------------ viewer
|
|
60
|
+
/** Say which sessions of other machines this one has open. */
|
|
61
|
+
export function writeWatching(open, now = Date.now(), shared = sharedDir()) {
|
|
62
|
+
const dir = chatsDir(shared);
|
|
63
|
+
if (!dir)
|
|
64
|
+
return;
|
|
65
|
+
const me = deviceName();
|
|
66
|
+
const body = { v: 1, device: me, at: new Date(now).toISOString(), open };
|
|
67
|
+
try {
|
|
68
|
+
writeAtomic(path.join(dir, me, "watching.json"), JSON.stringify(body) + "\n");
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
// the folder may be busy syncing; the next beat writes it again
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
/** Add a message or a stop for a host, dropping asks old enough that no host still needs them. */
|
|
75
|
+
export function appendAsk(a, now = Date.now(), shared = sharedDir()) {
|
|
76
|
+
const dir = chatsDir(shared);
|
|
77
|
+
if (!dir)
|
|
78
|
+
throw new Error("This machine shares no folder with others.");
|
|
79
|
+
const me = deviceName();
|
|
80
|
+
const ask = { id: `${me}-${now.toString(36)}-${crypto.randomBytes(3).toString("hex")}`, at: new Date(now).toISOString(), ...a };
|
|
81
|
+
const file = path.join(dir, me, "asks.jsonl");
|
|
82
|
+
const kept = readAskLines(file).filter((x) => now - Date.parse(x.at) < ASK_KEEP_MS);
|
|
83
|
+
writeAtomic(file, [...kept, ask].map((x) => JSON.stringify(x) + "\n").join(""));
|
|
84
|
+
return ask;
|
|
85
|
+
}
|
|
86
|
+
/** What the host last wrote about a session, if anything. */
|
|
87
|
+
export function readHosted(host, id, shared = sharedDir()) {
|
|
88
|
+
const dir = chatsDir(shared);
|
|
89
|
+
if (!dir || !DEVICE_NAME.test(host) || !validSession(id))
|
|
90
|
+
return undefined;
|
|
91
|
+
const h = readJson(path.join(dir, host, `${id}.json`));
|
|
92
|
+
return h && h.v === 1 && h.id === id ? h : undefined;
|
|
93
|
+
}
|
|
94
|
+
export function hostedPath(host, id, shared = sharedDir()) {
|
|
95
|
+
const dir = chatsDir(shared);
|
|
96
|
+
return dir && DEVICE_NAME.test(host) && validSession(id) ? path.join(dir, host, `${id}.json`) : undefined;
|
|
97
|
+
}
|
|
98
|
+
// ------------------------------------------------------------------ host
|
|
99
|
+
/** The sessions of this machine that another machine has open now, and which machines. */
|
|
100
|
+
export function watchedHere(now = Date.now(), shared = sharedDir()) {
|
|
101
|
+
const dir = chatsDir(shared);
|
|
102
|
+
const out = new Map();
|
|
103
|
+
if (!dir)
|
|
104
|
+
return out;
|
|
105
|
+
const me = deviceName();
|
|
106
|
+
for (const d of otherDevices(dir, me)) {
|
|
107
|
+
const w = readJson(path.join(dir, d, "watching.json"));
|
|
108
|
+
if (!w || !Array.isArray(w.open) || now - Date.parse(w.at) > WATCH_GONE_MS)
|
|
109
|
+
continue;
|
|
110
|
+
for (const o of w.open) {
|
|
111
|
+
if (o?.host !== me || typeof o.id !== "string" || !validSession(o.id))
|
|
112
|
+
continue;
|
|
113
|
+
out.set(o.id, [...(out.get(o.id) ?? []), d]);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return out;
|
|
117
|
+
}
|
|
118
|
+
function readAskLines(file) {
|
|
119
|
+
let text = "";
|
|
120
|
+
try {
|
|
121
|
+
text = fs.readFileSync(file, "utf8");
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
return [];
|
|
125
|
+
}
|
|
126
|
+
const out = [];
|
|
127
|
+
for (const line of text.split("\n")) {
|
|
128
|
+
if (!line.trim())
|
|
129
|
+
continue;
|
|
130
|
+
try {
|
|
131
|
+
const a = JSON.parse(line);
|
|
132
|
+
if (typeof a.id === "string" && typeof a.session === "string" && Number.isFinite(Date.parse(a.at)) && (a.op === "send" || a.op === "stop"))
|
|
133
|
+
out.push(a);
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
// a line half written by the sync tool; it is whole next time
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return out;
|
|
140
|
+
}
|
|
141
|
+
/** Asks other machines left for this one, oldest first. */
|
|
142
|
+
export function asksHere(shared = sharedDir()) {
|
|
143
|
+
const dir = chatsDir(shared);
|
|
144
|
+
if (!dir)
|
|
145
|
+
return [];
|
|
146
|
+
const me = deviceName();
|
|
147
|
+
const out = [];
|
|
148
|
+
for (const d of otherDevices(dir, me)) {
|
|
149
|
+
for (const a of readAskLines(path.join(dir, d, "asks.jsonl"))) {
|
|
150
|
+
// an ask names its own machine in its id, so one machine cannot answer for another
|
|
151
|
+
if (a.host === me && a.id.startsWith(`${d}-`) && validSession(a.session))
|
|
152
|
+
out.push(a);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return out.sort((a, b) => a.at.localeCompare(b.at));
|
|
156
|
+
}
|
|
157
|
+
/** The conversation with only its latest items, so the shared file stays small. */
|
|
158
|
+
export function slimState(s, max = SHARED_ITEMS) {
|
|
159
|
+
if (!s.view || s.view.items.length <= max)
|
|
160
|
+
return s;
|
|
161
|
+
const cut = s.view.items.length - max;
|
|
162
|
+
return { ...s, view: { ...s.view, items: s.view.items.slice(cut), earlier: s.view.earlier + cut } };
|
|
163
|
+
}
|
|
164
|
+
export function writeHosted(h, shared = sharedDir()) {
|
|
165
|
+
const me = deviceName();
|
|
166
|
+
const file = hostedPath(me, h.id, shared);
|
|
167
|
+
if (!file)
|
|
168
|
+
return;
|
|
169
|
+
const body = { v: 1, host: me, ...h, state: slimState(h.state) };
|
|
170
|
+
try {
|
|
171
|
+
writeAtomic(file, JSON.stringify(body) + "\n");
|
|
172
|
+
}
|
|
173
|
+
catch {
|
|
174
|
+
// the folder may be busy syncing; the next change or beat writes it again
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
export function removeHosted(id, shared = sharedDir()) {
|
|
178
|
+
const file = hostedPath(deviceName(), id, shared);
|
|
179
|
+
if (!file)
|
|
180
|
+
return;
|
|
181
|
+
try {
|
|
182
|
+
fs.rmSync(file, { force: true });
|
|
183
|
+
}
|
|
184
|
+
catch {
|
|
185
|
+
// gone or busy; nobody reads it once it is not watched
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
/** Asks this machine already handled, read back from what it wrote, so a restart does not send twice. */
|
|
189
|
+
export function handledHere(shared = sharedDir()) {
|
|
190
|
+
const dir = chatsDir(shared);
|
|
191
|
+
const out = new Map();
|
|
192
|
+
if (!dir)
|
|
193
|
+
return out;
|
|
194
|
+
const mine = path.join(dir, deviceName());
|
|
195
|
+
let names = [];
|
|
196
|
+
try {
|
|
197
|
+
names = fs.readdirSync(mine);
|
|
198
|
+
}
|
|
199
|
+
catch {
|
|
200
|
+
return out;
|
|
201
|
+
}
|
|
202
|
+
for (const n of names) {
|
|
203
|
+
const m = /^(.+)\.json$/.exec(n);
|
|
204
|
+
if (!m || m[1] === "watching" || !validSession(m[1]))
|
|
205
|
+
continue;
|
|
206
|
+
const h = readJson(path.join(mine, n));
|
|
207
|
+
if (h?.asks)
|
|
208
|
+
out.set(m[1], h.asks);
|
|
209
|
+
}
|
|
210
|
+
return out;
|
|
211
|
+
}
|
|
212
|
+
//# sourceMappingURL=remoteChat.js.map
|