parawatch 0.0.0-stage → 0.1.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.
@@ -0,0 +1,160 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import path from "node:path";
3
+ import { scanAll } from "../adapters/index.js";
4
+ import { ACTIVITY_MINUTES, activeMinutes } from "../adapters/jsonl.js";
5
+ /** All checkouts of a project path: the path itself plus its git worktrees. */
6
+ function rootsOf(p, cache) {
7
+ const hit = cache.get(p);
8
+ if (hit)
9
+ return hit;
10
+ const roots = [p];
11
+ try {
12
+ const git = (args) => execFileSync("git", ["-C", p, ...args], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 5000 });
13
+ // a folder inside a bigger repo (a notes vault, a monorepo package) must not claim the whole repo
14
+ const top = git(["rev-parse", "--show-toplevel"]).trim();
15
+ if (path.resolve(top) !== path.resolve(p)) {
16
+ cache.set(p, roots);
17
+ return roots;
18
+ }
19
+ const out = git(["worktree", "list", "--porcelain"]);
20
+ for (const line of out.split("\n")) {
21
+ if (line.startsWith("worktree ")) {
22
+ const w = line.slice("worktree ".length).trim();
23
+ if (w && !roots.includes(w))
24
+ roots.push(w);
25
+ }
26
+ }
27
+ }
28
+ catch {
29
+ // not a git checkout, or git missing; the path alone still matches
30
+ }
31
+ cache.set(p, roots);
32
+ return roots;
33
+ }
34
+ function inside(child, parent) {
35
+ const rel = path.relative(parent, child);
36
+ return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel));
37
+ }
38
+ function matchers(projects) {
39
+ const cache = new Map();
40
+ return projects.map((p) => ({ project: p.name, roots: p.paths.flatMap((x) => rootsOf(x, cache)) }));
41
+ }
42
+ /** The project whose checkout holds this directory; the deepest root wins. */
43
+ function projectForCwd(cwd, ms) {
44
+ if (!cwd)
45
+ return undefined;
46
+ let best;
47
+ for (const m of ms) {
48
+ for (const r of m.roots) {
49
+ if (inside(cwd, r) && (!best || r.length > best.depth))
50
+ best = { project: m.project, depth: r.length };
51
+ }
52
+ }
53
+ return best?.project;
54
+ }
55
+ /** A project whose path the run's first prompt names, when exactly one does. */
56
+ function projectForPrompt(prompt, ms) {
57
+ if (!prompt)
58
+ return undefined;
59
+ const hits = new Set();
60
+ for (const m of ms) {
61
+ for (const r of m.roots)
62
+ if (prompt.includes(r))
63
+ hits.add(m.project);
64
+ }
65
+ return hits.size === 1 ? [...hits][0] : undefined;
66
+ }
67
+ /**
68
+ * Give each observed run a project, in this order: an explicit claim, the checkout
69
+ * it runs in, its parent's project, a project path named in its first prompt.
70
+ */
71
+ export function attribute(runs, state) {
72
+ const ms = matchers([...state.projects.values()]);
73
+ const byId = new Map(runs.map((r) => [r.id, r]));
74
+ for (const r of runs) {
75
+ const claim = state.claims.find((c) => r.id.startsWith(c.session));
76
+ if (claim) {
77
+ r.project = claim.project;
78
+ r.task = claim.task ?? r.task;
79
+ r.role = claim.role ?? r.role;
80
+ if (claim.title)
81
+ r.title = claim.title;
82
+ continue;
83
+ }
84
+ r.project = projectForCwd(r.cwd, ms);
85
+ }
86
+ // parents may themselves get a project only from a claim, so resolve children afterwards
87
+ for (let pass = 0; pass < 3; pass++) {
88
+ for (const r of runs) {
89
+ if (r.project || !r.parent)
90
+ continue;
91
+ const p = byId.get(r.parent);
92
+ if (p?.project)
93
+ r.project = p.project;
94
+ else
95
+ r.project = state.claims.find((c) => r.parent.startsWith(c.session))?.project;
96
+ }
97
+ }
98
+ for (const r of runs) {
99
+ if (!r.project)
100
+ r.project = projectForPrompt(r.prompt, ms);
101
+ }
102
+ return runs;
103
+ }
104
+ /** A declared run is busy from its start until it ends (or now, while it runs). */
105
+ function declaredActivity(start, end, now) {
106
+ const from = Date.parse(start);
107
+ const to = end ? Date.parse(end) : now;
108
+ const times = [];
109
+ for (let t = Math.max(from, now - ACTIVITY_MINUTES * 60_000); t <= to; t += 60_000)
110
+ times.push(t);
111
+ if (to >= from)
112
+ times.push(to);
113
+ return activeMinutes(times, now);
114
+ }
115
+ export function declaredRuns(state, now = Date.now()) {
116
+ return [...state.runs.values()].map((d) => ({
117
+ id: d.id,
118
+ runtime: "declared",
119
+ kind: "declared",
120
+ title: d.title,
121
+ project: d.project,
122
+ task: d.task,
123
+ parent: d.parent,
124
+ startedAt: d.startedAt,
125
+ lastActivity: d.lastActivity,
126
+ state: d.status === "running" ? "working" : d.status,
127
+ action: d.note,
128
+ activity: declaredActivity(d.startedAt, d.status === "running" ? undefined : (d.endedAt ?? d.lastActivity), now),
129
+ }));
130
+ }
131
+ const ENDED = new Set(["done", "idle", "failed"]);
132
+ const RECENT_END_MS = 30 * 60_000;
133
+ const OLD_STALL_MS = 3 * 3_600_000;
134
+ export function collectRuns(state, q, now = Date.now()) {
135
+ const sinceMs = now - q.sinceHours * 3_600_000;
136
+ // a log file can be touched (title records, snapshots) long after its last real activity
137
+ const recent = (r) => !r.lastActivity || Date.parse(r.lastActivity) >= sinceMs;
138
+ let runs = attribute(scanAll({ sinceMs, now }, q.runtimes), state).filter(recent);
139
+ runs.push(...declaredRuns(state, now).filter(recent));
140
+ if (q.project)
141
+ runs = runs.filter((r) => r.project === q.project);
142
+ if (!q.includeEnded) {
143
+ // keep what is moving, plus things that finished in the last half hour and parents of live children
144
+ const live = new Set(runs.filter((r) => !ENDED.has(r.state)).map((r) => r.parent).filter(Boolean));
145
+ runs = runs.filter((r) => {
146
+ // a run quiet for hours is almost always gone, not stuck; --all still shows it
147
+ if (r.state === "stalled")
148
+ return r.lastActivity ? now - Date.parse(r.lastActivity) < OLD_STALL_MS : true;
149
+ if (!ENDED.has(r.state))
150
+ return true;
151
+ if (live.has(r.id))
152
+ return true;
153
+ if (r.role === "lead")
154
+ return true;
155
+ return r.lastActivity ? now - Date.parse(r.lastActivity) < RECENT_END_MS : false;
156
+ });
157
+ }
158
+ return runs.sort((a, b) => (b.lastActivity ?? "").localeCompare(a.lastActivity ?? ""));
159
+ }
160
+ //# sourceMappingURL=runs.js.map
@@ -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,24 @@
1
+ import { collectRuns } from "./runs.js";
2
+ import { fold } from "./state.js";
3
+ import { readEvents } from "./store.js";
4
+ import { SCHEMA_VERSION } from "./types.js";
5
+ /**
6
+ * `closed`: also carry the most recently closed cards, newest first, so a panel can show them
7
+ * again. Off by default; open and answered cards are always there.
8
+ */
9
+ export function snapshot(opts = {}) {
10
+ const state = fold(readEvents());
11
+ const all = [...state.cards.values()].filter((c) => !opts.project || c.project === opts.project);
12
+ const cards = [...all.filter((c) => c.status !== "closed"), ...recentlyClosed(all, opts.closed ?? 0)];
13
+ const runs = collectRuns(state, { sinceHours: opts.sinceHours ?? 6, project: opts.project });
14
+ return { v: SCHEMA_VERSION, generatedAt: new Date().toISOString(), projects: [...state.projects.values()], cards, runs };
15
+ }
16
+ export function recentlyClosed(cards, keep) {
17
+ if (keep <= 0)
18
+ return [];
19
+ return cards
20
+ .filter((c) => c.status === "closed")
21
+ .sort((a, b) => (b.closedAt ?? "").localeCompare(a.closedAt ?? ""))
22
+ .slice(0, keep);
23
+ }
24
+ //# sourceMappingURL=snapshot.js.map
@@ -0,0 +1,95 @@
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: [], 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.end": {
65
+ const r = s.runs.get(ev.run);
66
+ if (!r)
67
+ break;
68
+ r.status = ev.status;
69
+ r.endedAt = ev.ts;
70
+ r.lastActivity = ev.ts;
71
+ if (ev.note)
72
+ r.note = ev.note;
73
+ break;
74
+ }
75
+ case "run.claim":
76
+ // a later claim of the same session replaces the earlier one
77
+ s.claims = s.claims.filter((c) => c.session !== ev.claim.session);
78
+ s.claims.push(ev.claim);
79
+ break;
80
+ }
81
+ }
82
+ return s;
83
+ }
84
+ /** Next id with a prefix, counting the events that created that kind of thing. */
85
+ export function nextId(events, prefix) {
86
+ let n = 0;
87
+ for (const ev of events) {
88
+ if (prefix === "c" && ev.type === "card.open")
89
+ n++;
90
+ if (prefix === "r" && ev.type === "run.start")
91
+ n++;
92
+ }
93
+ return `${prefix}${n + 1}`;
94
+ }
95
+ //# sourceMappingURL=state.js.map
@@ -0,0 +1,128 @@
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(() => {
94
+ const events = readEvents();
95
+ const last = events[events.length - 1];
96
+ const ev = {
97
+ v: SCHEMA_VERSION,
98
+ seq: (last?.seq ?? 0) + 1,
99
+ ts: new Date().toISOString(),
100
+ ...build(events),
101
+ };
102
+ fs.appendFileSync(eventsPath(), JSON.stringify(ev) + "\n");
103
+ return ev;
104
+ });
105
+ }
106
+ const CURSOR_NAME = /^[A-Za-z0-9._-]+$/;
107
+ function cursorPath(name) {
108
+ if (!CURSOR_NAME.test(name))
109
+ throw new Error(`parawatch: bad consumer name "${name}"`);
110
+ return path.join(homeDir(), "cursors", `${name}.json`);
111
+ }
112
+ export function readCursor(name) {
113
+ try {
114
+ const data = JSON.parse(fs.readFileSync(cursorPath(name), "utf8"));
115
+ return typeof data.seq === "number" ? data.seq : 0;
116
+ }
117
+ catch {
118
+ return 0;
119
+ }
120
+ }
121
+ export function writeCursor(name, seq) {
122
+ const file = cursorPath(name);
123
+ fs.mkdirSync(path.dirname(file), { recursive: true });
124
+ const tmp = `${file}.${process.pid}.tmp`;
125
+ fs.writeFileSync(tmp, JSON.stringify({ seq, at: new Date().toISOString() }) + "\n");
126
+ fs.renameSync(tmp, file);
127
+ }
128
+ //# sourceMappingURL=store.js.map
@@ -0,0 +1,5 @@
1
+ export const SCHEMA_VERSION = 1;
2
+ export const CARD_KINDS = ["decide", "try", "block", "todo"];
3
+ /** Who counts as a human when deciding what wakes an agent. */
4
+ export const HUMAN = "human";
5
+ //# sourceMappingURL=types.js.map
package/package.json CHANGED
@@ -1,6 +1,59 @@
1
1
  {
2
2
  "name": "parawatch",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.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,105 @@
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, trying a build or looking at a capture, an action only they can do such as installing, signing in or sudo), when you lead or coordinate other agents on a project, when you start a long job that leaves no agent log (CI wait, bake, benchmark), and at the start of a session in a project that uses parawatch (its AGENTS.md or CLAUDE.md has a parawatch block, or `parawatch project list` shows it). Covers claiming your session, opening good cards, waiting for answers in the background, closing cards 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. At the start of a session
11
+
12
+ ```
13
+ parawatch project list
14
+ ```
15
+
16
+ If the project you work on is listed, put yourself on the board:
17
+
18
+ ```
19
+ parawatch claim -p <project> --role lead --title "<what you are, e.g. Game Director>" # if you lead the project
20
+ parawatch claim -p <project> --title "<your task>" # otherwise
21
+ ```
22
+
23
+ `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.
24
+
25
+ If the project is not listed and the human wants it tracked: `parawatch project add <name> <repo path> [<notes folder>]`. Git worktrees of a registered path belong to the project automatically.
26
+
27
+ Then check what is already waiting so you do not ask twice:
28
+
29
+ ```
30
+ parawatch cards -p <project>
31
+ parawatch inbox -p <project> --peek
32
+ ```
33
+
34
+ ## 2. Asking the human
35
+
36
+ One question per card. Pick the kind by what the human has to do:
37
+
38
+ | Need | Command |
39
+ |--|--|
40
+ | A choice | `parawatch ask -p <project> "<the question>" -o "<option>" -o "<option>" -r <n> -b "<why it matters now>"` |
41
+ | An open answer | `parawatch ask -p <project> "<the question>" -b "<context>"` |
42
+ | 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>"` |
43
+ | Something only they can do, and you are stuck until they do | `parawatch block -p <project> "<what to do>" -b "<exact steps>"` |
44
+ | Something only they can do, but nothing waits on it now | `parawatch todo -p <project> "<what to do>" -b "<exact steps, and by when>"` |
45
+
46
+ A good card:
47
+
48
+ - **The title is the question or the request**, readable alone in a list: "Ship the top-box ending?" not "Ending".
49
+ - **The body is two or three lines**: what changed, what it affects, what happens if they do not answer. It renders as Markdown.
50
+ - **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.
51
+ - **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.
52
+ - **Attach evidence** with absolute paths: screenshots, short videos, logs. `--open` takes what they should launch (an `.app`, a file, a URL).
53
+ - **Add `-t <task>`** when the project runs several tasks at once, so the human can tell them apart.
54
+ - **`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.
55
+ - **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.
56
+ - Do not open a card for something you can decide yourself, or that the project's own rules already settle.
57
+
58
+ ## 3. Waiting for the answer
59
+
60
+ Right after opening cards, keep exactly one wait armed for your project:
61
+
62
+ - **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.
63
+ - **Other runtimes**: check `parawatch inbox -p <project>` at the start of every turn.
64
+
65
+ 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.
66
+
67
+ ## 4. Handling an answer
68
+
69
+ 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`.
70
+
71
+ 1. Act on it. Read attached files; they are the human's evidence.
72
+ 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.
73
+ 3. Close the card with what you did: `parawatch close c4 -n "Top box shipped in #212"`.
74
+ 4. If more cards are open, arm the wait again.
75
+
76
+ To add information to a card without waking anyone, use `parawatch comment <card> "<text>"`. Never answer a card yourself: `answer` is the human's.
77
+
78
+ ## 5. Long jobs that leave no agent log
79
+
80
+ A CI run, a render, a benchmark, a deploy you are waiting on:
81
+
82
+ ```
83
+ id=$(parawatch run start -p <project> "PR 212 CI")
84
+ parawatch run note $id "UI tests running"
85
+ parawatch run end $id # or: parawatch run end $id --failed -n "UI test timed out"
86
+ ```
87
+
88
+ The run shows under your session. End it when it ends, or it keeps showing as working.
89
+
90
+ 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.
91
+
92
+ ## 6. Leads and workers
93
+
94
+ - 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.
95
+ - Tell workers in their brief what to return to you, so the lead can turn it into one clear card.
96
+ - Before ending a session, close cards you handled and end runs you started.
97
+
98
+ ## Quick reference
99
+
100
+ ```
101
+ parawatch status # everything: what needs the human, what is running
102
+ parawatch runs -p <project> --wide # agents with ids
103
+ parawatch show <card> # one card in full
104
+ parawatch cards -p <project> -a # include closed cards
105
+ ```