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
package/dist/core/store.js
CHANGED
|
@@ -2,7 +2,9 @@ import fs from "node:fs";
|
|
|
2
2
|
import os from "node:os";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { UserError } from "./errors.js";
|
|
5
|
+
import { mergeLogs, parseEventLines, stampFor } from "./log.js";
|
|
5
6
|
import { STORE_FORMAT } from "./types.js";
|
|
7
|
+
export { mergeLogs, parseEventLines };
|
|
6
8
|
/** Directory holding all parawatch state for this user on this machine. */
|
|
7
9
|
export function homeDir() {
|
|
8
10
|
const explicit = process.env.PARAWATCH_HOME;
|
|
@@ -188,48 +190,6 @@ export function readEvents() {
|
|
|
188
190
|
return lists[0] ?? [];
|
|
189
191
|
return mergeLogs(lists);
|
|
190
192
|
}
|
|
191
|
-
/** Merge per-device logs, each already in its own order, into one. */
|
|
192
|
-
export function mergeLogs(lists) {
|
|
193
|
-
const at = lists.map(() => 0);
|
|
194
|
-
const out = [];
|
|
195
|
-
for (;;) {
|
|
196
|
-
let pick = -1;
|
|
197
|
-
for (let i = 0; i < lists.length; i++) {
|
|
198
|
-
const ev = lists[i][at[i]];
|
|
199
|
-
if (!ev)
|
|
200
|
-
continue;
|
|
201
|
-
if (pick < 0) {
|
|
202
|
-
pick = i;
|
|
203
|
-
continue;
|
|
204
|
-
}
|
|
205
|
-
const best = lists[pick][at[pick]];
|
|
206
|
-
if (ev.ts < best.ts || (ev.ts === best.ts && (ev.dev ?? "") < (best.dev ?? "")))
|
|
207
|
-
pick = i;
|
|
208
|
-
}
|
|
209
|
-
if (pick < 0)
|
|
210
|
-
return out;
|
|
211
|
-
out.push(lists[pick][at[pick]++]);
|
|
212
|
-
}
|
|
213
|
-
}
|
|
214
|
-
export function parseEventLines(text, device) {
|
|
215
|
-
const out = [];
|
|
216
|
-
for (const line of text.split("\n")) {
|
|
217
|
-
if (!line.trim())
|
|
218
|
-
continue;
|
|
219
|
-
try {
|
|
220
|
-
const ev = JSON.parse(line);
|
|
221
|
-
if (typeof ev.seq !== "number" || typeof ev.type !== "string")
|
|
222
|
-
continue;
|
|
223
|
-
if (device)
|
|
224
|
-
ev.dev = device;
|
|
225
|
-
out.push(ev);
|
|
226
|
-
}
|
|
227
|
-
catch {
|
|
228
|
-
// a torn or hand-edited line; keep going
|
|
229
|
-
}
|
|
230
|
-
}
|
|
231
|
-
return out;
|
|
232
|
-
}
|
|
233
193
|
// ------------------------------------------------------------------ writing
|
|
234
194
|
/** Append one event. `build` sees the current events so it can pick ids under the lock. */
|
|
235
195
|
export function appendEvent(build) {
|
|
@@ -263,9 +223,13 @@ export function appendLocked(build) {
|
|
|
263
223
|
for (const e of own)
|
|
264
224
|
if (e.seq > seq)
|
|
265
225
|
seq = e.seq;
|
|
266
|
-
const
|
|
226
|
+
const mine = new Set(own);
|
|
227
|
+
let last = 0;
|
|
267
228
|
const v = writeFormat();
|
|
268
|
-
const out = build(events).map((body) =>
|
|
229
|
+
const out = build(events).map((body) => {
|
|
230
|
+
last = stampFor(events, (e) => mine.has(e), body.card, Math.max(Date.now(), last));
|
|
231
|
+
return { v, seq: ++seq, ts: new Date(last).toISOString(), ...body };
|
|
232
|
+
});
|
|
269
233
|
if (out.length) {
|
|
270
234
|
ensureDir(path.dirname(eventsPath()));
|
|
271
235
|
fs.appendFileSync(eventsPath(), out.map((e) => JSON.stringify(e) + "\n").join(""));
|
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.17",
|
|
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",
|
|
@@ -39,7 +39,7 @@
|
|
|
39
39
|
"build": "tsc -p tsconfig.json",
|
|
40
40
|
"dev": "tsx src/bin/cli.ts",
|
|
41
41
|
"test": "vitest run",
|
|
42
|
-
"typecheck": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.test.json --noEmit && npm run typecheck -w @parawatch/panel -w @parawatch/desktop",
|
|
42
|
+
"typecheck": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.test.json --noEmit && npm run typecheck -w @parawatch/panel -w @parawatch/desktop -w @parawatch/web",
|
|
43
43
|
"prepublishOnly": "npm run build && npm test",
|
|
44
44
|
"demo": "tsx scripts/demo.ts"
|
|
45
45
|
},
|
|
@@ -130,6 +130,7 @@ The session then shows as done, not as idle, and the line goes into the human's
|
|
|
130
130
|
- The lead owns the cards for the work it hands out. Workers (subagents, `codex exec` jobs, other sessions you start) report back to the lead instead of opening cards, unless the lead told them to ask the human directly.
|
|
131
131
|
- A session the human started on their own asks the human directly. Its cards and pull requests are its own: answers and news on them go to it, not to the lead. When that work belongs in the lead's queue, the session tells the lead, not the other way round.
|
|
132
132
|
- Tell workers in their brief what to return to you, so the lead can turn it into one clear card.
|
|
133
|
+
- **A lead that finishes one task claims its next work, not `--done`** (`parawatch claim -p <project> -t <next> --role lead`). `--done` ends the session as lead too: from then on what no session owns in the project goes nowhere until a session claims the lead. When you do a worker's task yourself, its brief's rules for workers (such as "do not use parawatch") are not yours.
|
|
133
134
|
- Before ending a session, close cards you handled, end runs you started, and claim `--done`.
|
|
134
135
|
|
|
135
136
|
## On more than one machine
|