parawatch 0.2.1 → 0.3.1

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.
@@ -0,0 +1,121 @@
1
+ /**
2
+ * The Syncthing adapter: asks the local Syncthing (its REST API) how the shared folder is doing.
3
+ * The API key is read from Syncthing's own config.xml and never printed or stored by parawatch.
4
+ */
5
+ import fs from "node:fs";
6
+ import os from "node:os";
7
+ import path from "node:path";
8
+ import { findConflicts, folderPeers } from "./sync.js";
9
+ /** Where Syncthing keeps config.xml on this machine, newest layout first. */
10
+ export function syncthingHomes() {
11
+ const home = os.homedir();
12
+ if (process.platform === "win32") {
13
+ const local = process.env.LOCALAPPDATA || path.join(home, "AppData", "Local");
14
+ return [path.join(local, "Syncthing")];
15
+ }
16
+ const state = process.env.XDG_STATE_HOME || path.join(home, ".local", "state");
17
+ const out = [path.join(state, "syncthing")];
18
+ if (process.platform === "darwin")
19
+ out.push(path.join(home, "Library", "Application Support", "Syncthing"));
20
+ out.push(path.join(process.env.XDG_CONFIG_HOME || path.join(home, ".config"), "syncthing"));
21
+ return out;
22
+ }
23
+ /** The GUI address and API key from config.xml (`home` overrides where to look). */
24
+ export function readSyncthingGui(home) {
25
+ const dirs = home ? [home] : syncthingHomes();
26
+ for (const d of dirs) {
27
+ const file = path.join(d, "config.xml");
28
+ let xml;
29
+ try {
30
+ xml = fs.readFileSync(file, "utf8");
31
+ }
32
+ catch {
33
+ continue;
34
+ }
35
+ const gui = /<gui\b([^>]*)>([\s\S]*?)<\/gui>/.exec(xml);
36
+ const attrs = gui?.[1] ?? "";
37
+ const body = gui?.[2] ?? "";
38
+ const address = /<address>([^<]+)<\/address>/.exec(body)?.[1]?.trim() || "127.0.0.1:8384";
39
+ const tls = /\btls="true"/.test(attrs);
40
+ const apiKey = /<apikey>([^<]+)<\/apikey>/.exec(body)?.[1]?.trim();
41
+ const host = address.replace(/^0\.0\.0\.0(?=:)/, "127.0.0.1").replace(/^\[::\](?=:)/, "[::1]");
42
+ return { url: `${tls ? "https" : "http"}://${host}`, apiKey, configFile: file };
43
+ }
44
+ return { url: "http://127.0.0.1:8384" };
45
+ }
46
+ function inside(child, parent) {
47
+ const rel = path.relative(path.resolve(parent), path.resolve(child));
48
+ return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel));
49
+ }
50
+ export const syncthingAdapter = {
51
+ name: "syncthing",
52
+ about: "Syncthing on this machine (its REST API on localhost)",
53
+ async status(ctx) {
54
+ const base = { adapter: "syncthing", dir: ctx.dir, device: ctx.device, ok: false, peers: folderPeers(ctx.dir, ctx.device), conflicts: findConflicts(ctx.dir) };
55
+ const gui = readSyncthingGui(typeof ctx.config.home === "string" ? ctx.config.home : undefined);
56
+ const url = typeof ctx.config.url === "string" ? ctx.config.url : gui.url;
57
+ const key = process.env.PARAWATCH_SYNCTHING_KEY || gui.apiKey;
58
+ if (!key)
59
+ return { ...base, error: "no Syncthing API key found (is Syncthing set up on this machine?)" };
60
+ const f = ctx.fetch ?? fetch;
61
+ const timeout = ctx.timeoutMs ?? 2000;
62
+ const get = async (p) => {
63
+ const res = await f(`${url}${p}`, { headers: { "X-API-Key": key }, signal: AbortSignal.timeout(timeout) });
64
+ if (!res.ok)
65
+ throw new Error(`Syncthing answered ${res.status} for ${p.split("?")[0]}`);
66
+ return (await res.json());
67
+ };
68
+ let folders;
69
+ try {
70
+ folders = await get("/rest/config/folders");
71
+ }
72
+ catch (err) {
73
+ const msg = err.message;
74
+ return { ...base, error: /fetch failed|ECONNREFUSED|abort|timeout/i.test(msg) ? `Syncthing is not running at ${url}` : msg };
75
+ }
76
+ const folder = folders
77
+ .map((x) => ({ ...x, path: x.path.replace(/^~(?=[\\/]|$)/, os.homedir()) }))
78
+ .filter((x) => inside(ctx.dir, x.path))
79
+ .sort((a, b) => b.path.length - a.path.length)[0];
80
+ if (!folder)
81
+ return { ...base, ok: true, error: `no Syncthing folder holds ${ctx.dir}` };
82
+ const [sys, devices, conns, stats, db, fstats] = await Promise.all([
83
+ get("/rest/system/status"),
84
+ get("/rest/config/devices"),
85
+ get("/rest/system/connections"),
86
+ get("/rest/stats/device"),
87
+ get(`/rest/db/status?folder=${encodeURIComponent(folder.id)}`),
88
+ get("/rest/stats/folder"),
89
+ ]);
90
+ const names = new Map(devices.map((d) => [d.deviceID, d.name || d.deviceID.slice(0, 7)]));
91
+ const known = new Map(base.peers.map((p) => [p.name, p]));
92
+ const peers = [];
93
+ for (const d of folder.devices ?? []) {
94
+ if (d.deviceID === sys.myID)
95
+ continue;
96
+ const c = conns.connections[d.deviceID];
97
+ const seen = stats[d.deviceID]?.lastSeen;
98
+ const name = names.get(d.deviceID) ?? d.deviceID.slice(0, 7);
99
+ const fromFolder = known.get(name);
100
+ peers.push({
101
+ name,
102
+ connected: !!c?.connected,
103
+ lastSeen: c?.connected ? new Date().toISOString() : seen && !seen.startsWith("1970") ? seen : fromFolder?.lastSeen,
104
+ address: c?.connected ? c.address : undefined,
105
+ version: fromFolder?.version,
106
+ });
107
+ }
108
+ const idle = db.state === "idle" && !db.needFiles;
109
+ const pending = db.needFiles ?? 0;
110
+ const errors = (db.errors ?? 0) + (db.pullErrors ?? 0);
111
+ return {
112
+ ...base,
113
+ ok: true,
114
+ peers,
115
+ pending: pending || undefined,
116
+ lastSync: idle ? db.stateChanged : fstats[folder.id]?.lastFile?.at,
117
+ error: errors ? `${errors} file(s) could not be synced (see Syncthing)` : !peers.length ? `folder "${folder.label || folder.id}" is not shared with another device yet` : undefined,
118
+ };
119
+ },
120
+ };
121
+ //# sourceMappingURL=syncthing.js.map
@@ -1,4 +1,11 @@
1
+ /** The version of the JSON that commands print (`--json`) and panels read. */
1
2
  export const SCHEMA_VERSION = 1;
