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 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 { fold } from "../core/state.js";
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("0.2.0");
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: fold(readEvents()), force });
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, fold(readEvents()));
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 = fold(readEvents()).cards.get(id);
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 = [...fold(readEvents()).cards.values()].filter((c) => (o.all || c.status !== "closed") && (!o.project || c.project === o.project));
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(fold(readEvents()), { sinceHours: o.since, project: o.project, includeEnded: o.all, runtimes: o.runtime, keepRepeats: o.every });
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 = fold(readEvents());
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: fold(readEvents()), force: true });
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 asking = runs.filter((r) => r.asks);
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 (asking.length) {
386
- out.push("", head("ASKED IN CHAT, NO CARD", asking.length, " (read from the log)"));
387
- out.push(...askRows(asking, v, now, " "));
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
- out.push("", v.color ? `\x1b[2mGitHub: ${snap.github.error}\x1b[0m` : `GitHub: ${snap.github.error}`);
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 = [...fold(readEvents()).projects.values()];
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
@@ -0,0 +1,4 @@
1
+ /** A mistake the user can fix: the CLI prints the message alone, without a stack. */
2
+ export class UserError extends Error {
3
+ }
4
+ //# sourceMappingURL=errors.js.map