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 +38 -5
- package/dist/bin/cli.js +69 -22
- package/dist/core/doctor.js +33 -10
- package/dist/core/follow.js +36 -49
- package/dist/core/format.js +28 -2
- package/dist/core/github.js +19 -2
- package/dist/core/integrate.js +13 -9
- package/dist/core/ops.js +170 -48
- package/dist/core/runs.js +109 -15
- package/dist/core/snapshot.js +5 -5
- package/dist/core/state.js +40 -9
- package/dist/core/store.js +204 -24
- package/dist/core/sync.js +317 -0
- package/dist/core/syncthing.js +121 -0
- package/dist/core/types.js +7 -0
- package/package.json +1 -1
- package/skills/parawatch/SKILL.md +10 -3
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
|
|
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
|
|
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
|
|
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
|
|
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,
|
|
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 {
|
|
11
|
-
import {
|
|
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:
|
|
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,
|
|
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 =
|
|
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 = [...
|
|
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>", "
|
|
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
|
|
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
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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(
|
|
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
|
|
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(
|
|
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 =
|
|
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:
|
|
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 = [...
|
|
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")
|
package/dist/core/doctor.js
CHANGED
|
@@ -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 {
|
|
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 {
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
|
|
49
|
-
|
|
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", `${
|
|
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
|
package/dist/core/follow.js
CHANGED
|
@@ -1,62 +1,49 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
|
-
import {
|
|
2
|
+
import { endOf, eventFiles, eventsDir, isAfter, parseEventLines, readEvents } from "./store.js";
|
|
3
3
|
/**
|
|
4
|
-
* Call `onEvent` for every event appended after `
|
|
5
|
-
* undefined). Watches the
|
|
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,
|
|
8
|
-
const
|
|
9
|
-
let
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
offset = 0;
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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(
|
|
59
|
-
watcher = fs.watch(
|
|
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
|
package/dist/core/format.js
CHANGED
|
@@ -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
|
-
|
|
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
|
package/dist/core/github.js
CHANGED
|
@@ -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
|
});
|
package/dist/core/integrate.js
CHANGED
|
@@ -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 {
|
|
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
|
|
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 =
|
|
99
|
+
const state = readState(events);
|
|
100
|
+
const session = input.session_id;
|
|
101
101
|
let name = project;
|
|
102
|
-
if (!name &&
|
|
103
|
-
name = state.claims.find((c) =>
|
|
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
|
-
|
|
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. */
|