parawatch 0.3.1 → 0.3.3

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
  {
@@ -290,10 +290,13 @@ parawatch sync # this machine, the others, how the sync tool is doing
290
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
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
+ - **A path that is absolute on either system is kept as written**, so an agent on the PC can ask you to open `/Users/Shared/Try/Game.app` on the Mac. Git Bash on Windows rewrites arguments that start with `/` before parawatch sees them; run such commands there with `MSYS_NO_PATHCONV=1`.
293
294
  - **Waits are seen everywhere.** A card on your laptop says whether an agent on the PC is waiting for it.
295
+ - **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
296
  - **Every machine reads GitHub for itself.** The same news is delivered once.
297
+ - **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
298
 
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.
299
+ 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
300
 
298
301
  **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
302
 
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)
@@ -415,8 +425,9 @@ program
415
425
  }
416
426
  if (answered.length) {
417
427
  const notes = {};
428
+ // the snapshot already marked the cards whose session has ended
418
429
  for (const c of answered)
419
- if (!listenersFor(c, ls).length)
430
+ if (!listenersFor(c, ls, () => false).length)
420
431
  notes[c.id] = "no agent waiting";
421
432
  out.push("", head("ANSWERED, WAITING FOR THE AGENT", answered.length));
422
433
  out.push(...cardRows(answered, v, now, " ", notes));
@@ -2,7 +2,8 @@ import fs from "node:fs";
2
2
  import os from "node:os";
3
3
  import path from "node:path";
4
4
  import { parsePrRef } from "./github.js";
5
- import { sessionTitle, UserError } from "./ops.js";
5
+ import { UserError } from "./errors.js";
6
+ import { sessionTitle } from "./state.js";
6
7
  /**
7
8
  * Where the Claude desktop app keeps its Code sessions: one JSON file per session, named
8
9
  * `local_<id>.json`, holding the CLI session id the app runs (`cliSessionId`).
@@ -27,36 +28,86 @@ function* sessionFiles(dir, depth = 0) {
27
28
  yield p;
28
29
  }
29
30
  }
30
- /** The desktop app session running a CLI session (full id or a prefix of 6+ characters). */
31
- export function findDesktopSession(cliSession, dir = desktopSessionsDir()) {
32
- if (cliSession.length < 6)
33
- return undefined;
34
- let best;
31
+ /** Session files read before, by path; a file is read again only when it changes. */
32
+ const readCache = new Map();
33
+ /** Every desktop app session in the folder, read once per change of its file. */
34
+ function desktopSessions(dir) {
35
+ const out = [];
35
36
  for (const file of sessionFiles(dir)) {
36
- let d;
37
+ let mtimeMs;
37
38
  try {
38
- d = JSON.parse(fs.readFileSync(file, "utf8"));
39
+ mtimeMs = fs.statSync(file).mtimeMs;
39
40
  }
40
41
  catch {
41
42
  continue;
42
43
  }
43
- const ids = [d.cliSessionId, d.lastAssistantCliSessionId].filter((x) => typeof x === "string");
44
+ let hit = readCache.get(file);
45
+ if (!hit || hit.mtimeMs !== mtimeMs) {
46
+ hit = { mtimeMs, session: readSessionFile(file) };
47
+ readCache.set(file, hit);
48
+ }
49
+ if (hit.session)
50
+ out.push(hit.session);
51
+ }
52
+ return out;
53
+ }
54
+ function readSessionFile(file) {
55
+ let d;
56
+ try {
57
+ d = JSON.parse(fs.readFileSync(file, "utf8"));
58
+ }
59
+ catch {
60
+ return undefined;
61
+ }
62
+ const ids = [d.cliSessionId, d.lastAssistantCliSessionId].filter((x) => typeof x === "string");
63
+ if (!ids.length)
64
+ return undefined;
65
+ return {
66
+ id: typeof d.sessionId === "string" ? d.sessionId : path.basename(file, ".json"),
67
+ cliSessionId: ids[0],
68
+ ids,
69
+ title: typeof d.title === "string" ? d.title : undefined,
70
+ cwd: typeof d.cwd === "string" ? d.cwd : undefined,
71
+ archived: d.isArchived === true,
72
+ lastActivityAt: typeof d.lastActivityAt === "number" ? d.lastActivityAt : 0,
73
+ };
74
+ }
75
+ /** The desktop app session running a CLI session (full id or a prefix of 6+ characters). */
76
+ export function findDesktopSession(cliSession, dir = desktopSessionsDir()) {
77
+ if (cliSession.length < 6)
78
+ return undefined;
79
+ let best;
80
+ for (const { ids, ...s } of desktopSessions(dir)) {
44
81
  if (!ids.some((x) => x.startsWith(cliSession)))
45
82
  continue;
46
- const s = {
47
- id: typeof d.sessionId === "string" ? d.sessionId : path.basename(file, ".json"),
48
- cliSessionId: ids[0],
49
- title: typeof d.title === "string" ? d.title : undefined,
50
- cwd: typeof d.cwd === "string" ? d.cwd : undefined,
51
- archived: d.isArchived === true,
52
- lastActivityAt: typeof d.lastActivityAt === "number" ? d.lastActivityAt : 0,
53
- };
54
83
  // prefer a live session, then the most recent one
55
84
  if (!best || (best.archived && !s.archived) || (best.archived === s.archived && s.lastActivityAt > best.lastActivityAt))
56
85
  best = s;
57
86
  }
58
87
  return best;
59
88
  }
89
+ /**
90
+ * The CLI sessions the human archived in the Claude desktop app, as a test for a session id
91
+ * (full or a prefix of 6+ characters). Such a session never takes another turn, however its log
92
+ * ends. A CLI session that a live desktop session also runs is not archived.
93
+ */
94
+ export function archivedSessions(dir = desktopSessionsDir()) {
95
+ const archived = new Set();
96
+ const live = new Set();
97
+ for (const s of desktopSessions(dir))
98
+ for (const id of s.ids)
99
+ (s.archived ? archived : live).add(id);
100
+ for (const id of live)
101
+ archived.delete(id);
102
+ const ids = [...archived];
103
+ return (session) => {
104
+ if (!session || session.length < 6)
105
+ return false;
106
+ if (archived.has(session))
107
+ return true;
108
+ return ids.some((id) => id.startsWith(session));
109
+ };
110
+ }
60
111
  /** A link that opens the session in the Claude desktop app. */
61
112
  export function desktopSessionUrl(s) {
62
113
  return `claude://code/continue?session=${encodeURIComponent(s.id)}&source=parawatch`;
@@ -321,6 +321,9 @@ export function runsTree(runs, v = PLAIN, now = Date.now(), indent = "") {
321
321
  const all = blocks.flatMap((b) => b.rows);
322
322
  const nameW = Math.min(Math.max(24, Math.floor(v.width * 0.4)), Math.max(12, ...all.map((row) => textWidth(row.prefix) + 2 + textWidth(row.run?.title ?? ""))));
323
323
  const wideW = v.wide ? 24 : 0;
324
+ // the machine a run is on gets a column of its own, so a long title cannot cut it off
325
+ const devW = Math.min(12, Math.max(0, ...all.map((row) => (row.run?.device ? textWidth(row.run.device) : 0))));
326
+ const devCol = devW ? devW + 2 : 0;
324
327
  const out = [];
325
328
  for (const { name, rows } of blocks) {
326
329
  if (name === OTHER)
@@ -342,12 +345,13 @@ export function runsTree(runs, v = PLAIN, now = Date.now(), indent = "") {
342
345
  const label = r.title + (r.repeats ? ` ×${r.repeats + 1}` : "") + (r.prs?.length ? ` (${r.prs.length} PR)` : "");
343
346
  const title = paint(v, pad(fit(label, room), room), ...titlePaint);
344
347
  const age = paint(v, ago(r.lastActivity, now).padStart(4), "dim");
348
+ const dev = devW ? " " + paint(v, pad(fit(r.device ?? "", devW), devW), "cyan") : "";
345
349
  const src = (r.projectSource && SOURCE_NOTE[r.projectSource]) || "";
346
350
  const wide = v.wide ? " " + paint(v, pad(fit(`${r.kind === "subagent" ? r.model ?? "subagent" : r.runtime} ${r.id.slice(0, 8)}${src ? " " + src : ""}`, wideW - 2), wideW - 2), "dim") : "";
347
- const restW = v.width - nameW - 2 - 4 - wideW - 2;
351
+ const restW = v.width - nameW - 2 - 4 - devCol - wideW - 2;
348
352
  const whatPaint = r.error || r.state === "stalled" || r.state === "failed" || r.asks?.blocked ? ["red"] : r.asks ? ["yellow"] : ended ? ["dim"] : [];
349
353
  const what = restW >= 8 ? " " + paint(v, fit(whatText(r), restW), ...whatPaint) : "";
350
- out.push(`${lead}${mark} ${title} ${age}${wide}${what}`.trimEnd());
354
+ out.push(`${lead}${mark} ${title} ${age}${dev}${wide}${what}`.trimEnd());
351
355
  }
352
356
  }
353
357
  return out.join("\n");
@@ -7,7 +7,7 @@ import { execFile, execFileSync } from "node:child_process";
7
7
  import fs from "node:fs";
8
8
  import path from "node:path";
9
9
  import { UserError } from "./ops.js";
10
- import { appendLocked, homeDir, withLock } from "./store.js";
10
+ import { appendLocked, deviceName, homeDir, withLock } from "./store.js";
11
11
  export const DEFAULT_STALL_HOURS = {
12
12
  review: 24,
13
13
  "no reviewer": 4,
@@ -307,6 +307,23 @@ export function nextCache(prev, fresh, hosts, at) {
307
307
  delete since[k];
308
308
  return { cache: { fetchedAt: at, hosts, prs, since }, events };
309
309
  }
310
+ /**
311
+ * Drop news another machine already reported. This machine's reading spans from its previous
312
+ * reading (`since`) to now; the same change on the same PR that another machine wrote in that
313
+ * span is the one this machine just noticed, maybe long after. A machine that reads GitHub for
314
+ * the first time in hours would otherwise tell every agent again what they already handled.
315
+ */
316
+ export function dropReported(fresh, log, since, me = deviceName()) {
317
+ if (!since)
318
+ return fresh;
319
+ const seen = new Set();
320
+ for (const e of log)
321
+ if (e.type === "pr.change" && e.dev && e.dev !== me && e.ts >= since)
322
+ seen.add(`${e.pr.key}|${e.change}|${e.by ?? ""}`);
323
+ if (!seen.size)
324
+ return fresh;
325
+ return fresh.filter((b) => b.type !== "pr.change" || !seen.has(`${b.pr.key}|${b.change}|${b.by ?? ""}`));
326
+ }
310
327
  // ------------------------------------------------------------------ sync
311
328
  function fetchingPath() {
312
329
  return path.join(homeDir(), "github.fetching");
@@ -386,7 +403,7 @@ export async function syncGithub(opts) {
386
403
  const { cache, events } = nextCache(latest, fresh, hosts, started);
387
404
  if (errors.length)
388
405
  cache.error = errors.join("; ");
389
- appendLocked(() => events);
406
+ appendLocked((log) => dropReported(events, log, latest.fetchedAt));
390
407
  writeCache(cache);
391
408
  return cache;
392
409
  });
@@ -1,9 +1,10 @@
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
+ import { insidePath } from "./paths.js";
7
8
  export const BEGIN = "<!-- parawatch:begin -->";
8
9
  export const END = "<!-- parawatch:end -->";
9
10
  export function instructions(project) {
@@ -23,7 +24,7 @@ The human follows this work through \`parawatch\`. Use it instead of asking in c
23
24
  - Stuck until the human does something only they can do (install, sudo, sign in): \`parawatch block --project ${p} "what to do"\`
24
25
  - Something only the human can do, but nothing waits on it now: \`parawatch todo --project ${p} "what to do"\`
25
26
  - 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.
27
+ - 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
28
  - 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
29
  - Never answer a card yourself. \`parawatch status\` shows everything that is open.
29
30
  ${END}`;
@@ -97,23 +98,28 @@ export function instructionState(file) {
97
98
  export function hookContext(input, project) {
98
99
  const events = readEvents();
99
100
  const state = readState(events);
101
+ const session = input.session_id;
100
102
  let name = project;
101
- if (!name && input.session_id)
102
- name = state.claims.find((c) => input.session_id.startsWith(c.session))?.project;
103
+ if (!name && session)
104
+ name = state.claims.find((c) => sameSession(session, c.session))?.project;
103
105
  if (!name && input.cwd) {
104
106
  for (const p of state.projects.values())
105
- if (p.paths.some((x) => input.cwd === x || input.cwd.startsWith(x + "/")))
107
+ if (p.paths.some((x) => insidePath(input.cwd, x)))
106
108
  name = p.name;
107
109
  }
108
- if (!name)
110
+ if (!name && !session)
109
111
  return undefined;
110
- const items = inboxFrom(events, readCursor(name), { project: name }, state);
112
+ // A session reads its own news with its own cursor; one still waiting on its project moves the
113
+ // project's cursor instead, so whichever is further says what it has handled.
114
+ const items = session
115
+ ? inboxFrom(events, furthest(readCursor(sessionConsumer(session)), name ? readCursor(name) : {}), { session, project: name }, state)
116
+ : inboxFrom(events, readCursor(name), { project: name }, state);
111
117
  if (!items.length)
112
118
  return undefined;
113
119
  const cards = items.filter((i) => i.kind !== "pr").length;
114
120
  const prs = items.length - cards;
115
121
  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");
122
+ 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
123
  }
118
124
  /** Best-effort desktop notification. Never throws, never blocks. */
119
125
  /** The desktop app writes its pid here while it runs, so the CLI leaves notifications to it. */
package/dist/core/ops.js CHANGED
@@ -2,10 +2,11 @@ import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { UserError } from "./errors.js";
4
4
  import { advance, appendEvent, deviceName, eventsDir, homeDir, isAfter, readCursor, readEvents, sharedDir, writeCursor } from "./store.js";
5
- import { findId, fold, nextId } from "./state.js";
5
+ import { archivedSessions } from "./desktop.js";
6
+ import { findId, fold, nextId, sessionTitle } from "./state.js";
6
7
  import { pathMap } from "./sync.js";
7
8
  import { CARD_KINDS, HUMAN, PR_CHANGES } from "./types.js";
8
- export { UserError };
9
+ export { UserError, sessionTitle };
9
10
  /** Current state, with other machines' paths made local. */
10
11
  export function readState(events = readEvents()) {
11
12
  return fold(events, pathMap());
@@ -18,9 +19,21 @@ function cleanList(xs) {
18
19
  const out = (xs ?? []).map((x) => x.trim()).filter(Boolean);
19
20
  return out.length ? out : undefined;
20
21
  }
22
+ /**
23
+ * An attachment or a thing to open, as stored. A URL, and a path that is absolute on either
24
+ * system (`/Users/...`, `C:\...`, `\\server\share`), is kept as written: it may name a place on
25
+ * another machine, and resolving it here would root it on this one (`E:\Users\...` on Windows,
26
+ * `<cwd>/C:\...` on a Mac). Only a relative path is resolved.
27
+ */
28
+ export function storedPath(x) {
29
+ if (/^[a-z]+:\/\//i.test(x))
30
+ return x;
31
+ if (path.posix.isAbsolute(x) || path.win32.isAbsolute(x))
32
+ return x;
33
+ return path.resolve(x);
34
+ }
21
35
  function absPaths(xs) {
22
- // attachments and things to open are kept as given when they look like URLs
23
- return cleanList(xs)?.map((x) => (/^[a-z]+:\/\//i.test(x) ? x : path.resolve(x)));
36
+ return cleanList(xs)?.map(storedPath);
24
37
  }
25
38
  export function addProject(name, paths) {
26
39
  if (!/^[A-Za-z0-9._-]+$/.test(name))
@@ -57,13 +70,6 @@ export function parseWhen(s) {
57
70
  throw new UserError(`"${s}" is not a date (use YYYY-MM-DD)`);
58
71
  return d.toISOString();
59
72
  }
60
- /** The claimed name of a session (claims may hold an id prefix). */
61
- export function sessionTitle(claims, session) {
62
- if (!session)
63
- return undefined;
64
- const c = claims.find((x) => session.startsWith(x.session) || x.session.startsWith(session));
65
- return c?.title;
66
- }
67
73
  function checkKind(kind) {
68
74
  if (!CARD_KINDS.includes(kind))
69
75
  throw new UserError(`kind must be one of ${CARD_KINDS.join(", ")}`);
@@ -273,6 +279,38 @@ export function prEvent(pr, change, opts = {}) {
273
279
  throw new UserError(`change must be one of ${PR_CHANGES.join(", ")}`);
274
280
  appendEvent(() => ({ type: "pr.change", pr, change: change, by: opts.by, text: opts.text?.trim() || undefined, title: opts.title, source: "external" }));
275
281
  }
282
+ /** Whether two session ids name the same session. A claim or a link may hold a short prefix. */
283
+ export function sameSession(a, b) {
284
+ return !!a && !!b && (a.startsWith(b) || b.startsWith(a));
285
+ }
286
+ /**
287
+ * Whether a session has ended: it said so with `claim --done`, or the human archived it in the
288
+ * Claude desktop app. Cards and pull requests it owned then belong to its project, so an answer
289
+ * still reaches a session waiting there. The desktop app is read only when asked about a session
290
+ * that has not claimed done.
291
+ */
292
+ export function endedSessions(state, isArchived) {
293
+ let archived = isArchived;
294
+ return (session) => {
295
+ const claim = state.claims.find((c) => sameSession(c.session, session));
296
+ if (claim?.done)
297
+ return true;
298
+ archived ??= archivedSessions();
299
+ return archived(session);
300
+ };
301
+ }
302
+ /** Whether something owned by `owner` in `project` is for a reader with this filter. */
303
+ function inScope(owner, project, f, ended) {
304
+ if (f.session) {
305
+ if (owner && sameSession(owner, f.session))
306
+ return true;
307
+ // what a session that has ended owned is the project's now
308
+ if (owner && !ended(owner))
309
+ return false;
310
+ return !!f.project && project === f.project;
311
+ }
312
+ return !f.project || project === f.project;
313
+ }
276
314
  /** Two machines that both read GitHub report the same change; the later report is an echo. */
277
315
  const ECHO_MS = 30 * 60_000;
278
316
  /**
@@ -285,6 +323,7 @@ export function inboxFrom(events, after, filter, state = readState(events)) {
285
323
  const out = [];
286
324
  const reported = new Map();
287
325
  const answerNo = new Map();
326
+ const ended = filter.ended ?? endedSessions(state);
288
327
  for (const ev of events) {
289
328
  if (ev.type === "pr.change") {
290
329
  const k = `${ev.pr.key}|${ev.change}|${ev.text ?? ""}`;
@@ -296,7 +335,7 @@ export function inboxFrom(events, after, filter, state = readState(events)) {
296
335
  if (!isAfter(ev, pos))
297
336
  continue;
298
337
  const link = state.prLinks.get(ev.pr.key);
299
- if (!link || filter.card || (filter.project && link.project !== filter.project))
338
+ if (!link || filter.card || !inScope(link.session, link.project, filter, ended))
300
339
  continue;
301
340
  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
341
  continue;
@@ -316,7 +355,7 @@ export function inboxFrom(events, after, filter, state = readState(events)) {
316
355
  const card = state.cards.get(ev.card);
317
356
  if (!card || card.status === "closed")
318
357
  continue;
319
- if (filter.project && card.project !== filter.project)
358
+ if (!inScope(card.session, card.project, filter, ended))
320
359
  continue;
321
360
  if (filter.card && card.id !== filter.card)
322
361
  continue;
@@ -432,13 +471,58 @@ export function listeners(now = Date.now()) {
432
471
  }
433
472
  return out;
434
473
  }
435
- /** The waits an answer to this card would wake right now. */
436
- export function listenersFor(card, ls) {
437
- return ls.filter((l) => (l.card ? l.card === card.id : !l.project || l.project === card.project));
474
+ /**
475
+ * The waits an answer to this card would wake right now. A card whose session has ended reaches
476
+ * the session waits in its project, as an unowned card does. A snapshot's card says so itself
477
+ * (`ownerEnded`); otherwise `ended` tells.
478
+ */
479
+ export function listenersFor(card, ls, ended) {
480
+ let gone = card.ownerEnded;
481
+ const ownerGone = () => (gone ??= !!card.session && (ended ?? endedSessions(readState()))(card.session));
482
+ return ls.filter((l) => l.card
483
+ ? l.card === card.id
484
+ : l.scope === "session"
485
+ ? card.session && sameSession(l.session, card.session)
486
+ ? true
487
+ : (!card.session || ownerGone()) && !!l.project && l.project === card.project
488
+ : !l.project || l.project === card.project);
438
489
  }
439
490
  export function consumerName(opts) {
440
491
  return opts.as || opts.project || (opts.card ? `card-${opts.card}` : "default");
441
492
  }
493
+ /** The cursor a session reads with when it waits for its own news. */
494
+ export function sessionConsumer(session) {
495
+ return `session-${session.replace(/[^A-Za-z0-9._-]/g, "_")}`;
496
+ }
497
+ /** Per device, the further of two positions. */
498
+ export function furthest(a, b) {
499
+ const out = { ...a };
500
+ for (const [d, seq] of Object.entries(b))
501
+ if (seq > (out[d] ?? 0))
502
+ out[d] = seq;
503
+ return out;
504
+ }
505
+ /**
506
+ * What a wait or an inbox reads. `--card` and `--project` read what they name, with a cursor of
507
+ * that name. Called from an agent session without either, it reads that session's own cards and
508
+ * pull requests, plus unowned ones in the project the session claimed, with a cursor of its own:
509
+ * sessions in one project neither wake for each other's news nor take it from each other.
510
+ * Outside a session, it reads everything.
511
+ */
512
+ export function inboxScope(opts, session) {
513
+ if (opts.card || opts.project || !session)
514
+ return { consumer: consumerName(opts), filter: { project: opts.project, card: opts.card } };
515
+ const claim = readState().claims.find((c) => sameSession(session, c.session));
516
+ const consumer = opts.as || sessionConsumer(session);
517
+ // A session that used to wait on its project starts where that wait stopped, so moving to its
518
+ // own cursor does not bring back news it has already handled.
519
+ if (claim && !Object.keys(readCursor(consumer)).length) {
520
+ const from = readCursor(claim.project);
521
+ if (Object.keys(from).length)
522
+ writeCursor(consumer, from);
523
+ }
524
+ return { consumer, filter: { session, project: claim?.project } };
525
+ }
442
526
  export function readInbox(consumer, filter, move) {
443
527
  const events = readEvents();
444
528
  const pos = readCursor(consumer);
@@ -0,0 +1,56 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ const WINDOWS = process.platform === "win32";
4
+ /**
5
+ * The real spelling of a path, for comparing: symlinks and Windows short names (`RUNNER~1`)
6
+ * resolved, through the deepest part that exists. On Windows also lower case with `\` only, since
7
+ * the file system ignores case and logs mix `/` and `\`.
8
+ */
9
+ export function canonicalPath(p) {
10
+ let rest = "";
11
+ let head = path.resolve(p);
12
+ let out = head;
13
+ for (;;) {
14
+ try {
15
+ out = path.join(fs.realpathSync.native(head), rest);
16
+ break;
17
+ }
18
+ catch {
19
+ const up = path.dirname(head);
20
+ if (up === head)
21
+ break;
22
+ rest = path.join(path.basename(head), rest);
23
+ head = up;
24
+ }
25
+ }
26
+ return WINDOWS ? out.replace(/\//g, "\\").toLowerCase() : out;
27
+ }
28
+ function within(child, parent) {
29
+ const rel = path.relative(parent, child);
30
+ return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel));
31
+ }
32
+ /**
33
+ * Whether `child` is `parent` or inside it, as written or once both are resolved
34
+ * (`canonicalPath`). Pass a canonical form when it is already known, to skip the file system.
35
+ */
36
+ export function insidePath(child, parent, canon) {
37
+ if (within(path.resolve(child), path.resolve(parent)))
38
+ return true;
39
+ return within(canon?.child ?? canonicalPath(child), canon?.parent ?? canonicalPath(parent));
40
+ }
41
+ /** Whether two paths name the same folder. */
42
+ export function samePath(a, b) {
43
+ return path.resolve(a) === path.resolve(b) || canonicalPath(a) === canonicalPath(b);
44
+ }
45
+ /**
46
+ * Whether a text (a prompt) names this folder: as written, or with `\` and `/` swapped, and on
47
+ * Windows ignoring case.
48
+ */
49
+ export function namesPath(text, dir) {
50
+ if (text.includes(dir))
51
+ return true;
52
+ const hay = WINDOWS ? text.toLowerCase() : text;
53
+ const d = WINDOWS ? dir.toLowerCase() : dir;
54
+ return hay.includes(d.replace(/\\/g, "/")) || hay.includes(d.replace(/\//g, "\\"));
55
+ }
56
+ //# sourceMappingURL=paths.js.map
package/dist/core/runs.js CHANGED
@@ -3,6 +3,9 @@ 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 { archivedSessions } from "./desktop.js";
7
+ import { canonicalPath, insidePath, namesPath, samePath } from "./paths.js";
8
+ import { deviceName, sharedDir } from "./store.js";
6
9
  /** All checkouts of a project path: the path itself plus its git worktrees. */
7
10
  function rootsOf(p, cache) {
8
11
  const hit = cache.get(p);
@@ -13,7 +16,7 @@ function rootsOf(p, cache) {
13
16
  const git = (args) => execFileSync("git", ["-C", p, ...args], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 5000 });
14
17
  // a folder inside a bigger repo (a notes vault, a monorepo package) must not claim the whole repo
15
18
  const top = git(["rev-parse", "--show-toplevel"]).trim();
16
- if (path.resolve(top) !== path.resolve(p)) {
19
+ if (!samePath(top, p)) {
17
20
  cache.set(p, roots);
18
21
  return roots;
19
22
  }
@@ -32,23 +35,23 @@ function rootsOf(p, cache) {
32
35
  cache.set(p, roots);
33
36
  return roots;
34
37
  }
35
- function inside(child, parent) {
36
- const rel = path.relative(parent, child);
37
- return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel));
38
- }
39
38
  function matchers(projects) {
40
39
  const cache = new Map();
41
- return projects.map((p) => ({ project: p.name, roots: p.paths.flatMap((x) => rootsOf(x, cache)) }));
40
+ return projects.map((p) => ({
41
+ project: p.name,
42
+ roots: p.paths.flatMap((x) => rootsOf(x, cache)).map((r) => ({ path: r, canonical: canonicalPath(r) })),
43
+ }));
42
44
  }
43
45
  /** The project whose checkout holds this directory; the deepest root wins. */
44
46
  function projectForCwd(cwd, ms) {
45
47
  if (!cwd)
46
48
  return undefined;
49
+ const child = canonicalPath(cwd);
47
50
  let best;
48
51
  for (const m of ms) {
49
52
  for (const r of m.roots) {
50
- if (inside(cwd, r) && (!best || r.length > best.depth))
51
- best = { project: m.project, depth: r.length };
53
+ if (insidePath(cwd, r.path, { child, parent: r.canonical }) && (!best || r.path.length > best.depth))
54
+ best = { project: m.project, depth: r.path.length };
52
55
  }
53
56
  }
54
57
  return best?.project;
@@ -60,7 +63,7 @@ function projectForPrompt(prompt, ms) {
60
63
  const hits = new Set();
61
64
  for (const m of ms) {
62
65
  for (const r of m.roots)
63
- if (prompt.includes(r))
66
+ if (namesPath(prompt, r.path))
64
67
  hits.add(m.project);
65
68
  }
66
69
  return hits.size === 1 ? [...hits][0] : undefined;
@@ -96,27 +99,31 @@ function claimFor(id, state) {
96
99
  * unless it only edits files of another project; its parent's project; the project whose files it
97
100
  * edits most; a project path named in its first prompt.
98
101
  */
102
+ function applyClaim(r, claim) {
103
+ r.project = claim.project;
104
+ r.projectSource = "claim";
105
+ r.task = claim.task ?? r.task;
106
+ r.role = claim.role ?? r.role;
107
+ if (claim.title)
108
+ r.title = claim.title;
109
+ if (claim.kind === "scheduled")
110
+ r.scheduled = true;
111
+ // done until someone writes to the session again
112
+ if (claim.done && claim.at && (!r.lastPromptAt || r.lastPromptAt < claim.at)) {
113
+ r.done = claim.done;
114
+ // a session that says it is finished is not stuck, even if its log ends mid-turn
115
+ if (r.state === "idle" || r.state === "stalled")
116
+ r.state = "done";
117
+ r.asks = undefined;
118
+ }
119
+ }
99
120
  export function attribute(runs, state) {
100
121
  const ms = matchers([...state.projects.values()]);
101
122
  const byId = new Map(runs.map((r) => [r.id, r]));
102
123
  for (const r of runs) {
103
124
  const claim = claimFor(r.id, state);
104
125
  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
- }
126
+ applyClaim(r, claim);
120
127
  continue;
121
128
  }
122
129
  const here = projectForCwd(r.cwd, ms);
@@ -154,6 +161,29 @@ export function attribute(runs, state) {
154
161
  }
155
162
  return runs;
156
163
  }
164
+ /**
165
+ * A Claude Code session the human archived in the Claude desktop app has ended, even when its log
166
+ * stops mid-turn (archived while a tool ran, say), and so have its subagents.
167
+ */
168
+ export function endArchived(runs, isArchived) {
169
+ const ended = new Set();
170
+ for (const r of runs) {
171
+ if (r.runtime !== "claude-code" || r.kind !== "session" || r.device || !isArchived(r.id))
172
+ continue;
173
+ ended.add(r.id);
174
+ }
175
+ for (const r of runs) {
176
+ if (!ended.has(r.id) && !(r.parent && ended.has(r.parent)))
177
+ continue;
178
+ if (r.state === "failed")
179
+ continue;
180
+ r.state = "done";
181
+ if (ended.has(r.id))
182
+ r.done ??= "archived in the Claude app";
183
+ r.asks = undefined;
184
+ }
185
+ return runs;
186
+ }
157
187
  /**
158
188
  * Fold repeated runs of a scheduled task into the newest one, with a count. Runs claimed as
159
189
  * scheduled group by project and task; unclaimed top-level sessions group by an identical first
@@ -247,6 +277,92 @@ export function declaredRuns(state, now = Date.now()) {
247
277
  };
248
278
  });
249
279
  }
280
+ // ---------------------------------------------------------------- other machines
281
+ /** How often a machine rewrites its runs for the others, and when its summary counts as gone. */
282
+ const SHARE_EVERY_MS = 30_000;
283
+ export const SHARED_RUNS_GONE_MS = 15 * 60_000;
284
+ const SHARED_RUNS_FILE = /^([a-z0-9][a-z0-9-]{0,31})\.json$/;
285
+ function sharedRunsDir() {
286
+ const s = sharedDir();
287
+ return s ? path.join(s, "runs") : undefined;
288
+ }
289
+ /**
290
+ * Write the runs read from this machine's agent logs where the other machines read them, at most
291
+ * every half minute. The logs themselves stay here; only the summary travels.
292
+ */
293
+ export function shareRuns(runs, now = Date.now()) {
294
+ if (!shareRunsDue(now))
295
+ return;
296
+ const dir = sharedRunsDir();
297
+ const me = deviceName();
298
+ const file = path.join(dir, `${me}.json`);
299
+ // the folders it edited are paths on this machine, and only guide attribution here
300
+ const slim = runs.map(({ touched: _t, ...r }) => ({ ...r, prompt: r.prompt?.slice(0, 300) }));
301
+ const body = { v: 1, device: me, at: new Date(now).toISOString(), runs: slim };
302
+ try {
303
+ fs.mkdirSync(dir, { recursive: true });
304
+ const tmp = path.join(dir, `.${me}.${process.pid}.tmp`);
305
+ fs.writeFileSync(tmp, JSON.stringify(body) + "\n");
306
+ fs.renameSync(tmp, file);
307
+ }
308
+ catch {
309
+ // the folder may be busy syncing; the next reading writes it again
310
+ }
311
+ }
312
+ /** Whether this machine's summary for the others is due (a shared folder, and none written lately). */
313
+ export function shareRunsDue(now = Date.now()) {
314
+ const dir = sharedRunsDir();
315
+ if (!dir)
316
+ return false;
317
+ try {
318
+ return now - fs.statSync(path.join(dir, `${deviceName()}.json`)).mtimeMs >= SHARE_EVERY_MS;
319
+ }
320
+ catch {
321
+ return true;
322
+ }
323
+ }
324
+ /**
325
+ * Runs other machines shared, as they last wrote them, marked with the machine. A machine that
326
+ * has not written for a while is left out: its agents may be long gone. Claims made since are
327
+ * applied here, and the activity chart slides by the summary's age.
328
+ */
329
+ export function otherRuns(state, now = Date.now()) {
330
+ const dir = sharedRunsDir();
331
+ if (!dir)
332
+ return [];
333
+ const me = deviceName();
334
+ let names = [];
335
+ try {
336
+ names = fs.readdirSync(dir);
337
+ }
338
+ catch {
339
+ return [];
340
+ }
341
+ const out = [];
342
+ for (const n of names.sort()) {
343
+ const m = SHARED_RUNS_FILE.exec(n);
344
+ if (!m || m[1] === me)
345
+ continue;
346
+ try {
347
+ const s = JSON.parse(fs.readFileSync(path.join(dir, n), "utf8"));
348
+ const at = Date.parse(s.at);
349
+ if (!Number.isFinite(at) || now - at > SHARED_RUNS_GONE_MS || !Array.isArray(s.runs))
350
+ continue;
351
+ const shift = Math.max(0, Math.floor((now - at) / 60_000));
352
+ for (const r of s.runs) {
353
+ const run = { ...r, device: m[1], activity: r.activity?.map((x) => x + shift).filter((x) => x < ACTIVITY_MINUTES) };
354
+ const claim = claimFor(run.id, state);
355
+ if (claim)
356
+ applyClaim(run, claim);
357
+ out.push(run);
358
+ }
359
+ }
360
+ catch {
361
+ // half synced; read it next time
362
+ }
363
+ }
364
+ return out;
365
+ }
250
366
  const ENDED = new Set(["done", "idle", "failed"]);
251
367
  const RECENT_END_MS = 30 * 60_000;
252
368
  const OLD_STALL_MS = 3 * 3_600_000;
@@ -254,7 +370,11 @@ export function collectRuns(state, q, now = Date.now()) {
254
370
  const sinceMs = now - q.sinceHours * 3_600_000;
255
371
  // a log file can be touched (title records, snapshots) long after its last real activity
256
372
  const recent = (r) => !r.lastActivity || Date.parse(r.lastActivity) >= sinceMs;
257
- let runs = attribute(scanAll({ sinceMs, now }, q.runtimes), state).filter(recent);
373
+ let runs = endArchived(attribute(scanAll({ sinceMs, now }, q.runtimes), state), archivedSessions()).filter(recent);
374
+ // a narrower reading (one runtime) would hide the rest from the other machines
375
+ if (!q.runtimes)
376
+ shareRuns(runs, now);
377
+ runs.push(...otherRuns(state, now).filter(recent));
258
378
  runs.push(...declaredRuns(state, now).filter(recent));
259
379
  attachPrs(runs, state);
260
380
  if (q.project)
@@ -1,5 +1,5 @@
1
1
  import { prViews, keyOf, parsePrRef, readCache, githubConfig } from "./github.js";
2
- import { linkPr, listeners, readState } from "./ops.js";
2
+ import { endedSessions, linkPr, listeners, readState } from "./ops.js";
3
3
  import { collectRuns } from "./runs.js";
4
4
  import { deviceName, eventFiles, sharedDir } from "./store.js";
5
5
  import { SCHEMA_VERSION } from "./types.js";
@@ -15,7 +15,8 @@ export function snapshot(opts = {}) {
15
15
  if (linkCreatedPrs(runs, state))
16
16
  state = readState();
17
17
  const all = [...state.cards.values()].filter((c) => !opts.project || c.project === opts.project);
18
- const cards = [...all.filter((c) => c.status !== "closed"), ...recentlyClosed(all, opts.closed ?? 0)];
18
+ const ended = endedSessions(state);
19
+ const cards = [...all.filter((c) => c.status !== "closed"), ...recentlyClosed(all, opts.closed ?? 0)].map((c) => c.status !== "closed" && c.session && ended(c.session) ? { ...c, ownerEnded: true } : c);
19
20
  quietAnsweredAsks(runs, all);
20
21
  const cache = readCache();
21
22
  const enabled = githubConfig().enabled && (cache.hosts === undefined ? false : cache.hosts.length > 0);
@@ -1,3 +1,10 @@
1
+ /** The claimed name of a session (claims may hold an id prefix). */
2
+ export function sessionTitle(claims, session) {
3
+ if (!session)
4
+ return undefined;
5
+ const c = claims.find((x) => session.startsWith(x.session) || x.session.startsWith(session));
6
+ return c?.title;
7
+ }
1
8
  function mapAll(xs, map, from) {
2
9
  return xs && map ? xs.map((x) => map(x, from)) : xs;
3
10
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "parawatch",
3
- "version": "0.3.1",
3
+ "version": "0.3.3",
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`.