parawatch 0.3.11 → 0.3.12

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/dist/bin/cli.js CHANGED
@@ -2,7 +2,7 @@
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, dismissCard, commentCard, inboxScope, editCard, endRun, hideRun, unhideRun, 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, dismissCard, endedSessions, handCard, handPr, leadOf, makeLead, commentCard, inboxScope, editCard, endRun, hideRun, unhideRun, 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 { recordPrompt } from "../core/prompts.js";
8
8
  import { callingSubagent } from "../adapters/claudeCode.js";
@@ -190,6 +190,26 @@ program
190
190
  .description("close a card that no longer matters, without answering it; the agent that asked is told")
191
191
  .option("-n, --note <text>", "why, for the agent")
192
192
  .action((id, o) => dismissCard(id, o.note));
193
+ program
194
+ .command("hand <target> <session>")
195
+ .description("give a card or a pull request to another session (id prefix, 6+ characters); that session is told once")
196
+ .action((target, session) => {
197
+ if (/#\d+$|:\/\//.test(target)) {
198
+ const ref = resolvePrRef(target);
199
+ handPr(ref, session);
200
+ process.stdout.write(`${ref.repo}#${ref.number} → ${session.slice(0, 8)}\n`);
201
+ return;
202
+ }
203
+ const card = handCard(target, session);
204
+ process.stdout.write(`${card.id} → ${session.slice(0, 8)}\n`);
205
+ });
206
+ program
207
+ .command("lead <project> <session>")
208
+ .description("make a session the project's lead: it gets what no session owns there, and what ended sessions left")
209
+ .action((project, session) => {
210
+ const r = makeLead(project, session);
211
+ process.stdout.write(`${r.session.slice(0, 8)} leads ${project}${r.handed.length ? `; handed ${r.handed.join(", ")}` : ""}\n`);
212
+ });
193
213
  program
194
214
  .command("show <card>")
195
215
  .description("show one card with its answers")
@@ -203,11 +223,16 @@ program
203
223
  return;
204
224
  }
205
225
  const waits = card.status === "closed" ? [] : listenersFor(card, listeners());
226
+ const state = readState();
227
+ const ended = endedSessions(state);
228
+ const noOne = card.status !== "closed" && (!card.session || ended(card.session)) && !leadOf(state, ended)(card.project);
206
229
  const delivery = card.status === "closed"
207
230
  ? ""
208
- : waits.length
209
- ? `\nDelivery: an agent is waiting (${waits.map((l) => l.consumer).join(", ")}); an answer wakes it now.`
210
- : "\nDelivery: no agent is waiting; an answer is kept until the agent next checks (its next turn, or its next wait).";
231
+ : noOne
232
+ ? `\nDelivery: no session gets an answer (${card.session ? "its session has ended" : "no session opened it"}, and ${card.project} has no lead). Pick a lead with parawatch lead ${card.project} <session>, or hand it on with parawatch hand ${card.id} <session>.`
233
+ : waits.length
234
+ ? `\nDelivery: an agent is waiting (${waits.map((l) => l.consumer).join(", ")}); an answer wakes it now.`
235
+ : "\nDelivery: no agent is waiting; an answer is kept until the agent next checks (its next turn, or its next wait).";
211
236
  process.stdout.write(cardDetail(card) + delivery + "\n");
212
237
  });
213
238
  program
@@ -226,9 +251,9 @@ program
226
251
  // ------------------------------------------------------------------ inbox
227
252
  function inboxOptions(c) {
228
253
  return c
229
- .option("-p, --project <name>", "everything in this project, whoever owns it (for a lead)")
254
+ .option("-p, --project <name>", "this project instead of the claimed one; outside a session, everything in it")
230
255
  .option("--card <id>", "only answers to this card")
231
- .option("--as <consumer>", "cursor name (defaults to the session, or the project with -p)")
256
+ .option("--as <consumer>", "cursor name (defaults to the session; outside a session, the project with -p)")
232
257
  .option("--json", "JSON output");
233
258
  }
