parawatch 0.0.0-stage → 0.2.0
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/LICENSE +21 -0
- package/README.md +261 -2
- package/dist/adapters/claudeCode.js +331 -0
- package/dist/adapters/codex.js +185 -0
- package/dist/adapters/index.js +18 -0
- package/dist/adapters/jsonl.js +144 -0
- package/dist/bin/cli.js +491 -0
- package/dist/core/desktop.js +102 -0
- package/dist/core/digest.js +146 -0
- package/dist/core/follow.js +70 -0
- package/dist/core/format.js +351 -0
- package/dist/core/github.js +442 -0
- package/dist/core/integrate.js +121 -0
- package/dist/core/ops.js +415 -0
- package/dist/core/runs.js +284 -0
- package/dist/core/skill.js +67 -0
- package/dist/core/snapshot.js +71 -0
- package/dist/core/state.js +107 -0
- package/dist/core/store.js +127 -0
- package/dist/core/types.js +6 -0
- package/package.json +57 -4
- package/skills/parawatch/SKILL.md +135 -0
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { UserError } from "./ops.js";
|
|
6
|
+
/** The agent skill shipped with this package (works from src/ and from dist/). */
|
|
7
|
+
export function skillSource() {
|
|
8
|
+
return path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..", "skills", "parawatch");
|
|
9
|
+
}
|
|
10
|
+
export function skillTargets() {
|
|
11
|
+
const claude = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), ".claude");
|
|
12
|
+
const codex = process.env.CODEX_HOME || path.join(os.homedir(), ".codex");
|
|
13
|
+
return [
|
|
14
|
+
{ runtime: "claude-code", dir: path.join(claude, "skills"), home: claude },
|
|
15
|
+
{ runtime: "codex", dir: path.join(codex, "skills"), home: codex },
|
|
16
|
+
];
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Link the skill into each runtime's skills folder. Runtimes whose config folder is missing are
|
|
20
|
+
* skipped unless named. An existing folder that is not our link is left alone unless `force`.
|
|
21
|
+
*/
|
|
22
|
+
export function installSkill(targets, opts = {}) {
|
|
23
|
+
const src = skillSource();
|
|
24
|
+
if (!fs.existsSync(path.join(src, "SKILL.md")))
|
|
25
|
+
throw new UserError(`skill not found at ${src}`);
|
|
26
|
+
const out = [];
|
|
27
|
+
for (const t of targets) {
|
|
28
|
+
const named = opts.only?.includes(t.runtime);
|
|
29
|
+
if (opts.only && !named)
|
|
30
|
+
continue;
|
|
31
|
+
const dest = path.join(t.dir, "parawatch");
|
|
32
|
+
if (!named && !fs.existsSync(t.home)) {
|
|
33
|
+
out.push({ runtime: t.runtime, path: dest, result: "skipped", note: `${t.home} not found` });
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
fs.mkdirSync(t.dir, { recursive: true });
|
|
37
|
+
let stat;
|
|
38
|
+
try {
|
|
39
|
+
stat = fs.lstatSync(dest);
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
stat = undefined;
|
|
43
|
+
}
|
|
44
|
+
if (stat?.isSymbolicLink()) {
|
|
45
|
+
const current = path.resolve(t.dir, fs.readlinkSync(dest));
|
|
46
|
+
if (current === src) {
|
|
47
|
+
out.push({ runtime: t.runtime, path: dest, result: "already linked" });
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
fs.unlinkSync(dest);
|
|
51
|
+
fs.symlinkSync(src, dest);
|
|
52
|
+
out.push({ runtime: t.runtime, path: dest, result: "replaced link", note: `was ${current}` });
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
if (stat) {
|
|
56
|
+
if (!opts.force) {
|
|
57
|
+
out.push({ runtime: t.runtime, path: dest, result: "skipped", note: "a folder is already there; use --force to replace it" });
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
fs.rmSync(dest, { recursive: true, force: true });
|
|
61
|
+
}
|
|
62
|
+
fs.symlinkSync(src, dest);
|
|
63
|
+
out.push({ runtime: t.runtime, path: dest, result: "linked" });
|
|
64
|
+
}
|
|
65
|
+
return out;
|
|
66
|
+
}
|
|
67
|
+
//# sourceMappingURL=skill.js.map
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { prViews, keyOf, parsePrRef, readCache, githubConfig } from "./github.js";
|
|
2
|
+
import { linkPr, listeners } from "./ops.js";
|
|
3
|
+
import { collectRuns } from "./runs.js";
|
|
4
|
+
import { fold } from "./state.js";
|
|
5
|
+
import { readEvents } from "./store.js";
|
|
6
|
+
import { SCHEMA_VERSION } from "./types.js";
|
|
7
|
+
/**
|
|
8
|
+
* `closed`: also carry the most recently closed cards, newest first, so a panel can show them
|
|
9
|
+
* again. Off by default; open and answered cards are always there.
|
|
10
|
+
*
|
|
11
|
+
* GitHub is read from the last reading only; `syncGithub` refreshes it.
|
|
12
|
+
*/
|
|
13
|
+
export function snapshot(opts = {}) {
|
|
14
|
+
let state = fold(readEvents());
|
|
15
|
+
const runs = collectRuns(state, { sinceHours: opts.sinceHours ?? 6, project: opts.project, keepRepeats: opts.keepRepeats });
|
|
16
|
+
if (linkCreatedPrs(runs, state))
|
|
17
|
+
state = fold(readEvents());
|
|
18
|
+
const all = [...state.cards.values()].filter((c) => !opts.project || c.project === opts.project);
|
|
19
|
+
const cards = [...all.filter((c) => c.status !== "closed"), ...recentlyClosed(all, opts.closed ?? 0)];
|
|
20
|
+
quietAnsweredAsks(runs, all);
|
|
21
|
+
const cache = readCache();
|
|
22
|
+
const enabled = githubConfig().enabled && (cache.hosts === undefined ? false : cache.hosts.length > 0);
|
|
23
|
+
let prs = enabled ? prViews(cache, state, runs) : undefined;
|
|
24
|
+
if (prs && opts.project)
|
|
25
|
+
prs = prs.filter((p) => p.project === opts.project);
|
|
26
|
+
return {
|
|
27
|
+
v: SCHEMA_VERSION,
|
|
28
|
+
generatedAt: new Date().toISOString(),
|
|
29
|
+
projects: [...state.projects.values()],
|
|
30
|
+
cards,
|
|
31
|
+
runs,
|
|
32
|
+
prs,
|
|
33
|
+
github: { enabled, fetchedAt: cache.fetchedAt, error: cache.error },
|
|
34
|
+
listeners: listeners(),
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
/** A pull request a session's log shows it opened belongs to that session, unless linked already. */
|
|
38
|
+
function linkCreatedPrs(runs, state) {
|
|
39
|
+
let added = false;
|
|
40
|
+
for (const r of runs) {
|
|
41
|
+
for (const url of r.createdPrs ?? []) {
|
|
42
|
+
const key = keyOf(url);
|
|
43
|
+
const pr = parsePrRef(url);
|
|
44
|
+
if (!key || !pr || state.prLinks.has(key))
|
|
45
|
+
continue;
|
|
46
|
+
linkPr({ pr, session: r.id, project: r.project, task: r.task });
|
|
47
|
+
state.prLinks.set(key, { pr, session: r.id, project: r.project, at: new Date().toISOString() });
|
|
48
|
+
added = true;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return added;
|
|
52
|
+
}
|
|
53
|
+
/** A question asked in chat is no longer pending once the session put it on a card. */
|
|
54
|
+
function quietAnsweredAsks(runs, cards) {
|
|
55
|
+
for (const r of runs) {
|
|
56
|
+
if (!r.asks)
|
|
57
|
+
continue;
|
|
58
|
+
const turn = r.lastPromptAt ?? "";
|
|
59
|
+
if (cards.some((c) => c.status !== "closed" && c.session && r.id.startsWith(c.session) && c.openedAt >= turn))
|
|
60
|
+
r.asks = undefined;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
export function recentlyClosed(cards, keep) {
|
|
64
|
+
if (keep <= 0)
|
|
65
|
+
return [];
|
|
66
|
+
return cards
|
|
67
|
+
.filter((c) => c.status === "closed")
|
|
68
|
+
.sort((a, b) => (b.closedAt ?? "").localeCompare(a.closedAt ?? ""))
|
|
69
|
+
.slice(0, keep);
|
|
70
|
+
}
|
|
71
|
+
//# sourceMappingURL=snapshot.js.map
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/** Rebuild current state from the event log. */
|
|
2
|
+
export function fold(events) {
|
|
3
|
+
const s = { projects: new Map(), cards: new Map(), runs: new Map(), claims: [], prLinks: new Map(), lastSeq: 0 };
|
|
4
|
+
for (const ev of events) {
|
|
5
|
+
s.lastSeq = Math.max(s.lastSeq, ev.seq);
|
|
6
|
+
switch (ev.type) {
|
|
7
|
+
case "project.add":
|
|
8
|
+
s.projects.set(ev.project.name, ev.project);
|
|
9
|
+
break;
|
|
10
|
+
case "project.remove":
|
|
11
|
+
s.projects.delete(ev.name);
|
|
12
|
+
break;
|
|
13
|
+
case "card.open":
|
|
14
|
+
s.cards.set(ev.card.id, { ...ev.card, openedAt: ev.ts, status: "open", answers: [], comments: [] });
|
|
15
|
+
break;
|
|
16
|
+
case "card.answer": {
|
|
17
|
+
const c = s.cards.get(ev.card);
|
|
18
|
+
if (!c)
|
|
19
|
+
break;
|
|
20
|
+
c.answers.push({ seq: ev.seq, at: ev.ts, by: ev.by, choice: ev.choice, comment: ev.comment, attach: ev.attach });
|
|
21
|
+
if (c.status === "open")
|
|
22
|
+
c.status = "answered";
|
|
23
|
+
break;
|
|
24
|
+
}
|
|
25
|
+
case "card.comment": {
|
|
26
|
+
const c = s.cards.get(ev.card);
|
|
27
|
+
if (c)
|
|
28
|
+
c.comments.push({ seq: ev.seq, at: ev.ts, by: ev.by, text: ev.text });
|
|
29
|
+
break;
|
|
30
|
+
}
|
|
31
|
+
case "card.edit": {
|
|
32
|
+
const c = s.cards.get(ev.card);
|
|
33
|
+
if (!c)
|
|
34
|
+
break;
|
|
35
|
+
const target = c;
|
|
36
|
+
for (const [k, v] of Object.entries(ev.set)) {
|
|
37
|
+
if (v === null || v === undefined)
|
|
38
|
+
delete target[k];
|
|
39
|
+
else
|
|
40
|
+
target[k] = v;
|
|
41
|
+
}
|
|
42
|
+
break;
|
|
43
|
+
}
|
|
44
|
+
case "card.close": {
|
|
45
|
+
const c = s.cards.get(ev.card);
|
|
46
|
+
if (!c)
|
|
47
|
+
break;
|
|
48
|
+
c.status = "closed";
|
|
49
|
+
c.closedAt = ev.ts;
|
|
50
|
+
c.closeNote = ev.note;
|
|
51
|
+
break;
|
|
52
|
+
}
|
|
53
|
+
case "run.start":
|
|
54
|
+
s.runs.set(ev.run.id, { ...ev.run, startedAt: ev.ts, lastActivity: ev.ts, status: "running" });
|
|
55
|
+
break;
|
|
56
|
+
case "run.note": {
|
|
57
|
+
const r = s.runs.get(ev.run);
|
|
58
|
+
if (!r)
|
|
59
|
+
break;
|
|
60
|
+
r.note = ev.text;
|
|
61
|
+
r.lastActivity = ev.ts;
|
|
62
|
+
break;
|
|
63
|
+
}
|
|
64
|
+
case "run.beat": {
|
|
65
|
+
const r = s.runs.get(ev.run);
|
|
66
|
+
if (r)
|
|
67
|
+
r.lastActivity = ev.ts;
|
|
68
|
+
break;
|
|
69
|
+
}
|
|
70
|
+
case "run.end": {
|
|
71
|
+
const r = s.runs.get(ev.run);
|
|
72
|
+
if (!r)
|
|
73
|
+
break;
|
|
74
|
+
r.status = ev.status;
|
|
75
|
+
r.endedAt = ev.ts;
|
|
76
|
+
r.lastActivity = ev.ts;
|
|
77
|
+
if (ev.note)
|
|
78
|
+
r.note = ev.note;
|
|
79
|
+
break;
|
|
80
|
+
}
|
|
81
|
+
case "run.claim":
|
|
82
|
+
// a later claim of the same session replaces the earlier one
|
|
83
|
+
s.claims = s.claims.filter((c) => c.session !== ev.claim.session);
|
|
84
|
+
s.claims.push({ ...ev.claim, at: ev.ts });
|
|
85
|
+
break;
|
|
86
|
+
case "pr.link":
|
|
87
|
+
s.prLinks.set(ev.link.pr.key, { ...ev.link, at: ev.ts });
|
|
88
|
+
break;
|
|
89
|
+
case "pr.unlink":
|
|
90
|
+
s.prLinks.delete(ev.pr);
|
|
91
|
+
break;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return s;
|
|
95
|
+
}
|
|
96
|
+
/** Next id with a prefix, counting the events that created that kind of thing. */
|
|
97
|
+
export function nextId(events, prefix) {
|
|
98
|
+
let n = 0;
|
|
99
|
+
for (const ev of events) {
|
|
100
|
+
if (prefix === "c" && ev.type === "card.open")
|
|
101
|
+
n++;
|
|
102
|
+
if (prefix === "r" && ev.type === "run.start")
|
|
103
|
+
n++;
|
|
104
|
+
}
|
|
105
|
+
return `${prefix}${n + 1}`;
|
|
106
|
+
}
|
|
107
|
+
//# sourceMappingURL=state.js.map
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { SCHEMA_VERSION } from "./types.js";
|
|
5
|
+
/** Directory holding all parawatch state for this user. */
|
|
6
|
+
export function homeDir() {
|
|
7
|
+
const explicit = process.env.PARAWATCH_HOME;
|
|
8
|
+
if (explicit)
|
|
9
|
+
return explicit;
|
|
10
|
+
const xdg = process.env.XDG_STATE_HOME || path.join(os.homedir(), ".local", "state");
|
|
11
|
+
return path.join(xdg, "parawatch");
|
|
12
|
+
}
|
|
13
|
+
export function eventsPath() {
|
|
14
|
+
return path.join(homeDir(), "events.jsonl");
|
|
15
|
+
}
|
|
16
|
+
function ensureHome() {
|
|
17
|
+
fs.mkdirSync(homeDir(), { recursive: true });
|
|
18
|
+
}
|
|
19
|
+
const LOCK_STALE_MS = 30_000;
|
|
20
|
+
const LOCK_WAIT_MS = 10_000;
|
|
21
|
+
function sleepSync(ms) {
|
|
22
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
23
|
+
}
|
|
24
|
+
/** Run fn while holding an exclusive lock file. Several agents may write at once. */
|
|
25
|
+
export function withLock(fn) {
|
|
26
|
+
ensureHome();
|
|
27
|
+
const lock = path.join(homeDir(), "lock");
|
|
28
|
+
const started = Date.now();
|
|
29
|
+
let fd;
|
|
30
|
+
while (fd === undefined) {
|
|
31
|
+
try {
|
|
32
|
+
fd = fs.openSync(lock, "wx");
|
|
33
|
+
fs.writeSync(fd, String(process.pid));
|
|
34
|
+
}
|
|
35
|
+
catch (err) {
|
|
36
|
+
if (err.code !== "EEXIST")
|
|
37
|
+
throw err;
|
|
38
|
+
try {
|
|
39
|
+
const age = Date.now() - fs.statSync(lock).mtimeMs;
|
|
40
|
+
if (age > LOCK_STALE_MS) {
|
|
41
|
+
fs.rmSync(lock, { force: true });
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
continue; // lock vanished between open and stat
|
|
47
|
+
}
|
|
48
|
+
if (Date.now() - started > LOCK_WAIT_MS) {
|
|
49
|
+
throw new Error(`parawatch: could not take the lock at ${lock}`);
|
|
50
|
+
}
|
|
51
|
+
sleepSync(15);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
try {
|
|
55
|
+
return fn();
|
|
56
|
+
}
|
|
57
|
+
finally {
|
|
58
|
+
fs.closeSync(fd);
|
|
59
|
+
fs.rmSync(lock, { force: true });
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
/** Parse every event line. Broken lines are skipped, not fatal. */
|
|
63
|
+
export function readEvents() {
|
|
64
|
+
let text;
|
|
65
|
+
try {
|
|
66
|
+
text = fs.readFileSync(eventsPath(), "utf8");
|
|
67
|
+
}
|
|
68
|
+
catch (err) {
|
|
69
|
+
if (err.code === "ENOENT")
|
|
70
|
+
return [];
|
|
71
|
+
throw err;
|
|
72
|
+
}
|
|
73
|
+
return parseEventLines(text);
|
|
74
|
+
}
|
|
75
|
+
export function parseEventLines(text) {
|
|
76
|
+
const out = [];
|
|
77
|
+
for (const line of text.split("\n")) {
|
|
78
|
+
if (!line.trim())
|
|
79
|
+
continue;
|
|
80
|
+
try {
|
|
81
|
+
const ev = JSON.parse(line);
|
|
82
|
+
if (typeof ev.seq === "number" && typeof ev.type === "string")
|
|
83
|
+
out.push(ev);
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
// a torn or hand-edited line; keep going
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return out;
|
|
90
|
+
}
|
|
91
|
+
/** Append one event. `build` sees the current events so it can pick ids under the lock. */
|
|
92
|
+
export function appendEvent(build) {
|
|
93
|
+
return withLock(() => appendLocked((events) => [build(events)])[0]);
|
|
94
|
+
}
|
|
95
|
+
/** Append events while already holding the lock (inside `withLock`). */
|
|
96
|
+
export function appendLocked(build) {
|
|
97
|
+
const events = readEvents();
|
|
98
|
+
let seq = events[events.length - 1]?.seq ?? 0;
|
|
99
|
+
const ts = new Date().toISOString();
|
|
100
|
+
const out = build(events).map((body) => ({ v: SCHEMA_VERSION, seq: ++seq, ts, ...body }));
|
|
101
|
+
if (out.length)
|
|
102
|
+
fs.appendFileSync(eventsPath(), out.map((e) => JSON.stringify(e) + "\n").join(""));
|
|
103
|
+
return out;
|
|
104
|
+
}
|
|
105
|
+
const CURSOR_NAME = /^[A-Za-z0-9._-]+$/;
|
|
106
|
+
function cursorPath(name) {
|
|
107
|
+
if (!CURSOR_NAME.test(name))
|
|
108
|
+
throw new Error(`parawatch: bad consumer name "${name}"`);
|
|
109
|
+
return path.join(homeDir(), "cursors", `${name}.json`);
|
|
110
|
+
}
|
|
111
|
+
export function readCursor(name) {
|
|
112
|
+
try {
|
|
113
|
+
const data = JSON.parse(fs.readFileSync(cursorPath(name), "utf8"));
|
|
114
|
+
return typeof data.seq === "number" ? data.seq : 0;
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
return 0;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
export function writeCursor(name, seq) {
|
|
121
|
+
const file = cursorPath(name);
|
|
122
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
123
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
124
|
+
fs.writeFileSync(tmp, JSON.stringify({ seq, at: new Date().toISOString() }) + "\n");
|
|
125
|
+
fs.renameSync(tmp, file);
|
|
126
|
+
}
|
|
127
|
+
//# sourceMappingURL=store.js.map
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export const SCHEMA_VERSION = 1;
|
|
2
|
+
export const CARD_KINDS = ["decide", "try", "block", "todo"];
|
|
3
|
+
export const PR_CHANGES = ["review", "approved", "changes_requested", "commented", "ci_failed", "ci_passed", "queued", "queue_removed", "conflict", "merged", "closed", "note"];
|
|
4
|
+
/** Who counts as a human when deciding what wakes an agent. */
|
|
5
|
+
export const HUMAN = "human";
|
|
6
|
+
//# sourceMappingURL=types.js.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,59 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "parawatch",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.2.0",
|
|
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
|
+
"keywords": [
|
|
6
|
+
"ai-agents",
|
|
7
|
+
"claude-code",
|
|
8
|
+
"codex",
|
|
9
|
+
"cli",
|
|
10
|
+
"human-in-the-loop",
|
|
11
|
+
"parallel",
|
|
12
|
+
"inbox"
|
|
13
|
+
],
|
|
14
|
+
"license": "MIT",
|
|
15
|
+
"author": "penspanic",
|
|
16
|
+
"homepage": "https://github.com/penspanic/parawatch#readme",
|
|
17
|
+
"repository": {
|
|
18
|
+
"type": "git",
|
|
19
|
+
"url": "git+https://github.com/penspanic/parawatch.git"
|
|
20
|
+
},
|
|
21
|
+
"bugs": {
|
|
22
|
+
"url": "https://github.com/penspanic/parawatch/issues"
|
|
23
|
+
},
|
|
24
|
+
"type": "module",
|
|
25
|
+
"bin": {
|
|
26
|
+
"parawatch": "dist/bin/cli.js"
|
|
27
|
+
},
|
|
28
|
+
"files": [
|
|
29
|
+
"dist",
|
|
30
|
+
"!dist/**/*.map",
|
|
31
|
+
"skills",
|
|
32
|
+
"README.md",
|
|
33
|
+
"LICENSE"
|
|
34
|
+
],
|
|
35
|
+
"engines": {
|
|
36
|
+
"node": ">=20"
|
|
37
|
+
},
|
|
38
|
+
"scripts": {
|
|
39
|
+
"build": "tsc -p tsconfig.json",
|
|
40
|
+
"dev": "tsx src/bin/cli.ts",
|
|
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",
|
|
43
|
+
"prepublishOnly": "npm run build && npm test",
|
|
44
|
+
"demo": "tsx scripts/demo.ts"
|
|
45
|
+
},
|
|
46
|
+
"dependencies": {
|
|
47
|
+
"commander": "^12.1.0"
|
|
48
|
+
},
|
|
49
|
+
"devDependencies": {
|
|
50
|
+
"@types/node": "^22.10.1",
|
|
51
|
+
"tsx": "^4.19.2",
|
|
52
|
+
"typescript": "^5.7.2",
|
|
53
|
+
"vitest": "^2.1.8"
|
|
54
|
+
},
|
|
55
|
+
"workspaces": [
|
|
56
|
+
"packages/*",
|
|
57
|
+
"apps/*"
|
|
58
|
+
]
|
|
59
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: parawatch
|
|
3
|
+
description: How an agent works with a human through parawatch. Use whenever you need something from the human (a decision, an approval of a prepared step, trying a build or looking at a capture, an action only they can do such as installing, signing in or sudo), as soon as you know what you are working on in a session where parawatch is set up (its AGENTS.md or CLAUDE.md has a parawatch block, or `parawatch project list` shows projects), when you open a pull request, when you finish your work, when you lead or coordinate other agents, and when you start a long job that leaves no agent log (CI wait, bake, benchmark). Covers claiming your work, linking pull requests, opening good cards, waiting for answers and PR news in the background, closing cards, finishing, and declaring runs. Not for talking to other agents, and not a task tracker.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# parawatch
|
|
7
|
+
|
|
8
|
+
The human watches every project and agent from `parawatch status` and a side panel, and answers there. Anything you need from them goes through parawatch, not through chat or a document they have to remember to check. Their answer wakes you.
|
|
9
|
+
|
|
10
|
+
## 1. Say what you work on, as soon as you know
|
|
11
|
+
|
|
12
|
+
The human sees agents grouped by project and task. Parawatch can only guess from the folder a session started in, and many people start every session in the same home or notes folder. So say it yourself, the moment the work is clear (from the first message, or once you have read enough to know):
|
|
13
|
+
|
|
14
|
+
```
|
|
15
|
+
parawatch claim -p <project> -t <task> --title "<what you do, in a few words>"
|
|
16
|
+
parawatch claim -p <project> --role lead --title "<what you are, e.g. Game Director>" # if you lead the project
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
- **Claim the project the work is for, not the folder you run in.** A session started in a notes folder that changes the `api` repository claims `api`.
|
|
20
|
+
- **`-t` is the unit of work**: a ticket key (`ABC-1234`), a pull request (`#88`), or a short name. It is how the human tells two sessions in one project apart.
|
|
21
|
+
- **Claim again when the work changes** (the next ticket, another repository). The latest claim wins.
|
|
22
|
+
- A project does not have to be registered to be claimed. `parawatch project list` shows the names already in use; reuse one when it fits.
|
|
23
|
+
- A session a schedule starts again and again adds `--kind scheduled`, so its runs fold into one row.
|
|
24
|
+
|
|
25
|
+
`claim` without a session id uses the calling session (`$CLAUDE_CODE_SESSION_ID`). Cards you open record your session too, so the human can jump from a card to you; claim first so the card shows your title. Your subagents and the `codex exec` jobs you start show under you on their own.
|
|
26
|
+
|
|
27
|
+
To register a project so sessions started inside it are grouped without a claim: `parawatch project add <name> <repo path> [<notes folder>]`. Git worktrees of a registered path belong to the project automatically.
|
|
28
|
+
|
|
29
|
+
Then check what is already waiting so you do not ask twice:
|
|
30
|
+
|
|
31
|
+
```
|
|
32
|
+
parawatch cards -p <project>
|
|
33
|
+
parawatch inbox -p <project> --peek
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## 2. Asking the human
|
|
37
|
+
|
|
38
|
+
One question per card. Pick the kind by what the human has to do:
|
|
39
|
+
|
|
40
|
+
| Need | Command |
|
|
41
|
+
|--|--|
|
|
42
|
+
| A choice | `parawatch ask -p <project> "<the question>" -o "<option>" -o "<option>" -r <n> -b "<why it matters now>"` |
|
|
43
|
+
| Approval of a step you have prepared (merge, post, move a ticket, send a message, force-push) | `parawatch ask -p <project> "<what and why, as a question>" --action "<the exact command or step>" --preview <file with the text it sends>` |
|
|
44
|
+
| An open answer | `parawatch ask -p <project> "<the question>" -b "<context>"` |
|
|
45
|
+
| Try a build, watch a video, look at a screen | `parawatch try -p <project> "<what to try>" --open <app, file or URL> --attach <image or video> -l "<what to look at>"` |
|
|
46
|
+
| Something only they can do, and you are stuck until they do | `parawatch block -p <project> "<what to do>" -b "<exact steps>"` |
|
|
47
|
+
| Something only they can do, but nothing waits on it now | `parawatch todo -p <project> "<what to do>" -b "<exact steps, and by when>"` |
|
|
48
|
+
|
|
49
|
+
A good card:
|
|
50
|
+
|
|
51
|
+
- **The title is the question or the request**, readable alone in a list: "Ship the top-box ending?" not "Ending".
|
|
52
|
+
- **The body is two or three lines**: what changed, what it affects, what happens if they do not answer. It renders as Markdown.
|
|
53
|
+
- **Write in the human's words**: what they will see or feel in the product, not ticket numbers, metrics, engine terms or internal ids. Put those in an attachment or a link at the end of the body.
|
|
54
|
+
- **Options are exclusive and concrete, and each says what happens if picked.** Recommend one with `-r` when you have a view; say why in the body.
|
|
55
|
+
- **Attach evidence** with absolute paths: screenshots, short videos, logs. `--open` takes what they should launch (an `.app`, a file, a URL).
|
|
56
|
+
- **Add `-t <task>`** when the project runs several tasks at once, so the human can tell them apart.
|
|
57
|
+
- **`block` is red and counts as needing them now.** Use it only when your work stops until they act. Errands for later (a bill, a registration, a policy check, a measurement whenever the machine is free) are `todo`; they wait folded in the panel and do not pop up.
|
|
58
|
+
- **Moving items from a board or a note:** pass `--since <YYYY-MM-DD>` with the date the item first came up, so the card does not look new. `parawatch edit <card>` fixes a card's kind, wording or date later.
|
|
59
|
+
- **An approval shows exactly what runs.** With `--action` the human sees the command and the preview and answers Do it or Don't, without opening your session. Run exactly that on yes; say in the close note that you ran it.
|
|
60
|
+
- **Never leave a question only in your reply.** If your turn ends needing the human, the question goes on a card. Parawatch flags questions it finds at the end of a turn as "asked in chat, no card", but that is a guess and easy to miss.
|
|
61
|
+
- Do not open a card for something you can decide yourself, or that the project's own rules already settle.
|
|
62
|
+
|
|
63
|
+
## 3. Pull requests
|
|
64
|
+
|
|
65
|
+
When you open a pull request, link it to yourself:
|
|
66
|
+
|
|
67
|
+
```
|
|
68
|
+
parawatch pr link <url> # or #88 inside the repository
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
A pull request you open with `gh pr create` is linked from your log on its own, but say it anyway; it costs nothing and survives a long session. When `gh` is installed and signed in, parawatch reads your pull requests from GitHub (GitHub Enterprise too). New reviews, requested changes, approvals, CI failures, conflicts, a drop from the merge queue and the merge itself arrive at your wait like an answer. The human sees the PR, what it waits on and for how long, next to your session.
|
|
72
|
+
|
|
73
|
+
News from somewhere GitHub does not show (a merge bot, a ticket system) can be recorded by any tool: `parawatch pr event <url> <change> -m "<what happened>"`.
|
|
74
|
+
|
|
75
|
+
## 4. Waiting for the answer
|
|
76
|
+
|
|
77
|
+
Right after opening cards or linking a pull request, keep exactly one wait armed for your project:
|
|
78
|
+
|
|
79
|
+
- **Claude Code**: run `parawatch wait -p <project>` 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.
|
|
80
|
+
- **Other runtimes**: check `parawatch inbox -p <project>` at the start of every turn.
|
|
81
|
+
|
|
82
|
+
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.
|
|
83
|
+
|
|
84
|
+
## 5. Handling an answer
|
|
85
|
+
|
|
86
|
+
The output looks like `[project] c4 "Ship the top-box ending?" chose 2 (Top box); said: fill it to the rim; attached: /path/shot.png`, or for a pull request `[project] PR acme/api#88 "Fix login": changes requested by alice (https://…)`.
|
|
87
|
+
|
|
88
|
+
1. Act on it. Read attached files; they are the human's evidence.
|
|
89
|
+
2. If the answer is a lasting decision, record it where the project keeps decisions (a design doc, an ADR), not only in the card.
|
|
90
|
+
3. Close the card with what you did: `parawatch close c4 -n "Top box shipped in #212"`.
|
|
91
|
+
4. If more cards are open, or a linked pull request is not merged yet, arm the wait again.
|
|
92
|
+
|
|
93
|
+
To add information to a card without waking anyone, use `parawatch comment <card> "<text>"`. Never answer a card yourself: `answer` is the human's.
|
|
94
|
+
|
|
95
|
+
## 6. Long jobs that leave no agent log
|
|
96
|
+
|
|
97
|
+
A render, a benchmark, an editor build, a deploy you are waiting on:
|
|
98
|
+
|
|
99
|
+
```
|
|
100
|
+
id=$(parawatch run start -p <project> "overnight bench" --heartbeat 600 --log /path/bench.log)
|
|
101
|
+
parawatch run note $id "scene 3 of 8" # also counts as a beat
|
|
102
|
+
parawatch run beat $id # still alive, nothing to say
|
|
103
|
+
parawatch run end $id # or: parawatch run end $id --failed -n "editor stopped ticking"
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
The run shows under your session. With `--heartbeat`, it turns stalled when no beat, note or log change comes in that many seconds, so a job that hangs silently (a locked screen, App Nap) shows at once. End it when it ends, or it keeps showing as working. Do not poll a job with sleep loops; declare it and arm a wait.
|
|
107
|
+
|
|
108
|
+
Do not declare runs for things parawatch already sees in their own logs: Claude Code sessions and subagents, and `codex exec` jobs, show up by themselves. A declared run on top of them is a duplicate.
|
|
109
|
+
|
|
110
|
+
## 7. Finishing
|
|
111
|
+
|
|
112
|
+
When your work is finished, say so with one line on how it ended:
|
|
113
|
+
|
|
114
|
+
```
|
|
115
|
+
parawatch claim --done "Merged #88; login works on staging"
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
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.
|
|
119
|
+
|
|
120
|
+
## 8. Leads and workers
|
|
121
|
+
|
|
122
|
+
- The lead owns the cards for its project. 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.
|
|
123
|
+
- Tell workers in their brief what to return to you, so the lead can turn it into one clear card.
|
|
124
|
+
- Before ending a session, close cards you handled, end runs you started, and claim `--done`.
|
|
125
|
+
|
|
126
|
+
## Quick reference
|
|
127
|
+
|
|
128
|
+
```
|
|
129
|
+
parawatch status # everything: what needs the human, what waits on others, what is running
|
|
130
|
+
parawatch runs -p <project> --wide # agents with ids
|
|
131
|
+
parawatch pr list # open pull requests and what each waits on
|
|
132
|
+
parawatch digest # what each session did today
|
|
133
|
+
parawatch show <card> # one card in full
|
|
134
|
+
parawatch cards -p <project> -a # include closed cards
|
|
135
|
+
```
|