parawatch 0.3.16 → 0.3.17

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
@@ -46,7 +46,7 @@ parawatch sync setup ~/agent-sync/parawatch --device pc --adapter syncthing
46
46
 
47
47
  Cards, answers and the list of running sessions then show on every machine, each marked with where it came from. Agent logs stay on the machine that wrote them. The details are in [More than one machine](docs/sync.md).
48
48
 
49
- Or run a [relay](docs/relay.md) of your own, a small server that carries the folder sealed end to end, so the server cannot read your cards or sessions: `parawatch relay setup <url> <folder>` on the first machine, `parawatch relay join <code> <folder>` on the others.
49
+ Or run a [relay](docs/relay.md) of your own, a small server that carries the folder sealed end to end, so the server cannot read your cards or sessions: `parawatch relay setup <url> <folder>` on the first machine, `parawatch relay join <code> <folder>` on the others. With a relay, a phone can answer cards too: [On a phone](docs/phone.md).
50
50
 
51
51
  ## How an answer gets back
52
52
 
@@ -239,7 +239,17 @@ export function readClaudeTail(recs, kind) {
239
239
  // the human wrote (a pasted block, a command's output): whatever the last wrap-up asked is answered
240
240
  wrapUp = undefined;
241
241
  }
242
+ if (str(r.permissionMode))
243
+ out.permissionMode = str(r.permissionMode);
242
244
  if (r.type === "assistant") {
245
+ const msg = isObj(r.message) ? r.message : {};
246
+ const model = str(msg.model);
247
+ // a message Claude Code makes up itself (an interrupt, an error) names no real model
248
+ if (model && !model.startsWith("<")) {
249
+ out.model = model;
250
+ out.effort = str(r.effort);
251
+ out.fast = isObj(msg.usage) && msg.usage.speed === "fast" ? true : undefined;
252
+ }
243
253
  const w = blocks(r).find((b) => b.type === "tool_use" && WRAP_UP.test(String(b.name)));
244
254
  if (w)
245
255
  wrapUp = { ...(w.input ?? {}), at: ts };
@@ -427,6 +437,12 @@ export const claudeCode = {
427
437
  return runs;
428
438
  },
429
439
  };
