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 +1 -1
- package/dist/adapters/claudeCode.js +19 -1
- package/dist/adapters/codex.js +18 -0
- package/dist/bin/cli.js +8 -1
- package/dist/core/format.js +2 -1
- package/dist/core/github.js +25 -7
- package/dist/core/log.js +69 -0
- package/dist/core/model.js +18 -0
- package/dist/core/store.js +8 -44
- package/package.json +2 -2
- package/skills/parawatch/SKILL.md +1 -0
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
|
-
|
|
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,
|
package/dist/adapters/codex.js
CHANGED
|
@@ -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)");
|
package/dist/core/format.js
CHANGED
|
@@ -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.
|
|
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) : "";
|
package/dist/core/github.js
CHANGED
|
@@ -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
|
-
/**
|
|
352
|
-
|
|
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
|
-
|
|
466
|
-
|
|
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
|
-
|
|
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));
|
package/dist/core/log.js
ADDED
|
@@ -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
|
package/dist/core/store.js
CHANGED
|
@@ -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
|
|
226
|
+
const mine = new Set(own);
|
|
227
|
+
let last = 0;
|
|
267
228
|
const v = writeFormat();
|
|
268
|
-
const out = build(events).map((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.
|
|
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
|