parawatch 0.2.1 → 0.3.2

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.2",
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",
@@ -74,10 +74,12 @@ News from somewhere GitHub does not show (a merge bot, a ticket system) can be r
74
74
 
75
75
  ## 4. Waiting for the answer
76
76
 
77
- Right after opening cards or linking a pull request, keep exactly one wait armed for your project:
77
+ Right after opening cards or linking a pull request, keep exactly one wait armed:
78
78
 
79
- - **Claude Code**: run `parawatch wait -p <project>` as a background Bash command (`run_in_background: true`). When the human answers, the command exits, its output is the answer, and you are woken. Do not poll, do not sleep-loop, do not start a second wait while one is running.
80
- - **Other runtimes**: check `parawatch inbox -p <project>` at the start of every turn.
79
+ - **Claude Code**: run `parawatch wait` as a background Bash command (`run_in_background: true`). When the human answers, the command exits, its output is the answer, and you are woken. Do not poll, do not sleep-loop, do not start a second wait while one is running.
80
+ - **Other runtimes**: check `parawatch inbox` at the start of every turn.
81
+
82
+ Inside your session, `wait` and `inbox` read only what is yours: answers on cards you opened, news on pull requests linked to you, and cards or pull requests no session owns in the project you claimed. Other sessions in the same project are not woken by your news and cannot take it from you. `-p <project>` reads the whole project instead; leave it to a lead (section 8).
81
83
 
82
84
  Keep working on other things meanwhile. A wait with `--timeout <seconds>` exits with code 2 when nothing came; re-arm it if you still need the answer. While no wait runs, an answer is kept, and the card tells the human that no agent is waiting; it reaches you on your next turn or your next wait.
83
85
 
@@ -119,10 +121,15 @@ The session then shows as done, not as idle, and the line goes into the human's
119
121
 
120
122
  ## 8. Leads and workers
121
123
 
124
+ - A lead that watches the whole project waits with `parawatch wait -p <project>`.
122
125
  - The lead owns the cards for its project. 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.
123
126
  - Tell workers in their brief what to return to you, so the lead can turn it into one clear card.
124
127
  - Before ending a session, close cards you handled, end runs you started, and claim `--done`.
125
128
 
129
+ ## On more than one machine
130
+
131
+ 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.
132
+
126
133
  ## Quick reference
127
134
 
128
135
  ```