440
+ const PERMISSION_MODES = { acceptEdits: "accepts edits", plan: "plan mode", bypassPermissions: "bypasses permissions", dontAsk: "never asks", auto: "auto mode" };
441
+ /** The model, effort and options a Claude Code run's last answer ran with. */
442
+ function claudeSettings(t) {
443
+ const options = [t.fast ? "fast" : undefined, t.permissionMode ? PERMISSION_MODES[t.permissionMode] : undefined].filter((x) => !!x);
444
+ return { model: t.model, effort: t.effort, options: options.length ? options : undefined };
445
+ }
430
446
  function readSession(file, now) {
431
447
  const id = path.basename(file, ".jsonl");
432
448
  const base = { id, runtime: "claude-code", kind: "session", title: id.slice(0, 8), state: "unknown", source: file };
@@ -451,6 +467,7 @@ function readSession(file, now) {
451
467
  prompt: head.prompt ? oneLine(head.prompt, 400) : undefined,
452
468
  activity: logActivity(file, now),
453
469
  pendingTool: tail.pendingTool,
470
+ ...claudeSettings(tail),
454
471
  };
455
472
  applyStall(run, tail.pendingTimeoutMs, now, newestMtime(tail.pendingOutputs));
456
473
  // in the log a permission prompt and a long command look the same; the hook can tell them apart
@@ -477,7 +494,8 @@ function readSubagent(file, now) {
477
494
  touched: work.touched.length ? work.touched : undefined,
478
495
  createdPrs: work.createdPrs.length ? work.createdPrs : undefined,
479
496
  title: str(meta.description) || (head.prompt ? oneLine(head.prompt, 60) : base.title),
480
- model: str(meta.model),
497
+ ...claudeSettings(tail),
498
+ model: tail.model ?? str(meta.model),
481
499
  cwd: tail.cwd || head.cwd,
482
500
  startedAt: head.startedAt,
483
501
  lastActivity: tail.lastActivity,
@@ -69,6 +69,22 @@ export function promptFromRaw(raw) {
69
69
  }
70
70
  return undefined;
71
71
  }
72
+ const SANDBOXES = { "read-only": "read-only", "workspace-write": "writes in its folders", "danger-full-access": "full access" };
73
+ const APPROVALS = { never: "never asks", "on-request": "asks when it wants to", "on-failure": "asks on failure", untrusted: "asks before most commands" };
74
+ /** The model, effort and options of the last turn, from Codex's `turn_context` lines. */
75
+ export function codexSettings(lines) {
76
+ let tc;
77
+ for (const r of lines)
78
+ if (isObj(r) && r.type === "turn_context" && isObj(r.payload))
79
+ tc = r.payload;
80
+ if (!tc)
81
+ return {};
82
+ const sp = tc.sandbox_policy;
83
+ const sandbox = typeof sp === "string" ? sp : isObj(sp) ? str(sp.type) : undefined;
84
+ const approval = str(tc.approval_policy);
85
+ const options = [sandbox ? (SANDBOXES[sandbox] ?? sandbox) : undefined, approval ? (APPROVALS[approval] ?? approval) : undefined].filter((x) => !!x);
86
+ return { model: str(tc.model), effort: str(tc.effort) ?? str(tc.reasoning_effort) ?? (isObj(tc.collaboration_mode) && isObj(tc.collaboration_mode.settings) ? str(tc.collaboration_mode.settings.reasoning_effort) : undefined), options: options.length ? options : undefined };
87
+ }
72
88
  export function readCodexLog(head, tail, rawHead) {
73
89
  const meta = head.map(payload).find((p, i) => p && isObj(head[i]) && head[i].type === "session_meta");
74
90
  const id = str(meta?.id) ?? str(meta?.session_id) ?? "unknown";
@@ -98,6 +114,8 @@ export function readCodexLog(head, tail, rawHead) {
98
114
  startedAt: str(meta?.timestamp) ?? (isObj(head[0]) ? str(head[0].timestamp) : undefined),
99
115
  state: "working",
100
116
  prompt: prompt ? oneLine(prompt, 400) : undefined,
117
+ // the last turn's settings: a later turn may change them (`codex exec resume -m ...`)
118
+ ...codexSettings([...head, ...tail]),
101
119
  };
102
120
  let lastStart = -1;
103
121
  let lastDone = -1;
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, 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";
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, sameSession, 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";
@@ -374,10 +374,17 @@ program
374
374
  id = callingSubagent(id, /\bparawatch\b.*\bclaim\b/) ?? id;
375
375
  if (o.kind !== undefined && o.kind !== "scheduled")
376
376
  throw new UserError("--kind can only be 'scheduled'");
377
+ // a lead that says done stops leading: say so, since it may only have finished one task
378
+ const before = o.done?.trim() ? readState() : undefined;
379
+ const ledBefore = before ? leadOf(before, endedSessions(before))(o.project?.trim() || before.claims.find((x) => sameSession(x.session, id))?.project) : undefined;
377
380
  const c = claimSession({ session: id, project: o.project, task: o.task, role: o.role, title: o.title, kind: o.kind, done: o.done });
378
381
  // a new lead: earlier leads of the project step down, and it takes what no session got
379
382
  const handed = o.role === "lead" && !c.done ? makeLead(c.project, id).handed : [];
380
383
  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`);
384
+ if (c.done && ledBefore && sameSession(ledBefore, id)) {
385
+ process.stdout.write(`This session no longer leads ${c.project}: what no session owns there now waits in parawatch until a session claims --role lead.\n` +
386
+ `If it goes on working, claim its next work instead of --done: parawatch claim -p ${c.project} -t <task> --role lead\n`);
387
+ }
381
388
  });
382
389
  // ------------------------------------------------------------------ pull requests
383
390
  const pr = program.command("pr").description("pull requests and what they wait on (reads GitHub through gh)");
@@ -1,3 +1,4 @@
1
+ import { settingsLine } from "./model.js";
1
2
  export function ago(iso, now = Date.now()) {
2
3
  if (!iso)
3
4
  return "?";
@@ -374,7 +375,7 @@ export function runsTree(runs, v = PLAIN, now = Date.now(), indent = "") {
374
375
  const age = paint(v, ago(r.lastActivity, now).padStart(4), "dim");
375
376
  const dev = devW ? " " + paint(v, pad(fit(r.device ?? "", devW), devW), "cyan") : "";
376
377
  const src = (r.projectSource && SOURCE_NOTE[r.projectSource]) || "";
377
- 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") : "";
378
+ const wide = v.wide ? " " + paint(v, pad(fit(`${r.runtime} ${r.id.slice(0, 8)}${settingsLine(r) ? " " + settingsLine(r) : ""}${src ? " " + src : ""}`, wideW - 2), wideW - 2), "dim") : "";
378
379
  const restW = v.width - nameW - 2 - 4 - devCol - wideW - 2;
379
380
  const whatPaint = r.error || r.state === "stalled" || r.state === "failed" || r.asks?.blocked ? ["red"] : r.asks ? ["yellow"] : ended ? ["dim"] : [];
380
381
  const what = restW >= 8 ? " " + paint(v, fit(whatText(r), restW), ...whatPaint) : "";
@@ -320,6 +320,11 @@ export function waitingOn(p) {
320
320
  return { what: "no reviewer", who: author };
321
321
  return { what: "merge", who: author };
322
322
  }
323
+ /** A reading of a PR from before anything happened to it: open, CI running, its reviews and comments as now. */
324
+ function unseen(p) {
325
+ // a PR already finished reports only that, not the CI it had
326
+ return { ...p, state: "open", ci: p.state === "open" ? "PENDING" : p.ci, mergeable: p.state === "open" ? undefined : p.mergeable, queue: p.queue };
327
+ }
323
328
  const REVIEW_CHANGE = { APPROVED: "approved", CHANGES_REQUESTED: "changes_requested", COMMENTED: "review", DISMISSED: "review" };
324
329
  /** The events between two readings of the same PR. */
325
330
  export function changesBetween(old, now) {
@@ -348,13 +353,18 @@ export function changesBetween(old, now) {
348
353
  out.push({ change: "closed" });
349
354
  return out;
350
355
  }
351
- /** Next cache and the events it implies, given the previous cache and a fresh reading. */
352
- export function nextCache(prev, fresh, hosts, at) {
356
+ /**
357
+ * Next cache and the events it implies, given the previous cache and a fresh reading. `newlyLinked`
358
+ * names PRs a session linked since the previous reading: one first seen with its CI already done,
359
+ * or already merged or closed, reports that, since no earlier reading can show it changing (a CI
360
+ * of a minute ends before the first reading).
361
+ */
362
+ export function nextCache(prev, fresh, hosts, at, newlyLinked = new Set()) {
353
363
  const old = new Map(prev.prs.map((p) => [p.key, p]));
354
364
  const events = [];
355
365
  const since = {};
356
366
  for (const p of fresh) {
357
- const was = old.get(p.key);
367
+ const was = old.get(p.key) ?? (newlyLinked.has(p.key) ? unseen(p) : undefined);
358
368
  if (was) {
359
369
  for (const c of changesBetween(was, p)) {
360
370
  events.push({ type: "pr.change", pr: prRef(p.host, p.repo, p.number, p.url), change: c.change, title: p.title, by: c.by, source: "github" });
@@ -460,10 +470,14 @@ export async function syncGithub(opts) {
460
470
  });
461
471
  }
462
472
  failedHere = 0;
463
- // PRs linked to sessions, and ones seen before, are read by number so merges and closes show
473
+ // PRs linked to sessions, and ones seen before, are read by number so merges and closes show.
474
+ // The newest links first, leaving out the ones already seen finished: the number read is capped.
464
475
  const extra = new Map();
465
- for (const l of opts.state.prLinks.values())
466
- extra.set(l.pr.key, l.pr);
476
+ const finished = new Set(prev.finished ?? []);
477
+ const links = [...opts.state.prLinks.values()].reverse().sort((a, b) => b.at.localeCompare(a.at));
478
+ for (const l of links)
479
+ if (!finished.has(l.pr.key))
480
+ extra.set(l.pr.key, l.pr);
467
481
  const cutoff = new Date(Date.now() - cfg.maxAgeDays * 86_400_000).toISOString();
468
482
  for (const p of prev.prs)
469
483
  if (p.state === "open" && p.updatedAt >= cutoff)
@@ -489,7 +503,11 @@ export async function syncGithub(opts) {
489
503
  const latest = readCache();
490
504
  if (latest.fetchedAt && latest.fetchedAt > started)
491
505
  return latest; // someone else was faster
492
- const { cache, events } = nextCache(latest, fresh, hosts, started);
506
+ // a first reading of everything reports nothing; after that, links made since the last reading are new
507
+ const newly = new Set(latest.fetchedAt ? links.filter((l) => l.at > latest.fetchedAt).map((l) => l.pr.key) : []);
508
+ const { cache, events } = nextCache(latest, fresh, hosts, started, newly);
509
+ const done = new Set([...(latest.finished ?? []), ...fresh.filter((p) => p.state !== "open").map((p) => p.key)]);
510
+ cache.finished = [...done].filter((k) => opts.state.prLinks.has(k));
493
511
  if (errors.length)
494
512
  cache.error = errors.join("; ");
495
513
  appendLocked((log) => dropReported(events, log, latest.fetchedAt));
@@ -0,0 +1,69 @@
1
+ /** Merge per-device logs, each already in its own order, into one. */
2
+ export function mergeLogs(lists) {
3
+ const at = lists.map(() => 0);
4
+ const out = [];
5
+ for (;;) {
6
+ let pick = -1;
7
+ for (let i = 0; i < lists.length; i++) {
8
+ const ev = lists[i][at[i]];
9
+ if (!ev)
10
+ continue;
11
+ if (pick < 0) {
12
+ pick = i;
13
+ continue;
14
+ }
15
+ const best = lists[pick][at[pick]];
16
+ if (ev.ts < best.ts || (ev.ts === best.ts && (ev.dev ?? "") < (best.dev ?? "")))
17
+ pick = i;
18
+ }
19
+ if (pick < 0)
20
+ return out;
21
+ out.push(lists[pick][at[pick]++]);
22
+ }
23
+ }
24
+ export function parseEventLines(text, device) {
25
+ const out = [];
26
+ for (const line of text.split("\n")) {
27
+ if (!line.trim())
28
+ continue;
29
+ try {
30
+ const ev = JSON.parse(line);
31
+ if (typeof ev.seq !== "number" || typeof ev.type !== "string")
32
+ continue;
33
+ if (device)
34
+ ev.dev = device;
35
+ out.push(ev);
36
+ }
37
+ catch {
38
+ // a torn or hand-edited line; keep going
39
+ }
40
+ }
41
+ return out;
42
+ }
43
+ /** The card an event is about, if any. */
44
+ function cardOf(e) {
45
+ if (e.type === "card.open")
46
+ return e.card.id;
47
+ const c = e.card;
48
+ return typeof c === "string" ? c : undefined;
49
+ }
50
+ /**
51
+ * When a writer may stamp its next event: now, but never before its own last event (each log
52
+ * stays in order) and, for an event about a card, after everything already said about that card.
53
+ * Logs merge by time and then by machine name, so an answer stamped in the same millisecond as the
54
+ * card on another machine, or by a clock running behind, would sort before the card and be lost.
55
+ */
56
+ export function stampFor(events, own, card, now) {
57
+ let t = now;
58
+ for (const e of events) {
59
+ const at = Date.parse(e.ts);
60
+ if (!Number.isFinite(at))
61
+ continue;
62
+ if (own(e) && at > t)
63
+ t = at;
64
+ if (typeof card === "string" && cardOf(e) === card && at >= t)
65
+ t = at + 1;
66
+ }
67
+ return t;
68
+ }
69
+ //# sourceMappingURL=log.js.map
@@ -0,0 +1,18 @@
1
+ const CLAUDE = /^claude-([a-z]+)-(\d+)-(\d+)(?:-\d{8})?(\[1m\])?$/;
2
+ export function shortModel(model) {
3
+ const m = CLAUDE.exec(model);
4
+ if (m)
5
+ return `${m[1]} ${m[2]}.${m[3]}${m[4] ? " 1M" : ""}`;
6
+ return model.replace(/-\d{8}$/, "");
7
+ }
8
+ /** The model and effort in a few words, `opus 5.5 high`; fast mode too, as it changes the speed. */
9
+ export function settingsLine(r) {
10
+ const parts = [r.model ? shortModel(r.model) : undefined, r.effort, r.options?.includes("fast") ? "fast" : undefined].filter(Boolean);
11
+ return parts.length ? parts.join(" ") : undefined;
12
+ }
13
+ /** Everything it runs with, for a tooltip or a detail line: `claude-opus-5-5 · effort high · bypasses permissions`. */
14
+ export function settingsFull(r) {
15
+ const parts = [r.model, r.effort ? `effort ${r.effort}` : undefined, ...(r.options ?? [])].filter(Boolean);
16
+ return parts.length ? parts.join(" · ") : undefined;
17
+ }
18
+ //# sourceMappingURL=model.js.map
@@ -2,7 +2,9 @@ import fs from "node:fs";
2
2
  import os from "node:os";
3
3
  import path from "node:path";
4
4
  import { UserError } from "./errors.js";
5
+ import { mergeLogs, parseEventLines, stampFor } from "./log.js";
5
6
  import { STORE_FORMAT } from "./types.js";
7
+ export { mergeLogs, parseEventLines };
6
8
  /** Directory holding all parawatch state for this user on this machine. */
7
9
  export function homeDir() {
8
10
  const explicit = process.env.PARAWATCH_HOME;
@@ -188,48 +190,6 @@ export function readEvents() {
188
190
  return lists[0] ?? [];
189
191
  return mergeLogs(lists);
190
192
  }
191
- /** Merge per-device logs, each already in its own order, into one. */
192
- export function mergeLogs(lists) {
193
- const at = lists.map(() => 0);
194
- const out = [];
195
- for (;;) {
196
- let pick = -1;
197
- for (let i = 0; i < lists.length; i++) {
198
- const ev = lists[i][at[i]];
199
- if (!ev)
200
- continue;
201
- if (pick < 0) {
202
- pick = i;
203
- continue;
204
- }
205
- const best = lists[pick][at[pick]];
206
- if (ev.ts < best.ts || (ev.ts === best.ts && (ev.dev ?? "") < (best.dev ?? "")))
207
- pick = i;
208
- }
209
- if (pick < 0)
210
- return out;
211
- out.push(lists[pick][at[pick]++]);
212
- }
213
- }
214
- export function parseEventLines(text, device) {
215
- const out = [];
216
- for (const line of text.split("\n")) {
217
- if (!line.trim())
218
- continue;
219
- try {
220
- const ev = JSON.parse(line);
221
- if (typeof ev.seq !== "number" || typeof ev.type !== "string")
222
- continue;
223
- if (device)
224
- ev.dev = device;
225
- out.push(ev);
226
- }
227
- catch {
228
- // a torn or hand-edited line; keep going
229
- }
230
- }
231
- return out;
232
- }
233
193
  // ------------------------------------------------------------------ writing
234
194
  /** Append one event. `build` sees the current events so it can pick ids under the lock. */
235
195
  export function appendEvent(build) {
@@ -263,9 +223,13 @@ export function appendLocked(build) {
263
223
  for (const e of own)
264
224
  if (e.seq > seq)
265
225
  seq = e.seq;
266
- const ts = new Date().toISOString();
226
+ const mine = new Set(own);
227
+ let last = 0;
267
228
  const v = writeFormat();
268
- const out = build(events).map((body) => ({ v, seq: ++seq, ts, ...body }));
229
+ const out = build(events).map((body) => {
230
+ last = stampFor(events, (e) => mine.has(e), body.card, Math.max(Date.now(), last));
231
+ return { v, seq: ++seq, ts: new Date(last).toISOString(), ...body };
232
+ });
269
233
  if (out.length) {
270
234
  ensureDir(path.dirname(eventsPath()));
271
235
  fs.appendFileSync(eventsPath(), out.map((e) => JSON.stringify(e) + "\n").join(""));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "parawatch",
3
- "version": "0.3.16",
3
+ "version": "0.3.17",
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",
@@ -39,7 +39,7 @@
39
39
  "build": "tsc -p tsconfig.json",
40
40
  "dev": "tsx src/bin/cli.ts",
41
41
  "test": "vitest run",
42
- "typecheck": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.test.json --noEmit && npm run typecheck -w @parawatch/panel -w @parawatch/desktop",
42
+ "typecheck": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.test.json --noEmit && npm run typecheck -w @parawatch/panel -w @parawatch/desktop -w @parawatch/web",
43
43
  "prepublishOnly": "npm run build && npm test",
44
44
  "demo": "tsx scripts/demo.ts"
45
45
  },
@@ -130,6 +130,7 @@ The session then shows as done, not as idle, and the line goes into the human's
130
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
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.
132
132
  - Tell workers in their brief what to return to you, so the lead can turn it into one clear card.
133
+ - **A lead that finishes one task claims its next work, not `--done`** (`parawatch claim -p <project> -t <next> --role lead`). `--done` ends the session as lead too: from then on what no session owns in the project goes nowhere until a session claims the lead. When you do a worker's task yourself, its brief's rules for workers (such as "do not use parawatch") are not yours.
133
134
  - Before ending a session, close cards you handled, end runs you started, and claim `--done`.
134
135
 
135
136
  ## On more than one machine