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.
package/README.md CHANGED
@@ -154,7 +154,7 @@ parawatch ask -p api "Merge PR 88?" --action "gh pr merge 88 --squash" --preview
154
154
  parawatch try -p game "Try build 139" --open /path/Game.app --look "does the glass read?"
155
155
  parawatch block -p app "Install the iPad simulator runtime"
156
156
  parawatch todo -p game "Check the GitHub bill" --since 2026-10-01
157
- parawatch wait -p game # run in the background; exits when the human answers
157
+ parawatch wait # run in the background; exits when the human answers
158
158
  parawatch comment c4 "baking the top box now" # a note on the card; does not wake anyone
159
159
  parawatch close c4 -n "used the top box"
160
160
  parawatch run start -p game "overnight bench" --heartbeat 600 --log bench.log # a job with no agent log
@@ -167,7 +167,7 @@ An approval (`ask --action`) shows the exact command and the text it sends, with
167
167
 
168
168
  `parawatch edit <card>` changes a card after the fact: its kind, wording, task, who asked or `--since`. `parawatch goto <card>` opens the session that asked in the Claude desktop app (`--copy` also puts a line about the card on the clipboard, to paste there); sessions outside the desktop app get the `claude --resume` command instead.
169
169
 
170
- `wait` prints the answers, and news on the project's linked pull requests, and exits with code 0. With `--timeout <seconds>` it exits with code 2 when nothing came. An answer given before `wait` starts is not lost: each consumer has its own cursor, and `wait` returns everything after it. While a `wait` runs, cards say an agent is waiting; when none does, an answered card says the answer is kept until the agent next checks.
170
+ `wait` prints the answers, and news on linked pull requests, and exits with code 0. Run from an agent session, it reads that session's own cards and pull requests, and the ones no session owns in the project it claimed, with a cursor of its own: four sessions in one project each wake only for their own news. `wait -p <project>` reads the whole project, for a lead. With `--timeout <seconds>` it exits with code 2 when nothing came. An answer given before `wait` starts is not lost: each consumer has its own cursor, and `wait` returns everything after it. While a `wait` runs, cards say an agent is waiting; when none does, an answered card says the answer is kept until the agent next checks.
171
171
 
172
172
  ### Agent skill
173
173
 
@@ -177,9 +177,9 @@ An approval (`ask --action`) shows the exact command and the text it sends, with
177
177
 
178
178
  ### Waking a Claude Code session
179
179
 
180
- Claude Code re-invokes a session when one of its background commands exits. So an agent that runs `parawatch wait -p <project>` as a background command is woken by your answer, with the answer as the command's output.
180
+ Claude Code re-invokes a session when one of its background commands exits. So an agent that runs `parawatch wait` as a background command is woken by your answer, with the answer as the command's output.
181
181
 
182
- As a backstop, the hook adds any unhandled answers to the next turn of a session in that project:
182
+ As a backstop, the hook adds the session's unhandled answers and pull request news to its next turn:
183
183
 
