parawatch 0.3.10 → 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/adapters/claudeCode.js +48 -0
- package/dist/bin/cli.js +57 -11
- package/dist/core/format.js +15 -1
- package/dist/core/integrate.js +1 -1
- package/dist/core/ops.js +174 -17
- package/dist/core/snapshot.js +23 -2
- package/dist/core/state.js +8 -0
- package/dist/core/store.js +24 -8
- package/package.json +1 -1
- package/skills/parawatch/SKILL.md +7 -4
|
@@ -508,4 +508,52 @@ function metaIsCurrent(file) {
|
|
|
508
508
|
return false;
|
|
509
509
|
}
|
|
510
510
|
}
|
|
511
|
+
/**
|
|
512
|
+
* Which run of a Claude Code session is running a command right now: the session itself, or one of
|
|
513
|
+
* its subagents. A subagent's commands get the session's own `CLAUDE_CODE_SESSION_ID`, so only the
|
|
514
|
+
* logs tell them apart: the run whose log ends on a pending Bash call matching `command`, the
|
|
515
|
+
* newest one if several do. Returns the subagent's id, or undefined for the session itself (or
|
|
516
|
+
* when the logs do not say).
|
|
517
|
+
*/
|
|
518
|
+
export function callingSubagent(session, command, now = Date.now()) {
|
|
519
|
+
const root = claudeProjectsDir();
|
|
520
|
+
let dirs;
|
|
521
|
+
try {
|
|
522
|
+
dirs = fs.readdirSync(root);
|
|
523
|
+
}
|
|
524
|
+
catch {
|
|
525
|
+
return undefined;
|
|
526
|
+
}
|
|
527
|
+
const pendingAt = (file) => {
|
|
528
|
+
try {
|
|
529
|
+
if (now - fs.statSync(file).mtimeMs > 120_000)
|
|
530
|
+
return undefined;
|
|
531
|
+
const recs = readTailLines(file, 64 * 1024).filter(isObj);
|
|
532
|
+
const last = recs[recs.length - 1];
|
|
533
|
+
if (!last || last.type !== "assistant")
|
|
534
|
+
return undefined;
|
|
535
|
+
const call = blocks(last).find((b) => b.type === "tool_use" && b.name === "Bash" && command.test(str(b.input?.command) ?? ""));
|
|
536
|
+
return call ? (str(last.timestamp) ?? new Date(fs.statSync(file).mtimeMs).toISOString()) : undefined;
|
|
537
|
+
}
|
|
538
|
+
catch {
|
|
539
|
+
return undefined;
|
|
540
|
+
}
|
|
541
|
+
};
|
|
542
|
+
for (const d of dirs) {
|
|
543
|
+
const own = path.join(root, d, `${session}.jsonl`);
|
|
544
|
+
if (!fs.existsSync(own))
|
|
545
|
+
continue;
|
|
546
|
+
let best;
|
|
547
|
+
const mine = pendingAt(own);
|
|
548
|
+
if (mine)
|
|
549
|
+
best = { at: mine };
|
|
550
|
+
for (const f of listJsonl(path.join(root, d, session, "subagents"))) {
|
|
551
|
+
const at = pendingAt(f);
|
|
552
|
+
if (at && (!best || at > best.at))
|
|
553
|
+
best = { id: path.basename(f, ".jsonl").replace(/^agent-/, ""), at };
|
|
554
|
+
}
|
|
555
|
+
return best?.id;
|
|
556
|
+
}
|
|
557
|
+
return undefined;
|
|
558
|
+
}
|
|
511
559
|
//# sourceMappingURL=claudeCode.js.map
|
package/dist/bin/cli.js
CHANGED
|
@@ -2,9 +2,10 @@
|
|
|
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
|
+
import { callingSubagent } from "../adapters/claudeCode.js";
|
|
8
9
|
import { digest, digestText, localDate } from "../core/digest.js";
|
|
9
10
|
import { everywhere, readEvents } from "../core/store.js";
|
|
10
11
|
import { followEvents } from "../core/follow.js";
|
|
@@ -189,6 +190,26 @@ program
|
|
|
189
190
|
.description("close a card that no longer matters, without answering it; the agent that asked is told")
|
|
190
191
|
.option("-n, --note <text>", "why, for the agent")
|
|
191
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
|
+
});
|
|
192
213
|
program
|
|
193
214
|
.command("show <card>")
|
|
194
215
|
.description("show one card with its answers")
|
|
@@ -202,11 +223,16 @@ program
|
|
|
202
223
|
return;
|
|
203
224
|
}
|
|
204
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);
|
|
205
229
|
const delivery = card.status === "closed"
|
|
206
230
|
? ""
|
|
207
|
-
:
|
|
208
|
-
? `\nDelivery:
|
|
209
|
-
:
|
|
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).";
|
|
210
236
|
process.stdout.write(cardDetail(card) + delivery + "\n");
|
|
211
237
|
});
|
|
212
238
|
program
|
|
@@ -225,9 +251,9 @@ program
|
|
|
225
251
|
// ------------------------------------------------------------------ inbox
|
|
226
252
|
function inboxOptions(c) {
|
|
227
253
|
return c
|
|
228
|
-
.option("-p, --project <name>", "
|
|
254
|
+
.option("-p, --project <name>", "this project instead of the claimed one; outside a session, everything in it")
|
|
229
255
|
.option("--card <id>", "only answers to this card")
|
|
230
|
-
.option("--as <consumer>", "cursor name (defaults to the session,
|
|
256
|
+
.option("--as <consumer>", "cursor name (defaults to the session; outside a session, the project with -p)")
|
|
231
257
|
.option("--json", "JSON output");
|
|
232
258
|
}
|
|
233
259
|
inboxOptions(program
|
|
@@ -331,13 +357,18 @@ program
|
|
|
331
357
|
.option("--kind <kind>", "'scheduled' for a session a schedule starts again and again")
|
|
332
358
|
.option("--done <summary>", "the work is finished; one line on how it ended")
|
|
333
359
|
.action((session, o) => {
|
|
334
|
-
|
|
360
|
+
let id = session ?? currentSession();
|
|
335
361
|
if (!id)
|
|
336
362
|
throw new UserError("no session id given and none found in the environment (CLAUDE_CODE_SESSION_ID, CODEX_THREAD_ID)");
|
|
363
|
+
// a subagent runs with its session's id; its claim is its own, not a new name for the session
|
|
364
|
+
if (!session && process.env.CLAUDE_CODE_SESSION_ID === id)
|
|
365
|
+
id = callingSubagent(id, /\bparawatch\b.*\bclaim\b/) ?? id;
|
|
337
366
|
if (o.kind !== undefined && o.kind !== "scheduled")
|
|
338
367
|
throw new UserError("--kind can only be 'scheduled'");
|
|
339
368
|
const c = claimSession({ session: id, project: o.project, task: o.task, role: o.role, title: o.title, kind: o.kind, done: o.done });
|
|
340
|
-
|
|
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`);
|
|
341
372
|
});
|
|
342
373
|
// ------------------------------------------------------------------ pull requests
|
|
343
374
|
const pr = program.command("pr").description("pull requests and what they wait on (reads GitHub through gh)");
|
|
@@ -432,16 +463,23 @@ program
|
|
|
432
463
|
const head = (title, n, ...extra) => v.color ? `\x1b[1m${title}\x1b[0m \x1b[2m${n}${extra.join("")}\x1b[0m` : `${title} ${n}${extra.join("")}`;
|
|
433
464
|
const open = cards.filter((c) => c.status === "open" && c.kind !== "todo");
|
|
434
465
|
const todo = cards.filter((c) => c.status === "open" && c.kind === "todo");
|
|
435
|
-
|
|
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);
|
|
436
470
|
// a session held by a permission prompt needs the human as much as a card does
|
|
437
471
|
const prompts = runs.filter((r) => r.asks?.via === "permission");
|
|
438
472
|
const askers = runs.filter((r) => r.asks && r.asks.via !== "permission");
|
|
439
473
|
const reviews = prs.filter((p) => p.reviewRequested && !p.mine);
|
|
440
|
-
const waiting = prs.filter((p) => !(p.reviewRequested && !p.mine));
|
|
474
|
+
const waiting = prs.filter((p) => !(p.reviewRequested && !p.mine) && !p.unrouted);
|
|
441
475
|
const ls = snap.listeners ?? [];
|
|
442
476
|
const out = [];
|
|
443
477
|
out.push(head("NEEDS YOU", open.length + prompts.length || "nothing"));
|
|
444
|
-
|
|
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));
|
|
445
483
|
out.push(...askRows(prompts, v, now, " "));
|
|
446
484
|
if (askers.length) {
|
|
447
485
|
out.push("", head("ASKED IN CHAT, NO CARD", askers.length, " (read from the log)"));
|
|
@@ -455,6 +493,14 @@ program
|
|
|
455
493
|
out.push("", head("TO DO, NOTHING WAITS ON IT", todo.length));
|
|
456
494
|
out.push(...cardRows(todo, v, now, " "));
|
|
457
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
|
+
}
|
|
458
504
|
if (answered.length) {
|
|
459
505
|
const notes = {};
|
|
460
506
|
// the snapshot already marked the cards whose session has ended
|
package/dist/core/format.js
CHANGED
|
@@ -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")
|
package/dist/core/integrate.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
543
|
-
*
|
|
544
|
-
* (`ownerEnded`); otherwise `ended`
|
|
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
|
|
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()) &&
|
|
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
|
-
*
|
|
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 ||
|
|
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) {
|
|
@@ -610,13 +754,26 @@ export async function waitInbox(consumer, filter, timeoutMs, pollMs = 1000) {
|
|
|
610
754
|
const dir = eventsDir();
|
|
611
755
|
fs.mkdirSync(dir, { recursive: true });
|
|
612
756
|
watcher = fs.watch(dir, () => wake?.());
|
|
757
|
+
// on Windows a watched folder a sync tool writes into can report EPERM; polling goes on
|
|
758
|
+
watcher.on("error", () => {
|
|
759
|
+
watcher?.close();
|
|
760
|
+
watcher = undefined;
|
|
761
|
+
});
|
|
613
762
|
}
|
|
614
763
|
catch {
|
|
615
764
|
watcher = undefined; // polling alone still works
|
|
616
765
|
}
|
|
617
766
|
try {
|
|
618
767
|
for (;;) {
|
|
619
|
-
|
|
768
|
+
let items = [];
|
|
769
|
+
try {
|
|
770
|
+
items = readInbox(consumer, filter, true);
|
|
771
|
+
}
|
|
772
|
+
catch (err) {
|
|
773
|
+
// a file busy for a moment is read again on the next poll; a wait must not end without an answer
|
|
774
|
+
if (!["EPERM", "EBUSY", "EACCES"].includes(err.code ?? ""))
|
|
775
|
+
throw err;
|
|
776
|
+
}
|
|
620
777
|
if (items.length)
|
|
621
778
|
return items;
|
|
622
779
|
const left = deadline - Date.now();
|
package/dist/core/snapshot.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
1
3
|
import { prViews, keyOf, parsePrRef, readCache, githubConfig, githubFailing } from "./github.js";
|
|
2
|
-
import { endedSessions, linkPr, listeners, readState } from "./ops.js";
|
|
4
|
+
import { endedSessions, leadOf, linkPr, listeners, readState } from "./ops.js";
|
|
3
5
|
import { collectRuns } from "./runs.js";
|
|
4
6
|
import { deviceName, eventFiles, sharedDir } from "./store.js";
|
|
5
7
|
import { SCHEMA_VERSION } from "./types.js";
|
|
@@ -16,13 +18,28 @@ export function snapshot(opts = {}) {
|
|
|
16
18
|
state = readState();
|
|
17
19
|
const all = [...state.cards.values()].filter((c) => !opts.project || c.project === opts.project);
|
|
18
20
|
const ended = endedSessions(state);
|
|
19
|
-
const
|
|
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
|
+
};
|
|
27
|
+
const cards = [...all.filter((c) => c.status !== "closed"), ...recentlyClosed(all, opts.closed ?? 0)]
|
|
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))
|
|
30
|
+
.map((c) => {
|
|
31
|
+
const missing = notHere([...(c.attach ?? []), ...(c.open ?? [])]);
|
|
32
|
+
return missing.length ? { ...c, missing } : c;
|
|
33
|
+
});
|
|
20
34
|
quietAnsweredAsks(runs, all);
|
|
21
35
|
const cache = readCache();
|
|
22
36
|
const enabled = githubConfig().enabled && (cache.hosts === undefined ? false : cache.hosts.length > 0);
|
|
23
37
|
let prs = enabled ? prViews(cache, state, runs) : undefined;
|
|
24
38
|
if (prs && opts.project)
|
|
25
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));
|
|
26
43
|
return {
|
|
27
44
|
v: SCHEMA_VERSION,
|
|
28
45
|
generatedAt: new Date().toISOString(),
|
|
@@ -63,6 +80,10 @@ function linkCreatedPrs(runs, state) {
|
|
|
63
80
|
}
|
|
64
81
|
return added;
|
|
65
82
|
}
|
|
83
|
+
/** Files named by absolute path (on either system) that this machine does not have. */
|
|
84
|
+
function notHere(targets) {
|
|
85
|
+
return targets.filter((t) => (path.isAbsolute(t) || /^[A-Za-z]:[\\/]/.test(t)) && !/^[a-z][a-z0-9+.-]*:\/\//i.test(t) && !fs.existsSync(t));
|
|
86
|
+
}
|
|
66
87
|
/** A question asked in chat is no longer pending once the session put it on a card. */
|
|
67
88
|
function quietAnsweredAsks(runs, cards) {
|
|
68
89
|
for (const r of runs) {
|
package/dist/core/state.js
CHANGED
|
@@ -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/dist/core/store.js
CHANGED
|
@@ -150,17 +150,33 @@ export function withLock(fn) {
|
|
|
150
150
|
}
|
|
151
151
|
}
|
|
152
152
|
// ------------------------------------------------------------------ reading
|
|
153
|
+
/** What each log held at its last good reading, for a moment when it cannot be opened. */
|
|
154
|
+
const lastRead = new Map();
|
|
155
|
+
/** Errors a sync tool causes for a moment while it replaces a file (Windows mostly). */
|
|
156
|
+
const BUSY = new Set(["EPERM", "EBUSY", "EACCES"]);
|
|
153
157
|
function readFileEvents(f) {
|
|
154
158
|
let text;
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
159
|
+
for (let attempt = 0; text === undefined; attempt++) {
|
|
160
|
+
try {
|
|
161
|
+
text = fs.readFileSync(f.file, "utf8");
|
|
162
|
+
}
|
|
163
|
+
catch (err) {
|
|
164
|
+
const code = err.code ?? "";
|
|
165
|
+
if (code === "ENOENT")
|
|
166
|
+
return [];
|
|
167
|
+
if (!BUSY.has(code))
|
|
168
|
+
throw err;
|
|
169
|
+
// Syncthing and cloud drives write a new copy and rename it over the old one; on Windows the
|
|
170
|
+
// file cannot be opened during the swap. Try again shortly, then go on with the last reading.
|
|
171
|
+
if (attempt < 5)
|
|
172
|
+
sleepSync(50);
|
|
173
|
+
else
|
|
174
|
+
return lastRead.get(f.file) ?? [];
|
|
175
|
+
}
|
|
162
176
|
}
|
|
163
|
-
|
|
177
|
+
const events = parseEventLines(text, f.device);
|
|
178
|
+
lastRead.set(f.file, events);
|
|
179
|
+
return events;
|
|
164
180
|
}
|
|
165
181
|
/**
|
|
166
182
|
* Every event, from every machine, in time order. Each machine's own order is kept as written;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "parawatch",
|
|
3
|
-
"version": "0.3.
|
|
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
|
|
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
|
|
128
|
-
- The lead owns the cards for
|
|
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
|
|