parawatch 0.3.14 → 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/dist/core/runs.js CHANGED
@@ -3,7 +3,7 @@ import fs from "node:fs";
3
3
  import path from "node:path";
4
4
  import { scanAll } from "../adapters/index.js";
5
5
  import { ACTIVITY_MINUTES, activeMinutes } from "../adapters/jsonl.js";
6
- import { archivedSessions } from "./desktop.js";
6
+ import { archivedSessions, withRemote } from "./desktop.js";
7
7
  import { canonicalPath, insidePath, namesPath, samePath } from "./paths.js";
8
8
  import { applyPrompts } from "./prompts.js";
9
9
  import { deviceName, sharedDir } from "./store.js";
@@ -392,7 +392,7 @@ export function collectRuns(state, q, now = Date.now()) {
392
392
  const sinceMs = now - q.sinceHours * 3_600_000;
393
393
  // a log file can be touched (title records, snapshots) long after its last real activity
394
394
  const recent = (r) => !r.lastActivity || Date.parse(r.lastActivity) >= sinceMs;
395
- let runs = applyPrompts(endArchived(attribute(scanAll({ sinceMs, now }, q.runtimes), state), archivedSessions())).filter(recent);
395
+ let runs = withRemote(applyPrompts(endArchived(attribute(scanAll({ sinceMs, now }, q.runtimes), state), archivedSessions())).filter(recent));
396
396
  // a narrower reading (one runtime) would hide the rest from the other machines
397
397
  if (!q.runtimes)
398
398
  shareRuns(runs, now);
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.14",
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",