184
184
  ```json
185
185
  {
@@ -267,12 +267,45 @@ parawatch watch --from 120 # starting after event 120
267
267
 
268
268
  ## Storage
269
269
 
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.
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
271
 
272
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).
273
273
 
274
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.
275
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
+ - **Runs are seen everywhere.** Each machine writes a summary of the sessions its agent logs show (state, title, project, last activity, what each does) to `runs/<device>.json` about every half minute while `status`, a `wait` or the app runs. The other machines show those runs marked with the machine. The logs stay where they are. A machine whose summary is older than 15 minutes drops out of the list.
295
+ - **Every machine reads GitHub for itself.** The same news is delivered once.
296
+ - **Cards reach you as far as the sync tool does.** With Syncthing, leave global discovery and relays on (its defaults) to stay in step from another network; with them off, machines only meet on the same network or a VPN. `parawatch sync` shows each connection's address, a relay included.
297
+
298
+ The shared folder holds `events/`, `devices/` (each machine's paths, version and last sign of life), `runs/`, `listeners/` and `attachments/`. Cursors, the lock, the GitHub reading and settings stay on each machine.
299
+
300
+ **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`):
301
+
302
+ | Adapter | Reports |
303
+ |---|---|
304
+ | `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. |
305
+ | `folder` | Anything else that keeps a folder in step (a cloud drive, a network share): the other machines' last writes and conflict copies. |
306
+
307
+ 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.
308
+
276
309
  ## Not goals
277
310
 
278
311
  - Starting or steering agents. Use whatever you already use; parawatch only watches and carries answers back.
package/dist/bin/cli.js CHANGED
@@ -2,13 +2,14 @@
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, inboxScope, 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";
11
- import { collectRuns } from "../core/runs.js";
10
+ import { adapterNames, setRoot, setupSync, syncAdapter, syncStatus, touchDevice } from "../core/sync.js";
11
+ import { syncText } from "../core/format.js";
12
+ import { collectRuns, shareRunsDue } from "../core/runs.js";
12
13
  import { askRows, cardDetail, cardLine, cardRows, inboxLine, prRows, runsTree, view } from "../core/format.js";
13
14
  import { hookContext, notify, removeInstructions, writeInstructions } from "../core/integrate.js";
14
15
  import { installSkill, skillSource, skillTargets, uninstallSkill } from "../core/skill.js";
@@ -42,6 +43,7 @@ function binPath() {
42
43
  }
43
44
  }
44
45
  recordClient({ name: "cli", version: VERSION, path: binPath() });
46
+ touchDevice(VERSION);
45
47
  const program = new Command();
46
48
  program
47
49
  .name("parawatch")
@@ -50,7 +52,7 @@ program
50
52
  /** Read GitHub when the last reading is old; never fails the command. */
51
53
  async function refreshGithub(force = false) {
52
54
  try {
53
- await syncGithub({ state: fold(readEvents()), force });
55
+ await syncGithub({ state: readState(), force });
54
56
  }
55
57
  catch (err) {
56
58
  if (force)
@@ -129,7 +131,7 @@ program
129
131
  .option("--copy", "also put a line about the card on the clipboard, to paste into that session")
130
132
  .option("--print", "print the link instead of opening it")
131
133
  .action((target, o) => {
132
- const g = resolveGoto(target, fold(readEvents()));
134
+ const g = resolveGoto(target, readState());
133
135
  if (o.copy && g.card) {
134
136
  const line = cardContextLine(g.card);
135
137
  try {
@@ -171,7 +173,7 @@ program
171
173
  .description("show one card with its answers")
172
174
  .option("--json", "JSON output")
173
175
  .action((id, o) => {
174
- const card = fold(readEvents()).cards.get(id);
176
+ const card = findCard(readState(), id);
175
177
  if (!card)
176
178
  throw new UserError(`no card "${id}"`);
177
179
  if (o.json) {
@@ -193,7 +195,7 @@ program
193
195
  .option("-a, --all", "include closed cards")
194
196
  .option("--json", "JSON output")
195
197
  .action((o) => {
196
- 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));
197
199
  if (o.json)
198
200
  print({ v: SCHEMA_VERSION, cards });
199
201
  else
@@ -202,21 +204,30 @@ program
202
204
  // ------------------------------------------------------------------ inbox
203
205
  function inboxOptions(c) {
204
206
  return c
205
- .option("-p, --project <name>", "only answers for this project")
207
+ .option("-p, --project <name>", "everything in this project, whoever owns it (for a lead)")
206
208
  .option("--card <id>", "only answers to this card")
207
- .option("--as <consumer>", "cursor name (defaults to the project)")
209
+ .option("--as <consumer>", "cursor name (defaults to the session, or the project with -p)")
208
210
  .option("--json", "JSON output");
209
211
  }
210
- inboxOptions(program.command("wait").description("block until the human answers, print the answers and exit"))
212
+ inboxOptions(program
213
+ .command("wait")
214
+ .description("block until the human answers or a linked pull request has news, print it and exit. In an agent session it reads that session's own cards and pull requests"))
211
215
  .option("--timeout <seconds>", "give up after this long (exit code 2); 0 waits forever", int, 0)
212
216
  .action(async (o) => {
213
- startListening({ consumer: consumerName(o), project: o.project, card: o.card, session: currentSession() });
214
- // while waiting, keep GitHub fresh so news on linked pull requests wakes this wait
215
- const tick = () => void refreshGithub();
217
+ const session = currentSession();
218
+ const { consumer, filter } = inboxScope(o, session);
219
+ startListening({ consumer, project: filter.project, card: filter.card, session, ...(filter.session ? { scope: "session" } : {}) });
220
+ // while waiting, keep GitHub fresh so news on linked pull requests wakes this wait, and tell
221
+ // the other machines what runs here
222
+ const tick = () => {
223
+ void refreshGithub();
224
+ if (shareRunsDue())
225
+ collectRuns(readState(), { sinceHours: 6 });
226
+ };
216
227
  tick();
217
228
  const timer = setInterval(tick, 60_000);
218
229
  timer.unref();
219
- const items = await waitInbox(consumerName(o), { project: o.project, card: o.card }, o.timeout * 1000);
230
+ const items = await waitInbox(consumer, filter, o.timeout * 1000);
220
231
  clearInterval(timer);
221
232
  if (!items.length) {
222
233
  process.exitCode = 2;
@@ -230,7 +241,8 @@ inboxOptions(program.command("wait").description("block until the human answers,
230
241
  inboxOptions(program.command("inbox").description("print unhandled answers without waiting"))
231
242
  .option("--peek", "do not mark them as seen")
232
243
  .action((o) => {
233
- const items = readInbox(consumerName(o), { project: o.project, card: o.card }, !o.peek);
244
+ const { consumer, filter } = inboxScope(o, currentSession());
245
+ const items = readInbox(consumer, filter, !o.peek);
234
246
  if (o.json)
235
247
  print({ v: SCHEMA_VERSION, items });
236
248
  else if (items.length)
@@ -239,9 +251,9 @@ inboxOptions(program.command("inbox").description("print unhandled answers witho
239
251
  program
240
252
  .command("watch")
241
253
  .description("stream every new event as one JSON line (for plugins and monitors)")
242
- .option("--from <seq>", "replay events after this sequence number first", int)
254
+ .option("--from <seq>", "replay events after this sequence number first (in every machine's log)", int)
243
255
  .action(async (o) => {
244
- followEvents(print, o.from);
256
+ followEvents(print, o.from === undefined ? undefined : everywhere(o.from, readEvents()));
245
257
  await new Promise(() => { });
246
258
  });
247
259
  // ------------------------------------------------------------------ runs
@@ -256,7 +268,7 @@ program
256
268
  .option("-w, --wide", "show runtime and ids")
257
269
  .option("--json", "JSON output")
258
270
  .action((o) => {
259
- const runs = collectRuns(fold(readEvents()), { sinceHours: o.since, project: o.project, includeEnded: o.all, runtimes: o.runtime, keepRepeats: o.every });
271
+ const runs = collectRuns(readState(), { sinceHours: o.since, project: o.project, includeEnded: o.all, runtimes: o.runtime, keepRepeats: o.every });
260
272
  if (o.json)
261
273
  print({ v: SCHEMA_VERSION, runs });
262
274
  else
@@ -322,7 +334,7 @@ pr.command("list")
322
334
  .action(async (o) => {
323
335
  if (!o.offline)
324
336
  await refreshGithub();
325
- const state = fold(readEvents());
337
+ const state = readState();
326
338
  const runs = collectRuns(state, { sinceHours: 72 });
327
339
  let prs = prViews(readCache(), state, runs);
328
340
  if (o.project)
@@ -335,7 +347,7 @@ pr.command("list")
335
347
  pr.command("sync")
336
348
  .description("read GitHub now")
337
349
  .action(async () => {
338
- const c = await syncGithub({ state: fold(readEvents()), force: true });
350
+ const c = await syncGithub({ state: readState(), force: true });
339
351
  if (c.error)
340
352
  process.stderr.write(`parawatch: ${c.error}\n`);
341
353
  process.stdout.write(`${c.prs.filter((p) => p.state === "open").length} open pull requests on ${(c.hosts ?? []).join(", ") || "no host"}\n`);
@@ -480,7 +492,7 @@ project
480
492
  .command("list")
481
493
  .option("--json", "JSON output")
482
494
  .action((o) => {
483
- const ps = [...fold(readEvents()).projects.values()];
495
+ const ps = [...readState().projects.values()];
484
496
  if (o.json)
485
497
  print({ v: SCHEMA_VERSION, projects: ps });
486
498
  else
@@ -513,6 +525,41 @@ program
513
525
  else
514
526
  process.stdout.write(`${doctorText(checks, view({}).color)}\n`);
515
527
  });
528
+ // ------------------------------------------------------------------ sharing between machines
529
+ const sync = program.command("sync").description("share cards and answers with your other machines through a synced folder");
530
+ sync
531
+ .command("status", { isDefault: true })
532
+ .description("how sharing is doing: this machine, the others, the sync tool")
533
+ .option("--json", "JSON output")
534
+ .action(async (o) => {
535
+ const st = await syncStatus();
536
+ if (o.json)
537
+ print({ v: SCHEMA_VERSION, sync: st ?? null });
538
+ else
539
+ process.stdout.write(syncText(st, view({}).color) + "\n");
540
+ });
541
+ sync
542
+ .command("setup <folder>")
543
+ .description("start writing to a shared folder; this machine's log moves there, nothing is lost")
544
+ .option("-d, --device <name>", "this machine's name in the folder (a-z, 0-9, -); defaults to the host name")
545
+ .option("-a, --adapter <name>", `the tool that carries the folder: ${adapterNames().join(", ")}`)
546
+ .action((folder, o) => {
547
+ const r = setupSync({ dir: folder, device: o.device, adapter: o.adapter, version: VERSION });
548
+ const lines = [
549
+ `This machine writes to ${r.dir} as "${r.device}" (adapter: ${r.adapter}, ${syncAdapter(r.adapter).about}).`,
550
+ r.moved ? `Moved ${r.moved} events from this machine's log; the old file is kept at ${r.backup}.` : "",
551
+ 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.",
552
+ "Older parawatch copies (before 0.3) on this machine stop writing until they are updated.",
553
+ ];
554
+ process.stdout.write(lines.filter(Boolean).join("\n") + "\n");
555
+ });
556
+ sync
557
+ .command("root <name> [folder]")
558
+ .description("name a folder on this machine (a cloud drive, say) so paths under it open on the others; no folder removes it")
559
+ .action((name, folder) => {
560
+ const d = setRoot(name, folder, VERSION);
561
+ process.stdout.write(Object.entries(d.roots ?? {}).map(([k, v]) => `${k} ${v}`).join("\n") + (d.roots ? "\n" : "No named folders.\n"));
562
+ });
516
563
  program
