parawatch 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +261 -2
- package/dist/adapters/claudeCode.js +331 -0
- package/dist/adapters/codex.js +185 -0
- package/dist/adapters/index.js +18 -0
- package/dist/adapters/jsonl.js +144 -0
- package/dist/bin/cli.js +491 -0
- package/dist/core/desktop.js +102 -0
- package/dist/core/digest.js +146 -0
- package/dist/core/follow.js +70 -0
- package/dist/core/format.js +351 -0
- package/dist/core/github.js +442 -0
- package/dist/core/integrate.js +121 -0
- package/dist/core/ops.js +415 -0
- package/dist/core/runs.js +284 -0
- package/dist/core/skill.js +67 -0
- package/dist/core/snapshot.js +71 -0
- package/dist/core/state.js +107 -0
- package/dist/core/store.js +127 -0
- package/dist/core/types.js +6 -0
- package/package.json +57 -4
- package/skills/parawatch/SKILL.md +135 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 penspanic
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,262 @@
|
|
|
1
|
-
#
|
|
1
|
+
# parawatch
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
A control tower for people who run many AI coding agents at once, across several projects and several tasks inside each one.
|
|
4
|
+
|
|
5
|
+
<picture>
|
|
6
|
+
<source media="(prefers-color-scheme: dark)" srcset="docs/app-dark.png">
|
|
7
|
+
<img src="docs/app-light.png" alt="The parawatch desktop app: a chart of the last hour of agent activity per project, then the cards that need you, one with two screenshots and three options." width="560">
|
|
8
|
+
</picture>
|
|
9
|
+
|
|
10
|
+
It answers three questions:
|
|
11
|
+
|
|
12
|
+
- **What needs me?** Decisions to make, builds or videos to try, things only you can do.
|
|
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
|
+
- **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".
|
|
16
|
+
|
|
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).
|
|
18
|
+
|
|
19
|
+
```
|
|
20
|
+
$ parawatch status
|
|
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
|
|
26
|
+
|
|
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
|
|
36
|
+
app
|
|
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.
|
|
39
|
+
game
|
|
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?
|
|
46
|
+
```
|
|
47
|
+
|
|
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`.
|
|
49
|
+
|
|
50
|
+
## Install
|
|
51
|
+
|
|
52
|
+
There are three parts. Most people want all three.
|
|
53
|
+
|
|
54
|
+
**The CLI**, which agents and you use:
|
|
55
|
+
|
|
56
|
+
```
|
|
57
|
+
npm install -g parawatch
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
**The agent skill**, so Claude Code and Codex agents learn the protocol on their own:
|
|
61
|
+
|
|
62
|
+
```
|
|
63
|
+
parawatch skill install
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
It links [`skills/parawatch`](skills/parawatch/SKILL.md) into `~/.claude/skills` and `~/.codex/skills`. The installed skill is a link to the copy in the package, so updating parawatch updates the skill.
|
|
67
|
+
|
|
68
|
+
**The desktop app**, to see and answer cards without a terminal. On macOS, download the DMG from the [latest release](https://github.com/penspanic/parawatch/releases/latest): `arm64` for Apple silicon, `x64` for Intel. It is signed and notarized, so it opens without warnings. Elsewhere, [build it from source](apps/desktop/README.md#run-from-source).
|
|
69
|
+
|
|
70
|
+
### Platforms
|
|
71
|
+
|
|
72
|
+
| | macOS | Linux | Windows |
|
|
73
|
+
|--|--|--|--|
|
|
74
|
+
| CLI | used daily | tests pass in CI | not supported yet |
|
|
75
|
+
| Desktop app | used daily | untested | untested |
|
|
76
|
+
| Agent logs read | Claude Code, Codex | same | |
|
|
77
|
+
|
|
78
|
+
Node 20 or newer.
|
|
79
|
+
|
|
80
|
+
## Concepts
|
|
81
|
+
|
|
82
|
+
| | |
|
|
83
|
+
|--|--|
|
|
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. |
|
|
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. |
|
|
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. |
|
|
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. |
|
|
90
|
+
|
|
91
|
+
## For the human
|
|
92
|
+
|
|
93
|
+
```
|
|
94
|
+
parawatch status # what needs you, then what is running
|
|
95
|
+
parawatch cards # open and answered cards
|
|
96
|
+
parawatch show c4 # one card with its body, options and attachments
|
|
97
|
+
parawatch answer c4 -c 2 # pick option 2
|
|
98
|
+
parawatch answer c4 -m "fill it to the rim" # or just say something
|
|
99
|
+
parawatch answer c5 -m "glass is too dark" --attach ~/Desktop/shot.png
|
|
100
|
+
parawatch runs --since 12 # agents seen in the last 12 hours
|
|
101
|
+
parawatch runs -p game --all # include finished ones
|
|
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)
|
|
107
|
+
```
|
|
108
|
+
|
|
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:
|
|
110
|
+
|
|
111
|
+
```
|
|
112
|
+
parawatch project add game ~/dev/game
|
|
113
|
+
parawatch project add app ~/dev/app ~/notes/app
|
|
114
|
+
parawatch claim 811a9612 -p game --role lead --title "Game Director" # claim a session for it
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
Children of a claimed session follow it.
|
|
118
|
+
|
|
119
|
+
## For agents
|
|
120
|
+
|
|
121
|
+
Add the agent protocol to a project's instruction file:
|
|
122
|
+
|
|
123
|
+
```
|
|
124
|
+
parawatch init -f AGENTS.md -p game # or CLAUDE.md
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
It writes a short block between `<!-- parawatch:begin -->` and `<!-- parawatch:end -->` and replaces it in place on later runs. The protocol in short:
|
|
128
|
+
|
|
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
|
|
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
|
|
134
|
+
parawatch try -p game "Try build 139" --open /path/Game.app --look "does the glass read?"
|
|
135
|
+
parawatch block -p app "Install the iPad simulator runtime"
|
|
136
|
+
parawatch todo -p game "Check the GitHub bill" --since 2026-10-01
|
|
137
|
+
parawatch wait -p game # run in the background; exits when the human answers
|
|
138
|
+
parawatch comment c4 "baking the top box now" # a note on the card; does not wake anyone
|
|
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
|
|
142
|
+
```
|
|
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
|
+
|
|
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.
|
|
149
|
+
|
|
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.
|
|
151
|
+
|
|
152
|
+
### Agent skill
|
|
153
|
+
|
|
154
|
+
[`skills/parawatch/SKILL.md`](skills/parawatch/SKILL.md) teaches an agent the whole protocol: claiming its session, writing a good card, keeping one background wait armed, closing cards, declaring long jobs, and who asks the human when a lead runs workers. Install it with `parawatch skill install` (see [Install](#install)); `parawatch skill path` prints where it lives, for runtimes that load skills from elsewhere.
|
|
155
|
+
|
|
156
|
+
`claim`, `run start` and every card command pick up the calling session from `$CLAUDE_CODE_SESSION_ID` (or `$CODEX_THREAD_ID`), so an agent can put itself on the board without knowing its id.
|
|
157
|
+
|
|
158
|
+
### Waking a Claude Code session
|
|
159
|
+
|
|
160
|
+
Claude Code re-invokes a session when one of its background commands exits. So an agent that runs `parawatch wait -p <project>` as a background command is woken by your answer, with the answer as the command's output.
|
|
161
|
+
|
|
162
|
+
As a backstop, the hook adds any unhandled answers to the next turn of a session in that project:
|
|
163
|
+
|
|
164
|
+
```json
|
|
165
|
+
{
|
|
166
|
+
"hooks": {
|
|
167
|
+
"UserPromptSubmit": [{ "hooks": [{ "type": "command", "command": "parawatch hook claude-code" }] }],
|
|
168
|
+
"SessionStart": [{ "hooks": [{ "type": "command", "command": "parawatch hook claude-code" }] }]
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
The hook only peeks. The agent's own `wait` or `inbox` moves its cursor.
|
|
174
|
+
|
|
175
|
+
### Other runtimes
|
|
176
|
+
|
|
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.
|
|
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
|
+
|
|
205
|
+
## Observed agents
|
|
206
|
+
|
|
207
|
+
parawatch reads agent logs. It never writes to them and never sends them anywhere.
|
|
208
|
+
|
|
209
|
+
| Runtime | Where it reads | What it shows |
|
|
210
|
+
|--|--|--|
|
|
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 |
|
|
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 |
|
|
213
|
+
|
|
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`).
|
|
217
|
+
|
|
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.
|
|
219
|
+
|
|
220
|
+
### Adding a runtime
|
|
221
|
+
|
|
222
|
+
An adapter is one file in `src/adapters/` that implements:
|
|
223
|
+
|
|
224
|
+
```ts
|
|
225
|
+
interface Adapter {
|
|
226
|
+
name: string;
|
|
227
|
+
scan(opts: { sinceMs: number; now: number }): Run[];
|
|
228
|
+
}
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
It reads only, reads only files touched since `sinceMs`, and reads only the head and tail of large logs.
|
|
232
|
+
|
|
233
|
+
## Apps, plugins and dashboards
|
|
234
|
+
|
|
235
|
+
| | |
|
|
236
|
+
|--|--|
|
|
237
|
+
| [`apps/desktop`](apps/desktop/README.md) | The desktop app. Reads and writes the store directly, so it works without the CLI on `PATH`. |
|
|
238
|
+
| [`packages/panel`](packages/panel) | The panel both of them show, as plain DOM, with a harness page for working on it. |
|
|
239
|
+
|
|
240
|
+
Anything else can build on the same two commands:
|
|
241
|
+
|
|
242
|
+
```
|
|
243
|
+
parawatch status --json # one snapshot: projects, cards, runs, pull requests, running waits
|
|
244
|
+
parawatch watch # every new event as one JSON line
|
|
245
|
+
parawatch watch --from 120 # starting after event 120
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
## Storage
|
|
249
|
+
|
|
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.
|
|
251
|
+
|
|
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.
|
|
253
|
+
|
|
254
|
+
## Not goals
|
|
255
|
+
|
|
256
|
+
- Starting or steering agents. Use whatever you already use; parawatch only watches and carries answers back.
|
|
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.
|
|
258
|
+
- Replacing your issue tracker. Cards are short-lived questions, not the backlog.
|
|
259
|
+
|
|
260
|
+
## License
|
|
261
|
+
|
|
262
|
+
MIT
|
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { isObj, logActivity, oneLine, readHeadLines, readTailLines, str } from "./jsonl.js";
|
|
5
|
+
const HEAD_BYTES = 96 * 1024;
|
|
6
|
+
const TAIL_BYTES = 384 * 1024;
|
|
7
|
+
export function claudeProjectsDir() {
|
|
8
|
+
if (process.env.PARAWATCH_CLAUDE_DIR)
|
|
9
|
+
return process.env.PARAWATCH_CLAUDE_DIR;
|
|
10
|
+
const base = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), ".claude");
|
|
11
|
+
return path.join(base, "projects");
|
|
12
|
+
}
|
|
13
|
+
function blocks(rec) {
|
|
14
|
+
const msg = rec.message;
|
|
15
|
+
if (!isObj(msg))
|
|
16
|
+
return [];
|
|
17
|
+
const c = msg.content;
|
|
18
|
+
if (typeof c === "string")
|
|
19
|
+
return [{ type: "text", text: c }];
|
|
20
|
+
return Array.isArray(c) ? c.filter(isObj) : [];
|
|
21
|
+
}
|
|
22
|
+
function isConversation(rec) {
|
|
23
|
+
return (rec.type === "user" || rec.type === "assistant") && rec.isMeta !== true;
|
|
24
|
+
}
|
|
25
|
+
function toolSummary(b) {
|
|
26
|
+
const inp = b.input ?? {};
|
|
27
|
+
const pick = str(inp.description) || str(inp.command) || str(inp.file_path) || str(inp.pattern) || str(inp.prompt) || str(inp.url);
|
|
28
|
+
const short = pick && (inp.file_path === pick ? path.basename(pick) : pick);
|
|
29
|
+
return short ? `${b.name}: ${oneLine(short, 80)}` : String(b.name ?? "tool");
|
|
30
|
+
}
|
|
31
|
+
/** A prompt the human (or parent agent) typed, not a tool result or system notice. */
|
|
32
|
+
function promptText(rec) {
|
|
33
|
+
if (rec.type !== "user" || rec.isMeta === true)
|
|
34
|
+
return undefined;
|
|
35
|
+
const bs = blocks(rec);
|
|
36
|
+
if (bs.some((b) => b.type === "tool_result"))
|
|
37
|
+
return undefined;
|
|
38
|
+
const text = bs.filter((b) => b.type === "text").map((b) => b.text ?? "").join("\n").trim();
|
|
39
|
+
if (!text || text.startsWith("<"))
|
|
40
|
+
return undefined;
|
|
41
|
+
return text;
|
|
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
|
+
}
|
|
109
|
+
/** Work out what a Claude Code transcript is doing from its last records. */
|
|
110
|
+
export function readClaudeTail(recs, kind) {
|
|
111
|
+
const out = { state: "unknown" };
|
|
112
|
+
let lastConv;
|
|
113
|
+
let wrapUp;
|
|
114
|
+
for (const r of recs) {
|
|
115
|
+
if (!isObj(r))
|
|
116
|
+
continue;
|
|
117
|
+
const ts = str(r.timestamp);
|
|
118
|
+
if (ts && (!out.lastActivity || ts > out.lastActivity))
|
|
119
|
+
out.lastActivity = ts;
|
|
120
|
+
if (str(r.cwd))
|
|
121
|
+
out.cwd = str(r.cwd);
|
|
122
|
+
if (r.type === "custom-title" && str(r.customTitle))
|
|
123
|
+
out.title = str(r.customTitle);
|
|
124
|
+
if (r.type === "agent-name" && str(r.agentName) && !out.title)
|
|
125
|
+
out.title = str(r.agentName);
|
|
126
|
+
if (isConversation(r))
|
|
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
|
+
}
|
|
137
|
+
}
|
|
138
|
+
if (!lastConv)
|
|
139
|
+
return out;
|
|
140
|
+
const bs = blocks(lastConv);
|
|
141
|
+
if (lastConv.type === "assistant") {
|
|
142
|
+
const tool = [...bs].reverse().find((b) => b.type === "tool_use");
|
|
143
|
+
if (tool) {
|
|
144
|
+
out.state = "working";
|
|
145
|
+
out.action = toolSummary(tool);
|
|
146
|
+
const t = tool.input?.timeout;
|
|
147
|
+
if (typeof t === "number")
|
|
148
|
+
out.pendingTimeoutMs = t;
|
|
149
|
+
}
|
|
150
|
+
else if (bs.some((b) => b.type === "text" && b.text?.trim())) {
|
|
151
|
+
out.state = kind === "subagent" ? "done" : "idle";
|
|
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
|
+
}
|
|
168
|
+
}
|
|
169
|
+
else {
|
|
170
|
+
out.state = "working";
|
|
171
|
+
out.action = "thinking";
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
else {
|
|
175
|
+
out.state = "working";
|
|
176
|
+
const result = bs.find((b) => b.type === "tool_result");
|
|
177
|
+
out.action = result ? "thinking" : "reading a new message";
|
|
178
|
+
}
|
|
179
|
+
return out;
|
|
180
|
+
}
|
|
181
|
+
function firstPrompt(file) {
|
|
182
|
+
const out = {};
|
|
183
|
+
for (const r of readHeadLines(file, HEAD_BYTES)) {
|
|
184
|
+
if (!isObj(r))
|
|
185
|
+
continue;
|
|
186
|
+
if (!out.startedAt && str(r.timestamp))
|
|
187
|
+
out.startedAt = str(r.timestamp);
|
|
188
|
+
if (!out.cwd && str(r.cwd))
|
|
189
|
+
out.cwd = str(r.cwd);
|
|
190
|
+
if (!out.prompt)
|
|
191
|
+
out.prompt = promptText(r);
|
|
192
|
+
if (out.prompt && out.cwd && out.startedAt)
|
|
193
|
+
break;
|
|
194
|
+
}
|
|
195
|
+
return out;
|
|
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
|
+
}
|
|
201
|
+
function readMeta(file) {
|
|
202
|
+
try {
|
|
203
|
+
const data = JSON.parse(fs.readFileSync(file.replace(/\.jsonl$/, ".meta.json"), "utf8"));
|
|
204
|
+
return isObj(data) ? data : {};
|
|
205
|
+
}
|
|
206
|
+
catch {
|
|
207
|
+
return {};
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
const STALL_MS = 20 * 60_000;
|
|
211
|
+
export function applyStall(run, pendingTimeoutMs, now) {
|
|
212
|
+
if (run.state !== "working" || !run.lastActivity)
|
|
213
|
+
return;
|
|
214
|
+
const quiet = now - Date.parse(run.lastActivity);
|
|
215
|
+
const allowed = Math.max(STALL_MS, (pendingTimeoutMs ?? 0) + 2 * 60_000);
|
|
216
|
+
if (quiet > allowed)
|
|
217
|
+
run.state = "stalled";
|
|
218
|
+
}
|
|
219
|
+
function listJsonl(dir) {
|
|
220
|
+
try {
|
|
221
|
+
return fs.readdirSync(dir).filter((f) => f.endsWith(".jsonl")).map((f) => path.join(dir, f));
|
|
222
|
+
}
|
|
223
|
+
catch {
|
|
224
|
+
return [];
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
function fresh(file, sinceMs) {
|
|
228
|
+
try {
|
|
229
|
+
return fs.statSync(file).mtimeMs >= sinceMs;
|
|
230
|
+
}
|
|
231
|
+
catch {
|
|
232
|
+
return false;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
export const claudeCode = {
|
|
236
|
+
name: "claude-code",
|
|
237
|
+
scan(opts) {
|
|
238
|
+
const root = claudeProjectsDir();
|
|
239
|
+
let dirs;
|
|
240
|
+
try {
|
|
241
|
+
dirs = fs.readdirSync(root).map((d) => path.join(root, d));
|
|
242
|
+
}
|
|
243
|
+
catch {
|
|
244
|
+
return [];
|
|
245
|
+
}
|
|
246
|
+
const runs = [];
|
|
247
|
+
for (const dir of dirs) {
|
|
248
|
+
for (const file of listJsonl(dir)) {
|
|
249
|
+
if (fresh(file, opts.sinceMs))
|
|
250
|
+
runs.push(readSession(file, opts.now));
|
|
251
|
+
}
|
|
252
|
+
let subdirs = [];
|
|
253
|
+
try {
|
|
254
|
+
subdirs = fs.readdirSync(dir).map((d) => path.join(dir, d, "subagents"));
|
|
255
|
+
}
|
|
256
|
+
catch {
|
|
257
|
+
// not a directory listing we can read
|
|
258
|
+
}
|
|
259
|
+
for (const sub of subdirs) {
|
|
260
|
+
for (const file of listJsonl(sub)) {
|
|
261
|
+
if (fresh(file, opts.sinceMs))
|
|
262
|
+
runs.push(readSubagent(file, opts.now));
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
return runs;
|
|
267
|
+
},
|
|
268
|
+
};
|
|
269
|
+
function readSession(file, now) {
|
|
270
|
+
const id = path.basename(file, ".jsonl");
|
|
271
|
+
const base = { id, runtime: "claude-code", kind: "session", title: id.slice(0, 8), state: "unknown", source: file };
|
|
272
|
+
try {
|
|
273
|
+
const head = firstPrompt(file);
|
|
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]);
|
|
277
|
+
const run = {
|
|
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,
|
|
284
|
+
title: tail.title || (head.prompt ? oneLine(head.prompt, 60) : base.title),
|
|
285
|
+
cwd: tail.cwd || head.cwd,
|
|
286
|
+
startedAt: head.startedAt,
|
|
287
|
+
lastActivity: tail.lastActivity,
|
|
288
|
+
state: tail.state,
|
|
289
|
+
action: tail.action,
|
|
290
|
+
prompt: head.prompt ? oneLine(head.prompt, 400) : undefined,
|
|
291
|
+
activity: logActivity(file, now),
|
|
292
|
+
};
|
|
293
|
+
applyStall(run, tail.pendingTimeoutMs, now);
|
|
294
|
+
return run;
|
|
295
|
+
}
|
|
296
|
+
catch (err) {
|
|
297
|
+
return { ...base, error: `could not read: ${err.message}` };
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
function readSubagent(file, now) {
|
|
301
|
+
const id = path.basename(file, ".jsonl").replace(/^agent-/, "");
|
|
302
|
+
const parent = path.basename(path.dirname(path.dirname(file)));
|
|
303
|
+
const base = { id, runtime: "claude-code", kind: "subagent", parent, title: id.slice(0, 8), state: "unknown", source: file };
|
|
304
|
+
try {
|
|
305
|
+
const meta = readMeta(file);
|
|
306
|
+
const head = firstPrompt(file);
|
|
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]);
|
|
310
|
+
const run = {
|
|
311
|
+
...base,
|
|
312
|
+
touched: work.touched.length ? work.touched : undefined,
|
|
313
|
+
createdPrs: work.createdPrs.length ? work.createdPrs : undefined,
|
|
314
|
+
title: str(meta.description) || (head.prompt ? oneLine(head.prompt, 60) : base.title),
|
|
315
|
+
model: str(meta.model),
|
|
316
|
+
cwd: tail.cwd || head.cwd,
|
|
317
|
+
startedAt: head.startedAt,
|
|
318
|
+
lastActivity: tail.lastActivity,
|
|
319
|
+
state: tail.state,
|
|
320
|
+
action: tail.action,
|
|
321
|
+
prompt: head.prompt ? oneLine(head.prompt, 400) : undefined,
|
|
322
|
+
activity: logActivity(file, now),
|
|
323
|
+
};
|
|
324
|
+
applyStall(run, tail.pendingTimeoutMs, now);
|
|
325
|
+
return run;
|
|
326
|
+
}
|
|
327
|
+
catch (err) {
|
|
328
|
+
return { ...base, error: `could not read: ${err.message}` };
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
//# sourceMappingURL=claudeCode.js.map
|