3
+ /**
4
+ * The newest event-log format this copy reads and writes. 1: one machine. 2: per-device logs in a
5
+ * shared folder (prefixed ids, answers that record what they saw). A copy refuses to write to a
6
+ * log with a newer format than it knows.
7
+ */
8
+ export const STORE_FORMAT = 2;
2
9
  export const CARD_KINDS = ["decide", "try", "block", "todo"];
3
10
  export const PR_CHANGES = ["review", "approved", "changes_requested", "commented", "ci_failed", "ci_passed", "queued", "queue_removed", "conflict", "merged", "closed", "note"];
4
11
  /** Who counts as a human when deciding what wakes an agent. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "parawatch",
3
- "version": "0.2.1",
3
+ "version": "0.3.1",
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",
@@ -123,6 +123,10 @@ The session then shows as done, not as idle, and the line goes into the human's
123
123
  - Tell workers in their brief what to return to you, so the lead can turn it into one clear card.
124
124
  - Before ending a session, close cards you handled, end runs you started, and claim `--done`.
125
125
 
126
+ ## On more than one machine
127
+
128
+ When `parawatch sync` shows other machines, the human may answer from any of them. Nothing changes in how you ask or wait. Ids then carry the machine (`pc-c3`); use the id the command printed. Attach files under your home folder or the shared folder (or a folder named with `parawatch sync root`), so the human's machine can open them. An app path given to `try --open` only opens on the machine that has it, so say in the body which machine to try it on.
129
+
126
130
  ## Quick reference
127
131
 
128
132
  ```