234
259
  inboxOptions(program
@@ -341,7 +366,9 @@ program
341
366
  if (o.kind !== undefined && o.kind !== "scheduled")
342
367
  throw new UserError("--kind can only be 'scheduled'");
343
368
  const c = claimSession({ session: id, project: o.project, task: o.task, role: o.role, title: o.title, kind: o.kind, done: o.done });
344
- process.stdout.write(`${id.slice(0, 8)} → ${c.project}${c.task ? ` / ${c.task}` : ""}${c.role ? ` (${c.role})` : ""}${c.done ? " · done" : ""}\n`);
369
+ // a new lead: earlier leads of the project step down, and it takes what no session got
370
+ const handed = o.role === "lead" && !c.done ? makeLead(c.project, id).handed : [];
371
+ process.stdout.write(`${id.slice(0, 8)} → ${c.project}${c.task ? ` / ${c.task}` : ""}${c.role ? ` (${c.role})` : ""}${c.done ? " · done" : ""}${handed.length ? `; handed ${handed.join(", ")}` : ""}\n`);
345
372
  });
346
373
  // ------------------------------------------------------------------ pull requests
347
374
  const pr = program.command("pr").description("pull requests and what they wait on (reads GitHub through gh)");
@@ -436,16 +463,23 @@ program
436
463
  const head = (title, n, ...extra) => v.color ? `\x1b[1m${title}\x1b[0m \x1b[2m${n}${extra.join("")}\x1b[0m` : `${title} ${n}${extra.join("")}`;
437
464
  const open = cards.filter((c) => c.status === "open" && c.kind !== "todo");
438
465
  const todo = cards.filter((c) => c.status === "open" && c.kind === "todo");
439
- const answered = cards.filter((c) => c.status === "answered");
466
+ // answered, but no session gets it: no owner left and no lead in the project
467
+ const stranded = cards.filter((c) => c.status === "answered" && c.unrouted);
468
+ const answered = cards.filter((c) => c.status === "answered" && !c.unrouted);
469
+ const strandedPrs = prs.filter((p) => p.unrouted);
440
470
  // a session held by a permission prompt needs the human as much as a card does
441
471
  const prompts = runs.filter((r) => r.asks?.via === "permission");
442
472
  const askers = runs.filter((r) => r.asks && r.asks.via !== "permission");
443
473
  const reviews = prs.filter((p) => p.reviewRequested && !p.mine);
444
- const waiting = prs.filter((p) => !(p.reviewRequested && !p.mine));
474
+ const waiting = prs.filter((p) => !(p.reviewRequested && !p.mine) && !p.unrouted);
445
475
  const ls = snap.listeners ?? [];
446
476
  const out = [];
447
477
  out.push(head("NEEDS YOU", open.length + prompts.length || "nothing"));
448
- out.push(...cardRows(open, v, now, " "));
478
+ const noOne = {};
479
+ for (const c of open)
480
+ if (c.unrouted)
481
+ noOne[c.id] = "no session gets the answer";
482
+ out.push(...cardRows(open, v, now, " ", noOne));
449
483
  out.push(...askRows(prompts, v, now, " "));
450
484
  if (askers.length) {
451
485
  out.push("", head("ASKED IN CHAT, NO CARD", askers.length, " (read from the log)"));
@@ -459,6 +493,14 @@ program
459
493
  out.push("", head("TO DO, NOTHING WAITS ON IT", todo.length));
460
494
  out.push(...cardRows(todo, v, now, " "));
461
495
  }
