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 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
- # Temporary Holding Version
1
+ # parawatch
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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