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 +79 -24
- package/dist/adapters/claudeCode.js +109 -4
- package/dist/bin/cli.js +153 -22
- package/dist/core/desktop.js +26 -2
- package/dist/core/digest.js +146 -0
- package/dist/core/format.js +97 -7
- package/dist/core/github.js +442 -0
- package/dist/core/integrate.js +11 -4
- package/dist/core/ops.js +151 -7
- package/dist/core/runs.js +148 -24
- package/dist/core/snapshot.js +50 -3
- package/dist/core/state.js +14 -2
- package/dist/core/store.js +11 -12
- package/dist/core/types.js +1 -0
- package/package.json +1 -1
- package/skills/parawatch/SKILL.md +54 -24
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
|
-
- **
|
|
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
|
|
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
|
|
21
|
-
c1 decide game/139
|
|
22
|
-
c2 try game/139
|
|
23
|
-
c3 blocked app
|
|
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
|
-
|
|
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
|
-
|
|
28
|
-
○ App Director
|
|
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
|
|
31
|
-
├ ● 139 shake round 2
|
|
32
|
-
├ ● Generate the level icons
|
|
33
|
-
└
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
77
|
-
out.action = oneLine(
|
|
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
|
|
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
|
|
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.
|
|
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
|
|
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
|
|
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
|
-
|
|
151
|
-
|
|
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
|
|
239
|
-
.
|
|
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
|
-
|
|
248
|
-
|
|
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,
|
|
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,
|
|
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,
|
|
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
|