496
+ if (stranded.length || strandedPrs.length) {
497
+ out.push("", head("NO SESSION GETS THESE", stranded.length + strandedPrs.length, " (no owner left, no lead)"));
498
+ out.push(...cardRows(stranded, v, now, " "));
499
+ out.push(...prRows(strandedPrs, v, now, " "));
500
+ const projects = [...new Set([...stranded.map((c) => c.project), ...strandedPrs.map((p) => p.project)])];
501
+ const dim = (t) => (v.color ? `\x1b[2m${t}\x1b[0m` : t);
502
+ out.push(dim(` pick a lead: parawatch lead ${projects.length === 1 ? projects[0] : "<project>"} <session> or hand one on: parawatch hand <card or PR> <session>`));
503
+ }
462
504
  if (answered.length) {
463
505
  const notes = {};
464
506
  // the snapshot already marked the cards whose session has ended
@@ -179,12 +179,26 @@ export function prChangeText(change, by) {
179
179
  return by && by !== "github" && by !== "external" ? `${w} by ${by}` : w;
180
180
  }
181
181
  export function inboxLine(i) {
182
+ if (i.kind === "handed" && i.pr) {
183
+ const p = i.pr;
184
+ return `[${p.project ?? "?"}] PR ${p.ref.repo}#${p.ref.number}${p.title ? ` "${p.title}"` : ""} handed to you by the human: its news wakes you from now on (${p.ref.url})`;
185
+ }
182
186
  if (i.kind === "pr" && i.pr) {
183
187
  const p = i.pr;
184
- return `[${p.project ?? "?"}] PR ${p.ref.repo}#${p.ref.number}${p.title ? ` "${p.title}"` : ""}: ${prChangeText(p.change, i.by)}${p.text ? `; ${p.text}` : ""} (${p.ref.url})`;
188
+ return `[${p.project ?? "?"}] PR ${p.ref.repo}#${p.ref.number}${p.title ? ` "${p.title}"` : ""}: ${p.change ? prChangeText(p.change, i.by) : "news"}${p.text ? `; ${p.text}` : ""} (${p.ref.url})`;
185
189
  }
186
190
  const card = i.card;
187
191
  const head = `[${card.project}] ${card.id} "${card.title}"`;
192
+ if (i.kind === "handed") {
193
+ const was = [];
194
+ if (i.choice !== undefined)
195
+ was.push(`chose ${i.choice}${i.choiceText ? ` (${i.choiceText})` : ""}`);
196
+ if (i.comment)
197
+ was.push(`said: ${i.comment}`);
198
+ if (i.attach?.length)
199
+ was.push(`attached: ${i.attach.join(", ")}`);
200
+ return `${head} handed to you by the human; ${was.length ? `the answer: ${was.join("; ")}` : "not answered yet"}`;
201
+ }
188
202
  if (i.kind === "comment")
189
203
  return `${head} comment: ${i.comment}`;
190
204
  if (i.kind === "dismissed")
@@ -24,7 +24,7 @@ The human follows this work through \`parawatch\`. Use it instead of asking in c
24
24
  - Stuck until the human does something only they can do (install, sudo, sign in): \`parawatch block --project ${p} "what to do"\`
25
25
  - Something only the human can do, but nothing waits on it now: \`parawatch todo --project ${p} "what to do"\`
26
26
  - Put every question for the human on a card, not only in your reply: a question left in chat is easy to miss.
27
- - After opening a card or linking a pull request, keep a background wait armed: \`parawatch wait\`. 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
+ - 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 waits the same way; answers on another live session's cards go to that session. What no session owns goes to the lead (\`parawatch claim --project ${p} --role lead\`).
28
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>\`.
29
29
  - Never answer a card yourself. \`parawatch status\` shows everything that is open.
30
30
  ${END}`;
package/dist/core/ops.js CHANGED
@@ -234,6 +234,65 @@ export function dismissCard(id, note) {
234
234
  throw new UserError(`card ${card.id} is already closed`);
235
235
  appendEvent(() => ({ type: "card.close", card: card.id, note: note?.trim() || undefined, dismissed: true }));
236
236
  }
237
+ /** The session id as claims hold it, when one matches; else as given. */
238
+ function claimedId(state, session) {
239
+ return state.claims.find((c) => sameSession(c.session, session))?.session ?? session;
240
+ }
241
+ /** Give an open card to another session. That session is told once, with the last answer. */
242
+ export function handCard(id, session) {
243
+ const card = requireCard(id);
244
+ if (card.status === "closed")
245
+ throw new UserError(`card ${card.id} is already closed`);
246
+ if (session.length < 6)
247
+ throw new UserError("give at least 6 characters of the session id");
248
+ const to = claimedId(readState(), session);
249
+ appendEvent(() => ({ type: "card.hand", card: card.id, session: to, by: HUMAN }));
250
+ return card;
251
+ }
252
+ /** Give a pull request to another session: its news wakes that session from now on. */
253
+ export function handPr(pr, session) {
254
+ if (session.length < 6)
255
+ throw new UserError("give at least 6 characters of the session id");
256
+ const state = readState();
257
+ const link = state.prLinks.get(pr.key);
258
+ linkPr({ pr, session: claimedId(state, session), project: link?.project, task: link?.task, handed: true });
259
+ }
260
+ /**
261
+ * Answered cards no session gets in a project: no owner, or the owner has ended, and no live lead
262
+ * (or a lead that came after the answer). A new lead takes them over.
263
+ */
264
+ export function backlogOf(project, state = readState()) {
265
+ const ended = endedSessions(state);
266
+ return [...state.cards.values()].filter((c) => c.project === project && c.status === "answered" && (!c.session || ended(c.session)));
267
+ }
268
+ /** Hand the answered cards no session gets to the project's lead, who is told of each once. */
269
+ export function takeBacklog(project, session) {
270
+ const handed = [];
271
+ for (const c of backlogOf(project)) {
272
+ if (sameSession(c.session, session))
273
+ continue;
274
+ handCard(c.id, session);
275
+ handed.push(c.id);
276
+ }
277
+ return handed;
278
+ }
279
+ /**
280
+ * Make a session the project's lead (the human's pick, from the panel or `parawatch lead`).
281
+ * Earlier leads of the project step down; answered cards no session got are handed to it.
282
+ */
283
+ export function makeLead(project, session) {
284
+ if (session.length < 6)
285
+ throw new UserError("give at least 6 characters of the session id");
286
+ const state = readState();
287
+ const to = claimedId(state, session);
288
+ for (const c of state.claims) {
289
+ if (c.role === "lead" && c.project === project && !sameSession(c.session, to))
290
+ claimSession({ session: c.session, role: "", done: c.done });
291
+ }
292
+ // a lead works on: any done it said earlier no longer stands
293
+ claimSession({ session: to, project, role: "lead" });
294
+ return { session: to, handed: takeBacklog(project, to) };
295
+ }
237
296
  export function startRun(r) {
238
297
  if (r.heartbeat !== undefined && (!Number.isFinite(r.heartbeat) || r.heartbeat <= 0))
239
298
  throw new UserError("--heartbeat must be a number of seconds above 0");
@@ -320,7 +379,7 @@ export function linkPr(link) {
320
379
  const project = link.project?.trim() || claim?.project;
321
380
  appendEvent(() => ({
322
381
  type: "pr.link",
323
- link: { pr: link.pr, session: link.session, project, task: link.task?.trim() || claim?.task, auto: link.auto || undefined },
382
+ link: { pr: link.pr, session: link.session, project, task: link.task?.trim() || claim?.task, auto: link.auto || undefined, handed: link.handed || undefined },
324
383
  }));
325
384
  }
326
385
  export function unlinkPr(key) {
@@ -354,15 +413,33 @@ export function endedSessions(state, isArchived) {
354
413
  return archived(session);
355
414
  };
356
415
  }
416
+ /**
417
+ * The project's lead: the session that most recently claimed it with `--role lead` and has not
418
+ * ended. It gets what no session owns in the project, and what a session that has ended left.
419
+ */
420
+ export function leadOf(state, ended) {
421
+ const memo = new Map();
422
+ return (project) => {
423
+ if (!project)
424
+ return undefined;
425
+ if (!memo.has(project)) {
426
+ const leads = state.claims
427
+ .filter((c) => c.role === "lead" && c.project === project && !c.done && !ended(c.session))
428
+ .sort((a, b) => (b.at ?? "").localeCompare(a.at ?? ""));
429
+ memo.set(project, leads[0]?.session);
430
+ }
431
+ return memo.get(project);
432
+ };
433
+ }
357
434
  /** Whether something owned by `owner` in `project` is for a reader with this filter. */
358
- function inScope(owner, project, f, ended) {
435
+ function inScope(owner, project, f, ended, lead) {
359
436
  if (f.session) {
360
437
  if (owner && sameSession(owner, f.session))
361
438
  return true;
362
- // what a session that has ended owned is the project's now
363
439
  if (owner && !ended(owner))
364
440
  return false;
365
- return !!f.project && project === f.project;
441
+ // no owner, or it has ended: the project's lead takes it; without a lead it waits in parawatch
442
+ return sameSession(lead(project), f.session);
366
443
  }
367
444
  return !f.project || project === f.project;
368
445
  }
@@ -379,8 +456,55 @@ export function inboxFrom(events, after, filter, state = readState(events)) {
379
456
  const reported = new Map();
380
457
  const answerNo = new Map();
381
458
  const ended = filter.ended ?? endedSessions(state);
382
- for (const ev of events) {
459
+ const lead = leadOf(state, ended);
460
+ const finished = new Set();
461
+ // a card handed to this reader comes with its last answer; the answers before it are not repeated
462
+ const handedAt = new Map();
463
+ if (filter.session) {
464
+ events.forEach((ev, i) => {
465
+ if (ev.type === "card.hand" && isAfter(ev, pos) && sameSession(ev.session, filter.session))
466
+ handedAt.set(ev.card, i);
467
+ });
468
+ }
469
+ for (const [i, ev] of events.entries()) {
470
+ if (ev.type === "pr.link") {
471
+ // a pull request the human handed to this session: told once
472
+ if (!ev.link.handed || !isAfter(ev, pos) || !filter.session || filter.card || !sameSession(ev.link.session, filter.session))
473
+ continue;
474
+ const now = state.prLinks.get(ev.link.pr.key);
475
+ if (!now || !sameSession(now.session, filter.session))
476
+ continue;
477
+ out.push({ seq: ev.seq, dev: ev.dev, at: ev.ts, kind: "handed", by: HUMAN, pr: { ref: ev.link.pr, project: now.project } });
478
+ continue;
479
+ }
480
+ if (ev.type === "card.hand") {
481
+ // a card the human handed to this session: told once, with the last answer on it
482
+ if (!isAfter(ev, pos) || !filter.session || !sameSession(ev.session, filter.session))
483
+ continue;
484
+ const card = state.cards.get(ev.card);
485
+ if (!card || card.status === "closed" || !sameSession(card.session, filter.session))
486
+ continue;
487
+ if (filter.card && card.id !== filter.card)
488
+ continue;
489
+ const last = [...card.answers].reverse().find((a) => a.by === HUMAN && !a.late);
490
+ out.push({
491
+ seq: ev.seq,
492
+ dev: ev.dev,
493
+ at: ev.ts,
494
+ kind: "handed",
495
+ card,
496
+ by: ev.by,
497
+ choice: last?.choice,
498
+ choiceText: last?.choice !== undefined ? card.options?.[last.choice - 1] : undefined,
499
+ comment: last?.comment,
500
+ attach: last?.attach,
501
+ });
502
+ continue;
503
+ }
383
504
  if (ev.type === "pr.change") {
505
+ const wasOver = finished.has(ev.pr.key);
506
+ if (ev.change === "merged" || ev.change === "closed")
507
+ finished.add(ev.pr.key);
384
508
  const k = `${ev.pr.key}|${ev.change}|${ev.text ?? ""}`;
385
509
  const prev = reported.get(k);
386
510
  const at = Date.parse(ev.ts);
@@ -390,7 +514,11 @@ export function inboxFrom(events, after, filter, state = readState(events)) {
390
514
  if (!isAfter(ev, pos))
391
515
  continue;
392
516
  const link = state.prLinks.get(ev.pr.key);
393
- if (!link || filter.card || !inScope(link.session, link.project, filter, ended))
517
+ if (!link || filter.card || !inScope(link.session, link.project, filter, ended, lead))
518
+ continue;
519
+ // a merged or closed pull request whose owner has ended needs no one: its news, the merge
520
+ // itself and the bot comments after it, stays on the pull request
521
+ if (filter.session && !sameSession(link.session, filter.session) && (wasOver || finished.has(ev.pr.key)))
394
522
  continue;
395
523
  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 } });
396
524
  continue;
@@ -400,7 +528,7 @@ export function inboxFrom(events, after, filter, state = readState(events)) {
400
528
  if (!ev.dismissed || !isAfter(ev, pos))
401
529
  continue;
402
530
  const card = state.cards.get(ev.card);
403
- if (!card || !inScope(card.session, card.project, filter, ended))
531
+ if (!card || !inScope(card.session, card.project, filter, ended, lead))
404
532
  continue;
405
533
  if (filter.card && card.id !== filter.card)
406
534
  continue;
@@ -419,10 +547,12 @@ export function inboxFrom(events, after, filter, state = readState(events)) {
419
547
  continue;
420
548
  if (ev.by !== HUMAN || late)
421
549
  continue;
550
+ if ((handedAt.get(ev.card) ?? -1) > i)
551
+ continue;
422
552
  const card = state.cards.get(ev.card);
423
553
  if (!card || card.status === "closed")
424
554
  continue;
425
- if (!inScope(card.session, card.project, filter, ended))
555
+ if (!inScope(card.session, card.project, filter, ended, lead))
426
556
  continue;
427
557
  if (filter.card && card.id !== filter.card)
428
558
  continue;
@@ -539,19 +669,23 @@ export function listeners(now = Date.now()) {
539
669
  return out;
540
670
  }
541
671
  /**
542
- * The waits an answer to this card would wake right now. A card whose session has ended reaches
543
- * the session waits in its project, as an unowned card does. A snapshot's card says so itself
544
- * (`ownerEnded`); otherwise `ended` tells.
672
+ * The waits an answer to this card would wake right now. A card whose session has ended, or that
673
+ * no session opened, reaches the project's lead only. A snapshot's card says so itself
674
+ * (`ownerEnded`, `lead`, `unrouted`); otherwise `ended` and the claims tell.
545
675
  */
546
676
  export function listenersFor(card, ls, ended) {
547
677
  let gone = card.ownerEnded;
548
- const ownerGone = () => (gone ??= !!card.session && (ended ?? endedSessions(readState()))(card.session));
678
+ const isEnded = () => ended ?? endedSessions(readState());
679
+ const ownerGone = () => (gone ??= !!card.session && isEnded()(card.session));
680
+ // a snapshot's card names its lead, or says it has none
681
+ let lead = card.lead ?? (card.unrouted ? null : undefined);
682
+ const leadNow = () => (lead === undefined ? (lead = leadOf(readState(), isEnded())(card.project) ?? null) : lead) ?? undefined;
549
683
  return ls.filter((l) => l.card
550
684
  ? l.card === card.id
551
685
  : l.scope === "session"
552
686
  ? card.session && sameSession(l.session, card.session)
553
687
  ? true
554
- : (!card.session || ownerGone()) && !!l.project && l.project === card.project
688
+ : (!card.session || ownerGone()) && sameSession(l.session, leadNow())
555
689
  : !l.project || l.project === card.project);
556
690
  }
557
691
  export function consumerName(opts) {
@@ -574,13 +708,23 @@ export function furthest(a, b) {
574
708
  * that name. Called from an agent session without either, it reads that session's own cards and
575
709
  * pull requests, plus unowned ones in the project the session claimed, with a cursor of its own:
576
710
  * sessions in one project neither wake for each other's news nor take it from each other.
577
- * Outside a session, it reads everything.
711
+ * `--project` from a session names the project instead of the claim, with the same rule. Outside
712
+ * a session, it reads everything.
578
713
  */
579
714
  export function inboxScope(opts, session) {
580
- if (opts.card || opts.project || !session)
715
+ if (opts.card || !session)
581
716
  return { consumer: consumerName(opts), filter: { project: opts.project, card: opts.card } };
582
- const claim = readState().claims.find((c) => sameSession(session, c.session));
583
717
  const consumer = opts.as || sessionConsumer(session);
718
+ if (opts.project) {
719
+ // A lead waiting on its project from a session: what other live sessions own is theirs. It
720
+ // takes over the project cursor once, so it does not hear again what that wait delivered.
721
+ const mine = readCursor(consumer);
722
+ const merged = furthest(mine, readCursor(opts.project));
723
+ if (JSON.stringify(merged) !== JSON.stringify(mine))
724
+ writeCursor(consumer, merged);
725
+ return { consumer, filter: { session, project: opts.project } };
726
+ }
727
+ const claim = readState().claims.find((c) => sameSession(session, c.session));
584
728
  // A session that used to wait on its project starts where that wait stopped, so moving to its
585
729
  // own cursor does not bring back news it has already handled.
586
730
  if (claim && !Object.keys(readCursor(consumer)).length) {
@@ -1,7 +1,7 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { prViews, keyOf, parsePrRef, readCache, githubConfig, githubFailing } from "./github.js";
4
- import { endedSessions, linkPr, listeners, readState } from "./ops.js";
4
+ import { endedSessions, leadOf, linkPr, listeners, readState } from "./ops.js";
5
5
  import { collectRuns } from "./runs.js";
6
6
  import { deviceName, eventFiles, sharedDir } from "./store.js";
7
7
  import { SCHEMA_VERSION } from "./types.js";
@@ -18,8 +18,15 @@ export function snapshot(opts = {}) {
18
18
  state = readState();
19
19
  const all = [...state.cards.values()].filter((c) => !opts.project || c.project === opts.project);
20
20
  const ended = endedSessions(state);
21
+ const lead = leadOf(state, ended);
22
+ // what has no live owner goes to the project's lead; with none, it waits here
23
+ const route = (x) => {
24
+ const to = lead(x.project);
25
+ return to ? { ...x, lead: to } : { ...x, unrouted: true };
26
+ };
21
27
  const cards = [...all.filter((c) => c.status !== "closed"), ...recentlyClosed(all, opts.closed ?? 0)]
22
28
  .map((c) => (c.status !== "closed" && c.session && ended(c.session) ? { ...c, ownerEnded: true } : c))
29
+ .map((c) => (c.status !== "closed" && (!c.session || c.ownerEnded) ? route(c) : c))
23
30
  .map((c) => {
24
31
  const missing = notHere([...(c.attach ?? []), ...(c.open ?? [])]);
25
32
  return missing.length ? { ...c, missing } : c;
@@ -30,6 +37,9 @@ export function snapshot(opts = {}) {
30
37
  let prs = enabled ? prViews(cache, state, runs) : undefined;
31
38
  if (prs && opts.project)
32
39
  prs = prs.filter((p) => p.project === opts.project);
40
+ // a pull request linked to a project whose session has ended, or that has none
41
+ if (prs)
42
+ prs = prs.map((p) => (p.state === "open" && p.project && (!p.session || ended(p.session)) ? route(p) : p));
33
43
  return {
34
44
  v: SCHEMA_VERSION,
35
45
  generatedAt: new Date().toISOString(),
@@ -86,6 +86,14 @@ export function fold(events, map) {
86
86
  c.dismissed = true;
87
87
  break;
88
88
  }
89
+ case "card.hand": {
90
+ const c = s.cards.get(ev.card);
91
+ if (!c)
92
+ break;
93
+ c.session = ev.session;
94
+ c.handedAt = ev.ts;
95
+ break;
96
+ }
89
97
  case "run.start":
90
98
  s.runs.set(ev.run.id, { ...ev.run, startedAt: ev.ts, lastActivity: ev.ts, status: "running" });
91
99
  break;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "parawatch",
3
- "version": "0.3.11",
3
+ "version": "0.3.12",
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",
@@ -80,7 +80,9 @@ Right after opening cards or linking a pull request, keep exactly one wait armed
80
80
  - **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.
81
81
  - **Other runtimes**: check `parawatch inbox` at the start of every turn.
82
82
 
83
- 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).
83
+ Inside your session, `wait` and `inbox` read only what is yours: answers on cards you opened and news on pull requests linked to you. Other sessions in the same project are not woken by your news and cannot take it from you, and you are not woken by theirs. What no session owns, and what a session that has ended left open, goes to the project's lead (section 8); with no lead it waits in parawatch until the human picks one. `-p <project>` only names a project other than the one you claimed.
84
+
85
+ If the human hands you a card or a pull request (`parawatch hand`), your wait prints `[project] c4 "..." handed to you by the human; the answer: ...`. It is yours from then on: act on it and close it like your own.
84
86
 
85
87
  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.
86
88
 
@@ -120,12 +122,13 @@ When your work is finished, say so with one line on how it ended:
120
122
  parawatch claim --done "Merged #88; login works on staging"
121
123
  ```
122
124
 
123
- The session then shows as done, not as idle, and the line goes into the human's daily digest (`parawatch digest`). It stays done until someone writes to the session again.
125
+ The session then shows as done, not as idle, and the line goes into the human's daily digest (`parawatch digest`). It stays done until someone writes to the session again. Close the cards you handled first: cards still open and pull requests not yet merged pass to the project's lead, or wait in parawatch when there is none. News on your pull requests that are already merged or closed wakes no one else.
124
126
 
125
127
  ## 8. Leads and workers
126
128
 
127
- - A lead that watches the whole project waits with `parawatch wait -p <project>`.
128
- - 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.
129
+ - A lead claims with `--role lead` and waits like any session, with a plain `parawatch wait`. Besides its own news it gets what no session owns in its project and what sessions that have ended left open, and on claiming it is handed the answers that waited for a lead. One lead per project: a new lead replaces the old one. The human can also pick the lead in the panel, or with `parawatch lead <project> <session>`.
130
+ - The lead owns the cards for the work it hands out. 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.
131
+ - A session the human started on their own asks the human directly. Its cards and pull requests are its own: answers and news on them go to it, not to the lead. When that work belongs in the lead's queue, the session tells the lead, not the other way round.
129
132
  - Tell workers in their brief what to return to you, so the lead can turn it into one clear card.
130
133
  - Before ending a session, close cards you handled, end runs you started, and claim `--done`.
131
134