517
564
  .command("hook <runtime>")
518
565
  .description("hook entry point; 'claude-code' reads the hook JSON on stdin")
@@ -10,9 +10,10 @@ import { ghHosts, githubConfig, readCache, runGh } from "./github.js";
10
10
  import { instructionState } from "./integrate.js";
11
11
  import { listeners } from "./ops.js";
12
12
  import { fold } from "./state.js";
13
- import { eventsPath, homeDir, readEvents, storeFormat } from "./store.js";
13
+ import { deviceName, eventFiles, homeDir, readEvents, sharedDir, storeFormat } from "./store.js";
14
+ import { readDevice, syncStatus } from "./sync.js";
14
15
  import { skillState, skillTargets } from "./skill.js";
15
- import { SCHEMA_VERSION } from "./types.js";
16
+ import { STORE_FORMAT } from "./types.js";
16
17
  import { updateChecksOn } from "./update.js";
17
18
  function claudeSettingsFiles() {
18
19
  const dir = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), ".claude");
@@ -38,17 +39,39 @@ export async function doctor(input) {
38
39
  // the store
39
40
  const events = readEvents();
40
41
  let size = 0;
41
- try {
42
- size = fs.statSync(eventsPath()).size;
43
- }
44
- catch {
45
- // no events yet
42
+ for (const f of eventFiles()) {
43
+ try {
44
+ size += fs.statSync(f.file).size;
45
+ }
46
+ catch {
47
+ // no events yet
48
+ }
46
49
  }
47
50
  const format = storeFormat(events);
48
- if (format > SCHEMA_VERSION)
49
- add("store", "warn", `${eventsPath()} has format ${format}, newer than this copy reads (${SCHEMA_VERSION})`, CLI_UPDATE);
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);
50
55
  else
51
- add("store", "ok", `${homeDir()} · ${events.length} events, ${bytes(size)}`);
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
+ }
52
75
  const state = fold(events);
