parawatch 0.1.0 → 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/README.md CHANGED
@@ -11,26 +11,38 @@ It answers three questions:
11
11
 
12
12
  - **What needs me?** Decisions to make, builds or videos to try, things only you can do.
13
13
  - **What is running?** Every agent session, its subagents and its `codex exec` jobs, grouped by project, with what each one is doing right now.
14
- - **Did my answer reach the agent?** When you answer a card, the agent waiting on it wakes up. You do not go back to the chat to say "I decided".
14
+ - **What waits on someone else?** Pull requests waiting on a review, CI or a merge queue, each next to the session that owns it, with how long it has waited.
15
+ - **Did my answer reach the agent?** When you answer a card, or a review lands on its pull request, the agent waiting on it wakes up. You do not go back to the chat to say "I decided" or "a review came in".
15
16
 
16
- parawatch is a CLI first. Agents use the CLI, and so can you. There is no server and nothing leaves your machine. On top of the CLI, this repository has a [desktop app](apps/desktop/README.md) (the panel above, a menu bar count, a Dock badge and notifications).
17
+ parawatch is a CLI first. Agents use the CLI, and so can you. There is no server. Agent logs and cards never leave your machine; the only thing read from outside is your pull requests, through the GitHub CLI you have already signed in, and only when you have it. On top of the CLI, this repository has a [desktop app](apps/desktop/README.md) (the panel above, a menu bar count, a Dock badge and notifications).
17
18
 
18
19
  ```
19
20
  $ parawatch status
20
- NEEDS YOU 3
21
- c1 decide game/139 Which ending for the shake box? 12m
22
- c2 try game/139 Try build 139: does the glass read as glass? 4m
23
- c3 blocked app Install the iPad simulator runtime 1h
21
+ NEEDS YOU 4
22
+ c1 decide game/139 Which ending for the shake box? 12m
23
+ c2 try game/139 Try build 139: does the glass read as glass? 4m
24
+ c3 blocked app Install the iPad simulator runtime 1h
25
+ c7 decide app/PR 21 Merge PR 21 and post the 1.4 release note? 9m
24
26
 
25
- AGENTS 5 working
27
+ ASKED IN CHAT, NO CARD 1 (read from the log)
28
+ Pricing page 6m Publish it now, or wait for the new photos? (site)
29
+
30
+ WAITING ON OTHERS 3 · 1 stuck
31
+ marble-box#139 stuck in merge queue 9h Game Director Shake round 2 and the top-box ending
32
+ ear-trainer#22 review (mina) 5h Settings screen
33
+ ear-trainer#21 merge 20m Interval lesson screen Interval lessons and the count-in
34
+
35
+ AGENTS 4 working
26
36
  app
27
- ● Interval lesson screen 1m Bash: Build the app
28
- ○ App Director 24m PR 18 passed. Waiting for the simulator runtime.
37
+ ✓ Interval lesson screen (1 PR) 1m done · PR 21 is green and reviewed; waiting on the merge
38
+ ○ App Director 24m PR 18 passed. Waiting for the simulator runtime.
29
39
  game
30
- ● Game Director 1s Bash: Wait for answers
31
- ├ ● 139 shake round 2 1s Bash: Run tests
32
- ├ ● Generate the level icons 2m exec: python3 make_icons.py
33
- └ ● Glass shader pass 9m Bash: Capture the tank
40
+ ● Game Director (1 PR) 0s Bash: Wait for answers
41
+ ├ ● 139 shake round 2 0s Bash: Run tests
42
+ ├ ● Generate the level icons 2m exec: python3 make_icons.py
43
+ └ ! Overnight physics benchmark 24m stalled · no sign of life for 24m · scene 5 of 8
44
+ site
45
+ ○ Pricing page 6m asks: Publish it now, or wait for the new photos?
34
46
  ```
35
47
 
36
48
  One line per item, always inside the terminal width. Finished subagents fold into a count after five minutes (`parawatch runs --all` shows them). `--wide` adds the runtime and ids. Colors follow `NO_COLOR` and `FORCE_COLOR`.
@@ -69,9 +81,11 @@ Node 20 or newer.
69
81
 
70
82
  | | |
71
83
  |--|--|
72
- | **Project** | A name and one or more paths. Git worktrees of a path belong to the project too. |
84
+ | **Project** | A name, and optionally one or more paths. Git worktrees of a path belong to the project too. |
85
+ | **Claim** | An agent session saying what it works on: a project, a task (a ticket key, a pull request, a short name), a title. It is the main way runs are grouped; guesses from the log are the fallback. A claim can also say the work is done. |
73
86
  | **Card** | Something that needs the human. Four kinds: `decide` (pick an option or write an answer), `try` (open a build, watch a video, look at a screen), `block` (only the human can do it and the agent is stuck until they do: install, sudo, sign in), `todo` (only the human can do it, but nothing waits on it now: check a bill, register a company). A card remembers the session that opened it, and `--since` keeps when the matter first came up when it is older than the card. |
