parawatch 0.3.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 +7 -5
- package/dist/bin/cli.js +20 -10
- package/dist/core/format.js +1 -1
- package/dist/core/github.js +19 -2
- package/dist/core/integrate.js +12 -7
- package/dist/core/ops.js +52 -3
- package/dist/core/runs.js +109 -15
- package/package.json +1 -1
- package/skills/parawatch/SKILL.md +6 -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
|
{
|
|
@@ -291,9 +291,11 @@ parawatch sync # this machine, the others, how the sync tool is doing
|
|
|
291
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
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
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.
|
|
294
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.
|
|
295
297
|
|
|
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.
|
|
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.
|
|
297
299
|
|
|
298
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`):
|
|
299
301
|
|
package/dist/bin/cli.js
CHANGED
|
@@ -2,14 +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
8
|
import { everywhere, readEvents } from "../core/store.js";
|
|
9
9
|
import { followEvents } from "../core/follow.js";
|
|
10
10
|
import { adapterNames, setRoot, setupSync, syncAdapter, syncStatus, touchDevice } from "../core/sync.js";
|
|
11
11
|
import { syncText } from "../core/format.js";
|
|
12
|
-
import { collectRuns } from "../core/runs.js";
|
|
12
|
+
import { collectRuns, shareRunsDue } from "../core/runs.js";
|
|
13
13
|
import { askRows, cardDetail, cardLine, cardRows, inboxLine, prRows, runsTree, view } from "../core/format.js";
|
|
14
14
|
import { hookContext, notify, removeInstructions, writeInstructions } from "../core/integrate.js";
|
|
15
15
|
import { installSkill, skillSource, skillTargets, uninstallSkill } from "../core/skill.js";
|
|
@@ -204,21 +204,30 @@ program
|
|
|
204
204
|
// ------------------------------------------------------------------ inbox
|
|
205
205
|
function inboxOptions(c) {
|
|
206
206
|
return c
|
|
207
|
-
.option("-p, --project <name>", "
|
|
207
|
+
.option("-p, --project <name>", "everything in this project, whoever owns it (for a lead)")
|
|
208
208
|
.option("--card <id>", "only answers to this card")
|
|
209
|
-
.option("--as <consumer>", "cursor name (defaults to the project)")
|
|
209
|
+
.option("--as <consumer>", "cursor name (defaults to the session, or the project with -p)")
|
|
210
210
|
.option("--json", "JSON output");
|
|
211
211
|
}
|
|
212
|
-
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"))
|
|
213
215
|
.option("--timeout <seconds>", "give up after this long (exit code 2); 0 waits forever", int, 0)
|
|
214
216
|
.action(async (o) => {
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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
|
+
};
|
|
218
227
|
tick();
|
|
219
228
|
const timer = setInterval(tick, 60_000);
|
|
220
229
|
timer.unref();
|
|
221
|
-
const items = await waitInbox(
|
|
230
|
+
const items = await waitInbox(consumer, filter, o.timeout * 1000);
|
|
222
231
|
clearInterval(timer);
|
|
223
232
|
if (!items.length) {
|
|
224
233
|
process.exitCode = 2;
|
|
@@ -232,7 +241,8 @@ inboxOptions(program.command("wait").description("block until the human answers,
|
|
|
232
241
|
inboxOptions(program.command("inbox").description("print unhandled answers without waiting"))
|
|
233
242
|
.option("--peek", "do not mark them as seen")
|
|
234
243
|
.action((o) => {
|
|
235
|
-
const
|
|
244
|
+
const { consumer, filter } = inboxScope(o, currentSession());
|
|
245
|
+
const items = readInbox(consumer, filter, !o.peek);
|
|
236
246
|
if (o.json)
|
|
237
247
|
print({ v: SCHEMA_VERSION, items });
|
|
238
248
|
else if (items.length)
|
package/dist/core/format.js
CHANGED
|
@@ -339,7 +339,7 @@ export function runsTree(runs, v = PLAIN, now = Date.now(), indent = "") {
|
|
|
339
339
|
const ended = ENDED.has(r.state);
|
|
340
340
|
const titlePaint = r.role === "lead" ? ["bold"] : ended ? ["dim"] : [];
|
|
341
341
|
const mark = paint(v, STATE_MARK[r.state], ...STATE_PAINT[r.state]);
|
|
342
|
-
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}` : "");
|
|
343
343
|
const title = paint(v, pad(fit(label, room), room), ...titlePaint);
|
|
344
344
|
const age = paint(v, ago(r.lastActivity, now).padStart(4), "dim");
|
|
345
345
|
const src = (r.projectSource && SOURCE_NOTE[r.projectSource]) || "";
|
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,7 +1,7 @@
|
|
|
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
6
|
import { inboxLine } from "./format.js";
|
|
7
7
|
export const BEGIN = "<!-- parawatch:begin -->";
|
|
@@ -23,7 +23,7 @@ The human follows this work through \`parawatch\`. Use it instead of asking in c
|
|
|
23
23
|
- Stuck until the human does something only they can do (install, sudo, sign in): \`parawatch block --project ${p} "what to do"\`
|
|
24
24
|
- Something only the human can do, but nothing waits on it now: \`parawatch todo --project ${p} "what to do"\`
|
|
25
25
|
- Put every question for the human on a card, not only in your reply: a question left in chat is easy to miss.
|
|
26
|
-
- 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}\`.
|
|
27
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>\`.
|
|
28
28
|
- Never answer a card yourself. \`parawatch status\` shows everything that is open.
|
|
29
29
|
${END}`;
|
|
@@ -97,23 +97,28 @@ export function instructionState(file) {
|
|
|
97
97
|
export function hookContext(input, project) {
|
|
98
98
|
const events = readEvents();
|
|
99
99
|
const state = readState(events);
|
|
100
|
+
const session = input.session_id;
|
|
100
101
|
let name = project;
|
|
101
|
-
if (!name &&
|
|
102
|
-
name = state.claims.find((c) =>
|
|
102
|
+
if (!name && session)
|
|
103
|
+
name = state.claims.find((c) => sameSession(session, c.session))?.project;
|
|
103
104
|
if (!name && input.cwd) {
|
|
104
105
|
for (const p of state.projects.values())
|
|
105
106
|
if (p.paths.some((x) => input.cwd === x || input.cwd.startsWith(x + "/")))
|
|
106
107
|
name = p.name;
|
|
107
108
|
}
|
|
108
|
-
if (!name)
|
|
109
|
+
if (!name && !session)
|
|
109
110
|
return undefined;
|
|
110
|
-
|
|
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);
|
|
111
116
|
if (!items.length)
|
|
112
117
|
return undefined;
|
|
113
118
|
const cards = items.filter((i) => i.kind !== "pr").length;
|
|
114
119
|
const prs = items.length - cards;
|
|
115
120
|
const what = [cards ? `${cards} answer(s) on cards` : "", prs ? `${prs} pull request update(s)` : ""].filter(Boolean).join(" and ");
|
|
116
|
-
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");
|
|
117
122
|
}
|
|
118
123
|
/** Best-effort desktop notification. Never throws, never blocks. */
|
|
119
124
|
/** The desktop app writes its pid here while it runs, so the CLI leaves notifications to it. */
|
package/dist/core/ops.js
CHANGED
|
@@ -273,6 +273,16 @@ export function prEvent(pr, change, opts = {}) {
|
|
|
273
273
|
throw new UserError(`change must be one of ${PR_CHANGES.join(", ")}`);
|
|
274
274
|
appendEvent(() => ({ type: "pr.change", pr, change: change, by: opts.by, text: opts.text?.trim() || undefined, title: opts.title, source: "external" }));
|
|
275
275
|
}
|
|
276
|
+
/** Whether two session ids name the same session. A claim or a link may hold a short prefix. */
|
|
277
|
+
export function sameSession(a, b) {
|
|
278
|
+
return !!a && !!b && (a.startsWith(b) || b.startsWith(a));
|
|
279
|
+
}
|
|
280
|
+
/** Whether something owned by `owner` in `project` is for a reader with this filter. */
|
|
281
|
+
function inScope(owner, project, f) {
|
|
282
|
+
if (f.session)
|
|
283
|
+
return owner ? sameSession(owner, f.session) : !!f.project && project === f.project;
|
|
284
|
+
return !f.project || project === f.project;
|
|
285
|
+
}
|
|
276
286
|
/** Two machines that both read GitHub report the same change; the later report is an echo. */
|
|
277
287
|
const ECHO_MS = 30 * 60_000;
|
|
278
288
|
/**
|
|
@@ -296,7 +306,7 @@ export function inboxFrom(events, after, filter, state = readState(events)) {
|
|
|
296
306
|
if (!isAfter(ev, pos))
|
|
297
307
|
continue;
|
|
298
308
|
const link = state.prLinks.get(ev.pr.key);
|
|
299
|
-
if (!link || filter.card || (
|
|
309
|
+
if (!link || filter.card || !inScope(link.session, link.project, filter))
|
|
300
310
|
continue;
|
|
301
311
|
out.push({ seq: ev.seq, dev: ev.dev, at: ev.ts, kind: "pr", by: ev.by ?? ev.source, pr: { ref: ev.pr, change: ev.change, title: ev.title, text: ev.text, project: link.project } });
|
|
302
312
|
continue;
|
|
@@ -316,7 +326,7 @@ export function inboxFrom(events, after, filter, state = readState(events)) {
|
|
|
316
326
|
const card = state.cards.get(ev.card);
|
|
317
327
|
if (!card || card.status === "closed")
|
|
318
328
|
continue;
|
|
319
|
-
if (
|
|
329
|
+
if (!inScope(card.session, card.project, filter))
|
|
320
330
|
continue;
|
|
321
331
|
if (filter.card && card.id !== filter.card)
|
|
322
332
|
continue;
|
|
@@ -434,11 +444,50 @@ export function listeners(now = Date.now()) {
|
|
|
434
444
|
}
|
|
435
445
|
/** The waits an answer to this card would wake right now. */
|
|
436
446
|
export function listenersFor(card, ls) {
|
|
437
|
-
return ls.filter((l) =>
|
|
447
|
+
return ls.filter((l) => l.card
|
|
448
|
+
? l.card === card.id
|
|
449
|
+
: l.scope === "session"
|
|
450
|
+
? card.session
|
|
451
|
+
? sameSession(l.session, card.session)
|
|
452
|
+
: !!l.project && l.project === card.project
|
|
453
|
+
: !l.project || l.project === card.project);
|
|
438
454
|
}
|
|
439
455
|
export function consumerName(opts) {
|
|
440
456
|
return opts.as || opts.project || (opts.card ? `card-${opts.card}` : "default");
|
|
441
457
|
}
|
|
458
|
+
/** The cursor a session reads with when it waits for its own news. */
|
|
459
|
+
export function sessionConsumer(session) {
|
|
460
|
+
return `session-${session.replace(/[^A-Za-z0-9._-]/g, "_")}`;
|
|
461
|
+
}
|
|
462
|
+
/** Per device, the further of two positions. */
|
|
463
|
+
export function furthest(a, b) {
|
|
464
|
+
const out = { ...a };
|
|
465
|
+
for (const [d, seq] of Object.entries(b))
|
|
466
|
+
if (seq > (out[d] ?? 0))
|
|
467
|
+
out[d] = seq;
|
|
468
|
+
return out;
|
|
469
|
+
}
|
|
470
|
+
/**
|
|
471
|
+
* What a wait or an inbox reads. `--card` and `--project` read what they name, with a cursor of
|
|
472
|
+
* that name. Called from an agent session without either, it reads that session's own cards and
|
|
473
|
+
* pull requests, plus unowned ones in the project the session claimed, with a cursor of its own:
|
|
474
|
+
* sessions in one project neither wake for each other's news nor take it from each other.
|
|
475
|
+
* Outside a session, it reads everything.
|
|
476
|
+
*/
|
|
477
|
+
export function inboxScope(opts, session) {
|
|
478
|
+
if (opts.card || opts.project || !session)
|
|
479
|
+
return { consumer: consumerName(opts), filter: { project: opts.project, card: opts.card } };
|
|
480
|
+
const claim = readState().claims.find((c) => sameSession(session, c.session));
|
|
481
|
+
const consumer = opts.as || sessionConsumer(session);
|
|
482
|
+
// A session that used to wait on its project starts where that wait stopped, so moving to its
|
|
483
|
+
// own cursor does not bring back news it has already handled.
|
|
484
|
+
if (claim && !Object.keys(readCursor(consumer)).length) {
|
|
485
|
+
const from = readCursor(claim.project);
|
|
486
|
+
if (Object.keys(from).length)
|
|
487
|
+
writeCursor(consumer, from);
|
|
488
|
+
}
|
|
489
|
+
return { consumer, filter: { session, project: claim?.project } };
|
|
490
|
+
}
|
|
442
491
|
export function readInbox(consumer, filter, move) {
|
|
443
492
|
const events = readEvents();
|
|
444
493
|
const pos = readCursor(consumer);
|
package/dist/core/runs.js
CHANGED
|
@@ -3,6 +3,7 @@ import fs from "node:fs";
|
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { scanAll } from "../adapters/index.js";
|
|
5
5
|
import { ACTIVITY_MINUTES, activeMinutes } from "../adapters/jsonl.js";
|
|
6
|
+
import { deviceName, sharedDir } from "./store.js";
|
|
6
7
|
/** All checkouts of a project path: the path itself plus its git worktrees. */
|
|
7
8
|
function rootsOf(p, cache) {
|
|
8
9
|
const hit = cache.get(p);
|
|
@@ -96,27 +97,30 @@ function claimFor(id, state) {
|
|
|
96
97
|
* unless it only edits files of another project; its parent's project; the project whose files it
|
|
97
98
|
* edits most; a project path named in its first prompt.
|
|
98
99
|
*/
|
|
100
|
+
function applyClaim(r, claim) {
|
|
101
|
+
r.project = claim.project;
|
|
102
|
+
r.projectSource = "claim";
|
|
103
|
+
r.task = claim.task ?? r.task;
|
|
104
|
+
r.role = claim.role ?? r.role;
|
|
105
|
+
if (claim.title)
|
|
106
|
+
r.title = claim.title;
|
|
107
|
+
if (claim.kind === "scheduled")
|
|
108
|
+
r.scheduled = true;
|
|
109
|
+
// done until someone writes to the session again
|
|
110
|
+
if (claim.done && claim.at && (!r.lastPromptAt || r.lastPromptAt < claim.at)) {
|
|
111
|
+
r.done = claim.done;
|
|
112
|
+
if (r.state === "idle")
|
|
113
|
+
r.state = "done";
|
|
114
|
+
r.asks = undefined;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
99
117
|
export function attribute(runs, state) {
|
|
100
118
|
const ms = matchers([...state.projects.values()]);
|
|
101
119
|
const byId = new Map(runs.map((r) => [r.id, r]));
|
|
102
120
|
for (const r of runs) {
|
|
103
121
|
const claim = claimFor(r.id, state);
|
|
104
122
|
if (claim) {
|
|
105
|
-
r
|
|
106
|
-
r.projectSource = "claim";
|
|
107
|
-
r.task = claim.task ?? r.task;
|
|
108
|
-
r.role = claim.role ?? r.role;
|
|
109
|
-
if (claim.title)
|
|
110
|
-
r.title = claim.title;
|
|
111
|
-
if (claim.kind === "scheduled")
|
|
112
|
-
r.scheduled = true;
|
|
113
|
-
// done until someone writes to the session again
|
|
114
|
-
if (claim.done && claim.at && (!r.lastPromptAt || r.lastPromptAt < claim.at)) {
|
|
115
|
-
r.done = claim.done;
|
|
116
|
-
if (r.state === "idle")
|
|
117
|
-
r.state = "done";
|
|
118
|
-
r.asks = undefined;
|
|
119
|
-
}
|
|
123
|
+
applyClaim(r, claim);
|
|
120
124
|
continue;
|
|
121
125
|
}
|
|
122
126
|
const here = projectForCwd(r.cwd, ms);
|
|
@@ -247,6 +251,92 @@ export function declaredRuns(state, now = Date.now()) {
|
|
|
247
251
|
};
|
|
248
252
|
});
|
|
249
253
|
}
|
|
254
|
+
// ---------------------------------------------------------------- other machines
|
|
255
|
+
/** How often a machine rewrites its runs for the others, and when its summary counts as gone. */
|
|
256
|
+
const SHARE_EVERY_MS = 30_000;
|
|
257
|
+
export const SHARED_RUNS_GONE_MS = 15 * 60_000;
|
|
258
|
+
const SHARED_RUNS_FILE = /^([a-z0-9][a-z0-9-]{0,31})\.json$/;
|
|
259
|
+
function sharedRunsDir() {
|
|
260
|
+
const s = sharedDir();
|
|
261
|
+
return s ? path.join(s, "runs") : undefined;
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
264
|
+
* Write the runs read from this machine's agent logs where the other machines read them, at most
|
|
265
|
+
* every half minute. The logs themselves stay here; only the summary travels.
|
|
266
|
+
*/
|
|
267
|
+
export function shareRuns(runs, now = Date.now()) {
|
|
268
|
+
if (!shareRunsDue(now))
|
|
269
|
+
return;
|
|
270
|
+
const dir = sharedRunsDir();
|
|
271
|
+
const me = deviceName();
|
|
272
|
+
const file = path.join(dir, `${me}.json`);
|
|
273
|
+
// the folders it edited are paths on this machine, and only guide attribution here
|
|
274
|
+
const slim = runs.map(({ touched: _t, ...r }) => ({ ...r, prompt: r.prompt?.slice(0, 300) }));
|
|
275
|
+
const body = { v: 1, device: me, at: new Date(now).toISOString(), runs: slim };
|
|
276
|
+
try {
|
|
277
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
278
|
+
const tmp = path.join(dir, `.${me}.${process.pid}.tmp`);
|
|
279
|
+
fs.writeFileSync(tmp, JSON.stringify(body) + "\n");
|
|
280
|
+
fs.renameSync(tmp, file);
|
|
281
|
+
}
|
|
282
|
+
catch {
|
|
283
|
+
// the folder may be busy syncing; the next reading writes it again
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
/** Whether this machine's summary for the others is due (a shared folder, and none written lately). */
|
|
287
|
+
export function shareRunsDue(now = Date.now()) {
|
|
288
|
+
const dir = sharedRunsDir();
|
|
289
|
+
if (!dir)
|
|
290
|
+
return false;
|
|
291
|
+
try {
|
|
292
|
+
return now - fs.statSync(path.join(dir, `${deviceName()}.json`)).mtimeMs >= SHARE_EVERY_MS;
|
|
293
|
+
}
|
|
294
|
+
catch {
|
|
295
|
+
return true;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
/**
|
|
299
|
+
* Runs other machines shared, as they last wrote them, marked with the machine. A machine that
|
|
300
|
+
* has not written for a while is left out: its agents may be long gone. Claims made since are
|
|
301
|
+
* applied here, and the activity chart slides by the summary's age.
|
|
302
|
+
*/
|
|
303
|
+
export function otherRuns(state, now = Date.now()) {
|
|
304
|
+
const dir = sharedRunsDir();
|
|
305
|
+
if (!dir)
|
|
306
|
+
return [];
|
|
307
|
+
const me = deviceName();
|
|
308
|
+
let names = [];
|
|
309
|
+
try {
|
|
310
|
+
names = fs.readdirSync(dir);
|
|
311
|
+
}
|
|
312
|
+
catch {
|
|
313
|
+
return [];
|
|
314
|
+
}
|
|
315
|
+
const out = [];
|
|
316
|
+
for (const n of names.sort()) {
|
|
317
|
+
const m = SHARED_RUNS_FILE.exec(n);
|
|
318
|
+
if (!m || m[1] === me)
|
|
319
|
+
continue;
|
|
320
|
+
try {
|
|
321
|
+
const s = JSON.parse(fs.readFileSync(path.join(dir, n), "utf8"));
|
|
322
|
+
const at = Date.parse(s.at);
|
|
323
|
+
if (!Number.isFinite(at) || now - at > SHARED_RUNS_GONE_MS || !Array.isArray(s.runs))
|
|
324
|
+
continue;
|
|
325
|
+
const shift = Math.max(0, Math.floor((now - at) / 60_000));
|
|
326
|
+
for (const r of s.runs) {
|
|
327
|
+
const run = { ...r, device: m[1], activity: r.activity?.map((x) => x + shift).filter((x) => x < ACTIVITY_MINUTES) };
|
|
328
|
+
const claim = claimFor(run.id, state);
|
|
329
|
+
if (claim)
|
|
330
|
+
applyClaim(run, claim);
|
|
331
|
+
out.push(run);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
catch {
|
|
335
|
+
// half synced; read it next time
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
return out;
|
|
339
|
+
}
|
|
250
340
|
const ENDED = new Set(["done", "idle", "failed"]);
|
|
251
341
|
const RECENT_END_MS = 30 * 60_000;
|
|
252
342
|
const OLD_STALL_MS = 3 * 3_600_000;
|
|
@@ -255,6 +345,10 @@ export function collectRuns(state, q, now = Date.now()) {
|
|
|
255
345
|
// a log file can be touched (title records, snapshots) long after its last real activity
|
|
256
346
|
const recent = (r) => !r.lastActivity || Date.parse(r.lastActivity) >= sinceMs;
|
|
257
347
|
let runs = attribute(scanAll({ sinceMs, now }, q.runtimes), state).filter(recent);
|
|
348
|
+
// a narrower reading (one runtime) would hide the rest from the other machines
|
|
349
|
+
if (!q.runtimes)
|
|
350
|
+
shareRuns(runs, now);
|
|
351
|
+
runs.push(...otherRuns(state, now).filter(recent));
|
|
258
352
|
runs.push(...declaredRuns(state, now).filter(recent));
|
|
259
353
|
attachPrs(runs, state);
|
|
260
354
|
if (q.project)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "parawatch",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.2",
|
|
4
4
|
"description": "A control tower for people running many AI coding agents in parallel: see what is running, what needs you, and wake the right agent when you answer.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ai-agents",
|
|
@@ -74,10 +74,12 @@ News from somewhere GitHub does not show (a merge bot, a ticket system) can be r
|
|
|
74
74
|
|
|
75
75
|
## 4. Waiting for the answer
|
|
76
76
|
|
|
77
|
-
Right after opening cards or linking a pull request, keep exactly one wait armed
|
|
77
|
+
Right after opening cards or linking a pull request, keep exactly one wait armed:
|
|
78
78
|
|
|
79
|
-
- **Claude Code**: run `parawatch wait
|
|
80
|
-
- **Other runtimes**: check `parawatch inbox
|
|
79
|
+
- **Claude Code**: run `parawatch wait` as a background Bash command (`run_in_background: true`). When the human answers, the command exits, its output is the answer, and you are woken. Do not poll, do not sleep-loop, do not start a second wait while one is running.
|
|
80
|
+
- **Other runtimes**: check `parawatch inbox` at the start of every turn.
|
|
81
|
+
|
|
82
|
+
Inside your session, `wait` and `inbox` read only what is yours: answers on cards you opened, news on pull requests linked to you, and cards or pull requests no session owns in the project you claimed. Other sessions in the same project are not woken by your news and cannot take it from you. `-p <project>` reads the whole project instead; leave it to a lead (section 8).
|
|
81
83
|
|
|
82
84
|
Keep working on other things meanwhile. A wait with `--timeout <seconds>` exits with code 2 when nothing came; re-arm it if you still need the answer. While no wait runs, an answer is kept, and the card tells the human that no agent is waiting; it reaches you on your next turn or your next wait.
|
|
83
85
|
|
|
@@ -119,6 +121,7 @@ The session then shows as done, not as idle, and the line goes into the human's
|
|
|
119
121
|
|
|
120
122
|
## 8. Leads and workers
|
|
121
123
|
|
|
124
|
+
- A lead that watches the whole project waits with `parawatch wait -p <project>`.
|
|
122
125
|
- The lead owns the cards for its project. Workers (subagents, `codex exec` jobs, other sessions you start) report back to the lead instead of opening cards, unless the lead told them to ask the human directly.
|
|
123
126
|
- Tell workers in their brief what to return to you, so the lead can turn it into one clear card.
|
|
124
127
|
- Before ending a session, close cards you handled, end runs you started, and claim `--done`.
|