parawatch 0.3.15 → 0.3.16
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/bin/cli.js +100 -1
- package/dist/core/codexChat.js +6 -2
- package/dist/core/relay.js +474 -0
- package/dist/core/remoteChat.js +212 -0
- package/dist/core/sync.js +2 -0
- package/dist/relay/server.js +234 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -46,6 +46,8 @@ parawatch sync setup ~/agent-sync/parawatch --device pc --adapter syncthing
|
|
|
46
46
|
|
|
47
47
|
Cards, answers and the list of running sessions then show on every machine, each marked with where it came from. Agent logs stay on the machine that wrote them. The details are in [More than one machine](docs/sync.md).
|
|
48
48
|
|
|
49
|
+
Or run a [relay](docs/relay.md) of your own, a small server that carries the folder sealed end to end, so the server cannot read your cards or sessions: `parawatch relay setup <url> <folder>` on the first machine, `parawatch relay join <code> <folder>` on the others.
|
|
50
|
+
|
|
49
51
|
## How an answer gets back
|
|
50
52
|
|
|
51
53
|
<picture>
|
|
@@ -133,6 +135,8 @@ This writes a short block that tells the agents in that project to say what they
|
|
|
133
135
|
| [Pull requests](docs/pull-requests.md) | What is read from GitHub, and its settings |
|
|
134
136
|
| [Observed agents](docs/observed-agents.md) | Which logs are read, run states, adding a runtime |
|
|
135
137
|
| [More than one machine](docs/sync.md) | Cards and answers across machines through a synced folder |
|
|
138
|
+
| [The relay](docs/relay.md) | Your own server that carries the shared folder, sealed end to end |
|
|
139
|
+
| [The shared folder](docs/protocol.md) | The files machines exchange, for anyone writing a client |
|
|
136
140
|
| [Storage](docs/storage.md) | Where everything is kept, notifications |
|
|
137
141
|
| [Installing, updating, removing](docs/install.md) | The three parts in detail, updates, `parawatch doctor`, uninstalling |
|
|
138
142
|
| [Building on it](docs/integrations.md) | `status --json`, `watch`, the panel package |
|
package/dist/bin/cli.js
CHANGED
|
@@ -7,10 +7,12 @@ import { githubConfig, readCache, resolvePrRef, syncGithub, prViews } from "../c
|
|
|
7
7
|
import { recordPrompt } from "../core/prompts.js";
|
|
8
8
|
import { callingSubagent } from "../adapters/claudeCode.js";
|
|
9
9
|
import { digest, digestText, localDate } from "../core/digest.js";
|
|
10
|
-
import { everywhere, readEvents } from "../core/store.js";
|
|
10
|
+
import { everywhere, readConfig, readEvents, sharedDir, writeConfig } from "../core/store.js";
|
|
11
11
|
import { followEvents } from "../core/follow.js";
|
|
12
12
|
import { adapterNames, setRoot, setupSync, syncAdapter, syncStatus, touchDevice } from "../core/sync.js";
|
|
13
13
|
import { syncText } from "../core/format.js";
|
|
14
|
+
import { RelayClient, RelayMirror, joinCode, newKey, readJoinCode, relayConfig, takeMirrorLock } from "../core/relay.js";
|
|
15
|
+
import { createRelayServer } from "../relay/server.js";
|
|
14
16
|
import { collectRuns, shareRunsDue } from "../core/runs.js";
|
|
15
17
|
import { askRows, cardDetail, cardLine, cardRows, inboxLine, prRows, runsTree, view } from "../core/format.js";
|
|
16
18
|
import { hookContext, notify, removeInstructions, writeInstructions } from "../core/integrate.js";
|
|
@@ -648,6 +650,103 @@ sync
|
|
|
648
650
|
const d = setRoot(name, folder, VERSION);
|
|
649
651
|
process.stdout.write(Object.entries(d.roots ?? {}).map(([k, v]) => `${k} ${v}`).join("\n") + (d.roots ? "\n" : "No named folders.\n"));
|
|
650
652
|
});
|
|
653
|
+
// ------------------------------------------------------------------ the relay
|
|
654
|
+
const relay = program.command("relay").description("keep the shared folder in step through a parawatch relay server instead of a sync tool");
|
|
655
|
+
/** Point this machine's shared folder at a relay, keeping what config.json already has. */
|
|
656
|
+
function useRelay(folder, device, c) {
|
|
657
|
+
const r = setupSync({ dir: folder, device, adapter: "relay", version: VERSION });
|
|
658
|
+
const cfg = readConfig();
|
|
659
|
+
writeConfig({ ...cfg, sync: { ...(cfg.sync ?? {}), adapter: "relay", relay: c } });
|
|
660
|
+
return r;
|
|
661
|
+
}
|
|
662
|
+
relay
|
|
663
|
+
.command("setup <url> <folder>")
|
|
664
|
+
.description("make a space on a relay and share this machine's folder through it; prints the code for your other machines")
|
|
665
|
+
.option("-d, --device <name>", "this machine's name in the folder (a-z, 0-9, -); defaults to the host name")
|
|
666
|
+
.option("--invite <invite>", "the relay's invite (or set PARAWATCH_RELAY_INVITE)")
|
|
667
|
+
.action(async (url, folder, o) => {
|
|
668
|
+
const invite = o.invite ?? process.env.PARAWATCH_RELAY_INVITE;
|
|
669
|
+
if (!invite)
|
|
670
|
+
throw new UserError("the relay needs its invite to make a space: pass --invite or set PARAWATCH_RELAY_INVITE");
|
|
671
|
+
const base = url.replace(/\/+$/, "");
|
|
672
|
+
const made = await RelayClient.createSpace(base, invite);
|
|
673
|
+
const c = { url: base, space: made.space, token: made.token, key: newKey() };
|
|
674
|
+
const r = useRelay(folder, o.device, c);
|
|
675
|
+
process.stdout.write([
|
|
676
|
+
`This machine shares ${r.dir} as "${r.device}" through ${base}.`,
|
|
677
|
+
"On each of your other machines, run:",
|
|
678
|
+
"",
|
|
679
|
+
` parawatch relay join <code> <folder> --device <its name>`,
|
|
680
|
+
"",
|
|
681
|
+
"with this code. It opens your space and its key: keep it to your own machines.",
|
|
682
|
+
"",
|
|
683
|
+
joinCode(c),
|
|
684
|
+
"",
|
|
685
|
+
"The folder is kept in step while the app runs, or while `parawatch relay run` does.",
|
|
686
|
+
].join("\n") + "\n");
|
|
687
|
+
});
|
|
688
|
+
relay
|
|
689
|
+
.command("join <code> <folder>")
|
|
690
|
+
.description("share this machine's folder through the relay another machine set up")
|
|
691
|
+
.option("-d, --device <name>", "this machine's name in the folder (a-z, 0-9, -); defaults to the host name")
|
|
692
|
+
.action(async (code, folder, o) => {
|
|
693
|
+
const c = readJoinCode(code);
|
|
694
|
+
await new RelayClient(c).changes(0);
|
|
695
|
+
const r = useRelay(folder, o.device, c);
|
|
696
|
+
process.stdout.write(`This machine shares ${r.dir} as "${r.device}" through ${c.url}. The folder is kept in step while the app runs, or while \`parawatch relay run\` does.\n`);
|
|
697
|
+
});
|
|
698
|
+
relay
|
|
699
|
+
.command("code")
|
|
700
|
+
.description("print the code that lets another of your machines join (it holds the space key)")
|
|
701
|
+
.action(() => {
|
|
702
|
+
const c = relayConfig();
|
|
703
|
+
if (!c)
|
|
704
|
+
throw new UserError("this machine does not use a relay");
|
|
705
|
+
process.stdout.write(joinCode(c) + "\n");
|
|
706
|
+
});
|
|
707
|
+
relay
|
|
708
|
+
.command("run")
|
|
709
|
+
.description("keep the shared folder in step with the relay until stopped (the app does this while it runs)")
|
|
710
|
+
.option("--once", "bring it up to date once and stop")
|
|
711
|
+
.action(async (o) => {
|
|
712
|
+
const c = relayConfig();
|
|
713
|
+
const dir = sharedDir();
|
|
714
|
+
if (!c || !dir)
|
|
715
|
+
throw new UserError("this machine does not use a relay: run `parawatch relay setup` or `parawatch relay join` first");
|
|
716
|
+
const unlock = takeMirrorLock();
|
|
717
|
+
if (!unlock)
|
|
718
|
+
throw new UserError("another parawatch (the app, or another `relay run`) already keeps this folder in step");
|
|
719
|
+
const m = new RelayMirror({ dir, config: c, log: (l) => process.stderr.write(l + "\n") });
|
|
720
|
+
try {
|
|
721
|
+
if (o.once) {
|
|
722
|
+
const r = await m.once();
|
|
723
|
+
process.stdout.write(`brought down ${r.pulled}, sent up ${r.pushed}\n`);
|
|
724
|
+
return;
|
|
725
|
+
}
|
|
726
|
+
const stop = () => m.stop();
|
|
727
|
+
process.once("SIGINT", stop);
|
|
728
|
+
process.once("SIGTERM", stop);
|
|
729
|
+
process.stderr.write(`keeping ${dir} in step with ${c.url}; stop with Ctrl-C\n`);
|
|
730
|
+
await m.run();
|
|
731
|
+
}
|
|
732
|
+
finally {
|
|
733
|
+
unlock();
|
|
734
|
+
}
|
|
735
|
+
});
|
|
736
|
+
relay
|
|
737
|
+
.command("serve")
|
|
738
|
+
.description("run a relay server (for your own server; machines reach it over HTTPS through a proxy in front)")
|
|
739
|
+
.option("--port <n>", "port to listen on", "8787")
|
|
740
|
+
.option("--host <host>", "address to listen on", "127.0.0.1")
|
|
741
|
+
.option("--data <folder>", "where spaces are kept", "~/.parawatch-relay")
|
|
742
|
+
.action((o) => {
|
|
743
|
+
const invite = process.env.PARAWATCH_RELAY_INVITE;
|
|
744
|
+
const data = o.data.startsWith("~") ? o.data.replace(/^~/, process.env.HOME ?? process.env.USERPROFILE ?? "") : o.data;
|
|
745
|
+
const server = createRelayServer({ dataDir: data, invite, log: (l) => process.stdout.write(`${new Date().toISOString()} ${l}\n`) });
|
|
746
|
+
server.listen(Number(o.port), o.host, () => {
|
|
747
|
+
process.stdout.write(`relay on http://${o.host}:${o.port}, spaces in ${data}${invite ? "" : " (no PARAWATCH_RELAY_INVITE: no new spaces can be made)"}\n`);
|
|
748
|
+
});
|
|
749
|
+
});
|
|
651
750
|
program
|
|
652
751
|
.command("hook <runtime>")
|
|
653
752
|
.description("hook entry point; 'claude-code' reads the hook JSON on stdin")
|
package/dist/core/codexChat.js
CHANGED
|
@@ -132,6 +132,7 @@ export function readChatLines(head, tail, file = "") {
|
|
|
132
132
|
const items = new Map();
|
|
133
133
|
let started = -1;
|
|
134
134
|
let ended = -1;
|
|
135
|
+
let turnAt;
|
|
135
136
|
let structured = false;
|
|
136
137
|
const fallback = [];
|
|
137
138
|
tail.forEach((r, i) => {
|
|
@@ -148,8 +149,10 @@ export function readChatLines(head, tail, file = "") {
|
|
|
148
149
|
return;
|
|
149
150
|
}
|
|
150
151
|
if (r.type === "event_msg") {
|
|
151
|
-
if (p.type === "task_started")
|
|
152
|
+
if (p.type === "task_started") {
|
|
152
153
|
started = i;
|
|
154
|
+
turnAt = at;
|
|
155
|
+
}
|
|
153
156
|
else if (p.type === "task_complete")
|
|
154
157
|
ended = i;
|
|
155
158
|
else if (p.type === "turn_aborted") {
|
|
@@ -186,6 +189,7 @@ export function readChatLines(head, tail, file = "") {
|
|
|
186
189
|
...(firstUser ? { title: firstUser.text.split("\n")[0].slice(0, 80) } : {}),
|
|
187
190
|
settings,
|
|
188
191
|
working: started > ended,
|
|
192
|
+
...(started > ended && turnAt ? { turnStarted: turnAt } : {}),
|
|
189
193
|
items: all.slice(-MAX_ITEMS),
|
|
190
194
|
earlier: Math.max(0, all.length - MAX_ITEMS),
|
|
191
195
|
};
|
|
@@ -230,7 +234,7 @@ export function findCodex(env = process.env, platform = process.platform) {
|
|
|
230
234
|
const dirs = [
|
|
231
235
|
...(env.PATH ?? "").split(path.delimiter),
|
|
232
236
|
...(platform === "win32"
|
|
233
|
-
? [env.APPDATA && path.join(env.APPDATA, "npm"), env.LOCALAPPDATA && path.join(env.LOCALAPPDATA, "Programs", "codex")]
|
|
237
|
+
? [env.APPDATA && path.join(env.APPDATA, "npm"), env.LOCALAPPDATA && path.join(env.LOCALAPPDATA, "Programs", "OpenAI", "Codex", "bin"), env.LOCALAPPDATA && path.join(env.LOCALAPPDATA, "Programs", "codex")]
|
|
234
238
|
: ["/opt/homebrew/bin", "/usr/local/bin", path.join(home, ".local", "bin"), path.join(home, ".npm-global", "bin"), path.join(home, ".volta", "bin"), path.join(home, ".bun", "bin")]),
|
|
235
239
|
].filter((d) => !!d);
|
|
236
240
|
for (const d of dirs) {
|
|
@@ -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
|
package/dist/core/sync.js
CHANGED
|
@@ -15,6 +15,7 @@ import path from "node:path";
|
|
|
15
15
|
import { UserError } from "./errors.js";
|
|
16
16
|
import { DEVICE_NAME, configPath, deviceName, eventFiles, expandHome, homeDir, parseEventLines, readConfig, sharedDir, withLock, writeConfig, } from "./store.js";
|
|
17
17
|
import { syncthingAdapter } from "./syncthing.js";
|
|
18
|
+
import { relayAdapter } from "./relay.js";
|
|
18
19
|
function devicesDir(shared) {
|
|
19
20
|
return path.join(shared, "devices");
|
|
20
21
|
}
|
|
@@ -209,6 +210,7 @@ function adapters() {
|
|
|
209
210
|
registry ??= new Map([
|
|
210
211
|
[folderAdapter.name, folderAdapter],
|
|
211
212
|
[syncthingAdapter.name, syncthingAdapter],
|
|
213
|
+
[relayAdapter.name, relayAdapter],
|
|
212
214
|
]);
|
|
213
215
|
return registry;
|
|
214
216
|
}
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The relay: a small server that carries the shared folder between machines in place of a sync
|
|
3
|
+
* tool. It keeps sealed files and hands out what changed. It never sees what is in them: every
|
|
4
|
+
* machine seals a file and its path with a key the server is never given (see core/relay.ts).
|
|
5
|
+
*
|
|
6
|
+
* A space is the folder of one person (or one group). Each space has a token; anyone with it can
|
|
7
|
+
* read and write the space's sealed files. Making a space needs the server's invite, so a public
|
|
8
|
+
* server is not open to everyone.
|
|
9
|
+
*
|
|
10
|
+
* POST /v1/spaces invite → { space, token }
|
|
11
|
+
* GET /v1/spaces/:space/changes?after=N&wait=S { head, changes: [{ name, seq, size?, deleted? }] }
|
|
12
|
+
* PUT /v1/spaces/:space/files/:name the sealed bytes → { seq }
|
|
13
|
+
* GET /v1/spaces/:space/files/:name the sealed bytes
|
|
14
|
+
* DELETE /v1/spaces/:space/files/:name → { seq }
|
|
15
|
+
* GET /healthz
|
|
16
|
+
*/
|
|
17
|
+
import crypto from "node:crypto";
|
|
18
|
+
import fs from "node:fs";
|
|
19
|
+
import http from "node:http";
|
|
20
|
+
import path from "node:path";
|
|
21
|
+
export const RELAY_MAX_FILE = 256 * 1024 * 1024;
|
|
22
|
+
const MAX_WAIT_S = 30;
|
|
23
|
+
const SPACE = /^[a-f0-9]{24}$/;
|
|
24
|
+
const NAME = /^[a-f0-9]{40}$/;
|
|
25
|
+
const sha = (s) => crypto.createHash("sha256").update(s).digest();
|
|
26
|
+
function sameSecret(given, hash) {
|
|
27
|
+
if (!given)
|
|
28
|
+
return false;
|
|
29
|
+
return crypto.timingSafeEqual(sha(given), hash);
|
|
30
|
+
}
|
|
31
|
+
class Store {
|
|
32
|
+
root;
|
|
33
|
+
spaces = new Map();
|
|
34
|
+
constructor(root) {
|
|
35
|
+
this.root = root;
|
|
36
|
+
fs.mkdirSync(root, { recursive: true });
|
|
37
|
+
}
|
|
38
|
+
create() {
|
|
39
|
+
const id = crypto.randomBytes(12).toString("hex");
|
|
40
|
+
const token = crypto.randomBytes(32).toString("base64url");
|
|
41
|
+
const dir = path.join(this.root, id);
|
|
42
|
+
fs.mkdirSync(path.join(dir, "files"), { recursive: true });
|
|
43
|
+
fs.writeFileSync(path.join(dir, "space.json"), JSON.stringify({ v: 1, token: sha(token).toString("hex"), at: new Date().toISOString() }) + "\n");
|
|
44
|
+
return { space: id, token };
|
|
45
|
+
}
|
|
46
|
+
open(id) {
|
|
47
|
+
if (!SPACE.test(id))
|
|
48
|
+
return undefined;
|
|
49
|
+
const had = this.spaces.get(id);
|
|
50
|
+
if (had)
|
|
51
|
+
return had;
|
|
52
|
+
const dir = path.join(this.root, id);
|
|
53
|
+
let meta;
|
|
54
|
+
try {
|
|
55
|
+
meta = JSON.parse(fs.readFileSync(path.join(dir, "space.json"), "utf8"));
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
return undefined;
|
|
59
|
+
}
|
|
60
|
+
const s = { id, dir, tokenHash: Buffer.from(meta.token, "hex"), head: 0, files: new Map(), journalLines: 0, waiters: new Set() };
|
|
61
|
+
let text = "";
|
|
62
|
+
try {
|
|
63
|
+
text = fs.readFileSync(path.join(dir, "journal.jsonl"), "utf8");
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
// a new space
|
|
67
|
+
}
|
|
68
|
+
for (const line of text.split("\n")) {
|
|
69
|
+
if (!line.trim())
|
|
70
|
+
continue;
|
|
71
|
+
try {
|
|
72
|
+
const e = JSON.parse(line);
|
|
73
|
+
if (!NAME.test(e.name) || !Number.isInteger(e.seq))
|
|
74
|
+
continue;
|
|
75
|
+
s.files.set(e.name, e.deleted ? { seq: e.seq, deleted: true } : { seq: e.seq, size: e.size });
|
|
76
|
+
s.head = Math.max(s.head, e.seq);
|
|
77
|
+
s.journalLines++;
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
// a line cut short by a crash; the file it named is written again by its machine
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
this.spaces.set(id, s);
|
|
84
|
+
if (s.journalLines > 4 * s.files.size + 100)
|
|
85
|
+
this.compact(s);
|
|
86
|
+
return s;
|
|
87
|
+
}
|
|
88
|
+
compact(s) {
|
|
89
|
+
const lines = [...s.files].sort((a, b) => a[1].seq - b[1].seq).map(([name, e]) => JSON.stringify({ name, ...e }) + "\n");
|
|
90
|
+
const tmp = path.join(s.dir, "journal.jsonl.tmp");
|
|
91
|
+
fs.writeFileSync(tmp, lines.join(""));
|
|
92
|
+
fs.renameSync(tmp, path.join(s.dir, "journal.jsonl"));
|
|
93
|
+
s.journalLines = lines.length;
|
|
94
|
+
}
|
|
95
|
+
record(s, name, e) {
|
|
96
|
+
const seq = ++s.head;
|
|
97
|
+
const entry = { seq, ...e };
|
|
98
|
+
s.files.set(name, entry);
|
|
99
|
+
fs.appendFileSync(path.join(s.dir, "journal.jsonl"), JSON.stringify({ name, ...entry }) + "\n");
|
|
100
|
+
s.journalLines++;
|
|
101
|
+
if (s.journalLines > 4 * s.files.size + 100)
|
|
102
|
+
this.compact(s);
|
|
103
|
+
for (const w of [...s.waiters])
|
|
104
|
+
w();
|
|
105
|
+
return seq;
|
|
106
|
+
}
|
|
107
|
+
filePath(s, name) {
|
|
108
|
+
return path.join(s.dir, "files", name);
|
|
109
|
+
}
|
|
110
|
+
changes(s, after) {
|
|
111
|
+
const changes = [...s.files].filter(([, e]) => e.seq > after).map(([name, e]) => ({ name, ...e }));
|
|
112
|
+
return { head: s.head, changes: changes.sort((a, b) => a.seq - b.seq) };
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
function send(res, status, body) {
|
|
116
|
+
const text = body === undefined ? "" : JSON.stringify(body);
|
|
117
|
+
res.writeHead(status, { "content-type": "application/json", "cache-control": "no-store" });
|
|
118
|
+
res.end(text);
|
|
119
|
+
}
|
|
120
|
+
function bearer(req) {
|
|
121
|
+
const h = req.headers.authorization;
|
|
122
|
+
return h?.startsWith("Bearer ") ? h.slice(7).trim() : undefined;
|
|
123
|
+
}
|
|
124
|
+
/** A relay server; call `.listen(port)` on it. */
|
|
125
|
+
export function createRelayServer(opts) {
|
|
126
|
+
const store = new Store(opts.dataDir);
|
|
127
|
+
const log = opts.log ?? (() => { });
|
|
128
|
+
const inviteHash = opts.invite ? sha(opts.invite) : undefined;
|
|
129
|
+
return http.createServer((req, res) => {
|
|
130
|
+
const url = new URL(req.url ?? "/", "http://relay");
|
|
131
|
+
const parts = url.pathname.split("/").filter(Boolean);
|
|
132
|
+
const fail = (status, error) => send(res, status, { error });
|
|
133
|
+
try {
|
|
134
|
+
if (req.method === "GET" && url.pathname === "/healthz")
|
|
135
|
+
return send(res, 200, { ok: true });
|
|
136
|
+
if (parts[0] !== "v1" || parts[1] !== "spaces")
|
|
137
|
+
return fail(404, "not found");
|
|
138
|
+
if (parts.length === 2 && req.method === "POST") {
|
|
139
|
+
if (!inviteHash || !sameSecret(bearer(req), inviteHash))
|
|
140
|
+
return fail(403, "this server needs its invite to make a space");
|
|
141
|
+
const made = store.create();
|
|
142
|
+
log(`space ${made.space} made`);
|
|
143
|
+
return send(res, 201, made);
|
|
144
|
+
}
|
|
145
|
+
const s = parts[2] ? store.open(parts[2]) : undefined;
|
|
146
|
+
// an unknown space and a wrong token look the same from outside
|
|
147
|
+
if (!s || !sameSecret(bearer(req), s.tokenHash))
|
|
148
|
+
return fail(401, "unknown space or wrong token");
|
|
149
|
+
if (parts.length === 4 && parts[3] === "changes" && req.method === "GET") {
|
|
150
|
+
const after = Math.max(0, Number(url.searchParams.get("after") ?? 0) || 0);
|
|
151
|
+
const wait = Math.min(MAX_WAIT_S, Math.max(0, Number(url.searchParams.get("wait") ?? 0) || 0));
|
|
152
|
+
const now = store.changes(s, after);
|
|
153
|
+
if (now.changes.length || !wait)
|
|
154
|
+
return send(res, 200, now);
|
|
155
|
+
// nothing new: hold the request until something changes or the wait runs out
|
|
156
|
+
let done = false;
|
|
157
|
+
const finish = () => {
|
|
158
|
+
if (done)
|
|
159
|
+
return;
|
|
160
|
+
done = true;
|
|
161
|
+
clearTimeout(timer);
|
|
162
|
+
s.waiters.delete(finish);
|
|
163
|
+
if (!res.writableEnded)
|
|
164
|
+
send(res, 200, store.changes(s, after));
|
|
165
|
+
};
|
|
166
|
+
const timer = setTimeout(finish, wait * 1000);
|
|
167
|
+
s.waiters.add(finish);
|
|
168
|
+
req.on("close", () => {
|
|
169
|
+
done = true;
|
|
170
|
+
clearTimeout(timer);
|
|
171
|
+
s.waiters.delete(finish);
|
|
172
|
+
});
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
if (parts.length === 5 && parts[3] === "files") {
|
|
176
|
+
const name = parts[4];
|
|
177
|
+
if (!NAME.test(name))
|
|
178
|
+
return fail(400, "bad file name");
|
|
179
|
+
const file = store.filePath(s, name);
|
|
180
|
+
if (req.method === "GET") {
|
|
181
|
+
const e = s.files.get(name);
|
|
182
|
+
if (!e || e.deleted)
|
|
183
|
+
return fail(404, "no such file");
|
|
184
|
+
res.writeHead(200, { "content-type": "application/octet-stream", "cache-control": "no-store", "x-seq": String(e.seq) });
|
|
185
|
+
fs.createReadStream(file).on("error", () => res.destroy()).pipe(res);
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
if (req.method === "DELETE") {
|
|
189
|
+
fs.rmSync(file, { force: true });
|
|
190
|
+
return send(res, 200, { seq: store.record(s, name, { deleted: true }) });
|
|
191
|
+
}
|
|
192
|
+
if (req.method === "PUT") {
|
|
193
|
+
const declared = Number(req.headers["content-length"] ?? NaN);
|
|
194
|
+
if (Number.isFinite(declared) && declared > RELAY_MAX_FILE)
|
|
195
|
+
return fail(413, "file too large");
|
|
196
|
+
const tmp = `${file}.${process.pid}.${crypto.randomBytes(4).toString("hex")}.tmp`;
|
|
197
|
+
const out = fs.createWriteStream(tmp);
|
|
198
|
+
let size = 0;
|
|
199
|
+
let tooBig = false;
|
|
200
|
+
req.on("data", (chunk) => {
|
|
201
|
+
size += chunk.length;
|
|
202
|
+
if (size > RELAY_MAX_FILE && !tooBig) {
|
|
203
|
+
tooBig = true;
|
|
204
|
+
out.destroy();
|
|
205
|
+
fs.rmSync(tmp, { force: true });
|
|
206
|
+
fail(413, "file too large");
|
|
207
|
+
req.destroy();
|
|
208
|
+
}
|
|
209
|
+
});
|
|
210
|
+
req.pipe(out);
|
|
211
|
+
out.on("finish", () => {
|
|
212
|
+
if (tooBig)
|
|
213
|
+
return;
|
|
214
|
+
fs.renameSync(tmp, file);
|
|
215
|
+
send(res, 200, { seq: store.record(s, name, { size }) });
|
|
216
|
+
});
|
|
217
|
+
out.on("error", () => {
|
|
218
|
+
fs.rmSync(tmp, { force: true });
|
|
219
|
+
if (!res.headersSent)
|
|
220
|
+
fail(500, "could not store the file");
|
|
221
|
+
});
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
return fail(404, "not found");
|
|
226
|
+
}
|
|
227
|
+
catch (err) {
|
|
228
|
+
log(`error: ${err.message}`);
|
|
229
|
+
if (!res.headersSent)
|
|
230
|
+
fail(500, "server error");
|
|
231
|
+
}
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
//# sourceMappingURL=server.js.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "parawatch",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.16",
|
|
4
4
|
"description": "A control tower for people running many AI coding agents in parallel: see what is running, what needs you, and wake the right agent when you answer.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ai-agents",
|