74
- | **Run** | An agent at work. Most runs are *observed*: parawatch reads the agent's own log. Jobs that leave no log (a bake, a benchmark) can be *declared* with `parawatch run start`. |
87
+ | **Run** | An agent at work. Most runs are *observed*: parawatch reads the agent's own log. Jobs that leave no log (a bake, a benchmark) can be *declared* with `parawatch run start`, with a heartbeat if they can hang silently. |
88
+ | **Pull request** | Read through `gh`. Linked to the session that owns it, so its news wakes that session. |
75
89
  | **Lead** | A role, not a concept of its own. Claim a session with `--role lead` and it shows at the top of its project with its children under it. |
76
90
 
77
91
  ## For the human
@@ -86,17 +100,21 @@ parawatch answer c5 -m "glass is too dark" --attach ~/Desktop/shot.png
86
100
  parawatch runs --since 12 # agents seen in the last 12 hours
87
101
  parawatch runs -p game --all # include finished ones
88
102
  parawatch runs --wide # with runtime and ids
103
+ parawatch runs --every # each run of a scheduled task, not one row per task
104
+ parawatch pr list # open pull requests: what each waits on, since when, whose it is
105
+ parawatch goto api#88 # open the session that owns a pull request
106
+ parawatch digest # what each session did today (--date, --json)
89
107
  ```
90
108
 
91
- Register projects once:
109
+ Agents say what they work on themselves (`parawatch claim`, below), so nothing has to be registered. Registering a project's paths groups the sessions that start inside it even when they do not claim:
92
110
 
93
111
  ```
94
112
  parawatch project add game ~/dev/game
95
113
  parawatch project add app ~/dev/app ~/notes/app
96
- parawatch claim 811a9612 -p game --role lead --title "Game Director"
114
+ parawatch claim 811a9612 -p game --role lead --title "Game Director" # claim a session for it
97
115
  ```
98
116
 
99
- `claim` is for sessions that do not start inside a project path (an agent run from a notes folder, a scratch directory). Children of a claimed session follow it.
117
+ Children of a claimed session follow it.
100
118
 
101
119
  ## For agents
102
120
 
@@ -109,18 +127,27 @@ parawatch init -f AGENTS.md -p game # or CLAUDE.md
109
127
  It writes a short block between `<!-- parawatch:begin -->` and `<!-- parawatch:end -->` and replaces it in place on later runs. The protocol in short:
110
128
 
111
129
  ```
130
+ parawatch claim -p api -t ABC-1234 --title "Fix the login redirect" # as soon as the work is clear
131
+ parawatch pr link https://github.com/acme/api/pull/88 # when it opens a pull request
112
132
  parawatch ask -p game "Which ending?" -o "Side tank" -o "Top box" -r 2 -b "two or three lines"
133
+ parawatch ask -p api "Merge PR 88?" --action "gh pr merge 88 --squash" --preview note.md # an approval
113
134
  parawatch try -p game "Try build 139" --open /path/Game.app --look "does the glass read?"
114
135
  parawatch block -p app "Install the iPad simulator runtime"
115
136
  parawatch todo -p game "Check the GitHub bill" --since 2026-10-01
116
137
  parawatch wait -p game # run in the background; exits when the human answers
117
138
  parawatch comment c4 "baking the top box now" # a note on the card; does not wake anyone
118
139
  parawatch close c4 -n "used the top box"
