parawatch 0.3.16 → 0.3.18

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,
@@ -2,7 +2,7 @@ import fs from "node:fs";
2
2
  import os from "node:os";
3
3
  import path from "node:path";
4
4
  import { applyStall } from "./claudeCode.js";
5
- import { isObj, logActivity, oneLine, readHeadLines, readHeadText, readTailLines, str, textValues } from "./jsonl.js";
5
+ import { isObj, lastLineWith, logActivity, oneLine, readHeadLines, readHeadText, readTailLines, str, textValues } from "./jsonl.js";
6
6
  const HEAD_BYTES = 128 * 1024;
7
7
  const TAIL_BYTES = 256 * 1024;
8
8
  // prompts can sit behind several inline base64 images on one line; read this far only when needed
@@ -69,6 +69,35 @@ 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
+ }
88
+ /** What the last turn ran with, per log: a long log is searched once, then only where it grew. */
89
+ const middle = new Map();
90
+ /** The settings of a log whose last `turn_context` sits between its head and tail. */
91
+ export function settingsInMiddle(file) {
92
+ const size = fs.statSync(file).size;
93
+ const known = middle.get(file);
94
+ if (known && known.size === size)
95
+ return known.settings;
96
+ const found = lastLineWith(file, '"type":"turn_context"', known && known.size < size ? Math.max(0, known.size - 64) : 0);
97
+ const settings = found ? codexSettings([found]) : (known?.settings ?? {});
98
+ middle.set(file, { size, settings });
99
+ return settings;
100
+ }
72
101
  export function readCodexLog(head, tail, rawHead) {
73
102
  const meta = head.map(payload).find((p, i) => p && isObj(head[i]) && head[i].type === "session_meta");
74
103
  const id = str(meta?.id) ?? str(meta?.session_id) ?? "unknown";
@@ -98,6 +127,8 @@ export function readCodexLog(head, tail, rawHead) {
98
127
  startedAt: str(meta?.timestamp) ?? (isObj(head[0]) ? str(head[0].timestamp) : undefined),
99
128
  state: "working",
100
129
  prompt: prompt ? oneLine(prompt, 400) : undefined,
130
+ // the last turn's settings: a later turn may change them (`codex exec resume -m ...`)
131
+ ...codexSettings([...head, ...tail]),
101
132
  };
102
133
  let lastStart = -1;
103
134
  let lastDone = -1;
@@ -170,6 +201,8 @@ export const codex = {
170
201
  if (fs.statSync(file).mtimeMs < opts.sinceMs)
171
202
  continue;
172
203
  const run = { ...readCodexLog(readHeadLines(file, HEAD_BYTES), readTailLines(file, TAIL_BYTES), () => readHeadText(file, RAW_HEAD_BYTES)), source: file };
204
+ if (!run.model)
205
+ Object.assign(run, settingsInMiddle(file));
173
206
  run.activity = logActivity(file, opts.now);
174
207
  applyStall(run, undefined, opts.now);
175
208
  runs.push(run);
@@ -141,4 +141,70 @@ export function logActivity(file, now) {
141
141
  seen.set(file, entry);
142
142
  return activeMinutes(entry.times, now);
143
143
  }
144
+ /**
145
+ * The last line holding `marker` in the part of a file after byte `from`, parsed, searching back
146
+ * from the end a megabyte at a time. For a record written once a turn that can sit anywhere in a
147
+ * log of many megabytes, between what the head and tail readings cover.
148
+ */
149
+ export function lastLineWith(file, marker, from = 0, maxLine = 4 * 1024 * 1024) {
150
+ const size = fs.statSync(file).size;
151
+ const needle = Buffer.from(marker);
152
+ const CHUNK = 1024 * 1024;
153
+ const fd = fs.openSync(file, "r");
154
+ try {
155
+ let end = size;
156
+ while (end > from) {
157
+ const start = Math.max(from, end - CHUNK);
158
+ // overlap by the marker's length, so one cut in two is still found
159
+ const len = Math.min(size, end + needle.length) - start;
160
+ const buf = Buffer.alloc(len);
161
+ const n = fs.readSync(fd, buf, 0, len, start);
162
+ const at = buf.subarray(0, n).lastIndexOf(needle);
163
+ if (at >= 0)
164
+ return lineAt(fd, size, start + at, maxLine);
165
+ end = start;
166
+ }
167
+ return undefined;
168
+ }
169
+ finally {
170
+ fs.closeSync(fd);
171
+ }
172
+ }
173
+ /** The whole line around a byte position, parsed; undefined when it is too long or not JSON. */
174
+ function lineAt(fd, size, pos, maxLine) {
175
+ const STEP = 64 * 1024;
176
+ let lineStart = pos;
177
+ while (lineStart > 0 && pos - lineStart < maxLine) {
178
+ const from = Math.max(0, lineStart - STEP);
179
+ const buf = Buffer.alloc(lineStart - from);
180
+ fs.readSync(fd, buf, 0, buf.length, from);
181
+ const nl = buf.lastIndexOf(10);
182
+ if (nl >= 0) {
183
+ lineStart = from + nl + 1;
184
+ break;
185
+ }
186
+ lineStart = from;
187
+ }
188
+ let lineEnd = pos;
189
+ while (lineEnd < size && lineEnd - lineStart < maxLine) {
190
+ const buf = Buffer.alloc(Math.min(STEP, size - lineEnd));
191
+ fs.readSync(fd, buf, 0, buf.length, lineEnd);
192
+ const nl = buf.indexOf(10);
193
+ if (nl >= 0) {
194
+ lineEnd += nl;
195
+ break;
196
+ }
197
+ lineEnd += buf.length;
198
+ }
199
+ if (lineEnd - lineStart >= maxLine)
200
+ return undefined;
201
+ const buf = Buffer.alloc(lineEnd - lineStart);
202
+ fs.readSync(fd, buf, 0, buf.length, lineStart);
203
+ try {
204
+ return JSON.parse(buf.toString("utf8"));
205
+ }
206
+ catch {
207
+ return undefined;
208
+ }
209
+ }
144
210
  //# sourceMappingURL=jsonl.js.map
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.18",
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