parawatch 0.3.10 → 0.3.11

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.
@@ -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
@@ -5,6 +5,7 @@ import { Command } from "commander";
5
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";
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";
@@ -331,9 +332,12 @@ program
331
332
  .option("--kind <kind>", "'scheduled' for a session a schedule starts again and again")
332
333
  .option("--done <summary>", "the work is finished; one line on how it ended")
333
334
  .action((session, o) => {
334
- const id = session ?? currentSession();
335
+ let id = session ?? currentSession();
335
336
  if (!id)
336
337
  throw new UserError("no session id given and none found in the environment (CLAUDE_CODE_SESSION_ID, CODEX_THREAD_ID)");
338
+ // a subagent runs with its session's id; its claim is its own, not a new name for the session
339
+ if (!session && process.env.CLAUDE_CODE_SESSION_ID === id)
340
+ id = callingSubagent(id, /\bparawatch\b.*\bclaim\b/) ?? id;
337
341
  if (o.kind !== undefined && o.kind !== "scheduled")
338
342
  throw new UserError("--kind can only be 'scheduled'");
339
343
  const c = claimSession({ session: id, project: o.project, task: o.task, role: o.role, title: o.title, kind: o.kind, done: o.done });
package/dist/core/ops.js CHANGED
@@ -610,13 +610,26 @@ export async function waitInbox(consumer, filter, timeoutMs, pollMs = 1000) {
610
610
  const dir = eventsDir();
611
611
  fs.mkdirSync(dir, { recursive: true });
612
612
  watcher = fs.watch(dir, () => wake?.());
613
+ // on Windows a watched folder a sync tool writes into can report EPERM; polling goes on
614
+ watcher.on("error", () => {
615
+ watcher?.close();
616
+ watcher = undefined;
617
+ });
613
618
  }
614
619
  catch {
615
620
  watcher = undefined; // polling alone still works
616
621
  }
617
622
  try {
618
623
  for (;;) {
619
- const items = readInbox(consumer, filter, true);
624
+ let items = [];
625
+ try {
626
+ items = readInbox(consumer, filter, true);
627
+ }
628
+ catch (err) {
629
+ // a file busy for a moment is read again on the next poll; a wait must not end without an answer
630
+ if (!["EPERM", "EBUSY", "EACCES"].includes(err.code ?? ""))
631
+ throw err;
632
+ }
620
633
  if (items.length)
621
634
  return items;
622
635
  const left = deadline - Date.now();
@@ -1,3 +1,5 @@
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
4
  import { endedSessions, linkPr, listeners, readState } from "./ops.js";
3
5
  import { collectRuns } from "./runs.js";
@@ -16,7 +18,12 @@ 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 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);
21
+ const cards = [...all.filter((c) => c.status !== "closed"), ...recentlyClosed(all, opts.closed ?? 0)]
22
+ .map((c) => (c.status !== "closed" && c.session && ended(c.session) ? { ...c, ownerEnded: true } : c))
23
+ .map((c) => {
24
+ const missing = notHere([...(c.attach ?? []), ...(c.open ?? [])]);
25
+ return missing.length ? { ...c, missing } : c;
26
+ });
20
27
  quietAnsweredAsks(runs, all);
21
28
  const cache = readCache();
22
29
  const enabled = githubConfig().enabled && (cache.hosts === undefined ? false : cache.hosts.length > 0);
@@ -63,6 +70,10 @@ function linkCreatedPrs(runs, state) {
63
70
  }
64
71
  return added;
65
72
  }
73
+ /** Files named by absolute path (on either system) that this machine does not have. */
74
+ function notHere(targets) {
75
+ return targets.filter((t) => (path.isAbsolute(t) || /^[A-Za-z]:[\\/]/.test(t)) && !/^[a-z][a-z0-9+.-]*:\/\//i.test(t) && !fs.existsSync(t));
76
+ }
66
77
  /** A question asked in chat is no longer pending once the session put it on a card. */
67
78
  function quietAnsweredAsks(runs, cards) {
68
79
  for (const r of runs) {
@@ -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
- try {
156
- text = fs.readFileSync(f.file, "utf8");
157
- }
158
- catch (err) {
159
- if (err.code === "ENOENT")
160
- return [];
161
- throw err;
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
- return parseEventLines(text, f.device);
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.10",
3
+ "version": "0.3.11",
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",