140
+ parawatch run start -p game "overnight bench" --heartbeat 600 --log bench.log # a job with no agent log
141
+ parawatch claim --done "Merged #88; login works on staging" # when the work is finished
119
142
  ```
120
143
 
144
+ An agent claims the project the work is for, not the folder it runs in, and claims again when it moves to other work. A claim of `--kind scheduled` folds the runs of a schedule into one row. `--done` marks the session finished until someone writes to it again, and its line goes into the digest.
145
+
146
+ An approval (`ask --action`) shows the exact command and the text it sends, with Do it and Don't, so the human can say yes without opening the session.
147
+
121
148
  `parawatch edit <card>` changes a card after the fact: its kind, wording, task, who asked or `--since`. `parawatch goto <card>` opens the session that asked in the Claude desktop app (`--copy` also puts a line about the card on the clipboard, to paste there); sessions outside the desktop app get the `claude --resume` command instead.
122
149
 
123
- `wait` prints the answers and exits with code 0. With `--timeout <seconds>` it exits with code 2 when nothing came. An answer given before `wait` starts is not lost: each consumer has its own cursor, and `wait` returns everything after it.
150
+ `wait` prints the answers, and news on the project's linked pull requests, and exits with code 0. With `--timeout <seconds>` it exits with code 2 when nothing came. An answer given before `wait` starts is not lost: each consumer has its own cursor, and `wait` returns everything after it. While a `wait` runs, cards say an agent is waiting; when none does, an answered card says the answer is kept until the agent next checks.
124
151
 
125
152
  ### Agent skill
126
153
 
@@ -149,6 +176,32 @@ The hook only peeks. The agent's own `wait` or `inbox` moves its cursor.
149
176
 
150
177
  Anything that can run a command and notice when it exits can use `wait`. `parawatch inbox` returns at once (add `--peek` to leave the cursor where it is), for runtimes that poll.
151
178
 
179
+ ## Pull requests
180
+
181
+ When the [GitHub CLI](https://cli.github.com) is installed and signed in, parawatch reads your open pull requests and the ones asking for your review, on every host `gh` knows (GitHub Enterprise included). It never holds a token of its own. Without `gh`, all of this stays off.
182
+
183
+ - **Whose it is.** A session links a pull request with `parawatch pr link <url>`. One it opened with `gh pr create` is linked from its log on its own.
184
+ - **What it waits on.** Review (and from whom), CI, a failed check, requested changes, a conflict, the merge queue, auto-merge, or the merge itself, and since when. Past a limit per state it shows as stuck.
185
+ - **News wakes the owner.** New reviews, approvals, requested changes, comments, CI results, conflicts, leaving the merge queue without merging, and the merge arrive at the owning project's `wait` like an answer. The desktop app notifies what keeps a pull request from landing, and the landing.
186
+ - **Other sources.** A merge bot or a ticket system can record news the same way: `parawatch pr event <url> queue_removed -m "batch 412 failed"`.
187
+
188
+ GitHub is read when its last reading is older than the interval: by `status`, by a running `wait`, and by the desktop app. `parawatch pr sync` reads it now. Pull requests nobody touched for 60 days are left out unless a session owns them.
189
+
190
+ `config.json` in the store folder changes the defaults (every key is optional):
191
+
192
+ ```json
193
+ {
194
+ "github": {
195
+ "enabled": true,
196
+ "intervalMinutes": 5,
197
+ "maxAgeDays": 60,
198
+ "stallHours": { "review": 24, "CI": 2, "merge queue": 6, "conflict": 4 }
199
+ }
200
+ }
201
+ ```
202
+
203
+ `PARAWATCH_GITHUB=0` turns it off for one command.
204
+
152
205
  ## Observed agents
153
206
 
154
207
  parawatch reads agent logs. It never writes to them and never sends them anywhere.
@@ -158,9 +211,11 @@ parawatch reads agent logs. It never writes to them and never sends them anywher
158
211
  | Claude Code | `~/.claude/projects/*/<session>.jsonl` and `<session>/subagents/` (or `$CLAUDE_CONFIG_DIR`) | sessions with their title, subagents with their description and model, the tool in flight |
159
212
  | Codex | `~/.codex/sessions/` (or `$CODEX_HOME`) | `codex exec` jobs and interactive sessions with their prompt and last tool call; jobs started from a Claude Code session show under it |
160
213
 
161
- States: `●` working, `◐` waiting, `○` idle, `✓` done, `!` stalled (quiet longer than its tool's timeout allows), `✗` failed.
214
+ States: `●` working, `◐` waiting, `○` idle, `✓` done (a finished subagent, or a session that claimed `--done`), `!` stalled (quiet longer than its tool's timeout allows, or a declared job past its heartbeat), `✗` failed.
215
+
216
+ A run belongs to a project by, in order: a claim; its working directory, unless it only edits files of another project; its parent's project; the project whose files it edits most; a project path named in its first prompt. Runs that match nothing show under `other`. `--wide` marks the guesses (`~edits`, `~prompt`).
162
217
 
163
- Runs that match no registered project show under `other`. A run belongs to a project by, in order: a claim, its working directory, its parent's project, a project path named in its first prompt.
218
+ When a session's turn ends with a question for the human and no card, parawatch lists it under ASKED IN CHAT. It reads the Claude desktop app's end-of-turn summary when there is one, else a question at the end of the last message. It is a guess, so agents are told to put questions on cards.
164
219
 
165
220
  ### Adding a runtime
166
221
 
@@ -185,21 +240,21 @@ It reads only, reads only files touched since `sinceMs`, and reads only the head
185
240
  Anything else can build on the same two commands:
186
241
 
187
242
  ```
188
- parawatch status --json # one snapshot: projects, cards, runs
243
+ parawatch status --json # one snapshot: projects, cards, runs, pull requests, running waits
189
244
  parawatch watch # every new event as one JSON line
190
245
  parawatch watch --from 120 # starting after event 120
191
246
  ```
192
247
 
193
248
  ## Storage
194
249
 
195
- Everything lives in one append-only file, `~/.local/state/parawatch/events.jsonl` (`$PARAWATCH_HOME`, or `$XDG_STATE_HOME/parawatch`). Writers take a short lock, so several agents can write at once. Cursors live next to it in `cursors/`. Delete the folder to start over.
250
+ Everything lives in one append-only file, `~/.local/state/parawatch/events.jsonl` (`$PARAWATCH_HOME`, or `$XDG_STATE_HOME/parawatch`). Writers take a short lock, so several agents can write at once. Next to it: cursors in `cursors/`, running waits in `listeners/`, the last GitHub reading in `github.json`, and the optional `config.json`. Delete the folder to start over.
196
251
 
197
252
  Desktop notifications for new cards use `osascript` on macOS and `notify-send` on Linux. `PARAWATCH_NOTIFY=0` turns them off. While the desktop app runs it notifies instead, and the CLI stays quiet. To-dos never notify.
198
253
 
199
254
  ## Not goals
200
255
 
201
256
  - Starting or steering agents. Use whatever you already use; parawatch only watches and carries answers back.
202
- - A shared team tracker. It is for one person and their agents, on one machine.
257
+ - A shared team tracker. It is for one person and their agents, on one machine. It shows other people only as the reviewers your pull requests wait on.
203
258
  - Replacing your issue tracker. Cards are short-lived questions, not the backlog.
204
259
 
205
260
  ## License
@@ -40,10 +40,77 @@ function promptText(rec) {
40
40
  return undefined;
41
41
  return text;
42
42
  }
43
+ const EDIT_TOOLS = new Set(["Edit", "Write", "MultiEdit", "NotebookEdit"]);
44
+ const DIR_IN_COMMAND = /(?:^|[;&|]\s*|\s)(?:cd|git\s+-C)\s+("[^"]+"|'[^']+'|[^\s;&|]+)/g;
45
+ const PR_URL = /https?:\/\/[^\s"'<>()]+\/pull\/\d+/g;
46
+ /** The desktop app's end-of-turn tool: a recap, maybe a question for the human. */
47
+ const WRAP_UP = /(^|__)wrap_up$/;
48
+ function resultText(b) {
49
+ const c = b.content;
50
+ if (typeof c === "string")
51
+ return c;
52
+ if (Array.isArray(c))
53
+ return c.map((x) => (isObj(x) && typeof x.text === "string" ? x.text : "")).join("\n");
54
+ return "";
55
+ }
56
+ function unquote(s) {
57
+ return s.replace(/^["']|["']$/g, "");
58
+ }
59
+ /**
60
+ * What a log shows about where the work happens and what it produced: folders of edited files and
61
+ * of `cd`/`git -C` targets (absolute paths only), and pull requests opened with `gh pr create`.
62
+ */
63
+ export function scanWork(recs) {
64
+ const touched = [];
65
+ const add = (dir) => {
66
+ if (!path.isAbsolute(dir))
67
+ return;
68
+ const i = touched.indexOf(dir);
69
+ if (i >= 0)
70
+ touched.splice(i, 1);
71
+ touched.push(dir);
72
+ };
73
+ const prCalls = new Set();
74
+ const prs = [];
75
+ for (const r of recs) {
76
+ if (!isObj(r) || !isConversation(r))
77
+ continue;
78
+ for (const b of blocks(r)) {
79
+ if (b.type === "tool_use") {
80
+ const inp = b.input ?? {};
81
+ if (EDIT_TOOLS.has(String(b.name))) {
82
+ const f = str(inp.file_path) || str(inp.notebook_path);
83
+ if (f)
84
+ add(path.dirname(f));
85
+ }
86
+ else if (b.name === "Bash") {
87
+ const cmd = str(inp.command) ?? "";
88
+ for (const m of cmd.matchAll(DIR_IN_COMMAND))
89
+ add(unquote(m[1]));
90
+ if (/\bgh\s+pr\s+create\b/.test(cmd) && b.id)
91
+ prCalls.add(b.id);
92
+ }
93
+ }
94
+ else if (b.type === "tool_result" && b.tool_use_id && prCalls.has(b.tool_use_id)) {
95
+ for (const m of resultText(b).matchAll(PR_URL))
96
+ if (!prs.includes(m[0]))
97
+ prs.push(m[0]);
98
+ }
99
+ }
100
+ }
101
+ return { touched: touched.slice(-50), createdPrs: prs };
102
+ }
103
+ /** The last line of an agent's closing message, when it is a question. */
104
+ function trailingQuestion(text) {
105
+ const lines = text.split("\n").map((l) => l.trim()).filter(Boolean);
106
+ const last = lines[lines.length - 1];
107
+ return last && /[??]\**$/.test(last) ? last : undefined;
108
+ }
43
109
  /** Work out what a Claude Code transcript is doing from its last records. */
44
110
  export function readClaudeTail(recs, kind) {
45
111
  const out = { state: "unknown" };
46
112
  let lastConv;
113
+ let wrapUp;
47
114
  for (const r of recs) {
48
115
  if (!isObj(r))
49
116
  continue;
@@ -58,6 +125,15 @@ export function readClaudeTail(recs, kind) {
58
125
  out.title = str(r.agentName);
59
126
  if (isConversation(r))
60
127
  lastConv = r;
128
+ if (promptText(r)) {
129
+ out.lastPromptAt = ts ?? out.lastPromptAt;
130
+ wrapUp = undefined; // a new turn began
131
+ }
132
+ if (r.type === "assistant") {
133
+ const w = blocks(r).find((b) => b.type === "tool_use" && WRAP_UP.test(String(b.name)));
134
+ if (w)
135
+ wrapUp = { ...(w.input ?? {}), at: ts };
136
+ }
61
137
  }
62
138
  if (!lastConv)
63
139
  return out;
@@ -73,8 +149,22 @@ export function readClaudeTail(recs, kind) {
73
149
  }
74
150
  else if (bs.some((b) => b.type === "text" && b.text?.trim())) {
75
151
  out.state = kind === "subagent" ? "done" : "idle";
76
- const text = bs.filter((b) => b.type === "text").map((b) => b.text ?? "").join(" ");
77
- out.action = oneLine(text, 100);
152
+ const full = bs.filter((b) => b.type === "text").map((b) => b.text ?? "").join("\n");
153
+ out.action = oneLine(full, 100);
154
+ if (kind === "session") {
155
+ const recap = wrapUp && str(wrapUp.recap);
156
+ if (recap)
157
+ out.recap = oneLine(recap, 300);
158
+ const ask = wrapUp && str(wrapUp.ask);
159
+ const blocked = wrapUp?.blocked === true;
160
+ if (ask || blocked)
161
+ out.asks = { text: oneLine(ask || recap || full, 200), via: "wrap_up", blocked: blocked || undefined, at: str(wrapUp.at) };
162
+ else if (!wrapUp) {
163
+ const q = trailingQuestion(full);
164
+ if (q)
165
+ out.asks = { text: oneLine(q.replace(/\*\*/g, ""), 200), via: "text", at: out.lastActivity };
166
+ }
167
+ }
78
168
  }
79
169
  else {
80
170
  out.state = "working";
@@ -104,6 +194,10 @@ function firstPrompt(file) {
104
194
  }
105
195
  return out;
106
196
  }
197
+ /** A log small enough that its tail already holds the whole file. */
198
+ function sameFile(file) {
199
+ return fs.statSync(file).size <= TAIL_BYTES;
200
+ }
107
201
  function readMeta(file) {
108
202
  try {
109
203
  const data = JSON.parse(fs.readFileSync(file.replace(/\.jsonl$/, ".meta.json"), "utf8"));
@@ -177,9 +271,16 @@ function readSession(file, now) {
177
271
  const base = { id, runtime: "claude-code", kind: "session", title: id.slice(0, 8), state: "unknown", source: file };
178
272
  try {
179
273
  const head = firstPrompt(file);
180
- const tail = readClaudeTail(readTailLines(file, TAIL_BYTES), "session");
274
+ const tailRecs = readTailLines(file, TAIL_BYTES);
275
+ const tail = readClaudeTail(tailRecs, "session");
276
+ const work = scanWork(sameFile(file) ? tailRecs : [...readHeadLines(file, HEAD_BYTES), ...tailRecs]);
181
277
  const run = {
182
278
  ...base,
279
+ lastPromptAt: tail.lastPromptAt,
280
+ asks: tail.asks,
281
+ recap: tail.recap,
282
+ touched: work.touched.length ? work.touched : undefined,
283
+ createdPrs: work.createdPrs.length ? work.createdPrs : undefined,
183
284
  title: tail.title || (head.prompt ? oneLine(head.prompt, 60) : base.title),
184
285
  cwd: tail.cwd || head.cwd,
185
286
  startedAt: head.startedAt,
@@ -203,9 +304,13 @@ function readSubagent(file, now) {
203
304
  try {
204
305
  const meta = readMeta(file);
205
306
  const head = firstPrompt(file);
206
- const tail = readClaudeTail(readTailLines(file, TAIL_BYTES), "subagent");
307
+ const tailRecs = readTailLines(file, TAIL_BYTES);
308
+ const tail = readClaudeTail(tailRecs, "subagent");
309
+ const work = scanWork(sameFile(file) ? tailRecs : [...readHeadLines(file, HEAD_BYTES), ...tailRecs]);
207
310
  const run = {
208
311
  ...base,
312
+ touched: work.touched.length ? work.touched : undefined,
313
+ createdPrs: work.createdPrs.length ? work.createdPrs : undefined,
209
314
  title: str(meta.description) || (head.prompt ? oneLine(head.prompt, 60) : base.title),
210
315
  model: str(meta.model),
211
316
  cwd: tail.cwd || head.cwd,
package/dist/bin/cli.js CHANGED
@@ -2,12 +2,14 @@
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, claimSession, closeCard, commentCard, consumerName, editCard, endRun, noteRun, openCard, readInbox, removeProject, startRun, waitInbox, } from "../core/ops.js";
5
+ import { UserError, addProject, answerCard, beatRun, claimSession, closeCard, commentCard, consumerName, editCard, endRun, linkPr, listeners, listenersFor, noteRun, openCard, prEvent, readInbox, removeProject, startListening, startRun, unlinkPr, waitInbox, } from "../core/ops.js";
6
+ import { githubConfig, readCache, resolvePrRef, syncGithub, prViews } from "../core/github.js";
7
+ import { digest, digestText, localDate } from "../core/digest.js";
6
8
  import { readEvents } from "../core/store.js";
7
9
  import { followEvents } from "../core/follow.js";
8
10
  import { fold } from "../core/state.js";
9
11
  import { collectRuns } from "../core/runs.js";
10
- import { cardDetail, cardLine, cardRows, inboxLine, runsTree, view } from "../core/format.js";
12
+ import { askRows, cardDetail, cardLine, cardRows, inboxLine, prRows, runsTree, view } from "../core/format.js";
11
13
  import { hookContext, notify, writeInstructions } from "../core/integrate.js";
12
14
  import { installSkill, skillSource, skillTargets } from "../core/skill.js";
13
15
  import { cardContextLine, resolveGoto } from "../core/desktop.js";
@@ -29,7 +31,17 @@ const program = new Command();
29
31
  program
30
32
  .name("parawatch")
31
33
  .description("See what your agents are doing, what needs you, and wake the right agent when you answer.")
32
- .version("0.1.0");
34
+ .version("0.2.0");
35
+ /** Read GitHub when the last reading is old; never fails the command. */
36
+ async function refreshGithub(force = false) {
37
+ try {
38
+ await syncGithub({ state: fold(readEvents()), force });
39
+ }
40
+ catch (err) {
41
+ if (force)
42
+ throw err;
43
+ }
44
+ }
33
45
  // ------------------------------------------------------------------ cards
34
46
  function cardCommand(kind, name, summary) {
35
47
  return program
@@ -64,10 +76,12 @@ function finishCard(input, json) {
64
76
  else
65
77
  process.stdout.write(`${card.id}\n`);
66
78
  }
67
- cardCommand("decide", "ask", "ask the human to decide")
79
+ cardCommand("decide", "ask", "ask the human to decide, or to approve a prepared action")
68
80
  .option("-o, --option <text>", "a choice (repeatable)", collect)
69
81
  .option("-r, --recommend <n>", "the recommended choice, 1-based", int)
70
- .action((title, o) => finishCard({ kind: "decide", title, ...common(o), options: o.option, recommend: o.recommend }, o.json));
82
+ .option("--action <text>", "the exact command or step that runs on yes (options default to Do it / Don't)")
83
+ .option("--preview <file>", "a file with the text the action sends or changes (a message, a diff)")
84
+ .action((title, o) => finishCard({ kind: "decide", title, ...common(o), options: o.option, recommend: o.recommend, action: o.action, preview: o.preview }, o.json));
71
85
  cardCommand("try", "try", "ask the human to try a build, watch a video or look at a screen")
72
86
  .option("--open <path>", "app, file or URL to open (repeatable)", collect)
73
87
  .option("-l, --look <text>", "what to look at")
@@ -96,7 +110,7 @@ program
96
110
  });
97
111
  program
98
112
  .command("goto <target>")
99
- .description("open the agent session behind a card (or a session id) in the Claude desktop app")
113
+ .description("open the agent session behind a card, a pull request (URL, owner/repo#N, repo#N) or a session id in the Claude desktop app")
100
114
  .option("--copy", "also put a line about the card on the clipboard, to paste into that session")
101
115
  .option("--print", "print the link instead of opening it")
102
116
  .action((target, o) => {
@@ -145,10 +159,17 @@ program
145
159
  const card = fold(readEvents()).cards.get(id);
146
160
  if (!card)
147
161
  throw new UserError(`no card "${id}"`);
148
- if (o.json)
162
+ if (o.json) {
149
163
  print(card);
150
- else
151
- process.stdout.write(cardDetail(card) + "\n");
164
+ return;
165
+ }
166
+ const waits = card.status === "closed" ? [] : listenersFor(card, listeners());
167
+ const delivery = card.status === "closed"
168
+ ? ""
169
+ : waits.length
170
+ ? `\nDelivery: an agent is waiting (${waits.map((l) => l.consumer).join(", ")}); an answer wakes it now.`
171
+ : "\nDelivery: no agent is waiting; an answer is kept until the agent next checks (its next turn, or its next wait).";
172
+ process.stdout.write(cardDetail(card) + delivery + "\n");
152
173
  });
153
174
  program
154
175
  .command("cards")
@@ -174,7 +195,14 @@ function inboxOptions(c) {
174
195
  inboxOptions(program.command("wait").description("block until the human answers, print the answers and exit"))
175
196
  .option("--timeout <seconds>", "give up after this long (exit code 2); 0 waits forever", int, 0)
176
197
  .action(async (o) => {
198
+ startListening({ consumer: consumerName(o), project: o.project, card: o.card, session: currentSession() });
199
+ // while waiting, keep GitHub fresh so news on linked pull requests wakes this wait
200
+ const tick = () => void refreshGithub();
201
+ tick();
202
+ const timer = setInterval(tick, 60_000);
203
+ timer.unref();
177
204
  const items = await waitInbox(consumerName(o), { project: o.project, card: o.card }, o.timeout * 1000);
205
+ clearInterval(timer);
178
206
  if (!items.length) {
179
207
  process.exitCode = 2;
180
208
  return;
@@ -209,10 +237,11 @@ program
209
237
  .option("--since <hours>", "look at logs touched in the last N hours", int, 6)
210
238
  .option("-a, --all", "include runs that ended a while ago")
211
239
  .option("--runtime <name>", "only this adapter (repeatable)", collect)
240
+ .option("--every", "list each run of a scheduled task instead of one row per task")
212
241
  .option("-w, --wide", "show runtime and ids")
213
242
  .option("--json", "JSON output")
214
243
  .action((o) => {
215
- const runs = collectRuns(fold(readEvents()), { sinceHours: o.since, project: o.project, includeEnded: o.all, runtimes: o.runtime });
244
+ const runs = collectRuns(fold(readEvents()), { sinceHours: o.since, project: o.project, includeEnded: o.all, runtimes: o.runtime, keepRepeats: o.every });
216
245
  if (o.json)
217
246
  print({ v: SCHEMA_VERSION, runs });
218
247
  else
@@ -224,10 +253,13 @@ run
224
253
  .requiredOption("-p, --project <name>", "project")
225
254
  .option("-t, --task <task>", "task")
226
255
  .option("--parent <id>", "the run that started this one (defaults to the calling session)")
256
+ .option("--heartbeat <seconds>", "count the run as stalled after this long without a beat, a note or log output", int)
257
+ .option("--log <file>", "a log file the job writes; each change counts as a beat")
227
258
  .action((title, o) => {
228
- process.stdout.write(startRun({ title, project: o.project, task: o.task, parent: o.parent ?? currentSession() }) + "\n");
259
+ process.stdout.write(startRun({ title, project: o.project, task: o.task, parent: o.parent ?? currentSession(), heartbeat: o.heartbeat, log: o.log }) + "\n");
229
260
  });
230
- run.command("note <id> <text>").action((id, text) => noteRun(id, text));
261
+ run.command("note <id> <text>").description("say how it is going (also counts as a beat)").action((id, text) => noteRun(id, text));
262
+ run.command("beat <id>").description("say it is still alive").action((id) => beatRun(id));
231
263
  run
232
264
  .command("end <id>")
233
265
  .option("--failed", "it failed")
@@ -235,53 +267,152 @@ run
235
267
  .action((id, o) => endRun(id, o.failed ? "failed" : "done", o.note));
236
268
  program
237
269
  .command("claim [session]")
238
- .description("say which project an agent session belongs to (id prefix, 6+ characters; defaults to the calling session)")
239
- .requiredOption("-p, --project <name>", "project")
240
- .option("-t, --task <task>", "task")
270
+ .description("say which project and task an agent session works on (id prefix, 6+ characters; defaults to the calling session)")
271
+ .option("-p, --project <name>", "project (may be left out once the session has claimed one)")
272
+ .option("-t, --task <task>", "task: a ticket key, a PR, a short name")
241
273
  .option("--role <role>", "for example lead or worker")
242
274
  .option("--title <title>", "name to show")
275
+ .option("--kind <kind>", "'scheduled' for a session a schedule starts again and again")
276
+ .option("--done <summary>", "the work is finished; one line on how it ended")
243
277
  .action((session, o) => {
244
278
  const id = session ?? currentSession();
245
279
  if (!id)
246
280
  throw new UserError("no session id given and none found in the environment (CLAUDE_CODE_SESSION_ID, CODEX_THREAD_ID)");
247
- claimSession({ session: id, project: o.project, task: o.task, role: o.role, title: o.title });
248
- process.stdout.write(`${id.slice(0, 8)} → ${o.project}${o.role ? ` (${o.role})` : ""}\n`);
281
+ if (o.kind !== undefined && o.kind !== "scheduled")
282
+ throw new UserError("--kind can only be 'scheduled'");
283
+ const c = claimSession({ session: id, project: o.project, task: o.task, role: o.role, title: o.title, kind: o.kind, done: o.done });
284
+ process.stdout.write(`${id.slice(0, 8)} → ${c.project}${c.task ? ` / ${c.task}` : ""}${c.role ? ` (${c.role})` : ""}${c.done ? " · done" : ""}\n`);
285
+ });
286
+ // ------------------------------------------------------------------ pull requests
287
+ const pr = program.command("pr").description("pull requests and what they wait on (reads GitHub through gh)");
288
+ pr.command("link <pr>")
289
+ .description("say this pull request belongs to the calling session: news on it wakes the session's wait")
290
+ .option("-p, --project <name>", "project (defaults to the session's claim)")
291
+ .option("-t, --task <task>", "task (defaults to the session's claim)")
292
+ .option("--session <id>", "the owning session (defaults to the calling session)")
293
+ .action((ref, o) => {
294
+ const r = resolvePrRef(ref);
295
+ const session = o.session ?? currentSession();
296
+ linkPr({ pr: r, session, project: o.project, task: o.task });
297
+ process.stdout.write(`${r.repo}#${r.number} → ${session ? session.slice(0, 8) : o.project ?? "no session"}\n`);
298
+ });
299
+ pr.command("unlink <pr>")
300
+ .description("forget which session owns a pull request")
301
+ .action((ref) => unlinkPr(resolvePrRef(ref).key));
302
+ pr.command("list")
303
+ .description("open pull requests you wrote or are asked to review, with what each waits on")
304
+ .option("-p, --project <name>", "only this project")
305
+ .option("--offline", "do not read GitHub, show the last reading")
306
+ .option("--json", "JSON output")
307
+ .action(async (o) => {
308
+ if (!o.offline)
309
+ await refreshGithub();
310
+ const state = fold(readEvents());
311
+ const runs = collectRuns(state, { sinceHours: 72 });
312
+ let prs = prViews(readCache(), state, runs);
313
+ if (o.project)
314
+ prs = prs.filter((p) => p.project === o.project);
315
+ if (o.json)
316
+ print({ v: SCHEMA_VERSION, prs });
317
+ else
318
+ process.stdout.write(prs.length ? prRows(prs, view()).join("\n") + "\n" : githubNote() ?? "No open pull requests.\n");
319
+ });
320
+ pr.command("sync")
321
+ .description("read GitHub now")
322
+ .action(async () => {
323
+ const c = await syncGithub({ state: fold(readEvents()), force: true });
324
+ if (c.error)
325
+ process.stderr.write(`parawatch: ${c.error}\n`);
326
+ process.stdout.write(`${c.prs.filter((p) => p.state === "open").length} open pull requests on ${(c.hosts ?? []).join(", ") || "no host"}\n`);
327
+ });
328
+ pr.command("event <pr> <change>")
329
+ .description("record news another tool saw on a pull request (a merge bot, a ticket system); it wakes the owner like GitHub's own")
330
+ .option("-m, --text <text>", "what happened")
331
+ .option("--by <who>", "who did it")
332
+ .action((ref, change, o) => prEvent(resolvePrRef(ref), change, { text: o.text, by: o.by }));
333
+ function githubNote() {
334
+ if (!githubConfig().enabled)
335
+ return "GitHub is turned off in config.json.\n";
336
+ const c = readCache();
337
+ if (c.hosts && !c.hosts.length)
338
+ return "GitHub: gh is not installed or not signed in (gh auth login).\n";
339
+ return undefined;
340
+ }
341
+ program
342
+ .command("digest")
343
+ .description("what each session did on a day: work claimed, cards, jobs, pull request news, how it ended")
344
+ .option("-d, --date <YYYY-MM-DD>", "the day (default: today)")
345
+ .option("--json", "JSON output")
346
+ .action((o) => {
347
+ const d = digest(o.date ?? localDate());
348
+ if (o.json)
349
+ print({ v: SCHEMA_VERSION, ...d });
350
+ else
351
+ process.stdout.write(digestText(d) + "\n");
249
352
  });
250
353
  // ------------------------------------------------------------------ status
251
354
  program
252
355
  .command("status")
253
- .description("everything at a glance: what needs you, then what is running")
356
+ .description("everything at a glance: what needs you, what waits on others, then what is running")
254
357
  .option("-p, --project <name>", "only this project")
255
358
  .option("--since <hours>", "look at logs touched in the last N hours", int, 6)
359
+ .option("--offline", "do not read GitHub, show the last reading")
256
360
  .option("-w, --wide", "show runtime and ids")
257
361
  .option("--json", "JSON output")
258
- .action((o) => {
362
+ .action(async (o) => {
363
+ if (!o.offline)
364
+ await refreshGithub();
259
365
  const snap = snapshot({ sinceHours: o.since, project: o.project });
260
366
  if (o.json) {
261
367
  print(snap);
262
368
  return;
263
369
  }
264
370
  const { cards, runs } = snap;
371
+ const prs = snap.prs ?? [];
265
372
  const v = view({ wide: o.wide });
373
+ const now = Date.now();
266
374
  const head = (title, n, ...extra) => v.color ? `\x1b[1m${title}\x1b[0m \x1b[2m${n}${extra.join("")}\x1b[0m` : `${title} ${n}${extra.join("")}`;
267
375
  const open = cards.filter((c) => c.status === "open" && c.kind !== "todo");
268
376
  const todo = cards.filter((c) => c.status === "open" && c.kind === "todo");
269
377
  const answered = cards.filter((c) => c.status === "answered");
378
+ const asking = runs.filter((r) => r.asks);
379
+ const reviews = prs.filter((p) => p.reviewRequested && !p.mine);
380
+ const waiting = prs.filter((p) => !(p.reviewRequested && !p.mine));
381
+ const ls = snap.listeners ?? [];
270
382
  const out = [];
271
383
  out.push(head("NEEDS YOU", open.length || "nothing"));
272
- out.push(...cardRows(open, v, Date.now(), " "));
384
+ out.push(...cardRows(open, v, now, " "));
385
+ if (asking.length) {
386
+ out.push("", head("ASKED IN CHAT, NO CARD", asking.length, " (read from the log)"));
387
+ out.push(...askRows(asking, v, now, " "));
388
+ }
389
+ if (reviews.length) {
390
+ out.push("", head("REVIEWS ASKED OF YOU", reviews.length));
391
+ out.push(...prRows(reviews, v, now, " "));
392
+ }
273
393
  if (todo.length) {
274
394
  out.push("", head("TO DO, NOTHING WAITS ON IT", todo.length));
275
- out.push(...cardRows(todo, v, Date.now(), " "));
395
+ out.push(...cardRows(todo, v, now, " "));
276
396
  }
277
397
  if (answered.length) {
398
+ const notes = {};
399
+ for (const c of answered)
400
+ if (!listenersFor(c, ls).length)
401
+ notes[c.id] = "no agent waiting";
278
402
  out.push("", head("ANSWERED, WAITING FOR THE AGENT", answered.length));
279
- out.push(...cardRows(answered, v, Date.now(), " "));
403
+ out.push(...cardRows(answered, v, now, " ", notes));
404
+ }
405
+ if (waiting.length) {
406
+ const stuck = waiting.filter((p) => p.stalled).length;
407
+ out.push("", head("WAITING ON OTHERS", waiting.length, stuck ? ` · ${stuck} stuck` : ""));
408
+ out.push(...prRows(waiting, v, now, " "));
280
409
  }
281
410
  const working = runs.filter((r) => r.state === "working" || r.state === "waiting").length;
282
411
  out.push("", head("AGENTS", runs.length ? `${working} working` : "nothing running"));
283
412
  if (runs.length)
284
413
  out.push(runsTree(runs, v));
414
+ if (snap.github?.error && snap.github.enabled)
415
+ out.push("", v.color ? `\x1b[2mGitHub: ${snap.github.error}\x1b[0m` : `GitHub: ${snap.github.error}`);
285
416
  process.stdout.write(out.join("\n") + "\n");
286
417
  });
287
418
  // ------------------------------------------------------------------ skill