53
76
  add("projects", state.projects.size ? "ok" : "info", state.projects.size ? [...state.projects.keys()].join(", ") : "none registered (claims still work)");
54
77
  // the desktop app
@@ -1,62 +1,49 @@
1
1
  import fs from "node:fs";
2
- import { eventsPath, homeDir, parseEventLines } from "./store.js";
2
+ import { endOf, eventFiles, eventsDir, isAfter, parseEventLines, readEvents } from "./store.js";
3
3
  /**
4
- * Call `onEvent` for every event appended after `afterSeq` (or after the current end when it is
5
- * undefined). Watches the store folder and also checks once a second. Returns a stop function.
4
+ * Call `onEvent` for every event appended after `after` (or after the current end when it is
5
+ * undefined), in any machine's log. Watches the log folder and also checks once a second, so a
6
+ * log a sync tool brings in from another machine shows up too. Returns a stop function.
6
7
  */
7
- export function followEvents(onEvent, afterSeq) {
8
- const file = eventsPath();
9
- let offset = 0;
10
- let lastSeq = afterSeq ?? Infinity;
11
- try {
12
- const text = fs.readFileSync(file, "utf8");
13
- offset = Buffer.byteLength(text);
14
- const events = parseEventLines(text);
15
- const end = events[events.length - 1]?.seq ?? 0;
16
- if (afterSeq === undefined)
17
- lastSeq = end;
18
- else {
19
- for (const ev of events)
20
- if (ev.seq > afterSeq)
21
- onEvent(ev);
22
- lastSeq = Math.max(afterSeq, end);
23
- }
24
- }
25
- catch {
26
- lastSeq = afterSeq ?? 0;
27
- }
8
+ export function followEvents(onEvent, after) {
9
+ const offsets = new Map();
10
+ let pos = after ?? endOf(readEvents());
28
11
  const pump = () => {
29
- let size = 0;
30
- try {
31
- size = fs.statSync(file).size;
32
- }
33
- catch {
34
- return;
35
- }
36
- if (size < offset)
37
- offset = 0; // file replaced
38
- if (size === offset)
39
- return;
40
- const fd = fs.openSync(file, "r");
41
- const buf = Buffer.alloc(size - offset);
42
- fs.readSync(fd, buf, 0, buf.length, offset);
43
- fs.closeSync(fd);
44
- const text = buf.toString("utf8");
45
- const cut = text.lastIndexOf("\n");
46
- if (cut < 0)
47
- return;
48
- offset += Buffer.byteLength(text.slice(0, cut + 1));
49
- for (const ev of parseEventLines(text.slice(0, cut + 1))) {
50
- if (ev.seq > lastSeq) {
12
+ for (const f of eventFiles()) {
13
+ let size = 0;
14
+ try {
15
+ size = fs.statSync(f.file).size;
16
+ }
17
+ catch {
18
+ continue;
19
+ }
20
+ let offset = offsets.get(f.file) ?? 0;
21
+ if (size < offset)
22
+ offset = 0; // file replaced
23
+ if (size === offset)
24
+ continue;
25
+ const fd = fs.openSync(f.file, "r");
26
+ const buf = Buffer.alloc(size - offset);
27
+ fs.readSync(fd, buf, 0, buf.length, offset);
28
+ fs.closeSync(fd);
29
+ const text = buf.toString("utf8");
30
+ const cut = text.lastIndexOf("\n");
31
+ if (cut < 0)
32
+ continue;
33
+ offsets.set(f.file, offset + Buffer.byteLength(text.slice(0, cut + 1)));
34
+ for (const ev of parseEventLines(text.slice(0, cut + 1), f.device)) {
35
+ if (!isAfter(ev, pos))
36
+ continue;
37
+ pos = { ...pos, [f.device]: ev.seq };
51
38
  onEvent(ev);
52
- lastSeq = ev.seq;
53
39
  }
54
40
  }
55
41
  };
42
+ pump();
56
43
  let watcher;
57
44
  try {
58
- fs.mkdirSync(homeDir(), { recursive: true });
59
- watcher = fs.watch(homeDir(), () => pump());
45
+ fs.mkdirSync(eventsDir(), { recursive: true });
46
+ watcher = fs.watch(eventsDir(), () => pump());
60
47
  }
61
48
  catch {
62
49
  watcher = undefined; // the timer alone still works
@@ -138,9 +138,13 @@ export function cardDetail(c) {
138
138
  if (c.from || c.session)
139
139
  out.push(`From: ${[c.from, c.session ? `session ${c.session.slice(0, 8)}` : ""].filter(Boolean).join(", ")}`);
140
140
  out.push(`Opened: ${new Date(c.openedAt).toLocaleString()}${c.since ? `, first came up ${new Date(c.since).toLocaleDateString()}` : ""}`);
141
+ if (c.dev)
142
+ out.push(`Machine: ${c.dev}`);
141
143
  for (const a of c.answers) {
142
144
  const choice = a.choice !== undefined ? ` chose ${a.choice}${c.options?.[a.choice - 1] ? ` (${c.options[a.choice - 1]})` : ""}` : "";
143
- out.push("", `${a.by}${choice}${a.comment ? `: ${a.comment}` : ""}`);
145
+ const where = a.dev ? ` on ${a.dev}` : "";
146
+ const late = a.late ? " (not used: given at the same time as the answer before it, which stands)" : "";
147
+ out.push("", `${a.by}${where}${choice}${a.comment ? `: ${a.comment}` : ""}${late}`);
144
148
  for (const f of a.attach ?? [])
145
149
  out.push(` attached ${f}`);
146
150
  }
@@ -335,7 +339,7 @@ export function runsTree(runs, v = PLAIN, now = Date.now(), indent = "") {
335
339
  const ended = ENDED.has(r.state);
336
340
  const titlePaint = r.role === "lead" ? ["bold"] : ended ? ["dim"] : [];
337
341
  const mark = paint(v, STATE_MARK[r.state], ...STATE_PAINT[r.state]);
338
- const label = r.title + (r.repeats ? ` ×${r.repeats + 1}` : "") + (r.prs?.length ? ` (${r.prs.length} PR)` : "");
342
+ const label = r.title + (r.repeats ? ` ×${r.repeats + 1}` : "") + (r.prs?.length ? ` (${r.prs.length} PR)` : "") + (r.device ? ` · on ${r.device}` : "");
339
343
  const title = paint(v, pad(fit(label, room), room), ...titlePaint);
340
344
  const age = paint(v, ago(r.lastActivity, now).padStart(4), "dim");
341
345
  const src = (r.projectSource && SOURCE_NOTE[r.projectSource]) || "";
@@ -348,4 +352,26 @@ export function runsTree(runs, v = PLAIN, now = Date.now(), indent = "") {
348
352
  }
349
353
  return out.join("\n");
350
354
  }
355
+ // ------------------------------------------------------------------ sharing
356
+ /** `parawatch sync status` as text. */
357
+ export function syncText(st, color = false, now = Date.now()) {
358
+ const v = { ...PLAIN, color };
359
+ if (!st)
360
+ return "This machine keeps its log to itself. To share it with another machine: parawatch sync setup <folder> --device <name>";
361
+ const lines = [
362
+ `${paint(v, st.device, "bold")} writes to ${st.dir}`,
363
+ `adapter ${st.adapter}${st.ok ? "" : paint(v, " (not reachable)", "yellow")}${st.lastSync ? ` · last in step ${ago(st.lastSync, now)} ago` : ""}${st.pending ? ` · ${st.pending} file(s) on the way` : ""}`,
364
+ ];
365
+ if (!st.peers.length)
366
+ lines.push("peers none yet");
367
+ for (const p of st.peers) {
368
+ const state = p.connected === true ? paint(v, "connected", "green") : p.connected === false ? paint(v, "not connected", "yellow") : "seen";
369
+ lines.push(`peer ${p.name} ${state}${p.lastSeen && p.connected !== true ? ` · last ${ago(p.lastSeen, now)} ago` : ""}${p.address ? ` · ${p.address}` : ""}${p.version ? ` · parawatch ${p.version}` : ""}`);
370
+ }
371
+ if (st.conflicts.length)
372
+ lines.push(paint(v, `conflicts ${st.conflicts.length}:`, "yellow"), ...st.conflicts.map((c) => ` ${c}`));
373
+ if (st.error)
374
+ lines.push(paint(v, st.error, "yellow"));
375
+ return lines.join("\n");
376
+ }
351
377
  //# sourceMappingURL=format.js.map
@@ -7,7 +7,7 @@ import { execFile, execFileSync } from "node:child_process";
7
7
  import fs from "node:fs";
8
8
  import path from "node:path";
9
9
  import { UserError } from "./ops.js";
10
- import { appendLocked, homeDir, withLock } from "./store.js";
10
+ import { appendLocked, deviceName, homeDir, withLock } from "./store.js";
11
11
  export const DEFAULT_STALL_HOURS = {
12
12
  review: 24,
13
13
  "no reviewer": 4,
@@ -307,6 +307,23 @@ export function nextCache(prev, fresh, hosts, at) {
307
307
  delete since[k];
308
308
  return { cache: { fetchedAt: at, hosts, prs, since }, events };
309
309
  }
310
+ /**
311
+ * Drop news another machine already reported. This machine's reading spans from its previous
312
+ * reading (`since`) to now; the same change on the same PR that another machine wrote in that
313
+ * span is the one this machine just noticed, maybe long after. A machine that reads GitHub for
314
+ * the first time in hours would otherwise tell every agent again what they already handled.
315
+ */
316
+ export function dropReported(fresh, log, since, me = deviceName()) {
317
+ if (!since)
318
+ return fresh;
319
+ const seen = new Set();
320
+ for (const e of log)
321
+ if (e.type === "pr.change" && e.dev && e.dev !== me && e.ts >= since)
322
+ seen.add(`${e.pr.key}|${e.change}|${e.by ?? ""}`);
323
+ if (!seen.size)
324
+ return fresh;
325
+ return fresh.filter((b) => b.type !== "pr.change" || !seen.has(`${b.pr.key}|${b.change}|${b.by ?? ""}`));
326
+ }
310
327
  // ------------------------------------------------------------------ sync
311
328
  function fetchingPath() {
312
329
  return path.join(homeDir(), "github.fetching");
@@ -386,7 +403,7 @@ export async function syncGithub(opts) {
386
403
  const { cache, events } = nextCache(latest, fresh, hosts, started);
387
404
  if (errors.length)
388
405
  cache.error = errors.join("; ");
389
- appendLocked(() => events);
406
+ appendLocked((log) => dropReported(events, log, latest.fetchedAt));
390
407
  writeCache(cache);
391
408
  return cache;
392
409
  });
@@ -1,9 +1,8 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import fs from "node:fs";
3
3
  import path from "node:path";
4
- import { readInbox, inboxFrom } from "./ops.js";
4
+ import { furthest, inboxFrom, readInbox, readState, sameSession, sessionConsumer } from "./ops.js";
5
5
  import { homeDir, readCursor, readEvents } from "./store.js";
6
- import { fold } from "./state.js";
7
6
  import { inboxLine } from "./format.js";
8
7
  export const BEGIN = "<!-- parawatch:begin -->";
9
8
  export const END = "<!-- parawatch:end -->";
@@ -24,7 +23,7 @@ The human follows this work through \`parawatch\`. Use it instead of asking in c
24
23
  - Stuck until the human does something only they can do (install, sudo, sign in): \`parawatch block --project ${p} "what to do"\`
25
24
  - Something only the human can do, but nothing waits on it now: \`parawatch todo --project ${p} "what to do"\`
26
25
  - Put every question for the human on a card, not only in your reply: a question left in chat is easy to miss.
27
- - After opening a card or linking a pull request, keep a background wait armed: \`parawatch wait --project ${p}\`. It prints the human's answers and pull request news and exits; act on them, \`parawatch close <card> --note "what you did"\`, then arm it again.
26
+ - After opening a card or linking a pull request, keep a background wait armed: \`parawatch wait\`. It prints the human's answers to your cards and news on your pull requests and exits; act on them, \`parawatch close <card> --note "what you did"\`, then arm it again. A lead watching the whole project waits with \`parawatch wait --project ${p}\`.
28
27
  - A long job with no agent log (a bake, a benchmark): \`parawatch run start --project ${p} "title" --heartbeat <seconds> [--log <file>]\`, \`parawatch run note <id> "..."\`, \`parawatch run end <id>\`.
29
28
  - Never answer a card yourself. \`parawatch status\` shows everything that is open.
30
29
  ${END}`;
@@ -97,24 +96,29 @@ export function instructionState(file) {
97
96
  */
98
97
  export function hookContext(input, project) {
99
98
  const events = readEvents();
100
- const state = fold(events);
99
+ const state = readState(events);
100
+ const session = input.session_id;
101
101
  let name = project;
102
- if (!name && input.session_id)
103
- name = state.claims.find((c) => input.session_id.startsWith(c.session))?.project;
102
+ if (!name && session)
103
+ name = state.claims.find((c) => sameSession(session, c.session))?.project;
104
104
  if (!name && input.cwd) {
105
105
  for (const p of state.projects.values())
106
106
  if (p.paths.some((x) => input.cwd === x || input.cwd.startsWith(x + "/")))
107
107
  name = p.name;
108
108
  }
109
- if (!name)
109
+ if (!name && !session)
110
110
  return undefined;
111
- const items = inboxFrom(events, readCursor(name), { project: name });
111
+ // A session reads its own news with its own cursor; one still waiting on its project moves the
112
+ // project's cursor instead, so whichever is further says what it has handled.
113
+ const items = session
114
+ ? inboxFrom(events, furthest(readCursor(sessionConsumer(session)), name ? readCursor(name) : {}), { session, project: name }, state)
115
+ : inboxFrom(events, readCursor(name), { project: name }, state);
112
116
  if (!items.length)
113
117
  return undefined;
114
118
  const cards = items.filter((i) => i.kind !== "pr").length;
115
119
  const prs = items.length - cards;
116
120
  const what = [cards ? `${cards} answer(s) on cards` : "", prs ? `${prs} pull request update(s)` : ""].filter(Boolean).join(" and ");
117
- return [`Parawatch: ${what} for ${name} that you have not handled yet.`, ...items.map(inboxLine), cards ? `Act on them, then \`parawatch close <card>\`.` : `Act on them.`].join("\n");
121
+ return [`Parawatch: ${what}${name ? ` for ${name}` : ""} that you have not handled yet.`, ...items.map(inboxLine), cards ? `Act on them, then \`parawatch close <card>\`.` : `Act on them.`].join("\n");
118
122
  }
119
123
  /** Best-effort desktop notification. Never throws, never blocks. */
120
124
  /** The desktop app writes its pid here while it runs, so the CLI leaves notifications to it. */