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 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
  {
@@ -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, consumerName, editCard, endRun, linkPr, listeners, listenersFor, noteRun, openCard, prEvent, findCard, readInbox, readState, 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
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>", "only answers for this project")
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.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"))
213
215
  .option("--timeout <seconds>", "give up after this long (exit code 2); 0 waits forever", int, 0)
214
216
  .action(async (o) => {
215
- startListening({ consumer: consumerName(o), project: o.project, card: o.card, session: currentSession() });
216
- // while waiting, keep GitHub fresh so news on linked pull requests wakes this wait
217
- 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
+ };
218
227
  tick();
219
228
  const timer = setInterval(tick, 60_000);
220
229
  timer.unref();
221
- 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);
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 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);
236
246
  if (o.json)
237
247
  print({ v: SCHEMA_VERSION, items });
238
248
  else if (items.length)
@@ -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]) || "";
@@ -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,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 { readInbox, inboxFrom, readState } from "./ops.js";
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 --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}\`.
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 && input.session_id)
102
- 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;
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
- const items = inboxFrom(events, readCursor(name), { project: name }, state);
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 || (filter.project && link.project !== filter.project))
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 (filter.project && card.project !== filter.project)
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) => (l.card ? l.card === card.id : !l.project || l.project === card.project));
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.project = claim.project;
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.1",
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 for your project:
77
+ Right after opening cards or linking a pull request, keep exactly one wait armed:
78
78
 
79
- - **Claude Code**: run `parawatch wait -p <project>` 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 -p <project>` at the start of every turn.
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`.