parawatch 0.2.0 → 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.
- package/README.md +55 -2
- package/dist/bin/cli.js +112 -21
- package/dist/core/clients.js +69 -0
- package/dist/core/doctor.js +165 -0
- package/dist/core/errors.js +4 -0
- package/dist/core/follow.js +36 -49
- package/dist/core/format.js +27 -1
- package/dist/core/integrate.js +41 -4
- package/dist/core/ops.js +119 -46
- package/dist/core/semver.js +22 -0
- package/dist/core/skill.js +56 -0
- package/dist/core/snapshot.js +5 -5
- package/dist/core/state.js +40 -9
- package/dist/core/store.js +215 -22
- package/dist/core/sync.js +317 -0
- package/dist/core/syncthing.js +121 -0
- package/dist/core/types.js +7 -0
- package/dist/core/update.js +87 -0
- package/dist/core/version.js +20 -0
- package/package.json +1 -1
- package/skills/parawatch/SKILL.md +4 -0
package/README.md
CHANGED
|
@@ -65,7 +65,27 @@ parawatch skill install
|
|
|
65
65
|
|
|
66
66
|
It links [`skills/parawatch`](skills/parawatch/SKILL.md) into `~/.claude/skills` and `~/.codex/skills`. The installed skill is a link to the copy in the package, so updating parawatch updates the skill.
|
|
67
67
|
|
|
68
|
-
**The desktop app**, to see and answer cards without a terminal. On macOS, download the DMG from the [latest release](https://github.com/penspanic/parawatch/releases/latest): `arm64` for Apple silicon, `x64` for Intel. It is signed and notarized, so it opens without warnings. Elsewhere, [build it from source](apps/desktop/README.md#run-from-source).
|
|
68
|
+
**The desktop app**, to see and answer cards without a terminal. On macOS, download the DMG from the [latest release](https://github.com/penspanic/parawatch/releases/latest): `arm64` for Apple silicon, `x64` for Intel. It is signed and notarized, so it opens without warnings. From 0.2.1 on it updates itself: it downloads a new version in the background and asks you to restart (Parawatch > Check for Updates, or Settings > Update automatically). Elsewhere, [build it from source](apps/desktop/README.md#run-from-source).
|
|
69
|
+
|
|
70
|
+
**Checking the setup.** `parawatch doctor` shows the versions of the CLI and the app and whether they differ, the store, the skill links, the instruction files in this folder, the hook, and GitHub, with what to run for anything that needs work. Paste it into bug reports. `status` also says, at its end, when a newer CLI is out or when the app and the CLI are different versions.
|
|
71
|
+
|
|
72
|
+
### Updating and removing
|
|
73
|
+
|
|
74
|
+
```
|
|
75
|
+
npm install -g parawatch@latest # the CLI; the linked skill follows it
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
The CLI asks the npm registry for the latest version at most once a day, only when you run `status` or `doctor` at a terminal. `"updates": { "check": false }` in `config.json` or `PARAWATCH_UPDATE_CHECK=0` turns that off; it is off in CI. The app looks at GitHub Releases every few hours unless you turn Update automatically off.
|
|
79
|
+
|
|
80
|
+
To remove parawatch:
|
|
81
|
+
|
|
82
|
+
```
|
|
83
|
+
parawatch skill uninstall # the skill links
|
|
84
|
+
parawatch init --remove -f AGENTS.md # the block in each instruction file you added it to
|
|
85
|
+
npm uninstall -g parawatch
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Take the `parawatch hook` lines out of your Claude Code settings if you added them, drag Parawatch.app to the Trash, and delete `~/.local/state/parawatch` (your cards and history) and `~/Library/Logs/Parawatch` if you want those gone too.
|
|
69
89
|
|
|
70
90
|
### Platforms
|
|
71
91
|
|
|
@@ -247,10 +267,43 @@ parawatch watch --from 120 # starting after event 120
|
|
|
247
267
|
|
|
248
268
|
## Storage
|
|
249
269
|
|
|
250
|
-
Everything lives in one append-only file, `~/.local/state/parawatch/events.jsonl` (`$PARAWATCH_HOME`, or `$XDG_STATE_HOME/parawatch`). Writers take a short lock, so several agents can write at once. Next to it: cursors in `cursors/`, running waits in `listeners/`, the last GitHub reading in `github.json`, and the optional `config.json`. Delete the folder to start over.
|
|
270
|
+
Everything lives in one append-only file, `~/.local/state/parawatch/events.jsonl` (`$PARAWATCH_HOME`, or `$XDG_STATE_HOME/parawatch`). Writers take a short lock, so several agents can write at once. Next to it: cursors in `cursors/`, running waits in `listeners/`, the last GitHub reading in `github.json`, the last update check in `update.json`, the version each copy (CLI, app) last ran in `clients/`, and the optional `config.json`. Delete the folder to start over. A shared log lives in the shared folder instead; see [More than one machine](#more-than-one-machine).
|
|
271
|
+
|
|
272
|
+
Every event carries a format number. A copy of parawatch reads a log that a newer copy wrote, skipping what it does not know, but refuses to write to it and says to update. The desktop app's own log is in `~/Library/Logs/Parawatch/main.log` (Help > Show Log File).
|
|
251
273
|
|
|
252
274
|
Desktop notifications for new cards use `osascript` on macOS and `notify-send` on Linux. `PARAWATCH_NOTIFY=0` turns them off. While the desktop app runs it notifies instead, and the CLI stays quiet. To-dos never notify.
|
|
253
275
|
|
|
276
|
+
## More than one machine
|
|
277
|
+
|
|
278
|
+
Agents on a second machine (a desktop PC, a build box) can ask and be answered like the ones on your laptop. Parawatch does not move files between machines itself: point it at a folder that something else keeps in step, and each machine writes only its own log there.
|
|
279
|
+
|
|
280
|
+
```sh
|
|
281
|
+
# on each machine, once, with its own name
|
|
282
|
+
parawatch sync setup ~/agent-sync/parawatch --device mac --adapter syncthing
|
|
283
|
+
parawatch sync setup ~/agent-sync/parawatch --device pc --adapter syncthing
|
|
284
|
+
parawatch sync # this machine, the others, how the sync tool is doing
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
- **Nothing moves until you run `sync setup`.** A machine on its own keeps today's single file and format.
|
|
288
|
+
- **`sync setup` moves this machine's log into the folder.** It becomes `events/<device>.jsonl` with every event, id and seq as it was, so cursors and open cards carry on. The old file is kept beside it as `events.jsonl.before-sync`. A copy of parawatch older than 0.3 on that machine stops writing until it is updated.
|
|
289
|
+
- **Each machine reads every log and writes only its own.** The logs merge in time order. Two machines never write one file, so a sync tool has nothing to merge.
|
|
290
|
+
- **Ids carry the machine once a log is shared:** `pc-c3`, `mac-r2`. Commands take the short form (`parawatch close c3`) while only one machine has that number. Cards from before the move keep their ids.
|
|
291
|
+
- **When two machines answer one card before either has seen the other's answer, the earlier answer stands.** The other one stays on the card, marked as not used, and does not wake the agent. An answer given after seeing the first one counts as usual.
|
|
292
|
+
- **Paths follow the machine.** Attachments, things to open and project folders written on another machine are read as the same place here, under its home folder or the shared folder. Name other folders that sit in different places on each machine (a cloud drive, say): `parawatch sync root gdrive "G:\My Drive"` on one machine and `parawatch sync root gdrive ~/Library/CloudStorage/…/My\ Drive` on the other. Pasted pictures are saved in the shared folder, so every machine can open them.
|
|
293
|
+
- **Waits are seen everywhere.** A card on your laptop says whether an agent on the PC is waiting for it.
|
|
294
|
+
- **Every machine reads GitHub for itself.** The same news is delivered once.
|
|
295
|
+
|
|
296
|
+
The shared folder holds `events/`, `devices/` (each machine's paths, version and last sign of life), `listeners/` and `attachments/`. Cursors, the lock, the GitHub reading and settings stay on each machine.
|
|
297
|
+
|
|
298
|
+
**Sync adapters** are how parawatch asks the sync tool how it is doing, for `parawatch sync`, `doctor` and the app's settings. Pick one with `--adapter` (or `"sync": { "adapter": … }` in `config.json`):
|
|
299
|
+
|
|
300
|
+
| Adapter | Reports |
|
|
301
|
+
|---|---|
|
|
302
|
+
| `syncthing` | Asks the local Syncthing through its REST API (address and API key from its `config.xml`; `PARAWATCH_SYNCTHING_KEY` or `"sync": { "syncthing": { "url", "home" } }` override): connected devices, when the folder was last in step, files on the way, errors. |
|
|
303
|
+
| `folder` | Anything else that keeps a folder in step (a cloud drive, a network share): the other machines' last writes and conflict copies. |
|
|
304
|
+
|
|
305
|
+
An adapter is one object with a `status()` method (`src/core/sync.ts`, `registerAdapter`). Another tool, such as a git checkout or a small server, fits the same shape.
|
|
306
|
+
|
|
254
307
|
## Not goals
|
|
255
308
|
|
|
256
309
|
- Starting or steering agents. Use whatever you already use; parawatch only watches and carries answers back.
|
package/dist/bin/cli.js
CHANGED
|
@@ -2,16 +2,21 @@
|
|
|
2
2
|
import { execFileSync } from "node:child_process";
|
|
3
3
|
import fs from "node:fs";
|
|
4
4
|
import { Command } from "commander";
|
|
5
|
-
import { UserError, addProject, answerCard, beatRun, claimSession, closeCard, commentCard, consumerName, editCard, endRun, linkPr, listeners, listenersFor, noteRun, openCard, prEvent, readInbox, removeProject, startListening, startRun, unlinkPr, waitInbox, } from "../core/ops.js";
|
|
5
|
+
import { UserError, addProject, answerCard, beatRun, claimSession, closeCard, commentCard, consumerName, editCard, endRun, linkPr, listeners, listenersFor, noteRun, openCard, prEvent, findCard, readInbox, readState, removeProject, startListening, startRun, unlinkPr, waitInbox, } from "../core/ops.js";
|
|
6
6
|
import { githubConfig, readCache, resolvePrRef, syncGithub, prViews } from "../core/github.js";
|
|
7
7
|
import { digest, digestText, localDate } from "../core/digest.js";
|
|
8
|
-
import { readEvents } from "../core/store.js";
|
|
8
|
+
import { everywhere, readEvents } from "../core/store.js";
|
|
9
9
|
import { followEvents } from "../core/follow.js";
|
|
10
|
-
import {
|
|
10
|
+
import { adapterNames, setRoot, setupSync, syncAdapter, syncStatus, touchDevice } from "../core/sync.js";
|
|
11
|
+
import { syncText } from "../core/format.js";
|
|
11
12
|
import { collectRuns } from "../core/runs.js";
|
|
12
13
|
import { askRows, cardDetail, cardLine, cardRows, inboxLine, prRows, runsTree, view } from "../core/format.js";
|
|
13
|
-
import { hookContext, notify, writeInstructions } from "../core/integrate.js";
|
|
14
|
-
import { installSkill, skillSource, skillTargets } from "../core/skill.js";
|
|
14
|
+
import { hookContext, notify, removeInstructions, writeInstructions } from "../core/integrate.js";
|
|
15
|
+
import { installSkill, skillSource, skillTargets, uninstallSkill } from "../core/skill.js";
|
|
16
|
+
import { recordClient, runningDesktop, versionSkew } from "../core/clients.js";
|
|
17
|
+
import { doctor, doctorText } from "../core/doctor.js";
|
|
18
|
+
import { checkUpdate, updateLine } from "../core/update.js";
|
|
19
|
+
import { packageVersion } from "../core/version.js";
|
|
15
20
|
import { cardContextLine, resolveGoto } from "../core/desktop.js";
|
|
16
21
|
import { snapshot } from "../core/snapshot.js";
|
|
17
22
|
import { SCHEMA_VERSION } from "../core/types.js";
|
|
@@ -27,15 +32,27 @@ const print = (x) => process.stdout.write(JSON.stringify(x) + "\n");
|
|
|
27
32
|
function currentSession() {
|
|
28
33
|
return process.env.CLAUDE_CODE_SESSION_ID || process.env.CODEX_THREAD_ID || undefined;
|
|
29
34
|
}
|
|
35
|
+
const VERSION = packageVersion();
|
|
36
|
+
/** Where this command line is installed, for the note other copies read. */
|
|
37
|
+
function binPath() {
|
|
38
|
+
try {
|
|
39
|
+
return fs.realpathSync(process.argv[1] ?? "");
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return process.argv[1];
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
recordClient({ name: "cli", version: VERSION, path: binPath() });
|
|
46
|
+
touchDevice(VERSION);
|
|
30
47
|
const program = new Command();
|
|
31
48
|
program
|
|
32
49
|
.name("parawatch")
|
|
33
50
|
.description("See what your agents are doing, what needs you, and wake the right agent when you answer.")
|
|
34
|
-
.version(
|
|
51
|
+
.version(VERSION);
|
|
35
52
|
/** Read GitHub when the last reading is old; never fails the command. */
|
|
36
53
|
async function refreshGithub(force = false) {
|
|
37
54
|
try {
|
|
38
|
-
await syncGithub({ state:
|
|
55
|
+
await syncGithub({ state: readState(), force });
|
|
39
56
|
}
|
|
40
57
|
catch (err) {
|
|
41
58
|
if (force)
|
|
@@ -114,7 +131,7 @@ program
|
|
|
114
131
|
.option("--copy", "also put a line about the card on the clipboard, to paste into that session")
|
|
115
132
|
.option("--print", "print the link instead of opening it")
|
|
116
133
|
.action((target, o) => {
|
|
117
|
-
const g = resolveGoto(target,
|
|
134
|
+
const g = resolveGoto(target, readState());
|
|
118
135
|
if (o.copy && g.card) {
|
|
119
136
|
const line = cardContextLine(g.card);
|
|
120
137
|
try {
|
|
@@ -156,7 +173,7 @@ program
|
|
|
156
173
|
.description("show one card with its answers")
|
|
157
174
|
.option("--json", "JSON output")
|
|
158
175
|
.action((id, o) => {
|
|
159
|
-
const card =
|
|
176
|
+
const card = findCard(readState(), id);
|
|
160
177
|
if (!card)
|
|
161
178
|
throw new UserError(`no card "${id}"`);
|
|
162
179
|
if (o.json) {
|
|
@@ -178,7 +195,7 @@ program
|
|
|
178
195
|
.option("-a, --all", "include closed cards")
|
|
179
196
|
.option("--json", "JSON output")
|
|
180
197
|
.action((o) => {
|
|
181
|
-
const cards = [...
|
|
198
|
+
const cards = [...readState().cards.values()].filter((c) => (o.all || c.status !== "closed") && (!o.project || c.project === o.project));
|
|
182
199
|
if (o.json)
|
|
183
200
|
print({ v: SCHEMA_VERSION, cards });
|
|
184
201
|
else
|
|
@@ -224,9 +241,9 @@ inboxOptions(program.command("inbox").description("print unhandled answers witho
|
|
|
224
241
|
program
|
|
225
242
|
.command("watch")
|
|
226
243
|
.description("stream every new event as one JSON line (for plugins and monitors)")
|
|
227
|
-
.option("--from <seq>", "replay events after this sequence number first", int)
|
|
244
|
+
.option("--from <seq>", "replay events after this sequence number first (in every machine's log)", int)
|
|
228
245
|
.action(async (o) => {
|
|
229
|
-
followEvents(print, o.from);
|
|
246
|
+
followEvents(print, o.from === undefined ? undefined : everywhere(o.from, readEvents()));
|
|
230
247
|
await new Promise(() => { });
|
|
231
248
|
});
|
|
232
249
|
// ------------------------------------------------------------------ runs
|
|
@@ -241,7 +258,7 @@ program
|
|
|
241
258
|
.option("-w, --wide", "show runtime and ids")
|
|
242
259
|
.option("--json", "JSON output")
|
|
243
260
|
.action((o) => {
|
|
244
|
-
const runs = collectRuns(
|
|
261
|
+
const runs = collectRuns(readState(), { sinceHours: o.since, project: o.project, includeEnded: o.all, runtimes: o.runtime, keepRepeats: o.every });
|
|
245
262
|
if (o.json)
|
|
246
263
|
print({ v: SCHEMA_VERSION, runs });
|
|
247
264
|
else
|
|
@@ -307,7 +324,7 @@ pr.command("list")
|
|
|
307
324
|
.action(async (o) => {
|
|
308
325
|
if (!o.offline)
|
|
309
326
|
await refreshGithub();
|
|
310
|
-
const state =
|
|
327
|
+
const state = readState();
|
|
311
328
|
const runs = collectRuns(state, { sinceHours: 72 });
|
|
312
329
|
let prs = prViews(readCache(), state, runs);
|
|
313
330
|
if (o.project)
|
|
@@ -320,7 +337,7 @@ pr.command("list")
|
|
|
320
337
|
pr.command("sync")
|
|
321
338
|
.description("read GitHub now")
|
|
322
339
|
.action(async () => {
|
|
323
|
-
const c = await syncGithub({ state:
|
|
340
|
+
const c = await syncGithub({ state: readState(), force: true });
|
|
324
341
|
if (c.error)
|
|
325
342
|
process.stderr.write(`parawatch: ${c.error}\n`);
|
|
326
343
|
process.stdout.write(`${c.prs.filter((p) => p.state === "open").length} open pull requests on ${(c.hosts ?? []).join(", ") || "no host"}\n`);
|
|
@@ -360,6 +377,8 @@ program
|
|
|
360
377
|
.option("-w, --wide", "show runtime and ids")
|
|
361
378
|
.option("--json", "JSON output")
|
|
362
379
|
.action(async (o) => {
|
|
380
|
+
// the update check runs beside the GitHub reading, and only for a person at a terminal
|
|
381
|
+
const asking = !o.offline && !o.json && process.stdout.isTTY ? checkUpdate(VERSION).catch(() => undefined) : undefined;
|
|
363
382
|
if (!o.offline)
|
|
364
383
|
await refreshGithub();
|
|
365
384
|
const snap = snapshot({ sinceHours: o.since, project: o.project });
|
|
@@ -375,16 +394,16 @@ program
|
|
|
375
394
|
const open = cards.filter((c) => c.status === "open" && c.kind !== "todo");
|
|
376
395
|
const todo = cards.filter((c) => c.status === "open" && c.kind === "todo");
|
|
377
396
|
const answered = cards.filter((c) => c.status === "answered");
|
|
378
|
-
const
|
|
397
|
+
const askers = runs.filter((r) => r.asks);
|
|
379
398
|
const reviews = prs.filter((p) => p.reviewRequested && !p.mine);
|
|
380
399
|
const waiting = prs.filter((p) => !(p.reviewRequested && !p.mine));
|
|
381
400
|
const ls = snap.listeners ?? [];
|
|
382
401
|
const out = [];
|
|
383
402
|
out.push(head("NEEDS YOU", open.length || "nothing"));
|
|
384
403
|
out.push(...cardRows(open, v, now, " "));
|
|
385
|
-
if (
|
|
386
|
-
out.push("", head("ASKED IN CHAT, NO CARD",
|
|
387
|
-
out.push(...askRows(
|
|
404
|
+
if (askers.length) {
|
|
405
|
+
out.push("", head("ASKED IN CHAT, NO CARD", askers.length, " (read from the log)"));
|
|
406
|
+
out.push(...askRows(askers, v, now, " "));
|
|
388
407
|
}
|
|
389
408
|
if (reviews.length) {
|
|
390
409
|
out.push("", head("REVIEWS ASKED OF YOU", reviews.length));
|
|
@@ -411,8 +430,18 @@ program
|
|
|
411
430
|
out.push("", head("AGENTS", runs.length ? `${working} working` : "nothing running"));
|
|
412
431
|
if (runs.length)
|
|
413
432
|
out.push(runsTree(runs, v));
|
|
433
|
+
const dim = (t) => (v.color ? `\x1b[2m${t}\x1b[0m` : t);
|
|
434
|
+
const notes = [];
|
|
414
435
|
if (snap.github?.error && snap.github.enabled)
|
|
415
|
-
|
|
436
|
+
notes.push(`GitHub: ${snap.github.error}`);
|
|
437
|
+
const skew = versionSkew(VERSION, runningDesktop()?.version);
|
|
438
|
+
if (skew)
|
|
439
|
+
notes.push(skew.text);
|
|
440
|
+
const update = updateLine(await asking);
|
|
441
|
+
if (update)
|
|
442
|
+
notes.push(update);
|
|
443
|
+
if (notes.length)
|
|
444
|
+
out.push("", ...notes.map(dim));
|
|
416
445
|
process.stdout.write(out.join("\n") + "\n");
|
|
417
446
|
});
|
|
418
447
|
// ------------------------------------------------------------------ skill
|
|
@@ -433,6 +462,15 @@ skill
|
|
|
433
462
|
process.stdout.write(`${r.runtime.padEnd(12)} ${r.result.padEnd(15)} ${r.path}${r.note ? ` (${r.note})` : ""}\n`);
|
|
434
463
|
}
|
|
435
464
|
});
|
|
465
|
+
skill
|
|
466
|
+
.command("uninstall")
|
|
467
|
+
.description("remove the skill links that install made")
|
|
468
|
+
.option("--runtime <name>", "only this runtime: claude-code or codex (repeatable)", collect)
|
|
469
|
+
.action((o) => {
|
|
470
|
+
for (const r of uninstallSkill(skillTargets(), { only: o.runtime })) {
|
|
471
|
+
process.stdout.write(`${r.runtime.padEnd(12)} ${r.result.padEnd(14)} ${r.path}${r.note ? ` (${r.note})` : ""}\n`);
|
|
472
|
+
}
|
|
473
|
+
});
|
|
436
474
|
// ------------------------------------------------------------------ setup
|
|
437
475
|
const project = program.command("project").description("projects you work on");
|
|
438
476
|
project
|
|
@@ -444,7 +482,7 @@ project
|
|
|
444
482
|
.command("list")
|
|
445
483
|
.option("--json", "JSON output")
|
|
446
484
|
.action((o) => {
|
|
447
|
-
const ps = [...
|
|
485
|
+
const ps = [...readState().projects.values()];
|
|
448
486
|
if (o.json)
|
|
449
487
|
print({ v: SCHEMA_VERSION, projects: ps });
|
|
450
488
|
else
|
|
@@ -455,10 +493,63 @@ program
|
|
|
455
493
|
.description("add parawatch instructions for agents to an instruction file")
|
|
456
494
|
.option("-f, --file <path>", "file to write", "AGENTS.md")
|
|
457
495
|
.option("-p, --project <name>", "project name to put in the examples")
|
|
496
|
+
.option("--remove", "take the parawatch block out of the file instead")
|
|
458
497
|
.action((o) => {
|
|
498
|
+
if (o.remove) {
|
|
499
|
+
process.stdout.write(removeInstructions(o.file) ? `Removed the parawatch block from ${o.file}\n` : `${o.file} has no parawatch block\n`);
|
|
500
|
+
return;
|
|
501
|
+
}
|
|
459
502
|
const changed = writeInstructions(o.file, o.project);
|
|
460
503
|
process.stdout.write(changed ? `Updated ${o.file}\n` : `${o.file} already up to date\n`);
|
|
461
504
|
});
|
|
505
|
+
program
|
|
506
|
+
.command("doctor")
|
|
507
|
+
.description("how parawatch is set up here: versions, store, desktop app, skill, GitHub; paste it into bug reports")
|
|
508
|
+
.option("--offline", "do not ask the npm registry for the latest version")
|
|
509
|
+
.option("--json", "JSON output")
|
|
510
|
+
.action(async (o) => {
|
|
511
|
+
const update = o.offline ? undefined : await checkUpdate(VERSION, { force: true }).catch(() => undefined);
|
|
512
|
+
const checks = await doctor({ version: VERSION, bin: binPath(), update });
|
|
513
|
+
if (o.json)
|
|
514
|
+
print({ v: SCHEMA_VERSION, version: VERSION, platform: `${process.platform}-${process.arch}`, checks });
|
|
515
|
+
else
|
|
516
|
+
process.stdout.write(`${doctorText(checks, view({}).color)}\n`);
|
|
517
|
+
});
|
|
518
|
+
// ------------------------------------------------------------------ sharing between machines
|
|
519
|
+
const sync = program.command("sync").description("share cards and answers with your other machines through a synced folder");
|
|
520
|
+
sync
|
|
521
|
+
.command("status", { isDefault: true })
|
|
522
|
+
.description("how sharing is doing: this machine, the others, the sync tool")
|
|
523
|
+
.option("--json", "JSON output")
|
|
524
|
+
.action(async (o) => {
|
|
525
|
+
const st = await syncStatus();
|
|
526
|
+
if (o.json)
|
|
527
|
+
print({ v: SCHEMA_VERSION, sync: st ?? null });
|
|
528
|
+
else
|
|
529
|
+
process.stdout.write(syncText(st, view({}).color) + "\n");
|
|
530
|
+
});
|
|
531
|
+
sync
|
|
532
|
+
.command("setup <folder>")
|
|
533
|
+
.description("start writing to a shared folder; this machine's log moves there, nothing is lost")
|
|
534
|
+
.option("-d, --device <name>", "this machine's name in the folder (a-z, 0-9, -); defaults to the host name")
|
|
535
|
+
.option("-a, --adapter <name>", `the tool that carries the folder: ${adapterNames().join(", ")}`)
|
|
536
|
+
.action((folder, o) => {
|
|
537
|
+
const r = setupSync({ dir: folder, device: o.device, adapter: o.adapter, version: VERSION });
|
|
538
|
+
const lines = [
|
|
539
|
+
`This machine writes to ${r.dir} as "${r.device}" (adapter: ${r.adapter}, ${syncAdapter(r.adapter).about}).`,
|
|
540
|
+
r.moved ? `Moved ${r.moved} events from this machine's log; the old file is kept at ${r.backup}.` : "",
|
|
541
|
+
r.others.length ? `Other machines already here: ${r.others.join(", ")}.` : "No other machine has written here yet. Run the same command on the next one with its own --device.",
|
|
542
|
+
"Older parawatch copies (before 0.3) on this machine stop writing until they are updated.",
|
|
543
|
+
];
|
|
544
|
+
process.stdout.write(lines.filter(Boolean).join("\n") + "\n");
|
|
545
|
+
});
|
|
546
|
+
sync
|
|
547
|
+
.command("root <name> [folder]")
|
|
548
|
+
.description("name a folder on this machine (a cloud drive, say) so paths under it open on the others; no folder removes it")
|
|
549
|
+
.action((name, folder) => {
|
|
550
|
+
const d = setRoot(name, folder, VERSION);
|
|
551
|
+
process.stdout.write(Object.entries(d.roots ?? {}).map(([k, v]) => `${k} ${v}`).join("\n") + (d.roots ? "\n" : "No named folders.\n"));
|
|
552
|
+
});
|
|
462
553
|
program
|
|
463
554
|
.command("hook <runtime>")
|
|
464
555
|
.description("hook entry point; 'claude-code' reads the hook JSON on stdin")
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which copies of parawatch use this store. The command line and the desktop app each carry their
|
|
3
|
+
* own copy of the code and are updated apart, so each leaves a note of its version here; the other
|
|
4
|
+
* reads it and says when they differ.
|
|
5
|
+
*/
|
|
6
|
+
import fs from "node:fs";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
import { homeDir } from "./store.js";
|
|
9
|
+
import { compareVersions } from "./semver.js";
|
|
10
|
+
/** A note older than this is rewritten even when nothing changed, so `at` stays roughly current. */
|
|
11
|
+
const REFRESH_MS = 6 * 3_600_000;
|
|
12
|
+
function clientPath(name) {
|
|
13
|
+
return path.join(homeDir(), "clients", `${name}.json`);
|
|
14
|
+
}
|
|
15
|
+
export function readClient(name) {
|
|
16
|
+
try {
|
|
17
|
+
const c = JSON.parse(fs.readFileSync(clientPath(name), "utf8"));
|
|
18
|
+
return typeof c.version === "string" ? c : undefined;
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return undefined;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
/** Leave this copy's note. Cheap when nothing changed; never throws. */
|
|
25
|
+
export function recordClient(info, now = Date.now()) {
|
|
26
|
+
try {
|
|
27
|
+
const old = readClient(info.name);
|
|
28
|
+
const same = old && old.version === info.version && old.path === info.path && old.pid === info.pid && old.log === info.log;
|
|
29
|
+
if (same && now - Date.parse(old.at) < REFRESH_MS)
|
|
30
|
+
return;
|
|
31
|
+
const file = clientPath(info.name);
|
|
32
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
33
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
34
|
+
fs.writeFileSync(tmp, JSON.stringify({ ...info, at: new Date(now).toISOString() }, null, 2) + "\n");
|
|
35
|
+
fs.renameSync(tmp, file);
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
// a note for other copies; nothing depends on it
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
export function processAlive(pid) {
|
|
42
|
+
if (!pid || !Number.isInteger(pid) || pid <= 0)
|
|
43
|
+
return false;
|
|
44
|
+
try {
|
|
45
|
+
process.kill(pid, 0);
|
|
46
|
+
return true;
|
|
47
|
+
}
|
|
48
|
+
catch (err) {
|
|
49
|
+
return err.code === "EPERM";
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
/** The desktop app's note while it runs. */
|
|
53
|
+
export function runningDesktop() {
|
|
54
|
+
const d = readClient("desktop");
|
|
55
|
+
return d && processAlive(d.pid) ? d : undefined;
|
|
56
|
+
}
|
|
57
|
+
export const CLI_UPDATE = "npm i -g parawatch@latest";
|
|
58
|
+
/** When the command line and the app are different versions, which one to update. */
|
|
59
|
+
export function versionSkew(cli, desktop) {
|
|
60
|
+
if (!cli || !desktop)
|
|
61
|
+
return undefined;
|
|
62
|
+
const c = compareVersions(cli, desktop);
|
|
63
|
+
if (c === 0)
|
|
64
|
+
return undefined;
|
|
65
|
+
const older = c < 0 ? "cli" : "desktop";
|
|
66
|
+
const fix = older === "cli" ? CLI_UPDATE : "update the app (Check for Updates in its menu)";
|
|
67
|
+
return { cli, desktop, older, text: `The desktop app is ${desktop} and the command line is ${cli}: ${fix}.` };
|
|
68
|
+
}
|
|
69
|
+
//# sourceMappingURL=clients.js.map
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `parawatch doctor`: one look at how parawatch is set up on this machine, for setting it up on a
|
|
3
|
+
* new one and for bug reports. It only reads; every line that needs work says what to run.
|
|
4
|
+
*/
|
|
5
|
+
import fs from "node:fs";
|
|
6
|
+
import os from "node:os";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
import { CLI_UPDATE, readClient, runningDesktop, versionSkew } from "./clients.js";
|
|
9
|
+
import { ghHosts, githubConfig, readCache, runGh } from "./github.js";
|
|
10
|
+
import { instructionState } from "./integrate.js";
|
|
11
|
+
import { listeners } from "./ops.js";
|
|
12
|
+
import { fold } from "./state.js";
|
|
13
|
+
import { deviceName, eventFiles, homeDir, readEvents, sharedDir, storeFormat } from "./store.js";
|
|
14
|
+
import { readDevice, syncStatus } from "./sync.js";
|
|
15
|
+
import { skillState, skillTargets } from "./skill.js";
|
|
16
|
+
import { STORE_FORMAT } from "./types.js";
|
|
17
|
+
import { updateChecksOn } from "./update.js";
|
|
18
|
+
function claudeSettingsFiles() {
|
|
19
|
+
const dir = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), ".claude");
|
|
20
|
+
return [path.join(dir, "settings.json"), path.join(dir, "settings.local.json")];
|
|
21
|
+
}
|
|
22
|
+
function bytes(n) {
|
|
23
|
+
return n < 1024 ? `${n} B` : n < 1024 * 1024 ? `${(n / 1024).toFixed(0)} KB` : `${(n / 1024 / 1024).toFixed(1)} MB`;
|
|
24
|
+
}
|
|
25
|
+
export async function doctor(input) {
|
|
26
|
+
const out = [];
|
|
27
|
+
const add = (area, state, text, fix) => out.push({ area, state, text, ...(fix ? { fix } : {}) });
|
|
28
|
+
// the command line
|
|
29
|
+
add("cli", "ok", `parawatch ${input.version} on node ${process.versions.node}${input.bin ? ` at ${input.bin}` : ""}`);
|
|
30
|
+
const u = input.update;
|
|
31
|
+
if (!u)
|
|
32
|
+
add("updates", "off", updateChecksOn() ? "not checked" : "update checks are off");
|
|
33
|
+
else if (u.newer)
|
|
34
|
+
add("updates", "warn", `${u.latest} is out`, CLI_UPDATE);
|
|
35
|
+
else if (u.error)
|
|
36
|
+
add("updates", "info", `could not check: ${u.error}`);
|
|
37
|
+
else
|
|
38
|
+
add("updates", "ok", `up to date${u.latest ? ` (latest ${u.latest})` : ""}`);
|
|
39
|
+
// the store
|
|
40
|
+
const events = readEvents();
|
|
41
|
+
let size = 0;
|
|
42
|
+
for (const f of eventFiles()) {
|
|
43
|
+
try {
|
|
44
|
+
size += fs.statSync(f.file).size;
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
// no events yet
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
const format = storeFormat(events);
|
|
51
|
+
const shared = sharedDir();
|
|
52
|
+
const where = shared ? `${shared} (shared) · this machine ${homeDir()}` : homeDir();
|
|
53
|
+
if (format > STORE_FORMAT)
|
|
54
|
+
add("store", "warn", `${where} has format ${format}, newer than this copy reads (${STORE_FORMAT})`, CLI_UPDATE);
|
|
55
|
+
else
|
|
56
|
+
add("store", "ok", `${where} · ${events.length} events, ${bytes(size)}`);
|
|
57
|
+
// other machines
|
|
58
|
+
const st = input.sync === undefined ? await syncStatus().catch(() => undefined) : input.sync ?? undefined;
|
|
59
|
+
if (!shared)
|
|
60
|
+
add("sync", "off", "this machine only", "to share with another machine: parawatch sync setup <folder> --device <name>");
|
|
61
|
+
else {
|
|
62
|
+
const me = deviceName();
|
|
63
|
+
const mine = readDevice(me);
|
|
64
|
+
if (mine && mine.host !== `${os.hostname()}:${os.homedir()}`)
|
|
65
|
+
add("sync", "warn", `another machine (${mine.host}) also writes as "${me}"`, "give each machine its own name: parawatch sync setup <folder> --device <name>");
|
|
66
|
+
if (!st)
|
|
67
|
+
add("sync", "info", `${me} · ${shared}`);
|
|
68
|
+
else {
|
|
69
|
+
const peers = st.peers.map((p) => `${p.name} ${p.connected === true ? "connected" : p.connected === false ? "not connected" : "seen"}`).join(", ");
|
|
70
|
+
add("sync", st.error || !st.ok ? "warn" : "ok", `${me} · ${st.adapter} · ${peers || "no other machine yet"}${st.error ? ` · ${st.error}` : ""}`);
|
|
71
|
+
if (st.conflicts.length)
|
|
72
|
+
add("sync", "warn", `${st.conflicts.length} conflict copy(ies): ${st.conflicts.slice(0, 3).join(", ")}`, "each machine writes only its own file; look for two machines using one device name");
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
const state = fold(events);
|
|
76
|
+
add("projects", state.projects.size ? "ok" : "info", state.projects.size ? [...state.projects.keys()].join(", ") : "none registered (claims still work)");
|
|
77
|
+
// the desktop app
|
|
78
|
+
const desktop = runningDesktop();
|
|
79
|
+
const seen = desktop ?? readClient("desktop");
|
|
80
|
+
if (desktop)
|
|
81
|
+
add("desktop", "ok", `Parawatch ${desktop.version} is running${desktop.log ? ` · log ${desktop.log}` : ""}`);
|
|
82
|
+
else if (seen)
|
|
83
|
+
add("desktop", "info", `not running (last seen ${seen.version}, ${seen.at.slice(0, 10)})${seen.log ? ` · log ${seen.log}` : ""}`);
|
|
84
|
+
else
|
|
85
|
+
add("desktop", "info", "not used on this machine (the command line works alone)");
|
|
86
|
+
const skew = versionSkew(input.version, desktop?.version);
|
|
87
|
+
if (skew)
|
|
88
|
+
add("versions", "warn", skew.text, skew.older === "cli" ? CLI_UPDATE : "Parawatch > Check for Updates");
|
|
89
|
+
// what agents are told
|
|
90
|
+
for (const t of input.targets ?? skillTargets()) {
|
|
91
|
+
const s = skillState(t);
|
|
92
|
+
const where = path.join(t.dir, "parawatch");
|
|
93
|
+
if (s.state === "linked")
|
|
94
|
+
add(`skill:${t.runtime}`, "ok", where);
|
|
95
|
+
else if (s.state === "linked elsewhere")
|
|
96
|
+
add(`skill:${t.runtime}`, "warn", `${where} links to ${s.target}, not to this copy`, "parawatch skill install");
|
|
97
|
+
else if (s.state === "folder")
|
|
98
|
+
add(`skill:${t.runtime}`, "info", `${where} is a copy; it will not follow updates`, "parawatch skill install --force");
|
|
99
|
+
else if (s.state === "missing")
|
|
100
|
+
add(`skill:${t.runtime}`, "warn", "not installed", "parawatch skill install");
|
|
101
|
+
else
|
|
102
|
+
add(`skill:${t.runtime}`, "off", `${t.runtime} not found (${t.home})`);
|
|
103
|
+
}
|
|
104
|
+
const cwd = input.cwd ?? process.cwd();
|
|
105
|
+
const files = ["AGENTS.md", "CLAUDE.md"].map((f) => path.join(cwd, f));
|
|
106
|
+
const found = files.map((f) => ({ f, s: instructionState(f) })).filter((x) => x.s !== "absent");
|
|
107
|
+
if (!found.length)
|
|
108
|
+
add("instructions", "info", `no parawatch block in AGENTS.md or CLAUDE.md in ${cwd}`, "parawatch init");
|
|
109
|
+
for (const x of found) {
|
|
110
|
+
if (x.s === "current")
|
|
111
|
+
add("instructions", "ok", x.f);
|
|
112
|
+
else
|
|
113
|
+
add("instructions", "warn", `${x.f} has an older parawatch block`, `parawatch init -f ${path.basename(x.f)}`);
|
|
114
|
+
}
|
|
115
|
+
const hooked = (input.claudeSettings ?? claudeSettingsFiles()).filter((f) => {
|
|
116
|
+
try {
|
|
117
|
+
return fs.readFileSync(f, "utf8").includes("parawatch hook");
|
|
118
|
+
}
|
|
119
|
+
catch {
|
|
120
|
+
return false;
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
add("hook", hooked.length ? "ok" : "info", hooked.length ? `Claude Code hook in ${hooked.join(", ")}` : "no Claude Code hook (optional; see the README)");
|
|
124
|
+
// GitHub
|
|
125
|
+
const g = githubConfig();
|
|
126
|
+
if (!g.enabled)
|
|
127
|
+
add("github", "off", "turned off in config.json or PARAWATCH_GITHUB=0");
|
|
128
|
+
else {
|
|
129
|
+
const gh = input.gh ?? runGh;
|
|
130
|
+
let ghVersion;
|
|
131
|
+
try {
|
|
132
|
+
ghVersion = (await gh(["--version"])).split("\n")[0]?.trim();
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
ghVersion = undefined;
|
|
136
|
+
}
|
|
137
|
+
if (!ghVersion)
|
|
138
|
+
add("github", "off", "gh not found, so pull requests are not followed (nothing else needs it)", "install gh and run gh auth login");
|
|
139
|
+
else {
|
|
140
|
+
const hosts = await ghHosts(gh);
|
|
141
|
+
if (!hosts.length)
|
|
142
|
+
add("github", "warn", `${ghVersion}, but not signed in`, "gh auth login");
|
|
143
|
+
else {
|
|
144
|
+
const c = readCache();
|
|
145
|
+
const when = c.fetchedAt ? `last read ${c.fetchedAt.replace("T", " ").slice(0, 16)}` : "not read yet";
|
|
146
|
+
add("github", c.error ? "warn" : "ok", `${hosts.join(", ")} · ${c.prs.length} PRs · ${when}${c.error ? ` · ${c.error}` : ""}`, c.error ? "parawatch pr sync" : undefined);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
const ls = listeners();
|
|
151
|
+
add("waits", "info", ls.length ? `${ls.length} agent wait(s) armed` : "no agent is waiting right now");
|
|
152
|
+
return out;
|
|
153
|
+
}
|
|
154
|
+
const MARK = { ok: "✓", warn: "!", off: "-", info: "·" };
|
|
155
|
+
export function doctorText(checks, color = false) {
|
|
156
|
+
const width = Math.max(...checks.map((c) => c.area.length));
|
|
157
|
+
return checks
|
|
158
|
+
.map((c) => {
|
|
159
|
+
const mark = color && c.state === "warn" ? `\x1b[33m${MARK[c.state]}\x1b[0m` : color && c.state === "ok" ? `\x1b[32m${MARK[c.state]}\x1b[0m` : MARK[c.state];
|
|
160
|
+
const fix = c.fix ? (color ? `\n${" ".repeat(width + 4)}\x1b[2m→ ${c.fix}\x1b[0m` : `\n${" ".repeat(width + 4)}→ ${c.fix}`) : "";
|
|
161
|
+
return `${mark} ${c.area.padEnd(width)} ${c.text}${fix}`;
|
|
162
|
+
})
|
|
163
|
+
.join("\n");
|
|
164
|
+
}
|
|
165
|
+
//# sourceMappingURL=doctor.js.map
|